@-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>
This commit is contained in:
parent
206beefbc5
commit
aaec9c811d
7 changed files with 578 additions and 29 deletions
|
|
@ -639,6 +639,7 @@ class FeedEntryOut(BaseModel):
|
||||||
created_at: str
|
created_at: str
|
||||||
reactions: list[ReactionSummary] = []
|
reactions: list[ReactionSummary] = []
|
||||||
comment_count: int = 0
|
comment_count: int = 0
|
||||||
|
tags: list[TagOut] = []
|
||||||
|
|
||||||
|
|
||||||
@router.get("/feed", response_model=list[FeedEntryOut])
|
@router.get("/feed", response_model=list[FeedEntryOut])
|
||||||
|
|
@ -703,6 +704,7 @@ async def get_feed(
|
||||||
entry_ids = [r["id"] for r in rows]
|
entry_ids = [r["id"] for r in rows]
|
||||||
reactions_by_id = await _round_message_reactions(conn, entry_ids, user.user_id)
|
reactions_by_id = await _round_message_reactions(conn, entry_ids, user.user_id)
|
||||||
comment_counts = await _round_message_comment_counts(conn, entry_ids)
|
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 [
|
return [
|
||||||
FeedEntryOut(
|
FeedEntryOut(
|
||||||
id=r["id"],
|
id=r["id"],
|
||||||
|
|
@ -718,6 +720,7 @@ async def get_feed(
|
||||||
created_at=r["created_at"].isoformat(),
|
created_at=r["created_at"].isoformat(),
|
||||||
reactions=reactions_by_id.get(r["id"], []),
|
reactions=reactions_by_id.get(r["id"], []),
|
||||||
comment_count=comment_counts.get(r["id"], 0),
|
comment_count=comment_counts.get(r["id"], 0),
|
||||||
|
tags=tags_by_id.get(r["id"], []),
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import Link from "next/link"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
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"
|
import { BottomNav } from "@/components/teecup/bottom-nav"
|
||||||
|
|
||||||
/** Rå feed-oppføring fra backend. */
|
/** Rå feed-oppføring fra backend. */
|
||||||
|
|
@ -22,6 +24,7 @@ export type ApiFeedEntry = {
|
||||||
created_at: string // ISO 8601
|
created_at: string // ISO 8601
|
||||||
reactions: ReactionSummary[]
|
reactions: ReactionSummary[]
|
||||||
comment_count: number
|
comment_count: number
|
||||||
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
@ -232,13 +235,14 @@ function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId
|
||||||
{/* Tekst */}
|
{/* Tekst */}
|
||||||
{entry.body && (
|
{entry.body && (
|
||||||
<p className={cn("text-base leading-relaxed text-foreground text-pretty whitespace-pre-wrap")}>
|
<p className={cn("text-base leading-relaxed text-foreground text-pretty whitespace-pre-wrap")}>
|
||||||
{entry.body}
|
<TaggedText body={entry.body} tags={entry.tags} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<PostEngagement
|
<PostEngagement
|
||||||
apiBase={`/rounds/${entry.round_id}/messages/${entry.id}`}
|
apiBase={`/rounds/${entry.round_id}/messages/${entry.id}`}
|
||||||
|
roundId={entry.round_id}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
initialReactions={entry.reactions}
|
initialReactions={entry.reactions}
|
||||||
initialCommentCount={entry.comment_count}
|
initialCommentCount={entry.comment_count}
|
||||||
|
|
|
||||||
238
frontend/components/mention-input.tsx
Normal file
238
frontend/components/mention-input.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
import { useCallback, useMemo, useState } from "react"
|
import { useCallback, useMemo, useState } from "react"
|
||||||
import { MessageSquare, Reply, Trash2 } from "lucide-react"
|
import { MessageSquare, Reply, Trash2 } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { Tag } from "@/lib/mentions"
|
||||||
|
import { MentionTextarea, TaggedText } from "./mention-input"
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Data contracts (match the real API shape) */
|
/* Data contracts (match the real API shape) */
|
||||||
|
|
@ -23,12 +26,23 @@ export type CommentOut = {
|
||||||
author_display_name: string
|
author_display_name: string
|
||||||
body: string
|
body: string
|
||||||
created_at: string // ISO 8601
|
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 = {
|
export type PostEngagementProps = {
|
||||||
/** Fully-resolved base path, e.g. "/rounds/{id}/messages/{id}". This
|
/** Fully-resolved base path, e.g. "/rounds/{id}/messages/{id}". This
|
||||||
* component appends "/reaction" and "/comments". */
|
* component appends "/reaction" and "/comments". */
|
||||||
apiBase: string
|
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
|
currentUserId: string | null
|
||||||
initialReactions: ReactionSummary[]
|
initialReactions: ReactionSummary[]
|
||||||
initialCommentCount: number
|
initialCommentCount: number
|
||||||
|
|
@ -90,6 +104,7 @@ function formatTimestamp(iso: string): string {
|
||||||
|
|
||||||
export function PostEngagement({
|
export function PostEngagement({
|
||||||
apiBase,
|
apiBase,
|
||||||
|
roundId,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
initialReactions,
|
initialReactions,
|
||||||
initialCommentCount,
|
initialCommentCount,
|
||||||
|
|
@ -353,6 +368,7 @@ export function PostEngagement({
|
||||||
depth={0}
|
depth={0}
|
||||||
childrenOf={childrenOf}
|
childrenOf={childrenOf}
|
||||||
apiBase={apiBase}
|
apiBase={apiBase}
|
||||||
|
roundId={roundId}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canModerate={canModerate}
|
canModerate={canModerate}
|
||||||
replyingTo={replyingTo}
|
replyingTo={replyingTo}
|
||||||
|
|
@ -368,6 +384,7 @@ export function PostEngagement({
|
||||||
{loggedIn ? (
|
{loggedIn ? (
|
||||||
<CommentComposer
|
<CommentComposer
|
||||||
apiBase={apiBase}
|
apiBase={apiBase}
|
||||||
|
roundId={roundId}
|
||||||
parentCommentId={null}
|
parentCommentId={null}
|
||||||
onPosted={appendComment}
|
onPosted={appendComment}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
|
|
@ -392,6 +409,7 @@ function CommentNode({
|
||||||
depth,
|
depth,
|
||||||
childrenOf,
|
childrenOf,
|
||||||
apiBase,
|
apiBase,
|
||||||
|
roundId,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
canModerate,
|
canModerate,
|
||||||
replyingTo,
|
replyingTo,
|
||||||
|
|
@ -403,6 +421,7 @@ function CommentNode({
|
||||||
depth: number
|
depth: number
|
||||||
childrenOf: Map<string, CommentOut[]>
|
childrenOf: Map<string, CommentOut[]>
|
||||||
apiBase: string
|
apiBase: string
|
||||||
|
roundId?: string
|
||||||
currentUserId: string | null
|
currentUserId: string | null
|
||||||
canModerate: boolean
|
canModerate: boolean
|
||||||
replyingTo: string | null
|
replyingTo: string | null
|
||||||
|
|
@ -462,7 +481,7 @@ function CommentNode({
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-foreground">
|
<p className="whitespace-pre-wrap break-words text-base leading-relaxed text-foreground">
|
||||||
{comment.body}
|
<TaggedText body={comment.body} tags={comment.tags ?? []} />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|
@ -532,6 +551,7 @@ function CommentNode({
|
||||||
{isReplyOpen && loggedIn && (
|
{isReplyOpen && loggedIn && (
|
||||||
<CommentComposer
|
<CommentComposer
|
||||||
apiBase={apiBase}
|
apiBase={apiBase}
|
||||||
|
roundId={roundId}
|
||||||
parentCommentId={comment.id}
|
parentCommentId={comment.id}
|
||||||
autoFocus
|
autoFocus
|
||||||
onPosted={(c) => {
|
onPosted={(c) => {
|
||||||
|
|
@ -553,6 +573,7 @@ function CommentNode({
|
||||||
depth={cappedForChildren + 1}
|
depth={cappedForChildren + 1}
|
||||||
childrenOf={childrenOf}
|
childrenOf={childrenOf}
|
||||||
apiBase={apiBase}
|
apiBase={apiBase}
|
||||||
|
roundId={roundId}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canModerate={canModerate}
|
canModerate={canModerate}
|
||||||
replyingTo={replyingTo}
|
replyingTo={replyingTo}
|
||||||
|
|
@ -573,18 +594,21 @@ function CommentNode({
|
||||||
|
|
||||||
function CommentComposer({
|
function CommentComposer({
|
||||||
apiBase,
|
apiBase,
|
||||||
|
roundId,
|
||||||
parentCommentId,
|
parentCommentId,
|
||||||
onPosted,
|
onPosted,
|
||||||
onCancel,
|
onCancel,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
}: {
|
}: {
|
||||||
apiBase: string
|
apiBase: string
|
||||||
|
roundId?: string
|
||||||
parentCommentId: string | null
|
parentCommentId: string | null
|
||||||
onPosted: (c: CommentOut) => void
|
onPosted: (c: CommentOut) => void
|
||||||
onCancel?: () => void
|
onCancel?: () => void
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [body, setBody] = useState("")
|
const [body, setBody] = useState("")
|
||||||
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -602,6 +626,10 @@ function CommentComposer({
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
body: body.trim(),
|
body: body.trim(),
|
||||||
...(parentCommentId ? { parent_comment_id: parentCommentId } : {}),
|
...(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) {
|
if (!res.ok) {
|
||||||
|
|
@ -611,36 +639,55 @@ function CommentComposer({
|
||||||
const created = (await res.json()) as CommentOut
|
const created = (await res.json()) as CommentOut
|
||||||
onPosted(created)
|
onPosted(created)
|
||||||
setBody("")
|
setBody("")
|
||||||
|
setTags([])
|
||||||
} catch {
|
} catch {
|
||||||
setError(GENERIC_ERROR)
|
setError(GENERIC_ERROR)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}, [apiBase, body, empty, onPosted, parentCommentId, submitting])
|
}, [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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<textarea
|
{roundId ? (
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: composer opens on explicit user action
|
<MentionTextarea
|
||||||
autoFocus={autoFocus}
|
roundId={roundId}
|
||||||
value={body}
|
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: composer opens on explicit user action
|
||||||
onChange={(e) => setBody(e.target.value)}
|
autoFocus={autoFocus}
|
||||||
onKeyDown={(e) => {
|
value={body}
|
||||||
// Cmd/Ctrl+Enter submits; plain Enter keeps newlines. Respect IME.
|
tags={tags}
|
||||||
if (
|
onChange={(v, t) => {
|
||||||
(e.metaKey || e.ctrlKey) &&
|
setBody(v)
|
||||||
e.key === "Enter" &&
|
setTags(t)
|
||||||
!e.nativeEvent.isComposing &&
|
}}
|
||||||
e.keyCode !== 229
|
onKeyDownWhenClosed={handleComposerKeyDown}
|
||||||
) {
|
rows={parentCommentId ? 2 : 3}
|
||||||
e.preventDefault()
|
placeholder={parentCommentId ? "Skriv et svar … (@ for å tagge)" : "Skriv en kommentar … (@ for å tagge)"}
|
||||||
void submit()
|
className={composerClassName}
|
||||||
}
|
/>
|
||||||
}}
|
) : (
|
||||||
rows={parentCommentId ? 2 : 3}
|
<textarea
|
||||||
placeholder={parentCommentId ? "Skriv et svar …" : "Skriv en kommentar …"}
|
// eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: composer opens on explicit user action
|
||||||
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"
|
autoFocus={autoFocus}
|
||||||
/>
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onKeyDown={handleComposerKeyDown}
|
||||||
|
rows={parentCommentId ? 2 : 3}
|
||||||
|
placeholder={parentCommentId ? "Skriv et svar …" : "Skriv en kommentar …"}
|
||||||
|
className={composerClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm font-semibold text-destructive">
|
<p role="alert" className="text-sm font-semibold text-destructive">
|
||||||
{error}
|
{error}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { Camera, ImagePlus, Send, Trash2, X } from "lucide-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"
|
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
||||||
|
|
||||||
/** Rå meldingsform fra backend. */
|
/** Rå meldingsform fra backend. */
|
||||||
|
|
@ -15,6 +17,7 @@ export type ApiRoundMessage = {
|
||||||
created_at: string // ISO 8601
|
created_at: string // ISO 8601
|
||||||
reactions: ReactionSummary[]
|
reactions: ReactionSummary[]
|
||||||
comment_count: number
|
comment_count: number
|
||||||
|
tags: Tag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoundMessagesProps = {
|
export type RoundMessagesProps = {
|
||||||
|
|
@ -98,6 +101,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
const [body, setBody] = useState("")
|
const [body, setBody] = useState("")
|
||||||
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null)
|
const [imageFile, setImageFile] = useState<File | null>(null)
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||||
const [posting, setPosting] = useState(false)
|
const [posting, setPosting] = useState(false)
|
||||||
|
|
@ -179,6 +183,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
if (trimmedBody.length > 0) formData.append("body", trimmedBody)
|
if (trimmedBody.length > 0) formData.append("body", trimmedBody)
|
||||||
if (imageFile) formData.append("image", imageFile)
|
if (imageFile) formData.append("image", imageFile)
|
||||||
|
if (tags.length > 0) formData.append("tags", JSON.stringify(tags))
|
||||||
|
|
||||||
const res = await fetch(`/rounds/${roundId}/messages`, {
|
const res = await fetch(`/rounds/${roundId}/messages`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -195,6 +200,7 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
// backend nå returnerer listen i).
|
// backend nå returnerer listen i).
|
||||||
setMessages((prev) => [created, ...prev])
|
setMessages((prev) => [created, ...prev])
|
||||||
setBody("")
|
setBody("")
|
||||||
|
setTags([])
|
||||||
clearSelectedImage()
|
clearSelectedImage()
|
||||||
} catch {
|
} catch {
|
||||||
setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
|
setPostError("Kunne ikke poste kommentaren. Sjekk tilkoblingen og prøv igjen.")
|
||||||
|
|
@ -230,14 +236,17 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
<label htmlFor="round-message-body" className="sr-only">
|
<label htmlFor="round-message-body" className="sr-only">
|
||||||
Skriv en kommentar
|
Skriv en kommentar
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<MentionTextarea
|
||||||
|
roundId={roundId}
|
||||||
id="round-message-body"
|
id="round-message-body"
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => {
|
tags={tags}
|
||||||
setBody(e.target.value)
|
onChange={(v, t) => {
|
||||||
|
setBody(v)
|
||||||
|
setTags(t)
|
||||||
if (postError) setPostError(null)
|
if (postError) setPostError(null)
|
||||||
}}
|
}}
|
||||||
placeholder="Skriv en kommentar..."
|
placeholder="Skriv en kommentar... (skriv @ for å tagge noen)"
|
||||||
rows={3}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
@ -406,12 +415,13 @@ export function RoundMessages({ roundId, currentUserId, roundOwnerUserId, refres
|
||||||
|
|
||||||
{m.body && (
|
{m.body && (
|
||||||
<p className="text-base leading-relaxed text-foreground whitespace-pre-wrap">
|
<p className="text-base leading-relaxed text-foreground whitespace-pre-wrap">
|
||||||
{m.body}
|
<TaggedText body={m.body} tags={m.tags} />
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PostEngagement
|
<PostEngagement
|
||||||
apiBase={`/rounds/${roundId}/messages/${m.id}`}
|
apiBase={`/rounds/${roundId}/messages/${m.id}`}
|
||||||
|
roundId={roundId}
|
||||||
currentUserId={currentUserId || null}
|
currentUserId={currentUserId || null}
|
||||||
initialReactions={m.reactions}
|
initialReactions={m.reactions}
|
||||||
initialCommentCount={m.comment_count}
|
initialCommentCount={m.comment_count}
|
||||||
|
|
|
||||||
121
frontend/lib/mentions.test.ts
Normal file
121
frontend/lib/mentions.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
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 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
126
frontend/lib/mentions.ts
Normal file
126
frontend/lib/mentions.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// @-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
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue