diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9a394d..01c5fcd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,14 @@ jobs: > **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install. + ### New in v0.4.1 — Media & Feed Fixes + - **Video player** — direct video URLs (.mp4, .webm, .mov, etc.) now render as inline players with native controls + - **Audio player** — direct audio URLs (.mp3, .wav, .flac, etc.) render as inline audio players with filename display + - **YouTube/Vimeo rich cards** — YouTube links show thumbnail previews, Vimeo/Spotify/Tidal show branded cards; all open in external browser + - **Media detection** — parser now recognizes YouTube, Vimeo, Spotify, and Tidal URLs as distinct media types + - **Fix: video clicks opening thread** — media elements rendered outside the clickable area so interactions don't navigate away + - **Fix: post not visible on Following tab** — publishing a note while on Following now correctly shows it in the feed + ### New in v0.4.0 — Phase 3: Discovery & Polish - **Image lightbox** — click any image to view full-screen; Escape to close, arrow keys to navigate multi-image posts - **Bookmarks (NIP-51)** — save/unsave notes with one click; dedicated Bookmarks view in sidebar; synced to relays (kind 10003) diff --git a/PKGBUILD b/PKGBUILD index 1f15144..5f0dd3e 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: hoornet pkgname=wrystr -pkgver=0.4.0 +pkgver=0.4.1 pkgrel=1 pkgdesc="Cross-platform Nostr desktop client with Lightning integration" arch=('x86_64') diff --git a/package.json b/package.json index 71252eb..20bf28e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wrystr", "private": true, - "version": "0.4.0", + "version": "0.4.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 49b5f0b..9fda48d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wrystr" -version = "0.4.0" +version = "0.4.1" description = "Cross-platform Nostr desktop client with Lightning integration" authors = ["hoornet"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c1148f6..80270bd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Wrystr", - "version": "0.4.0", + "version": "0.4.1", "identifier": "com.hoornet.wrystr", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx index 3dc2ceb..2b143a7 100644 --- a/src/components/feed/ComposeBox.tsx +++ b/src/components/feed/ComposeBox.tsx @@ -5,7 +5,7 @@ import { useUserStore } from "../../stores/user"; import { useFeedStore } from "../../stores/feed"; import { shortenPubkey } from "../../lib/utils"; -export function ComposeBox({ onPublished }: { onPublished?: () => void }) { +export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () => void; onNoteInjected?: (event: import("@nostr-dev-kit/ndk").NDKEvent) => void }) { const [text, setText] = useState(""); const [publishing, setPublishing] = useState(false); const [uploading, setUploading] = useState(false); @@ -62,10 +62,14 @@ export function ComposeBox({ onPublished }: { onPublished?: () => void }) { try { const event = await publishNote(text.trim()); // Inject into feed immediately so the user sees their post - const { notes } = useFeedStore.getState(); - useFeedStore.setState({ - notes: [event, ...notes], - }); + if (onNoteInjected) { + onNoteInjected(event); + } else { + const { notes } = useFeedStore.getState(); + useFeedStore.setState({ + notes: [event, ...notes], + }); + } setText(""); textareaRef.current?.focus(); onPublished?.(); diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx index 00bf10b..dd06999 100644 --- a/src/components/feed/Feed.tsx +++ b/src/components/feed/Feed.tsx @@ -133,7 +133,9 @@ export function Feed() { {/* Compose */} - {loggedIn && !!getNDK().signer && } + {loggedIn && !!getNDK().signer && ( + setFollowNotes((prev) => [event, ...prev]) : undefined} /> + )} {/* Feed */}
diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index d0cc3cc..4e33f0b 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -216,8 +216,9 @@ export function NoteCard({ event, focused }: NoteCardProps) { className="cursor-pointer" onClick={() => openThread(event, currentView as "feed" | "profile")} > - +
+ {/* Actions */} {loggedIn && !!getNDK().signer && ( diff --git a/src/components/feed/NoteContent.tsx b/src/components/feed/NoteContent.tsx index b7753ff..899036e 100644 --- a/src/components/feed/NoteContent.tsx +++ b/src/components/feed/NoteContent.tsx @@ -9,14 +9,21 @@ import { ImageLightbox } from "../shared/ImageLightbox"; // 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)(\?[^\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; interface ContentSegment { - type: "text" | "link" | "image" | "video" | "mention" | "hashtag" | "quote"; + 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 } function parseContent(content: string): ContentSegment[] { @@ -43,20 +50,60 @@ function parseContent(content: string): ContentSegment[] { length: cleaned.length, segment: { type: "video", value: cleaned }, }); - } 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 */ } - + } else if (AUDIO_EXTENSIONS.test(cleaned)) { allMatches.push({ index: match.index, length: cleaned.length, - segment: { type: "link", value: cleaned, display }, + 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 }, + }); + } } } @@ -204,16 +251,239 @@ function QuotePreview({ eventId }: { eventId: string }) { ); } -export function NoteContent({ content }: { content: string }) { +interface NoteContentProps { + content: string; + /** Render only inline text (no media blocks). Used inside the clickable area. */ + inline?: boolean; + /** Render only media blocks (videos, embeds, quotes). Used outside the clickable area. */ + mediaOnly?: boolean; +} + +export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) { const { openSearch } = useUIStore(); const segments = parseContent(content); const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value); const videos: string[] = segments.filter((s) => s.type === "video").map((s) => s.value); + const audios: string[] = segments.filter((s) => s.type === "audio").map((s) => s.value); + const youtubes = segments.filter((s) => s.type === "youtube"); + const vimeos = segments.filter((s) => s.type === "vimeo"); + const spotifys = segments.filter((s) => s.type === "spotify"); + const tidals = segments.filter((s) => s.type === "tidal"); const quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value); const [lightboxIndex, setLightboxIndex] = useState(null); - const inlineElements: ReactNode[] = []; + // --- Inline text + images (safe inside clickable wrapper) --- + if (inline) { + const inlineElements: ReactNode[] = []; + segments.forEach((seg, i) => { + switch (seg.type) { + case "text": + inlineElements.push({seg.value}); + break; + case "link": + inlineElements.push( + { + if (tryHandleUrlInternally(seg.value)) e.preventDefault(); + }} + > + {seg.display} + + ); + break; + case "mention": + inlineElements.push( + { e.stopPropagation(); tryOpenNostrEntity(seg.value); }} + > + @{seg.display} + + ); + break; + case "hashtag": + inlineElements.push( + { e.stopPropagation(); openSearch(`#${seg.value}`); }} + > + {seg.display} + + ); + break; + default: + break; + } + }); + return ( +
+
+ {inlineElements} +
+ {/* Images stay inside the clickable area (they have their own stopPropagation) */} + {images.length > 0 && ( +
1 ? "grid grid-cols-2 gap-1" : ""}`}> + {images.map((src, idx) => ( + { e.stopPropagation(); setLightboxIndex(idx); }} + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ))} +
+ )} + {lightboxIndex !== null && ( + setLightboxIndex(null)} + onNavigate={setLightboxIndex} + /> + )} +
+ ); + } + // --- Media blocks only (rendered OUTSIDE the clickable wrapper) --- + if (mediaOnly) { + const hasMedia = videos.length > 0 || audios.length > 0 || youtubes.length > 0 + || vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || quoteIds.length > 0; + if (!hasMedia) return null; + + return ( +
e.stopPropagation()}> + {/* Videos */} + {videos.length > 0 && ( +
+ {videos.map((src, i) => ( +
+ )} + + {/* Audio */} + {audios.length > 0 && ( +
+ {audios.map((src, i) => { + const filename = src.split("/").pop()?.split("?")[0] ?? src; + return ( +
+
{filename}
+
+ ); + })} +
+ )} + + {/* YouTube — open in browser (WebKitGTK can't play YouTube iframes) */} + {youtubes.map((seg, i) => ( + + +
+
YouTube
+
{seg.value}
+
+
+ ))} + + {/* Vimeo — open in browser */} + {vimeos.map((seg, i) => ( + +
+ +
+
+
Vimeo
+
{seg.value}
+
+
+ ))} + + {/* Spotify — open in browser/app */} + {spotifys.map((seg, i) => ( + +
+ S +
+
+
Spotify · {seg.mediaType}
+
{seg.value}
+
+
+ ))} + + {/* Tidal — open in browser/app */} + {tidals.map((seg, i) => ( + +
+ T +
+
+
Tidal · {seg.mediaType}
+
{seg.value}
+
+
+ ))} + + {/* Quoted notes */} + {quoteIds.map((id) => ( + + ))} +
+ ); + } + + // --- Default: full render (used in ThreadView, SearchView, etc.) --- + const inlineElements: ReactNode[] = []; segments.forEach((seg, i) => { switch (seg.type) { case "text": @@ -240,10 +510,7 @@ export function NoteContent({ content }: { content: string }) { { - e.stopPropagation(); - tryOpenNostrEntity(seg.value); - }} + onClick={(e) => { e.stopPropagation(); tryOpenNostrEntity(seg.value); }} > @{seg.display} @@ -254,19 +521,13 @@ export function NoteContent({ content }: { content: string }) { { - e.stopPropagation(); - openSearch(`#${seg.value}`); - }} + onClick={(e) => { e.stopPropagation(); openSearch(`#${seg.value}`); }} > {seg.display} ); break; - case "image": - case "video": - case "quote": - // Rendered separately below the text + default: break; } }); @@ -277,20 +538,17 @@ export function NoteContent({ content }: { content: string }) { {inlineElements} - {/* Images */} {images.length > 0 && (
1 ? "grid grid-cols-2 gap-1" : ""}`}> - {images.map((src, i) => ( + {images.map((src, idx) => ( { e.stopPropagation(); setLightboxIndex(i); }} - onError={(e) => { - (e.target as HTMLImageElement).style.display = "none"; - }} + onClick={(e) => { e.stopPropagation(); setLightboxIndex(idx); }} + onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> ))}
@@ -305,25 +563,90 @@ export function NoteContent({ content }: { content: string }) { /> )} - {/* Quoted notes */} - {quoteIds.map((id) => ( - - ))} - - {/* Videos */} {videos.length > 0 && ( -
+
{videos.map((src, i) => (
)} + + {audios.length > 0 && ( +
+ {audios.map((src, i) => { + const filename = src.split("/").pop()?.split("?")[0] ?? src; + return ( +
+
{filename}
+
+ ); + })} +
+ )} + + {youtubes.map((seg, i) => ( + + +
+
YouTube
+
{seg.value}
+
+
+ ))} + + {vimeos.map((seg, i) => ( + +
+ +
+
+
Vimeo
+
{seg.value}
+
+
+ ))} + + {spotifys.map((seg, i) => ( + +
+ S +
+
+
Spotify · {seg.mediaType}
+
{seg.value}
+
+
+ ))} + + {tidals.map((seg, i) => ( + +
+ T +
+
+
Tidal · {seg.mediaType}
+
{seg.value}
+
+
+ ))} + + {quoteIds.map((id) => ( + + ))}
); }