teecup/frontend/components/round-messages.tsx
Erol Haagenrud dd8a8c133d Offline kommentar-/bildeposting i rundefeeden (ADR-069)
Utvider den eksisterende offline-skrivekøen (ADR-028, hittil kun
hull-scoreføring) til også å dekke rundefeedens kommentarer/bilder.
offline-queue.ts fikk et isMultipart-flagg (Blob-verdier lagres nativt
i IndexedDB, flushQueue bygger FormData ved synk). round-messages.tsx
fikk sin egen isolerte kø (matchId scoped til "{roundId}:messages",
ikke bare roundId) og et optimistisk "Venter på synk"-kort.

Migrasjon 073: round_message.client_message_id (nullbar uuid) + delvis
unik indeks -- POST /messages er ulikt hull-score-PATCH IKKE naturlig
idempotent, så en avbrutt synk kunne duplisert en kommentar uten dette.
Klienten sender en selvgenerert id ved både første forsøk og et evt.
køet gjenforsøk; serveren deduplikerer på den.

Se ARCHITECTURE_DECISIONS.md (ADR-069) og CHANGELOG.md (punkt 85) for
full begrunnelse og verifiseringslogg (inkl. et reload-mens-offline-
scenario som beviser fravær av duplikater).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 11:51:30 +02:00

