diff --git a/package-lock.json b/package-lock.json index 11ffee4..e6f00dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wrystr", - "version": "0.6.0", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wrystr", - "version": "0.6.0", + "version": "0.8.2", "dependencies": { "@nostr-dev-kit/ndk": "^3.0.3", "@tailwindcss/vite": "^4.2.1", @@ -14,12 +14,15 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-http": "^2.5.7", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", "@types/dompurify": "^3.0.5", "dompurify": "^3.3.2", + "highlight.js": "^11.11.1", "marked": "^17.0.4", + "marked-highlight": "^2.2.3", "react": "^19.1.0", "react-dom": "^19.1.0", "react-qr-code": "^2.0.18", @@ -2152,6 +2155,15 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", @@ -3085,6 +3097,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -3561,6 +3582,15 @@ "node": ">= 20" } }, + "node_modules/marked-highlight": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.3.tgz", + "integrity": "sha512-FCfZRxW/msZAiasCML4isYpxyQWKEEx44vOgdn5Kloae+Qc3q4XR7WjpKKf8oMLk7JP9ZCRd2vhtclJFdwxlWQ==", + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <18" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", diff --git a/package.json b/package.json index 2bb7244..04387a1 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,15 @@ "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-http": "^2.5.7", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", "@types/dompurify": "^3.0.5", "dompurify": "^3.3.2", + "highlight.js": "^11.11.1", "marked": "^17.0.4", + "marked-highlight": "^2.2.3", "react": "^19.1.0", "react-dom": "^19.1.0", "react-qr-code": "^2.0.18", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b619151..c0171d6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,4 +29,5 @@ rusqlite = { version = "0.32", features = ["bundled"] } tauri-plugin-http = "2.5.7" tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" +tauri-plugin-notification = "2.3.3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index db07f97..10694a8 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -20,6 +20,7 @@ "allow": [ { "path": "**" } ] - } + }, + "notification:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6207123..121ca77 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -144,6 +144,7 @@ pub fn run() { .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) + .plugin(tauri_plugin_notification::init()) .setup(|app| { // ── SQLite ─────────────────────────────────────────────────────── let data_dir = app.path().app_data_dir()?; diff --git a/src/components/article/ArticleEditor.tsx b/src/components/article/ArticleEditor.tsx index 0b492bb..4068a3c 100644 --- a/src/components/article/ArticleEditor.tsx +++ b/src/components/article/ArticleEditor.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from "react"; -import { marked } from "marked"; +import { renderMarkdown } from "../../lib/markdown"; import { publishArticle } from "../../lib/nostr"; import { useUIStore } from "../../stores/ui"; import { MarkdownToolbar, handleEditorKeyDown } from "./MarkdownToolbar"; @@ -100,7 +100,7 @@ export function ArticleEditor() { return () => clearInterval(iv); }, [lastSaved]); - const renderedHtml = marked(content || "*Nothing to preview yet.*") as string; + const renderedHtml = renderMarkdown(content || "*Nothing to preview yet.*"); const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0; const canPublish = title.trim().length > 0 && content.trim().length > 0; diff --git a/src/components/article/ArticleView.tsx b/src/components/article/ArticleView.tsx index 5ce661a..ed2ebd2 100644 --- a/src/components/article/ArticleView.tsx +++ b/src/components/article/ArticleView.tsx @@ -1,7 +1,6 @@ import { useEffect, useState, useRef, useCallback } from "react"; import { NDKEvent } from "@nostr-dev-kit/ndk"; -import { marked } from "marked"; -import DOMPurify from "dompurify"; +import { renderMarkdown } from "../../lib/markdown"; import { useUIStore } from "../../stores/ui"; import { useUserStore } from "../../stores/user"; import { useBookmarkStore } from "../../stores/bookmark"; @@ -27,11 +26,6 @@ function getTags(event: NDKEvent, name: string): string[] { return event.tags.filter((t) => t[0] === name).map((t) => t[1]).filter(Boolean); } -function renderMarkdown(md: string): string { - const html = marked(md, { breaks: true }) as string; - return DOMPurify.sanitize(html, { ADD_ATTR: ["id"] }); -} - // ── Author row ──────────────────────────────────────────────────────────────── function AuthorRow({ pubkey, publishedAt, readingTime }: { pubkey: string; publishedAt: number | null; readingTime?: number }) { diff --git a/src/components/shared/SettingsView.tsx b/src/components/shared/SettingsView.tsx index 7422839..da67c2f 100644 --- a/src/components/shared/SettingsView.tsx +++ b/src/components/shared/SettingsView.tsx @@ -7,6 +7,7 @@ import { useBookmarkStore } from "../../stores/bookmark"; import { getNDK, getStoredRelayUrls, addRelay, removeRelay, publishRelayList } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; import { NWCWizard } from "./NWCWizard"; +import { getNotificationSettings, saveNotificationSettings, ensurePermission } from "../../lib/notifications"; function MutedRow({ pubkey, onUnmute }: { pubkey: string; onUnmute: () => void }) { const profile = useProfile(pubkey); @@ -263,6 +264,54 @@ function ExportSection() { ); } +function NotificationSection() { + const [settings, setSettings] = useState(getNotificationSettings); + + const toggle = (key: "mentions" | "dms" | "zaps") => { + const next = { ...settings, [key]: !settings[key] }; + setSettings(next); + saveNotificationSettings(next); + // Request permission on first enable + if (next[key]) ensurePermission().catch(() => {}); + }; + + const items: Array<{ key: "mentions" | "dms" | "zaps"; label: string }> = [ + { key: "mentions", label: "Mentions" }, + { key: "dms", label: "Direct messages" }, + { key: "zaps", label: "Zaps received" }, + ]; + + return ( +
+

+ Notifications +

+

+ OS-level push notifications. Requires system permission. +

+
+ {items.map(({ key, label }) => ( + + ))} +
+
+ ); +} + export function SettingsView() { return (
@@ -272,6 +321,7 @@ export function SettingsView() {
+ diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 0000000..d3f2305 --- /dev/null +++ b/src/lib/markdown.ts @@ -0,0 +1,57 @@ +import { marked } from "marked"; +import { markedHighlight } from "marked-highlight"; +import DOMPurify from "dompurify"; +import hljs from "highlight.js/lib/core"; +import "highlight.js/styles/atom-one-dark.min.css"; + +// Register commonly used languages (keeps bundle small) +import javascript from "highlight.js/lib/languages/javascript"; +import typescript from "highlight.js/lib/languages/typescript"; +import python from "highlight.js/lib/languages/python"; +import rust from "highlight.js/lib/languages/rust"; +import json from "highlight.js/lib/languages/json"; +import bash from "highlight.js/lib/languages/bash"; +import css from "highlight.js/lib/languages/css"; +import xml from "highlight.js/lib/languages/xml"; +import markdown from "highlight.js/lib/languages/markdown"; +import go from "highlight.js/lib/languages/go"; +import sql from "highlight.js/lib/languages/sql"; +import yaml from "highlight.js/lib/languages/yaml"; + +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("js", javascript); +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("ts", typescript); +hljs.registerLanguage("python", python); +hljs.registerLanguage("rust", rust); +hljs.registerLanguage("json", json); +hljs.registerLanguage("bash", bash); +hljs.registerLanguage("sh", bash); +hljs.registerLanguage("shell", bash); +hljs.registerLanguage("css", css); +hljs.registerLanguage("html", xml); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("markdown", markdown); +hljs.registerLanguage("md", markdown); +hljs.registerLanguage("go", go); +hljs.registerLanguage("sql", sql); +hljs.registerLanguage("yaml", yaml); +hljs.registerLanguage("yml", yaml); + +// Configure marked with highlight.js via marked-highlight extension +marked.use( + markedHighlight({ + highlight(code: string, lang: string) { + if (lang && hljs.getLanguage(lang)) { + return hljs.highlight(code, { language: lang }).value; + } + return hljs.highlightAuto(code).value; + }, + }), + { breaks: true }, +); + +export function renderMarkdown(md: string): string { + const html = marked(md) as string; + return DOMPurify.sanitize(html, { ADD_ATTR: ["id", "class"] }); +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts new file mode 100644 index 0000000..4057add --- /dev/null +++ b/src/lib/notifications.ts @@ -0,0 +1,67 @@ +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from "@tauri-apps/plugin-notification"; + +const SETTINGS_KEY = "wrystr_notification_settings"; + +interface NotificationSettings { + mentions: boolean; + dms: boolean; + zaps: boolean; +} + +const defaults: NotificationSettings = { mentions: true, dms: true, zaps: true }; + +export function getNotificationSettings(): NotificationSettings { + try { + const stored = localStorage.getItem(SETTINGS_KEY); + return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; + } catch { + return defaults; + } +} + +export function saveNotificationSettings(settings: NotificationSettings): void { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); +} + +export async function ensurePermission(): Promise { + let granted = await isPermissionGranted(); + if (!granted) { + const result = await requestPermission(); + granted = result === "granted"; + } + return granted; +} + +export async function notifyMention(authorName: string, preview: string): Promise { + const settings = getNotificationSettings(); + if (!settings.mentions) return; + if (!(await ensurePermission())) return; + sendNotification({ + title: `${authorName} mentioned you`, + body: preview.slice(0, 120), + }); +} + +export async function notifyDM(authorName: string, preview: string): Promise { + const settings = getNotificationSettings(); + if (!settings.dms) return; + if (!(await ensurePermission())) return; + sendNotification({ + title: `DM from ${authorName}`, + body: preview.slice(0, 120), + }); +} + +export async function notifyZap(senderName: string, amount: number): Promise { + const settings = getNotificationSettings(); + if (!settings.zaps) return; + if (!(await ensurePermission())) return; + sendNotification({ + title: `${senderName} zapped you`, + body: `${amount.toLocaleString()} sats`, + }); +} diff --git a/src/stores/notifications.ts b/src/stores/notifications.ts index f9461cd..58069fc 100644 --- a/src/stores/notifications.ts +++ b/src/stores/notifications.ts @@ -1,6 +1,7 @@ import { create } from "zustand"; import { NDKEvent } from "@nostr-dev-kit/ndk"; import { fetchMentions } from "../lib/nostr"; +import { notifyMention, notifyDM } from "../lib/notifications"; const NOTIF_SEEN_KEY = "wrystr_notif_last_seen"; const DM_SEEN_KEY = "wrystr_dm_last_seen"; @@ -52,7 +53,16 @@ export const useNotificationsStore = create((set, get) => ({ try { const lastSeenAt = isNewAccount ? loadLastSeen() : get().lastSeenAt; const events = await fetchMentions(pubkey, lastSeenAt); - const unreadCount = events.filter((e) => (e.created_at ?? 0) > lastSeenAt).length; + const newEvents = events.filter((e) => (e.created_at ?? 0) > lastSeenAt); + const unreadCount = newEvents.length; + // Fire OS notification for new mentions (only truly new ones since last fetch) + const prevCount = get().unreadCount; + if (unreadCount > prevCount && newEvents.length > 0) { + const latest = newEvents[0]; + const authorName = latest.pubkey.slice(0, 8) + "…"; + const preview = latest.content?.slice(0, 120) || "mentioned you"; + notifyMention(authorName, preview).catch(() => {}); + } set({ notifications: events, unreadCount, lastSeenAt }); } catch { // Non-critical @@ -76,10 +86,17 @@ export const useNotificationsStore = create((set, get) => ({ }, computeDMUnread: (conversations: Array<{ partnerPubkey: string; lastAt: number }>) => { - const { dmLastSeen } = get(); - const dmUnreadCount = conversations.filter( + const { dmLastSeen, dmUnreadCount: prevCount } = get(); + const unreadConvos = conversations.filter( (c) => c.lastAt > (dmLastSeen[c.partnerPubkey] ?? 0) - ).length; + ); + const dmUnreadCount = unreadConvos.length; + // Fire OS notification if new unread DMs appeared + if (dmUnreadCount > prevCount && unreadConvos.length > 0) { + const latest = unreadConvos[0]; + const name = latest.partnerPubkey.slice(0, 8) + "…"; + notifyDM(name, "New message").catch(() => {}); + } set({ dmUnreadCount }); }, }));