Add mentions to chat
This commit is contained in:
parent
05dd79f283
commit
2859f7069f
5 changed files with 540 additions and 390 deletions
|
|
@ -3,6 +3,7 @@ import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
||||||
import { auth } from "$lib/auth.svelte";
|
import { auth } from "$lib/auth.svelte";
|
||||||
import { ingestNostrUser } from "$lib/profiles.svelte";
|
import { ingestNostrUser } from "$lib/profiles.svelte";
|
||||||
|
import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
|
||||||
|
|
||||||
export type ChatMessageData = {
|
export type ChatMessageData = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -114,10 +115,21 @@ export async function sendChatMessage(
|
||||||
.slice(-3)
|
.slice(-3)
|
||||||
.map((m) => m.id.slice(0, 8));
|
.map((m) => m.id.slice(0, 8));
|
||||||
|
|
||||||
|
const notifyPubkeys = new Set<string>();
|
||||||
|
for (const pk of extractMentionPubkeys(content)) notifyPubkeys.add(pk);
|
||||||
|
if (replyTo && replyTo.pubkey !== ownPubkey)
|
||||||
|
notifyPubkeys.add(replyTo.pubkey);
|
||||||
|
notifyPubkeys.delete(ownPubkey);
|
||||||
|
|
||||||
|
const hints = await buildPTagHints(notifyPubkeys);
|
||||||
|
|
||||||
const tags: string[][] = [["h", GROUP_ID]];
|
const tags: string[][] = [["h", GROUP_ID]];
|
||||||
if (replyTo) {
|
if (replyTo) {
|
||||||
tags.push(["q", replyTo.id, RELAY_URL, replyTo.pubkey]);
|
tags.push(["q", replyTo.id, RELAY_URL, replyTo.pubkey]);
|
||||||
if (replyTo.pubkey !== ownPubkey) tags.push(["p", replyTo.pubkey]);
|
}
|
||||||
|
for (const pk of notifyPubkeys) {
|
||||||
|
const hint = hints.get(pk);
|
||||||
|
tags.push(hint ? ["p", pk, hint] : ["p", pk]);
|
||||||
}
|
}
|
||||||
if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]);
|
if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]);
|
||||||
|
|
||||||
|
|
|
||||||
81
src/lib/components/ChatContent.svelte
Normal file
81
src/lib/components/ChatContent.svelte
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: string;
|
||||||
|
profiles?: Record<string, NostrUser>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { content, profiles = {} }: Props = $props();
|
||||||
|
|
||||||
|
type Token =
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "mention"; pubkey: string; entity: string; fallback: string };
|
||||||
|
|
||||||
|
const MENTION_RE = /nostr:(npub1[a-z0-9]+|nprofile1[a-z0-9]+)/gi;
|
||||||
|
|
||||||
|
function shortEntity(entity: string): string {
|
||||||
|
const m = entity.match(/^(npub|nprofile)1/);
|
||||||
|
if (!m) return entity;
|
||||||
|
const prefix = m[0];
|
||||||
|
const rest = entity.slice(prefix.length);
|
||||||
|
if (rest.length <= 12) return entity;
|
||||||
|
return `${prefix}${rest.slice(0, 6)}…${rest.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = $derived.by<Token[]>(() => {
|
||||||
|
const out: Token[] = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const m of content.matchAll(MENTION_RE)) {
|
||||||
|
const start = m.index ?? 0;
|
||||||
|
const entity = m[1].toLowerCase();
|
||||||
|
let pubkey: string | null = null;
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(entity);
|
||||||
|
if (decoded.type === "npub") pubkey = decoded.data;
|
||||||
|
else if (decoded.type === "nprofile") pubkey = decoded.data.pubkey;
|
||||||
|
} catch {
|
||||||
|
// Invalid bech32, fall through to text
|
||||||
|
}
|
||||||
|
if (start > last)
|
||||||
|
out.push({ type: "text", value: content.slice(last, start) });
|
||||||
|
if (pubkey) {
|
||||||
|
out.push({
|
||||||
|
type: "mention",
|
||||||
|
pubkey,
|
||||||
|
entity,
|
||||||
|
fallback: shortEntity(entity),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push({ type: "text", value: m[0] });
|
||||||
|
}
|
||||||
|
last = start + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < content.length)
|
||||||
|
out.push({ type: "text", value: content.slice(last) });
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (t.type !== "mention") continue;
|
||||||
|
if (resolvedUsers[t.pubkey] || profiles[t.pubkey]) continue;
|
||||||
|
loadNostrUser(t.pubkey).then((u) => {
|
||||||
|
resolvedUsers = { ...resolvedUsers, [t.pubkey]: u };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="break-words whitespace-pre-wrap"
|
||||||
|
>{#each tokens as t (t)}{#if t.type === "mention"}{@const u =
|
||||||
|
profiles[t.pubkey] ?? resolvedUsers[t.pubkey]}<a
|
||||||
|
href="https://njump.me/{t.entity}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-brand hover:underline">@{u?.shortName ?? t.fallback}</a
|
||||||
|
>{:else}{t.value}{/if}{/each}</span
|
||||||
|
>
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
import { auth, openLogin } from "$lib/auth.svelte";
|
import { auth, openLogin } from "$lib/auth.svelte";
|
||||||
import { withJoin } from "$lib/join.svelte";
|
import { withJoin } from "$lib/join.svelte";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||||
|
import ChatContent from "$lib/components/ChatContent.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
|
|
@ -19,7 +21,7 @@
|
||||||
|
|
||||||
let asideEl: HTMLElement;
|
let asideEl: HTMLElement;
|
||||||
let listEl = $state<HTMLDivElement | null>(null);
|
let listEl = $state<HTMLDivElement | null>(null);
|
||||||
let inputEl = $state<HTMLTextAreaElement | null>(null);
|
let inputEl = $state<MentionAutocomplete | null>(null);
|
||||||
let openMenuId = $state<string | null>(null);
|
let openMenuId = $state<string | null>(null);
|
||||||
let replyTarget = $state<ChatMessageData | null>(null);
|
let replyTarget = $state<ChatMessageData | null>(null);
|
||||||
let inputValue = $state("");
|
let inputValue = $state("");
|
||||||
|
|
@ -29,6 +31,9 @@
|
||||||
const messages = $derived(chatStore.messages);
|
const messages = $derived(chatStore.messages);
|
||||||
const profiles = $derived(chatStore.profiles);
|
const profiles = $derived(chatStore.profiles);
|
||||||
|
|
||||||
|
// Distinct authors of loaded messages — power the @ autocomplete context.
|
||||||
|
const contextPubkeys = $derived([...new Set(messages.map((m) => m.pubkey))]);
|
||||||
|
|
||||||
function resolveAuthor(pubkey: string) {
|
function resolveAuthor(pubkey: string) {
|
||||||
const u: NostrUser | undefined = profiles[pubkey];
|
const u: NostrUser | undefined = profiles[pubkey];
|
||||||
return {
|
return {
|
||||||
|
|
@ -45,7 +50,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncate(s: string, n = 60) {
|
function truncate(s: string, n = 60) {
|
||||||
const t = s.replace(/\s+/g, " ").trim();
|
// Collapse mentions to @… so the preview stays readable.
|
||||||
|
const stripped = s.replace(/nostr:(?:npub1|nprofile1)[a-z0-9]+/gi, "@…");
|
||||||
|
const t = stripped.replace(/\s+/g, " ").trim();
|
||||||
return t.length > n ? t.slice(0, n) + "…" : t;
|
return t.length > n ? t.slice(0, n) + "…" : t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,14 +144,14 @@
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
bind:this={asideEl}
|
bind:this={asideEl}
|
||||||
class="absolute right-0 top-2 h-[calc(100%-0.5rem)] z-10 flex flex-col rounded-tl-xl min-[1540px]:rounded-tr-xl bg-white transition-all duration-200
|
class="absolute top-2 right-0 z-10 flex h-[calc(100%-0.5rem)] flex-col rounded-tl-xl bg-white transition-all duration-200 min-[1540px]:rounded-tr-xl
|
||||||
{expanded ? 'w-150 shadow-2xl' : 'w-80 shadow-lg'} px-6 py-6"
|
{expanded ? 'w-150 shadow-2xl' : 'w-80 shadow-lg'} px-6 py-6"
|
||||||
>
|
>
|
||||||
<div class="flex shrink-0 items-center justify-between mb-6">
|
<div class="mb-6 flex shrink-0 items-center justify-between">
|
||||||
<span class="text-[1.5rem] text-brand leading-7">Chat</span>
|
<span class="text-brand text-[1.5rem] leading-7">Chat</span>
|
||||||
<button
|
<button
|
||||||
onclick={onToggle}
|
onclick={onToggle}
|
||||||
class="rounded bg-neutral-100 hover:bg-neutral-200 transition-colors"
|
class="rounded bg-neutral-100 transition-colors hover:bg-neutral-200"
|
||||||
aria-label={expanded ? "Collapse chat" : "Expand chat"}
|
aria-label={expanded ? "Collapse chat" : "Expand chat"}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -171,10 +178,10 @@
|
||||||
<div
|
<div
|
||||||
bind:this={listEl}
|
bind:this={listEl}
|
||||||
onscroll={onListScroll}
|
onscroll={onListScroll}
|
||||||
class="flex-1 overflow-y-auto flex flex-col -mr-6 pr-6"
|
class="-mr-6 flex flex-1 flex-col overflow-y-auto pr-6"
|
||||||
>
|
>
|
||||||
{#if messages.length === 0}
|
{#if messages.length === 0}
|
||||||
<div class="m-auto text-center text-sm text-neutral-400 py-8">
|
<div class="m-auto py-8 text-center text-sm text-neutral-400">
|
||||||
No messages yet.
|
No messages yet.
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -184,7 +191,7 @@
|
||||||
{@const parent = msg.replyToId ? getChatMessage(msg.replyToId) : null}
|
{@const parent = msg.replyToId ? getChatMessage(msg.replyToId) : null}
|
||||||
{@const parentAuthor = parent ? resolveAuthor(parent.pubkey) : null}
|
{@const parentAuthor = parent ? resolveAuthor(parent.pubkey) : null}
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="mb-1 flex items-center gap-2">
|
||||||
{#if author.picture}
|
{#if author.picture}
|
||||||
<img
|
<img
|
||||||
src={author.picture}
|
src={author.picture}
|
||||||
|
|
@ -193,7 +200,7 @@
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
class="h-6 w-6 shrink-0 rounded-full bg-neutral-200 flex items-center justify-center text-xs font-semibold text-neutral-500"
|
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500"
|
||||||
>
|
>
|
||||||
{author.name[0].toUpperCase()}
|
{author.name[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -216,10 +223,8 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<p
|
<p class="leading-5 text-neutral-700">
|
||||||
class="text-neutral-700 leading-5 whitespace-pre-wrap break-words"
|
<ChatContent content={msg.content} {profiles} />
|
||||||
>
|
|
||||||
{msg.content}
|
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-0.5 flex items-center gap-2">
|
<div class="mt-0.5 flex items-center gap-2">
|
||||||
<div class="relative ml-auto">
|
<div class="relative ml-auto">
|
||||||
|
|
@ -228,7 +233,7 @@
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openMenuId = openMenuId === msg.id ? null : msg.id;
|
openMenuId = openMenuId === msg.id ? null : msg.id;
|
||||||
}}
|
}}
|
||||||
class="flex items-center justify-center rounded p-0.5 text-neutral-300 hover:text-neutral-500 hover:bg-neutral-100 transition-colors"
|
class="flex items-center justify-center rounded p-0.5 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500"
|
||||||
aria-label="Message actions"
|
aria-label="Message actions"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -244,7 +249,7 @@
|
||||||
</button>
|
</button>
|
||||||
{#if openMenuId === msg.id}
|
{#if openMenuId === msg.id}
|
||||||
<div
|
<div
|
||||||
class="absolute right-0 bottom-6 z-20 w-36 rounded-lg border border-neutral-100 bg-white py-1 shadow-lg text-sm"
|
class="absolute right-0 bottom-6 z-20 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
|
|
@ -271,7 +276,7 @@
|
||||||
<div
|
<div
|
||||||
class="mb-2 flex items-start gap-2 rounded bg-neutral-50 px-2 py-1.5 text-xs text-neutral-600"
|
class="mb-2 flex items-start gap-2 rounded bg-neutral-50 px-2 py-1.5 text-xs text-neutral-600"
|
||||||
>
|
>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<span class="text-neutral-400">↳ Reply to </span>
|
<span class="text-neutral-400">↳ Reply to </span>
|
||||||
<span class="font-medium">{replyAuthor.name}</span>:
|
<span class="font-medium">{replyAuthor.name}</span>:
|
||||||
<span class="text-neutral-500"
|
<span class="text-neutral-500"
|
||||||
|
|
@ -289,19 +294,20 @@
|
||||||
{/if}
|
{/if}
|
||||||
{#if sendError}
|
{#if sendError}
|
||||||
<div
|
<div
|
||||||
class="mb-2 rounded bg-red-50 px-2 py-1.5 text-xs text-red-700 border border-red-200"
|
class="mb-2 rounded border border-red-200 bg-red-50 px-2 py-1.5 text-xs text-red-700"
|
||||||
>
|
>
|
||||||
{sendError}
|
{sendError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<textarea
|
<MentionAutocomplete
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
bind:value={inputValue}
|
bind:value={inputValue}
|
||||||
onkeydown={onKeydown}
|
onkeydown={onKeydown}
|
||||||
rows="1"
|
rows={1}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
placeholder={auth.user ? "Message..." : "Login to send messages"}
|
placeholder={auth.user ? "Message..." : "Login to send messages"}
|
||||||
class="w-full resize-none rounded border border-neutral-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50"
|
{contextPubkeys}
|
||||||
></textarea>
|
textareaClass="w-full resize-none rounded border border-neutral-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
401
src/lib/components/MentionAutocomplete.svelte
Normal file
401
src/lib/components/MentionAutocomplete.svelte
Normal file
|
|
@ -0,0 +1,401 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import { loadRelayList } from "@nostr/gadgets/lists";
|
||||||
|
import {
|
||||||
|
profileStore,
|
||||||
|
searchLocalProfiles,
|
||||||
|
searchRemoteProfiles,
|
||||||
|
type ProfileEntry,
|
||||||
|
} from "$lib/profiles.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
contextPubkeys?: string[];
|
||||||
|
textareaClass?: string;
|
||||||
|
onkeydown?: (e: KeyboardEvent) => void;
|
||||||
|
onfocus?: () => void;
|
||||||
|
onblur?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(),
|
||||||
|
disabled = false,
|
||||||
|
rows = 4,
|
||||||
|
placeholder = "",
|
||||||
|
contextPubkeys = [],
|
||||||
|
textareaClass = "",
|
||||||
|
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
|
||||||
|
{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 bg-white 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"
|
||||||
|
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 px-3 py-1.5 text-xs text-neutral-400"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-3 w-3 animate-spin rounded-full border border-neutral-300 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">
|
||||||
|
{#if remoteSearching}
|
||||||
|
<span
|
||||||
|
class="inline-block h-3 w-3 animate-spin rounded-full border border-neutral-300 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 text-xs text-neutral-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{profileLabel(entry)[0]?.toUpperCase() ?? "?"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="truncate font-medium text-neutral-700">
|
||||||
|
{profileLabel(entry)}
|
||||||
|
</span>
|
||||||
|
{#if profileSubLabel(entry)}
|
||||||
|
<span class="truncate text-xs text-neutral-400">
|
||||||
|
{profileSubLabel(entry)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -1,16 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
|
||||||
import { loadRelayList } from "@nostr/gadgets/lists";
|
|
||||||
import { uploadImage } from "$lib/blossom";
|
import { uploadImage } from "$lib/blossom";
|
||||||
import { BLOSSOM_URL } from "$lib/config";
|
import { BLOSSOM_URL } from "$lib/config";
|
||||||
import PostContent from "$lib/components/PostContent.svelte";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
import {
|
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||||
profileStore,
|
|
||||||
searchLocalProfiles,
|
|
||||||
searchRemoteProfiles,
|
|
||||||
type ProfileEntry,
|
|
||||||
} from "$lib/profiles.svelte";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -34,94 +27,13 @@
|
||||||
threadEventAuthors = {},
|
threadEventAuthors = {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
let editorEl = $state<MentionAutocomplete | null>(null);
|
||||||
let fileInputEl = $state<HTMLInputElement | null>(null);
|
let fileInputEl = $state<HTMLInputElement | null>(null);
|
||||||
let uploadError = $state<string | null>(null);
|
let uploadError = $state<string | null>(null);
|
||||||
let previewing = $state(false);
|
let previewing = $state(false);
|
||||||
|
|
||||||
// Mention autocomplete state
|
|
||||||
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;
|
|
||||||
let listboxEl = $state<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const contextSet = $derived(new Set(contextPubkeys));
|
|
||||||
|
|
||||||
const mentionLocalResults = $derived.by(() => {
|
|
||||||
if (mentionQuery === null) return [];
|
|
||||||
if (mentionQuery === "") {
|
|
||||||
// Bare "@": only thread participants we know about, no global cache spill.
|
|
||||||
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 } = {}) {
|
export function focus(opts: { caretAtEnd?: boolean } = {}) {
|
||||||
const ta = textareaEl;
|
editorEl?.focus(opts);
|
||||||
if (!ta) return;
|
|
||||||
ta.focus();
|
|
||||||
if (opts.caretAtEnd) {
|
|
||||||
const pos = ta.value.length;
|
|
||||||
ta.setSelectionRange(pos, pos);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePreview() {
|
function togglePreview() {
|
||||||
|
|
@ -140,7 +52,7 @@
|
||||||
uploading = true;
|
uploading = true;
|
||||||
try {
|
try {
|
||||||
const blob = await uploadImage(file);
|
const blob = await uploadImage(file);
|
||||||
const ta = textareaEl;
|
const ta = editorEl?.getTextarea();
|
||||||
if (ta) {
|
if (ta) {
|
||||||
const start = ta.selectionStart;
|
const start = ta.selectionStart;
|
||||||
const end = ta.selectionEnd;
|
const end = ta.selectionEnd;
|
||||||
|
|
@ -163,176 +75,12 @@
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Fetch the user's kind:10002 to embed write-relay hints in the nprofile.
|
|
||||||
// Cached by gadgets, so repeat selections are instant.
|
|
||||||
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) return;
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
closeMention();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mergedResults.length === 0) return;
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
userMovedCursor = true;
|
|
||||||
mentionIndex = (safeMentionIndex + 1) % mergedResults.length;
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
userMovedCursor = true;
|
|
||||||
mentionIndex =
|
|
||||||
(safeMentionIndex - 1 + mergedResults.length) % mergedResults.length;
|
|
||||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
const entry = mergedResults[safeMentionIndex];
|
|
||||||
if (entry) selectMention(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTextareaClickOrSelect() {
|
|
||||||
updateMentionFromTextarea();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTextareaBlur() {
|
|
||||||
setTimeout(() => closeMention(), 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col {previewing ? 'flex-1 min-h-0' : ''}">
|
<div class="flex flex-col {previewing ? 'min-h-0 flex-1' : ''}">
|
||||||
{#if previewing}
|
{#if previewing}
|
||||||
<div
|
<div
|
||||||
class="w-full rounded-t border border-neutral-200 px-3 py-2 overflow-auto flex-1 min-h-0 max-h-[70vh] {minHeightClass}"
|
class="max-h-[70vh] min-h-0 w-full flex-1 overflow-auto rounded-t border border-neutral-200 px-3 py-2 {minHeightClass}"
|
||||||
aria-label="Preview"
|
aria-label="Preview"
|
||||||
>
|
>
|
||||||
{#if value.trim()}
|
{#if value.trim()}
|
||||||
|
|
@ -342,113 +90,15 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="relative">
|
<MentionAutocomplete
|
||||||
<textarea
|
bind:this={editorEl}
|
||||||
bind:this={textareaEl}
|
|
||||||
bind:value
|
bind:value
|
||||||
{disabled}
|
{disabled}
|
||||||
{rows}
|
{rows}
|
||||||
{placeholder}
|
{placeholder}
|
||||||
oninput={onTextareaInput}
|
{contextPubkeys}
|
||||||
onkeydown={onTextareaKeydown}
|
textareaClass="block w-full rounded-t border border-neutral-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50 resize-y {minHeightClass}"
|
||||||
onclick={onTextareaClickOrSelect}
|
|
||||||
onkeyup={onTextareaClickOrSelect}
|
|
||||||
onfocus={onTextareaFocus}
|
|
||||||
onblur={onTextareaBlur}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls={mentionOpen ? "mention-listbox" : undefined}
|
|
||||||
class="block w-full rounded-t border border-neutral-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50 resize-y {minHeightClass}"
|
|
||||||
></textarea>
|
|
||||||
{#if mentionOpen}
|
|
||||||
<div
|
|
||||||
id="mention-listbox"
|
|
||||||
bind:this={listboxEl}
|
|
||||||
class="absolute z-30 left-0 right-0 max-h-72 overflow-auto rounded border border-neutral-200 bg-white 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"
|
|
||||||
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="px-3 py-1.5 text-xs text-neutral-400 flex items-center gap-2 border-b border-neutral-100"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="h-3 w-3 inline-block rounded-full border border-neutral-300 border-t-neutral-600 animate-spin"
|
|
||||||
aria-hidden="true"
|
|
||||||
></span>
|
|
||||||
<span>Searching…</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if mergedResults.length === 0 && mentionQuery !== ""}
|
|
||||||
<div
|
|
||||||
class="px-3 py-2 text-xs text-neutral-400 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{#if remoteSearching}
|
|
||||||
<span
|
|
||||||
class="h-3 w-3 inline-block rounded-full border border-neutral-300 border-t-neutral-600 animate-spin"
|
|
||||||
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 items-center gap-2 px-3 py-1.5 cursor-pointer 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 rounded-full object-cover flex-shrink-0"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
class="h-6 w-6 rounded-full bg-neutral-200 flex items-center justify-center text-xs text-neutral-500 flex-shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{profileLabel(entry)[0]?.toUpperCase() ?? "?"}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<span class="font-medium text-neutral-700 truncate">
|
|
||||||
{profileLabel(entry)}
|
|
||||||
</span>
|
|
||||||
{#if profileSubLabel(entry)}
|
|
||||||
<span class="text-xs text-neutral-400 truncate">
|
|
||||||
{profileSubLabel(entry)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="flex flex-shrink-0 items-center gap-4 rounded-b border border-t-0 border-neutral-200 bg-neutral-50 px-3 py-2 text-sm"
|
class="flex flex-shrink-0 items-center gap-4 rounded-b border border-t-0 border-neutral-200 bg-neutral-50 px-3 py-2 text-sm"
|
||||||
|
|
@ -458,7 +108,7 @@
|
||||||
onclick={onUploadClick}
|
onclick={onUploadClick}
|
||||||
disabled={uploading || disabled || previewing || !BLOSSOM_URL}
|
disabled={uploading || disabled || previewing || !BLOSSOM_URL}
|
||||||
title={!BLOSSOM_URL ? "Blossom server not configured" : ""}
|
title={!BLOSSOM_URL ? "Blossom server not configured" : ""}
|
||||||
class="inline-flex items-center gap-1.5 text-neutral-600 hover:text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="inline-flex items-center gap-1.5 text-neutral-600 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
|
|
@ -485,7 +135,7 @@
|
||||||
onclick={togglePreview}
|
onclick={togglePreview}
|
||||||
disabled={disabled || uploading}
|
disabled={disabled || uploading}
|
||||||
aria-pressed={previewing}
|
aria-pressed={previewing}
|
||||||
class="ml-auto inline-flex items-center gap-1.5 text-neutral-600 hover:text-neutral-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="ml-auto inline-flex items-center gap-1.5 text-neutral-600 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue