You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

346 lines
8.6 KiB

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { tick } from 'svelte';
import { getNexoContext } from '../../_lib/context';
import { DatingApiError } from '../../_lib/api';
import type { DatingMatch, DatingMessage, DatingProfile } from '../../_lib/types';
const nexo = getNexoContext();
const matchId = $derived(page.params.matchId ?? '');
let match = $state<DatingMatch | null>(null);
let messages = $state<DatingMessage[]>([]);
let pending = $state<DatingMessage[]>([]);
let loading = $state(true);
let composer = $state('');
let sending = $state(false);
let topError = $state<string | null>(null);
let timeline: HTMLElement | null = null;
$effect(() => {
const session = nexo.session();
if (session === null) return;
if (!session.authenticated) {
void goto('/dating/login', { replaceState: true });
return;
}
if (matchId !== '') void load();
});
async function load() {
loading = true;
topError = null;
try {
const [allMatches, response] = await Promise.all([
nexo.api.matches(),
nexo.api.messages(matchId)
]);
match = allMatches.find((entry) => entry.id === matchId) ?? null;
messages = [...response.messages];
pending = [];
await tick();
scrollToEnd();
} catch (caught) {
if (caught instanceof DatingApiError && caught.code === 'match_not_found') {
await goto('/dating/matches', { replaceState: true });
return;
}
topError = caught instanceof Error ? caught.message : String(caught);
} finally {
loading = false;
}
}
function counterpart(): DatingProfile | null {
const session = nexo.session();
if (session?.authenticated !== true || match === null) return null;
return match.profiles.find((profile) => profile.userId !== session.user.id) ?? null;
}
const myUserId = $derived.by(() => {
const session = nexo.session();
return session?.authenticated === true ? session.user.id : '';
});
const counterpartName = $derived(counterpart()?.displayName || 'Match');
function scrollToEnd() {
if (timeline === null) return;
timeline.scrollTop = timeline.scrollHeight;
}
function newNonce(): string {
return `nx-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
async function send() {
const body = composer.trim();
if (body === '' || sending) return;
const nonce = newNonce();
const provisional: DatingMessage = {
id: nonce,
matchId,
senderId: myUserId,
body,
state: 'queued',
clientNonce: nonce,
deliveredAt: '',
metadata: {},
created: new Date().toISOString(),
updated: new Date().toISOString()
};
pending = [...pending, provisional];
composer = '';
sending = true;
await tick();
scrollToEnd();
try {
const sent = await nexo.api.sendMessage(matchId, { body, clientNonce: nonce });
messages = [...messages, sent];
pending = pending.filter((entry) => entry.clientNonce !== nonce);
await tick();
scrollToEnd();
} catch (caught) {
pending = pending.map((entry) =>
entry.clientNonce === nonce ? { ...entry, state: 'failed' } : entry
);
topError = caught instanceof DatingApiError ? caught.message : String(caught);
} finally {
sending = false;
}
}
function retry(message: DatingMessage) {
pending = pending.filter((entry) => entry.clientNonce !== message.clientNonce);
composer = message.body;
void send();
}
function dropFailed(message: DatingMessage) {
pending = pending.filter((entry) => entry.clientNonce !== message.clientNonce);
}
function timeOf(iso: string): string {
if (iso === '') return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const allMessages = $derived([...messages, ...pending]);
</script>
<svelte:head>
<title>{counterpartName} — Nexo</title>
</svelte:head>
<section class="chat">
<header class="chat-header">
<a href="/dating/matches" class="back" aria-label="Volver a matches">←</a>
<div class="who">
<strong>{counterpartName}</strong>
{#if match !== null}
<small>match {match.state}</small>
{/if}
</div>
</header>
{#if topError !== null}
<p class="banner error" role="alert">{topError}</p>
{/if}
<div class="timeline" bind:this={timeline}>
{#if loading}
<p class="empty pulse">Cargando conversación…</p>
{:else if allMessages.length === 0}
<p class="empty">
Aún no hay mensajes. Rompe el hielo: pregunta por algo que viste en su perfil.
</p>
{:else}
{#each allMessages as message (message.id)}
{@const mine = message.senderId === myUserId}
<article class="bubble" class:mine class:pending={message.state === 'queued'}
class:failed={message.state === 'failed'}>
<p>{message.body}</p>
<footer>
<time datetime={message.created}>{timeOf(message.created)}</time>
{#if message.state === 'queued'}
<span class="state">enviando…</span>
{:else if message.state === 'failed'}
<span class="state state-error">no enviado</span>
<button type="button" class="link" onclick={() => retry(message)}>reintentar</button>
<button type="button" class="link" onclick={() => dropFailed(message)}>descartar</button>
{/if}
</footer>
</article>
{/each}
{/if}
</div>
<form class="composer" onsubmit={(event) => { event.preventDefault(); void send(); }}>
<input
type="text"
placeholder="Escribe un mensaje…"
autocomplete="off"
bind:value={composer}
disabled={loading}
/>
<button type="submit" class="send" disabled={sending || composer.trim() === ''}>
Enviar
</button>
</form>
</section>
<style>
.chat {
max-width: 40rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: calc(100vh - 5rem);
}
.chat-header {
display: flex;
gap: 0.85rem;
align-items: center;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
}
.back {
font-size: 1.25rem;
text-decoration: none;
color: inherit;
padding: 0.2rem 0.45rem;
border-radius: 6px;
}
.back:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
.who {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.who small {
opacity: 0.6;
font-size: 0.78rem;
}
.timeline {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.5rem 0.25rem;
min-height: 12rem;
max-height: calc(100vh - 14rem);
}
.bubble {
max-width: 78%;
padding: 0.5rem 0.7rem;
border-radius: 12px;
background: color-mix(in srgb, currentColor 6%, transparent);
align-self: flex-start;
}
.bubble.mine {
align-self: flex-end;
background: color-mix(in srgb, #c44a78 18%, canvas);
}
.bubble.pending {
opacity: 0.65;
}
.bubble.failed {
border: 1px solid color-mix(in srgb, #d73a49 50%, transparent);
background: color-mix(in srgb, #d73a49 8%, canvas);
}
.bubble p {
margin: 0;
font-size: 0.95rem;
line-height: 1.4;
white-space: pre-wrap;
word-wrap: break-word;
}
.bubble footer {
display: flex;
gap: 0.5rem;
font-size: 0.7rem;
opacity: 0.75;
margin-top: 0.25rem;
align-items: center;
}
.state-error {
color: color-mix(in srgb, #d73a49 70%, currentColor);
font-weight: 600;
}
.link {
font: inherit;
font-size: 0.7rem;
background: none;
border: none;
color: inherit;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
.composer {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.25rem;
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: canvas;
}
.composer input {
font: inherit;
flex: 1;
padding: 0.6rem 0.8rem;
border-radius: 8px;
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: canvas;
color: inherit;
}
.composer input:focus-visible {
outline: 2px solid color-mix(in srgb, #0366d6 70%, transparent);
outline-offset: 1px;
border-color: transparent;
}
.composer .send {
font: inherit;
font-weight: 600;
padding: 0.55rem 1rem;
border-radius: 8px;
border: none;
background: color-mix(in srgb, currentColor 88%, transparent);
color: canvas;
cursor: pointer;
}
.composer .send:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.banner.error {
padding: 0.55rem 0.85rem;
border-radius: 8px;
background: color-mix(in srgb, #d73a49 12%, canvas);
border: 1px solid color-mix(in srgb, #d73a49 35%, transparent);
font-size: 0.9rem;
margin: 0;
}
.empty {
text-align: center;
opacity: 0.7;
padding: 2rem 1rem;
}
.empty.pulse {
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
50% {
opacity: 0.35;
}
}
</style>

Powered by TurnKey Linux.