bpist-squalk-forum/src/lib/components/MentionAutocomplete.svelte

407 lines
12 KiB
Svelte

<script lang="ts">
import { tick } from "svelte";
import * as nip19 from "@nostr/tools/nip19";
import { loadRelayList } from "$lib/gadgets";
import {
profileStore,
searchLocalProfiles,
searchRemoteProfiles,
type ProfileEntry,
} from "$lib/profiles.svelte";
import { autogrow } from "$lib/actions/autogrow";
type Props = {
value: string;
disabled?: boolean;
rows?: number;
placeholder?: string;
contextPubkeys?: string[];
textareaClass?: string;
autoGrow?: boolean;
maxRows?: number;
onkeydown?: (e: KeyboardEvent) => void;
onfocus?: () => void;
onblur?: () => void;
};
let {
value = $bindable(),
disabled = false,
rows = 4,
placeholder = "",
contextPubkeys = [],
textareaClass = "",
autoGrow = false,
maxRows = 10,
onkeydown,
onfocus,
onblur,
}: Props = $props();
let textareaEl = $state<HTMLTextAreaElement | null>(null);
let listboxEl = $state<HTMLDivElement | null>(null);
let mentionQuery = $state<string | null>(null);
let mentionStart = $state(0);
let mentionEnd = $state(0);
let mentionRemoteResults = $state<ProfileEntry[]>([]);
let mentionIndex = $state(0);
let userMovedCursor = $state(false);
let anchorAbove = $state(false);
let remoteSearching = $state(false);
let remoteSearchTimer: ReturnType<typeof setTimeout> | null = null;
let remoteSearchAbort: AbortController | null = null;
const contextSet = $derived(new Set(contextPubkeys));
const mentionLocalResults = $derived.by(() => {
if (mentionQuery === null) return [];
if (mentionQuery === "") {
const out: ProfileEntry[] = [];
for (const pk of contextSet) {
const p = profileStore.profiles.get(pk);
if (p) out.push(p);
}
return out
.sort((a, b) =>
(a.name ?? a.displayName ?? "").localeCompare(
b.name ?? b.displayName ?? "",
),
)
.slice(0, 8);
}
return searchLocalProfiles(mentionQuery, {
contextPubkeys: contextSet,
limit: 8,
});
});
// Most-relevant first internally, reversed for display so the best match
// sits at the bottom (closest to the textarea when the dropdown opens above).
const mergedResults = $derived.by(() => {
const seen = new Set<string>();
const out: ProfileEntry[] = [];
for (const p of mentionLocalResults) {
if (seen.has(p.pubkey)) continue;
seen.add(p.pubkey);
out.push(p);
}
for (const p of mentionRemoteResults) {
if (seen.has(p.pubkey)) continue;
seen.add(p.pubkey);
out.push(p);
}
return out.slice(0, 8).reverse();
});
const mentionOpen = $derived(mentionQuery !== null);
const safeMentionIndex = $derived.by(() => {
if (mergedResults.length === 0) return 0;
if (!userMovedCursor) return mergedResults.length - 1;
return Math.min(mentionIndex, mergedResults.length - 1);
});
export function focus(opts: { caretAtEnd?: boolean } = {}) {
const ta = textareaEl;
if (!ta) return;
ta.focus();
if (opts.caretAtEnd) {
const pos = ta.value.length;
ta.setSelectionRange(pos, pos);
}
}
export function getTextarea(): HTMLTextAreaElement | null {
return textareaEl;
}
// Warm the kind:10002 cache for thread participants so relay hints are
// ready by publish time. Best-effort, dedup across focus events.
const prefetched = new Set<string>();
function onTextareaFocus() {
for (const pk of contextPubkeys) {
if (prefetched.has(pk)) continue;
prefetched.add(pk);
loadRelayList(pk).catch(() => {});
}
onfocus?.();
}
function detectMentionContext(text: string, cursor: number) {
const before = text.slice(0, cursor);
const m = before.match(/(?:^|\s)@([^\s@]*)$/);
if (!m) return null;
const query = m[1];
return {
query,
start: cursor - query.length - 1,
end: cursor,
};
}
function scheduleRemoteSearch(query: string, localCount: number) {
if (remoteSearchTimer) clearTimeout(remoteSearchTimer);
remoteSearchAbort?.abort();
if (!query || query.length < 1 || localCount >= 8) {
mentionRemoteResults = [];
remoteSearching = false;
return;
}
remoteSearching = true;
remoteSearchTimer = setTimeout(async () => {
const abort = new AbortController();
remoteSearchAbort = abort;
const captured = query;
try {
const results = await searchRemoteProfiles(captured, abort.signal);
if (abort.signal.aborted) return;
if (mentionQuery !== captured) return;
mentionRemoteResults = results;
} finally {
if (mentionQuery === captured) remoteSearching = false;
}
}, 200);
}
function closeMention() {
mentionQuery = null;
mentionRemoteResults = [];
mentionIndex = 0;
userMovedCursor = false;
remoteSearching = false;
if (remoteSearchTimer) clearTimeout(remoteSearchTimer);
remoteSearchAbort?.abort();
}
function updateMentionFromTextarea() {
const ta = textareaEl;
if (!ta) {
closeMention();
return;
}
const ctx = detectMentionContext(ta.value, ta.selectionStart);
if (!ctx) {
closeMention();
return;
}
const wasOpen = mentionQuery !== null;
if (ctx.query !== mentionQuery) {
mentionIndex = 0;
userMovedCursor = false;
mentionRemoteResults = [];
}
mentionQuery = ctx.query;
mentionStart = ctx.start;
mentionEnd = ctx.end;
scheduleRemoteSearch(ctx.query, mentionLocalResults.length);
if (!wasOpen) updateAnchor();
}
function updateAnchor() {
const ta = textareaEl;
if (!ta) return;
const rect = ta.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
anchorAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
}
async function selectMention(entry: ProfileEntry) {
const ta = textareaEl;
if (!ta || mentionQuery === null) return;
const before = value.slice(0, mentionStart);
const after = value.slice(mentionEnd);
let relays: string[] = [];
try {
const list = await loadRelayList(entry.pubkey);
relays = list.items
.filter((r) => r.write)
.slice(0, 2)
.map((r) => r.url);
} catch {
// No hints — still a valid nprofile, just less robust for receivers.
}
const nprofile = nip19.nprofileEncode({ pubkey: entry.pubkey, relays });
const insertion = `nostr:${nprofile}`;
const trailing = after.startsWith(" ") ? "" : " ";
value = before + insertion + trailing + after;
closeMention();
await tick();
const pos = (before + insertion + trailing).length;
ta.focus();
ta.setSelectionRange(pos, pos);
}
function profileLabel(p: ProfileEntry): string {
return p.name || p.displayName || p.nip05 || p.npub.slice(0, 12) + "…";
}
function profileSubLabel(p: ProfileEntry): string | null {
const main = profileLabel(p);
if (p.nip05 && p.nip05 !== main) return p.nip05;
if (p.displayName && p.displayName !== main) return p.displayName;
return null;
}
function onTextareaInput() {
updateMentionFromTextarea();
}
function onTextareaKeydown(e: KeyboardEvent) {
if (mentionQuery !== null) {
if (e.key === "Escape") {
e.preventDefault();
closeMention();
return;
}
if (mergedResults.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
userMovedCursor = true;
mentionIndex = (safeMentionIndex + 1) % mergedResults.length;
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
userMovedCursor = true;
mentionIndex =
(safeMentionIndex - 1 + mergedResults.length) %
mergedResults.length;
return;
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const entry = mergedResults[safeMentionIndex];
if (entry) selectMention(entry);
return;
}
}
}
onkeydown?.(e);
}
function onTextareaClickOrSelect() {
updateMentionFromTextarea();
}
function onTextareaBlur() {
setTimeout(() => closeMention(), 120);
onblur?.();
}
// Keep the highlighted (most-relevant) item visible as results change.
$effect(() => {
if (!listboxEl) return;
const idx = safeMentionIndex;
const items = listboxEl.querySelectorAll<HTMLElement>('[role="option"]');
items[idx]?.scrollIntoView({ block: "nearest" });
});
</script>
<div class="relative">
<textarea
bind:this={textareaEl}
bind:value
use:autogrow={{ enabled: autoGrow, maxRows, value }}
{disabled}
{rows}
{placeholder}
oninput={onTextareaInput}
onkeydown={onTextareaKeydown}
onclick={onTextareaClickOrSelect}
onkeyup={onTextareaClickOrSelect}
onfocus={onTextareaFocus}
onblur={onTextareaBlur}
aria-autocomplete="list"
aria-controls={mentionOpen ? "mention-listbox" : undefined}
class={textareaClass}
></textarea>
{#if mentionOpen}
<div
id="mention-listbox"
bind:this={listboxEl}
class="absolute right-0 left-0 z-30 max-h-72 overflow-auto rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-lg"
class:bottom-full={anchorAbove}
class:mb-1={anchorAbove}
class:top-full={!anchorAbove}
class:mt-1={!anchorAbove}
>
{#if mentionQuery === ""}
<div
class="px-3 py-1.5 text-xs text-neutral-400 dark:text-neutral-500"
class:border-b={mergedResults.length > 0}
class:border-neutral-100={mergedResults.length > 0}
>
Type to search
</div>
{:else if remoteSearching && mergedResults.length > 0}
<div
class="flex items-center gap-2 border-b border-neutral-100 dark:border-neutral-800 px-3 py-1.5 text-xs text-neutral-400 dark:text-neutral-500"
aria-live="polite"
>
<span
class="inline-block h-3 w-3 animate-spin rounded-full border border-neutral-300 dark:border-neutral-600 border-t-neutral-600"
aria-hidden="true"
></span>
<span>Searching…</span>
</div>
{/if}
{#if mergedResults.length === 0 && mentionQuery !== ""}
<div class="flex items-center gap-2 px-3 py-2 text-xs text-neutral-400 dark:text-neutral-500">
{#if remoteSearching}
<span
class="inline-block h-3 w-3 animate-spin rounded-full border border-neutral-300 dark:border-neutral-600 border-t-neutral-600"
aria-hidden="true"
></span>
<span>Searching…</span>
{:else}
No matches
{/if}
</div>
{:else if mergedResults.length > 0}
<ul role="listbox">
{#each mergedResults as entry, i (entry.pubkey)}
<li
role="option"
aria-selected={i === safeMentionIndex}
class="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm"
class:bg-neutral-100={i === safeMentionIndex}
onmousedown={(e) => {
e.preventDefault();
selectMention(entry);
}}
onmouseenter={() => {
userMovedCursor = true;
mentionIndex = i;
}}
>
{#if entry.picture}
<img
src={entry.picture}
alt=""
class="h-6 w-6 flex-shrink-0 rounded-full object-cover"
/>
{:else}
<span
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-700 text-xs text-neutral-500 dark:text-neutral-400"
aria-hidden="true"
>
{profileLabel(entry)[0]?.toUpperCase() ?? "?"}
</span>
{/if}
<span class="truncate font-medium text-neutral-700 dark:text-neutral-300">
{profileLabel(entry)}
</span>
{#if profileSubLabel(entry)}
<span class="truncate text-xs text-neutral-400 dark:text-neutral-500">
{profileSubLabel(entry)}
</span>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>