teecup/frontend/lib/mentions.ts
Erol Haagenrud aaec9c811d @-tagging (frontend): egen-implementert etter V0-credits tomme
Fullfører ADR-063 -- backend var allerede committet (206beef). Bruker
ba eksplisitt om at Claude selv implementerer frontend-promptet siden
V0-kreditter var oppbrukt.

lib/mentions.ts: ren offset-/diff-logikk (deteksjon, innsetting,
diff-justering ved redigering, tekst-segmentering), 15 enhetstester.
components/mention-input.tsx: MentionTextarea (autocomplete-dropdown,
tastaturnavigasjon) + TaggedText (lenker til /my-friends/{id}). Koblet
inn i round-messages.tsx (innlegg) og post-engagement.tsx (kommentarer,
delt av flere meldingssystemer -- roundId/tags gjort valgfrie for å
ikke bryte de org-scopede callerne uten tagging).

Rettet også et hull oppdaget underveis: backendens GET /feed manglet
tags (kun list_round_messages/comments hadde det) -- det er nettopp
/my-feed-siden som var det opprinnelige brukseksempelet.

Scratch-verifisert (egen DB/rolle/MinIO/API-/frontend-container, to
ekte testbrukere, venn-relasjon, felles runde): autocomplete, tagging
i både innlegg og kommentar, rendring som lenke i rundevisning OG i
aggregert feed, varsel opprettet, personvern-scoping bekreftet (kun
faktiske relasjoner tilbys, ikke seg selv, ikke fremmede), lys+mørk.
tsc --noEmit rent, 45/45 vitest grønt. teecup_db urørt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:15:02 +02:00

126 lines
4.8 KiB
TypeScript

// @-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 (så 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
}