"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 // 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 { 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([]) const [loading, setLoading] = useState(true) const [loadError, setLoadError] = useState(null) const [body, setBody] = useState("") const [tags, setTags] = useState([]) const [imageFile, setImageFile] = useState(null) const [imagePreview, setImagePreview] = useState(null) const [posting, setPosting] = useState(false) const [postError, setPostError] = useState(null) const [deletingId, setDeletingId] = useState(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([]) const fileInputRef = useRef(null) const cameraInputRef = useRef(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 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) { 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, 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 = { 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 (
{/* Composer */}
{ 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 && (
{/* eslint-disable-next-line @next/next/no-img-element */} Forhåndsvisning av valgt bilde {imageFile?.name}
)}
{/* 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. */}
{postError && (

{postError}

)} {/* Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) -- samme banner-mønster som round-detail.tsx sin hull-scoreføring. */} {!isOnline && (
)} {pendingCount > 0 && (
{pendingCount} {pendingCount === 1 ? "kommentar venter" : "kommentarer venter"} på synkronisering. {isOnline && ( )}
)} {/* Meldingsliste -- ikke-synkroniserte innlegg ligger ALLTID først, uavhengig av created_at (de er per definisjon de ferskeste). */} {loading ? (
) : loadError ? (

{loadError}

) : pendingMessages.length === 0 && messages.length === 0 ? (

Ingen kommentarer ennå — vær den første til å dele et bilde eller en kommentar fra runden.

) : (
    {[...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 (
  • {m.author_display_name} {isPending ? ( ) : ( )}
    {canDelete && ( )}
    {m.image_url && ( // eslint-disable-next-line @next/next/no-img-element {`Bilde )} {m.body && (

    )} {!isPending && ( )}
  • ) })}
)}
) } export default RoundMessages