diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e200f80..75052ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,12 +69,15 @@ 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.8.0 — Polish, Portability & Discovery - - **Profile banner polish** — hero-height banner, click to open in lightbox, avatar overlaps banner edge Telegram-style, loading shimmer - - **Export Data** — export your bookmarks, follows, and relay list as JSON via native save dialog; your keys, your data - - **Relay recommendations** — "Discover relays" analyzes your follows' NIP-65 relay lists and suggests popular relays you're missing, with one-click add - - **Reading list tracking** — bookmarked articles show read/unread status with dot indicators; auto-marks read when opened; unread count badge in sidebar; hover to toggle read/unread - - **Trending hashtags** — search idle screen shows popular hashtags from the last 24 hours as clickable pills; click to search + ### New in v0.8.2 — Writing & Reading Polish + - **No more 280-char limit** — Nostr has no protocol limit; compose freely up to 4000 chars with soft warnings, never blocked + - **Serif reading font** — article reader uses Georgia/serif at 17px for comfortable long-form reading; code blocks stay monospace + - **Reading progress bar** — thin accent-colored bar at the top of articles tracks your scroll position + - **Article table of contents** — floating TOC in the right margin on wide screens; parses h2/h3 headings, highlights active section, click to scroll; hidden on narrow screens + - **Connection indicator fix** — data-aware connectivity checking with 25s grace period; no more false "offline" on startup + + ### Previous: v0.8.0 — Polish, Portability & Discovery + - Profile banner polish, data export, relay recommendations, reading list tracking, trending hashtags ### Previous: v0.7.1 — Relay Health Checker & Advanced Search - **Relay health checker** — NIP-11 info fetch + WebSocket latency probing; relays classified as online/slow/offline; expandable cards show software, description, supported NIPs; "Remove dead" strips offline relays; "Republish list" publishes cleaned NIP-65 relay list diff --git a/PKGBUILD b/PKGBUILD index 8f0a69a..6d6dfe4 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: hoornet pkgname=wrystr -pkgver=0.8.1 +pkgver=0.8.2 pkgrel=1 pkgdesc="Cross-platform Nostr desktop client with Lightning integration" arch=('x86_64') diff --git a/package.json b/package.json index e3ce928..2bb7244 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wrystr", "private": true, - "version": "0.8.1", + "version": "0.8.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 117860a..b619151 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wrystr" -version = "0.8.1" +version = "0.8.2" 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 76b5daa..9226669 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.8.1", + "version": "0.8.2", "identifier": "com.hoornet.wrystr", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/article/ArticleView.tsx b/src/components/article/ArticleView.tsx index 53551c9..5ce661a 100644 --- a/src/components/article/ArticleView.tsx +++ b/src/components/article/ArticleView.tsx @@ -9,6 +9,14 @@ import { fetchArticle, publishReaction } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; import { ZapModal } from "../zap/ZapModal"; +// ── Types ──────────────────────────────────────────────────────────────────── + +interface TocHeading { + id: string; + text: string; + level: number; +} + // ── Helpers ────────────────────────────────────────────────────────────────── function getTag(event: NDKEvent, name: string): string { @@ -21,7 +29,7 @@ function getTags(event: NDKEvent, name: string): string[] { function renderMarkdown(md: string): string { const html = marked(md, { breaks: true }) as string; - return DOMPurify.sanitize(html); + return DOMPurify.sanitize(html, { ADD_ATTR: ["id"] }); } // ── Author row ──────────────────────────────────────────────────────────────── @@ -121,9 +129,27 @@ export function ArticleView() { const readingTime = Math.max(1, Math.ceil(wordCount / 230)); const bookmarked = event?.id ? isBookmarked(event.id) : false; - // Reading progress bar + // Reading progress bar + TOC const scrollRef = useRef(null); + const contentRef = useRef(null); const [progress, setProgress] = useState(0); + const [headings, setHeadings] = useState([]); + const [activeId, setActiveId] = useState(""); + + // Extract headings from rendered content and assign IDs + useEffect(() => { + const el = contentRef.current; + if (!el) { setHeadings([]); return; } + const nodes = el.querySelectorAll("h2, h3"); + const items: TocHeading[] = []; + nodes.forEach((node, i) => { + const id = `toc-${i}`; + node.id = id; + items.push({ id, text: node.textContent || "", level: parseInt(node.tagName[1]) }); + }); + setHeadings(items); + if (items.length > 0) setActiveId(items[0].id); + }, [bodyHtml]); const handleScroll = useCallback(() => { const el = scrollRef.current; @@ -131,7 +157,18 @@ export function ArticleView() { const { scrollTop, scrollHeight, clientHeight } = el; const max = scrollHeight - clientHeight; setProgress(max > 0 ? (scrollTop / max) * 100 : 0); - }, []); + + // Track active heading — find the last heading above the top ~80px of scroll area + const scrollRect = el.getBoundingClientRect(); + let active = ""; + for (const { id } of headings) { + const heading = document.getElementById(id); + if (!heading) continue; + const top = heading.getBoundingClientRect().top - scrollRect.top; + if (top <= 80) active = id; + } + if (active) setActiveId(active); + }, [headings]); useEffect(() => { const el = scrollRef.current; @@ -140,6 +177,16 @@ export function ArticleView() { return () => el.removeEventListener("scroll", handleScroll); }, [event, handleScroll]); + const scrollToHeading = useCallback((id: string) => { + const el = document.getElementById(id); + const scrollEl = scrollRef.current; + if (!el || !scrollEl) return; + const scrollRect = scrollEl.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const offset = elRect.top - scrollRect.top + scrollEl.scrollTop - 16; + scrollEl.scrollTo({ top: offset, behavior: "smooth" }); + }, []); + const handleReaction = async () => { if (!event?.id || reacted) return; setReacted(true); @@ -227,93 +274,121 @@ export function ArticleView() { )} {event && ( -
- {/* Cover image */} - {image && ( -
- { (e.target as HTMLImageElement).style.display = "none"; }} - /> +
+
+ {/* Cover image */} + {image && ( +
+ { (e.target as HTMLImageElement).style.display = "none"; }} + /> +
+ )} + + {/* Title */} +

+ {title || "Untitled"} +

+ + {/* Summary */} + {summary && ( +

+ {summary} +

+ )} + + {/* Author + date + reading time */} + + + {/* Tags */} + {articleTags.length > 0 && ( +
+ {articleTags.map((tag) => ( + + #{tag} + + ))} +
+ )} + + {/* Content */} +
+ + {/* Footer */} +
+ +
+ {loggedIn && ( + + )} + {loggedIn && ( + + )} + {loggedIn && ( + + )} +
+
+ + {/* Table of Contents — right margin on wide screens */} + {headings.length >= 2 && ( + )} - - {/* Title */} -

- {title || "Untitled"} -

- - {/* Summary */} - {summary && ( -

- {summary} -

- )} - - {/* Author + date + reading time */} - - - {/* Tags */} - {articleTags.length > 0 && ( -
- {articleTags.map((tag) => ( - - #{tag} - - ))} -
- )} - - {/* Content */} -
- - {/* Footer */} -
- -
- {loggedIn && ( - - )} - {loggedIn && ( - - )} - {loggedIn && ( - - )} -
-
-
+ )}