Normalize forum URLs as nostr events and rendered them as titles
This commit is contained in:
parent
fc51e33ceb
commit
2dc823d055
9 changed files with 262 additions and 57 deletions
|
|
@ -4,6 +4,7 @@ import { RELAY_URL } 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";
|
import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
|
||||||
|
import { convertForumUrls } from "$lib/linkify";
|
||||||
|
|
||||||
export type ChatMessageData = {
|
export type ChatMessageData = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -131,6 +132,8 @@ export async function sendChatMessage(
|
||||||
if (!currentGroup) throw new Error("No room selected");
|
if (!currentGroup) throw new Error("No room selected");
|
||||||
const ownPubkey = await auth.signer.getPublicKey();
|
const ownPubkey = await auth.signer.getPublicKey();
|
||||||
|
|
||||||
|
content = convertForumUrls(content);
|
||||||
|
|
||||||
const previousRefs = messages
|
const previousRefs = messages
|
||||||
.filter((m) => m.pubkey !== ownPubkey)
|
.filter((m) => m.pubkey !== ownPubkey)
|
||||||
.slice(-3)
|
.slice(-3)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import { tokenizeChat } from "$lib/linkify";
|
import { tokenizeChat } from "$lib/linkify";
|
||||||
|
import {
|
||||||
|
resolveThreadRef,
|
||||||
|
threadRefHref,
|
||||||
|
type ThreadRef,
|
||||||
|
} from "$lib/threadRefs";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -12,14 +17,21 @@
|
||||||
const tokens = $derived(tokenizeChat(content));
|
const tokens = $derived(tokenizeChat(content));
|
||||||
|
|
||||||
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
||||||
|
let resolvedThreads = $state<Record<string, ThreadRef>>({});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const t of tokens) {
|
for (const t of tokens) {
|
||||||
if (t.type !== "mention") continue;
|
if (t.type === "mention") {
|
||||||
if (resolvedUsers[t.pubkey] || profiles[t.pubkey]) continue;
|
if (resolvedUsers[t.pubkey] || profiles[t.pubkey]) continue;
|
||||||
loadNostrUser(t.pubkey).then((u) => {
|
loadNostrUser(t.pubkey).then((u) => {
|
||||||
resolvedUsers = { ...resolvedUsers, [t.pubkey]: u };
|
resolvedUsers = { ...resolvedUsers, [t.pubkey]: u };
|
||||||
});
|
});
|
||||||
|
} else if (t.type === "entity" && t.id && !resolvedThreads[t.id]) {
|
||||||
|
const id = t.id;
|
||||||
|
resolveThreadRef(id).then((ref) => {
|
||||||
|
if (ref) resolvedThreads = { ...resolvedThreads, [id]: ref };
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -31,12 +43,17 @@
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-brand hover:underline">@{u?.shortName ?? t.fallback}</a
|
class="text-brand hover:underline">@{u?.shortName ?? t.fallback}</a
|
||||||
>{:else if t.type === "entity"}<a
|
>{:else if t.type === "entity"}{@const ref = t.id
|
||||||
|
? resolvedThreads[t.id]
|
||||||
|
: undefined}{#if ref}<a
|
||||||
|
href={threadRefHref(ref)}
|
||||||
|
class="text-brand hover:underline">{ref.title}</a
|
||||||
|
>{:else}<a
|
||||||
href={t.href}
|
href={t.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-brand hover:underline">{t.label}</a
|
class="text-brand hover:underline">{t.label}</a
|
||||||
>{:else if t.type === "link"}<a
|
>{/if}{:else if t.type === "link"}<a
|
||||||
href={t.href}
|
href={t.href}
|
||||||
title={t.href}
|
title={t.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||||
import ChatContent from "$lib/components/ChatContent.svelte";
|
import ChatContent from "$lib/components/ChatContent.svelte";
|
||||||
|
import { shortNostrEntity } from "$lib/linkify";
|
||||||
|
import { resolveThreadRef } from "$lib/threadRefs";
|
||||||
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
|
|
@ -87,9 +90,43 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ENTITY_PATTERN = "nostr:(note1[a-z0-9]+|nevent1[a-z0-9]+)";
|
||||||
|
|
||||||
|
// Resolved thread titles, keyed by the bech32 entity (note1…/nevent1…).
|
||||||
|
let resolvedTitles = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const re = new RegExp(ENTITY_PATTERN, "gi");
|
||||||
|
const entities = new Set<string>();
|
||||||
|
for (const m of messages)
|
||||||
|
for (const match of m.content.matchAll(re))
|
||||||
|
entities.add(match[1].toLowerCase());
|
||||||
|
for (const entity of entities) {
|
||||||
|
if (resolvedTitles[entity]) continue;
|
||||||
|
let id: string | null = null;
|
||||||
|
try {
|
||||||
|
const d = nip19.decode(entity);
|
||||||
|
if (d.type === "note") id = d.data;
|
||||||
|
else if (d.type === "nevent") id = d.data.id;
|
||||||
|
} catch {
|
||||||
|
// Invalid bech32, skip
|
||||||
|
}
|
||||||
|
if (!id) continue;
|
||||||
|
resolveThreadRef(id).then((ref) => {
|
||||||
|
if (ref) resolvedTitles = { ...resolvedTitles, [entity]: ref.title };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function truncate(s: string, n = 60) {
|
function truncate(s: string, n = 60) {
|
||||||
// Collapse mentions to @… so the preview stays readable.
|
// Collapse mentions to @… and show thread refs as their title.
|
||||||
const stripped = s.replace(/nostr:(?:npub1|nprofile1)[a-z0-9]+/gi, "@…");
|
const stripped = s
|
||||||
|
.replace(/nostr:(?:npub1|nprofile1)[a-z0-9]+/gi, "@…")
|
||||||
|
.replace(
|
||||||
|
new RegExp(ENTITY_PATTERN, "gi"),
|
||||||
|
(_m, entity) =>
|
||||||
|
resolvedTitles[entity.toLowerCase()] ?? shortNostrEntity(entity),
|
||||||
|
);
|
||||||
const t = stripped.replace(/\s+/g, " ").trim();
|
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;
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +227,7 @@
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
bind:this={asideEl}
|
bind:this={asideEl}
|
||||||
class="flex-1 flex-col bg-white dark:bg-neutral-900 px-4 pt-4 pb-20 min-[1540px]:rounded-tr-xl md:absolute md:top-6 md:right-0 md:z-10 md:h-[calc(100%-1.5rem)] md:flex-none md:rounded-tl-xl md:px-6 md:py-6 md:transition-all md:duration-200
|
class="flex-1 flex-col bg-white px-4 pt-4 pb-20 min-[1540px]:rounded-tr-xl md:absolute md:top-6 md:right-0 md:z-10 md:h-[calc(100%-1.5rem)] md:flex-none md:rounded-tl-xl md:px-6 md:py-6 md:transition-all md:duration-200 dark:bg-neutral-900
|
||||||
{mobileActive ? 'flex' : 'hidden'} md:flex
|
{mobileActive ? 'flex' : 'hidden'} md:flex
|
||||||
{expanded ? 'md:w-150 md:shadow-2xl' : 'md:w-80 md:shadow-lg'}"
|
{expanded ? 'md:w-150 md:shadow-2xl' : 'md:w-80 md:shadow-lg'}"
|
||||||
>
|
>
|
||||||
|
|
@ -198,7 +235,7 @@
|
||||||
<span class="text-brand text-[1.5rem] leading-7">Chat</span>
|
<span class="text-brand text-[1.5rem] leading-7">Chat</span>
|
||||||
<button
|
<button
|
||||||
onclick={onToggle}
|
onclick={onToggle}
|
||||||
class="hidden rounded bg-neutral-100 dark:bg-neutral-800 transition-colors hover:bg-neutral-200 dark:hover:bg-neutral-700 md:block"
|
class="hidden rounded bg-neutral-100 transition-colors hover:bg-neutral-200 md:block dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||||
aria-label={expanded ? "Collapse chat" : "Expand chat"}
|
aria-label={expanded ? "Collapse chat" : "Expand chat"}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -228,7 +265,9 @@
|
||||||
class="no-scrollbar -mr-6 flex flex-1 flex-col overflow-y-auto pr-6"
|
class="no-scrollbar -mr-6 flex flex-1 flex-col overflow-y-auto pr-6"
|
||||||
>
|
>
|
||||||
{#if messages.length === 0}
|
{#if messages.length === 0}
|
||||||
<div class="m-auto py-8 text-center text-sm text-neutral-400 dark:text-neutral-500">
|
<div
|
||||||
|
class="m-auto py-8 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||||
|
>
|
||||||
No messages yet.
|
No messages yet.
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -247,17 +286,19 @@
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-700 text-xs font-semibold text-neutral-500 dark:text-neutral-400"
|
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-neutral-200 text-xs font-semibold text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
|
||||||
>
|
>
|
||||||
{author.name[0].toUpperCase()}
|
{author.name[0].toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="font-medium text-neutral-500 dark:text-neutral-400">{author.name}</span>
|
<span class="font-medium text-neutral-500 dark:text-neutral-400"
|
||||||
|
>{author.name}</span
|
||||||
|
>
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
onclick={(e) => toggleMenu(msg.id, e)}
|
onclick={(e) => toggleMenu(msg.id, e)}
|
||||||
class="flex items-center justify-center rounded p-0.5 text-neutral-300 dark:text-neutral-600 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-500 dark:hover:text-neutral-400"
|
class="flex items-center justify-center rounded p-0.5 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-500 dark:text-neutral-600 dark:hover:bg-neutral-800 dark:hover:text-neutral-400"
|
||||||
aria-label="Message actions"
|
aria-label="Message actions"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={openMenuId === msg.id}
|
aria-expanded={openMenuId === msg.id}
|
||||||
|
|
@ -277,7 +318,7 @@
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
style={`${menuPos.top !== undefined ? `top:${menuPos.top}px` : `bottom:${menuPos.bottom}px`};right:${menuPos.right}px`}
|
style={`${menuPos.top !== undefined ? `top:${menuPos.top}px` : `bottom:${menuPos.bottom}px`};right:${menuPos.right}px`}
|
||||||
class="fixed z-50 w-36 rounded-lg border border-neutral-100 dark:border-neutral-800 bg-white dark:bg-neutral-900 py-1 text-sm shadow-lg"
|
class="fixed z-50 w-36 rounded-lg border border-neutral-100 bg-white py-1 text-sm shadow-lg dark:border-neutral-800 dark:bg-neutral-900"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|
@ -310,7 +351,7 @@
|
||||||
<div class="mt-0.5">
|
<div class="mt-0.5">
|
||||||
{#if msg.replyToId}
|
{#if msg.replyToId}
|
||||||
<div
|
<div
|
||||||
class="mb-1 border-l-2 border-neutral-300 dark:border-neutral-600 pl-2 text-xs text-neutral-500 dark:text-neutral-400"
|
class="mb-1 border-l-2 border-neutral-300 pl-2 text-xs text-neutral-500 dark:border-neutral-600 dark:text-neutral-400"
|
||||||
>
|
>
|
||||||
{#if parent}
|
{#if parent}
|
||||||
<span class="font-medium">{parentAuthor?.name}</span>:
|
<span class="font-medium">{parentAuthor?.name}</span>:
|
||||||
|
|
@ -330,14 +371,16 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-neutral-200 dark:border-neutral-700 pt-4">
|
<div class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
|
||||||
{#if replyTarget}
|
{#if replyTarget}
|
||||||
{@const replyAuthor = resolveAuthor(replyTarget.pubkey)}
|
{@const replyAuthor = resolveAuthor(replyTarget.pubkey)}
|
||||||
<div
|
<div
|
||||||
class="mb-2 flex items-start gap-2 rounded bg-neutral-50 dark:bg-neutral-800 px-2 py-1.5 text-xs text-neutral-600 dark:text-neutral-400"
|
class="mb-2 flex items-start gap-2 rounded bg-neutral-50 px-2 py-1.5 text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<span class="text-neutral-400 dark:text-neutral-500">↳ Reply to </span>
|
<span class="text-neutral-400 dark:text-neutral-500"
|
||||||
|
>↳ Reply to
|
||||||
|
</span>
|
||||||
<span class="font-medium">{replyAuthor.name}</span>:
|
<span class="font-medium">{replyAuthor.name}</span>:
|
||||||
<span class="text-neutral-500 dark:text-neutral-400"
|
<span class="text-neutral-500 dark:text-neutral-400"
|
||||||
>{truncate(replyTarget.content, 80)}</span
|
>{truncate(replyTarget.content, 80)}</span
|
||||||
|
|
@ -345,7 +388,7 @@
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={cancelReply}
|
onclick={cancelReply}
|
||||||
class="shrink-0 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-400"
|
class="shrink-0 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-400"
|
||||||
aria-label="Cancel reply"
|
aria-label="Cancel reply"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
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 MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||||
|
import { convertForumUrls } from "$lib/linkify";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -32,6 +33,9 @@
|
||||||
let uploadError = $state<string | null>(null);
|
let uploadError = $state<string | null>(null);
|
||||||
let previewing = $state(false);
|
let previewing = $state(false);
|
||||||
|
|
||||||
|
// Mirror the publish transform so the preview shows resolved thread titles.
|
||||||
|
const previewContent = $derived(convertForumUrls(value));
|
||||||
|
|
||||||
export function focus(opts: { caretAtEnd?: boolean } = {}) {
|
export function focus(opts: { caretAtEnd?: boolean } = {}) {
|
||||||
editorEl?.focus(opts);
|
editorEl?.focus(opts);
|
||||||
}
|
}
|
||||||
|
|
@ -80,13 +84,15 @@
|
||||||
<div class="flex flex-col {previewing ? 'min-h-0 flex-1' : ''}">
|
<div class="flex flex-col {previewing ? 'min-h-0 flex-1' : ''}">
|
||||||
{#if previewing}
|
{#if previewing}
|
||||||
<div
|
<div
|
||||||
class="max-h-[70vh] min-h-0 w-full flex-1 overflow-auto rounded-t border border-neutral-200 dark:border-neutral-700 px-3 py-2 {minHeightClass}"
|
class="max-h-[70vh] min-h-0 w-full flex-1 overflow-auto rounded-t border border-neutral-200 px-3 py-2 dark:border-neutral-700 {minHeightClass}"
|
||||||
aria-label="Preview"
|
aria-label="Preview"
|
||||||
>
|
>
|
||||||
{#if value.trim()}
|
{#if value.trim()}
|
||||||
<PostContent content={value} {threadEventAuthors} />
|
<PostContent content={previewContent} {threadEventAuthors} />
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-neutral-400 dark:text-neutral-500 italic">Nothing to preview</p>
|
<p class="text-neutral-400 italic dark:text-neutral-500">
|
||||||
|
Nothing to preview
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -101,14 +107,14 @@
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="flex flex-shrink-0 items-center gap-4 rounded-b border border-t-0 border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 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 dark:border-neutral-700 dark:bg-neutral-800"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
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 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 disabled:cursor-not-allowed disabled:opacity-50"
|
class="inline-flex items-center gap-1.5 text-neutral-600 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
|
|
@ -135,7 +141,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 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 disabled:cursor-not-allowed disabled:opacity-50"
|
class="ml-auto inline-flex items-center gap-1.5 text-neutral-600 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import {
|
||||||
|
resolveThreadRef,
|
||||||
|
threadRefHref,
|
||||||
|
type ThreadRef,
|
||||||
|
} from "$lib/threadRefs";
|
||||||
import { parse } from "@djot/djot";
|
import { parse } from "@djot/djot";
|
||||||
import type {
|
import type {
|
||||||
Block as DjBlock,
|
Block as DjBlock,
|
||||||
|
|
@ -147,7 +152,7 @@
|
||||||
| { type: "text"; value: string }
|
| { type: "text"; value: string }
|
||||||
| { type: "link"; href: string; label: string }
|
| { type: "link"; href: string; label: string }
|
||||||
| { type: "mention"; pubkey: string; entity: string; fallback: string }
|
| { type: "mention"; pubkey: string; entity: string; fallback: string }
|
||||||
| { type: "entity"; entity: string; label: string }
|
| { type: "entity"; entity: string; label: string; id?: string }
|
||||||
| { type: "thread-quote"; pubkey: string; eventId: string }
|
| { type: "thread-quote"; pubkey: string; eventId: string }
|
||||||
| { type: "strong"; children: Inline[] }
|
| { type: "strong"; children: Inline[] }
|
||||||
| { type: "em"; children: Inline[] }
|
| { type: "em"; children: Inline[] }
|
||||||
|
|
@ -201,14 +206,14 @@
|
||||||
const author = threadEventAuthors[id];
|
const author = threadEventAuthors[id];
|
||||||
if (author)
|
if (author)
|
||||||
return { type: "thread-quote", pubkey: author, eventId: id };
|
return { type: "thread-quote", pubkey: author, eventId: id };
|
||||||
return { type: "entity", entity, label: shortEntity(entity) };
|
return { type: "entity", entity, label: shortEntity(entity), id };
|
||||||
}
|
}
|
||||||
if (decoded.type === "nevent") {
|
if (decoded.type === "nevent") {
|
||||||
const id = decoded.data.id;
|
const id = decoded.data.id;
|
||||||
const author = threadEventAuthors[id];
|
const author = threadEventAuthors[id];
|
||||||
if (author)
|
if (author)
|
||||||
return { type: "thread-quote", pubkey: author, eventId: id };
|
return { type: "thread-quote", pubkey: author, eventId: id };
|
||||||
return { type: "entity", entity, label: shortEntity(entity) };
|
return { type: "entity", entity, label: shortEntity(entity), id };
|
||||||
}
|
}
|
||||||
if (decoded.type === "naddr") {
|
if (decoded.type === "naddr") {
|
||||||
return { type: "entity", entity, label: shortEntity(entity) };
|
return { type: "entity", entity, label: shortEntity(entity) };
|
||||||
|
|
@ -548,13 +553,16 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
||||||
|
let resolvedThreads = $state<Record<string, ThreadRef>>({});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
const seenRefs = new Set<string>();
|
||||||
const collect = (inlines: Inline[]) => {
|
const collect = (inlines: Inline[]) => {
|
||||||
for (const inline of inlines) {
|
for (const inline of inlines) {
|
||||||
if (inline.type === "mention" || inline.type === "thread-quote")
|
if (inline.type === "mention" || inline.type === "thread-quote")
|
||||||
seen.add(inline.pubkey);
|
seen.add(inline.pubkey);
|
||||||
|
else if (inline.type === "entity" && inline.id) seenRefs.add(inline.id);
|
||||||
else if (
|
else if (
|
||||||
inline.type === "strong" ||
|
inline.type === "strong" ||
|
||||||
inline.type === "em" ||
|
inline.type === "em" ||
|
||||||
|
|
@ -582,6 +590,12 @@
|
||||||
resolvedUsers = { ...resolvedUsers, [pubkey]: u };
|
resolvedUsers = { ...resolvedUsers, [pubkey]: u };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const id of seenRefs) {
|
||||||
|
if (resolvedThreads[id]) continue;
|
||||||
|
resolveThreadRef(id).then((ref) => {
|
||||||
|
if (ref) resolvedThreads = { ...resolvedThreads, [id]: ref };
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -607,7 +621,7 @@
|
||||||
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
{@const u = profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||||
<a
|
<a
|
||||||
href="#post-{inline.eventId}"
|
href="#post-{inline.eventId}"
|
||||||
class="-ml-3 block bg-neutral-100 dark:bg-neutral-800 py-1 pl-3 leading-4 font-normal text-neutral-700 dark:text-neutral-300 no-underline hover:bg-neutral-200 dark:hover:bg-neutral-700"
|
class="-ml-3 block bg-neutral-100 py-1 pl-3 leading-4 font-normal text-neutral-700 no-underline hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||||
>{u?.shortName ?? inline.pubkey.slice(0, 8)} said
|
>{u?.shortName ?? inline.pubkey.slice(0, 8)} said
|
||||||
<svg
|
<svg
|
||||||
class="mb-0.5 inline w-3"
|
class="mb-0.5 inline w-3"
|
||||||
|
|
@ -624,12 +638,19 @@
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
{:else if inline.type === "entity"}
|
{:else if inline.type === "entity"}
|
||||||
|
{@const ref = inline.id ? resolvedThreads[inline.id] : undefined}
|
||||||
|
{#if ref}
|
||||||
|
<a href={threadRefHref(ref)} class="text-brand hover:underline"
|
||||||
|
>{ref.title}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
<a
|
<a
|
||||||
href="https://njump.me/{inline.entity}"
|
href="https://njump.me/{inline.entity}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-brand break-all hover:underline">{inline.label}</a
|
class="text-brand break-all hover:underline">{inline.label}</a
|
||||||
>
|
>
|
||||||
|
{/if}
|
||||||
{:else if inline.type === "strong"}
|
{:else if inline.type === "strong"}
|
||||||
<strong>{@render renderInlines(inline.children)}</strong>
|
<strong>{@render renderInlines(inline.children)}</strong>
|
||||||
{:else if inline.type === "em"}
|
{:else if inline.type === "em"}
|
||||||
|
|
@ -683,7 +704,7 @@
|
||||||
/>
|
/>
|
||||||
{:else if block.type === "blockquote"}
|
{:else if block.type === "blockquote"}
|
||||||
<blockquote
|
<blockquote
|
||||||
class="my-3 mb-3 border-l-3 border-neutral-200 dark:border-neutral-700 pb-1 pl-3 text-neutral-500 dark:text-neutral-400"
|
class="my-3 mb-3 border-l-3 border-neutral-200 pb-1 pl-3 text-neutral-500 dark:border-neutral-700 dark:text-neutral-400"
|
||||||
>
|
>
|
||||||
{@render renderBlocks(block.blocks)}
|
{@render renderBlocks(block.blocks)}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { withJoin } from "$lib/join.svelte";
|
||||||
import { activeGroup } from "$lib/active.svelte";
|
import { activeGroup } from "$lib/active.svelte";
|
||||||
import { RELAY_URL } from "$lib/config";
|
import { RELAY_URL } from "$lib/config";
|
||||||
import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
|
import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
|
||||||
|
import { convertForumUrls } from "$lib/linkify";
|
||||||
|
|
||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let iconized = $state(false);
|
let iconized = $state(false);
|
||||||
|
|
@ -98,11 +99,11 @@ export async function publishDraft(): Promise<{
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
}
|
}
|
||||||
const t = title.trim();
|
const t = title.trim();
|
||||||
const c = content.trim();
|
if (!t || !content.trim()) {
|
||||||
if (!t || !c) {
|
|
||||||
publishError = "Title and content are required";
|
publishError = "Title and content are required";
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
}
|
}
|
||||||
|
const c = convertForumUrls(content.trim());
|
||||||
const groupId = activeGroup.id;
|
const groupId = activeGroup.id;
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
publishError = "No room selected";
|
publishError = "No room selected";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
import { RELAY_URL } from "./config";
|
||||||
|
|
||||||
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
|
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
|
||||||
const TLDS = [
|
const TLDS = [
|
||||||
|
|
@ -107,7 +108,14 @@ export type ChatToken =
|
||||||
| { type: "text"; value: string }
|
| { type: "text"; value: string }
|
||||||
| { type: "link"; href: string; label: string }
|
| { type: "link"; href: string; label: string }
|
||||||
| { type: "mention"; pubkey: string; entity: string; fallback: string }
|
| { type: "mention"; pubkey: string; entity: string; fallback: string }
|
||||||
| { type: "entity"; entity: string; href: string; label: string };
|
| {
|
||||||
|
type: "entity";
|
||||||
|
entity: string;
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
// Underlying event id for note/nevent — used to resolve a forum thread.
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Shorten a nostr entity to xxxxxxxx...xxxx
|
// Shorten a nostr entity to xxxxxxxx...xxxx
|
||||||
export function shortNostrEntity(entity: string): string {
|
export function shortNostrEntity(entity: string): string {
|
||||||
|
|
@ -145,7 +153,15 @@ function decodeEntity(entity: string): ChatToken | null {
|
||||||
entity,
|
entity,
|
||||||
fallback: shortNostrEntity(entity),
|
fallback: shortNostrEntity(entity),
|
||||||
};
|
};
|
||||||
if (decoded.type === "note" || decoded.type === "nevent" || decoded.type === "naddr")
|
if (decoded.type === "note" || decoded.type === "nevent")
|
||||||
|
return {
|
||||||
|
type: "entity",
|
||||||
|
entity,
|
||||||
|
href: `https://njump.me/${entity}`,
|
||||||
|
label: shortNostrEntity(entity),
|
||||||
|
id: decoded.type === "note" ? decoded.data : decoded.data.id,
|
||||||
|
};
|
||||||
|
if (decoded.type === "naddr")
|
||||||
return {
|
return {
|
||||||
type: "entity",
|
type: "entity",
|
||||||
entity,
|
entity,
|
||||||
|
|
@ -193,3 +209,29 @@ export function tokenizeChat(content: string): ChatToken[] {
|
||||||
out.push({ type: "text", value: content.slice(last) });
|
out.push({ type: "text", value: content.slice(last) });
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite same-origin forum thread URLs into nostr entities before publishing,
|
||||||
|
// so the reference is portable to other Nostr clients. A `#post-<id>` fragment
|
||||||
|
// targets the reply (kind 1111); otherwise the thread itself (kind 11). The
|
||||||
|
// author is omitted; the parent is recovered from the reply's own tags on read.
|
||||||
|
export function convertForumUrls(content: string): string {
|
||||||
|
if (typeof window === "undefined") return content;
|
||||||
|
const re = new RegExp(
|
||||||
|
`${escapeRegExp(window.location.origin)}/thread/([0-9a-f]{64})\\/?(?:#post-([0-9a-f]{64}))?`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
return content.replace(re, (whole, threadId, replyId) => {
|
||||||
|
try {
|
||||||
|
const nevent = replyId
|
||||||
|
? nip19.neventEncode({ id: replyId, kind: 1111, relays: [RELAY_URL] })
|
||||||
|
: nip19.neventEncode({ id: threadId, kind: 11, relays: [RELAY_URL] });
|
||||||
|
return `nostr:${nevent}`;
|
||||||
|
} catch {
|
||||||
|
return whole;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
extractQuotedEvents,
|
extractQuotedEvents,
|
||||||
buildPTagHints,
|
buildPTagHints,
|
||||||
} from "$lib/mentions";
|
} from "$lib/mentions";
|
||||||
|
import { convertForumUrls } from "$lib/linkify";
|
||||||
|
|
||||||
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
|
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
|
||||||
|
|
||||||
|
|
@ -141,6 +142,8 @@ export async function sendReply(content: string, ownPubkey: string) {
|
||||||
if (!detail) throw new Error("No thread loaded");
|
if (!detail) throw new Error("No thread loaded");
|
||||||
if (!auth.signer) throw new Error("Not logged in");
|
if (!auth.signer) throw new Error("Not logged in");
|
||||||
|
|
||||||
|
content = convertForumUrls(content);
|
||||||
|
|
||||||
// Use last 3 events not authored by us as previous refs (NIP-29)
|
// Use last 3 events not authored by us as previous refs (NIP-29)
|
||||||
const previousRefs = [...detail.replies, detail.op]
|
const previousRefs = [...detail.replies, detail.op]
|
||||||
.filter((p) => p.pubkey !== ownPubkey)
|
.filter((p) => p.pubkey !== ownPubkey)
|
||||||
|
|
|
||||||
69
src/lib/threadRefs.ts
Normal file
69
src/lib/threadRefs.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { SimplePool } from "@nostr/tools";
|
||||||
|
import { RELAY_URL } from "./config";
|
||||||
|
|
||||||
|
// A note/nevent that resolves to a forum thread (kind 11) or reply (kind 1111).
|
||||||
|
export type ThreadRef = {
|
||||||
|
threadId: string;
|
||||||
|
replyId?: string;
|
||||||
|
title: string;
|
||||||
|
pubkey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cache = new Map<string, Promise<ThreadRef | null>>();
|
||||||
|
|
||||||
|
function titleOf(tags: string[][]): string {
|
||||||
|
return tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doResolve(id: string): Promise<ThreadRef | null> {
|
||||||
|
const pool = new SimplePool();
|
||||||
|
try {
|
||||||
|
const events = await pool.querySync([RELAY_URL], {
|
||||||
|
ids: [id],
|
||||||
|
kinds: [11, 1111],
|
||||||
|
});
|
||||||
|
const ev = events[0];
|
||||||
|
if (!ev) return null;
|
||||||
|
|
||||||
|
if (ev.kind === 11)
|
||||||
|
return { threadId: ev.id, title: titleOf(ev.tags), pubkey: ev.pubkey };
|
||||||
|
|
||||||
|
// Reply: recover the root thread from its uppercase root tag.
|
||||||
|
const root = ev.tags.find((t) => t[0] === "E");
|
||||||
|
const rootId = root?.[1];
|
||||||
|
if (!rootId) return null;
|
||||||
|
const threads = await pool.querySync([RELAY_URL], {
|
||||||
|
ids: [rootId],
|
||||||
|
kinds: [11],
|
||||||
|
});
|
||||||
|
const thread = threads[0];
|
||||||
|
return {
|
||||||
|
threadId: rootId,
|
||||||
|
replyId: ev.id,
|
||||||
|
title: thread ? titleOf(thread.tags) : "(untitled)",
|
||||||
|
pubkey: thread?.pubkey ?? root?.[3] ?? ev.pubkey,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
pool.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve (and cache) a note/nevent id to a forum thread reference, or null if
|
||||||
|
// it is not a thread/reply reachable on our relay.
|
||||||
|
export function resolveThreadRef(id: string): Promise<ThreadRef | null> {
|
||||||
|
let p = cache.get(id);
|
||||||
|
if (!p) {
|
||||||
|
p = doResolve(id);
|
||||||
|
cache.set(id, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-app URL for a resolved reference.
|
||||||
|
export function threadRefHref(ref: ThreadRef): string {
|
||||||
|
return ref.replyId
|
||||||
|
? `/thread/${ref.threadId}#post-${ref.replyId}`
|
||||||
|
: `/thread/${ref.threadId}`;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue