MessageBox: API-utvidelser, reaksjoner og pin-toggle

- GET messages returnerer alle felter + aggregerte reaksjoner via LATERAL join
- Nytt PATCH/DELETE endepunkt for enkeltmeldinger (/api/messages/[id])
- Nye reaksjons-endepunkter (POST/DELETE /api/messages/[id]/reactions)
- refresh() eksponert på ChatConnection (PG + SpacetimeDB)
- MessageBox UI: reaksjonspills med toggle + hurtig-emojis ved hover
- Pin-toggle i header med hover-synlighet
- ChatBlock wirer onReaction og onTogglePin callbacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-15 22:42:41 +01:00
parent 568c385cd0
commit 5bc992272d
9 changed files with 342 additions and 7 deletions

View file

@ -17,7 +17,25 @@
let messagesEl: HTMLDivElement | undefined; let messagesEl: HTMLDivElement | undefined;
const chatCallbacks = { 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<string, unknown>, mentions: Array<{ id: string; name: string; type: string; aliases: string[] }>) { async function handleSubmit(html: string, json: Record<string, unknown>, mentions: Array<{ id: string; name: string; type: string; aliases: string[] }>) {

View file

@ -27,6 +27,7 @@ export function createPgChat(channelId: string): ChatConnection {
visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace', visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace',
created_at: raw.created_at as string, created_at: raw.created_at as string,
updated_at: (raw.updated_at as string) ?? (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, kanban_view: null,
calendar_view: null calendar_view: null
}; };
@ -76,6 +77,7 @@ export function createPgChat(channelId: string): ChatConnection {
get error() { return error; }, get error() { return error; },
get connected() { return connected; }, get connected() { return connected; },
send, send,
refresh,
destroy destroy
}; };
} }

View file

@ -36,6 +36,7 @@ export function createSpacetimeChat(
visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace', visibility: (raw.visibility as 'workspace' | 'private') ?? 'workspace',
created_at: raw.created_at as string, created_at: raw.created_at as string,
updated_at: (raw.updated_at as string) ?? (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, kanban_view: null,
calendar_view: null calendar_view: null
}; };
@ -188,6 +189,7 @@ export function createSpacetimeChat(
get error() { return error; }, get error() { return error; },
get connected() { return connected; }, get connected() { return connected; },
send, send,
refresh: loadFromPg,
destroy destroy
}; };
} }

View file

@ -27,5 +27,6 @@ export interface ChatConnection {
readonly error: string; readonly error: string;
readonly connected: boolean; readonly connected: boolean;
send(body: string, mentions?: MentionRef[]): Promise<void>; send(body: string, mentions?: MentionRef[]): Promise<void>;
refresh(): Promise<void>;
destroy(): void; destroy(): void;
} }

View file

@ -44,11 +44,27 @@
callbacks.onClick?.(message.id); callbacks.onClick?.(message.id);
} }
const QUICK_REACTIONS = ['👍', '❤️', '🔥', '👀', '💯'];
let hasBadges = $derived( let hasBadges = $derived(
(mode === 'expanded' && (message.kanban_view || message.calendar_view)) (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)); 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);
}
</script> </script>
{#if mode === 'expanded'} {#if mode === 'expanded'}
@ -63,12 +79,40 @@
{#if showTimestamp} {#if showTimestamp}
<span class="messagebox__time">{formatTime(message.created_at)}</span> <span class="messagebox__time">{formatTime(message.created_at)}</span>
{/if} {/if}
{#if message.pinned} {#if callbacks.onTogglePin}
<span class="messagebox__pinned" title="Festet">&#128204;</span> <button
class="messagebox__pin-toggle"
class:messagebox__pin-toggle--active={message.pinned}
title={message.pinned ? 'Løsne' : 'Fest'}
onclick={handleTogglePin}
>📌</button>
{:else if message.pinned}
<span class="messagebox__pinned" title="Festet">📌</span>
{/if} {/if}
</div> </div>
{/if} {/if}
<div class="messagebox__body">{@html message.body}</div> <div class="messagebox__body">{@html message.body}</div>
{#if hasReactions || callbacks.onReaction}
<div class="messagebox__reactions">
{#each message.reactions ?? [] as r}
<button
class="messagebox__reaction-pill"
class:messagebox__reaction-pill--active={r.user_reacted}
onclick={(e) => handleReaction(e, r.reaction)}
>{r.reaction} {r.count}</button>
{/each}
{#if callbacks.onReaction}
<span class="messagebox__reaction-add">
{#each QUICK_REACTIONS as emoji}
<button
class="messagebox__reaction-quick"
onclick={(e) => handleReaction(e, emoji)}
>{emoji}</button>
{/each}
</span>
{/if}
</div>
{/if}
{#if hasBadges} {#if hasBadges}
<div class="messagebox__badges"> <div class="messagebox__badges">
{#if message.kanban_view} {#if message.kanban_view}
@ -141,6 +185,83 @@
margin-left: auto; 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 { .messagebox__body {
font-size: 0.85rem; font-size: 0.85rem;
color: #e1e4e8; color: #e1e4e8;

View file

@ -33,4 +33,6 @@ export type MessageBoxMode = 'compact' | 'calendar' | 'expanded';
export interface MessageBoxCallbacks { export interface MessageBoxCallbacks {
onClick?: (id: string) => void; onClick?: (id: string) => void;
onMentionClick?: (entityId: string) => void; onMentionClick?: (entityId: string) => void;
onReaction?: (messageId: string, reaction: string) => void;
onTogglePin?: (messageId: string, pinned: boolean) => void;
} }

View file

@ -17,21 +17,57 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
`; `;
if (!channel) error(404, 'Kanal ikke funnet'); if (!channel) error(404, 'Kanal ikke funnet');
const currentUserId = locals.user.id;
const messages = after const messages = after
? await sql` ? await sql`
SELECT m.id, m.body, m.message_type, m.created_at, m.reply_to, SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
u.display_name as author_name, u.authentik_id as author_id 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 FROM messages m
LEFT JOIN users u ON u.authentik_id = m.author_id 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} WHERE m.channel_id = ${channelId} AND m.created_at > ${after}
ORDER BY m.created_at ASC ORDER BY m.created_at ASC
LIMIT ${limit} LIMIT ${limit}
` `
: await sql` : await sql`
SELECT m.id, m.body, m.message_type, m.created_at, m.reply_to, SELECT m.id, m.channel_id, m.body, m.message_type, m.title,
u.display_name as author_name, u.authentik_id as author_id 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 FROM messages m
LEFT JOIN users u ON u.authentik_id = m.author_id 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} WHERE m.channel_id = ${channelId}
ORDER BY m.created_at DESC ORDER BY m.created_at DESC
LIMIT ${limit} LIMIT ${limit}

View file

@ -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<string> = 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 });
};

View file

@ -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);
};