614 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Camera, Clock, ImagePlus, RefreshCw, Send, Trash2, WifiOff, X } from "lucide-react"
import { MentionTextarea, TaggedText } from "./mention-input"
import type { Tag } from "@/lib/mentions"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
import { cn } from "@/lib/utils"
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
/** Rå meldingsform fra backend. */
export type ApiRoundMessage = {
id: string
round_id: string
author_user_id: string
author_display_name: string
body: string | null
image_url: string | null
created_at: string // ISO 8601
reactions: ReactionSummary[]
comment_count: number
tags: Tag[]
}
// Et lokalt, ikke-synkronisert innlegg (offline-kø-utvidelse, 2026-08-14,
// se ADR-028-tillegget). `id` er en midlertidig klient-id (aldri sendt til
// serveren) -- brukes kun som React-key inntil `load()` erstatter hele
// listen med serverens fasit etter vellykket synk. `image_url` er da en
// lokal object-URL (blob-forhåndsvisning), ikke en ekte MinIO-URL.
type PendingRoundMessage = ApiRoundMessage & { _clientMessageId: string }
export type RoundMessagesProps = {
roundId: string
currentUserId: string
roundOwnerUserId: string
// Økes av round-detail.tsx sitt "/ws/rounds/{id}/live"-signal (samme
// "noe endret seg, hent på nytt"-mønster som resten av rundens
// sanntid) -- trigger en ny henting av meldingslisten uten å måtte
// koble en egen WebSocket til akkurat denne komponenten.
refreshKey?: number
}
// "image/*" (ikke en eksplisitt liste av MIME-typer) -- mange mobilnett-
// lesere viser bare galleri, ikke kamera, i filvelgeren når accept lister
// flere eksplisitte typer. Server-siden validerer uansett strengt mot
// storage.ALLOWED_INPUT_CONTENT_TYPES, så dette svekker ingen kontroll.
const ACCEPTED_IMAGE_TYPES = "image/*"
// Funnet 2026-08-06 (ekte enhet, Android/Chrome, installert som PWA): siden
// Android 13 bruker Chrome systemets eget bilde-vlger ("Photo Picker") for
// <input type=file accept="image/*"> UTEN capture-attributt -- den viser
// KUN galleri, aldri et kamera-alternativ. Løsning: to atskilte knapper/
// inputs -- én med capture="environment" (rett til kamera), én uten (rett
// til galleri) -- i stedet for å stole på at nettleseren tilbyr begge i
// samme velger.
const MAX_IMAGE_HEIGHT = 300
// Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
// klient-side også for en umiddelbar, presis feilmelding uten en unødvendig
// tur-retur til serveren for noe som uansett vil bli avvist.
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024
/** Leser {detail:{code,message}} fra et feil-svar, samme kontrakt som
* resten av API-et (app/errors.py). Faller tilbake til en generisk
* norsk melding hvis svaret ikke har denne formen (f.eks. et rått 502
* fra en proxy, ikke fra selve API-et). */
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
try {
const data = await res.json()
const message = data?.detail?.message
if (typeof message === "string" && message.length > 0) return message
} catch {
// Ikke JSON -- bruk fallback.
}
return fallback
}
/** Kort, norsk relativt tidsstempel (f.eks. "nå", "5 min", "2 t", "i går"). */
function formatRelativeTime(iso: string): { label: string; title: string } {
const then = new Date(iso)
const title = then.toLocaleString("nb-NO", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})
const diffMs = Date.now() - then.getTime()
const diffSec = Math.round(diffMs / 1000)
const diffMin = Math.round(diffSec / 60)
const diffHour = Math.round(diffMin / 60)
const diffDay = Math.round(diffHour / 24)
let label: string
if (diffSec < 45) label = "nå nettopp"
else if (diffMin < 60) label = `for ${diffMin} min siden`
else if (diffHour < 24) label = `for ${diffHour} t siden`
else if (diffDay === 1) label = "i går"
else if (diffDay < 7) label = `for ${diffDay} dager siden`
else
label = then.toLocaleDateString("nb-NO", {
day: "numeric",
month: "short",
})
return { label, title }
}
export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refreshKey }: RoundMessagesProps) {
const [messages, setMessages] = useState<ApiRoundMessage[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [body, setBody] = useState("")
const [tags, setTags] = useState<Tag[]>([])
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [posting, setPosting] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
// Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) --
// egen kø-navnerom (`${roundId}:messages`, ikke bare roundId) slik at
// denne komponentens kø ALDRI blandes med round-detail.tsx sin
// hull-score-kø, selv om begge deler samme IndexedDB-database. De to
// køene flushes og telles helt uavhengig av hverandre.
const messagesMatchId = `${roundId}:messages`
const [isOnline, setIsOnline] = useState(true)
const [pendingCount, setPendingCount] = useState(0)
const [syncingMessages, setSyncingMessages] = useState(false)
// Ikke-synkroniserte innlegg, vist FØRST i listen inntil `load()` (kalt
// etter en vellykket synk) erstatter dem med serverens fasit. Kan IKKE
// legges inn i `messages` direkte -- de har ingen ekte server-id ennå.
const [pendingMessages, setPendingMessages] = useState<PendingRoundMessage[]>([])
const fileInputRef = useRef<HTMLInputElement | null>(null)
const cameraInputRef = useRef<HTMLInputElement | null>(null)
const load = useCallback(async () => {
setLoading(true)
setLoadError(null)
try {
const res = await fetch(`/rounds/${roundId}/messages`, { credentials: "include" })
if (!res.ok) throw new Error(`Status ${res.status}`)
const data: ApiRoundMessage[] = await res.json()
setMessages(data)
} catch {
setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.")
} finally {
setLoading(false)
}
}, [roundId])
// Hent meldinger ved mount, og på nytt hver gang refreshKey endres
// (WS-signal fra round-detail.tsx).
useEffect(() => {
void load()
}, [load, refreshKey])
const flushPendingMessages = useCallback(async () => {
if (syncingMessages) return
setSyncingMessages(true)
try {
const outcomes = await flushQueue(messagesMatchId)
if (outcomes.length === 0) return
for (const o of outcomes) {
const sentBody = o.entry.body as Record<string, unknown>
const clientMessageId = sentBody.client_message_id as string | undefined
setPendingMessages((prev) => {
const match = prev.find((m) => m._clientMessageId === clientMessageId)
if (match?.image_url) URL.revokeObjectURL(match.image_url)
return prev.filter((m) => m._clientMessageId !== clientMessageId)
})
}
const failed = outcomes.filter((o) => !o.ok)
if (failed.length > 0) {
setPostError(
`${failed.length} ${failed.length === 1 ? "kommentar" : "kommentarer"} kunne ikke synkroniseres: ${failed[0].message}`,
)
}
setPendingCount(await queueCount(messagesMatchId))
// Serverens fasit -- fanger opp bl.a. tagger som ble forkastet
// server-side (f.eks. mot noen som ikke lenger er medspiller/venn).
await load()
} finally {
setSyncingMessages(false)
}
}, [messagesMatchId, syncingMessages, load])
useEffect(() => {
setIsOnline(navigator.onLine)
function handleOnline() {
setIsOnline(true)
void flushPendingMessages()
}
function handleOffline() {
setIsOnline(false)
}
window.addEventListener("online", handleOnline)
window.addEventListener("offline", handleOffline)
return () => {
window.removeEventListener("online", handleOnline)
window.removeEventListener("offline", handleOffline)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roundId])
// Køede skrivinger fra en tidligere økt (f.eks. nettleseren ble lukket
// mens offline) -- samme engangs-sjekk-ved-mount-mønster som
// round-detail.tsx. MERK: en optimistisk visning av DISSE gjenopprettes
// bevisst IKKE (bilde-forhåndsvisningens object-URL overlever uansett
// ikke en reload) -- de dukker opp i selve listen først når `load()`
// kjører etter vellykket synk, en akseptert forenkling.
useEffect(() => {
let cancelled = false
queueCount(messagesMatchId)
.then((count) => {
if (cancelled) return
setPendingCount(count)
if (count > 0 && navigator.onLine) void flushPendingMessages()
})
.catch(() => {})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roundId])
// Rydd opp objekt-URL-en for bildeforhåndsvisning.
useEffect(() => {
return () => {
if (imagePreview) URL.revokeObjectURL(imagePreview)
}
}, [imagePreview])
const clearSelectedImage = useCallback(() => {
setImageFile(null)
setImagePreview((prev) => {
if (prev) URL.revokeObjectURL(prev)
return null
})
if (fileInputRef.current) fileInputRef.current.value = ""
if (cameraInputRef.current) cameraInputRef.current.value = ""
}, [])
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (file.size > MAX_UPLOAD_BYTES) {
setPostError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
e.target.value = ""
return
}
setPostError(null)
setImageFile(file)
setImagePreview((prev) => {
if (prev) URL.revokeObjectURL(prev)
return URL.createObjectURL(file)
})
}
const trimmedBody = body.trim()
const canPost = (trimmedBody.length > 0 || imageFile != null) && !posting
// Lagrer skrivingen i offline-køen og speiler den optimistisk øverst i
// listen (ADR-028-tillegget, 2026-08-14). `client_message_id` sendes med
// BÅDE her og i det direkte (online) forsøket under -- serveren
// dedupliserer på den ved en evt. gjentatt synk (migrasjon 073).
async function queueMessageOffline(fields: Record<string, string | Blob>, clientMessageId: string) {
await enqueueWrite({
url: `/rounds/${roundId}/messages`,
method: "POST",
body: fields,
matchId: messagesMatchId,
isMultipart: true,
})
// Egen, LOKAL object-URL for den optimistiske visningen -- må IKKE
// gjenbruke composerens `imagePreview` (den revokes av
// clearSelectedImage() rett under, som ville ugyldiggjort et delt bilde).
const pendingImageUrl = imageFile ? URL.createObjectURL(imageFile) : null
setPendingMessages((prev) => [
{
id: `pending-${clientMessageId}`,
round_id: roundId,
author_user_id: currentUserId,
author_display_name: "Du",
body: trimmedBody || null,
image_url: pendingImageUrl,
created_at: new Date().toISOString(),
reactions: [],
comment_count: 0,
tags,
_clientMessageId: clientMessageId,
},
...prev,
])
setPendingCount((c) => c + 1)
setBody("")
setTags([])
clearSelectedImage()
}
async function handlePost(e: React.FormEvent) {
e.preventDefault()
if (!canPost) return
setPosting(true)
setPostError(null)
const clientMessageId = crypto.randomUUID()
const fields: Record<string, string | Blob> = { client_message_id: clientMessageId }
if (trimmedBody.length > 0) fields.body = trimmedBody
if (imageFile) fields.image = imageFile
if (tags.length > 0) fields.tags = JSON.stringify(tags)
if (!navigator.onLine) {
await queueMessageOffline(fields, clientMessageId)
setPosting(false)
return
}
try {
const formData = new FormData()
for (const [k, v] of Object.entries(fields)) formData.append(k, v)
const res = await fetch(`/rounds/${roundId}/messages`, {
method: "POST",
credentials: "include",
body: formData,
})
if (!res.ok) {
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
return
}
const created: ApiRoundMessage = await res.json()
// Legg øverst — nyeste først (2026-08-06, samme rekkefølge som
// backend nå returnerer listen i).
setMessages((prev) => [created, ...prev])
setBody("")
setTags([])
clearSelectedImage()
} catch {
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
// for å tape kommentaren, samme prinsipp som hull-scoreføring (ADR-028).
await queueMessageOffline(fields, clientMessageId)
} finally {
setPosting(false)
}
}
async function handleDelete(id: string) {
setDeletingId(id)
setPostError(null)
try {
const res = await fetch(`/rounds/${roundId}/messages/${id}`, {
method: "DELETE",
credentials: "include",
})
if (res.status !== 204) throw new Error(`Status ${res.status}`)
setMessages((prev) => prev.filter((m) => m.id !== id))
} catch {
setPostError("Kunne ikke slette kommentaren. Prøv igjen.")
} finally {
setDeletingId(null)
}
}
return (
<section className="flex flex-col gap-4" aria-label="Kommentarer og bilder fra runden">
{/* Composer */}
<form
onSubmit={handlePost}
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5"
>
<label htmlFor="round-message-body" className="sr-only">
Skriv en kommentar
</label>
<MentionTextarea
roundId={roundId}
id="round-message-body"
value={body}
tags={tags}
onChange={(v, t) => {
setBody(v)
setTags(t)
if (postError) setPostError(null)
}}
placeholder="Skriv en kommentar... (skriv @ for å tagge noen)"
rows={3}
className="w-full resize-y rounded-xl border border-border bg-background px-3 py-2.5 text-base text-foreground outline-none transition-all duration-200 ease-in-out placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
{/* Valgt bilde forhåndsvisning */}
{imagePreview && (
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted p-2.5">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imagePreview || "/placeholder.svg"}
alt="Forhåndsvisning av valgt bilde"
className="size-14 shrink-0 rounded-lg object-cover"
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{imageFile?.name}
</span>
<button
type="button"
onClick={clearSelectedImage}
className="inline-flex h-11 min-w-11 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-muted-foreground outline-none transition-all duration-200 ease-in-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<X aria-hidden="true" className="size-4" />
Fjern
</button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
{/* To atskilte inputs -- se MAX_IMAGE_HEIGHT-kommentaren over:
Android sin systembilde-vlger (Photo Picker) viser ALDRI
et kamera-alternativ når accept="image/*" mangler
capture, uansett hvilken nettleser. capture="environment"
her sender rett til kamera i stedet for å stole på at
velgeren tilbyr det. */}
<input
ref={cameraInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES}
capture="environment"
onChange={handleFileChange}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
onClick={() => cameraInputRef.current?.click()}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<Camera aria-hidden="true" className="size-5" />
Ta bilde
</button>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_IMAGE_TYPES}
onChange={handleFileChange}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-background px-4 text-base font-medium text-foreground outline-none transition-all duration-200 ease-in-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98]"
>
<ImagePlus aria-hidden="true" className="size-5" />
Velg fra galleri
</button>
</div>
<button
type="submit"
disabled={!canPost}
className="inline-flex h-11 min-w-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 text-base font-medium text-primary-foreground outline-none transition-all duration-200 ease-in-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
>
{posting ? (
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground"
/>
) : (
<Send aria-hidden="true" className="size-5" />
)}
{posting ? "Poster..." : "Post"}
</button>
</div>
{postError && (
<p role="alert" className="text-sm font-medium text-destructive">
{postError}
</p>
)}
</form>
{/* Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) --
samme banner-mønster som round-detail.tsx sin hull-scoreføring. */}
{!isOnline && (
<div className="flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
<span>Du er offline. Kommentaren lagres lokalt og postes automatisk når du er tilbake nett.</span>
</div>
)}
{pendingCount > 0 && (
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
<span>
{pendingCount} {pendingCount === 1 ? "kommentar venter" : "kommentarer venter"} synkronisering.
</span>
{isOnline && (
<button
type="button"
onClick={() => void flushPendingMessages()}
disabled={syncingMessages}
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
>
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncingMessages && "animate-spin")} />
Synkroniser
</button>
)}
</div>
)}
{/* Meldingsliste -- ikke-synkroniserte innlegg ligger ALLTID først,
uavhengig av created_at (de er per definisjon de ferskeste). */}
{loading ? (
<div className="flex justify-center py-12" aria-live="polite" aria-busy="true">
<span
aria-hidden="true"
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<span className="sr-only">Laster kommentarer...</span>
</div>
) : loadError ? (
<p role="alert" className="rounded-2xl border border-border bg-card p-5 text-base text-destructive">
{loadError}
</p>
) : pendingMessages.length === 0 && messages.length === 0 ? (
<p className="rounded-2xl border border-border bg-card p-5 text-base text-muted-foreground">
Ingen kommentarer ennå vær den første til å dele et bilde eller en kommentar fra runden.
</p>
) : (
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
{[...pendingMessages, ...messages].map((m) => {
const isPending = "_clientMessageId" in m
const canDelete = !isPending && (m.author_user_id === currentUserId || roundOwnerUserId === currentUserId)
const isDeleting = deletingId === m.id
const { label, title } = formatRelativeTime(m.created_at)
return (
<li key={m.id} className="flex flex-col gap-3 p-4 sm:p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-base font-semibold text-foreground">
{m.author_display_name}
</span>
{isPending ? (
<span className="inline-flex items-center gap-1 text-sm font-medium text-muted-foreground">
<Clock aria-hidden="true" className="size-3.5 shrink-0" />
Venter synk
</span>
) : (
<time
dateTime={m.created_at}
title={title}
className="text-sm font-medium text-muted-foreground"
>
{label}
</time>
)}
</div>
{canDelete && (
<button
type="button"
onClick={() => handleDelete(m.id)}
disabled={isDeleting}
className="inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-destructive outline-none transition-all duration-200 ease-in-out hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
>
{isDeleting ? (
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-destructive/30 border-t-destructive"
/>
) : (
<Trash2 aria-hidden="true" className="size-4" />
)}
Slett
</button>
)}
</div>
{m.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={m.image_url || "/placeholder.svg"}
alt={`Bilde delt av ${m.author_display_name}`}
style={{ maxHeight: MAX_IMAGE_HEIGHT }}
className="w-full rounded-xl object-cover"
/>
)}
{m.body && (
<p className="text-base leading-relaxed text-foreground whitespace-pre-wrap">
<TaggedText body={m.body} tags={m.tags} />
</p>
)}
{!isPending && (
<PostEngagement
apiBase={`/rounds/${roundId}/messages/${m.id}`}
roundId={roundId}
currentUserId={currentUserId || null}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}
canModerate={roundOwnerUserId === currentUserId}
/>
)}
</li>
)
})}
</ul>
)}
</section>
)
}
export default RoundMessages