Utvider SpacetimeDB-modulen med metadata, edited_at og MessageRevision-tabell. Worker skriver via reducers (set_ai_processing, ai_update_message) i stedet for direkte PG-skriving. Frontend-adapteren leser alt fra SpacetimeDB — null fetch()-kall. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
341 lines
8.7 KiB
TypeScript
341 lines
8.7 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 {
|
|
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<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 {
|
|
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
|
|
};
|
|
}
|