diff --git a/web/src/lib/blocks/ChatBlock.svelte b/web/src/lib/blocks/ChatBlock.svelte index a110c86..b57d1e0 100644 --- a/web/src/lib/blocks/ChatBlock.svelte +++ b/web/src/lib/blocks/ChatBlock.svelte @@ -17,7 +17,25 @@ let messagesEl: HTMLDivElement | undefined; const chatCallbacks = { - onMentionClick: (entityId: string) => goto(`/entities/${entityId}`) + onMentionClick: (entityId: string) => goto(`/entities/${entityId}`), + onReaction: async (messageId: string, reaction: string) => { + const msg = chat?.messages.find(m => m.id === messageId); + const existing = msg?.reactions?.find(r => r.reaction === reaction); + await fetch(`/api/messages/${messageId}/reactions`, { + method: existing?.user_reacted ? 'DELETE' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reaction }) + }); + await chat?.refresh(); + }, + onTogglePin: async (messageId: string, pinned: boolean) => { + await fetch(`/api/messages/${messageId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pinned }) + }); + await chat?.refresh(); + } }; async function handleSubmit(html: string, json: Record, mentions: Array<{ id: string; name: string; type: string; aliases: string[] }>) { diff --git a/web/src/lib/chat/pg.svelte.ts b/web/src/lib/chat/pg.svelte.ts index fe5aec9..1c7e305 100644 --- a/web/src/lib/chat/pg.svelte.ts +++ b/web/src/lib/chat/pg.svelte.ts @@ -27,6 +27,7 @@ export function createPgChat(channelId: string): ChatConnection { visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace', created_at: raw.created_at as string, updated_at: (raw.updated_at as string) ?? (raw.created_at as string), + reactions: (raw.reactions as MessageData['reactions']) ?? [], kanban_view: null, calendar_view: null }; @@ -76,6 +77,7 @@ export function createPgChat(channelId: string): ChatConnection { get error() { return error; }, get connected() { return connected; }, send, + refresh, destroy }; } diff --git a/web/src/lib/chat/spacetime.svelte.ts b/web/src/lib/chat/spacetime.svelte.ts index 120fa68..bad0e7a 100644 --- a/web/src/lib/chat/spacetime.svelte.ts +++ b/web/src/lib/chat/spacetime.svelte.ts @@ -36,6 +36,7 @@ export function createSpacetimeChat( visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace', created_at: raw.created_at as string, updated_at: (raw.updated_at as string) ?? (raw.created_at as string), + reactions: (raw.reactions as MessageData['reactions']) ?? [], kanban_view: null, calendar_view: null }; @@ -188,6 +189,7 @@ export function createSpacetimeChat( get error() { return error; }, get connected() { return connected; }, send, + refresh: loadFromPg, destroy }; } diff --git a/web/src/lib/chat/types.ts b/web/src/lib/chat/types.ts index a1730d2..1e0c65a 100644 --- a/web/src/lib/chat/types.ts +++ b/web/src/lib/chat/types.ts @@ -27,5 +27,6 @@ export interface ChatConnection { readonly error: string; readonly connected: boolean; send(body: string, mentions?: MentionRef[]): Promise; + refresh(): Promise; destroy(): void; } diff --git a/web/src/lib/components/MessageBox.svelte b/web/src/lib/components/MessageBox.svelte index 2886c91..a615219 100644 --- a/web/src/lib/components/MessageBox.svelte +++ b/web/src/lib/components/MessageBox.svelte @@ -44,11 +44,27 @@ callbacks.onClick?.(message.id); } + const QUICK_REACTIONS = ['👍', '❤️', '🔥', '👀', '💯']; + let hasBadges = $derived( (mode === 'expanded' && (message.kanban_view || message.calendar_view)) ); + let hasReactions = $derived( + mode === 'expanded' && message.reactions && message.reactions.length > 0 + ); + let bodyPreview = $derived(truncate(stripHtml(message.body), 80)); + + function handleReaction(e: MouseEvent, reaction: string) { + e.stopPropagation(); + callbacks.onReaction?.(message.id, reaction); + } + + function handleTogglePin(e: MouseEvent) { + e.stopPropagation(); + callbacks.onTogglePin?.(message.id, !message.pinned); + } {#if mode === 'expanded'} @@ -63,12 +79,40 @@ {#if showTimestamp} {formatTime(message.created_at)} {/if} - {#if message.pinned} - 📌 + {#if callbacks.onTogglePin} + + {:else if message.pinned} + 📌 {/if} {/if}
{@html message.body}
+ {#if hasReactions || callbacks.onReaction} +
+ {#each message.reactions ?? [] as r} + + {/each} + {#if callbacks.onReaction} + + {#each QUICK_REACTIONS as emoji} + + {/each} + + {/if} +
+ {/if} {#if hasBadges}
{#if message.kanban_view} @@ -141,6 +185,83 @@ margin-left: auto; } + .messagebox__pin-toggle { + font-size: 0.65rem; + margin-left: auto; + background: none; + border: none; + cursor: pointer; + opacity: 0; + padding: 0; + transition: opacity 0.15s; + } + + .messagebox__pin-toggle--active { + opacity: 1; + } + + .messagebox--expanded:hover .messagebox__pin-toggle { + opacity: 0.5; + } + + .messagebox--expanded:hover .messagebox__pin-toggle:hover { + opacity: 1; + } + + /* === Reactions === */ + .messagebox__reactions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + margin-top: 0.2rem; + } + + .messagebox__reaction-pill { + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 10px; + border: 1px solid #2d3148; + background: rgba(255, 255, 255, 0.04); + color: #e1e4e8; + cursor: pointer; + transition: background 0.15s; + } + + .messagebox__reaction-pill:hover { + background: rgba(255, 255, 255, 0.1); + } + + .messagebox__reaction-pill--active { + border-color: #7dd3fc; + background: rgba(125, 211, 252, 0.12); + } + + .messagebox__reaction-add { + display: none; + gap: 0.1rem; + } + + .messagebox--expanded:hover .messagebox__reaction-add { + display: flex; + } + + .messagebox__reaction-quick { + font-size: 0.65rem; + background: none; + border: none; + cursor: pointer; + padding: 0.05rem 0.15rem; + border-radius: 4px; + opacity: 0.4; + transition: opacity 0.15s; + } + + .messagebox__reaction-quick:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.08); + } + .messagebox__body { font-size: 0.85rem; color: #e1e4e8; diff --git a/web/src/lib/types/message.ts b/web/src/lib/types/message.ts index 9f8c221..bfb3f09 100644 --- a/web/src/lib/types/message.ts +++ b/web/src/lib/types/message.ts @@ -33,4 +33,6 @@ export type MessageBoxMode = 'compact' | 'calendar' | 'expanded'; export interface MessageBoxCallbacks { onClick?: (id: string) => void; onMentionClick?: (entityId: string) => void; + onReaction?: (messageId: string, reaction: string) => void; + onTogglePin?: (messageId: string, pinned: boolean) => void; } diff --git a/web/src/routes/api/channels/[id]/messages/+server.ts b/web/src/routes/api/channels/[id]/messages/+server.ts index d539172..b613e2a 100644 --- a/web/src/routes/api/channels/[id]/messages/+server.ts +++ b/web/src/routes/api/channels/[id]/messages/+server.ts @@ -17,21 +17,57 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { `; if (!channel) error(404, 'Kanal ikke funnet'); + const currentUserId = locals.user.id; + const messages = after ? await sql` - SELECT m.id, m.body, m.message_type, m.created_at, m.reply_to, - u.display_name as author_name, u.authentik_id as author_id + SELECT m.id, m.channel_id, m.body, m.message_type, m.title, + m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to, + u.display_name as author_name, u.authentik_id as author_id, + COALESCE(r.reactions, '[]'::jsonb) as reactions FROM messages m LEFT JOIN users u ON u.authentik_id = m.author_id + LEFT JOIN LATERAL ( + SELECT jsonb_agg(jsonb_build_object( + 'reaction', sub.reaction, + 'count', sub.cnt, + 'user_reacted', sub.user_reacted + )) as reactions + FROM ( + SELECT mr.reaction, + count(*)::int as cnt, + bool_or(mr.user_id = ${currentUserId}) as user_reacted + FROM message_reactions mr + WHERE mr.message_id = m.id + GROUP BY mr.reaction + ) sub + ) r ON true WHERE m.channel_id = ${channelId} AND m.created_at > ${after} ORDER BY m.created_at ASC LIMIT ${limit} ` : await sql` - SELECT m.id, m.body, m.message_type, m.created_at, m.reply_to, - u.display_name as author_name, u.authentik_id as author_id + SELECT m.id, m.channel_id, m.body, m.message_type, m.title, + m.pinned, m.visibility, m.created_at, m.updated_at, m.reply_to, + u.display_name as author_name, u.authentik_id as author_id, + COALESCE(r.reactions, '[]'::jsonb) as reactions FROM messages m LEFT JOIN users u ON u.authentik_id = m.author_id + LEFT JOIN LATERAL ( + SELECT jsonb_agg(jsonb_build_object( + 'reaction', sub.reaction, + 'count', sub.cnt, + 'user_reacted', sub.user_reacted + )) as reactions + FROM ( + SELECT mr.reaction, + count(*)::int as cnt, + bool_or(mr.user_id = ${currentUserId}) as user_reacted + FROM message_reactions mr + WHERE mr.message_id = m.id + GROUP BY mr.reaction + ) sub + ) r ON true WHERE m.channel_id = ${channelId} ORDER BY m.created_at DESC LIMIT ${limit} diff --git a/web/src/routes/api/messages/[messageId]/+server.ts b/web/src/routes/api/messages/[messageId]/+server.ts new file mode 100644 index 0000000..663d57b --- /dev/null +++ b/web/src/routes/api/messages/[messageId]/+server.ts @@ -0,0 +1,94 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** Trekk ut entity-UUIDs fra Tiptap HTML mentions (data-id attributter) */ +function extractMentionIds(html: string): string[] { + const ids: string[] = []; + const regex = /data-id="([0-9a-f-]{36})"/g; + let match; + while ((match = regex.exec(html)) !== null) { + if (!ids.includes(match[1])) ids.push(match[1]); + } + return ids; +} + +/** PATCH /api/messages/:messageId — Oppdater melding (pin, visibility, title, body) */ +export const PATCH: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + const workspace = locals.workspace; + const user = locals.user; + const messageId = params.messageId; + + const updates = await request.json(); + + // Verifiser at meldingen tilhører workspace + const [msg] = await sql` + SELECT m.id FROM messages m + JOIN nodes n ON n.id = m.id + WHERE m.id = ${messageId} AND n.workspace_id = ${workspace.id} + `; + if (!msg) error(404, 'Melding ikke funnet'); + + const bodyChanged = updates.body !== undefined; + + const [updated] = await sql` + UPDATE messages SET + pinned = COALESCE(${updates.pinned ?? null}::boolean, pinned), + visibility = COALESCE(${updates.visibility ?? null}::text, visibility), + title = COALESCE(${updates.title ?? null}::text, title), + body = CASE WHEN ${bodyChanged} THEN ${updates.body ?? ''} ELSE body END, + edited_at = CASE WHEN ${bodyChanged} THEN now() ELSE edited_at END + WHERE id = ${messageId} + RETURNING id, channel_id, body, message_type, title, pinned, visibility, + created_at, updated_at, reply_to + `; + + // Synkroniser MENTIONS-edges ved body-endring + if (bodyChanged) { + const mentionIds = extractMentionIds(updates.body ?? ''); + + let validIds: Set = new Set(); + if (mentionIds.length > 0) { + const validEntities = await sql` + SELECT id FROM nodes + WHERE id = ANY(${mentionIds}) AND workspace_id = ${workspace.id} + `; + validIds = new Set(validEntities.map((e) => (e as { id: string }).id)); + } + + await sql` + DELETE FROM graph_edges + WHERE source_id = ${messageId} AND relation_type = 'MENTIONS' + `; + + for (const entityId of mentionIds) { + if (!validIds.has(entityId)) continue; + await sql` + INSERT INTO graph_edges (workspace_id, source_id, target_id, relation_type, created_by, origin) + VALUES (${workspace.id}, ${messageId}, ${entityId}, 'MENTIONS', ${user.id}, 'user') + ON CONFLICT (source_id, target_id, relation_type) DO NOTHING + `; + } + } + + return json(updated); +}; + +/** DELETE /api/messages/:messageId — Slett melding */ +export const DELETE: RequestHandler = async ({ params, locals }) => { + if (!locals.workspace || !locals.user) error(401); + const messageId = params.messageId; + + // Verifiser at meldingen tilhører workspace + const [msg] = await sql` + SELECT n.id FROM nodes n + WHERE n.id = ${messageId} AND n.workspace_id = ${locals.workspace.id} + `; + if (!msg) error(404, 'Melding ikke funnet'); + + // Slett noden — kaskaderer til messages, reactions, edges + await sql`DELETE FROM nodes WHERE id = ${messageId}`; + + return new Response(null, { status: 204 }); +}; diff --git a/web/src/routes/api/messages/[messageId]/reactions/+server.ts b/web/src/routes/api/messages/[messageId]/reactions/+server.ts new file mode 100644 index 0000000..cef1cbe --- /dev/null +++ b/web/src/routes/api/messages/[messageId]/reactions/+server.ts @@ -0,0 +1,59 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { sql } from '$lib/server/db'; + +/** Hent aggregerte reaksjoner for en melding */ +async function getReactions(messageId: string, userId: string) { + return await sql` + SELECT reaction, count(*)::int as count, + bool_or(user_id = ${userId}) as user_reacted + FROM message_reactions + WHERE message_id = ${messageId} + GROUP BY reaction + `; +} + +/** POST /api/messages/:messageId/reactions — Legg til reaksjon */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + const messageId = params.messageId; + const userId = locals.user.id; + + const { reaction } = await request.json(); + if (!reaction || typeof reaction !== 'string') error(400, 'Reaksjon mangler'); + + // Verifiser at meldingen tilhører workspace + const [msg] = await sql` + SELECT m.id FROM messages m + JOIN nodes n ON n.id = m.id + WHERE m.id = ${messageId} AND n.workspace_id = ${locals.workspace.id} + `; + if (!msg) error(404, 'Melding ikke funnet'); + + await sql` + INSERT INTO message_reactions (message_id, user_id, reaction) + VALUES (${messageId}, ${userId}, ${reaction}) + ON CONFLICT DO NOTHING + `; + + const reactions = await getReactions(messageId, userId); + return json(reactions, { status: 201 }); +}; + +/** DELETE /api/messages/:messageId/reactions — Fjern reaksjon */ +export const DELETE: RequestHandler = async ({ params, request, locals }) => { + if (!locals.workspace || !locals.user) error(401); + const messageId = params.messageId; + const userId = locals.user.id; + + const { reaction } = await request.json(); + if (!reaction || typeof reaction !== 'string') error(400, 'Reaksjon mangler'); + + await sql` + DELETE FROM message_reactions + WHERE message_id = ${messageId} AND user_id = ${userId} AND reaction = ${reaction} + `; + + const reactions = await getReactions(messageId, userId); + return json(reactions); +};