Polish DM view: clickable links, inline images, nostr entity rendering

- DM messages now parse and render URLs as clickable accent links
- Image URLs show inline in the bubble (max-h-48)
- nostr:naddr and mentions rendered as styled clickable links
- nostr:nevent quotes show a subtle indicator
- Media URLs (video/audio/youtube etc.) rendered as clickable links
- parseContent regex now case-insensitive for nostr: prefix; entity
  lowercased before nip19.decode for robustness
- renderTextSegments: add case for quote type (↩ note indicator)
This commit is contained in:
Jure
2026-04-10 17:49:11 +02:00
parent 7375ddc7e3
commit f4878f9d4f
3 changed files with 90 additions and 5 deletions
+74 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { NDKEvent, NDKKind, nip19 } from "@nostr-dev-kit/ndk";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
import { useNotificationsStore } from "../../stores/notifications";
@@ -7,6 +8,8 @@ import { fetchDMConversations, fetchDMThread, sendDM, decryptDM, getNDK } from "
import { useProfile } from "../../hooks/useProfile";
import { timeAgo, shortenPubkey, profileName } from "../../lib/utils";
import { debug } from "../../lib/debug";
import { parseContent } from "../../lib/parsing";
import { tryHandleUrlInternally, tryOpenNostrEntity } from "../feed/TextSegments";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -77,6 +80,75 @@ function ConvRow({
);
}
// ── DM text renderer ─────────────────────────────────────────────────────────
function DMText({ text }: { text: string }) {
const segments = useMemo(() => parseContent(text), [text]);
return (
<span className="whitespace-pre-wrap break-words">
{segments.map((seg, i) => {
if (seg.type === "link") {
return (
<a
key={i}
href={seg.value}
className="text-accent hover:text-accent-hover underline underline-offset-2 decoration-accent/40"
onClick={(e) => {
e.preventDefault();
if (!tryHandleUrlInternally(seg.value)) openUrl(seg.value).catch(() => {});
}}
>
{seg.display || seg.value}
</a>
);
}
if (seg.type === "image") {
return (
<img
key={i}
src={seg.value}
className="max-h-48 max-w-full rounded-md mt-1.5 block"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
);
}
if (seg.type === "naddr" || seg.type === "mention") {
return (
<span
key={i}
className="text-accent hover:text-accent-hover cursor-pointer underline-offset-2"
onClick={() => tryOpenNostrEntity(seg.value)}
>
{seg.type === "naddr" ? "🔗 " : "@"}{String(seg.display ?? seg.value).slice(0, 20)}
</span>
);
}
if (seg.type === "quote") {
return (
<span key={i} className="text-accent/60 text-xs cursor-pointer hover:text-accent italic">
quoted note
</span>
);
}
// video/audio/youtube/etc. — show URL as a plain link
if (["video", "audio", "youtube", "vimeo", "spotify", "tidal", "fountain"].includes(seg.type)) {
return (
<a
key={i}
href={seg.value}
className="text-accent hover:text-accent-hover underline underline-offset-2 decoration-accent/40"
onClick={(e) => { e.preventDefault(); openUrl(seg.value).catch(() => {}); }}
>
{seg.value}
</a>
);
}
return <span key={i}>{seg.value}</span>;
})}
</span>
);
}
// ── Message bubble ────────────────────────────────────────────────────────────
function MessageBubble({ event, myPubkey }: { event: NDKEvent; myPubkey: string }) {
@@ -106,7 +178,7 @@ function MessageBubble({ event, myPubkey }: { event: NDKEvent; myPubkey: string
) : text === null ? (
<span className="text-text-dim"></span>
) : (
text
<DMText text={text} />
)}
<div className={`text-[10px] mt-1 ${isMine ? "text-accent/60" : "text-text-dim"}`}>
{time}
+13
View File
@@ -165,6 +165,19 @@ export function renderTextSegments(
</span>
);
break;
case "quote":
// Inline text placeholder — the QuotePreview card renders separately below
elements.push(
<span
key={i}
className="text-accent/60 text-xs cursor-pointer hover:text-accent"
onClick={(e) => { e.stopPropagation(); tryOpenNostrEntity(`note1${seg.value.slice(0, 8)}`); }}
title="Quoted note"
>
note
</span>
);
break;
case "hashtag":
elements.push(
<span
+3 -3
View File
@@ -10,7 +10,7 @@ const TIDAL_REGEX = /tidal\.com\/(?:browse\/)?(?:track|album|playlist)\/([a-zA-Z
const SPOTIFY_REGEX = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/;
const VIMEO_REGEX = /vimeo\.com\/(\d+)/;
const FOUNTAIN_REGEX = /fountain\.fm\/(episode|show)\/([a-zA-Z0-9-]+)/;
const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/g;
const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/gi;
const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g;
export interface ContentSegment {
@@ -114,9 +114,9 @@ export function parseContent(content: string): ContentSegment[] {
}
// Find nostr: mentions
const mentionRegex = new RegExp(NOSTR_MENTION_REGEX.source, "g");
const mentionRegex = new RegExp(NOSTR_MENTION_REGEX.source, "gi");
while ((match = mentionRegex.exec(content)) !== null) {
const raw = match[1];
const raw = match[1].toLowerCase();
let display = raw.slice(0, 12) + "…";
let mentionPubkey: string | undefined;