server/web/src/lib/chat/spacetime.svelte.ts
vegard 4001c05648 Fiks: bruk SDK sin toISOString()/toDate() for timestamps i stedet for microsSinceEpoch
microsSinceEpoch var feil propertynavn — SDK bruker __timestamp_micros_since_unix_epoch__.
Bruker nå SDK-metodene direkte som er framtidssikre.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 18:21:53 +01:00

338 lines
8.6 KiB
TypeScript

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<MessageData[]>([]);
let error = $state('');
let connected = $state(false);
let conn: InstanceType<typeof DbConnection> | null = null;
let destroyed = false;
// Lokal reaksjonsstate (SpacetimeDB har message_reaction-tabellen)
let reactionMap = $state<Map<string, ReactionSummary[]>>(new Map());
function spacetimeRowToMessage(row: any): MessageData {
let createdAt: string;
try {
createdAt = row.createdAt?.toISOString?.() ?? row.createdAt?.toDate?.()?.toISOString() ?? new Date().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<string, ReactionSummary[]>();
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 {
return ts?.toDate?.()?.getTime() ?? 0;
} 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
};
}