From dd8a8c133d12330079dbeeb53450408b41f354a5 Mon Sep 17 00:00:00 2001
From: Erol Haagenrud
Date: Fri, 14 Aug 2026 11:51:30 +0200
Subject: [PATCH] Offline kommentar-/bildeposting i rundefeeden (ADR-069)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
073_round_message_client_id.sql | 25 +++
ARCHITECTURE_DECISIONS.md | 92 +++++++++
CHANGELOG.md | 45 +++++
app/routers/round_messages.py | 26 ++-
frontend/components/round-messages.tsx | 263 ++++++++++++++++++++-----
frontend/lib/offline-queue.ts | 47 +++--
tests/test_message_tags.py | 61 +++++-
7 files changed, 494 insertions(+), 65 deletions(-)
create mode 100644 073_round_message_client_id.sql
diff --git a/073_round_message_client_id.sql b/073_round_message_client_id.sql
new file mode 100644
index 0000000..a6d334c
--- /dev/null
+++ b/073_round_message_client_id.sql
@@ -0,0 +1,25 @@
+-- =====================================================================
+-- TeeCup — Idempotens for kommentar-posting via offline-kø (ADR-028-
+-- tillegget "kommentarer/bilder offline", 2026-08-14, migrasjon 073).
+--
+-- Hull-score-køen (ADR-028) er trygg å synke på nytt fordi PATCH +
+-- expected_version konvergerer uansett hvor mange ganger den gjentas.
+-- POST /rounds/{id}/messages er IKKE naturlig idempotent -- hvert kall
+-- oppretter en ny rad. En avbrutt synk (nettleseren lukkes midt i et
+-- flush-kall, eller samme kø flushes fra to faner) kunne dermed skrevet
+-- samme kommentar to ganger. Løsning: klienten sender en selvgenerert
+-- `client_message_id` (crypto.randomUUID()); et gjentatt kall med samme
+-- id returnerer den allerede opprettede meldingen i stedet for å
+-- opprette en duplikat (se app/routers/round_messages.py::
+-- post_round_message).
+-- =====================================================================
+\set ON_ERROR_STOP on
+
+ALTER TABLE round_message ADD COLUMN client_message_id uuid;
+
+-- Delvis unik indeks (kun når feltet faktisk er satt) -- eldre/andre
+-- kallere som ikke sender client_message_id er upåvirket, samme
+-- "None = ikke i bruk"-konvensjon som resten av appen.
+CREATE UNIQUE INDEX round_message_client_id_idx
+ ON round_message (round_id, client_message_id)
+ WHERE client_message_id IS NOT NULL;
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 0c63476..29d416e 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -6818,6 +6818,98 @@ alle bygget OG rullet ut.
---
+## ADR-069: Offline-utvidelse -- kommentar-/bildeposting i rundefeeden (ADR-028-tillegg) — 2026-08-14
+
+Foranledning: bruker spurte om installasjonsbanneret sin påstand
+"virker delvis uten nett" faktisk stemte. Undersøkelse bekreftet at den
+GJORDE det (ekte IndexedDB-skrivekø for hull-scoreføring, service
+worker-cache for allerede besøkte sider) -- men bruker ønsket å gjøre
+"delvis" til mer fullstendig. Presentert tre konkrete utvidelsesretninger
+(AskUserQuestion); bruker valgte "Utvid offline-skriving til mer enn
+scoreføring (kommentarer/bilder)" -- IKKE de to andre alternativene
+(proaktiv full-runde-precaching ved åpning, eller et generelt app-skall
+for en aldri-besøkt kaldstart).
+
+**Datamodell (migrasjon 073) -- idempotens, IKKE en ny funksjon i seg
+selv.** Hull-score-køen (ADR-028) er trygg å gjenta fordi PATCH +
+`expected_version` konvergerer uansett hvor mange ganger den sendes.
+`POST /rounds/{id}/messages` er IKKE naturlig idempotent -- hvert kall
+oppretter en ny rad. En avbrutt synk (nettleseren lukkes midt i et
+flush-kall, eller samme kø flushes fra to faner) kunne dermed skrevet
+samme kommentar to ganger. Løsning: `round_message` fikk en nullbar
+`client_message_id uuid`-kolonne + en delvis unik indeks
+`(round_id, client_message_id) WHERE client_message_id IS NOT NULL`.
+Klienten genererer denne (`crypto.randomUUID()`) FØR første forsøk
+(både det direkte online-forsøket og et evt. køet gjenforsøk sender
+samme id) -- et gjentatt kall med samme id returnerer den allerede
+opprettede meldingen (`post_round_message`, round_messages.py) i stedet
+for å opprette en duplikat. 3 nye backend-tester (repetert id → samme
+melding, ulike id-er → separate meldinger, manglende id → uendret
+oppførsel for bakoverkompatible kallere) -- full backend-suite 58/58.
+
+**offline-queue.ts utvidet til multipart, ikke bare JSON.** Køen
+(opprinnelig kun `Content-Type: application/json`) fikk et nytt
+`isMultipart`-flagg på `QueueEntry`. Når satt, er `body` et FLATT
+objekt av string/Blob-felt i stedet for en JSON-serialiserbar verdi --
+IndexedDB sin structured-clone-algoritme lagrer `Blob`/`File`-verdier
+NATIVT, ingen egen blob-lagringsstruktur trengs. `flushQueue` bygger da
+`FormData` i stedet for å JSON-serialisere. Uendret oppførsel for
+eksisterende (hull-score-)oppføringer uten flagget.
+
+**Frontend (round-messages.tsx) -- SAMME mønster som round-detail.tsx
+sin hull-score-kø, men et EGET, isolert kø-navnerom.** `matchId:
+\`${roundId}:messages\`` (ikke bare `roundId`) -- forhindrer at denne
+komponentens kø noensinne blandes med round-detail.tsx sin egen
+hull-score-kø i samme IndexedDB-database, selv om begge er montert på
+samme side samtidig. Egne `isOnline`/`pendingCount`/`syncingMessages`-
+tilstander, egne online/offline-lyttere, egen mount-tids-sjekk for
+gjenværende kø fra en tidligere økt -- fullstendig frikoblet fra
+round-detail.tsx sin tilsvarende logikk. Et lokalt "optimistisk kort"
+(egen `PendingRoundMessage`-type, ALDRI blandet inn i den ekte
+`messages`-listen) vises øverst med "Venter på synk" + klokke-ikon
+inntil en vellykket synk trigger en full `load()` som erstatter det med
+serverens fasit (fanger bl.a. opp tagger forkastet server-side).
+Bilde-forhåndsvisningen bruker sin EGEN, dedikerte `URL.createObjectURL`
+(ikke composerens `imagePreview`, som revokes med det samme av
+`clearSelectedImage()`).
+
+**Bevisst UTENFOR omfang:** kommentar-på-kommentar (`round_message_
+comment`, `PostEngagement`-tråden) fikk IKKE samme idempotens/kø-støtte
+-- kun rundefeedens hovedinnlegg (tekst+bilde), som var det brukeren
+faktisk pekte på. Å gjenopprette et optimistisk kort etter en FULL
+nettleser-lukking+gjenåpning mens offline er bevisst IKKE forsøkt
+(bilde-forhåndsvisningens object-URL overlever uansett ikke en reload)
+-- en køet skriving flushes likevel korrekt automatisk når nettet er
+tilbake, den vises bare ikke optimistisk i mellomtiden. Denne
+avgrensningen er testet eksplisitt (se under) og fungerer som forventet.
+
+**Verifisert:** `python3 -m py_compile` + full
+`./scripts/run_backend_tests.sh` (58/58, migrasjon 073 inkludert).
+`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
+`teecup_api`-container (port 18002, live-mountet kode) + lokal `next
+dev` (port 13002), ekte nettverk-frakoblet-emulering (Chrome DevTools
+MCP sin `networkConditions: "Offline"`, IKKE bare CDP-nivå -- bekreftet
+at appens egen `navigator.onLine`-avledede tilstand faktisk reagerte):
+(1) tekst-only kommentar postet offline → optimistisk kort med
+"Venter på synk" → satt online igjen → automatisk synk, banner
+forsvant, EKTE melding med reaksjoner/slett-knapp dukket opp, bekreftet
+NØYAKTIG 1 rad i databasen (ingen duplikat); (2) samme med bilde
+(ekte opplastet PNG via Chrome DevTools sin `upload_file`) -- optimistisk
+bilde-forhåndsvisning vist offline, ekte AVIF-konvertert MinIO-bilde
+etter synk; (3) reload-mens-offline-scenario -- postet en tredje
+kommentar offline, lastet siden PÅ NYTT mens fortsatt offline (simulerer
+at appen lukkes/gjenåpnes), satt online igjen -- den køede skrivingen
+fra FØR reload ble automatisk funnet og synket ved mount, bekreftet
+NØYAKTIG 3 rader totalt (ingen duplikat på tvers av reload). Lys+mørk
+bekreftet. Scratch-stacken (database, rolle, container, MinIO-
+scratch-bucket) fullstendig revet ned etterpå -- ekte `teecup_db`/
+`teecup_api`/`teecup_frontend` urørt gjennom hele verifiseringen.
+
+**IKKE rullet ut ennå** -- migrasjon 073 venter på eksplisitt
+bekreftelse fra bruker.
+
+---
+
## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7878105..29722e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11235,3 +11235,48 @@ Neste steg:
containere startet rent (ruller også ut de tre UI-rettelsene).
Dette var siste del av den tredelte Flaggturnering-utvidelsen --
Del A/B/C alle bygget OG rullet ut.
+
+85. **Offline kommentar-/bildeposting i rundefeeden (ADR-069) —
+ 2026-08-14.** Bruker spurte om installasjonsbannerets "virker
+ delvis uten nett"-påstand faktisk stemte -- undersøkelse bekreftet
+ at den gjorde det, men bruker ønsket å tette det største gjenværende
+ gapet: kun hull-scoreføring hadde offline-støtte, kommentarer/bilder
+ krevde fortsatt nett. Valgte dette fremfor to andre foreslåtte
+ retninger (proaktiv full-runde-precaching, generelt app-skall for
+ aldri-besøkt kaldstart).
+
+ Migrasjon 073: `round_message.client_message_id` (nullbar uuid) +
+ delvis unik indeks -- IKKE en ny funksjon i seg selv, men
+ idempotens. Ulikt hull-score-køens PATCH+expected_version (naturlig
+ trygg å gjenta), er `POST /messages` IKKE idempotent -- en avbrutt
+ synk kunne skrevet samme kommentar to ganger uten dette. Klienten
+ sender en `crypto.randomUUID()`-generert id ved både første forsøk
+ OG et evt. køet gjenforsøk; et gjentatt kall med samme id returnerer
+ den allerede opprettede meldingen fremfor å duplisere. 3 nye
+ backend-tester -- full suite 58/58.
+
+ `offline-queue.ts` utvidet med et `isMultipart`-flagg -- `body` blir
+ da et flatt string/Blob-objekt (IndexedDB lagrer Blob/File nativt)
+ i stedet for JSON, og `flushQueue` bygger `FormData` ved synk.
+ `round-messages.tsx` fikk sin EGEN, isolerte kø (`matchId:
+ \`${roundId}:messages\``, ikke bare `roundId`) -- blandes aldri med
+ round-detail.tsx sin hull-score-kø selv om begge er montert
+ samtidig. Optimistisk "Venter på synk"-kort med egen bilde-object-
+ URL (ikke composerens, som revokes med det samme).
+
+ **Verifisert:** full `./scripts/run_backend_tests.sh` (58/58,
+ migrasjon 073 inkludert). `tsc --noEmit` rent + 45/45 vitest. Egen
+ scratch-database + scratch `teecup_api`-container (port 18002,
+ live-mountet kode) + lokal `next dev` (port 13002), ekte
+ nettverk-frakoblet-emulering: tekst-only kommentar offline → synk →
+ nøyaktig 1 rad i databasen (ingen duplikat); samme med ekte
+ opplastet bilde (AVIF-konvertert av MinIO etter synk); reload-mens-
+ offline-scenario (postet offline, lastet siden på nytt MENS
+ fortsatt offline, satt online) -- den køede skrivingen ble funnet
+ og synket automatisk ved mount, nøyaktig 3 rader totalt (ingen
+ duplikat på tvers av en reload). Lys+mørk bekreftet. Scratch-stacken
+ fullstendig revet ned -- ekte `teecup_db`/`teecup_api`/
+ `teecup_frontend` urørt.
+
+ **IKKE rullet ut ennå** -- migrasjon 073 venter på eksplisitt
+ bekreftelse fra bruker.
diff --git a/app/routers/round_messages.py b/app/routers/round_messages.py
index d0312c1..ece5858 100644
--- a/app/routers/round_messages.py
+++ b/app/routers/round_messages.py
@@ -318,6 +318,12 @@ async def post_round_message(
# siden dette endepunktet er multipart (bilde-opplasting), ikke en ren
# JSON-body som CommentIn under kan bruke direkte.
tags: str | None = Form(default=None),
+ # Idempotens for offline-kø-synk (ADR-028-tillegget, migrasjon 073,
+ # 2026-08-14) -- klient-generert (crypto.randomUUID()), valgfri for
+ # bakoverkompatible kallere. Et gjentatt kall med SAMME id (f.eks. en
+ # avbrutt synk som prøves på nytt) returnerer den allerede opprettede
+ # meldingen fremfor å opprette en duplikat -- se migrasjonens header.
+ client_message_id: str | None = Form(default=None),
user: CurrentUser = Depends(get_current_user),
) -> RoundMessageOut:
if not body and image is None:
@@ -333,6 +339,20 @@ async def post_round_message(
# ingen snevrere gate i tillegg (ADR-044 Beslutning A).
await _get_viewable_round_or_404(conn, round_id, user.user_id)
+ if client_message_id is not None:
+ existing = await conn.fetchrow(
+ f"SELECT {_ROUND_MESSAGE_COLUMNS} FROM round_message WHERE round_id = $1 AND client_message_id = $2",
+ round_id, client_message_id,
+ )
+ if existing is not None:
+ reactions = await _round_message_reactions(conn, [existing["id"]], user.user_id)
+ comment_counts = await _round_message_comment_counts(conn, [existing["id"]])
+ tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", [existing["id"]])
+ return _row_to_round_message(
+ existing, reactions.get(existing["id"]), comment_counts.get(existing["id"], 0),
+ tags_by_id.get(existing["id"]),
+ )
+
image_key = None
if raw is not None:
try:
@@ -345,8 +365,9 @@ async def post_round_message(
row = await conn.fetchrow(
f"""
- INSERT INTO round_message (round_id, author_user_id, author_display_name, body, image_key)
- VALUES ($1, $2, $3, $4, $5)
+ INSERT INTO round_message
+ (round_id, author_user_id, author_display_name, body, image_key, client_message_id)
+ VALUES ($1, $2, $3, $4, $5, $6)
RETURNING {_ROUND_MESSAGE_COLUMNS}
""",
round_id,
@@ -354,6 +375,7 @@ async def post_round_message(
display_name,
body,
image_key,
+ client_message_id,
)
await _insert_tags_and_notify(
conn, table="round_message_tag", fk_column="round_message_id", message_id=row["id"],
diff --git a/frontend/components/round-messages.tsx b/frontend/components/round-messages.tsx
index 24e13ed..e4c7acc 100644
--- a/frontend/components/round-messages.tsx
+++ b/frontend/components/round-messages.tsx
@@ -1,10 +1,12 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
-import { Camera, ImagePlus, Send, Trash2, X } from "lucide-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 = {
@@ -20,6 +22,13 @@ export type ApiRoundMessage = {
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
@@ -108,34 +117,112 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
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(() => {
- const controller = new AbortController()
- async function load() {
- setLoading(true)
- setLoadError(null)
- try {
- const res = await fetch(`/rounds/${roundId}/messages`, {
- credentials: "include",
- signal: controller.signal,
+ 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)
})
- 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)
}
+ 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)
}
- load()
- return () => controller.abort()
- }, [roundId, refreshKey])
+ }, [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(() => {
@@ -173,24 +260,72 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
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()
- if (trimmedBody.length > 0) formData.append("body", trimmedBody)
- if (imageFile) formData.append("image", imageFile)
- if (tags.length > 0) formData.append("tags", JSON.stringify(tags))
+ 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.status !== 201) {
+ if (!res.ok) {
setPostError(await readErrorMessage(res, "Kunne ikke poste kommentaren. Prøv igjen."))
return
}
@@ -203,7 +338,9 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
setTags([])
clearSelectedImage()
} catch {
- setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
+ // 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)
}
@@ -344,7 +481,35 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
)}
- {/* Meldingsliste */}
+ {/* 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.
+
+ )}
+
+ {/* Meldingsliste -- ikke-synkroniserte innlegg ligger ALLTID først,
+ uavhengig av created_at (de er per definisjon de ferskeste). */}
{loading ? (