mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 04:39:12 -07:00
Bump to v0.6.1 — native file upload, mention names, connection stability
- Native file picker (+) in compose box uploads via Rust backend (reqwest) - Pasting a local file path auto-uploads instead of inserting text - @mentions resolve to profile display names via useProfile hook - Connection indicator uses 15s grace period before showing offline - Upload uses correct nostr.build v2 API; Rust-side multipart for native picks - Content parser extracted to src/lib/parsing.ts
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { publishNote } from "../../lib/nostr";
|
||||
import { uploadImage } from "../../lib/upload";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useFeedStore } from "../../stores/feed";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const COMPOSE_DRAFT_KEY = "wrystr_compose_draft";
|
||||
|
||||
export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () => void; onNoteInjected?: (event: import("@nostr-dev-kit/ndk").NDKEvent) => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const [text, setText] = useState(() => {
|
||||
try { return localStorage.getItem(COMPOSE_DRAFT_KEY) || ""; }
|
||||
catch { return ""; }
|
||||
});
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -16,31 +23,46 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
const avatar = profile?.picture;
|
||||
const name = profile?.displayName || profile?.name || (npub ? shortenPubkey(npub) : "");
|
||||
|
||||
// Auto-save draft with debounce
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (text.trim()) {
|
||||
localStorage.setItem(COMPOSE_DRAFT_KEY, text);
|
||||
} else {
|
||||
localStorage.removeItem(COMPOSE_DRAFT_KEY);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [text]);
|
||||
|
||||
const charCount = text.length;
|
||||
const overLimit = charCount > 280;
|
||||
const canPost = text.trim().length > 0 && !overLimit && !publishing && !uploading;
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/"));
|
||||
if (!file) return;
|
||||
e.preventDefault();
|
||||
// Insert a URL at the current cursor position in the textarea
|
||||
const insertUrl = (url: string) => {
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
const start = ta.selectionStart ?? text.length;
|
||||
const end = ta.selectionEnd ?? text.length;
|
||||
const next = text.slice(0, start) + url + text.slice(end);
|
||||
setText(next);
|
||||
setTimeout(() => {
|
||||
ta.selectionStart = ta.selectionEnd = start + url.length;
|
||||
ta.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
setText((t) => t + url);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload a web File object (from clipboard/drag-drop)
|
||||
const handleImageUpload = async (file: File) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
const start = ta.selectionStart ?? text.length;
|
||||
const end = ta.selectionEnd ?? text.length;
|
||||
const next = text.slice(0, start) + url + text.slice(end);
|
||||
setText(next);
|
||||
setTimeout(() => {
|
||||
ta.selectionStart = ta.selectionEnd = start + url.length;
|
||||
ta.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
setText((t) => t + url);
|
||||
}
|
||||
insertUrl(url);
|
||||
} catch (err) {
|
||||
setError(`Image upload failed: ${err}`);
|
||||
} finally {
|
||||
@@ -48,6 +70,79 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
}
|
||||
};
|
||||
|
||||
// Upload a file by path using the Rust backend (bypasses WebView FormData issues)
|
||||
const handleNativeUpload = async (filePath: string) => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = await invoke<string>("upload_file", { path: filePath });
|
||||
insertUrl(url);
|
||||
} catch (err) {
|
||||
setError(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
// Try clipboardData.files first (works on Windows, some Linux DEs)
|
||||
const fileFromFiles = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/"));
|
||||
if (fileFromFiles) {
|
||||
e.preventDefault();
|
||||
handleImageUpload(fileFromFiles);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try clipboardData.items (needed for Linux/Wayland screenshot paste)
|
||||
const items = Array.from(e.clipboardData.items ?? []);
|
||||
const imageItem = items.find((item) => item.type.startsWith("image/"));
|
||||
if (imageItem) {
|
||||
const file = imageItem.getAsFile();
|
||||
if (file) {
|
||||
e.preventDefault();
|
||||
handleImageUpload(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If pasted text looks like a local file path to a media file, upload it directly
|
||||
const pastedText = e.clipboardData.getData("text/plain");
|
||||
if (pastedText && /\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)$/i.test(pastedText.trim()) && /^(\/|[A-Z]:\\)/.test(pastedText.trim())) {
|
||||
e.preventDefault();
|
||||
handleNativeUpload(pastedText.trim());
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/"));
|
||||
if (!file) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleImageUpload(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
|
||||
if (Array.from(e.dataTransfer.types).includes("Files")) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilePicker = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: "Media", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg", "mp4", "webm", "mov", "ogg", "m4v"] },
|
||||
],
|
||||
});
|
||||
if (!selected) return;
|
||||
const path = typeof selected === "string" ? selected : selected;
|
||||
handleNativeUpload(path);
|
||||
} catch (err) {
|
||||
setError(`File picker failed: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
@@ -71,6 +166,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
});
|
||||
}
|
||||
setText("");
|
||||
localStorage.removeItem(COMPOSE_DRAFT_KEY);
|
||||
textareaRef.current?.focus();
|
||||
onPublished?.();
|
||||
} catch (err) {
|
||||
@@ -108,6 +204,8 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
placeholder="What's on your mind?"
|
||||
rows={3}
|
||||
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none"
|
||||
@@ -120,8 +218,19 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}>
|
||||
{uploading ? "uploading image…" : charCount > 0 ? `${charCount}/280` : ""}
|
||||
{!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && (
|
||||
<span className="ml-1 text-text-dim">(draft)</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleFilePicker}
|
||||
disabled={uploading}
|
||||
title="Attach image or video"
|
||||
className="text-text-dim hover:text-text text-[13px] transition-colors disabled:opacity-30"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="text-text-dim text-[10px]">Ctrl+Enter to post</span>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SkeletonNoteList } from "../shared/Skeleton";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
export function Feed() {
|
||||
const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, focusedNoteIndex } = useFeedStore();
|
||||
const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, trendingNotes, trendingLoading, loadTrendingFeed, focusedNoteIndex } = useFeedStore();
|
||||
const { loggedIn, follows } = useUserStore();
|
||||
const { mutedPubkeys } = useMuteStore();
|
||||
const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore();
|
||||
@@ -29,6 +29,9 @@ export function Feed() {
|
||||
if (tab === "following" && loggedIn && follows.length > 0) {
|
||||
loadFollowFeed();
|
||||
}
|
||||
if (tab === "trending") {
|
||||
loadTrendingFeed();
|
||||
}
|
||||
}, [tab, follows]);
|
||||
|
||||
const loadFollowFeed = async () => {
|
||||
@@ -42,8 +45,9 @@ export function Feed() {
|
||||
};
|
||||
|
||||
const isFollowing = tab === "following";
|
||||
const activeNotes = isFollowing ? followNotes : notes;
|
||||
const isLoading = isFollowing ? followLoading : loading;
|
||||
const isTrending = tab === "trending";
|
||||
const activeNotes = isTrending ? trendingNotes : isFollowing ? followNotes : notes;
|
||||
const isLoading = isTrending ? trendingLoading : isFollowing ? followLoading : loading;
|
||||
|
||||
const filteredNotes = activeNotes.filter((event) => {
|
||||
if (mutedPubkeys.includes(event.pubkey)) return false;
|
||||
@@ -105,6 +109,16 @@ export function Feed() {
|
||||
Following
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setTab("trending")}
|
||||
className={`px-3 py-1 text-[12px] transition-colors ${
|
||||
tab === "trending"
|
||||
? "text-text border-b-2 border-accent"
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Trending
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
@@ -124,7 +138,7 @@ export function Feed() {
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={isFollowing ? loadFollowFeed : loadFeed}
|
||||
onClick={isTrending ? () => loadTrendingFeed(true) : isFollowing ? loadFollowFeed : loadFeed}
|
||||
disabled={isLoading}
|
||||
className="text-text-muted hover:text-text text-[11px] px-2 py-1 border border-border hover:border-text-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
@@ -140,7 +154,7 @@ export function Feed() {
|
||||
|
||||
{/* Feed */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error && !isFollowing && (
|
||||
{error && !isFollowing && !isTrending && (
|
||||
<div className="px-4 py-3 text-danger text-[12px] border-b border-border bg-danger/5">
|
||||
{error}
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,7 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
const [liked, setLiked] = useState(() => getLiked().has(event.id));
|
||||
const [liking, setLiking] = useState(false);
|
||||
const [reactionCount, adjustReactionCount] = useReactionCount(event.id);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [replyCount, adjustReplyCount] = useReplyCount(event.id);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const zapData = useZapCount(event.id);
|
||||
@@ -78,14 +79,17 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
const [reposted, setReposted] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const handleLike = async () => {
|
||||
const REACTION_EMOJIS = ["❤️", "🤙", "🔥", "😂", "🫡", "👀", "⚡"];
|
||||
|
||||
const handleReact = async (emoji?: string) => {
|
||||
if (!loggedIn || liked || liking) return;
|
||||
setLiking(true);
|
||||
setShowEmojiPicker(false);
|
||||
try {
|
||||
await publishReaction(event.id, event.pubkey);
|
||||
const liked = getLiked();
|
||||
liked.add(event.id);
|
||||
localStorage.setItem(likedKey, JSON.stringify(Array.from(liked)));
|
||||
await publishReaction(event.id, event.pubkey, emoji || "+");
|
||||
const likedSet = getLiked();
|
||||
likedSet.add(event.id);
|
||||
localStorage.setItem(likedKey, JSON.stringify(Array.from(likedSet)));
|
||||
setLiked(true);
|
||||
adjustReactionCount(1);
|
||||
} finally {
|
||||
@@ -249,15 +253,34 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
>
|
||||
reply{replyCount !== null && replyCount > 0 ? ` ${replyCount}` : ""}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLike}
|
||||
disabled={liked || liking}
|
||||
className={`text-[11px] transition-colors ${
|
||||
liked ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => handleReact("❤️")}
|
||||
onContextMenu={(e) => { e.preventDefault(); if (!liked && !liking) setShowEmojiPicker((v) => !v); }}
|
||||
disabled={liked || liking}
|
||||
className={`text-[11px] transition-colors ${
|
||||
liked ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
|
||||
</button>
|
||||
{showEmojiPicker && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" onClick={() => setShowEmojiPicker(false)} />
|
||||
<div className="absolute bottom-6 left-0 bg-bg-raised border border-border shadow-lg z-10 flex gap-0.5 px-1.5 py-1">
|
||||
{REACTION_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => handleReact(emoji)}
|
||||
className="text-[16px] hover:scale-125 transition-transform px-0.5"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRepost}
|
||||
disabled={reposting || reposted}
|
||||
|
||||
@@ -5,181 +5,108 @@ import { fetchNoteById } from "../../lib/nostr";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
import { ImageLightbox } from "../shared/ImageLightbox";
|
||||
import { parseContent } from "../../lib/parsing";
|
||||
|
||||
// Regex patterns
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
||||
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?$/i;
|
||||
const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|ogg|m4v|avi)(\?[^\s]*)?$/i;
|
||||
const AUDIO_EXTENSIONS = /\.(mp3|wav|flac|aac|m4a|opus|ogg)(\?[^\s]*)?$/i;
|
||||
const YOUTUBE_REGEX = /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
const TIDAL_REGEX = /tidal\.com\/(?:browse\/)?(?:track|album|playlist)\/([a-zA-Z0-9-]+)/;
|
||||
const SPOTIFY_REGEX = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/;
|
||||
const VIMEO_REGEX = /vimeo\.com\/(\d+)/;
|
||||
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 HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g;
|
||||
function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (index: number) => void }) {
|
||||
const count = images.length;
|
||||
if (count === 0) return null;
|
||||
|
||||
interface ContentSegment {
|
||||
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "mention" | "hashtag" | "quote";
|
||||
value: string; // for "quote": the hex event ID
|
||||
display?: string;
|
||||
mediaId?: string; // video/embed ID for youtube/vimeo
|
||||
mediaType?: string; // e.g. "track", "album" for spotify/tidal
|
||||
const maxVisible = Math.min(count, 4);
|
||||
const extraCount = count - 4;
|
||||
const visible = images.slice(0, maxVisible);
|
||||
|
||||
if (count === 1) {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={images[0]}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(0); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (count === 2) {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{visible.map((src, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (count === 3) {
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-2 grid-rows-2 gap-1" style={{ gridTemplateRows: "1fr 1fr" }}>
|
||||
<img
|
||||
src={visible[0]}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in row-span-2"
|
||||
style={{ aspectRatio: "3/4" }}
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(0); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<img
|
||||
src={visible[1]}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(1); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<img
|
||||
src={visible[2]}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(2); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 4+ images: 2x2 grid with "+N more" overlay on 4th
|
||||
return (
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{visible.map((src, idx) => (
|
||||
<div key={idx} className="relative">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full aspect-[4/3] rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
{idx === 3 && extraCount > 0 && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 flex items-center justify-center rounded-sm cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); onImageClick(idx); }}
|
||||
>
|
||||
<span className="text-white text-lg font-semibold">+{extraCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseContent(content: string): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
const allMatches: { index: number; length: number; segment: ContentSegment }[] = [];
|
||||
|
||||
// Find URLs
|
||||
let match: RegExpExecArray | null;
|
||||
const urlRegex = new RegExp(URL_REGEX.source, "g");
|
||||
while ((match = urlRegex.exec(content)) !== null) {
|
||||
const url = match[0];
|
||||
// Clean trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, "");
|
||||
|
||||
if (IMAGE_EXTENSIONS.test(cleaned)) {
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "image", value: cleaned },
|
||||
});
|
||||
} else if (VIDEO_EXTENSIONS.test(cleaned)) {
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "video", value: cleaned },
|
||||
});
|
||||
} else if (AUDIO_EXTENSIONS.test(cleaned)) {
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "audio", value: cleaned },
|
||||
});
|
||||
} else {
|
||||
// Check for embeddable media URLs
|
||||
const ytMatch = cleaned.match(YOUTUBE_REGEX);
|
||||
const vimeoMatch = cleaned.match(VIMEO_REGEX);
|
||||
const spotifyMatch = cleaned.match(SPOTIFY_REGEX);
|
||||
const tidalMatch = cleaned.match(TIDAL_REGEX);
|
||||
|
||||
if (ytMatch) {
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "youtube", value: cleaned, mediaId: ytMatch[1] },
|
||||
});
|
||||
} else if (vimeoMatch) {
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "vimeo", value: cleaned, mediaId: vimeoMatch[1] },
|
||||
});
|
||||
} else if (spotifyMatch) {
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "spotify", value: cleaned, mediaType: spotifyMatch[1], mediaId: spotifyMatch[2] },
|
||||
});
|
||||
} else if (tidalMatch) {
|
||||
// Extract the type (track/album/playlist) from the URL
|
||||
const tidalTypeMatch = cleaned.match(/tidal\.com\/(?:browse\/)?(track|album|playlist)\//);
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "tidal", value: cleaned, mediaType: tidalTypeMatch?.[1] ?? "track", mediaId: tidalMatch[1] },
|
||||
});
|
||||
} else {
|
||||
// Shorten display URL
|
||||
let display = cleaned;
|
||||
try {
|
||||
const u = new URL(cleaned);
|
||||
display = u.hostname + (u.pathname !== "/" ? u.pathname : "");
|
||||
if (display.length > 50) display = display.slice(0, 47) + "…";
|
||||
} catch { /* keep as-is */ }
|
||||
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "link", value: cleaned, display },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find nostr: mentions
|
||||
const mentionRegex = new RegExp(NOSTR_MENTION_REGEX.source, "g");
|
||||
while ((match = mentionRegex.exec(content)) !== null) {
|
||||
const raw = match[1];
|
||||
let display = raw.slice(0, 12) + "…";
|
||||
|
||||
let isQuote = false;
|
||||
let eventId = "";
|
||||
try {
|
||||
const decoded = nip19.decode(raw);
|
||||
if (decoded.type === "npub") {
|
||||
display = raw.slice(0, 12) + "…";
|
||||
} else if (decoded.type === "note") {
|
||||
// Always treat note1 references as inline quotes
|
||||
isQuote = true;
|
||||
eventId = decoded.data as string;
|
||||
} else if (decoded.type === "nevent") {
|
||||
const d = decoded.data as { id: string; kind?: number };
|
||||
// Only quote kind-1 notes (or unknown kind)
|
||||
if (!d.kind || d.kind === 1) {
|
||||
isQuote = true;
|
||||
eventId = d.id;
|
||||
} else {
|
||||
display = "event:" + raw.slice(7, 15) + "…";
|
||||
}
|
||||
}
|
||||
} catch { /* keep default */ }
|
||||
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
segment: isQuote
|
||||
? { type: "quote", value: eventId }
|
||||
: { type: "mention", value: raw, display },
|
||||
});
|
||||
}
|
||||
|
||||
// Find hashtags
|
||||
const hashtagRegex = new RegExp(HASHTAG_REGEX.source, "g");
|
||||
while ((match = hashtagRegex.exec(content)) !== null) {
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
segment: { type: "hashtag", value: match[1], display: `#${match[1]}` },
|
||||
});
|
||||
}
|
||||
|
||||
// Sort matches by index, remove overlaps
|
||||
allMatches.sort((a, b) => a.index - b.index);
|
||||
const filtered: typeof allMatches = [];
|
||||
let lastEnd = 0;
|
||||
for (const m of allMatches) {
|
||||
if (m.index >= lastEnd) {
|
||||
filtered.push(m);
|
||||
lastEnd = m.index + m.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Build segments
|
||||
let cursor = 0;
|
||||
for (const m of filtered) {
|
||||
if (m.index > cursor) {
|
||||
segments.push({ type: "text", value: content.slice(cursor, m.index) });
|
||||
}
|
||||
segments.push(m.segment);
|
||||
cursor = m.index + m.length;
|
||||
}
|
||||
if (cursor < content.length) {
|
||||
segments.push({ type: "text", value: content.slice(cursor) });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Returns true if we handled the URL internally (njump.me interception).
|
||||
function tryHandleUrlInternally(url: string): boolean {
|
||||
@@ -251,6 +178,13 @@ function QuotePreview({ eventId }: { eventId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MentionName({ pubkey, fallback }: { pubkey?: string; fallback: string }) {
|
||||
const profile = useProfile(pubkey ?? "");
|
||||
if (!pubkey) return <>{fallback}</>;
|
||||
const name = profile?.displayName || profile?.name;
|
||||
return <>{name || fallback}</>;
|
||||
}
|
||||
|
||||
interface NoteContentProps {
|
||||
content: string;
|
||||
/** Render only inline text (no media blocks). Used inside the clickable area. */
|
||||
@@ -303,7 +237,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
className="text-accent cursor-pointer hover:text-accent-hover"
|
||||
onClick={(e) => { e.stopPropagation(); tryOpenNostrEntity(seg.value); }}
|
||||
>
|
||||
@{seg.display}
|
||||
@<MentionName pubkey={seg.mentionPubkey} fallback={seg.display ?? seg.value.slice(0, 12) + "…"} />
|
||||
</span>
|
||||
);
|
||||
break;
|
||||
@@ -328,21 +262,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
{inlineElements}
|
||||
</div>
|
||||
{/* Images stay inside the clickable area (they have their own stopPropagation) */}
|
||||
{images.length > 0 && (
|
||||
<div className={`mt-2 ${images.length > 1 ? "grid grid-cols-2 gap-1" : ""}`}>
|
||||
{images.map((src, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex(idx); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ImageGrid images={images} onImageClick={setLightboxIndex} />
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={images}
|
||||
@@ -538,21 +458,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
|
||||
{inlineElements}
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className={`mt-2 ${images.length > 1 ? "grid grid-cols-2 gap-1" : ""}`}>
|
||||
{images.map((src, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex(idx); }}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ImageGrid images={images} onImageClick={setLightboxIndex} />
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
|
||||
Reference in New Issue
Block a user