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
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>
|