mirror of
https://github.com/hoornet/vega.git
synced 2026-06-11 15:33:32 -07:00
Bump to v0.8.2 — writing & reading polish, article TOC
- Remove 280-char note limit, soft warning at 4000 - Serif reading font (Georgia) at 17px for articles - Reading progress bar on articles - Article table of contents (floating right, scroll-aware) - Connection indicator fix (data-aware, 25s grace)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||
pkgname=wrystr
|
||||
pkgver=0.8.1
|
||||
pkgver=0.8.2
|
||||
pkgrel=1
|
||||
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
||||
arch=('x86_64')
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wrystr",
|
||||
"private": true,
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [headings, setHeadings] = useState<TocHeading[]>([]);
|
||||
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 && (
|
||||
<article className="max-w-2xl mx-auto px-6 py-8">
|
||||
{/* Cover image */}
|
||||
{image && (
|
||||
<div className="mb-6 -mx-2">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full max-h-72 object-cover rounded-sm"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<div className="flex">
|
||||
<article className="flex-1 max-w-2xl mx-auto px-6 py-8">
|
||||
{/* Cover image */}
|
||||
{image && (
|
||||
<div className="mb-6 -mx-2">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full max-h-72 object-cover rounded-sm"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-text text-2xl font-bold leading-tight mb-3 tracking-tight">
|
||||
{title || "Untitled"}
|
||||
</h1>
|
||||
|
||||
{/* Summary */}
|
||||
{summary && (
|
||||
<p className="text-text-muted text-[14px] leading-relaxed mb-4 italic border-l-2 border-border pl-3">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Author + date + reading time */}
|
||||
<AuthorRow pubkey={authorPubkey} publishedAt={publishedAt} readingTime={readingTime} />
|
||||
|
||||
{/* Tags */}
|
||||
{articleTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-6">
|
||||
{articleTags.map((tag) => (
|
||||
<span key={tag} className="px-2 py-0.5 text-[10px] border border-border text-text-dim">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="prose-article"
|
||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-10 pt-6 border-t border-border flex items-center justify-between">
|
||||
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
|
||||
← back
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{loggedIn && (
|
||||
<button
|
||||
onClick={handleReaction}
|
||||
disabled={reacted}
|
||||
className={`text-[11px] px-3 py-1.5 border transition-colors disabled:cursor-not-allowed ${
|
||||
reacted
|
||||
? "border-accent/40 text-accent"
|
||||
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
{reacted ? "♥ liked" : "♡ like"}
|
||||
</button>
|
||||
)}
|
||||
{loggedIn && (
|
||||
<button
|
||||
onClick={handleBookmark}
|
||||
className={`text-[11px] px-3 py-1.5 border transition-colors ${
|
||||
bookmarked
|
||||
? "border-accent/40 text-accent"
|
||||
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
{bookmarked ? "▪ saved" : "▫ save"}
|
||||
</button>
|
||||
)}
|
||||
{loggedIn && (
|
||||
<button
|
||||
onClick={() => setShowZap(true)}
|
||||
className="text-[11px] px-4 py-1.5 bg-zap hover:bg-zap/90 text-white transition-colors"
|
||||
>
|
||||
⚡ Zap {authorName}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Table of Contents — right margin on wide screens */}
|
||||
{headings.length >= 2 && (
|
||||
<nav className="hidden xl:block w-44 shrink-0 py-8 pr-4">
|
||||
<div className="sticky top-4 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
||||
<p className="text-text-dim text-[10px] uppercase tracking-wider mb-3">Contents</p>
|
||||
{headings.map(({ id, text, level }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => scrollToHeading(id)}
|
||||
className={`block text-left w-full py-1 text-[11px] leading-snug transition-colors truncate ${
|
||||
level === 3 ? "pl-3" : ""
|
||||
} ${
|
||||
activeId === id
|
||||
? "text-accent"
|
||||
: "text-text-dim hover:text-text-muted"
|
||||
}`}
|
||||
title={text}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-text text-2xl font-bold leading-tight mb-3 tracking-tight">
|
||||
{title || "Untitled"}
|
||||
</h1>
|
||||
|
||||
{/* Summary */}
|
||||
{summary && (
|
||||
<p className="text-text-muted text-[14px] leading-relaxed mb-4 italic border-l-2 border-border pl-3">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Author + date + reading time */}
|
||||
<AuthorRow pubkey={authorPubkey} publishedAt={publishedAt} readingTime={readingTime} />
|
||||
|
||||
{/* Tags */}
|
||||
{articleTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-6">
|
||||
{articleTags.map((tag) => (
|
||||
<span key={tag} className="px-2 py-0.5 text-[10px] border border-border text-text-dim">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="prose-article"
|
||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-10 pt-6 border-t border-border flex items-center justify-between">
|
||||
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
|
||||
← back
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{loggedIn && (
|
||||
<button
|
||||
onClick={handleReaction}
|
||||
disabled={reacted}
|
||||
className={`text-[11px] px-3 py-1.5 border transition-colors disabled:cursor-not-allowed ${
|
||||
reacted
|
||||
? "border-accent/40 text-accent"
|
||||
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
{reacted ? "♥ liked" : "♡ like"}
|
||||
</button>
|
||||
)}
|
||||
{loggedIn && (
|
||||
<button
|
||||
onClick={handleBookmark}
|
||||
className={`text-[11px] px-3 py-1.5 border transition-colors ${
|
||||
bookmarked
|
||||
? "border-accent/40 text-accent"
|
||||
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
||||
}`}
|
||||
>
|
||||
{bookmarked ? "▪ saved" : "▫ save"}
|
||||
</button>
|
||||
)}
|
||||
{loggedIn && (
|
||||
<button
|
||||
onClick={() => setShowZap(true)}
|
||||
className="text-[11px] px-4 py-1.5 bg-zap hover:bg-zap/90 text-white transition-colors"
|
||||
>
|
||||
⚡ Zap {authorName}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user