Add a preview feature to the post editor
This commit is contained in:
parent
e96e99cb36
commit
42ed029dfc
4 changed files with 263 additions and 209 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
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";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -24,11 +25,16 @@
|
||||||
let textareaEl = $state<HTMLTextAreaElement | null>(null);
|
let textareaEl = $state<HTMLTextAreaElement | 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);
|
||||||
|
|
||||||
export function focus() {
|
export function focus() {
|
||||||
textareaEl?.focus();
|
textareaEl?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePreview() {
|
||||||
|
previewing = !previewing;
|
||||||
|
}
|
||||||
|
|
||||||
function onUploadClick() {
|
function onUploadClick() {
|
||||||
fileInputEl?.click();
|
fileInputEl?.click();
|
||||||
}
|
}
|
||||||
|
|
@ -66,22 +72,35 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col {previewing ? 'flex-1 min-h-0' : ''}">
|
||||||
<textarea
|
{#if previewing}
|
||||||
bind:this={textareaEl}
|
<div
|
||||||
bind:value
|
class="w-full rounded-t border border-gray-200 px-3 py-2 overflow-auto flex-1 min-h-0 max-h-[70vh] {minHeightClass}"
|
||||||
{disabled}
|
aria-label="Preview"
|
||||||
{rows}
|
>
|
||||||
{placeholder}
|
{#if value.trim()}
|
||||||
class="w-full rounded-t border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50 resize-none {minHeightClass}"
|
<PostContent content={value} />
|
||||||
></textarea>
|
{:else}
|
||||||
|
<p class="text-gray-400 italic">Nothing to preview</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<textarea
|
||||||
|
bind:this={textareaEl}
|
||||||
|
bind:value
|
||||||
|
{disabled}
|
||||||
|
{rows}
|
||||||
|
{placeholder}
|
||||||
|
class="w-full rounded-t border border-gray-200 px-3 py-2 focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50 resize-none {minHeightClass}"
|
||||||
|
></textarea>
|
||||||
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-4 rounded-b border border-t-0 border-gray-200 bg-gray-50 px-3 py-2 text-sm"
|
class="flex flex-shrink-0 items-center gap-4 rounded-b border border-t-0 border-gray-200 bg-gray-50 px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onUploadClick}
|
onclick={onUploadClick}
|
||||||
disabled={uploading || disabled || !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-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="inline-flex items-center gap-1.5 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|
@ -105,6 +124,29 @@
|
||||||
{#if uploadError}
|
{#if uploadError}
|
||||||
<span class="text-xs text-red-600">{uploadError}</span>
|
<span class="text-xs text-red-600">{uploadError}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={togglePreview}
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
aria-pressed={previewing}
|
||||||
|
class="ml-auto inline-flex items-center gap-1.5 text-gray-600 hover:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
{previewing ? "Edit" : "Preview"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,7 @@
|
||||||
onclick={iconizeDraft}
|
onclick={iconizeDraft}
|
||||||
></button>
|
></button>
|
||||||
<div
|
<div
|
||||||
class="relative w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl flex flex-col gap-4 max-h-[90vh]"
|
class="relative w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl flex flex-col gap-4 max-h-[90vh] overflow-hidden"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="newdisc-title"
|
aria-labelledby="newdisc-title"
|
||||||
|
|
|
||||||
206
src/lib/components/PostContent.svelte
Normal file
206
src/lib/components/PostContent.svelte
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: string;
|
||||||
|
profiles?: Record<string, NostrUser>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { content, profiles = {} }: Props = $props();
|
||||||
|
|
||||||
|
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
|
||||||
|
const TLDS = [
|
||||||
|
"com","org","net","edu","gov","mil","int","info","biz","name","pro",
|
||||||
|
"io","co","app","dev","ai","sh","me","ly","tv","fm","lol","club",
|
||||||
|
"online","site","store","blog","tech","xyz","art","design","news",
|
||||||
|
"media","page","link","fun","gg","gl","st","to","run","life","world",
|
||||||
|
"space","cloud","email","social","chat","wtf","cafe","zone","studio",
|
||||||
|
"us","uk","de","fr","it","es","nl","ru","jp","cn","ca","au","br","in",
|
||||||
|
"mx","se","no","fi","dk","ch","at","be","pl","pt","gr","cz","ie","nz",
|
||||||
|
"kr","sg","hk","tw","za","cc","ws","eu","tr","ua","il","ar","cl","pe",
|
||||||
|
].join("|");
|
||||||
|
|
||||||
|
const URL_RE = new RegExp(
|
||||||
|
`nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+|https?:\\/\\/[^\\s<>"']+|(?<![\\w@.\\/])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+(?:${TLDS})\\b(?:[\\/?#][^\\s<>"']*)?`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
const IMG_EXT_RE = /\.(?:jpg|jpeg|png|gif|webp|avif|svg|bmp)(?:\?.*)?$/i;
|
||||||
|
// Trailing punctuation that is usually not part of the URL
|
||||||
|
const TRAILING_PUNCT_RE = /[).,;:!?'"]+$/;
|
||||||
|
|
||||||
|
function shortEntity(entity: string): string {
|
||||||
|
const m = entity.match(/^(npub|nprofile|note|nevent|naddr)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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Inline =
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "link"; href: string; label: string }
|
||||||
|
| { type: "mention"; pubkey: string; entity: string; fallback: string }
|
||||||
|
| { type: "entity"; entity: string; label: string };
|
||||||
|
|
||||||
|
type Block =
|
||||||
|
| { type: "image"; value: string }
|
||||||
|
| { type: "para"; inlines: Inline[] };
|
||||||
|
|
||||||
|
function tokenize(text: string): Block[] {
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
let inlines: Inline[] = [];
|
||||||
|
const flush = () => {
|
||||||
|
if (
|
||||||
|
inlines.some(
|
||||||
|
(i) =>
|
||||||
|
i.type === "link" ||
|
||||||
|
i.type === "mention" ||
|
||||||
|
i.type === "entity" ||
|
||||||
|
i.value.trim(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
blocks.push({ type: "para", inlines });
|
||||||
|
inlines = [];
|
||||||
|
};
|
||||||
|
let last = 0;
|
||||||
|
for (const m of text.matchAll(URL_RE)) {
|
||||||
|
const start = m.index ?? 0;
|
||||||
|
let url = m[0];
|
||||||
|
const isNostr = /^nostr:/i.test(url);
|
||||||
|
let trailing = "";
|
||||||
|
if (!isNostr) {
|
||||||
|
const trail = url.match(TRAILING_PUNCT_RE);
|
||||||
|
if (trail) {
|
||||||
|
trailing = trail[0];
|
||||||
|
url = url.slice(0, -trailing.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start > last)
|
||||||
|
inlines.push({ type: "text", value: text.slice(last, start) });
|
||||||
|
if (isNostr) {
|
||||||
|
const entity = url.slice(6).toLowerCase();
|
||||||
|
let handled = false;
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(entity);
|
||||||
|
if (decoded.type === "npub") {
|
||||||
|
inlines.push({
|
||||||
|
type: "mention",
|
||||||
|
pubkey: decoded.data,
|
||||||
|
entity,
|
||||||
|
fallback: shortEntity(entity),
|
||||||
|
});
|
||||||
|
handled = true;
|
||||||
|
} else if (decoded.type === "nprofile") {
|
||||||
|
inlines.push({
|
||||||
|
type: "mention",
|
||||||
|
pubkey: decoded.data.pubkey,
|
||||||
|
entity,
|
||||||
|
fallback: shortEntity(entity),
|
||||||
|
});
|
||||||
|
handled = true;
|
||||||
|
} else if (
|
||||||
|
decoded.type === "note" ||
|
||||||
|
decoded.type === "nevent" ||
|
||||||
|
decoded.type === "naddr"
|
||||||
|
) {
|
||||||
|
inlines.push({
|
||||||
|
type: "entity",
|
||||||
|
entity,
|
||||||
|
label: shortEntity(entity),
|
||||||
|
});
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to text
|
||||||
|
}
|
||||||
|
if (!handled) inlines.push({ type: "text", value: m[0] });
|
||||||
|
} else {
|
||||||
|
const href = /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
||||||
|
if (IMG_EXT_RE.test(url)) {
|
||||||
|
flush();
|
||||||
|
blocks.push({ type: "image", value: href });
|
||||||
|
if (trailing) inlines.push({ type: "text", value: trailing });
|
||||||
|
} else {
|
||||||
|
inlines.push({ type: "link", href, label: url });
|
||||||
|
if (trailing) inlines.push({ type: "text", value: trailing });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last = start + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length)
|
||||||
|
inlines.push({ type: "text", value: text.slice(last) });
|
||||||
|
flush();
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraphs = $derived(
|
||||||
|
content.split(/\n{2,}/).map((para) => tokenize(para)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const blocks of paragraphs) {
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.type !== "para") continue;
|
||||||
|
for (const inline of block.inlines) {
|
||||||
|
if (inline.type === "mention") seen.add(inline.pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pubkey of seen) {
|
||||||
|
if (resolvedUsers[pubkey] || profiles[pubkey]) continue;
|
||||||
|
loadNostrUser(pubkey).then((u) => {
|
||||||
|
resolvedUsers = { ...resolvedUsers, [pubkey]: u };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="prose leading-5 max-w-none text-gray-700 [&_p]:my-3 [&_img]:my-3">
|
||||||
|
{#each paragraphs as blocks}
|
||||||
|
{#each blocks as block}
|
||||||
|
{#if block.type === "image"}
|
||||||
|
<img
|
||||||
|
src={block.value}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
{#each block.inlines as inline}
|
||||||
|
{#if inline.type === "link"}
|
||||||
|
<a
|
||||||
|
href={inline.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-brand hover:underline break-all">{inline.label}</a
|
||||||
|
>
|
||||||
|
{:else if inline.type === "mention"}
|
||||||
|
{@const u =
|
||||||
|
profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
||||||
|
<a
|
||||||
|
href="https://njump.me/{inline.entity}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
>@{u?.shortName ?? inline.fallback}</a
|
||||||
|
>
|
||||||
|
{:else if inline.type === "entity"}
|
||||||
|
<a
|
||||||
|
href="https://njump.me/{inline.entity}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-brand hover:underline break-all">{inline.label}</a
|
||||||
|
>
|
||||||
|
{:else}{inline.value}{/if}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
@ -12,8 +12,8 @@
|
||||||
import Reactions from "$lib/components/Reactions.svelte";
|
import Reactions from "$lib/components/Reactions.svelte";
|
||||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||||
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
import MessageEditor from "$lib/components/MessageEditor.svelte";
|
||||||
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
import PostContent from "$lib/components/PostContent.svelte";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import { type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
|
||||||
type Author = { pubkey: string; name: string; picture?: string };
|
type Author = { pubkey: string; name: string; picture?: string };
|
||||||
|
|
||||||
|
|
@ -37,132 +37,6 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Curated TLD list: gTLDs, popular new gTLDs, common ccTLDs
|
|
||||||
const TLDS = [
|
|
||||||
"com","org","net","edu","gov","mil","int","info","biz","name","pro",
|
|
||||||
"io","co","app","dev","ai","sh","me","ly","tv","fm","lol","club",
|
|
||||||
"online","site","store","blog","tech","xyz","art","design","news",
|
|
||||||
"media","page","link","fun","gg","gl","st","to","run","life","world",
|
|
||||||
"space","cloud","email","social","chat","wtf","cafe","zone","studio",
|
|
||||||
"us","uk","de","fr","it","es","nl","ru","jp","cn","ca","au","br","in",
|
|
||||||
"mx","se","no","fi","dk","ch","at","be","pl","pt","gr","cz","ie","nz",
|
|
||||||
"kr","sg","hk","tw","za","cc","ws","eu","tr","ua","il","ar","cl","pe",
|
|
||||||
].join("|");
|
|
||||||
|
|
||||||
const URL_RE = new RegExp(
|
|
||||||
`nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+|https?:\\/\\/[^\\s<>"']+|(?<![\\w@.\\/])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+(?:${TLDS})\\b(?:[\\/?#][^\\s<>"']*)?`,
|
|
||||||
"gi",
|
|
||||||
);
|
|
||||||
const IMG_EXT_RE = /\.(?:jpg|jpeg|png|gif|webp|avif|svg|bmp)(?:\?.*)?$/i;
|
|
||||||
// Trailing punctuation that is usually not part of the URL
|
|
||||||
const TRAILING_PUNCT_RE = /[).,;:!?'"]+$/;
|
|
||||||
|
|
||||||
function shortEntity(entity: string): string {
|
|
||||||
const m = entity.match(/^(npub|nprofile|note|nevent|naddr)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)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Inline =
|
|
||||||
| { type: "text"; value: string }
|
|
||||||
| { type: "link"; href: string; label: string }
|
|
||||||
| { type: "mention"; pubkey: string; entity: string; fallback: string }
|
|
||||||
| { type: "entity"; entity: string; label: string };
|
|
||||||
|
|
||||||
type Block =
|
|
||||||
| { type: "image"; value: string }
|
|
||||||
| { type: "para"; inlines: Inline[] };
|
|
||||||
|
|
||||||
function tokenize(text: string): Block[] {
|
|
||||||
const blocks: Block[] = [];
|
|
||||||
let inlines: Inline[] = [];
|
|
||||||
const flush = () => {
|
|
||||||
if (
|
|
||||||
inlines.some(
|
|
||||||
(i) =>
|
|
||||||
i.type === "link" ||
|
|
||||||
i.type === "mention" ||
|
|
||||||
i.type === "entity" ||
|
|
||||||
i.value.trim(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
blocks.push({ type: "para", inlines });
|
|
||||||
inlines = [];
|
|
||||||
};
|
|
||||||
let last = 0;
|
|
||||||
for (const m of text.matchAll(URL_RE)) {
|
|
||||||
const start = m.index ?? 0;
|
|
||||||
let url = m[0];
|
|
||||||
const isNostr = /^nostr:/i.test(url);
|
|
||||||
let trailing = "";
|
|
||||||
if (!isNostr) {
|
|
||||||
const trail = url.match(TRAILING_PUNCT_RE);
|
|
||||||
if (trail) {
|
|
||||||
trailing = trail[0];
|
|
||||||
url = url.slice(0, -trailing.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (start > last)
|
|
||||||
inlines.push({ type: "text", value: text.slice(last, start) });
|
|
||||||
if (isNostr) {
|
|
||||||
const entity = url.slice(6).toLowerCase();
|
|
||||||
let handled = false;
|
|
||||||
try {
|
|
||||||
const decoded = nip19.decode(entity);
|
|
||||||
if (decoded.type === "npub") {
|
|
||||||
inlines.push({
|
|
||||||
type: "mention",
|
|
||||||
pubkey: decoded.data,
|
|
||||||
entity,
|
|
||||||
fallback: shortEntity(entity),
|
|
||||||
});
|
|
||||||
handled = true;
|
|
||||||
} else if (decoded.type === "nprofile") {
|
|
||||||
inlines.push({
|
|
||||||
type: "mention",
|
|
||||||
pubkey: decoded.data.pubkey,
|
|
||||||
entity,
|
|
||||||
fallback: shortEntity(entity),
|
|
||||||
});
|
|
||||||
handled = true;
|
|
||||||
} else if (
|
|
||||||
decoded.type === "note" ||
|
|
||||||
decoded.type === "nevent" ||
|
|
||||||
decoded.type === "naddr"
|
|
||||||
) {
|
|
||||||
inlines.push({
|
|
||||||
type: "entity",
|
|
||||||
entity,
|
|
||||||
label: shortEntity(entity),
|
|
||||||
});
|
|
||||||
handled = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall through to text
|
|
||||||
}
|
|
||||||
if (!handled) inlines.push({ type: "text", value: m[0] });
|
|
||||||
} else {
|
|
||||||
const href = /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
|
||||||
if (IMG_EXT_RE.test(url)) {
|
|
||||||
flush();
|
|
||||||
blocks.push({ type: "image", value: href });
|
|
||||||
if (trailing) inlines.push({ type: "text", value: trailing });
|
|
||||||
} else {
|
|
||||||
inlines.push({ type: "link", href, label: url });
|
|
||||||
if (trailing) inlines.push({ type: "text", value: trailing });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
last = start + m[0].length;
|
|
||||||
}
|
|
||||||
if (last < text.length)
|
|
||||||
inlines.push({ type: "text", value: text.slice(last) });
|
|
||||||
flush();
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
let opEl = $state<HTMLElement | null>(null);
|
let opEl = $state<HTMLElement | null>(null);
|
||||||
let replyEls = $state<(HTMLElement | null)[]>([]);
|
let replyEls = $state<(HTMLElement | null)[]>([]);
|
||||||
let isScrolled = $state(false);
|
let isScrolled = $state(false);
|
||||||
|
|
@ -196,27 +70,6 @@
|
||||||
const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
|
const allPosts = $derived(detail ? [detail.op, ...detail.replies] : []);
|
||||||
const postEls = $derived([opEl, ...replyEls]);
|
const postEls = $derived([opEl, ...replyEls]);
|
||||||
|
|
||||||
let resolvedUsers = $state<Record<string, NostrUser>>({});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!detail) return;
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const post of [detail.op, ...detail.replies]) {
|
|
||||||
for (const block of tokenize(post.content)) {
|
|
||||||
if (block.type !== "para") continue;
|
|
||||||
for (const inline of block.inlines) {
|
|
||||||
if (inline.type === "mention") seen.add(inline.pubkey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const pubkey of seen) {
|
|
||||||
if (resolvedUsers[pubkey] || profiles[pubkey]) continue;
|
|
||||||
loadNostrUser(pubkey).then((u) => {
|
|
||||||
resolvedUsers = { ...resolvedUsers, [pubkey]: u };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reload when navigating between threads
|
// Reload when navigating between threads
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (page.params.id) loadThread(page.params.id);
|
if (page.params.id) loadThread(page.params.id);
|
||||||
|
|
@ -278,54 +131,7 @@
|
||||||
>{formatDate(p.createdAt)}</span
|
>{formatDate(p.createdAt)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<PostContent content={p.content} {profiles} />
|
||||||
class="prose leading-5 max-w-none text-gray-700 [&_p]:my-3 [&_img]:my-3"
|
|
||||||
>
|
|
||||||
{#each p.content.split(/\n{2,}/) as para}
|
|
||||||
{#each tokenize(para) as block}
|
|
||||||
{#if block.type === "image"}
|
|
||||||
<img
|
|
||||||
src={block.value}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
class="block mx-auto w-full max-h-[80vh] object-contain rounded"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<p>
|
|
||||||
{#each block.inlines as inline}
|
|
||||||
{#if inline.type === "link"}
|
|
||||||
<a
|
|
||||||
href={inline.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-brand hover:underline break-all"
|
|
||||||
>{inline.label}</a
|
|
||||||
>
|
|
||||||
{:else if inline.type === "mention"}
|
|
||||||
{@const u =
|
|
||||||
profiles[inline.pubkey] ?? resolvedUsers[inline.pubkey]}
|
|
||||||
<a
|
|
||||||
href="https://njump.me/{inline.entity}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-brand hover:underline"
|
|
||||||
>@{u?.shortName ?? inline.fallback}</a
|
|
||||||
>
|
|
||||||
{:else if inline.type === "entity"}
|
|
||||||
<a
|
|
||||||
href="https://njump.me/{inline.entity}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-brand hover:underline break-all"
|
|
||||||
>{inline.label}</a
|
|
||||||
>
|
|
||||||
{:else}{inline.value}{/if}
|
|
||||||
{/each}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between mt-3">
|
<div class="flex items-center justify-between mt-3">
|
||||||
<Reactions reactions={[]} zaps={0} />
|
<Reactions reactions={[]} zaps={0} />
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue