Compare commits

..

No commits in common. "1ca27cbc09b8f74881d6e15680913739fabf059e" and "206beefbc5b341dd2152188c44abd0b820801f5a" have entirely different histories.

9 changed files with 44 additions and 638 deletions

View file

@ -5991,25 +5991,11 @@ varsel bekreftet skrevet til `notification`-tabellen), ugyldig tag
avvise selve meldingen. Alle 31 backend-tester (23 fra før + 8 nye)
grønne i samme kjøring. `teecup_db` bekreftet uendret.
**Frontend — oppdatering samme dag:** opprinnelig sendt som eget
V0-prompt (se over), men bruker gikk tom for V0-credits og ba Claude
implementere promptet selv i stedet. `lib/mentions.ts` (ren offset-/
diff-logikk, 15 enhetstester) + `components/mention-input.tsx`
(`MentionTextarea`-autocomplete + `TaggedText`-rendring), koblet inn i
`round-messages.tsx` (innlegg), `post-engagement.tsx` (kommentarer —
delt av flere meldingssystemer, `roundId`/`tags` gjort valgfrie for å
ikke bryte de org-scopede callerne) og `feed.tsx` (aggregert
`/my-feed`-visning — der backendens `GET /feed` manglet `tags` ble
også lagt til her, oppdaget under dette arbeidet). Scratch-verifisert
i nettleser (autocomplete, tagging i innlegg og kommentar, rendring som
lenke både i rundevisning og i feed, varsel bekreftet, personvern-
scoping bekreftet i UI også, lys+mørk). `tsc --noEmit` rent, 45/45
vitest grønt. Se CHANGELOG.md.
Org-turneringenes tilsvarende "Banter Board" (`message`/
`message_comment`) fikk IKKE samme funksjon — helt separat system,
ikke nevnt i den opprinnelige forespørselen, fortsatt bevisst utenfor
omfang.
**Bevisst utenfor omfang denne runden:** frontend (autocomplete-UI +
tag-rendering) sendt som eget V0-prompt, ikke bygget her — se
CHANGELOG.md. Org-turneringenes tilsvarende "Banter Board"
(`message`/`message_comment`) fikk IKKE samme funksjon — helt separat
system, ikke nevnt i den opprinnelige forespørselen.
Disse må avklares før eller under de relevante fasene:

View file

@ -10440,9 +10440,9 @@ Neste steg:
andre `ChoiceRow`-bruken (alle har 3 valg fra før). Browserverifisert
lys+mørk. Committet (`17e4793`), ikke deployet ennå.
72. **@-tagging av medspillere/venner i runde-feeden — 2026-08-11, se
ADR-063.** Bakt inn i FEATURE_BACKLOG.md som egen sak under
ADR-062, bruker ba om å sette den i gang.
72. **@-tagging av medspillere/venner i runde-feeden (backend) —
2026-08-11, se ADR-063.** Bakt inn i FEATURE_BACKLOG.md som egen sak
under ADR-062, bruker ba om å sette den i gang.
Migrasjon 064: `round_message_tag`/`round_message_comment_tag` (to
parallelle tabeller, samme mønster som `round_message_reaction` vs.
@ -10461,42 +10461,11 @@ Neste steg:
tag + varsel bekreftet, tre typer ugyldig-tag-forkastelse. Alle 31
backend-tester grønne. `teecup_db` uendret.
**Frontend — samme dag, egen-implementert.** Frontend var
opprinnelig sendt som eget V0-prompt, men bruker gikk tom for
V0-credits ("Kan du gi promptet til deg selv, og gjøre det beste
ut av det?") — eksplisitt, avgrenset unntak fra prosjektets
stående "frontend via V0"-konvensjon for akkurat denne funksjonen.
`frontend/lib/mentions.ts` (ren offset-/diff-logikk: @-deteksjon,
innsetting, diff-justering av eksisterende tags ved redigering,
tekst-segmentering — 15 enhetstester) +
`frontend/components/mention-input.tsx` (`MentionTextarea` med
debouncet autocomplete-dropdown + tastaturnavigasjon, `TaggedText`
som rendrer tagger som lenker til `/my-friends/{id}`). Koblet inn i
`round-messages.tsx` (innlegg), `post-engagement.tsx` (kommentarer
— delt komponent mellom FIRE meldingssystemer, `roundId`/`tags`
gjort valgfrie for å ikke bryte `feed.tsx`/`public-tournament.tsx`/
`team-chat.tsx`, hvorav de to siste bevisst IKKE får tagging) og
`feed.tsx` (aggregert `/my-feed`-visning). Fant og rettet et hull
underveis: backendens `GET /feed` manglet `tags` i `FeedEntryOut`
kun `list_round_messages`/kommentar-endepunktene hadde det fra
før, selv om `/my-feed` var nettopp det opprinnelige eksempelet
("Fredrik i farta") som motiverte hele funksjonen.
**Bevisst utenfor omfang:** frontend (autocomplete + tag-rendering)
sendt som eget V0-prompt, ikke bygget i denne runden. Org-
turneringenes "Banter Board" (`message`/`message_comment`) fikk IKKE
samme funksjon — separat system.
**Verifisert (frontend):** `tsc --noEmit` rent, 45/45 vitest
grønt. Full scratch-stack (DB, MinIO, API, frontend — ekte
produksjonsbuild). To ekte testbrukere (venn-relasjon + felles
runde) browserverifisert: autocomplete-dropdown i både innleggs-
og kommentar-komposereren, tag rendret som lenke i selve
rundevisningen OG i aggregert feed, varsel bekreftet opprettet
(`GET /notifications`), personvern-scoping bekreftet i selve UI-et
(skriver "@" alene → kun faktisk relasjon tilbys, aldri seg selv
eller en fremmed), lys+mørk modus. Ingen konsollfeil. Scratch-
miljøet ryddet fullstendig opp, `teecup_db` bekreftet uendret før
og etter.
Org-turneringenes "Banter Board" (`message`/`message_comment`)
fikk IKKE samme funksjon — separat system, fortsatt bevisst
utenfor omfang.
**Ikke rullet ut ennå** — migrasjon 064 er ikke kjørt mot ekte
`teecup_db`. Se deploy-plan under.
**Ikke rullet ut ennå** — venter på V0-leveransen for frontend-delen
før migrasjon 064 kjøres mot ekte `teecup_db` (samme rekkefølge-
prinsipp som alltid: migrasjon før kode som er avhengig av den).

View file

@ -639,7 +639,6 @@ class FeedEntryOut(BaseModel):
created_at: str
reactions: list[ReactionSummary] = []
comment_count: int = 0
tags: list[TagOut] = []
@router.get("/feed", response_model=list[FeedEntryOut])
@ -704,7 +703,6 @@ async def get_feed(
entry_ids = [r["id"] for r in rows]
reactions_by_id = await _round_message_reactions(conn, entry_ids, user.user_id)
comment_counts = await _round_message_comment_counts(conn, entry_ids)
tags_by_id = await _tags_for_messages(conn, "round_message_tag", "round_message_id", entry_ids)
return [
FeedEntryOut(
id=r["id"],
@ -720,7 +718,6 @@ async def get_feed(
created_at=r["created_at"].isoformat(),
reactions=reactions_by_id.get(r["id"], []),
comment_count=comment_counts.get(r["id"], 0),
tags=tags_by_id.get(r["id"], []),
)
for r in rows
]

View file

@ -5,8 +5,6 @@ import Link from "next/link"
import { cn } from "@/lib/utils"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
import { TaggedText } from "./mention-input"
import type { Tag } from "@/lib/mentions"
import { BottomNav } from "@/components/teecup/bottom-nav"
/** Rå feed-oppføring fra backend. */
@ -24,7 +22,6 @@ export type ApiFeedEntry = {
created_at: string // ISO 8601
reactions: ReactionSummary[]
comment_count: number
tags: Tag[]
}
const PAGE_SIZE = 20
@ -235,14 +232,13 @@ function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId
{/* Tekst */}
{entry.body && (
<p className={cn("text-base leading-relaxed text-foreground text-pretty whitespace-pre-wrap")}>
<TaggedText body={entry.body} tags={entry.tags} />
{entry.body}
</p>
)}
</Link>
<PostEngagement
apiBase={`/rounds/${entry.round_id}/messages/${entry.id}`}
roundId={entry.round_id}
currentUserId={currentUserId}
initialReactions={entry.reactions}
initialCommentCount={entry.comment_count}

View file

@ -1,238 +0,0 @@
"use client"
// @-tagging (ADR-063) -- delt UI mellom innleggs-komposereren
// (round-messages.tsx) og kommentar-komposereren (post-engagement.tsx).
// Selve logikken (offset-regning, tekst-diffing) er bevisst flyttet ut i
// lib/mentions.ts og enhetstestet der -- denne fila er ren presentasjon
// og DOM-/fokushåndtering.
import type React from "react"
import Link from "next/link"
import { useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import {
adjustTagsForEdit,
detectActiveMention,
fetchTaggablePeople,
insertMention,
segmentTaggedText,
type Tag,
type TaggablePerson,
} from "@/lib/mentions"
type MentionTextareaProps = {
roundId: string
value: string
tags: Tag[]
onChange: (value: string, tags: Tag[]) => void
placeholder?: string
rows?: number
id?: string
className?: string
autoFocus?: boolean
// Videresendes KUN når dropdownen ikke er åpen -- Enter/piltaster skal
// styre forslagslisten når den vises, ikke utløse f.eks. avsenderens
// egen Cmd/Ctrl+Enter-innsending samtidig (post-engagement.tsx).
onKeyDownWhenClosed?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
}
export function MentionTextarea({
roundId,
value,
tags,
onChange,
placeholder,
rows = 3,
id,
className,
autoFocus,
onKeyDownWhenClosed,
}: MentionTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
const [mention, setMention] = useState<{ start: number; query: string } | null>(null)
const [suggestions, setSuggestions] = useState<TaggablePerson[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const [highlighted, setHighlighted] = useState(0)
// Markørposisjonen som stod da @-forsøket sist ble oppdatert -- trengs
// for å vite hvor SLUTTEN av "@søketekst" er ved faktisk innsetting
// (markøren kan ha rukket å flytte seg videre siden selve deteksjonen).
const cursorAtDetectionRef = useRef(0)
const pendingCursorRef = useRef<number | null>(null)
const open = mention !== null
// Debouncet henting av kandidater -- kun mens dropdownen faktisk er åpen.
useEffect(() => {
if (!open) {
setSuggestions([])
return
}
let cancelled = false
setLoadingSuggestions(true)
const handle = setTimeout(async () => {
const people = await fetchTaggablePeople(roundId, mention.query)
if (!cancelled) {
setSuggestions(people)
setHighlighted(0)
setLoadingSuggestions(false)
}
}, 200)
return () => {
cancelled = true
clearTimeout(handle)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- kun query/roundId skal trigge ny henting
}, [open, mention?.query, roundId])
// Sett markøren riktig sted ETTER at React har rendret den nye,
// lengre/kortere verdien -- kan ikke gjøres synkront i onChange, siden
// <textarea>s DOM-verdi ikke er oppdatert med den nye teksten ennå der.
useEffect(() => {
if (pendingCursorRef.current !== null && textareaRef.current) {
textareaRef.current.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
pendingCursorRef.current = null
}
}, [value])
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
const newValue = e.target.value
const cursor = e.target.selectionStart
const adjustedTags = adjustTagsForEdit(value, newValue, tags)
onChange(newValue, adjustedTags)
const active = detectActiveMention(newValue, cursor)
cursorAtDetectionRef.current = cursor
setMention(active)
}
function selectSuggestion(person: TaggablePerson) {
if (!mention || !textareaRef.current) return
const { body: newBody, tag, cursor } = insertMention(value, mention.start, cursorAtDetectionRef.current, person)
onChange(newBody, [...tags, tag])
setMention(null)
pendingCursorRef.current = cursor
textareaRef.current.focus()
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (open && suggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault()
setHighlighted((i) => (i + 1) % suggestions.length)
return
}
if (e.key === "ArrowUp") {
e.preventDefault()
setHighlighted((i) => (i - 1 + suggestions.length) % suggestions.length)
return
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault()
selectSuggestion(suggestions[highlighted])
return
}
if (e.key === "Escape") {
e.preventDefault()
setMention(null)
return
}
} else if (open && e.key === "Escape") {
e.preventDefault()
setMention(null)
return
}
onKeyDownWhenClosed?.(e)
}
return (
<div className="relative">
<textarea
ref={textareaRef}
id={id}
autoFocus={autoFocus}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={() => {
// Liten forsinkelse: en mouseDown på et forslag skal rekke å
// trigge FØR blur lukker listen (ellers mistes klikket).
window.setTimeout(() => setMention(null), 150)
}}
placeholder={placeholder}
rows={rows}
className={className}
/>
{open && (
<div
role="listbox"
aria-label="Forslag til tagging"
className="absolute left-0 right-0 top-full z-20 mt-1 max-h-60 overflow-y-auto rounded-xl border border-border bg-card shadow-lg"
>
{loadingSuggestions ? (
<div className="flex items-center justify-center p-3">
<span
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary/20 border-t-primary"
/>
<span className="sr-only">Søker...</span>
</div>
) : suggestions.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">Ingen treff</p>
) : (
<ul>
{suggestions.map((person, i) => (
<li key={person.user_id}>
<button
type="button"
role="option"
aria-selected={i === highlighted}
onMouseDown={(e) => {
// preventDefault: unngår at textarea mister fokus
// FØR onClick rekker å kjøre.
e.preventDefault()
selectSuggestion(person)
}}
onMouseEnter={() => setHighlighted(i)}
className={cn(
"flex min-h-11 w-full items-center px-3 text-left text-base text-foreground",
i === highlighted ? "bg-accent" : "hover:bg-accent/50",
)}
>
{person.display_name}
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
/** Rendrer body-tekst med taggede utsnitt som lenker til vedkommendes
* profil (`/my-friends/{id}`) -- tydelig annerledes enn vanlig tekst,
* bevisst UTEN understrek (ligger for tett inntil resten av teksten
* visuelt sett til at en vanlig lenkestil er lesbar der). */
export function TaggedText({ body, tags, className }: { body: string; tags: Tag[]; className?: string }) {
if (tags.length === 0) return <span className={className}>{body}</span>
const segments = segmentTaggedText(body, tags)
return (
<span className={className}>
{segments.map((seg, i) =>
seg.tag ? (
<Link
key={i}
href={`/my-friends/${seg.tag.user_id}`}
className="font-semibold text-primary no-underline hover:underline"
>
{seg.text}
</Link>
) : (
<span key={i}>{seg.text}</span>
),
)}
</span>
)
}

View file

@ -1,12 +1,9 @@
"use client"
import type React from "react"
import { useCallback, useMemo, useState } from "react"
import { MessageSquare, Reply, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { Tag } from "@/lib/mentions"
import { MentionTextarea, TaggedText } from "./mention-input"
/* ------------------------------------------------------------------ */
/* Data contracts (match the real API shape) */
@ -26,23 +23,12 @@ export type CommentOut = {
author_display_name: string
body: string
created_at: string // ISO 8601
// Valgfritt (ADR-063): kun round_message_comment (frittstående runder)
// har tagging. Org-lagchat/turneringsfeedens tilsvarende CommentOut-
// former (team-chat.tsx/public-tournament.tsx) har aldri dette feltet.
tags?: Tag[]
}
export type PostEngagementProps = {
/** Fully-resolved base path, e.g. "/rounds/{id}/messages/{id}". This
* component appends "/reaction" and "/comments". */
apiBase: string
// Valgfri (ADR-063): kun satt for frittstående runders "Banter Board"
// (round-messages.tsx/feed.tsx) -- MentionTextarea sitt @-forslags-søk
// går mot /rounds/{roundId}/taggable-people. Org-lagchat/turneringsfeed
// (team-chat.tsx/public-tournament.tsx) har ikke dette konseptet og
// lar feltet stå utelatt -- komposereren faller da tilbake til en vanlig
// tekstboks uten @-tagging, ingen regresjon for de flytene.
roundId?: string
currentUserId: string | null
initialReactions: ReactionSummary[]
initialCommentCount: number
@ -104,7 +90,6 @@ function formatTimestamp(iso: string): string {
export function PostEngagement({
apiBase,
roundId,
currentUserId,
initialReactions,
initialCommentCount,
@ -368,7 +353,6 @@ export function PostEngagement({
depth={0}
childrenOf={childrenOf}
apiBase={apiBase}
roundId={roundId}
currentUserId={currentUserId}
canModerate={canModerate}
replyingTo={replyingTo}
@ -384,7 +368,6 @@ export function PostEngagement({
{loggedIn ? (
<CommentComposer
apiBase={apiBase}
roundId={roundId}
parentCommentId={null}
onPosted={appendComment}
autoFocus={false}
@ -409,7 +392,6 @@ function CommentNode({
depth,
childrenOf,
apiBase,
roundId,
currentUserId,
canModerate,
replyingTo,
@ -421,7 +403,6 @@ function CommentNode({
depth: number
childrenOf: Map<string, CommentOut[]>
apiBase: string
roundId?: string
currentUserId: string | null
canModerate: boolean
replyingTo: string | null
@ -481,7 +462,7 @@ function CommentNode({
{/* Body */}
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-foreground">
<TaggedText body={comment.body} tags={comment.tags ?? []} />
{comment.body}
</p>
{/* Actions */}
@ -551,7 +532,6 @@ function CommentNode({
{isReplyOpen && loggedIn && (
<CommentComposer
apiBase={apiBase}
roundId={roundId}
parentCommentId={comment.id}
autoFocus
onPosted={(c) => {
@ -573,7 +553,6 @@ function CommentNode({
depth={cappedForChildren + 1}
childrenOf={childrenOf}
apiBase={apiBase}
roundId={roundId}
currentUserId={currentUserId}
canModerate={canModerate}
replyingTo={replyingTo}
@ -594,21 +573,18 @@ function CommentNode({
function CommentComposer({
apiBase,
roundId,
parentCommentId,
onPosted,
onCancel,
autoFocus,
}: {
apiBase: string
roundId?: string
parentCommentId: string | null
onPosted: (c: CommentOut) => void
onCancel?: () => void
autoFocus?: boolean
}) {
const [body, setBody] = useState("")
const [tags, setTags] = useState<Tag[]>([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -626,10 +602,6 @@ function CommentComposer({
body: JSON.stringify({
body: body.trim(),
...(parentCommentId ? { parent_comment_id: parentCommentId } : {}),
// Kun med når roundId faktisk er satt (round_message_comment-
// flyten) -- org-lagchat/turneringsfeedens comment-endepunkt
// aksepterer ikke (og trenger ikke) dette feltet.
...(roundId ? { tags } : {}),
}),
})
if (!res.ok) {
@ -639,55 +611,36 @@ function CommentComposer({
const created = (await res.json()) as CommentOut
onPosted(created)
setBody("")
setTags([])
} catch {
setError(GENERIC_ERROR)
} finally {
setSubmitting(false)
}
}, [apiBase, body, empty, onPosted, parentCommentId, roundId, submitting, tags])
const composerClassName =
"min-h-11 w-full resize-y rounded-2xl border border-border bg-card px-4 py-3 text-base leading-relaxed text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
function handleComposerKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
// Cmd/Ctrl+Enter submits; plain Enter keeps newlines. Respect IME.
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && !e.nativeEvent.isComposing && e.keyCode !== 229) {
e.preventDefault()
void submit()
}
}
}, [apiBase, body, empty, onPosted, parentCommentId, submitting])
return (
<div className="flex flex-col gap-2">
{roundId ? (
<MentionTextarea
roundId={roundId}
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: composer opens on explicit user action
autoFocus={autoFocus}
value={body}
tags={tags}
onChange={(v, t) => {
setBody(v)
setTags(t)
}}
onKeyDownWhenClosed={handleComposerKeyDown}
rows={parentCommentId ? 2 : 3}
placeholder={parentCommentId ? "Skriv et svar … (@ for å tagge)" : "Skriv en kommentar … (@ for å tagge)"}
className={composerClassName}
/>
) : (
<textarea
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: composer opens on explicit user action
autoFocus={autoFocus}
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleComposerKeyDown}
onKeyDown={(e) => {
// Cmd/Ctrl+Enter submits; plain Enter keeps newlines. Respect IME.
if (
(e.metaKey || e.ctrlKey) &&
e.key === "Enter" &&
!e.nativeEvent.isComposing &&
e.keyCode !== 229
) {
e.preventDefault()
void submit()
}
}}
rows={parentCommentId ? 2 : 3}
placeholder={parentCommentId ? "Skriv et svar …" : "Skriv en kommentar …"}
className={composerClassName}
className="min-h-11 w-full resize-y rounded-2xl border border-border bg-card px-4 py-3 text-base leading-relaxed text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
/>
)}
{error && (
<p role="alert" className="text-sm font-semibold text-destructive">
{error}

View file

@ -2,8 +2,6 @@
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. */
@ -17,7 +15,6 @@ export type ApiRoundMessage = {
created_at: string // ISO 8601
reactions: ReactionSummary[]
comment_count: number
tags: Tag[]
}
export type RoundMessagesProps = {
@ -101,7 +98,6 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
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)
@ -183,7 +179,6 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
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",
@ -200,7 +195,6 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
// backend nå returnerer listen i).
setMessages((prev) => [created, ...prev])
setBody("")
setTags([])
clearSelectedImage()
} catch {
setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
@ -236,17 +230,14 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
<label htmlFor="round-message-body" className="sr-only">
Skriv en kommentar
</label>
<MentionTextarea
roundId={roundId}
<textarea
id="round-message-body"
value={body}
tags={tags}
onChange={(v, t) => {
setBody(v)
setTags(t)
onChange={(e) => {
setBody(e.target.value)
if (postError) setPostError(null)
}}
placeholder="Skriv en kommentar... (skriv @ for å tagge noen)"
placeholder="Skriv en kommentar..."
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"
/>
@ -415,13 +406,12 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
{m.body && (
<p className="text-base leading-relaxed text-foreground whitespace-pre-wrap">
<TaggedText body={m.body} tags={m.tags} />
{m.body}
</p>
)}
<PostEngagement
apiBase={`/rounds/${roundId}/messages/${m.id}`}
roundId={roundId}
currentUserId={currentUserId || null}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}

View file

@ -1,121 +0,0 @@
import { describe, expect, it } from "vitest"
import { adjustTagsForEdit, detectActiveMention, insertMention, segmentTaggedText } from "./mentions"
describe("detectActiveMention", () => {
it("finner @-forsøk ved starten av teksten", () => {
expect(detectActiveMention("@Fre", 4)).toEqual({ start: 0, query: "Fre" })
})
it("finner @-forsøk midt i teksten, etter mellomrom", () => {
const body = "God runde med @Fre"
expect(detectActiveMention(body, body.length)).toEqual({ start: 14, query: "Fre" })
})
it("trigges IKKE av en e-post-adresse (@ uten forutgående mellomrom)", () => {
const body = "kontakt meg på navn@example"
expect(detectActiveMention(body, body.length)).toBeNull()
})
it("returnerer null når markøren ikke er i et @-forsøk i det hele tatt", () => {
expect(detectActiveMention("bare vanlig tekst", 5)).toBeNull()
})
it("returnerer null når @-forsøket ble avbrutt av et mellomrom (fullført/forlatt)", () => {
const body = "@Fredrik er kul"
expect(detectActiveMention(body, body.length)).toBeNull()
})
it("virker med markøren midt i ordet, ikke bare på slutten", () => {
const body = "@Fredrik Mathisen"
// markør rett etter "Fre" i "Fredrik"
expect(detectActiveMention(body, 4)).toEqual({ start: 0, query: "Fre" })
})
})
describe("insertMention", () => {
it("erstatter @søketekst med @Fullt Navn og regner riktig ny markørposisjon", () => {
const body = "God runde med @Fre i dag"
const start = 14
const cursor = 18 // rett etter "Fre"
const result = insertMention(body, start, cursor, { user_id: "u1", display_name: "Fredrik Mathisen" })
expect(result.body).toBe("God runde med @Fredrik Mathisen i dag")
expect(result.tag).toEqual({ user_id: "u1", display_name: "Fredrik Mathisen", start_index: 14, end_index: 31 })
expect(result.cursor).toBe(31)
// Taggens eget utsnitt skal faktisk væra "@Fredrik Mathisen".
expect(result.body.slice(result.tag.start_index, result.tag.end_index)).toBe("@Fredrik Mathisen")
})
})
describe("adjustTagsForEdit", () => {
const tag = { user_id: "u1", display_name: "Fredrik Mathisen", start_index: 14, end_index: 32 }
it("beholder taggen uendret når redigeringen skjer ETTER den", () => {
const oldBody = "God runde med @Fredrik Mathisen i dag"
const newBody = "God runde med @Fredrik Mathisen i dag, supert vær"
const result = adjustTagsForEdit(oldBody, newBody, [tag])
expect(result).toEqual([tag])
})
it("forskyver taggen når tekst settes inn FØR den", () => {
const oldBody = "med @Fredrik Mathisen i dag"
const newBody = "God runde med @Fredrik Mathisen i dag"
const shifted = { ...tag, start_index: 0, end_index: 18 } // relativt til oldBody sin tag
const oldTag = { ...tag, start_index: 4, end_index: 22 }
const result = adjustTagsForEdit(oldBody, newBody, [oldTag])
const delta = newBody.length - oldBody.length
expect(result).toEqual([{ ...oldTag, start_index: oldTag.start_index + delta, end_index: oldTag.end_index + delta }])
})
it("dropper taggen stille når redigeringen overlapper dens eget utsnitt", () => {
const oldBody = "med @Fredrik Mathisen i dag"
const oldTag = { ...tag, start_index: 4, end_index: 22 }
// Sletter en bokstav MIDT i det taggede navnet.
const newBody = "med @Fredrik athisen i dag"
const result = adjustTagsForEdit(oldBody, newBody, [oldTag])
expect(result).toEqual([])
})
it("dropper KUN den berørte taggen, beholder en urørt tag et annet sted", () => {
const oldBody = "@Fredrik og @Kari spilte i dag"
const fredrikTag = { user_id: "u1", display_name: "Fredrik", start_index: 0, end_index: 8 }
const kariTag = { user_id: "u2", display_name: "Kari", start_index: 12, end_index: 17 }
// Redigerer midt i "Fredrik".
const newBody = "@Fred og @Kari spilte i dag"
const result = adjustTagsForEdit(oldBody, newBody, [fredrikTag, kariTag])
const delta = newBody.length - oldBody.length
expect(result).toEqual([{ ...kariTag, start_index: kariTag.start_index + delta, end_index: kariTag.end_index + delta }])
})
})
describe("segmentTaggedText", () => {
it("deler teksten korrekt rundt én tag", () => {
const body = "God runde med @Fredrik Mathisen i dag"
const tag = { user_id: "u1", display_name: "Fredrik Mathisen", start_index: 14, end_index: 31 }
const segments = segmentTaggedText(body, [tag])
expect(segments).toEqual([
{ text: "God runde med ", tag: null },
{ text: "@Fredrik Mathisen", tag },
{ text: " i dag", tag: null },
])
})
it("returnerer hele teksten som ett segment når det ikke finnes tags", () => {
const segments = segmentTaggedText("bare vanlig tekst", [])
expect(segments).toEqual([{ text: "bare vanlig tekst", tag: null }])
})
it("håndterer flere tags i rekkefølge", () => {
const body = "@Fredrik og @Kari spilte"
const t1 = { user_id: "u1", display_name: "Fredrik", start_index: 0, end_index: 8 }
const t2 = { user_id: "u2", display_name: "Kari", start_index: 12, end_index: 17 }
const segments = segmentTaggedText(body, [t1, t2])
expect(segments.map((s) => s.text)).toEqual(["@Fredrik", " og ", "@Kari", " spilte"])
})
it("hopper defensivt over en tag med ugyldig/utenfor-grense offset i stedet for å krasje", () => {
const body = "kort tekst"
const badTag = { user_id: "u1", display_name: "X", start_index: 5, end_index: 999 }
expect(() => segmentTaggedText(body, [badTag])).not.toThrow()
expect(segmentTaggedText(body, [badTag])).toEqual([{ text: "kort tekst", tag: null }])
})
})

View file

@ -1,126 +0,0 @@
// @-tagging (ADR-063) -- ren logikk, ingen React her, så den kan
// enhetstestes uavhengig av komponentene som bruker den (se
// mentions.test.ts). UI-delen (dropdown, tekstfelt, rendering av taggede
// utsnitt) ligger i components/mention-input.tsx.
export type Tag = {
user_id: string
display_name: string
start_index: number
end_index: number
}
export type TaggablePerson = {
user_id: string
display_name: string
}
/** Henter kandidater fra det nye søkeendepunktet (kun faktiske relasjoner
* -- rundedeltakere + avsenderens venner, ALDRI fritekstsøk i hele
* brukerbasen, se ADR-063). */
export async function fetchTaggablePeople(roundId: string, query: string): Promise<TaggablePerson[]> {
const res = await fetch(`/rounds/${roundId}/taggable-people?q=${encodeURIComponent(query)}`, {
credentials: "include",
})
if (!res.ok) return []
return (await res.json()) as TaggablePerson[]
}
/** Er markøren midt i et "@søketekst"-forsøk? Søker bakover fra markøren
* gjennom sammenhengende ikke-mellomrom-tegn etter en "@" som enten står
* helt i starten av teksten eller rett etter et mellomrom ( en e-post-
* adresse skrevet inn et sted i teksten ikke feilaktig trigger et
* forslag). Returnerer `null` når det ikke finnes noe aktivt @-forsøk. */
export function detectActiveMention(
body: string,
cursor: number,
): { start: number; query: string } | null {
let i = cursor - 1
while (i >= 0 && !/\s/.test(body[i])) {
if (body[i] === "@") {
const precedingChar = i > 0 ? body[i - 1] : undefined
if (precedingChar === undefined || /\s/.test(precedingChar)) {
return { start: i, query: body.slice(i + 1, cursor) }
}
return null
}
i--
}
return null
}
/** Setter inn `@Fornavn Etternavn` der `@søketekst` sto (fra `start` til
* `cursor`), og returnerer den nye teksten + den ferdige taggen + hvor
* markøren skal stå etterpå (rett etter navnet). */
export function insertMention(
body: string,
start: number,
cursor: number,
person: TaggablePerson,
): { body: string; tag: Tag; cursor: number } {
const inserted = `@${person.display_name}`
const newBody = body.slice(0, start) + inserted + body.slice(cursor)
return {
body: newBody,
tag: { user_id: person.user_id, display_name: person.display_name, start_index: start, end_index: start + inserted.length },
cursor: start + inserted.length,
}
}
/** Justerer eksisterende tags etter en tekstredigering -- differ
* oldBody/newBody via felles prefiks/suffiks (samme prinsipp som en enkel
* tekst-diff) i stedet for å anta HVOR endringen skjedde. Tags som ligger
* HELT før eller HELT etter det redigerte området beholdes (sistnevnte
* med forskjøvet offset); en tag hvis eget tegn-utsnitt ble RØRT av
* redigeringen droppes stille -- samme "ikke prøv å reparere en tag som
* ikke lenger stemmer"-filosofi som backend-valideringen allerede har
* (se ADR-063 Beslutning C). */
export function adjustTagsForEdit(oldBody: string, newBody: string, tags: Tag[]): Tag[] {
const maxCommon = Math.min(oldBody.length, newBody.length)
let prefix = 0
while (prefix < maxCommon && oldBody[prefix] === newBody[prefix]) prefix++
let suffix = 0
const maxSuffix = maxCommon - prefix
while (
suffix < maxSuffix &&
oldBody[oldBody.length - 1 - suffix] === newBody[newBody.length - 1 - suffix]
) {
suffix++
}
const oldEditEnd = oldBody.length - suffix
const delta = newBody.length - oldBody.length
const result: Tag[] = []
for (const t of tags) {
if (t.end_index <= prefix) {
result.push(t)
} else if (t.start_index >= oldEditEnd) {
result.push({ ...t, start_index: t.start_index + delta, end_index: t.end_index + delta })
}
// else: redigeringen overlapper taggens eget utsnitt -- droppes.
}
return result
}
/** Deler en tekst i vanlige og taggede segmenter, sortert og ikke-
* overlappende (overlappende tags -- skal aldri skje siden de kommer fra
* enten insertMention eller server-validerte data, men filtreres bort
* defensivt om det likevel skjer, fremfor å krasje rendringen). Brukt av
* <TaggedText> (components/mention-input.tsx). */
export type TextSegment = { text: string; tag: Tag | null }
export function segmentTaggedText(body: string, tags: Tag[]): TextSegment[] {
const sorted = [...tags].sort((a, b) => a.start_index - b.start_index)
const segments: TextSegment[] = []
let cursor = 0
for (const t of sorted) {
if (t.start_index < cursor || t.end_index > body.length || t.start_index >= t.end_index) continue
if (t.start_index > cursor) segments.push({ text: body.slice(cursor, t.start_index), tag: null })
segments.push({ text: body.slice(t.start_index, t.end_index), tag: t })
cursor = t.end_index
}
if (cursor < body.length) segments.push({ text: body.slice(cursor), tag: null })
return segments
}