From 9169e0b50b2d6fcc6815495eab482adb83ba37c2 Mon Sep 17 00:00:00 2001 From: Jiaxing Hu Date: Sun, 2 Aug 2026 22:47:34 +1200 Subject: [PATCH] feat: add a native macOS build with new Preferences and notifications Koshi now builds and runs as a standalone .app on macOS with no Homebrew dependency at runtime. build-aux/macos/bundle.sh compiles the release binary, relinks every dylib it needs with dylibbundler, and bundles the gdk-pixbuf loaders, the GIO TLS module, the Adwaita and hicolor icon themes, and compiled GSettings schemas alongside it. A matching build-macos job in the release workflow packages the result as a .dmg and attaches it to the GitHub release next to the Flatpak; it also installs adwaita-icon-theme explicitly, since neither gtk4 nor libadwaita depends on it and a Homebrew prefix that has never had another GTK app built on it (a fresh CI runner, mainly) won't have it. On macOS the window buttons now match every other platform instead of showing native traffic lights: GtkHeaderBar and GtkWindowControls both default to platform-native controls there since GTK 4.18, so the main window and every dialog force use-native-controls off and move the buttons to the right. AdwDialog also hardcodes its own close button to the start side with no public way to reposition it, so dialogs get a plain close button of their own on the end side instead. Keyboard shortcuts move from a hardcoded to (plus an explicit fallback on macOS, where 's resolution to Cmd has proven unreliable in practice), so Cmd+F and friends work as expected instead of only ever answering to a literal Ctrl press. New replies notify through UNUserNotificationCenter on macOS, since there is no session D-Bus for the existing XDG portal path to use. Authorization is requested once at startup rather than on the first notification, because addNotificationRequest fails outright while authorization is still undecided and asking any later would lose a fresh install's first notification to that race. A warning row in Preferences points the user at System Settings if they end up denying it. Preferences also picks up a few things asked for directly: adjustable text size for message bodies and, separately, for the whole interface (the interface one scales through gtk-xft-dpi, the same knob GNOME's own large-text accessibility setting uses, so it reaches menus and dialogs too); a system/light/dark theme switch; and, in the thread view, per-message toggles to fold away quoted text and diff hunks without losing the row's place in the list. Folding a line only shrinks the row once the tag covers its trailing newline, not just its glyphs, and diff-folding now hides a hunk's unchanged context lines along with the colored ones - leaving them out stranded isolated fragments of code with blank gaps around them instead of closing the hunk. Thread parsing now drops a message repeated under the same Message-ID: lore mirrors a cross-posted message once per list it was sent to, so a thread spanning more than one list could come back from t.mbox.gz with the same message twice, showing as a genuine duplicate in the thread view and, since the watcher's digest pass goes through the same parser, as a duplicate notification too. The profile popover gained an editor for git send-email identities. Creating, editing, or deleting one still writes straight to the user's global git config, matching how profile.rs already treats git as the only account store Koshi keeps. Finally, a new Sent page (mirroring Favorites/Subscriptions) logs every reply once git send-email has actually confirmed delivery - subject, recipients, when, and, for a reply to a thread Koshi had open, enough to reopen that thread the same way a favorite does. --- .github/workflows/release.yml | 60 ++++++- .gitignore | 1 + Cargo.lock | 74 ++++++++ Cargo.toml | 6 + build-aux/macos/bundle.sh | 173 +++++++++++++++++++ src/composer.rs | 37 +++- src/highlight.rs | 213 ++++++++++++++++++++++- src/main.rs | 317 +++++++++++++++++++++++++++++++++- src/profile.rs | 49 ++++++ src/profile_menu.rs | 316 +++++++++++++++++++++++++++++++-- src/sent.rs | 248 ++++++++++++++++++++++++++ src/sent_page.rs | 87 ++++++++++ src/settings.rs | 179 +++++++++++++++++++ src/thread_page.rs | 269 ++++++++++++++++++++++++++--- src/watcher.rs | 136 ++++++++++++++- 15 files changed, 2108 insertions(+), 57 deletions(-) create mode 100755 build-aux/macos/bundle.sh create mode 100644 src/sent.rs create mode 100644 src/sent_page.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba1cb2b..bfca059 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,11 +4,19 @@ name: Release # 1. build & GPG-sign the Flatpak, # 2. generate a build-provenance attestation for the bundle, # 3. publish the signed OSTree repo to GitHub Pages (dl.nikableh.moe), -# 4. create a GitHub Release with the .flatpak bundle attached. +# 4. build the standalone macOS .app (build-aux/macos/bundle.sh) and pack +# it into a .dmg, +# 5. create a GitHub Release with the .flatpak and .dmg attached. # # Requires repo secrets FLATPAK_GPG_PRIVATE_KEY and FLATPAK_GPG_KEY_ID, Pages # set to "GitHub Actions" as its source, and (for public reach) a public repo. # See docs/RELEASING.md for the one-time setup. +# +# The .dmg is ad-hoc signed only (no Apple Developer ID/notarization), so +# Gatekeeper will flag it as from an unidentified developer on first launch - +# right-click > Open, or System Settings > Privacy & Security > Open Anyway, +# clears it. Notarizing would need a paid Developer ID and its own secrets; +# out of scope until someone asks for it. on: push: @@ -124,6 +132,45 @@ jobs: path: dist/ if-no-files-found: error + build-macos: + name: Build macOS app (.dmg) + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Install build dependencies + # rust: cargo/rustc (edition 2024). gtk4/libadwaita/libsoup@3: the + # runtime stack bundle.sh links against and then bundles into the + # .app. dylibbundler: copies and relinks every dependent dylib. + # librsvg: gdk-pixbuf's SVG loader, and rsvg-convert for the app icon. + # adwaita-icon-theme: neither gtk4 nor libadwaita depends on it, but + # bundle.sh copies it in for the symbolic icons Koshi's UI uses - + # without it explicitly listed here, a runner with no other GTK app + # ever built on it (unlike a dev's already-populated Homebrew prefix) + # won't have it. + run: brew install rust gtk4 libadwaita libsoup@3 dylibbundler librsvg adwaita-icon-theme + + - name: Build Koshi.app + run: bash build-aux/macos/bundle.sh + + - name: Package as .dmg + run: | + set -eu + STAGE="$(mktemp -d)/Koshi" + mkdir -p "$STAGE" + cp -R target/macos/Koshi.app "$STAGE/" + ln -s /Applications "$STAGE/Applications" + mkdir -p dist + hdiutil create -volname Koshi -srcfolder "$STAGE" -ov -format UDZO dist/koshi.dmg + + - name: Upload release assets + uses: actions/upload-artifact@v4 + with: + name: release-assets-macos + path: dist/ + if-no-files-found: error + deploy-pages: name: Publish to dl.nikableh.moe needs: build @@ -138,16 +185,22 @@ jobs: release: name: Create GitHub Release - needs: build + needs: [build, build-macos] if: github.ref_type == 'tag' runs-on: ubuntu-latest steps: - - name: Download release assets + - name: Download Linux release assets uses: actions/download-artifact@v4 with: name: release-assets path: dist + - name: Download macOS release assets + uses: actions/download-artifact@v4 + with: + name: release-assets-macos + path: dist + - name: Publish release uses: softprops/action-gh-release@v2 with: @@ -156,5 +209,6 @@ jobs: dist/koshi.gpg dist/koshi.flatpakrepo dist/koshi.flatpakref + dist/koshi.dmg generate_release_notes: true fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index b95e17a..25d1134 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ result-* .direnv/ /.flatpak/ /.vscode/ +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 7733a62..942565b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "cairo-rs" version = "0.22.0" @@ -90,6 +99,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -559,6 +578,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" name = "koshi" version = "0.3.0" dependencies = [ + "block2", "flate2", "glib 0.22.8", "glib-build-tools", @@ -566,6 +586,9 @@ dependencies = [ "libadwaita", "log", "mailparse", + "objc2", + "objc2-foundation", + "objc2-user-notifications", "quick-xml", "serde_json", "soup3", @@ -650,6 +673,57 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "pango" version = "0.22.8" diff --git a/Cargo.toml b/Cargo.toml index 54f32bf..eb05172 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,9 @@ textwrap = { version = "0.16", default-features = false, features = [ [build-dependencies] glib-build-tools = "0.21" + +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6" +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-user-notifications = { version = "0.3", default-features = false, features = ["UNUserNotificationCenter", "UNNotificationContent", "UNNotificationRequest", "UNNotificationSettings", "std", "alloc", "block2", "UNNotificationTrigger"] } diff --git a/build-aux/macos/bundle.sh b/build-aux/macos/bundle.sh new file mode 100755 index 0000000..eccb321 --- /dev/null +++ b/build-aux/macos/bundle.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Build a standalone Koshi.app for macOS: compiles the release binary, then +# bundles every Homebrew dylib and GTK runtime resource (icon theme, +# gdk-pixbuf loaders, the GIO TLS module, GSettings schemas) it needs so the +# result runs on a Mac that never had Homebrew's GTK stack installed. See +# otool -L on the output binary/Frameworks - none of it should reference +# /opt/homebrew once this script finishes. +# +# Requires (via `brew install rust libsoup@3 dylibbundler librsvg`): +# cargo, pkg-config-visible gtk4/libadwaita/libsoup3, dylibbundler, +# rsvg-convert. Run from anywhere; paths below are relative to the repo root. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +BREW_PREFIX="$(brew --prefix)" +APP="target/macos/Koshi.app" +VERSION="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')" + +echo "==> Building release binary" +cargo build -r + +echo "==> Generating app icon" +ICONSET="target/macos/icon.iconset" +rm -rf "$ICONSET" && mkdir -p "$ICONSET" +SRC_SVG="data/icons/scalable/apps/moe.nikableh.Koshi.svg" +for sz in 16 32 64 128 256 512 1024; do + rsvg-convert -w "$sz" -h "$sz" "$SRC_SVG" -o "$ICONSET/tmp_${sz}.png" +done +cp "$ICONSET/tmp_16.png" "$ICONSET/icon_16x16.png" +cp "$ICONSET/tmp_32.png" "$ICONSET/icon_16x16@2x.png" +cp "$ICONSET/tmp_32.png" "$ICONSET/icon_32x32.png" +cp "$ICONSET/tmp_64.png" "$ICONSET/icon_32x32@2x.png" +cp "$ICONSET/tmp_128.png" "$ICONSET/icon_128x128.png" +cp "$ICONSET/tmp_256.png" "$ICONSET/icon_128x128@2x.png" +cp "$ICONSET/tmp_256.png" "$ICONSET/icon_256x256.png" +cp "$ICONSET/tmp_512.png" "$ICONSET/icon_256x256@2x.png" +cp "$ICONSET/tmp_512.png" "$ICONSET/icon_512x512.png" +cp "$ICONSET/tmp_1024.png" "$ICONSET/icon_512x512@2x.png" +rm "$ICONSET"/tmp_*.png +iconutil -c icns "$ICONSET" -o target/macos/koshi.icns + +echo "==> Creating bundle skeleton" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/Frameworks" +cp target/macos/koshi.icns "$APP/Contents/Resources/koshi.icns" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName + Koshi + CFBundleDisplayName + Koshi + CFBundleIdentifier + moe.nikableh.Koshi + CFBundleVersion + $VERSION + CFBundleShortVersionString + $VERSION + CFBundlePackageType + APPL + CFBundleExecutable + koshi + CFBundleIconFile + koshi.icns + CFBundleInfoDictionaryVersion + 6.0 + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.productivity + + +PLIST + +echo "==> Copying binary and dlopen'd modules" +cp target/release/koshi "$APP/Contents/MacOS/koshi-bin" +chmod +x "$APP/Contents/MacOS/koshi-bin" + +mkdir -p "$APP/Contents/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders" +cp "$BREW_PREFIX/lib/gdk-pixbuf-2.0/2.10.0/loaders/"*.so \ + "$APP/Contents/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders/" +mkdir -p "$APP/Contents/Resources/lib/gio/modules" +cp "$BREW_PREFIX/lib/gio/modules/libgiognutls.so" "$APP/Contents/Resources/lib/gio/modules/" +cp "$BREW_PREFIX/opt/gdk-pixbuf/bin/gdk-pixbuf-query-loaders" "$APP/Contents/MacOS/gdk-pixbuf-query-loaders" +chmod +w "$APP/Contents/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders/"*.so \ + "$APP/Contents/Resources/lib/gio/modules/"*.so \ + "$APP/Contents/MacOS/gdk-pixbuf-query-loaders" + +echo "==> Bundling dylibs (dylibbundler)" +# Every Mach-O we're shipping must go through ONE dylibbundler invocation +# with -od: it wipes the destination dir on each run, so a second pass would +# discard whatever the first pass copied for files not in that pass's -x list. +XARGS=(-x "$APP/Contents/MacOS/koshi-bin" -x "$APP/Contents/MacOS/gdk-pixbuf-query-loaders") +for f in "$APP"/Contents/Resources/lib/gdk-pixbuf-2.0/2.10.0/loaders/*.so \ + "$APP"/Contents/Resources/lib/gio/modules/*.so; do + XARGS+=(-x "$f") +done +dylibbundler -od -b "${XARGS[@]}" \ + -d "$APP/Contents/Frameworks" \ + -p @executable_path/../Frameworks/ \ + -s "$BREW_PREFIX/lib" \ + -s "$BREW_PREFIX/lib/gdk-pixbuf-2.0/2.10.0/loaders" \ + < /dev/null + +echo "==> De-duplicating LC_RPATH entries dylibbundler can leave behind" +# dylibbundler occasionally adds the same @executable_path/../Frameworks/ +# rpath to a file twice (seen on the librsvg gdk-pixbuf loader); dyld refuses +# to load a Mach-O with a duplicate LC_RPATH, so strip repeats down to one. +find "$APP" -type f | while read -r f; do + file "$f" 2>/dev/null | grep -q "Mach-O" || continue + dupes=$(otool -l "$f" 2>/dev/null | grep -A2 "cmd LC_RPATH" | grep "path " | sed -E 's/^ *path (.*) \(offset.*/\1/' | sort | uniq -d || true) + [ -z "$dupes" ] && continue + while IFS= read -r rpath; do + chmod +w "$f" + install_name_tool -delete_rpath "$rpath" "$f" + codesign --force --sign - "$f" 2>/dev/null + done <<< "$dupes" +done + +echo "==> Bundling Adwaita/hicolor icon themes and GSettings schemas" +# Neither gtk4 nor libadwaita depends on adwaita-icon-theme, so a prefix that +# has never had another GTK app built against it (a fresh CI runner, most +# often) won't have it unless it was installed explicitly - fail clearly here +# instead of a bare `cp: No such file or directory`. +if [ ! -d "$BREW_PREFIX/share/icons/Adwaita" ]; then + echo "error: $BREW_PREFIX/share/icons/Adwaita not found - run:" >&2 + echo " brew install adwaita-icon-theme" >&2 + exit 1 +fi +mkdir -p "$APP/Contents/Resources/share/icons" +cp -RL "$BREW_PREFIX/share/icons/Adwaita" "$APP/Contents/Resources/share/icons/" +cp -RL "$BREW_PREFIX/share/icons/hicolor" "$APP/Contents/Resources/share/icons/" + +mkdir -p "$APP/Contents/Resources/share/glib-2.0/schemas" +cp "$BREW_PREFIX/share/glib-2.0/schemas/org.gtk.gtk4.Settings."*.gschema.xml \ + "$APP/Contents/Resources/share/glib-2.0/schemas/" +glib-compile-schemas "$APP/Contents/Resources/share/glib-2.0/schemas/" + +echo "==> Writing launcher" +cat > "$APP/Contents/MacOS/koshi" <<'LAUNCHER' +#!/bin/bash +# Points the bundled GTK4/libadwaita stack at the resources shipped +# alongside it instead of Homebrew's /opt/homebrew. +set -e + +HERE="$(cd "$(dirname "$0")" && pwd)" +RES="$HERE/../Resources" +CACHE_DIR="$HOME/Library/Caches/moe.nikableh.Koshi" +mkdir -p "$CACHE_DIR" + +# gdk-pixbuf's loaders.cache embeds absolute paths, which depend on where +# this .app happens to be installed - regenerate it against the bundled +# loaders every launch (a handful of small files, this is instant). +GDK_PIXBUF_MODULEDIR="$RES/lib/gdk-pixbuf-2.0/2.10.0/loaders" \ + "$HERE/gdk-pixbuf-query-loaders" > "$CACHE_DIR/loaders.cache" + +export GDK_PIXBUF_MODULE_FILE="$CACHE_DIR/loaders.cache" +export GIO_EXTRA_MODULES="$RES/lib/gio/modules" +export GSETTINGS_SCHEMA_DIR="$RES/share/glib-2.0/schemas" +export XDG_DATA_DIRS="$RES/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" + +exec "$HERE/koshi-bin" "$@" +LAUNCHER +chmod +x "$APP/Contents/MacOS/koshi" + +echo "==> Done: $APP ($(du -sh "$APP" | cut -f1))" diff --git a/src/composer.rs b/src/composer.rs index 19b032f..b2f29df 100644 --- a/src/composer.rs +++ b/src/composer.rs @@ -29,6 +29,13 @@ pub struct ReplyContext { /// The parent message's `References` header, carried (not edited) so the /// reply can extend the chain. pub references: String, + /// The lore list and a Message-ID of the thread being replied to — any + /// member of the thread works, since Koshi always fetches a whole thread + /// by any one of its messages. `None` for a from-scratch compose, which + /// has no thread to link back to. Carried through only so a successful + /// send can log it in [`crate::sent`] for the Sent page's "view thread". + pub list: Option, + pub thread_message_id: Option, } impl ReplyContext { @@ -42,6 +49,8 @@ impl ReplyContext { subject: String::new(), in_reply_to: String::new(), references: String::new(), + list: None, + thread_message_id: None, } } } @@ -566,12 +575,23 @@ fn build_send_button(state: &ComposerState, surface: &Surface) -> gtk::Button { fn send_now(state: &ComposerState, button: >k::Button, surface: &Surface) { let doc = normalize_document(&state.document_text()); let (headers, _) = message::parse_headers(&doc); + let to = header_owned(&headers, "To"); + let cc = header_owned(&headers, "Cc"); + let subject = header_owned(&headers, "Subject"); let request = send::Request { from: header_owned(&headers, "From"), - to: header_owned(&headers, "To"), - cc: header_owned(&headers, "Cc"), + to: to.clone(), + cc: cc.clone(), eml: doc, }; + // The thread this is a reply to (if any) rides the composer's own + // ReplyContext, not the editable headers - see the doc comment on + // ReplyContext::list. Read now, before the async send, since a fast + // Discard/retarget elsewhere could otherwise change it out from under us. + let (list, thread_message_id) = { + let initial = state.initial.borrow(); + (initial.list.clone(), initial.thread_message_id.clone()) + }; // Resolve the toast surface now, while the button is still in the tree. let overlay = button .ancestor(adw::ToastOverlay::static_type()) @@ -590,6 +610,18 @@ fn send_now(state: &ComposerState, button: >k::Button, surface: &Surface) { button.set_sensitive(true); match outcome { Ok(send::Outcome::Sent) => { + crate::sent::record(crate::sent::SentMessage { + subject, + to, + cc, + sent_at: glib::DateTime::now_local() + .ok() + .and_then(|now| now.format("%a, %d %b %Y %H:%M").ok()) + .map(Into::into) + .unwrap_or_default(), + list, + thread_message_id, + }); if let Some(overlay) = &overlay { overlay.add_toast(adw::Toast::new("Reply sent")); } @@ -971,6 +1003,7 @@ fn build_body_editor(buffer: >k::TextBuffer, compact: bool) -> (gtk::Overlay, .right_margin(8) .top_margin(8) .bottom_margin(8) + .css_classes(["koshi-body-text"]) .build(); strip_extra_context_items(&view); diff --git a/src/highlight.rs b/src/highlight.rs index b3396bb..7267582 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -351,6 +351,80 @@ const ALL_TAGS: [&str; 6] = [ QUOTE_TAG, ADD_TAG, REMOVE_TAG, HUNK_TAG, HEADER_TAG, META_TAG, ]; +/// Fold tags: applied alongside the color tags above to every quote (any +/// depth) and every diff span respectively. Folding a message toggles the +/// tag's own `invisible` property rather than adding/removing it from text, +/// so it stays in step with [`refresh`]/[`refresh_step`] re-tagging the same +/// spans on every highlight pass. +const FOLD_QUOTE_TAG: &str = "koshi-fold-quote"; +const FOLD_DIFF_TAG: &str = "koshi-fold-diff"; +const FOLD_TAGS: [&str; 2] = [FOLD_QUOTE_TAG, FOLD_DIFF_TAG]; + +/// Per line of `text`, whether it is quoted (any depth) and whether it is +/// part of a diff — unlike [`classify`]'s color spans, this covers *every* +/// line a diff touches, including unchanged context lines, which carry no +/// span (and so no color) but still have to fold away for a diff-fold to +/// actually shrink the row instead of leaving a blank gap where they used to +/// be. Reuses [`step`], the same state machine `classify` and `preserve_mask` +/// both drive. +fn fold_membership(text: &str) -> Vec<(bool, bool)> { + let lines: Vec<&str> = text.split('\n').collect(); + let mut membership = Vec::with_capacity(lines.len()); + let mut state = State::None; + let mut state_depth = 0usize; + + for (n, line) in lines.iter().enumerate() { + let (depth, prefix) = split_quote(line); + if depth != state_depth { + state = State::None; + state_depth = depth; + } + let content = line[prefix..].strip_suffix('\r').unwrap_or(&line[prefix..]); + let next_is_plus = lines.get(n + 1).is_some_and(|next| { + let (d, p) = split_quote(next); + d == depth && next[p..].starts_with("+++ ") + }); + let (next_state, outcome) = step(state, content, next_is_plus, depth > 0); + state = next_state; + + membership.push((depth > 0, matches!(outcome, Outcome::InDiff(_)))); + } + membership +} + +/// How many lines of `text` are quoted, and how many are part of a diff — +/// the same per-line membership folding uses, run once up front so a caller +/// can size a row before folding and decide whether to show fold controls at +/// all. A line quoting a diff counts in both. +pub fn foldable_line_counts(text: &str) -> (usize, usize) { + let membership = fold_membership(text); + let quote_lines = membership.iter().filter(|(quote, _)| *quote).count(); + let diff_lines = membership.iter().filter(|(_, diff)| *diff).count(); + (quote_lines, diff_lines) +} + +/// Fold (hide) or unfold quoted text in `buffer` — every quoted line, at any +/// depth. A no-op if [`attach`] was never called on this buffer. +pub fn set_quote_folded(buffer: >k::TextBuffer, folded: bool) { + set_folded(buffer, FOLD_QUOTE_TAG, folded); +} + +/// Fold (hide) or unfold every line of a diff in `buffer` — headers, hunk +/// markers, metadata, added/removed lines, and unchanged context lines +/// alike. Context lines carry no color (see [`Kind`]), but still have to +/// fold away with the rest of the hunk: leaving them visible would strand +/// isolated fragments of unrelated-looking code with blank gaps around them +/// instead of folding the hunk closed. +pub fn set_diff_folded(buffer: >k::TextBuffer, folded: bool) { + set_folded(buffer, FOLD_DIFF_TAG, folded); +} + +fn set_folded(buffer: >k::TextBuffer, tag_name: &str, folded: bool) { + if let Some(tag) = buffer.tag_table().lookup(tag_name) { + tag.set_property("invisible", folded); + } +} + /// Find-in-thread highlight tags. Deliberately kept out of ALL_TAGS: the /// quote/diff refresh must not strip them, and they paint a background (not a /// foreground), so a match keeps its line's quote/diff coloring underneath. @@ -435,6 +509,10 @@ pub fn attach(buffer: >k::TextBuffer) { for name in [SEARCH_TAG, SEARCH_CURRENT_TAG, TRAILING_TAG] { buffer.create_tag(Some(name), &[]); } + // Unfolded (visible) by default; a fresh message always opens expanded. + for name in FOLD_TAGS { + buffer.create_tag(Some(name), &[("invisible", &false)]); + } let style = adw::StyleManager::default(); apply_colors(buffer, style.is_dark()); @@ -452,6 +530,9 @@ pub fn attach(buffer: >k::TextBuffer) { SPAN_CACHE.with_borrow_mut(|cache| { cache.remove(&key); }); + FOLD_CACHE.with_borrow_mut(|cache| { + cache.remove(&key); + }); PAINT_PROGRESS.with_borrow_mut(|progress| { progress.remove(&key); }); @@ -574,6 +655,49 @@ thread_local! { /// live buffers. static SPAN_CACHE: std::cell::RefCell>> = std::cell::RefCell::new(std::collections::HashMap::new()); + /// Last fold membership applied per buffer, keyed the same way as + /// [`SPAN_CACHE`] and cleared by the same weak-ref notify. + static FOLD_CACHE: std::cell::RefCell>> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Apply or clear the fold tags a line's [`fold_membership`] calls for, +/// touching only lines whose membership changed since the last call +/// (mirroring how [`refresh`] diffs spans). Each tag covers the *whole* +/// line, including its trailing newline — unlike a color span, folding only +/// shrinks a row's height when the newline itself is hidden too, not just +/// the glyphs before it. +fn retag_fold_lines(buffer: >k::TextBuffer, text: &str, line_starts: &[i32], total_chars: i32) { + let key = buffer.as_ptr() as usize; + let membership = fold_membership(text); + let old = FOLD_CACHE + .with_borrow(|cache| cache.get(&key).cloned()) + .unwrap_or_default(); + + let table = buffer.tag_table(); + for (n, &(quote, diff)) in membership.iter().enumerate() { + if old.get(n) == Some(&(quote, diff)) { + continue; + } + let base = line_starts[n]; + let line_end = line_starts.get(n + 1).copied().unwrap_or(total_chars); + let from = buffer.iter_at_offset(base); + let to = buffer.iter_at_offset(line_end); + for name in FOLD_TAGS { + if let Some(tag) = table.lookup(name) { + buffer.remove_tag(&tag, &from, &to); + } + } + if quote && let Some(tag) = table.lookup(FOLD_QUOTE_TAG) { + buffer.apply_tag(&tag, &from, &to); + } + if diff && let Some(tag) = table.lookup(FOLD_DIFF_TAG) { + buffer.apply_tag(&tag, &from, &to); + } + } + FOLD_CACHE.with_borrow_mut(|cache| { + cache.insert(key, membership); + }); } /// Re-run classification over the whole buffer and retag only the lines @@ -639,6 +763,8 @@ pub fn refresh(buffer: >k::TextBuffer) { SPAN_CACHE.with_borrow_mut(|cache| { cache.insert(key, spans); }); + + retag_fold_lines(buffer, &text, &line_starts, total_chars); } /// A partially applied highlight pass, so multi-megabyte bodies can be @@ -647,6 +773,7 @@ pub fn refresh(buffer: >k::TextBuffer) { /// nearly every line). struct PaintProgress { spans: Vec, + fold_membership: Vec<(bool, bool)>, line_starts: Vec, total_chars: i32, next_line: usize, @@ -683,6 +810,7 @@ pub fn refresh_step(buffer: >k::TextBuffer, lines: usize) -> bool { } PaintProgress { spans: classify(&text), + fold_membership: fold_membership(&text), line_starts, total_chars: off, next_line: 0, @@ -705,14 +833,44 @@ pub fn refresh_step(buffer: >k::TextBuffer, lines: usize) -> bool { buffer.apply_tag_by_name(tag_name(span.kind), &from, &to); progress.next_span += 1; } + + // Fold tags cover the whole line, including the newline, for the same + // [next_line, end_line) window the spans loop above just advanced + // through - see retag_fold_lines's doc comment for why. + let table = buffer.tag_table(); + let chunk_end = end_line.min(progress.fold_membership.len()); + for n in progress.next_line..chunk_end { + let (quote, diff) = progress.fold_membership[n]; + if !quote && !diff { + continue; + } + let base = progress.line_starts[n]; + let line_end = progress + .line_starts + .get(n + 1) + .copied() + .unwrap_or(progress.total_chars); + let from = buffer.iter_at_offset(base); + let to = buffer.iter_at_offset(line_end); + if quote && let Some(tag) = table.lookup(FOLD_QUOTE_TAG) { + buffer.apply_tag(&tag, &from, &to); + } + if diff && let Some(tag) = table.lookup(FOLD_DIFF_TAG) { + buffer.apply_tag(&tag, &from, &to); + } + } + progress.next_line = end_line; if progress.next_line >= progress.line_starts.len() { - // Done: leave the final spans where refresh's diffing expects them, - // so a later full refresh sees the true tag state. + // Done: leave the final spans/membership where refresh's diffing + // expects them, so a later full refresh sees the true tag state. SPAN_CACHE.with_borrow_mut(|cache| { cache.insert(key, progress.spans); }); + FOLD_CACHE.with_borrow_mut(|cache| { + cache.insert(key, progress.fold_membership); + }); false } else { PAINT_PROGRESS.with_borrow_mut(|map| { @@ -1013,6 +1171,57 @@ diff --git a/f b/f assert!(trailing_whitespace("a\nb\nc").is_empty()); } + #[test] + fn foldable_line_counts_separates_quote_from_diff() { + let body = "\ +reply line +> quoted line one +> quoted line two +diff --git a/f b/f +--- a/f ++++ b/f +@@ -1 +1 @@ +-old ++new"; + let (quote, diff) = foldable_line_counts(body); + assert_eq!(quote, 2); + assert_eq!(diff, 6); + } + + #[test] + fn foldable_line_counts_double_counts_a_quoted_diff_line() { + // A line that is both quoted and part of a diff contributes to both + // counts - callers subtracting both when folding both may + // under-estimate the freed height slightly, which is fine for a + // pre-realization size estimate. + let (quote, diff) = foldable_line_counts("> diff --git a/f b/f"); + assert_eq!(quote, 1); + assert_eq!(diff, 1); + } + + #[test] + fn foldable_line_counts_are_zero_for_plain_prose() { + assert_eq!(foldable_line_counts("just a reply\nwith no quotes"), (0, 0)); + } + + #[test] + fn foldable_line_counts_includes_context_lines_in_a_hunk() { + // Context lines carry no color span (see kinds_by_line's own check + // that a context line is untagged), but they still have to fold with + // the rest of the hunk - otherwise folding "diff" strands them as + // isolated, unexplained fragments instead of closing the hunk. + let body = "\ +diff --git a/f b/f +--- a/f ++++ b/f +@@ -1,3 +1,3 @@ + keep this line +-old ++new"; + let (_, diff) = foldable_line_counts(body); + assert_eq!(diff, 7, "the ' keep this line' context line must count too"); + } + #[test] fn crlf_body_classifies_like_lf() { let body = diff --git a/src/main.rs b/src/main.rs index b8b2cb0..b893967 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,8 @@ mod profile; mod profile_menu; mod remote_page; mod send; +mod sent; +mod sent_page; mod settings; mod subscriptions; mod subscriptions_page; @@ -21,7 +23,7 @@ mod thread_page; mod watcher; mod window_state; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use adw::prelude::*; @@ -30,6 +32,7 @@ use gtk::{gio, glib}; use favorites_page::{FAVORITES_PAGE_NAME, build_favorites_page}; use inbox_page::{INBOX_LIST_TITLE, build_inbox_page, build_inbox_page_deferred}; use profile_menu::build_profile_button; +use sent_page::{SENT_PAGE_NAME, build_sent_page}; use subscriptions_page::{SUBSCRIPTIONS_PAGE_NAME, build_subscriptions_page}; use thread_list_page::{ build_search_page, build_thread_list_page, build_thread_list_page_deferred, @@ -77,11 +80,15 @@ fn main() -> glib::ExitCode { let data_dir = glib::user_data_dir().join("koshi"); favorites::init(data_dir.join("favorites.json")); subscriptions::init(data_dir.join("subscriptions.json")); + sent::init(data_dir.join("sent.json")); window_state::init(data_dir.join("window-state.json")); // Preferences are user configuration, so they live in the config dir // ($XDG_CONFIG_HOME), not the data dir used for window state above. settings::init(glib::user_config_dir().join("koshi").join("settings.json")); load_css(); + apply_body_font_size(settings::body_font_size()); + apply_ui_text_scale(settings::ui_text_scale()); + apply_theme(settings::theme()); register_bundled_icons(); // Use the bundled app icon for window/taskbar decorations. When Koshi // is installed its desktop file points the shell at the same icon; this @@ -95,8 +102,9 @@ fn main() -> glib::ExitCode { app.run() } -// The single user-approved custom-CSS exception: compact address chips. -// Everything else must stay stock Adwaita. +// The two user-approved custom-CSS exceptions: compact address chips, and the +// user-adjustable body text size (below). Everything else must stay stock +// Adwaita. fn load_css() { let provider = gtk::CssProvider::new(); provider.load_from_string( @@ -111,6 +119,90 @@ fn load_css() { } } +thread_local! { + // Reloaded (not re-added) on every Preferences change, so the display + // never accumulates one provider per adjustment. + static BODY_FONT_PROVIDER: gtk::CssProvider = { + let provider = gtk::CssProvider::new(); + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } + provider + }; +} + +/// Apply the user's chosen body text size to every message body: the thread +/// reading pane and the composer editor, both marked with the +/// `koshi-body-text` class. Scoped to that class alone so nothing else in the +/// UI moves off stock Adwaita sizing. +pub(crate) fn apply_body_font_size(px: u32) { + BODY_FONT_PROVIDER.with(|provider| { + provider.load_from_string(&format!( + "textview.koshi-body-text {{ font-size: {px}px; }}" + )); + }); +} + +/// GTK's own fallback font resolution when nothing else has set one — used as +/// the 100% baseline for [`apply_ui_text_scale`] when `gtk-xft-dpi` reads back +/// `-1` ("use the default"), which it always does unless something (the +/// desktop's own accessibility "large text" setting, or this function on an +/// earlier call) has already set a real value. 96 dpi is the standard +/// reference resolution X11/Pango assume in that case. +const DEFAULT_DPI_1024: i32 = 96 * 1024; + +thread_local! { + /// The 100% baseline, captured from `gtk-xft-dpi` the first time + /// [`apply_ui_text_scale`] runs (before it ever overwrites that setting), + /// so a later change in Preferences scales from the system's real default + /// rather than compounding onto whatever the last scale left behind. + static BASELINE_DPI_1024: Cell> = const { Cell::new(None) }; +} + +/// Apply the user's chosen interface text scale, as a percentage of the +/// system default, to the *entire* application — every label, button and +/// menu, not just message bodies. Unlike [`apply_body_font_size`] (a CSS rule +/// scoped to one class), this adjusts `GtkSettings:gtk-xft-dpi`, the same +/// font-resolution knob the desktop's own "large text" accessibility setting +/// uses, so it reaches every widget uniformly including ones in popovers and +/// dialogs that a CSS class on the main window would not. +pub(crate) fn apply_ui_text_scale(percent: u32) { + let Some(display_settings) = gtk::Settings::default() else { + return; + }; + let baseline = BASELINE_DPI_1024.with(|cell| { + if let Some(dpi) = cell.get() { + return dpi; + } + let current = display_settings.gtk_xft_dpi(); + let dpi = if current > 0 { + current + } else { + DEFAULT_DPI_1024 + }; + cell.set(Some(dpi)); + dpi + }); + let scaled = (baseline as i64 * percent as i64 / 100) as i32; + display_settings.set_gtk_xft_dpi(scaled); +} + +/// Apply the user's chosen theme via libadwaita's own color-scheme manager, +/// so it takes effect exactly like the system appearance changing under a +/// "System" choice would - every stock Adwaita color already responds to it. +pub(crate) fn apply_theme(theme: settings::Theme) { + let scheme = match theme { + settings::Theme::System => adw::ColorScheme::Default, + settings::Theme::Light => adw::ColorScheme::ForceLight, + settings::Theme::Dark => adw::ColorScheme::ForceDark, + }; + adw::StyleManager::default().set_color_scheme(scheme); +} + // Icons bundled in the gresource (e.g. the mirrored rewrap arrow) are not in // the system theme, so the resource icon dir must be on the theme's path. fn register_bundled_icons() { @@ -642,6 +734,25 @@ fn build_search_entry() -> gtk::SearchEntry { .build() } +/// macOS shows real native traffic-light buttons, fixed at the OS's top-left +/// corner, whenever a `GtkWindowControls` leaves `use-native-controls` at its +/// default - AdwHeaderBar doesn't expose that as a property of its own, so +/// this walks down to the two it builds internally (one per side, see its +/// `windowcontrols.start`/`windowcontrols.end` CSS nodes) and turns it off on +/// each, falling back to GTK's own drawn close/minimize/maximize buttons, the +/// same ones every other platform already shows. +#[cfg(target_os = "macos")] +fn force_gtk_drawn_window_controls(widget: >k::Widget) { + if let Some(controls) = widget.downcast_ref::() { + controls.set_use_native_controls(false); + } + let mut child = widget.first_child(); + while let Some(w) = child { + force_gtk_drawn_window_controls(&w); + child = w.next_sibling(); + } +} + fn build_header_bar( search_entry: >k::SearchEntry, tab_view: &adw::TabView, @@ -649,6 +760,14 @@ fn build_header_bar( thread_overview: &gio::SimpleAction, ) -> adw::HeaderBar { let header = adw::HeaderBar::new(); + #[cfg(target_os = "macos")] + { + header.set_decoration_layout(Some(":minimize,maximize,close")); + // AdwHeaderBar builds its internal windowcontrols children lazily, + // not yet present right after `new()` - connecting here instead of + // calling immediately guarantees they exist by the time this runs. + header.connect_map(|header| force_gtk_drawn_window_controls(header.upcast_ref())); + } let back_button = gtk::Button::builder() .icon_name("go-previous-symbolic") @@ -725,6 +844,26 @@ fn build_header_bar( } )); + let sent_button = gtk::Button::builder() + .icon_name("mail-reply-sender-symbolic") + .tooltip_text("Sent") + .build(); + sent_button.connect_clicked(glib::clone!( + #[weak] + tab_view, + move |_| { + let Some(nav) = selected_nav(&tab_view) else { + return; + }; + let already_there = nav + .visible_page() + .is_some_and(|page| page.widget_name() == SENT_PAGE_NAME); + if !already_there { + nav.push(&build_sent_page(&nav)); + } + } + )); + let clamp = adw::Clamp::builder() .maximum_size(600) .tightening_threshold(400) @@ -749,6 +888,7 @@ fn build_header_bar( header.pack_end(&build_profile_button()); header.pack_end(&favorites_button); header.pack_end(&subscriptions_button); + header.pack_end(&sent_button); header.pack_end(&overview_button); header @@ -810,13 +950,33 @@ fn setup_actions( app.add_action_entries([preferences, shortcuts, about, quit]); - app.set_accels_for_action("win.focus-search", &["l"]); - app.set_accels_for_action("win.close-tab", &["w"]); + // rather than : GTK is documented to resolve it to Cmd + // on macOS and Ctrl everywhere else, so these are meant to match each + // platform's own muscle memory instead of only ever responding to a + // literal Ctrl press. Belt-and-braces on macOS specifically: this GTK + // backend's -to-Cmd resolution has been unreliable in practice, + // so every action also gets an explicit (literal Cmd) binding + // there, at no cost to the Linux bindings above. + accel(app, "win.focus-search", "l", "l"); + accel(app, "win.close-tab", "w", "w"); app.set_accels_for_action("win.toggle-thread-overview", &["F9"]); - app.set_accels_for_action("win.find-in-thread", &["f"]); - app.set_accels_for_action("app.preferences", &["comma"]); - app.set_accels_for_action("app.shortcuts", &["question"]); - app.set_accels_for_action("app.quit", &["q"]); + accel(app, "win.find-in-thread", "f", "f"); + accel(app, "app.preferences", "comma", "comma"); + accel(app, "app.shortcuts", "question", "question"); + accel(app, "app.quit", "q", "q"); +} + +/// Bind `action` to `primary` (`...`, the portable Ctrl/Cmd +/// modifier) everywhere, plus `meta` (the literal Cmd modifier) as an extra +/// binding on macOS only — see the comment above this function's call sites. +fn accel(app: &adw::Application, action: &str, primary: &str, meta: &str) { + #[cfg(target_os = "macos")] + app.set_accels_for_action(action, &[primary, meta]); + #[cfg(not(target_os = "macos"))] + { + let _ = meta; + app.set_accels_for_action(action, &[primary]); + } } fn show_preferences(app: &adw::Application) { @@ -824,6 +984,7 @@ fn show_preferences(app: &adw::Application) { .title("General") .icon_name("emblem-system-symbolic") .build(); + page.add(&build_appearance_group()); page.add(&build_replies_group()); page.add(&build_signature_group()); page.add(&build_notifications_group()); @@ -832,9 +993,121 @@ fn show_preferences(app: &adw::Application) { let dialog = adw::PreferencesDialog::new(); dialog.add(&page); + #[cfg(target_os = "macos")] + dialog.connect_map(|d| move_dialog_close_button_to_end(d.upcast_ref(), d.upcast_ref())); dialog.present(app.active_window().as_ref()); } +/// AdwDialog forces any `AdwHeaderBar` placed inside it to show only a close +/// button (see the "Header Bar Integration" section of Adw.Dialog's docs) at +/// its own fixed position on the start (left) side via a private internal +/// widget, with no public property to move it - unlike the main window's +/// title buttons, this has nothing to do with macOS's native window chrome, +/// it is libadwaita's own cross-platform dialog convention. Disabling the +/// built-in title buttons and packing a plain close button of our own on the +/// end (right) side is the only way to move it, so every dialog matches the +/// main window's buttons-on-the-right layout on macOS. +/// +/// `dialog` and `widget` are the same object in different types (the search +/// needs `>k::Widget` to walk the tree; closing needs `&adw::Dialog`) - +/// callers that already hold an `adw::HeaderBar` they built themselves should +/// just call the two `set_show_*_title_buttons` calls directly instead of +/// walking down to find it. +#[cfg(target_os = "macos")] +fn move_dialog_close_button_to_end(widget: >k::Widget, dialog: &adw::Dialog) { + if let Some(header) = widget.downcast_ref::() { + header.set_show_start_title_buttons(false); + header.set_show_end_title_buttons(false); + let close = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .valign(gtk::Align::Center) + .css_classes(["flat", "circular"]) + .build(); + close.connect_clicked(glib::clone!( + #[weak] + dialog, + move |_| { + dialog.close(); + } + )); + header.pack_end(&close); + return; + } + let mut child = widget.first_child(); + while let Some(w) = child { + move_dialog_close_button_to_end(&w, dialog); + child = w.next_sibling(); + } +} + +/// The Preferences group for reading-pane and composer text size, in pixels. +fn build_appearance_group() -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder().title("Appearance").build(); + + let theme_row = adw::ComboRow::builder() + .title("Theme") + .model(>k::StringList::new(&["System", "Light", "Dark"])) + .selected(match settings::theme() { + settings::Theme::System => 0, + settings::Theme::Light => 1, + settings::Theme::Dark => 2, + }) + .build(); + theme_row.connect_selected_notify(|row| { + let theme = match row.selected() { + 1 => settings::Theme::Light, + 2 => settings::Theme::Dark, + _ => settings::Theme::System, + }; + settings::set_theme(theme); + apply_theme(theme); + }); + group.add(&theme_row); + + let adjustment = gtk::Adjustment::new( + settings::body_font_size() as f64, + settings::MIN_BODY_FONT_SIZE as f64, + settings::MAX_BODY_FONT_SIZE as f64, + 1.0, + 2.0, + 0.0, + ); + let row = adw::SpinRow::builder() + .title("Message text size") + .subtitle("Applies to the reading pane and the reply composer.") + .adjustment(&adjustment) + .build(); + row.connect_value_notify(|row| { + let px = row.value() as u32; + settings::set_body_font_size(px); + apply_body_font_size(px); + }); + group.add(&row); + + let ui_adjustment = gtk::Adjustment::new( + settings::ui_text_scale() as f64, + settings::MIN_UI_TEXT_SCALE as f64, + settings::MAX_UI_TEXT_SCALE as f64, + 5.0, + 10.0, + 0.0, + ); + let ui_row = adw::SpinRow::builder() + .title("Interface text size") + .subtitle("Scales every label, button and menu in Koshi, as a percentage.") + .adjustment(&ui_adjustment) + .build(); + ui_row.connect_value_notify(|row| { + let percent = row.value() as u32; + settings::set_ui_text_scale(percent); + apply_ui_text_scale(percent); + }); + group.add(&ui_row); + + group +} + fn build_replies_group() -> adw::PreferencesGroup { let group = adw::PreferencesGroup::builder().title("Replies").build(); @@ -925,6 +1198,32 @@ fn build_notifications_group() -> adw::PreferencesGroup { .description("Koshi notifies you of new replies on threads you subscribe to.") .build(); + // macOS notifications need the user to grant permission at a one-time + // system prompt, easy to dismiss without noticing (and the whole point + // of a background poll is that Koshi isn't necessarily in front when it + // fires) - denial is otherwise silent, so surface it here whenever it is + // actually known to have happened. See watcher::notifications_denied. + #[cfg(target_os = "macos")] + if watcher::notifications_denied() { + let warning = adw::ActionRow::builder() + .title("Notifications are turned off for Koshi") + .subtitle("New-reply alerts won't show until you allow them in System Settings.") + .build(); + warning.add_prefix(>k::Image::from_icon_name("dialog-warning-symbolic")); + let open_settings = gtk::Button::builder() + .label("Open Settings") + .valign(gtk::Align::Center) + .css_classes(["flat"]) + .build(); + open_settings.connect_clicked(|_| { + let _ = std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.notifications") + .spawn(); + }); + warning.add_suffix(&open_settings); + group.add(&warning); + } + let adjustment = gtk::Adjustment::new( settings::poll_interval_minutes() as f64, settings::MIN_POLL_INTERVAL_MINUTES as f64, diff --git a/src/profile.rs b/src/profile.rs index 868056e..83f4ce5 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -227,6 +227,55 @@ pub fn set_active_identity(name: &str) -> bool { ok } +/// Write (or, when `value` is `None`, clear) one `sendemail..` +/// setting in the user's global git config — the same file [`load`] reads, so +/// Koshi keeps no identity store of its own even when it is the one writing. +/// `key` is the lowercase git config variable name (e.g. `"smtpserver"`, not +/// `"smtpServer"`); git lowercases the section/variable regardless of what is +/// passed, but the caller should not rely on that. Returns whether the write +/// (or clear) succeeded; clearing a key that was never set counts as success, +/// since the caller's intent — that key being absent — already holds. +pub fn set_identity_setting(identity: &str, key: &str, value: Option<&str>) -> bool { + let config_key = format!("sendemail.{identity}.{key}"); + let ok = match value { + Some(value) if !value.trim().is_empty() => crate::flatpak::git_command() + .args(["config", "--global", &config_key, value]) + .status() + .map(|status| status.success()) + .unwrap_or(false), + _ => { + let status = crate::flatpak::git_command() + .args(["config", "--global", "--unset", &config_key]) + .status(); + match status { + // Exit code 5: "the key does not exist" - already absent. + Ok(status) => status.success() || status.code() == Some(5), + Err(_) => false, + } + } + }; + invalidate(); + ok +} + +/// Remove a whole `[sendemail ""]` section from the user's global git +/// config, deleting the identity and every setting under it. Returns whether +/// the removal succeeded. +pub fn delete_identity(name: &str) -> bool { + let ok = crate::flatpak::git_command() + .args([ + "config", + "--global", + "--remove-section", + &format!("sendemail.{name}"), + ]) + .status() + .map(|status| status.success()) + .unwrap_or(false); + invalidate(); + ok +} + /// Evict a stored send-email SMTP password from git's credential helpers, so /// the next send prompts for it again. Best-effort: a send with no helper /// simply has nothing to evict. diff --git a/src/profile_menu.rs b/src/profile_menu.rs index f521e9a..f506833 100644 --- a/src/profile_menu.rs +++ b/src/profile_menu.rs @@ -90,9 +90,7 @@ fn build_content( } root.append(&build_header(profile)); - if profile.identities.len() >= 2 { - root.append(&build_identities(popover, profile)); - } + root.append(&build_identities(popover, button, profile)); if let Some(transport) = build_transport(profile) { root.append(&transport); } @@ -158,23 +156,35 @@ fn build_email_pill(email: &str) -> gtk::Widget { pill.upcast() } -/// The identity switcher, shown only when two or more identities exist: one -/// activatable row each, the active one marked with a checkmark. Activating a -/// different row writes `sendemail.identity` and closes the popover. -fn build_identities(popover: >k::Popover, profile: &Profile) -> gtk::Widget { - let group = adw::PreferencesGroup::new(); +/// The identity switcher and editor. One row per configured identity — with +/// an edit button that opens [`build_identity_editor_dialog`] pre-filled — +/// plus a trailing "Add Identity" row that opens the same dialog empty. +/// Shown whenever there is any git profile at all, even with zero identities +/// configured yet, since adding the first one is the point. +/// +/// The row itself is only activatable (to switch identity) when there are two +/// or more: with zero or one there is nothing to switch to, and clicking the +/// lone row would be a confusing no-op. Activating a different row writes +/// `sendemail.identity` and closes the popover. +fn build_identities( + popover: >k::Popover, + button: >k::MenuButton, + profile: &Profile, +) -> gtk::Widget { + let group = adw::PreferencesGroup::builder().title("Identities").build(); + let switchable = profile.identities.len() >= 2; for identity in &profile.identities { let is_active = profile.active_identity.as_deref() == Some(identity.name.as_str()); let row = adw::ActionRow::builder() .title(glib::markup_escape_text(&identity.name)) - .activatable(true) + .activatable(switchable) .build(); if let Some(email) = &identity.email { row.set_subtitle(&glib::markup_escape_text(email)); } - if is_active { + if is_active && switchable { row.add_suffix(>k::Image::from_icon_name("object-select-symbolic")); } @@ -189,12 +199,257 @@ fn build_identities(popover: >k::Popover, profile: &Profile) -> gtk::Widget { popover.popdown(); } )); + + let edit = gtk::Button::builder() + .icon_name("document-edit-symbolic") + .valign(gtk::Align::Center) + .tooltip_text("Edit identity") + .css_classes(["flat"]) + .build(); + let identity = identity.clone(); + edit.connect_clicked(glib::clone!( + #[weak] + popover, + #[weak] + button, + move |_| { + let dialog = build_identity_editor_dialog(Some(&identity)); + dialog.present(button.root().and_downcast::().as_ref()); + popover.popdown(); + } + )); + row.add_suffix(&edit); + group.add(&row); } + let add_row = adw::ActionRow::builder() + .title("Add Identity") + .activatable(true) + .build(); + add_row.add_prefix(>k::Image::from_icon_name("list-add-symbolic")); + add_row.connect_activated(glib::clone!( + #[weak] + popover, + #[weak] + button, + move |_| { + let dialog = build_identity_editor_dialog(None); + dialog.present(button.root().and_downcast::().as_ref()); + popover.popdown(); + } + )); + group.add(&add_row); + group.upcast() } +/// The create/edit form for one `[sendemail ""]` identity. Save writes +/// straight to the user's global git config via +/// [`profile::set_identity_setting`] — this dialog is a friendlier way to +/// edit that file, not a parallel store, matching how the rest of Koshi's +/// identity handling works (see the module doc on [`crate::profile`]). +fn build_identity_editor_dialog(existing: Option<&profile::Identity>) -> adw::Dialog { + // Built without a child first so the buttons below can hold a weak + // reference to it and close it themselves on Save/Delete. + let dialog = adw::Dialog::builder() + .title(if existing.is_some() { + "Edit Identity" + } else { + "Add Identity" + }) + .content_width(440) + .content_height(560) + .build(); + + let page = adw::PreferencesPage::new(); + page.set_description( + "Identities are git send-email identities, kept in your global git \ + configuration \u{2014} saving here writes straight there; nothing is \ + stored by Koshi itself.", + ); + + let group = adw::PreferencesGroup::new(); + + let name_row = adw::EntryRow::builder().title("Identity name").build(); + if let Some(identity) = existing { + name_row.set_text(identity.name.as_str()); + // Renaming would mean moving every setting to a new git config + // subsection, which this editor does not do - keep it fixed once + // created, same as the git config file itself has no rename. + name_row.set_sensitive(false); + } + group.add(&name_row); + + let field = |key: &str| -> String { + existing + .and_then(|identity| identity.settings.iter().find(|s| s.key == key)) + .map(|s| s.value.clone()) + .unwrap_or_default() + }; + + let from_row = adw::EntryRow::builder() + .title("From (Name )") + .build(); + from_row.set_text(&field("from")); + group.add(&from_row); + + let smtp_server_row = adw::EntryRow::builder().title("SMTP server").build(); + smtp_server_row.set_text(&field("smtpServer")); + group.add(&smtp_server_row); + + let smtp_port_row = adw::EntryRow::builder().title("SMTP port").build(); + smtp_port_row.set_text(&field("smtpServerPort")); + group.add(&smtp_port_row); + + let encryption_row = adw::ComboRow::builder() + .title("Encryption") + .model(>k::StringList::new(&["None", "SSL", "TLS"])) + .build(); + encryption_row.set_selected( + match field("smtpEncryption").to_ascii_lowercase().as_str() { + "ssl" => 1, + "tls" => 2, + _ => 0, + }, + ); + group.add(&encryption_row); + + let smtp_user_row = adw::EntryRow::builder().title("SMTP username").build(); + smtp_user_row.set_text(&field("smtpUser")); + group.add(&smtp_user_row); + + let sendmail_row = adw::EntryRow::builder() + .title("Local sendmail command (instead of SMTP)") + .build(); + sendmail_row.set_text(&field("sendmailCmd")); + group.add(&sendmail_row); + + page.add(&group); + + let overlay = adw::ToastOverlay::new(); + let header = adw::HeaderBar::new(); + // AdwDialog forces its own close button onto the header's start side with + // no way to move it - on macOS, replace it with one of our own on the end + // side, matching how the main window's buttons were moved there too. See + // the comment on main.rs's move_dialog_close_button_to_end. + #[cfg(target_os = "macos")] + { + header.set_show_start_title_buttons(false); + header.set_show_end_title_buttons(false); + let close = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .valign(gtk::Align::Center) + .css_classes(["flat", "circular"]) + .build(); + close.connect_clicked(glib::clone!( + #[weak] + dialog, + move |_| { + dialog.close(); + } + )); + header.pack_end(&close); + } + + let save = gtk::Button::builder() + .label("Save") + .css_classes(["suggested-action"]) + .build(); + header.pack_end(&save); + let is_new = existing.is_none(); + save.connect_clicked(glib::clone!( + #[weak] + dialog, + #[weak] + overlay, + #[weak] + name_row, + #[weak] + from_row, + #[weak] + smtp_server_row, + #[weak] + smtp_port_row, + #[weak] + encryption_row, + #[weak] + smtp_user_row, + #[weak] + sendmail_row, + move |_| { + let name = name_row.text().trim().to_string(); + if name.is_empty() { + overlay.add_toast(adw::Toast::new("Identity name is required")); + return; + } + let encryption = match encryption_row.selected() { + 1 => "ssl", + 2 => "tls", + _ => "", + }; + let fields = [ + ("from", from_row.text()), + ("smtpserver", smtp_server_row.text()), + ("smtpserverport", smtp_port_row.text()), + ("smtpencryption", encryption.into()), + ("smtpuser", smtp_user_row.text()), + ("sendmailcmd", sendmail_row.text()), + ]; + let mut ok = true; + for (key, value) in fields { + let value = value.trim(); + let value = (!value.is_empty()).then_some(value); + if !profile::set_identity_setting(&name, key, value) { + ok = false; + } + } + if !ok { + overlay.add_toast(adw::Toast::new("Failed to save identity")); + return; + } + // A brand-new identity is the obvious thing to send as next; + // editing an existing one leaves whichever is active alone. + if is_new { + profile::set_active_identity(&name); + } + dialog.close(); + } + )); + + if let Some(identity) = existing { + let delete = gtk::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Delete identity") + .css_classes(["flat", "destructive-action"]) + .build(); + header.pack_start(&delete); + let name = identity.name.clone(); + delete.connect_clicked(glib::clone!( + #[weak] + dialog, + #[weak] + overlay, + move |_| { + if profile::delete_identity(&name) { + dialog.close(); + } else { + overlay.add_toast(adw::Toast::new("Failed to delete identity")); + } + } + )); + } + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&page)); + overlay.set_child(Some(&toolbar)); + dialog.set_child(Some(&overlay)); + + dialog +} + /// A single calm row saying where mail goes: server as title, "Port 465 · SSL" /// as subtitle (the Wi-Fi-row idiom). Absent when nothing sends mail. fn build_transport(profile: &Profile) -> Option { @@ -347,17 +602,44 @@ fn build_sending_dialog(profile: &Profile) -> adw::Dialog { page.add(&build_forget_password_group(host, username, &overlay)); } + let dialog = adw::Dialog::builder() + .title("Send Email") + .content_width(460) + .content_height(620) + .build(); + + let header = adw::HeaderBar::new(); + // See the comment on main.rs's move_dialog_close_button_to_end: AdwDialog + // forces its own close button onto the header's start side with no way to + // move it, so on macOS this replaces it with one of our own on the end + // side, matching the main window's buttons-on-the-right layout. + #[cfg(target_os = "macos")] + { + header.set_show_start_title_buttons(false); + header.set_show_end_title_buttons(false); + let close = gtk::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close") + .valign(gtk::Align::Center) + .css_classes(["flat", "circular"]) + .build(); + close.connect_clicked(glib::clone!( + #[weak] + dialog, + move |_| { + dialog.close(); + } + )); + header.pack_end(&close); + } + let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.add_top_bar(&header); toolbar.set_content(Some(&page)); overlay.set_child(Some(&toolbar)); + dialog.set_child(Some(&overlay)); - adw::Dialog::builder() - .title("Send Email") - .content_width(460) - .content_height(620) - .child(&overlay) - .build() + dialog } /// A group with one destructive action: forget the SMTP password kept for this diff --git a/src/sent.rs b/src/sent.rs new file mode 100644 index 0000000..59dead2 --- /dev/null +++ b/src/sent.rs @@ -0,0 +1,248 @@ +//! A local log of replies Koshi has sent, so "did I already reply to this" +//! and "what did I say" don't depend on lore.kernel.org having indexed the +//! message yet (it can take minutes). Purely a history: Koshi does not use +//! this to decide anything about sending itself, and nothing here is ever +//! sent anywhere — it is written only after `git send-email` has already +//! confirmed delivery. + +use std::cell::RefCell; +use std::fs; +use std::path::{Path, PathBuf}; + +/// One reply Koshi has sent. `list`/`thread_message_id` are the thread it was +/// sent into, when it was a reply to one Koshi had open (a from-scratch +/// compose has neither) — enough to reopen that thread the same way a +/// favorite or subscription does. +#[derive(Clone)] +pub struct SentMessage { + pub subject: String, + pub to: String, + pub cc: String, + /// RFC 2822 send time, as `humantime`-free local formatting already used + /// elsewhere in Koshi (see `Mail::date`) — a plain string, not parsed + /// back into anything. + pub sent_at: String, + pub list: Option, + pub thread_message_id: Option, +} + +thread_local! { + static SENT: RefCell> = const { RefCell::new(Vec::new()) }; + static STORE_PATH: RefCell> = const { RefCell::new(None) }; +} + +/// Load sent history from `path` and persist every later record back to it. +/// Without this call the store is in-memory only. +pub fn init(path: PathBuf) { + if let Some(sent) = load(&path) { + SENT.set(sent); + } + STORE_PATH.set(Some(path)); +} + +fn load(path: &Path) -> Option> { + let json = fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&json) + .inspect_err(|err| { + log::warn!("ignoring malformed {}: {err}", path.display()); + }) + .ok()?; + let items = value.get("sent").and_then(serde_json::Value::as_array)?; + let text = |item: &serde_json::Value, key: &str| { + item.get(key) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }; + Some( + items + .iter() + .filter_map(|item| { + Some(SentMessage { + subject: text(item, "subject")?, + to: text(item, "to")?, + cc: text(item, "cc")?, + sent_at: text(item, "sent_at")?, + list: text(item, "list"), + thread_message_id: text(item, "thread_message_id"), + }) + }) + .collect(), + ) +} + +fn save() { + let Some(path) = STORE_PATH.with_borrow(|path| path.clone()) else { + return; + }; + let sent: Vec<_> = SENT.with_borrow(|sent| { + sent.iter() + .map(|msg| { + serde_json::json!({ + "subject": msg.subject, + "to": msg.to, + "cc": msg.cc, + "sent_at": msg.sent_at, + "list": msg.list, + "thread_message_id": msg.thread_message_id, + }) + }) + .collect() + }); + let value = serde_json::json!({ "sent": sent }); + if let Err(err) = write_atomically(&path, &value.to_string()) { + log::error!("failed to save sent history to {}: {err}", path.display()); + } +} + +/// Write via a temp file and rename so a crash mid-write can't truncate +/// the store. +fn write_atomically(path: &Path, contents: &str) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, contents)?; + fs::rename(&tmp, path) +} + +/// Record a successfully sent message. Call only after `git send-email` has +/// confirmed delivery (see `send::Outcome::Sent`) — this is a history, not a +/// send queue, so there is nothing to retry or undo here. +pub fn record(msg: SentMessage) { + SENT.with_borrow_mut(|sent| sent.push(msg)); + save(); +} + +/// Every sent message, most recent first. +pub fn all() -> Vec { + SENT.with_borrow(|sent| sent.iter().rev().cloned().collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn msg(subject: &str) -> SentMessage { + SentMessage { + subject: subject.to_string(), + to: "maintainer@example.org".to_string(), + cc: "list@vger.kernel.org".to_string(), + sent_at: "Thu, 3 Jul 2026 12:00:00 +0000".to_string(), + list: Some("rockchip".to_string()), + thread_message_id: Some("".to_string()), + } + } + + #[test] + fn all_is_empty_without_a_store() { + assert!(all().is_empty()); + } + + #[test] + fn record_appends_and_all_returns_most_recent_first() { + record(msg("[PATCH v1] first")); + record(msg("[PATCH v2] second")); + let sent = all(); + assert_eq!(sent.len(), 2); + assert_eq!(sent[0].subject, "[PATCH v2] second"); + assert_eq!(sent[1].subject, "[PATCH v1] first"); + } + + #[test] + fn record_keeps_every_send_even_with_the_same_subject() { + // Unlike favorites/subscriptions, history is not keyed or deduped - + // resending (e.g. a v2) is a distinct, equally real event. + record(msg("[PATCH] retry")); + record(msg("[PATCH] retry")); + assert_eq!(all().len(), 2); + } + + /// A scratch store file in a per-test temp dir; the dir is removed + /// when the guard drops. + struct ScratchStore { + dir: PathBuf, + } + + impl ScratchStore { + fn new(test_name: &str) -> Self { + let dir = + std::env::temp_dir().join(format!("koshi-{test_name}-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + Self { dir } + } + + fn path(&self) -> PathBuf { + self.dir.join("sent.json") + } + } + + impl Drop for ScratchStore { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } + } + + #[test] + fn sent_history_survives_a_reload() { + let store = ScratchStore::new("sent-survives-a-reload"); + init(store.path()); + record(msg("[PATCH] persisted")); + + SENT.set(Vec::new()); + init(store.path()); + + let sent = all(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].subject, "[PATCH] persisted"); + assert_eq!(sent[0].to, "maintainer@example.org"); + assert_eq!(sent[0].list.as_deref(), Some("rockchip")); + assert_eq!(sent[0].thread_message_id.as_deref(), Some("")); + } + + #[test] + fn a_from_scratch_compose_has_no_thread_to_link_back_to() { + let store = ScratchStore::new("sent-no-thread"); + init(store.path()); + record(SentMessage { + list: None, + thread_message_id: None, + ..msg("[ANNOUNCE] something") + }); + let sent = all(); + assert!(sent[0].list.is_none()); + assert!(sent[0].thread_message_id.is_none()); + } + + #[test] + fn malformed_store_file_starts_empty_and_is_replaced_on_save() { + let store = ScratchStore::new("sent-malformed-store"); + fs::write(store.path(), "not json").unwrap(); + init(store.path()); + assert!(all().is_empty()); + + record(msg("[PATCH] after malformed")); + SENT.set(Vec::new()); + init(store.path()); + assert_eq!(all().len(), 1); + } + + #[test] + fn missing_store_file_starts_empty() { + let store = ScratchStore::new("sent-missing-store"); + init(store.path()); + assert!(all().is_empty()); + } + + #[test] + fn entries_missing_required_fields_are_skipped_not_fatal() { + let store = ScratchStore::new("sent-entries-missing-fields"); + fs::write( + store.path(), + r#"{"sent": [{"subject": "no to/cc/sent_at"}, {"subject": "ok", "to": "a@b", "cc": "", "sent_at": "now"}]}"#, + ) + .unwrap(); + init(store.path()); + assert_eq!(all().len(), 1, "the incomplete entry must be dropped"); + assert_eq!(all()[0].subject, "ok"); + } +} diff --git a/src/sent_page.rs b/src/sent_page.rs new file mode 100644 index 0000000..7d9eea4 --- /dev/null +++ b/src/sent_page.rs @@ -0,0 +1,87 @@ +use adw::prelude::*; +use gtk::glib; + +use crate::sent::{self, SentMessage}; +use crate::thread_page::build_thread_page; + +pub const SENT_TITLE: &str = "Sent"; + +/// Widget name marking the sent page, matching how [`crate::favorites_page`] +/// marks its own — recognizable without comparing titles or navigation tags. +pub const SENT_PAGE_NAME: &str = "koshi-sent-page"; + +pub fn build_sent_page(nav: &adw::NavigationView) -> adw::NavigationPage { + let sent = sent::all(); + + // HIG placeholder-page pattern: an empty view gets a symbolic + // AdwStatusPage instead of an empty list. + let page = if sent.is_empty() { + let status = adw::StatusPage::builder() + .icon_name("mail-reply-sender-symbolic") + .title("No Sent Messages") + .description("Replies you send are logged here") + .build(); + adw::NavigationPage::new(&status, SENT_TITLE) + } else { + let list = build_sent_list(nav, sent); + crate::list_page::build_list_page( + SENT_TITLE, + SENT_TITLE, + "Replies you've sent, most recent first", + &[], + &list, + ) + }; + page.set_widget_name(SENT_PAGE_NAME); + page +} + +fn build_sent_list(nav: &adw::NavigationView, sent: Vec) -> gtk::ListBox { + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .css_classes(["boxed-list"]) + .build(); + for msg in &sent { + list.append(&build_sent_row(msg)); + } + list.connect_row_activated(glib::clone!( + #[weak] + nav, + move |_, row| { + let msg = &sent[row.index() as usize]; + // Only a reply to a thread Koshi had open can be reopened this + // way; a from-scratch compose has nowhere to go back to. + if let (Some(list_slug), Some(message_id)) = + (msg.list.as_deref(), msg.thread_message_id.as_deref()) + { + nav.push(&build_thread_page(&nav, list_slug, message_id)); + } + } + )); + list +} + +fn build_sent_row(msg: &SentMessage) -> adw::ActionRow { + let has_thread = msg.list.is_some() && msg.thread_message_id.is_some(); + let row = adw::ActionRow::builder() + .title(format!( + "{}", + glib::markup_escape_text(&msg.subject) + )) + .title_lines(1) + .subtitle(glib::markup_escape_text(&msg.to)) + .subtitle_lines(1) + .tooltip_text(&msg.subject) + .activatable(has_thread) + .build(); + row.add_suffix( + >k::Label::builder() + .label(&msg.sent_at) + .css_classes(["numeric", "caption", "dim-label"]) + .build(), + ); + if has_thread { + row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + } + row +} diff --git a/src/settings.rs b/src/settings.rs index 49095f0..d3065fd 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -119,6 +119,114 @@ fn write_key(key: &str, new: serde_json::Value) { } } +/// The default, minimum, and maximum body text size, in pixels. The default +/// matches the size GTK's own monospace font stack renders at; the range +/// keeps the Preferences spinner from producing an unreadably small or +/// absurdly large body. +pub const DEFAULT_BODY_FONT_SIZE: u32 = 13; +pub const MIN_BODY_FONT_SIZE: u32 = 8; +pub const MAX_BODY_FONT_SIZE: u32 = 32; + +/// The font size for message bodies (reading pane and composer), in pixels. +/// Defaults to [`DEFAULT_BODY_FONT_SIZE`] and is clamped to the spinner's +/// range so a hand-edited store can't request an unusable size. +pub fn body_font_size() -> u32 { + let stored = STORE_PATH + .with_borrow(|path| path.clone()) + .and_then(|path| fs::read_to_string(&path).ok()) + .and_then(|json| serde_json::from_str::(&json).ok()) + .as_ref() + .and_then(|value| value.get("bodyFontSize")) + .and_then(serde_json::Value::as_u64); + match stored { + Some(size) => (size as u32).clamp(MIN_BODY_FONT_SIZE, MAX_BODY_FONT_SIZE), + None => DEFAULT_BODY_FONT_SIZE, + } +} + +/// Persist the body font size, preserving any other settings already in the +/// file. +pub fn set_body_font_size(px: u32) { + write_key("bodyFontSize", serde_json::Value::from(px)); +} + +/// The default, minimum, and maximum interface text scale, as a percentage of +/// the system's own default size. Unlike [`DEFAULT_BODY_FONT_SIZE`] (a fixed +/// pixel size for message bodies only), this scales every label, button and +/// menu in the app, so it is expressed relative to whatever the system +/// default already is rather than an absolute size. +pub const DEFAULT_UI_TEXT_SCALE: u32 = 100; +pub const MIN_UI_TEXT_SCALE: u32 = 50; +pub const MAX_UI_TEXT_SCALE: u32 = 200; + +/// The interface text scale, as a percentage. Defaults to +/// [`DEFAULT_UI_TEXT_SCALE`] (unchanged) and is clamped to the spinner's +/// range so a hand-edited store can't request an unusable scale. +pub fn ui_text_scale() -> u32 { + let stored = STORE_PATH + .with_borrow(|path| path.clone()) + .and_then(|path| fs::read_to_string(&path).ok()) + .and_then(|json| serde_json::from_str::(&json).ok()) + .as_ref() + .and_then(|value| value.get("uiTextScale")) + .and_then(serde_json::Value::as_u64); + match stored { + Some(scale) => (scale as u32).clamp(MIN_UI_TEXT_SCALE, MAX_UI_TEXT_SCALE), + None => DEFAULT_UI_TEXT_SCALE, + } +} + +/// Persist the interface text scale, preserving any other settings already in +/// the file. +pub fn set_ui_text_scale(percent: u32) { + write_key("uiTextScale", serde_json::Value::from(percent)); +} + +/// The user's chosen color scheme: follow the system, or always light/dark +/// regardless of it. Stored as one of these exact strings; anything else +/// (absent, or a hand-edited store with a stray value) reads back as +/// [`Theme::System`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Theme { + #[default] + System, + Light, + Dark, +} + +impl Theme { + fn as_str(self) -> &'static str { + match self { + Theme::System => "system", + Theme::Light => "light", + Theme::Dark => "dark", + } + } + + fn from_str(value: &str) -> Self { + match value { + "light" => Theme::Light, + "dark" => Theme::Dark, + _ => Theme::System, + } + } +} + +/// The user's chosen theme. Defaults to [`Theme::System`]. +pub fn theme() -> Theme { + read_key("theme") + .and_then(|value| value.as_str().map(Theme::from_str)) + .unwrap_or_default() +} + +/// Persist the theme, preserving any other settings already in the file. +pub fn set_theme(theme: Theme) { + write_key( + "theme", + serde_json::Value::String(theme.as_str().to_owned()), + ); +} + /// Koshi's default gap between subscription polls, in minutes. pub const DEFAULT_POLL_INTERVAL_MINUTES: u32 = 5; @@ -265,6 +373,77 @@ mod tests { assert!(send_user_agent()); } + #[test] + fn body_font_size_defaults_without_a_store() { + assert_eq!(body_font_size(), DEFAULT_BODY_FONT_SIZE); + } + + #[test] + fn body_font_size_survives_a_reload() { + let store = ScratchStore::new("settings-font-size"); + init(store.path()); + set_body_font_size(18); + assert_eq!(body_font_size(), 18); + } + + #[test] + fn body_font_size_is_clamped_to_the_spinner_range() { + let store = ScratchStore::new("settings-font-size-clamp"); + init(store.path()); + fs::write(store.path(), r#"{"bodyFontSize": 1}"#).unwrap(); + assert_eq!(body_font_size(), MIN_BODY_FONT_SIZE); + fs::write(store.path(), r#"{"bodyFontSize": 999}"#).unwrap(); + assert_eq!(body_font_size(), MAX_BODY_FONT_SIZE); + } + + #[test] + fn theme_defaults_to_system_without_a_store() { + assert_eq!(theme(), Theme::System); + } + + #[test] + fn theme_survives_a_reload() { + let store = ScratchStore::new("settings-theme"); + init(store.path()); + set_theme(Theme::Dark); + assert_eq!(theme(), Theme::Dark); + set_theme(Theme::Light); + assert_eq!(theme(), Theme::Light); + set_theme(Theme::System); + assert_eq!(theme(), Theme::System); + } + + #[test] + fn theme_falls_back_to_system_for_a_stray_value() { + let store = ScratchStore::new("settings-theme-stray"); + init(store.path()); + fs::write(store.path(), r#"{"theme": "purple"}"#).unwrap(); + assert_eq!(theme(), Theme::System); + } + + #[test] + fn ui_text_scale_defaults_without_a_store() { + assert_eq!(ui_text_scale(), DEFAULT_UI_TEXT_SCALE); + } + + #[test] + fn ui_text_scale_survives_a_reload() { + let store = ScratchStore::new("settings-ui-scale"); + init(store.path()); + set_ui_text_scale(125); + assert_eq!(ui_text_scale(), 125); + } + + #[test] + fn ui_text_scale_is_clamped_to_the_spinner_range() { + let store = ScratchStore::new("settings-ui-scale-clamp"); + init(store.path()); + fs::write(store.path(), r#"{"uiTextScale": 1}"#).unwrap(); + assert_eq!(ui_text_scale(), MIN_UI_TEXT_SCALE); + fs::write(store.path(), r#"{"uiTextScale": 999}"#).unwrap(); + assert_eq!(ui_text_scale(), MAX_UI_TEXT_SCALE); + } + #[test] fn poll_interval_defaults_without_a_store() { assert_eq!(poll_interval_minutes(), DEFAULT_POLL_INTERVAL_MINUTES); diff --git a/src/thread_page.rs b/src/thread_page.rs index 66d9c95..b6cd91d 100644 --- a/src/thread_page.rs +++ b/src/thread_page.rs @@ -168,6 +168,9 @@ impl MessageRow { // Counted once here so the reseed walk stays O(1) per row: bodies // run to megabytes and reseed is called on every row of every pass. imp.body_lines.set(mail.body.lines().count().max(1) as i32); + let (quote_lines, diff_lines) = highlight::foldable_line_counts(&mail.body); + imp.quote_lines.set(quote_lines as i32); + imp.diff_lines.set(diff_lines as i32); *imp.mail.borrow_mut() = Some(mail); // Seed the row's height before any of its widgetry exists: the @@ -190,14 +193,68 @@ impl MessageRow { return; } let (unit, header) = key; - let height = imp - .body_lines - .get() - .max(1) + self.set_size_request(-1, self.compute_height(unit, header)); + } + + /// The row height for `unit`/`header` line/header estimates, minus + /// whatever is currently folded away. A folded-and-quoted diff line is + /// subtracted once per active fold, which can under-estimate slightly — + /// harmless, since this only seeds height ahead of realization (see + /// reseed's doc comment); once laid out, natural sizing takes over. + fn compute_height(&self, unit: i32, header: i32) -> i32 { + let imp = self.imp(); + let hidden = if imp.quote_folded.get() { + imp.quote_lines.get() + } else { + 0 + } + if imp.diff_folded.get() { + imp.diff_lines.get() + } else { + 0 + }; + let visible_lines = (imp.body_lines.get() - hidden).max(1); + visible_lines .saturating_mul(unit) .saturating_add(header) - .saturating_add(ROW_CHROME_HEIGHT); - self.set_size_request(-1, height); + .saturating_add(ROW_CHROME_HEIGHT) + } + + /// Recompute and apply this row's height after a fold toggle, without + /// re-measuring the (unchanged) global line/header estimates reseed uses. + fn apply_fold_height(&self) { + let imp = self.imp(); + if imp.mail.borrow().is_none() { + return; + } + let (unit, header) = imp.seed_key.get(); + self.set_size_request(-1, self.compute_height(unit, header)); + } + + /// Fold or unfold quoted text in this row's body and resize to fit. + fn set_quote_folded(&self, folded: bool) { + let imp = self.imp(); + imp.quote_folded.set(folded); + if let Some(view) = imp.view.get() { + highlight::set_quote_folded(&view.buffer(), folded); + } + update_fold_label( + imp.quote_toggle.get(), + "quoted text", + imp.quote_lines.get(), + folded, + ); + self.apply_fold_height(); + } + + /// Fold or unfold diff hunks in this row's body and resize to fit. + fn set_diff_folded(&self, folded: bool) { + let imp = self.imp(); + imp.diff_folded.set(folded); + if let Some(view) = imp.view.get() { + highlight::set_diff_folded(&view.buffer(), folded); + } + update_fold_label(imp.diff_toggle.get(), "diff", imp.diff_lines.get(), folded); + self.apply_fold_height(); } fn is_filled(&self) -> bool { @@ -237,6 +294,28 @@ impl MessageRow { if filled { let view = imp.ensure_view().clone(); view.buffer().set_text(&mail.body); + // The buffer (and its tag table) is reused across rebinds, so a + // previous message's fold state must not leak into this one - a + // fresh message always opens fully expanded. + highlight::set_quote_folded(&view.buffer(), false); + highlight::set_diff_folded(&view.buffer(), false); + imp.quote_folded.set(false); + imp.diff_folded.set(false); + let quote_lines = imp.quote_lines.get(); + let diff_lines = imp.diff_lines.get(); + if let Some(toggle) = imp.quote_toggle.get() { + toggle.set_visible(quote_lines > 0); + toggle.set_active(false); + update_fold_label(Some(toggle), "quoted text", quote_lines, false); + } + if let Some(toggle) = imp.diff_toggle.get() { + toggle.set_visible(diff_lines > 0); + toggle.set_active(false); + update_fold_label(Some(toggle), "diff", diff_lines, false); + } + if let Some(fold_row) = imp.fold_row.get() { + fold_row.set_visible(quote_lines > 0 || diff_lines > 0); + } // First body anywhere: learn the real line height so every seed // from here on is exact. Measured as the advance between a one- @@ -278,6 +357,9 @@ impl MessageRow { view.buffer().set_text(""); } self.remove_header(); + if let Some(fold_row) = imp.fold_row.get() { + fold_row.set_visible(false); + } } imp.filled.set(filled); imp.highlighted.set(false); @@ -294,9 +376,10 @@ impl MessageRow { return; }; let composer = imp.composer.get().expect("MessageRow composer set"); + let list_slug = imp.list.get().expect("MessageRow list set"); let is_op = imp.is_op.get(); - let header = build_header_list(mail, &overlay, composer); + let header = build_header_list(mail, &overlay, composer, list_slug); // Selectable header labels replace right-clicks with their own stock // menu, shadowing the row's; hand them the mail actions as an extra // section. The action names resolve against the "mailview" group the @@ -361,6 +444,18 @@ impl MessageRow { } } +/// Set a fold toggle's label to reflect its current state: what folding it +/// would hide (with a count) while expanded, that it is currently hidden +/// while folded. A no-op if the toggle was never built (row not yet filled). +fn update_fold_label(button: Option<>k::ToggleButton>, what: &str, lines: i32, folded: bool) { + let Some(button) = button else { return }; + if folded { + button.set_label(&format!("Show {what} ({lines} lines hidden)")); + } else { + button.set_label(&format!("Hide {what}")); + } +} + mod imp { use std::cell::{Cell, OnceCell, RefCell}; use std::rc::Rc; @@ -426,6 +521,18 @@ mod imp { /// computed with, so reseed is a cheap no-op while the estimates /// are unchanged. pub(super) seed_key: Cell<(i32, i32)>, + /// The row holding the "Quoted text" / "Diff" fold toggles, hidden + /// when the bound message has neither. + pub(super) fold_row: OnceCell, + pub(super) quote_toggle: OnceCell, + pub(super) diff_toggle: OnceCell, + /// Distinct quoted/diff line counts for the bound message, from + /// [`highlight::foldable_line_counts`] - used both to decide whether + /// to show each fold toggle and to size the row while folded. + pub(super) quote_lines: Cell, + pub(super) diff_lines: Cell, + pub(super) quote_folded: Cell, + pub(super) diff_folded: Cell, } #[glib::object_subclass] @@ -466,6 +573,7 @@ mod imp { .right_margin(12) .top_margin(12) .bottom_margin(12) + .css_classes(["koshi-body-text"]) .build(); // Bodies don't wrap (patches carry deliberately long lines), so a @@ -478,6 +586,41 @@ mod imp { .propagate_natural_height(true) .build(); + // Quote/diff fold toggles: message-independent widgetry built + // once, like the view above; fill_header shows/hides and resets + // them per message since a recycled row can go from a message + // with a huge patch to one with none. + let quote_toggle = gtk::ToggleButton::builder() + .css_classes(["flat"]) + .halign(gtk::Align::Start) + .visible(false) + .build(); + let diff_toggle = gtk::ToggleButton::builder() + .css_classes(["flat"]) + .halign(gtk::Align::Start) + .visible(false) + .build(); + let fold_row = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .visible(false) + .build(); + fold_row.append("e_toggle); + fold_row.append(&diff_toggle); + + let row_weak = self.obj().downgrade(); + quote_toggle.connect_toggled(move |button| { + if let Some(row) = row_weak.upgrade() { + row.set_quote_folded(button.is_active()); + } + }); + let row_weak = self.obj().downgrade(); + diff_toggle.connect_toggled(move |button| { + if let Some(row) = row_weak.upgrade() { + row.set_diff_folded(button.is_active()); + } + }); + // The column the fill mounts the header card into, above the // body. Its top margin and spacing are part of the row height // seed (ROW_CHROME_HEIGHT) — keep them in step. @@ -486,7 +629,11 @@ mod imp { .spacing(12) .margin_top(12) .build(); + content.append(&fold_row); content.append(&hscroll); + self.fold_row.set(fold_row).ok(); + self.quote_toggle.set(quote_toggle).ok(); + self.diff_toggle.set(diff_toggle).ok(); // The ListView is the ScrolledWindow's scrollable child (so it can // virtualize), which means the reading-width clamp lives per row @@ -793,7 +940,26 @@ fn parse_thread(mbox: &[u8]) -> Vec { .iter() .map(|raw| parse_message(&unescape_mboxrd(raw))) .collect(); - in_thread_order(mails) + in_thread_order(dedup_by_message_id(mails)) +} + +/// Drop repeated copies of the same message, keeping the first. lore mirrors +/// a message once per mailing list it was posted to, so a thread cross-posted +/// to more than one list can come back from `t.mbox.gz` with the same +/// Message-ID twice - which would otherwise show up as a genuinely +/// duplicated row in both the thread view and the overview sidebar, and (via +/// [`thread_message_digests`], which also goes through this function) as a +/// duplicate notification from the watcher. +fn dedup_by_message_id(mails: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + mails + .into_iter() + .filter(|mail| match &mail.message_id { + Some(id) => seen.insert(id.trim().trim_matches(['<', '>']).to_string()), + // No Message-ID to key on - never dedupe what we cannot compare. + None => true, + }) + .collect() } /// The minimum the subscription watcher needs from a fetched thread: each @@ -1291,7 +1457,7 @@ fn build_thread_content( let overlay = adw::ToastOverlay::new(); // The composer opens targeting the OP; each mail's Reply button can // retarget it later. - let composer = composer::build_composer(build_reply_context(op)); + let composer = composer::build_composer(build_reply_context(op, list)); // Favorites toggled through the header star and through a message's // context menu must agree; the hub keeps every view of a Message-ID in @@ -1331,12 +1497,12 @@ fn build_thread_content( let replies: Rc> = Rc::new( thread .iter() - .map(|mail| build_reply_context(mail)) + .map(|mail| build_reply_context(mail, list)) .collect(), ); let op_fav = favorite_of(op); let op_sub = subscription_of(op); - let op_reply = build_reply_context(op); + let op_reply = build_reply_context(op, list); // The message single view currently shows; the star, Reply and overview // highlight all track it. Single view is the default and opens on `opened`. let single_shown = Rc::new(Cell::new(opened)); @@ -3093,19 +3259,19 @@ fn add_label_extra_menus(widget: >k::Widget, menu: &gio::Menu) { /// Reply prefill: To = the author, Cc = everyone else on the thread, /// Re:-prefixed subject and the mail's Message-ID for threading. Parsed /// address lists are preferred; raw header values are the fallback. -fn build_reply_context(mail: &Mail) -> composer::ReplyContext { +fn build_reply_context(mail: &Mail, list: &str) -> composer::ReplyContext { // Cc the sender to themselves unless they have turned it off, so a copy of // the reply lands in their own mailbox. let cc_self = settings::cc_self() .then(|| profile::cached().sender_header()) .flatten(); - reply_context(mail, cc_self.as_deref()) + reply_context(mail, cc_self.as_deref(), list) } /// The pure core of [`build_reply_context`]: given the optional address to Cc /// the sender at, build the reply prefill. Split out so recipient handling is /// unit-testable without touching git config or the settings store. -fn reply_context(mail: &Mail, cc_self: Option<&str>) -> composer::ReplyContext { +fn reply_context(mail: &Mail, cc_self: Option<&str>, list: &str) -> composer::ReplyContext { let mut cc: Vec = Vec::new(); if mail.to_addrs.is_empty() { cc.push(mail.to.clone()); @@ -3130,6 +3296,11 @@ fn reply_context(mail: &Mail, cc_self: Option<&str>) -> composer::ReplyContext { subject: composer::reply_subject(&mail.subject), in_reply_to: mail.message_id.clone().unwrap_or_default(), references: mail.references.clone().unwrap_or_default(), + // Any Message-ID in the thread reopens the same thread (Koshi always + // fetches a whole thread by any one of its messages), so the mail + // being replied to works as well as the thread root would. + list: Some(list.to_string()), + thread_message_id: mail.message_id.clone(), } } @@ -3533,14 +3704,14 @@ pub(crate) fn add_star_and_bell( } /// A flat Reply icon button that retargets the composer to `mail`. -fn build_reply_button(mail: &Mail, composer: &composer::Composer) -> gtk::Button { +fn build_reply_button(mail: &Mail, composer: &composer::Composer, list_slug: &str) -> gtk::Button { let button = gtk::Button::builder() .icon_name("mail-reply-sender-symbolic") .tooltip_text("Reply") .valign(gtk::Align::Center) .css_classes(["flat"]) .build(); - let reply = build_reply_context(mail); + let reply = build_reply_context(mail, list_slug); button.connect_clicked(glib::clone!( #[strong] composer, @@ -3558,6 +3729,7 @@ fn build_header_list( mail: &Mail, overlay: &adw::ToastOverlay, composer: &composer::Composer, + list_slug: &str, ) -> gtk::ListBox { let list = gtk::ListBox::builder() .selection_mode(gtk::SelectionMode::None) @@ -3583,7 +3755,7 @@ fn build_header_list( let subject_row = build_single_line_row("Subject", &mail.subject, &visible_titles); subject_row.set_tooltip_text(Some(&mail.subject)); if let Some(content) = subject_row.child().and_downcast::() { - content.append(&build_reply_button(mail, composer)); + content.append(&build_reply_button(mail, composer, list_slug)); } list.append(&subject_row); @@ -3778,7 +3950,7 @@ fn build_mail_actions( list: &str, ) -> [gio::SimpleAction; 4] { let reply = gio::SimpleAction::new("reply", None); - let reply_context = build_reply_context(mail); + let reply_context = build_reply_context(mail, list); reply.connect_activate(glib::clone!( #[strong] composer, @@ -3946,7 +4118,7 @@ mod tests { #[test] fn reply_context_targets_the_clicked_message() { let thread = parse_thread(RAW_THREAD); - let reply = reply_context(&thread[1], None); + let reply = reply_context(&thread[1], None, "lkml"); assert_eq!(reply.to, "sashiko-bot@kernel.org"); assert!(reply.cc.contains("Linus Walleij ")); assert!(reply.cc.contains("linux-watchdog@vger.kernel.org")); @@ -3958,16 +4130,19 @@ mod tests { reply.in_reply_to, "<20260619204041.040D71F000E9@smtp.kernel.org>" ); + // The list carries through so a sent reply can reopen the thread. + assert_eq!(reply.list.as_deref(), Some("lkml")); + assert_eq!(reply.thread_message_id, thread[1].message_id); } #[test] fn reply_context_adds_self_to_cc_once() { let to = mail("t@x", None, "Subj", "Author "); // A brand-new address is appended to Cc. - let with_self = reply_context(&to, Some("Me ")); + let with_self = reply_context(&to, Some("Me "), "lkml"); assert!(with_self.cc.contains("Me ")); // Turning it off leaves Cc without it. - let without = reply_context(&to, None); + let without = reply_context(&to, None, "lkml"); assert!(!without.cc.contains("me@x")); } @@ -3976,7 +4151,7 @@ mod tests { // Replying to yourself: self is the reply target, so it must not also // appear in Cc. let own = mail("t@x", None, "Subj", "Me "); - let reply = reply_context(&own, Some("Me ")); + let reply = reply_context(&own, Some("Me "), "lkml"); assert_eq!(reply.to, "Me "); assert!(!reply.cc.contains("me@x")); } @@ -4030,6 +4205,34 @@ mod tests { assert!(thread_message_digests(mbox).is_empty()); } + #[test] + fn a_message_id_repeated_in_the_mbox_is_only_kept_once() { + // lore mirrors a message once per mailing list it was cross-posted + // to, so a thread spanning more than one list can come back from + // t.mbox.gz with the exact same Message-ID twice - both in + // parse_thread (a duplicated row in the thread view) and, since + // thread_message_digests goes through it too, as a duplicate + // notification from the watcher. + let mbox = b"From a@b Thu Jan 1 00:00:00 1970\n\ + From: Nika Krasnova \n\ + Subject: bleh\n\ + Message-ID: \n\n\ + body\n\ + From a@b Thu Jan 1 00:00:00 1970\n\ + From: Nika Krasnova \n\ + Subject: bleh\n\ + Message-ID: \n\n\ + body\n\ + From c@d Thu Jan 1 00:00:00 1970\n\ + From: reply-guy@example.org\n\ + Subject: Re: bleh\n\ + Message-ID: \n\ + In-Reply-To: \n\n\ + ok\n"; + assert_eq!(parse_thread(mbox).len(), 2); + assert_eq!(thread_message_digests(mbox).len(), 2); + } + #[test] fn splits_on_separator_lines_only() { let mbox = b"From a@b Thu Jan 1 00:00:00 1970\nSubject: x\n\n>From escaped\n\ @@ -4116,6 +4319,28 @@ mod tests { } } + #[test] + fn dedup_by_message_id_keeps_only_the_first_copy() { + let mails = vec![ + mail("root@x", None, "bleh", "Nika"), + mail("root@x", None, "bleh", "Nika"), + mail("reply@x", Some("root@x"), "Re: bleh", "Miguel"), + ]; + let deduped = dedup_by_message_id(mails); + assert_eq!(deduped.len(), 2); + assert_eq!(deduped[0].message_id.as_deref(), Some("")); + assert_eq!(deduped[1].message_id.as_deref(), Some("")); + } + + #[test] + fn dedup_by_message_id_never_drops_messages_with_no_id_to_compare() { + let mut a = mail("root@x", None, "bleh", "Nika"); + a.message_id = None; + let mut b = mail("root@x", None, "bleh", "Nika"); + b.message_id = None; + assert_eq!(dedup_by_message_id(vec![a, b]).len(), 2); + } + #[test] fn tree_nests_replies_depth_first() { // op ─ a ─ c ─ d, and op ─ b: DFS must visit a's subtree before b. diff --git a/src/watcher.rs b/src/watcher.rs index db79f82..a8bde09 100644 --- a/src/watcher.rs +++ b/src/watcher.rs @@ -14,7 +14,9 @@ //! limiter trips, it answers *everything* with 503, including whatever page //! the user is trying to read. -use std::collections::{HashMap, HashSet}; +#[cfg(not(target_os = "macos"))] +use std::collections::HashMap; +use std::collections::HashSet; use adw::prelude::*; use gtk::{gio, glib}; @@ -47,6 +49,13 @@ const POLL_SPACING_SECONDS: u32 = 2; /// concurrent cycles onto lore. The interval is re-read every cycle, so a /// change in Preferences is honoured from the following one. pub fn start(app: &adw::Application) { + // Ask up front rather than on the first notification: addNotificationRequest + // fails outright while authorization is still undecided, so requesting only + // when a message actually arrives would routinely lose a fresh install's + // very first notification to that race. See the macos_notify module doc. + #[cfg(target_os = "macos")] + macos_notify::request_authorization(); + let weak = app.downgrade(); glib::spawn_future_local(async move { glib::timeout_future_seconds(STARTUP_POLL_DELAY_SECONDS).await; @@ -222,6 +231,11 @@ async fn poll_one(app: &adw::Application, subscription: Subscription) -> Result< /// `id` is the message's own Message-Id: the portal replaces a notification /// whose id it has already seen, so reusing it dedupes a message that somehow /// surfaces twice without coalescing distinct arrivals. +/// +/// macOS has no session D-Bus (and so no portal); [`macos_notify::notify`] +/// below raises the same notification through `UNUserNotificationCenter` +/// instead, the Cocoa-native equivalent. +#[cfg(not(target_os = "macos"))] fn notify_new_message(app: &adw::Application, id: &str, author: &str, subject: &str) { let Some(connection) = app.dbus_connection() else { log::warn!("no session bus; cannot notify about \"{subject}\""); @@ -246,9 +260,19 @@ fn notify_new_message(app: &adw::Application, id: &str, author: &str, subject: & ); } +/// See the doc comment on the non-macOS `notify_new_message` above; this is +/// its `UNUserNotificationCenter` counterpart. `app` is unused here — Cocoa's +/// notification center is a process-wide singleton, not reached through the +/// session bus connection the portal needs. +#[cfg(target_os = "macos")] +fn notify_new_message(_app: &adw::Application, id: &str, author: &str, subject: &str) { + macos_notify::notify(id, author, subject); +} + /// The `(sa{sv})` argument tuple for /// `org.freedesktop.portal.Notification.AddNotification`: the notification id /// and its property dictionary (title, body, icon). +#[cfg(not(target_os = "macos"))] fn add_notification_params(id: &str, author: &str, subject: &str) -> glib::Variant { let mut notification: HashMap<&str, glib::Variant> = HashMap::new(); notification.insert("title", author.to_variant()); @@ -263,7 +287,7 @@ fn add_notification_params(id: &str, author: &str, subject: &str) -> glib::Varia (id, notification).to_variant() } -#[cfg(test)] +#[cfg(all(test, not(target_os = "macos")))] mod tests { use super::*; @@ -286,3 +310,111 @@ mod tests { assert_eq!(icon.type_().as_str(), "(sv)"); } } + +/// `UNUserNotificationCenter` backend for macOS, which has no session D-Bus +/// (and so no XDG notification portal) for `notify_new_message` above to use. +/// +/// Notifications need per-app authorization. [`macos_notify::request_authorization`] +/// fires it once at startup ([`start`] calls it), so the system prompt has the +/// whole [`STARTUP_POLL_DELAY_SECONDS`] head start (and realistically much +/// longer, given typical poll intervals) to be answered before any message +/// actually needs to notify — `addNotificationRequest` fails outright for a +/// still-undecided authorization, so asking any later would routinely lose +/// the very first notification of a fresh install to that race. +/// [`macos_notify::notify`] requests again defensively (a no-op once already +/// requested) in case it is ever reached without `start` having run first. +#[cfg(target_os = "macos")] +mod macos_notify { + use std::cell::Cell; + + use objc2::runtime::Bool; + use objc2_foundation::{NSBundle, NSError, NSString}; + use objc2_user_notifications::{ + UNAuthorizationOptions, UNMutableNotificationContent, UNNotificationRequest, + UNUserNotificationCenter, + }; + + thread_local! { + /// Whether authorization has been requested this run yet. + static AUTHORIZATION_REQUESTED: Cell = const { Cell::new(false) }; + /// The last known answer: `None` until the (async) system prompt has + /// actually been answered, then whatever it decided. Preferences + /// reads this to warn the user when it is `Some(false)` - the prompt + /// only ever appears once per install, so a denial is otherwise + /// invisible short of checking System Settings directly. + static AUTHORIZATION_GRANTED: Cell> = const { Cell::new(None) }; + } + + /// Ask the user to allow notifications, if this run has not already + /// asked. A no-op outside an app bundle, for the same reason [`notify`] + /// skips there — see its doc comment. + pub(super) fn request_authorization() { + if NSBundle::mainBundle().bundleIdentifier().is_none() { + return; + } + if AUTHORIZATION_REQUESTED.replace(true) { + return; + } + let center = UNUserNotificationCenter::currentNotificationCenter(); + let options = UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound; + let handler = block2::RcBlock::new(|granted: Bool, _error: *mut NSError| { + let granted = granted.as_bool(); + AUTHORIZATION_GRANTED.set(Some(granted)); + if !granted { + log::warn!("notification authorization was not granted"); + } + }); + center.requestAuthorizationWithOptions_completionHandler(options, &handler); + } + + /// Whether the system prompt has been answered *and* the answer was no - + /// `false` both when it was granted and while it is still undecided (the + /// prompt is asynchronous; the answer may not be in yet), so a caller + /// checking this to decide whether to show a warning never flashes one + /// before startup's authorization request has had a chance to resolve. + pub(super) fn is_denied() -> bool { + AUTHORIZATION_GRANTED.get() == Some(false) + } + + pub(super) fn notify(id: &str, author: &str, subject: &str) { + // UNUserNotificationCenter reads the process's own bundle identifier + // internally and *aborts the whole process* (an uncaught + // NSInternalInconsistencyException, not a Rust-catchable error) when + // there isn't one - true for `cargo run`/`cargo test`, which run the + // bare binary outside any .app bundle. Notifications only work from + // the packaged .app (see build-aux/macos/bundle.sh); anywhere else, + // skip with a log line instead of taking the process down. + if NSBundle::mainBundle().bundleIdentifier().is_none() { + log::warn!("not running inside an app bundle; cannot notify about \"{subject}\""); + return; + } + request_authorization(); + + let center = UNUserNotificationCenter::currentNotificationCenter(); + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(author)); + content.setBody(&NSString::from_str(subject)); + + let request = UNNotificationRequest::requestWithIdentifier_content_trigger( + &NSString::from_str(id), + &content, + None, + ); + + let subject = subject.to_string(); + let completion = block2::RcBlock::new(move |error: *mut NSError| { + if !error.is_null() { + log::warn!("notification failed for \"{subject}\""); + } + }); + center.addNotificationRequest_withCompletionHandler(&request, Some(&completion)); + } +} + +/// Whether the user has explicitly declined the macOS notification +/// authorization prompt, for Preferences to surface a warning — see +/// [`macos_notify::is_denied`] for exactly what this does and does not mean. +#[cfg(target_os = "macos")] +pub fn notifications_denied() -> bool { + macos_notify::is_denied() +}