"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 */}
{/* Offline kommentar-/bildeposting (ADR-028-tillegget, 2026-08-14) --
samme banner-mønster som round-detail.tsx sin hull-scoreføring. */}
{!isOnline && (
Du er offline. Kommentaren lagres lokalt og postes automatisk når du er tilbake på nett.