From d62cf73510346695ed975c3ec40660787fbe2e13 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:50:33 +0100 Subject: [PATCH] Writing & reading polish: remove 280-char limit, serif reader font, progress bar - ComposeBox: remove hard 280-char post limit (Nostr has none); show counter only after 3000 chars with yellow/red warnings at 3500/4000 - Article reader: switch from monospace to serif font (Georgia stack) at 17px for comfortable long-form reading; article preview gets serif at 15px - ArticleView: add 2px accent-colored reading progress bar (sticky top, scroll-driven, smooth transition) - Connection indicator: data-aware checking (wraps fetchEvents), 30s recent- fetch grace period, 25s offline grace (5 checks) before marking disconnected --- src/components/article/ArticleView.tsx | 32 ++++++++++++++++++++++++-- src/components/feed/ComposeBox.tsx | 9 ++++---- src/index.css | 5 +++- src/stores/feed.ts | 31 +++++++++++++++++++------ 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/components/article/ArticleView.tsx b/src/components/article/ArticleView.tsx index 59e01db..53551c9 100644 --- a/src/components/article/ArticleView.tsx +++ b/src/components/article/ArticleView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { marked } from "marked"; import DOMPurify from "dompurify"; @@ -121,6 +121,25 @@ export function ArticleView() { const readingTime = Math.max(1, Math.ceil(wordCount / 230)); const bookmarked = event?.id ? isBookmarked(event.id) : false; + // Reading progress bar + const scrollRef = useRef(null); + const [progress, setProgress] = useState(0); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const { scrollTop, scrollHeight, clientHeight } = el; + const max = scrollHeight - clientHeight; + setProgress(max > 0 ? (scrollTop / max) * 100 : 0); + }, []); + + useEffect(() => { + const el = scrollRef.current; + if (!el || !event) return; + el.addEventListener("scroll", handleScroll, { passive: true }); + return () => el.removeEventListener("scroll", handleScroll); + }, [event, handleScroll]); + const handleReaction = async () => { if (!event?.id || reacted) return; setReacted(true); @@ -179,7 +198,16 @@ export function ArticleView() { {/* Body */} -
+
+ {/* Reading progress bar */} + {event && ( +
+
+
+ )} {loading && (
Loading article…
)} diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx index 1f60ce6..8f04b77 100644 --- a/src/components/feed/ComposeBox.tsx +++ b/src/components/feed/ComposeBox.tsx @@ -36,8 +36,9 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = }, [text]); const charCount = text.length; - const overLimit = charCount > 280; - const canPost = text.trim().length > 0 && !overLimit && !publishing && !uploading; + const warnLimit = charCount > 3500; + const overLimit = charCount > 4000; + const canPost = text.trim().length > 0 && !publishing && !uploading; // Insert a URL at the current cursor position in the textarea const insertUrl = (url: string) => { @@ -225,13 +226,13 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = )}
- + {uploading ? ( uploading… - ) : charCount > 0 ? `${charCount}/280` : ""} + ) : charCount > 3000 ? `${charCount}` : ""} {!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && ( (draft) )} diff --git a/src/index.css b/src/index.css index 8d45772..3197ce0 100644 --- a/src/index.css +++ b/src/index.css @@ -16,6 +16,7 @@ --color-warning: #f59e0b; --color-success: #22c55e; --font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace; + --font-reading: Georgia, "Times New Roman", "Noto Serif", serif; } * { @@ -50,6 +51,8 @@ body { .article-preview { -webkit-user-select: text; user-select: text; + font-family: var(--font-reading); + font-size: 15px; } .article-preview h1 { font-size: 1.6em; font-weight: bold; margin: 1.2em 0 0.5em; } .article-preview h2 { font-size: 1.3em; font-weight: bold; margin: 1.2em 0 0.5em; } @@ -68,7 +71,7 @@ body { .article-preview img { max-width: 100%; border-radius: 2px; margin: 0.5em 0; } /* Article reader — slightly larger type than the editor preview */ -.prose-article { -webkit-user-select: text; user-select: text; color: var(--color-text); font-size: 15px; line-height: 1.8; } +.prose-article { -webkit-user-select: text; user-select: text; color: var(--color-text); font-family: var(--font-reading); font-size: 17px; line-height: 1.8; } .prose-article h1 { font-size: 1.7em; font-weight: bold; margin: 1.4em 0 0.5em; } .prose-article h2 { font-size: 1.35em; font-weight: bold; margin: 1.4em 0 0.5em; border-bottom: 1px solid var(--color-border); padding-bottom: 0.3em; } .prose-article h3 { font-size: 1.15em; font-weight: bold; margin: 1.2em 0 0.4em; } diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 3a62ddb..98b492a 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -38,23 +38,40 @@ export const useFeedStore = create((set, get) => ({ set({ connected: true }); // Monitor relay connectivity with grace period. - // NDK relays can briefly show connected=false during WebSocket - // reconnection cycles, so we require multiple consecutive "offline" - // checks before flipping the indicator, and attempt reconnection. + // NDK's relay.connected property is unreliable — it can briefly + // read false during WebSocket reconnection or message processing, + // even when data flows fine. We also check relay.status and use + // a generous grace period before marking offline. const ndk = getNDK(); let offlineStreak = 0; + let lastSuccessfulFetch = Date.now(); + + // Mark connected whenever a successful fetch happens anywhere + const originalFetch = ndk.fetchEvents.bind(ndk); + ndk.fetchEvents = async (...args: Parameters) => { + const result = await originalFetch(...args); + if (result.size > 0) { + lastSuccessfulFetch = Date.now(); + if (!get().connected) set({ connected: true }); + offlineStreak = 0; + } + return result; + }; + const checkConnection = () => { const relays = Array.from(ndk.pool?.relays?.values() ?? []); const hasConnected = relays.some((r) => r.connected); - if (hasConnected) { + // Also consider connected if we fetched data recently (within 30s) + const recentFetch = Date.now() - lastSuccessfulFetch < 30000; + + if (hasConnected || recentFetch) { offlineStreak = 0; if (!get().connected) set({ connected: true }); } else { offlineStreak++; - // Only mark offline after 3 consecutive checks (15s grace) - if (offlineStreak >= 3 && get().connected) { + // Only mark offline after 5 consecutive checks (25s grace) + if (offlineStreak >= 5 && get().connected) { set({ connected: false }); - // Attempt reconnection ndk.connect().catch(() => {}); } }