Generell revisjonshistorikk + markdown-rendering i meldinger

- Markdown rendres automatisk for all ikke-HTML body (detekterer <p>, <div> osv.)
- «redigert»-indikator med bla-navigasjon (← →) for alle redigerte meldinger
- Henter revisjoner fra PG via /api/messages/:id/revisions ved behov
- SpacetimeDB onUpdate setter edited_at slik at frontend vet meldingen er redigert
- AI-badge ( action) vises fortsatt for AI-behandlede meldinger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-16 03:33:04 +01:00
parent d5882c8c45
commit aeda5e7527
3 changed files with 137 additions and 60 deletions

View file

@ -128,7 +128,7 @@ export function createSpacetimeChat(
})
.subscribe([
`SELECT * FROM chat_message WHERE channel_id = '${channelId}'`,
`SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE cm.channel_id = '${channelId}'`
`SELECT * FROM message_reaction`
]);
})
.onDisconnect(() => {
@ -153,7 +153,7 @@ export function createSpacetimeChat(
conn.db.chat_message.onUpdate((_ctx: EventContext, _oldRow, newRow) => {
if (destroyed || newRow.channelId !== channelId) return;
messages = messages.map(m =>
m.id === newRow.id ? { ...m, body: newRow.body } : m
m.id === newRow.id ? { ...m, body: newRow.body, edited_at: new Date().toISOString() } : m
);
});

View file

@ -148,46 +148,84 @@
}
let isAiProcessing = $derived(message.metadata?.ai_processing === true);
let isAiProcessed = $derived(message.metadata?.ai_processed === true);
// Revisjons-toggle for AI-behandlede meldinger
let showingOriginal = $state(false);
let originalBody = $state<string | null>(null);
let loadingRevision = $state(false);
// --- Revisjonshistorikk (generell — gjelder alle redigerte meldinger) ---
async function toggleRevision() {
if (showingOriginal) {
showingOriginal = false;
return;
}
if (originalBody === null) {
loadingRevision = true;
try {
const res = await fetch(`/api/messages/${message.id}/revisions`);
if (res.ok) {
const data = await res.json();
originalBody = data.revisions?.[0]?.body ?? null;
}
} finally {
loadingRevision = false;
}
}
showingOriginal = true;
interface Revision {
id: string;
body: string;
created_at: string;
}
// Markdown → HTML for AI-behandlet innhold
function renderBody(body: string, aiProcessed: boolean): string {
if (!aiProcessed) return body;
// AI returnerer markdown — konverter til HTML
const isHtml = /<[a-z][\s\S]*>/i.test(body);
let revisions = $state<Revision[]>([]);
let revisionIndex = $state(-1); // -1 = nåværende versjon
let loadingRevisions = $state(false);
let revisionsLoaded = $state(false);
async function loadRevisions() {
if (revisionsLoaded) return;
loadingRevisions = true;
try {
const res = await fetch(`/api/messages/${message.id}/revisions`);
if (res.ok) {
const data = await res.json();
revisions = data.revisions ?? [];
}
} finally {
loadingRevisions = false;
revisionsLoaded = true;
}
}
async function toggleRevisions(e: MouseEvent) {
e.stopPropagation();
if (revisionIndex >= 0) {
// Tilbake til nåværende
revisionIndex = -1;
return;
}
await loadRevisions();
if (revisions.length > 0) {
revisionIndex = 0; // Vis nyeste revisjon (original)
}
}
function prevRevision(e: MouseEvent) {
e.stopPropagation();
if (revisionIndex < revisions.length - 1) revisionIndex++;
}
function nextRevision(e: MouseEvent) {
e.stopPropagation();
if (revisionIndex > 0) revisionIndex--;
else revisionIndex = -1; // Tilbake til nåværende
}
let showingRevision = $derived(revisionIndex >= 0);
let totalVersions = $derived(revisions.length + 1); // revisjoner + nåværende
let currentVersionNumber = $derived(
revisionIndex < 0
? totalVersions
: totalVersions - revisionIndex - 1
);
// --- Body-rendering med markdown-deteksjon ---
function renderBody(body: string): string {
// Sjekk om body allerede er HTML (fra Tiptap-editor)
const isHtml = /<(?:p|div|br|span|h[1-6]|ul|ol|li|a|strong|em|code)\b/i.test(body);
if (isHtml) return body;
// Ellers: behandle som markdown
return marked.parse(body, { async: false }) as string;
}
let displayBody = $derived(
showingOriginal && originalBody !== null
? originalBody
: renderBody(message.body, isAiProcessed)
let activeBody = $derived(
showingRevision ? revisions[revisionIndex]?.body ?? message.body : message.body
);
let displayBody = $derived(renderBody(activeBody));
let isEdited = $derived(
!!(message.edited_at || message.metadata?.ai_processed)
);
</script>
@ -274,18 +312,29 @@
{#if isAiProcessing}
<div class="messagebox__ai-status">✨ AI behandler…</div>
{/if}
{#if isAiProcessed && !editing}
<div class="messagebox__ai-footer">
<span class="messagebox__ai-badge">{message.metadata?.ai_action ?? 'AI'}</span>
<button class="messagebox__revision-btn" onclick={(e) => { e.stopPropagation(); toggleRevision(); }} disabled={loadingRevision}>
{#if loadingRevision}
Laster…
{:else if showingOriginal}
Vis AI-resultat
{#if isEdited && !editing}
<div class="messagebox__revision-footer">
<button class="messagebox__revision-toggle" onclick={toggleRevisions} disabled={loadingRevisions}>
{#if loadingRevisions}
{:else if showingRevision}
versjon {currentVersionNumber}/{totalVersions}
{:else}
Vis original
redigert
{/if}
</button>
{#if showingRevision}
<button class="messagebox__revision-nav" onclick={prevRevision} disabled={revisionIndex >= revisions.length - 1}>&larr;</button>
<button class="messagebox__revision-nav" onclick={nextRevision}>&rarr;</button>
{#if revisionIndex >= 0}
<button class="messagebox__revision-toggle" onclick={(e) => { e.stopPropagation(); revisionIndex = -1; }}>
nåværende
</button>
{/if}
{/if}
{#if message.metadata?.ai_action}
<span class="messagebox__ai-badge">{message.metadata.ai_action}</span>
{/if}
</div>
{/if}
{/if}
@ -564,23 +613,17 @@
text-decoration: underline;
}
/* === AI footer (badge + revision toggle) === */
.messagebox__ai-footer {
/* === Revisjonshistorikk === */
.messagebox__revision-footer {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.3rem;
gap: 0.4rem;
margin-top: 0.2rem;
}
.messagebox__ai-badge {
.messagebox__revision-toggle {
font-size: 0.65rem;
color: #facc15;
opacity: 0.7;
}
.messagebox__revision-btn {
font-size: 0.65rem;
color: #7dd3fc;
color: #8b92a5;
background: none;
border: none;
cursor: pointer;
@ -588,16 +631,42 @@
font-family: inherit;
}
.messagebox__revision-btn:hover {
color: #bae6fd;
text-decoration: underline;
.messagebox__revision-toggle:hover {
color: #7dd3fc;
}
.messagebox__revision-btn:disabled {
color: #8b92a5;
.messagebox__revision-toggle:disabled {
cursor: wait;
}
.messagebox__revision-nav {
font-size: 0.65rem;
color: #8b92a5;
background: none;
border: 1px solid #2d3148;
border-radius: 3px;
cursor: pointer;
padding: 0 0.3rem;
font-family: inherit;
line-height: 1.4;
}
.messagebox__revision-nav:hover:not(:disabled) {
color: #7dd3fc;
border-color: #7dd3fc;
}
.messagebox__revision-nav:disabled {
opacity: 0.3;
cursor: default;
}
.messagebox__ai-badge {
font-size: 0.65rem;
color: #facc15;
opacity: 0.7;
}
/* Tiptap HTML rendering */
.messagebox__body :global(p) {
margin: 0;

View file

@ -22,6 +22,13 @@ export interface MessageData {
all_day: boolean;
color: string | null;
} | null;
edited_at?: string | null;
revision_count?: number;
metadata?: {
ai_processing?: boolean;
ai_processed?: boolean;
ai_action?: string;
} | null;
}
export interface ReactionSummary {
@ -42,5 +49,6 @@ export interface MessageBoxCallbacks {
onReply?: (messageId: string) => void;
onConvertToKanban?: (messageId: string) => void;
onConvertToCalendar?: (messageId: string) => void;
onMagic?: (messageId: string) => void;
currentUserId?: string;
}