import type { MessageData, ReactionSummary } from '$lib/types/message'; import type { ChatConnection, ChatUser, MentionRef } from './types'; import { DbConnection, type EventContext } from './module_bindings'; /** * SpacetimeDB-only chat-adapter. * All data (historikk + sanntid) kommer fra SpacetimeDB. * Worker håndterer warmup (PG → ST) og sync (ST → PG). * Frontend snakker KUN med SpacetimeDB. */ export function createSpacetimeChat( channelId: string, spacetimeUrl: string, moduleName: string, user: ChatUser, workspaceId: string ): ChatConnection { let messages = $state([]); let error = $state(''); let connected = $state(false); let conn: InstanceType | null = null; let destroyed = false; // Lokal reaksjonsstate (SpacetimeDB har message_reaction-tabellen) let reactionMap = $state>(new Map()); function spacetimeRowToMessage(row: any): MessageData { let createdAt: string; try { const micros = row.createdAt?.microsSinceEpoch; const ms = typeof micros === 'bigint' ? Number(micros / 1000n) : Number(micros) / 1000; createdAt = new Date(ms).toISOString(); } catch { createdAt = new Date().toISOString(); } // Parse metadata fra JSON-streng let metadata: MessageData['metadata'] = null; if (row.metadata) { try { metadata = JSON.parse(row.metadata); } catch { /* ugyldig JSON — ignorer */ } } return { id: row.id, channel_id: channelId, reply_to: row.replyTo || null, author_id: row.authorId || null, author_name: row.authorName || null, message_type: row.messageType ?? 'chat', title: null, body: row.body, pinned: false, visibility: 'workspace', created_at: createdAt, updated_at: createdAt, reactions: reactionMap.get(row.id) ?? [], kanban_view: null, calendar_view: null, metadata, edited_at: row.editedAt || null }; } function rebuildReactions() { // Bygg reaksjonsaggregat fra message_reaction-tabellen if (!conn) return; const newMap = new Map(); for (const r of conn.db.message_reaction.iter()) { const msgId = r.messageId; if (!newMap.has(msgId)) newMap.set(msgId, []); const summaries = newMap.get(msgId)!; const existing = summaries.find(s => s.reaction === r.reaction); if (existing) { existing.count++; if (r.userId === user.id) existing.user_reacted = true; } else { summaries.push({ reaction: r.reaction, count: 1, user_reacted: r.userId === user.id }); } } reactionMap = newMap; } function rebuildMessages() { if (!conn) return; const rows: any[] = []; for (const row of conn.db.chat_message.iter()) { if (row.channelId === channelId) { rows.push(row); } } // Sorter kronologisk rows.sort((a, b) => { const aMs = extractMs(a.createdAt); const bMs = extractMs(b.createdAt); return aMs - bMs; }); // Bevar felter som ikke finnes i SpacetimeDB (reply_count, parent_body, etc.) const existing = new Map(messages.map(m => [m.id, m])); messages = rows.map(r => { const msg = spacetimeRowToMessage(r); const prev = existing.get(msg.id); if (prev) { msg.reply_count = prev.reply_count; msg.parent_body = prev.parent_body; msg.parent_author_name = prev.parent_author_name; } return msg; }); } function extractMs(ts: any): number { try { const micros = ts?.microsSinceEpoch; return typeof micros === 'bigint' ? Number(micros / 1000n) : Number(micros) / 1000; } catch { return 0; } } function connectRealtime() { try { conn = DbConnection.builder() .withUri(spacetimeUrl) .withDatabaseName(moduleName) .onConnect((connection) => { if (destroyed) return; connected = true; error = ''; try { sessionStorage.setItem('spacetime_token', ''); } catch { /* SSR-safe */ } // Abonner på meldinger + reaksjoner for denne kanalen connection.subscriptionBuilder() .onApplied(() => { // Initialload — bygg state fra subscription rebuildReactions(); rebuildMessages(); }) .onError(() => { console.error('[spacetime] subscription error'); }) .subscribe([ `SELECT * FROM chat_message WHERE channel_id = '${channelId}'`, `SELECT * FROM message_reaction`, `SELECT * FROM message_revision` ]); }) .onDisconnect(() => { connected = false; }) .onConnectError((_ctx, err) => { console.warn('[spacetime] connection error:', err); error = 'Tilkobling til sanntidstjeneste feilet'; }) .withToken(getStoredToken() ?? '') .build(); // Nye meldinger i sanntid conn.db.chat_message.onInsert((_ctx: EventContext, row) => { if (destroyed || row.channelId !== channelId) return; if (messages.some(m => m.id === row.id)) return; const msg = spacetimeRowToMessage(row); messages = [...messages, msg]; }); // Meldinger oppdatert (edit) conn.db.chat_message.onUpdate((_ctx: EventContext, _oldRow, newRow) => { if (destroyed || newRow.channelId !== channelId) return; let metadata: MessageData['metadata'] = null; if (newRow.metadata) { try { metadata = JSON.parse(newRow.metadata); } catch { /* ignorer */ } } messages = messages.map(m => m.id === newRow.id ? { ...m, body: newRow.body, edited_at: newRow.editedAt || m.edited_at, metadata } : m ); }); // Meldinger slettet conn.db.chat_message.onDelete((_ctx: EventContext, row) => { if (destroyed || row.channelId !== channelId) return; messages = messages.filter(m => m.id !== row.id); }); // Reaksjoner — rebuild ved endring conn.db.message_reaction.onInsert((_ctx: EventContext, _row) => { if (destroyed) return; rebuildReactions(); // Oppdater reactions i messages messages = messages.map(m => ({ ...m, reactions: reactionMap.get(m.id) ?? [] })); }); conn.db.message_reaction.onDelete((_ctx: EventContext, _row) => { if (destroyed) return; rebuildReactions(); messages = messages.map(m => ({ ...m, reactions: reactionMap.get(m.id) ?? [] })); }); } catch (e) { console.warn('[spacetime] setup feilet:', e); error = 'Kunne ikke koble til sanntidstjeneste'; } } async function send(body: string, _mentions?: MentionRef[], replyTo?: string) { if (!conn || !connected) { error = 'Ikke tilkoblet'; return; } try { const id = crypto.randomUUID(); conn.reducers.sendMessage({ id, channelId, workspaceId, authorId: user.id, authorName: user.name, body, replyTo: replyTo ?? '' }); } catch { error = 'Kunne ikke sende melding'; } } async function edit(messageId: string, newBody: string) { if (!conn || !connected) return; try { conn.reducers.editMessage({ id: messageId, workspaceId, newBody }); } catch { error = 'Kunne ikke redigere melding'; } } async function del(messageId: string) { if (!conn || !connected) return; try { conn.reducers.deleteMessage({ id: messageId, workspaceId }); } catch { error = 'Kunne ikke slette melding'; } } async function react(messageId: string, reaction: string) { if (!conn || !connected) return; try { // Sjekk om bruker allerede har reagert med denne const existing = reactionMap.get(messageId)?.find( r => r.reaction === reaction && r.user_reacted ); if (existing) { conn.reducers.removeReaction({ messageId, workspaceId, userId: user.id, reaction }); } else { conn.reducers.addReaction({ messageId, workspaceId, userId: user.id, userName: user.name, reaction }); } } catch { error = 'Kunne ikke legge til reaksjon'; } } function getRevisions(messageId: string): { id: string; body: string; edited_at: string }[] { if (!conn) return []; const revs: { id: string; body: string; edited_at: string }[] = []; for (const r of conn.db.message_revision.iter()) { if (r.messageId === messageId) { revs.push({ id: String(r.id), body: r.body, edited_at: r.editedAt }); } } // Sorter nyeste først revs.sort((a, b) => b.edited_at.localeCompare(a.edited_at)); return revs; } function destroy() { destroyed = true; if (conn) { conn.disconnect(); conn = null; } } function getStoredToken(): string | undefined { try { return sessionStorage.getItem('spacetime_token') ?? undefined; } catch { return undefined; } } // Start tilkobling connectRealtime(); return { get messages() { return messages; }, get error() { return error; }, get connected() { return connected; }, get readonly() { return false; }, send, edit, delete: del, react, refresh: async () => { rebuildMessages(); }, destroy, getRevisions }; }