mirror of
https://github.com/hoornet/vega.git
synced 2026-05-12 15:28:37 -07:00
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
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
@@ -121,6 +121,25 @@ export function ArticleView() {
|
|||||||
const readingTime = Math.max(1, Math.ceil(wordCount / 230));
|
const readingTime = Math.max(1, Math.ceil(wordCount / 230));
|
||||||
const bookmarked = event?.id ? isBookmarked(event.id) : false;
|
const bookmarked = event?.id ? isBookmarked(event.id) : false;
|
||||||
|
|
||||||
|
// Reading progress bar
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(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 () => {
|
const handleReaction = async () => {
|
||||||
if (!event?.id || reacted) return;
|
if (!event?.id || reacted) return;
|
||||||
setReacted(true);
|
setReacted(true);
|
||||||
@@ -179,7 +198,16 @@ export function ArticleView() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||||
|
{/* Reading progress bar */}
|
||||||
|
{event && (
|
||||||
|
<div className="sticky top-0 z-10 h-0.5 bg-transparent">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-[width] duration-150 ease-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="px-8 py-16 text-text-dim text-[12px] text-center">Loading article…</div>
|
<div className="px-8 py-16 text-text-dim text-[12px] text-center">Loading article…</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
const charCount = text.length;
|
const charCount = text.length;
|
||||||
const overLimit = charCount > 280;
|
const warnLimit = charCount > 3500;
|
||||||
const canPost = text.trim().length > 0 && !overLimit && !publishing && !uploading;
|
const overLimit = charCount > 4000;
|
||||||
|
const canPost = text.trim().length > 0 && !publishing && !uploading;
|
||||||
|
|
||||||
// Insert a URL at the current cursor position in the textarea
|
// Insert a URL at the current cursor position in the textarea
|
||||||
const insertUrl = (url: string) => {
|
const insertUrl = (url: string) => {
|
||||||
@@ -225,13 +226,13 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}>
|
<span className={`text-[10px] ${overLimit ? "text-danger" : warnLimit ? "text-warning" : "text-text-dim"}`}>
|
||||||
{uploading ? (
|
{uploading ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
|
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
uploading…
|
uploading…
|
||||||
</span>
|
</span>
|
||||||
) : charCount > 0 ? `${charCount}/280` : ""}
|
) : charCount > 3000 ? `${charCount}` : ""}
|
||||||
{!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && (
|
{!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && (
|
||||||
<span className="ml-1 text-text-dim">(draft)</span>
|
<span className="ml-1 text-text-dim">(draft)</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
--color-warning: #f59e0b;
|
--color-warning: #f59e0b;
|
||||||
--color-success: #22c55e;
|
--color-success: #22c55e;
|
||||||
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
--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 {
|
.article-preview {
|
||||||
-webkit-user-select: text;
|
-webkit-user-select: text;
|
||||||
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 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; }
|
.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-preview img { max-width: 100%; border-radius: 2px; margin: 0.5em 0; }
|
||||||
|
|
||||||
/* Article reader — slightly larger type than the editor preview */
|
/* 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 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 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; }
|
.prose-article h3 { font-size: 1.15em; font-weight: bold; margin: 1.2em 0 0.4em; }
|
||||||
|
|||||||
@@ -38,23 +38,40 @@ export const useFeedStore = create<FeedState>((set, get) => ({
|
|||||||
set({ connected: true });
|
set({ connected: true });
|
||||||
|
|
||||||
// Monitor relay connectivity with grace period.
|
// Monitor relay connectivity with grace period.
|
||||||
// NDK relays can briefly show connected=false during WebSocket
|
// NDK's relay.connected property is unreliable — it can briefly
|
||||||
// reconnection cycles, so we require multiple consecutive "offline"
|
// read false during WebSocket reconnection or message processing,
|
||||||
// checks before flipping the indicator, and attempt reconnection.
|
// even when data flows fine. We also check relay.status and use
|
||||||
|
// a generous grace period before marking offline.
|
||||||
const ndk = getNDK();
|
const ndk = getNDK();
|
||||||
let offlineStreak = 0;
|
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<typeof ndk.fetchEvents>) => {
|
||||||
|
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 checkConnection = () => {
|
||||||
const relays = Array.from(ndk.pool?.relays?.values() ?? []);
|
const relays = Array.from(ndk.pool?.relays?.values() ?? []);
|
||||||
const hasConnected = relays.some((r) => r.connected);
|
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;
|
offlineStreak = 0;
|
||||||
if (!get().connected) set({ connected: true });
|
if (!get().connected) set({ connected: true });
|
||||||
} else {
|
} else {
|
||||||
offlineStreak++;
|
offlineStreak++;
|
||||||
// Only mark offline after 3 consecutive checks (15s grace)
|
// Only mark offline after 5 consecutive checks (25s grace)
|
||||||
if (offlineStreak >= 3 && get().connected) {
|
if (offlineStreak >= 5 && get().connected) {
|
||||||
set({ connected: false });
|
set({ connected: false });
|
||||||
// Attempt reconnection
|
|
||||||
ndk.connect().catch(() => {});
|
ndk.connect().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user