"use client" import { useCallback, useEffect, useRef, useState } from "react" import { Camera, ImagePlus, Send, Trash2, X } from "lucide-react" import { MentionTextarea, TaggedText } from "./mention-input" import type { Tag } from "@/lib/mentions" import { PostEngagement, type ReactionSummary } from "./post-engagement" /** 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[] } 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) const fileInputRef = useRef(null) const cameraInputRef = useRef(null) // Hent meldinger ved mount, og på nytt hver gang refreshKey endres // (WS-signal fra round-detail.tsx). useEffect(() => { const controller = new AbortController() async function load() { setLoading(true) setLoadError(null) try { const res = await fetch(`/rounds/${roundId}/messages`, { credentials: "include", signal: controller.signal, }) if (!res.ok) throw new Error(`Status ${res.status}`) const data: ApiRoundMessage[] = await res.json() setMessages(data) } catch (err) { if ((err as Error).name === "AbortError") return setLoadError("Kunne ikke laste kommentarene. Prøv å laste siden på nytt.") } finally { setLoading(false) } } load() return () => controller.abort() }, [roundId, refreshKey]) // 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 async function handlePost(e: React.FormEvent) { e.preventDefault() if (!canPost) return setPosting(true) setPostError(null) try { const formData = new FormData() if (trimmedBody.length > 0) formData.append("body", trimmedBody) if (imageFile) formData.append("image", imageFile) if (tags.length > 0) formData.append("tags", JSON.stringify(tags)) const res = await fetch(`/rounds/${roundId}/messages`, { method: "POST", credentials: "include", body: formData, }) if (res.status !== 201) { 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 { setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.") } 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 */} Skriv en kommentar { 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 */} {imageFile?.name} Fjern )} {/* 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. */} 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]" > Ta bilde 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]" > Velg fra galleri {posting ? ( ) : ( )} {posting ? "Poster..." : "Post"} {postError && ( {postError} )} {/* Meldingsliste */} {loading ? ( Laster kommentarer... ) : loadError ? ( {loadError} ) : messages.length === 0 ? ( Ingen kommentarer ennå — vær den første til å dele et bilde eller en kommentar fra runden. ) : ( {messages.map((m) => { const canDelete = m.author_user_id === currentUserId || roundOwnerUserId === currentUserId const isDeleting = deletingId === m.id const { label, title } = formatRelativeTime(m.created_at) return ( {m.author_display_name} {label} {canDelete && ( 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 ? ( ) : ( )} Slett )} {m.image_url && ( // eslint-disable-next-line @next/next/no-img-element )} {m.body && ( )} ) })} )} ) } export default RoundMessages
{postError}
{loadError}
Ingen kommentarer ennå — vær den første til å dele et bilde eller en kommentar fra runden.