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>
126 lines
4.8 KiB
TypeScript
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
|
|
}
|