Skip to main content

max / balanced_breakfast

v0.3.1 — Mobile/touch support, concurrent feed fetching, sync scheduler hardening Add mobile viewport and touch gesture modules (mobile.js, touch.js) with swipe navigation and responsive CSS. Switch feed fetching to concurrent via futures::join_all in orchestrator and Rhai plugin host. Harden sync scheduler with backoff, jitter, and forced full-sync fallback. Improve crypto error handling with tracing::warn on encrypt/decrypt failures. Add open crate for cross-platform link opening. Introduce open_in_browser Tauri command. Update feed ordering with tie-breaking by title. Clean up completed items from todo docs and remove archived done list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-13 22:18 UTC
Commit: 8b8e3f9966ea76ccc8b0f669463c675b264714d0
Parent: 319bc1e
30 files changed, +1123 insertions, -618 deletions
M Cargo.lock +28 -8
@@ -190,6 +190,8 @@ dependencies = [
190 190 "bb-feed",
191 191 "bb-interface",
192 192 "chrono",
193 + "futures",
194 + "open",
193 195 "parking_lot",
194 196 "rand 0.8.5",
195 197 "roxmltree",
@@ -243,6 +245,7 @@ dependencies = [
243 245 "bb-interface",
244 246 "chrono",
245 247 "docengine",
248 + "futures",
246 249 "html2text",
247 250 "keyring",
248 251 "parking_lot",
@@ -920,7 +923,7 @@ dependencies = [
920 923 "libc",
921 924 "option-ext",
922 925 "redox_users",
923 - "windows-sys 0.59.0",
926 + "windows-sys 0.61.2",
924 927 ]
925 928
926 929 [[package]]
@@ -1088,7 +1091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1088 1091 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1089 1092 dependencies = [
1090 1093 "libc",
1091 - "windows-sys 0.59.0",
1094 + "windows-sys 0.61.2",
1092 1095 ]
1093 1096
1094 1097 [[package]]
@@ -1250,6 +1253,21 @@ dependencies = [
1250 1253 ]
1251 1254
1252 1255 [[package]]
1256 + name = "futures"
1257 + version = "0.3.31"
1258 + source = "registry+https://github.com/rust-lang/crates.io-index"
1259 + checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
1260 + dependencies = [
1261 + "futures-channel",
1262 + "futures-core",
1263 + "futures-executor",
1264 + "futures-io",
1265 + "futures-sink",
1266 + "futures-task",
1267 + "futures-util",
1268 + ]
1269 +
1270 + [[package]]
1253 1271 name = "futures-channel"
1254 1272 version = "0.3.31"
1255 1273 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1322,6 +1340,7 @@ version = "0.3.31"
1322 1340 source = "registry+https://github.com/rust-lang/crates.io-index"
1323 1341 checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
1324 1342 dependencies = [
1343 + "futures-channel",
1325 1344 "futures-core",
1326 1345 "futures-io",
1327 1346 "futures-macro",
@@ -2641,7 +2660,7 @@ version = "0.50.3"
2641 2660 source = "registry+https://github.com/rust-lang/crates.io-index"
2642 2661 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
2643 2662 dependencies = [
2644 - "windows-sys 0.59.0",
2663 + "windows-sys 0.61.2",
2645 2664 ]
2646 2665
2647 2666 [[package]]
@@ -3026,7 +3045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3026 3045 checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
3027 3046 dependencies = [
3028 3047 "libc",
3029 - "windows-sys 0.45.0",
3048 + "windows-sys 0.61.2",
3030 3049 ]
3031 3050
3032 3051 [[package]]
@@ -3885,7 +3904,7 @@ dependencies = [
3885 3904 "errno",
3886 3905 "libc",
3887 3906 "linux-raw-sys",
3888 - "windows-sys 0.59.0",
3907 + "windows-sys 0.61.2",
3889 3908 ]
3890 3909
3891 3910 [[package]]
@@ -3942,7 +3961,7 @@ dependencies = [
3942 3961 "security-framework",
3943 3962 "security-framework-sys",
3944 3963 "webpki-root-certs",
3945 - "windows-sys 0.59.0",
3964 + "windows-sys 0.61.2",
3946 3965 ]
3947 3966
3948 3967 [[package]]
@@ -4832,6 +4851,7 @@ dependencies = [
4832 4851 "serde_json",
4833 4852 "thiserror 2.0.18",
4834 4853 "tokio",
4854 + "tokio-stream",
4835 4855 "tracing",
4836 4856 "unicode-normalization",
4837 4857 "urlencoding",
@@ -5307,7 +5327,7 @@ dependencies = [
5307 5327 "getrandom 0.3.4",
5308 5328 "once_cell",
5309 5329 "rustix",
5310 - "windows-sys 0.59.0",
5330 + "windows-sys 0.61.2",
5311 5331 ]
5312 5332
5313 5333 [[package]]
@@ -6285,7 +6305,7 @@ version = "0.1.11"
6285 6305 source = "registry+https://github.com/rust-lang/crates.io-index"
6286 6306 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
6287 6307 dependencies = [
6288 - "windows-sys 0.48.0",
6308 + "windows-sys 0.61.2",
6289 6309 ]
6290 6310
6291 6311 [[package]]
M Cargo.toml +5 -1
@@ -10,7 +10,7 @@ members = [
10 10 default-members = ["src-tauri"]
11 11
12 12 [workspace.package]
13 - version = "0.3.0"
13 + version = "0.3.1"
14 14 edition = "2021"
15 15 authors = ["BalancedBreakfast Contributors"]
16 16 license-file = "LICENSE"
@@ -40,6 +40,10 @@ keyring = "3"
40 40
41 41 # Concurrency
42 42 parking_lot = "0.12"
43 + futures = "0.3"
44 +
45 + # Cross-platform file opener
46 + open = "5"
43 47
44 48 # Utilities
45 49 chrono = { version = "0.4.43", features = ["serde"] }
@@ -48,5 +48,8 @@ regex = "1"
48 48 # HTML sanitization for untrusted feed content
49 49 docengine = { path = "../../../../Shared/docengine" }
50 50
51 + # Concurrent fetching
52 + futures = { workspace = true }
53 +
51 54 # Article extraction (reader view)
52 55 readable-readability = "0.4"
@@ -162,8 +162,13 @@ pub fn encrypt_config_secrets(
162 162 }
163 163 if let Some(serde_json::Value::String(val)) = obj.get(&field.key) {
164 164 if !val.is_empty() && !val.starts_with(PREFIX) {
165 - if let Ok(encrypted) = encrypt_field(val, key) {
166 - obj.insert(field.key.clone(), serde_json::Value::String(encrypted));
165 + match encrypt_field(val, key) {
166 + Ok(encrypted) => {
167 + obj.insert(field.key.clone(), serde_json::Value::String(encrypted));
168 + }
169 + Err(e) => {
170 + tracing::warn!(field = %field.key, error = %e, "Failed to encrypt secret, plaintext retained");
171 + }
167 172 }
168 173 }
169 174 }
@@ -185,8 +190,13 @@ pub fn decrypt_config_secrets(
185 190 }
186 191 if let Some(serde_json::Value::String(val)) = obj.get(&field.key) {
187 192 if val.starts_with(PREFIX) {
188 - if let Ok(decrypted) = decrypt_field(val, key) {
189 - obj.insert(field.key.clone(), serde_json::Value::String(decrypted));
193 + match decrypt_field(val, key) {
194 + Ok(decrypted) => {
195 + obj.insert(field.key.clone(), serde_json::Value::String(decrypted));
196 + }
197 + Err(e) => {
198 + tracing::warn!(field = %field.key, error = %e, "Failed to decrypt secret, ciphertext passed through");
199 + }
190 200 }
191 201 }
192 202 }
@@ -8,6 +8,7 @@ use std::sync::Arc;
8 8
9 9 use bb_db::Database;
10 10 use bb_interface::{BusserConfig, ConfigFieldType};
11 + use futures::stream::{self, StreamExt};
11 12 use thiserror::Error;
12 13 use tokio::sync::RwLock;
13 14 use tracing::{debug, error, info, instrument};
@@ -291,7 +292,7 @@ impl Orchestrator {
291 292 self.fetch_plugin(plugin_id).await
292 293 }
293 294
294 - /// Fetch from all active plugins
295 + /// Fetch from all active plugins concurrently (up to 4 at a time)
295 296 #[instrument(skip_all)]
296 297 pub async fn fetch_all(&self) -> Result<usize, OrchestratorError> {
297 298 let plugin_ids = {
@@ -299,15 +300,19 @@ impl Orchestrator {
299 300 plugins.list_plugins()
300 301 };
301 302
302 - let mut total = 0;
303 - for plugin_id in plugin_ids {
304 - match self.fetch_plugin(&plugin_id).await {
305 - Ok(count) => total += count,
306 - Err(e) => {
307 - error!(error = %e, %plugin_id, "Failed to fetch from plugin");
303 + let total: usize = stream::iter(plugin_ids)
304 + .map(|plugin_id| async move {
305 + match self.fetch_plugin(&plugin_id).await {
306 + Ok(count) => count,
307 + Err(e) => {
308 + error!(error = %e, %plugin_id, "Failed to fetch from plugin");
309 + 0
310 + }
308 311 }
309 - }
310 - }
312 + })
313 + .buffer_unordered(4)
314 + .fold(0usize, |acc, count| async move { acc + count })
315 + .await;
311 316
312 317 Ok(total)
313 318 }
@@ -60,6 +60,18 @@ fn validate_url(url: &str) -> Result<(), String> {
60 60 {
61 61 return Err(format!("Blocked request to internal address: {}", url));
62 62 }
63 + // Block IPv6 private/link-local ranges (fc00::/7, fe80::/10)
64 + // and IPv4-mapped IPv6 addresses (::ffff:x.x.x.x)
65 + if host.starts_with('[') {
66 + let inner = &host[1..host.len().saturating_sub(1)].to_ascii_lowercase();
67 + if inner.starts_with("fc")
68 + || inner.starts_with("fd")
69 + || inner.starts_with("fe80")
70 + || inner.starts_with("::ffff:")
71 + {
72 + return Err(format!("Blocked request to internal address: {}", url));
73 + }
74 + }
63 75 // Block 172.16.0.0/12
64 76 if let Some(rest) = host.strip_prefix("172.") {
65 77 if let Some(second) = rest.split('.').next() {
@@ -630,6 +642,23 @@ mod tests {
630 642 }
631 643
632 644 #[test]
645 + fn validate_url_blocks_ipv6_private_ranges() {
646 + // Unique local addresses (fc00::/7)
647 + assert!(validate_url("http://[fc00::1]/api").is_err());
648 + assert!(validate_url("http://[fd12:3456::1]/api").is_err());
649 + // Link-local (fe80::/10)
650 + assert!(validate_url("http://[fe80::1]/api").is_err());
651 + // IPv4-mapped IPv6
652 + assert!(validate_url("http://[::ffff:127.0.0.1]/api").is_err());
653 + assert!(validate_url("http://[::ffff:192.168.1.1]/api").is_err());
654 + }
655 +
656 + #[test]
657 + fn validate_url_allows_public_ipv6() {
658 + assert!(validate_url("http://[2001:db8::1]/api").is_ok());
659 + }
660 +
661 + #[test]
633 662 fn validate_url_case_insensitive_scheme() {
634 663 assert!(validate_url("HTTP://example.com").is_ok());
635 664 assert!(validate_url("HTTPS://example.com").is_ok());
@@ -228,7 +228,7 @@ impl RhaiPlugin {
228 228 .map_err(|e| RhaiPluginError::RuntimeError(e.to_string()))?;
229 229
230 230 validate_dynamic_sizes(&result, 0)
231 - .map_err(|e| RhaiPluginError::RuntimeError(e))?;
231 + .map_err(RhaiPluginError::RuntimeError)?;
232 232
233 233 dynamic_to_fetch_result(result, &self.id)
234 234 }
@@ -236,76 +236,59 @@ impl RhaiPlugin {
236 236
237 237 /// Manager for Rhai plugins.
238 238 pub struct RhaiPluginManager {
239 - engine: Arc<Engine>,
240 239 plugins: HashMap<String, RhaiPlugin>,
241 - request_counter: Arc<AtomicUsize>,
242 - fetch_deadline: Arc<AtomicU64>,
243 240 }
244 241
245 242 impl RhaiPluginManager {
246 - /// Create a new plugin manager with the Rhai engine configured.
243 + /// Create a new plugin manager.
247 244 ///
248 - /// Safety limits prevent malicious or buggy scripts from hanging the app:
249 - /// - `max_operations(100_000)`: caps total operations per script execution.
250 - /// A typical RSS fetch costs 1k–5k ops; 100k allows complex plugins while
251 - /// catching infinite loops.
252 - /// - `max_expr_depths(128, 128)`: limits AST nesting depth for both expressions
253 - /// and functions, preventing stack overflows from deeply recursive scripts.
254 - /// - `max_call_levels(32)`: limits function call nesting depth.
255 - /// - HTTP limits: 15s timeout, 2 MB response cap, 100 requests per fetch,
256 - /// http/https only, no localhost/internal addresses.
257 - ///
258 - /// Data size limits (max_string_size, max_array_size, max_map_size) are
259 - /// intentionally disabled. Rhai counts these cumulatively across the entire
260 - /// nested value tree (not per container), so normal API responses trip modest
261 - /// limits. The 2 MB HTTP response cap is the real memory gate.
245 + /// Each plugin gets its own Rhai engine (created in `load_plugin()`) with
246 + /// independent request counters and fetch deadlines. This prevents concurrent
247 + /// plugins from interfering with each other's rate limits.
262 248 pub fn new() -> Self {
263 - let mut engine = Engine::new();
264 -
265 - engine.set_max_expr_depths(128, 128);
266 - engine.set_max_operations(100_000);
267 - engine.set_max_call_levels(32);
268 -
269 - let request_counter = Arc::new(AtomicUsize::new(0));
270 - let fetch_deadline = Arc::new(AtomicU64::new(0));
271 - register_host_functions(&mut engine, request_counter.clone(), fetch_deadline.clone());
272 -
273 249 Self {
274 - engine: Arc::new(engine),
275 250 plugins: HashMap::new(),
276 - request_counter,
277 - fetch_deadline,
278 251 }
279 252 }
280 253
281 254 /// Load a plugin from a .rhai file.
255 + ///
256 + /// Creates a fresh Rhai engine per plugin with its own request counter and
257 + /// fetch deadline. This isolates concurrent plugins from interfering with
258 + /// each other's rate limits during `fetch_all()`.
282 259 pub fn load_plugin(&mut self, path: &Path) -> Result<String, RhaiPluginError> {
283 260 let script = std::fs::read_to_string(path)
284 261 .map_err(|e| RhaiPluginError::CompileError(format!("Failed to read file: {}", e)))?;
285 262
286 - let ast = self
287 - .engine
263 + // Create a per-plugin engine with its own atomics
264 + let mut engine = Engine::new();
265 + engine.set_max_expr_depths(128, 128);
266 + engine.set_max_operations(100_000);
267 + engine.set_max_call_levels(32);
268 + let request_counter = Arc::new(AtomicUsize::new(0));
269 + let fetch_deadline = Arc::new(AtomicU64::new(0));
270 + register_host_functions(&mut engine, request_counter.clone(), fetch_deadline.clone());
271 + let engine = Arc::new(engine);
272 +
273 + let ast = engine
288 274 .compile(&script)
289 275 .map_err(|e| RhaiPluginError::CompileError(e.to_string()))?;
290 276
291 277 // Evaluate top-level code (const declarations) so they're visible in functions
292 278 let mut scope = Scope::new();
293 - self.engine
279 + engine
294 280 .run_ast_with_scope(&mut scope, &ast)
295 281 .map_err(|e| RhaiPluginError::CompileError(format!("Top-level eval failed: {}", e)))?;
296 282
297 - let id: String = self
298 - .engine
283 + let id: String = engine
299 284 .call_fn(&mut scope, &ast, "id", ())
300 285 .map_err(|e| RhaiPluginError::MissingFunction(format!("id(): {}", e)))?;
301 286
302 - let name: String = self
303 - .engine
287 + let name: String = engine
304 288 .call_fn(&mut scope, &ast, "name", ())
305 289 .map_err(|e| RhaiPluginError::MissingFunction(format!("name(): {}", e)))?;
306 290
307 - let _: Dynamic = self
308 - .engine
291 + let _: Dynamic = engine
309 292 .call_fn(&mut scope, &ast, "config_schema", ())
310 293 .map_err(|e| RhaiPluginError::MissingFunction(format!("config_schema(): {}", e)))?;
311 294
@@ -316,9 +299,9 @@ impl RhaiPluginManager {
316 299 name,
317 300 path: path.to_path_buf(),
318 301 ast,
319 - engine: self.engine.clone(),
320 - request_counter: self.request_counter.clone(),
321 - fetch_deadline: self.fetch_deadline.clone(),
302 + engine,
303 + request_counter,
304 + fetch_deadline,
322 305 };
323 306
324 307 self.plugins.insert(id.clone(), plugin);
@@ -590,20 +573,20 @@ mod tests {
590 573
591 574 #[test]
592 575 fn sandbox_enforces_operation_limit() {
593 - let manager = RhaiPluginManager::new();
594 - let result = manager.engine.eval::<()>("let x = 0; loop { x += 1; }");
576 + let engine = create_engine();
577 + let result = engine.eval::<()>("let x = 0; loop { x += 1; }");
595 578 assert!(result.is_err(), "infinite loop should be stopped by operation limit");
596 579 }
597 580
598 581 #[test]
599 582 fn sandbox_enforces_call_level_limit() {
600 - let manager = RhaiPluginManager::new();
583 + let engine = create_engine();
601 584 // Recursive function that exceeds 32 call levels
602 585 let script = r#"
603 586 fn recurse(n) { recurse(n + 1) }
604 587 recurse(0)
605 588 "#;
606 - let result = manager.engine.eval::<()>(script);
589 + let result = engine.eval::<()>(script);
607 590 assert!(result.is_err(), "deep recursion should be stopped by call level limit");
608 591 }
609 592
@@ -3,6 +3,8 @@
3 3 //! `OrderBy` controls sort order; `FeedFilter` narrows which items
4 4 //! are shown. Both are applied in-memory after the database query.
5 5
6 + use std::collections::HashMap;
7 +
6 8 use bb_db::QueryCondition;
7 9 use bb_interface::FeedItem;
8 10 use regex::Regex;
@@ -160,8 +162,35 @@ impl FeedFilter {
160 162 filter
161 163 }
162 164
165 + /// Pre-compile regex patterns from conditions. Called once before filtering
166 + /// a batch of items to avoid recompiling per-item.
167 + fn compile_regexes(&self) -> HashMap<usize, Regex> {
168 + let mut cache = HashMap::new();
169 + for (i, c) in self.conditions.iter().enumerate() {
170 + if c.operator == "matches_regex" {
171 + match Regex::new(&c.value) {
172 + Ok(re) => { cache.insert(i, re); }
173 + Err(e) => {
174 + tracing::warn!(
175 + regex = %c.value,
176 + error = %e,
177 + "Invalid regex in query feed condition, skipping"
178 + );
179 + }
180 + }
181 + }
182 + }
183 + cache
184 + }
185 +
163 186 /// Check if an item matches the filter
164 187 pub fn matches(&self, item: &FeedItem) -> bool {
188 + let cache = self.compile_regexes();
189 + self.matches_with_cache(item, &cache)
190 + }
191 +
192 + /// Check if an item matches using pre-compiled regex cache.
193 + fn matches_with_cache(&self, item: &FeedItem, regex_cache: &HashMap<usize, Regex>) -> bool {
165 194 // Check source filter
166 195 if let Some(ref source) = self.source {
167 196 if item.id.source.as_str() != source {
@@ -214,7 +243,7 @@ impl FeedFilter {
214 243 // Source/starred/unread/tag conditions are already handled by the
215 244 // fast-path fields above (set in `from_conditions()`), so we only
216 245 // need to evaluate title/author/body conditions here.
217 - for c in &self.conditions {
246 + for (i, c) in self.conditions.iter().enumerate() {
218 247 match c.field.as_str() {
219 248 "title" | "author" | "body" => {
220 249 let field_value = match c.field.as_str() {
@@ -240,20 +269,12 @@ impl FeedFilter {
240 269 }
241 270 }
242 271 "matches_regex" => {
243 - match Regex::new(&c.value) {
244 - Ok(re) => {
245 - if !re.is_match(field_value) {
246 - return false;
247 - }
248 - }
249 - Err(e) => {
250 - tracing::warn!(
251 - regex = %c.value,
252 - error = %e,
253 - "Invalid regex in query feed condition, skipping"
254 - );
272 + if let Some(re) = regex_cache.get(&i) {
273 + if !re.is_match(field_value) {
274 + return false;
255 275 }
256 276 }
277 + // Missing from cache = invalid regex, skip (already warned)
257 278 }
258 279 _ => {}
259 280 }
@@ -268,7 +289,8 @@ impl FeedFilter {
268 289
269 290 /// Apply filter to a list of items
270 291 pub fn apply(&self, items: Vec<FeedItem>) -> Vec<FeedItem> {
271 - items.into_iter().filter(|item| self.matches(item)).collect()
292 + let cache = self.compile_regexes();
293 + items.into_iter().filter(|item| self.matches_with_cache(item, &cache)).collect()
272 294 }
273 295
274 296 /// Apply only the tag filter to a list of items.
@@ -1,227 +0,0 @@
1 - # Balanced Breakfast — Completed Work
2 -
3 - Archived completed sections from todo.md. All items here are done.
4 -
5 - ## Tauri Conversion
6 -
7 - ### Done
8 - - [x] Rewrote bb-db for SQLite (PgPool → SqlitePool, $N → ?N placeholders, model types to String)
9 - - [x] Created SQLite migrations (feeds, feed_items, busser_state)
10 - - [x] Removed bb-display (TUI), bb-web (Axum), old CLI entry point
11 - - [x] Created src-tauri Tauri 2 app shell (Cargo.toml, build.rs, tauri.conf.json, capabilities)
12 - - [x] Implemented AppState with Orchestrator, plugin loading, DB init
13 - - [x] Ported Axum handlers to 15 Tauri commands (items, sources, plugins, feeds)
14 - - [x] Built vanilla JS frontend with BB namespace (api, state, utils, components, sources, items, detail, feeds, app)
15 - - [x] Three-panel layout (sources sidebar, items list, item detail)
16 - - [x] Native menu bar (File: Refresh/Add Feed, View: All/Unread/Starred, Edit, Help)
17 - - [x] Keyboard shortcuts (j/k navigate, s star, r read, / search, Escape close)
18 - - [x] Plugin bundling (dev-mode fallback to project-root plugins/, production copy to config dir)
19 - - [x] Deleted old PostgreSQL migrations
20 -
21 - ## Phase 1: Polish
22 -
23 - ### Done
24 - - [x] Fix stale PostgreSQL default in OrchestratorConfig
25 - - [x] Remove dead code: LoadedPlugin struct, unused config field, JS computeTimeAgo/formatTime
26 - - [x] Extract TIMESTAMP_FMT constant (used throughout repository.rs)
27 - - [x] Extract shared parse_timestamp helper (deduplicated 3 copies)
28 - - [x] Replace magic sort-order strings with OrderBy::from_str_loose
29 - - [x] Fix expect() panic in state.rs plugin dir resolution (fallback chain)
30 - - [x] Add PluginError::FetchError variant (was misusing InitError)
31 - - [x] Add ApiError::bad_request variant (invalid UUIDs no longer return not_found)
32 - - [x] Set Rhai max operations limit to 100,000
33 - - [x] Fix per-source unread count (count_unread_by_busser query, wired into generator + sidebar)
34 - - [x] Wrap delete_feed in SQLite transaction
35 - - [x] Delete feed from UI (delete button on sources sidebar, delete_feeds_by_busser command)
36 - - [x] Background auto-fetch per plugin (BusserCapabilities.fetch_interval_secs, default 15min, 60s check loop, frontend auto-refresh via event)
37 - - [x] Generate proper app icons (fried-egg AI-star parody logo, all Tauri formats)
38 - - [x] Plugin error handling (warn!() on capabilities/config_schema failure, debug!() on expected empty paths, auto-fetch-error event + toast)
39 - - [x] Fix silent unwrap_or_default() in db models (parse_or_default helper with tracing::warn, 6 call sites)
40 -
41 - ## Phase 2: Features
42 -
43 - ### Done
44 - - [x] OPML import (File menu + Cmd+I, parses outlines with xmlUrl, deduplicates)
45 - - [x] OPML export (File menu + Cmd+E, exports RSS feeds as OPML 2.0 download)
46 - - [x] URL tracker parameter stripping (strip utm_*, fbclid, gclid, msclkid from item links/body on fetch; exposed to Rhai plugins)
47 - - [x] JSON Feed format support (JSON Feed 1.0/1.1 auto-detected in parse_feed, 4 tests)
48 - - [x] Feed tags (feed_tags junction table, TagsRepository CRUD, sidebar tag filter bar + tag chips, 6 tests)
49 - - [x] Full-text search (FTS5 external content mode, 3 sync triggers, sanitize_fts_query, rank ordering, 6 tests)
50 - - [x] Feed health monitoring (consecutive_failures/last_error/last_success_at on feeds, record_success/failure, server-authoritative health dots in sidebar, 3 tests)
51 - - [x] Theming support (9 TOML themes, bundled + user custom, high-contrast accessibility theme, multi-source path resolution)
52 - - [x] Stale item cleanup (background task every 6h, deletes read non-starred items older than 30 days)
53 - - [x] Validate feed config inputs before storage (name, field types, lengths, duplicates)
54 - - [x] Encrypt plugin secrets at rest (AES-256-GCM, bb_enc:v1: format, auto-migration)
55 -
56 - ## Pre-Launch Fixes (from audit 2026-02-27)
57 -
58 - ### Done
59 - - [x] Moved raw SQL from `orchestrator.rs` to `FeedsRepository::update_config()` (layer violation fixed)
60 - - [x] Wrapped `TagsRepository::set_tags()` DELETE+INSERTs in a transaction (repository.rs)
61 - - [x] Removed unused `thiserror` dep from bb-interface Cargo.toml
62 -
63 - ## Phase 3: Testing & Docs
64 -
65 - ### Done
66 - - [x] 4 unit tests in bb-core plugin_manager
67 - - [x] Unit tests for bb-db repository layer (25 tests: 8 feeds, 12 items, 5 state + 6 tags + 6 FTS5 + 3 health)
68 - - [x] Unit tests for bb-core url_cleaner (10 tests) and conversions/json_feed (4 tests)
69 - - [x] Unit tests for bb-feed ordering/generator (~15 tests, lines 295-508)
70 - - [x] Integration tests for Tauri commands (33 tests: OPML import/export, item CRUD, feed CRUD, tags, orchestrator)
71 - - [x] CI pipeline — `.build.yml` for builds.sr.ht (check, test, clippy, audit)
72 - - [x] README with setup instructions, workspace architecture, and plugin authoring guide
73 -
74 - ## Audit Follow-Up (2026-02-27)
75 -
76 - ### Done — Security
77 - - [x] Fix single-quote XSS in tag filter bar — uses `addEventListener` (not inline onclick) (`sources.js`)
78 - - [x] Set restrictive file permissions on `encryption.key` — sets `0o600` on Unix (`crypto.rs`)
79 - - [x] Replace `.expect("poisoned")` with error propagation — all 6 uses `.map_err()` (`plugin_manager.rs`, `state.rs`)
80 -
81 - ### Done — Code Quality
82 - - [x] Remove deprecated stub functions `markFailed`, `clearFailed`, `clearAllFailed` — removed from `sources.js`
83 - - [x] `sanitizeHtml` blocks `data:` URIs alongside `javascript:` (`utils.js`)
84 -
85 - ### Done — Testing
86 - - [x] Add unit tests for `FeedFilter::apply_tags_only()` and tag-related `matches()` paths — 8 tests in `ordering.rs`
87 - - [x] Add Tauri command unit tests — extracted `validate_feed_input` from `create_feed`, 25 tests for feed validation (name, config, URL, number, select, toggle, text length), 5 tests for `format_time_ago`, 9 tests for `validate_theme_id` and `parse_meta` (`commands/feeds.rs`, `commands/items.rs`, `commands/themes.rs`)
88 -
89 - ### Done — Clippy (2026-02-28, third audit)
90 - - [x] Fix `items_after_test_module` in `bb-interface/src/busser.rs` — moved Busser trait above test module
91 - - [x] Fix `useless_vec` in `bb-interface/src/busser.rs` — changed to array literal
92 - - [x] Fix `bool_assert_comparison` in `bb-core/rhai_plugin/conversions.rs` — use `assert!()`
93 - - [x] Fix `approx_constant` in `bb-core/rhai_plugin/conversions.rs` — `#[allow]` (intentional `3.14` round-trip test)
94 - - [x] Fix `unnecessary_get_then_check` in `bb-core/rhai_plugin/conversions.rs` — use `!map.contains_key()`
95 -
96 - ### Done — Test Coverage (Mar 1, 2026)
97 - - [x] Host function tests: 12 tests (parse_int, parse_datetime, html_to_text, str_contains, str_split, timestamp_now) (`crates/bb-core/src/rhai_plugin/host_functions.rs`)
98 - - [x] Orchestrator tests: 5 tests (config defaults, new+migrate, fetch interval, store+dedup) (`crates/bb-core/src/orchestrator.rs`)
99 - - [x] State timing tests: 3 tests (first time, overdue, not yet due) — extracted `is_single_feed_due` pure helper (`src-tauri/src/state.rs`)
100 -
101 - ### Done — Error Handling (Mar 1, 2026)
102 - - [x] `From<sqlx::Error>` for `ApiError` — eliminates 28 `.map_err(|e| ApiError::database(e.to_string()))` calls
103 - - [x] `From<bb_feed::FeedError>` for `ApiError` — same pattern for feed generator errors
104 - - [x] `From<OrchestratorError>` for `ApiError` — semantic mapping (Database→DATABASE, Plugin→PLUGIN, Feed→DATABASE, Config→INTERNAL)
105 - - [x] Logged silent event emission failures in auto-fetch background task (`state.rs`)
106 -
107 - ---
108 -
109 - ## JS Audit (2026-03-11) — Complete (8/8)
110 -
111 - ### Done — Critical
112 - - [x] Add escapeAttr() to item.id in onclick handler (items.js, new escapeAttr function in utils.js)
113 -
114 - ### Done — Medium
115 - - [x] Remove stale OAuth polling loop in settings-sync.js (removed 29-line dead polling loop + unused pendingAuth)
116 - - [x] Deduplicate updateReadState/updateStarState into single updateItemField(id, field, value) in items.js
117 - - [x] Add escapeHtml() to timeAgo and score in detail.js and items.js innerHTML
118 - - [x] Fix detail.js state desync (onItemsChanged subscriber merges summary fields into currentItem on external refresh)
119 -
120 - ### Done — Low
121 - - [x] Remove unused pendingAuth (already done) and total variables (feeds.js)
122 - - [x] Replace prompt() with BB.ui.openFormModal() for tag editing in sources.js
123 - - [x] Replace inline styles with 6 CSS classes in settings-sync.js
124 -
125 - ---
126 -
127 - ## Phase 5: Query Feeds & Reader View (Done)
128 -
129 - ### Done
130 - - [x] Filter/query feeds: virtual feeds from keyword/regex rules across all sources (migration 009, QueryFeedsRepository CRUD, FeedFilter conditions with SQL fast-path + in-memory eval, AND logic)
131 - - [x] Query feed builder UI (condition builder modal, field/operator/value rows, match count preview, sidebar integration with "Saved Filters" section)
132 - - [x] Reader view: `extract_article` host function (readable-readability crate), `plugins/reader.rhai`, Tauri command, detail panel button
133 -
134 - ## Phase 6: SyncKit Integration (Done)
135 -
136 - Cloud sync for feed configs, read/star state, and user preferences via MNW SyncKit. Follows GO's proven pattern (changelog triggers, push/pull, E2E encryption, OAuth2 PKCE).
137 -
138 - ### Done
139 - - [x] Migration 007: `user_config` key-value table, `sync_state` table, `sync_changelog` table
140 - - [x] Triggers on `feeds` (INSERT/UPDATE/DELETE), `feed_tags` (INSERT/DELETE), `user_config` (INSERT/UPDATE/DELETE), `feed_items` UPDATE (is_read/is_starred only)
141 - - [x] All triggers conditional on `applying_remote != '1'`
142 - - [x] `synckit-client` dependency added to workspace + src-tauri
143 - - [x] `SyncKitClient` added to AppState, configured via `BB_SYNC_SERVER_URL` / `BB_SYNC_API_KEY` env vars
144 - - [x] `sync_service.rs`: table whitelist, FK ordering (UPSERT_ORDER/DELETE_ORDER), push/pull, initial snapshot, cleanup, partial feed_items sync (user state only)
145 - - [x] `sync_scheduler.rs`: 60s check interval, exponential backoff (2^n min, cap 15), `sync:changes-applied` event
146 - - [x] 8 Tauri commands: sync_status, sync_start_auth, sync_complete_auth, sync_disconnect, sync_now, sync_setup_encryption_new, sync_setup_encryption_existing, sync_update_settings
147 - - [x] OAuth2 PKCE flow with inline callback server
148 - - [x] `settings-sync.js`: 4-state sync settings UI (connect, authenticating, encryption, ready)
149 - - [x] Sync button in sidebar, `BB.sync` namespace, `BB.api.sync` API methods
150 -
151 - ### Done
152 - - [x] Move localStorage values (bb-theme, bb-welcomed) into `user_config` on app startup
153 - - [x] Read/write preferences via `user_config` table instead of localStorage
154 -
155 - ### Design Notes
156 - - `feed_items` triggers fire only on UPDATE of `is_read`/`is_starred` — avoids syncing fetched content (thousands of rows per refresh)
157 - - Feed content re-fetches from source on each device; only user state (read/star) syncs
158 - - `busser_state` (plugin cursors) does NOT sync — pagination position is device-local
159 -
160 - ## Alpha Polish (2026-03-08)
161 - - [x] Feed editing: `get_feed`/`update_feed` commands, `update_name` repository method, edit button + modal in sources sidebar (feeds.rs, repository.rs, sources.js, api.js)
162 -
163 - ## Audit Follow-Up (2026-02-27)
164 -
165 - ### Done — Testing
166 - - [x] `FeedsRepository` tests (update_name, update_config, fetch success/failure health tracking)
167 - - [x] `ItemsRepository` tests (count_by_busser, count_unread_by_busser, counts_by_busser bulk, search with source/unread/starred filters)
168 - - [x] `TagsRepository` tests (all_feed_tags bulk retrieval)
169 - - [x] `ConfigRepository` tests (get/set/delete, overwrite semantics)
170 - - [x] `StateRepository` tests (list ordering)
171 -
172 - ### Done — Testing
173 - - [x] Add unit tests for `generator.rs` in-memory filtering logic (26 tests: ordering, tag filtering, pagination, combined filters, sources)
174 -
175 - ### Done — Testing
176 - - [x] Add integration tests for Tauri commands with in-memory DB (51 tests: item CRUD, mark read/star, pagination with filters, sources, feeds, tags, config, circuit breaker, themes)
177 -
178 - ### Done — Documentation
179 - - [x] README with setup instructions (build, run, plugin authoring basics)
180 - - [x] Plugin authoring guide (Rhai API surface, host functions, capabilities, config schema, fetch_interval_secs, safety limits)
181 -
182 - ## Audit Follow-Up (2026-03-11, fifth audit)
183 -
184 - ### Done — Rhai HTTP Safety
185 - - [x] Add HTTP request count limit to Rhai http_get/http_get_json (100 per fetch, Arc<AtomicUsize> reset before each fetch())
186 - - [x] Add HTTP response size cap to Rhai http_get/http_get_json (2 MB via .take())
187 - - [x] Add per-request timeout to Rhai http_get/http_get_json (15s via ureq .timeout())
188 - - [x] Add URL scheme restriction to http_get (http/https only, block localhost/internal/private ranges, IPv6 loopback)
189 -
190 - ### Done — Resilience
191 - - [x] Add circuit breaker: auto-disable feed after 10 consecutive failures (migration 008, circuit_broken column, Tauri event, reset command, 7 tests)
192 - - [x] Add sync changelog retention cap (10,000 entry cap, enforced every scheduler tick, 2 tests)
193 -
194 - ### Done — Hardening
195 - - [x] Harden FTS query sanitization (strip ^/* inside quotes, handle NEAR/column: via quoting, empty query guard, 16 tests)
196 - - [x] Verify applying_remote flag cleared on startup (crash recovery) — cleared at top of perform_sync()
197 -
198 - ### Done — Observability
199 - - [x] Migrate to structured logging (tracing) — 83 log statements enhanced with structured fields across 14 files
200 -
201 - ## Audit Follow-Up (2026-03-13, sixth audit — pre-launch skeptical lens)
202 -
203 - - [x] Fix `sync_disconnect` — was a no-op, now clears in-memory session via `client.clear_session()` + clears DB sync state/changelog via `clear_all_sync_state()`
204 - - [x] Add `<base>` to DANGEROUS_ELEMENTS in HTML sanitizer (`src-tauri/frontend/js/utils.js`)
205 - - [x] Add URL scheme validation to OPML import path (`src-tauri/src/commands/opml.rs` — rejects non-http(s) URLs with error)
206 - - [x] Test count discrepancy resolved — 520 is correct (audit agent miscounted)
207 - - [x] Harden `escapeAttr()` to HTML-encode all 5 special characters (`&`, `<`, `>`, `"`, `'`)
208 -
209 - ## Deferred (Done)
210 - - [x] Unify pagination strategy (get_all_items now uses MAX_ALL_ITEMS+1 for has_more detection, matching get_items)
211 -
212 - ---
213 -
214 - ## Rust Patterns Audit (2026-03-21)
215 -
216 - - [x] Release RwLock before calling plugin fetch — drop lock before async network I/O (`orchestrator.rs`)
217 - - [x] Avoid cloning item fields in conversion functions — `feed_item_to_summary` takes ownership, uses moves (`commands/items.rs`)
218 - - [x] Avoid cloning JSON config before in-place mutation — serialize before move (`commands/feeds.rs`)
219 - - [x] Avoid cloning plugin config fields when building response — `into_iter()` instead of `iter()` + clone (`commands/plugins.rs`)
220 -
221 - ---
222 -
223 - ## TagTree Integration (2026-03-21)
224 -
225 - - [x] Add `tagtree` workspace dependency
226 - - [x] Add `BB_TAG_CONFIG` (max_depth 3, max_length 80) validation at command boundary in `set_feed_tags()`
227 - - [x] All 544 tests pass
@@ -168,7 +168,6 @@ Clean doc set. README and plugin_authoring.md are good additions from recent aud
168 168 | Document | Status | Last Verified | Notes |
169 169 |----------|:------:|:-------------:|-------|
170 170 | docs/apps/bb/todo.md | Current | 2026-03-11 | Updated with audit 5 action items |
171 - | docs/apps/bb/todo_done.md | Current | 2026-03-04 | Completed items archive |
172 171 | docs/apps/bb/description.md | Placeholder | 2026-03-04 | Intentional placeholder |
173 172 | docs/apps/bb/competition.md | Current | 2026-03-04 | Competitive analysis |
174 173 | docs/apps/bb/structural_metrics.md | New | 2026-03-11 | Structural metrics from audit 5 |
M docs/todo.md +2 -48
@@ -1,58 +1,14 @@
1 1 # Balanced Breakfast TODO
2 2
3 3 ## Status
4 - Done: All pre-beta phases, UX polish (Phase 8). Active: None. Next: Post-beta features.
4 + Done: All pre-beta phases. Active: None. Next: Post-beta features.
5 5
6 - v0.3.0. Audit grade A. 10 bundled plugins. Rhai sandbox hardened.
7 -
8 - **Scope:** Pre-beta coding complete. All remaining sections are post-beta.
9 -
10 - Completed work archived in `docs/archive/bb_todo_done.md`.
11 -
12 - ---
13 -
14 - ## Phase 8: UX Polish
15 -
16 - Addresses non-STRONG grades from the UX audit (`_meta/uxaudit.md`, 2026-04-04).
17 -
18 - ### 8A: Active User — Sources Sidebar Empty State
19 - First-time users see an empty sidebar with no guidance.
20 -
21 - - [x] In `sources.js` `render()`, detect when `sources.length === 0` and show onboarding message
22 - - [x] Message: cooking pot emoji + "Add your first feed to get started" with pointer to Add Feed button
23 - - [x] Remove onboarding when first source is added (reactive via `BB.state.subscribe('sources')`)
24 -
25 - ### 8B: Goal-Gradient — Positive Unread Completion State
26 - When a source reaches 0 unread, show a positive visual cue instead of just removing the count.
27 -
28 - - [x] In `sources.js`, when `source.unreadCount === 0` and `source.totalCount > 0`, render checkmark icon
29 - - [x] CSS: `.source-count.all-read` with checkmark glyph + muted green color
30 - - [x] "All" aggregate: when `totalUnread === 0`, show checkmark on the All row too
31 -
32 - ### 8C: Von Restorff — Stronger Selected-Item Highlight
33 - Currently selected item uses `background-color: var(--bg-tertiary)` which is subtle.
34 -
35 - - [x] Add left border accent to `.item.selected`: `border-left-color: var(--yolk)`
36 - - [x] Ensure selected + unread combination is visually distinct (darker accent background)
37 -
38 - ### 8D: Peak-End Rule — Session Summary
39 - Give users a sense of accomplishment about their reading session.
40 -
41 - - [x] Track session stats in `BB.state`: `sessionArticlesRead`, `sessionArticlesStarred`
42 - - [x] On app close (`beforeunload`), show brief toast: "This session: N read, M starred"
43 - - [x] Reset counters on app launch (initialized to 0 in state defaults)
44 -
45 - ### 8E: Fitts's Law — Wider Star Toggle Target
46 - Star button is icon-only (~18px) with no padding.
47 -
48 - - [x] Expand `.star-btn` padding: `0.25rem 0.4rem` with negative margin to maintain layout
6 + v0.3.0. Audit grade A. 602 tests.
49 7
50 8 ---
51 9
52 10 ## Phase 7: Plugin OAuth (Post-beta)
53 11
54 - Authenticated plugin feeds — users log into services via OAuth to access personalized content.
55 -
56 12 ### 7A: OAuth Infrastructure
57 13 - [ ] New host functions: `oauth_start(provider, scopes)`, `oauth_token(provider)`, `oauth_refresh(provider)`
58 14 - [ ] Tauri deep-link handler for OAuth callback URLs
@@ -69,8 +25,6 @@ Authenticated plugin feeds — users log into services via OAuth to access perso
69 25 - [ ] Dev.to — reading list, followed tags
70 26
71 27 ## Phase 4: Plugin Store (Long-term)
72 -
73 - Community plugin marketplace. Prerequisites: plugin sandboxing, plugin authoring guide.
74 28 - [ ] 4A: Plugin registry backend (manifest format, index, submission flow, CI validation)
75 29 - [ ] 4B: Client-side store UI (browse, search, install, update, uninstall)
76 30 - [ ] 4C: Publishing from the app
@@ -1,187 +1,12 @@
1 1 # Balanced Breakfast - Mobile Port
2 2
3 - Tauri 2 iOS/Android port. CSS-first responsive design with touch gestures. Based on GoingsOn mobile patterns.
4 -
5 - **What stays the same:**
6 - - `crates/bb-core/` — orchestrator, plugin manager, unchanged
7 - - `crates/bb-db/` — SQLite repository, unchanged
8 - - `crates/bb-interface/` — plugin types, unchanged
9 - - `crates/bb-feed/` — aggregation, unchanged
10 - - `src-tauri/src/commands/` — Tauri commands, unchanged
11 - - `src-tauri/frontend/js/` — all JS modules (minor mobile branches)
12 - - `BB.api` with `invoke()` — same IPC mechanism
13 -
14 - **What changes:** CSS responsiveness, new touch/mobile JS modules, bottom tab bar, single-column layout, Tauri mobile config.
15 -
16 - ---
17 -
18 - ## Design: Three-Panel → Single-Column
19 -
20 - **Desktop:** Sidebar (sources) | Items (list) | Detail (article) — all visible simultaneously.
21 -
22 - **Mobile (<=768px):** One panel at a time with drill-down navigation.
23 -
24 - ### Bottom Tab Bar
25 -
26 - | Tab | Default View | Description |
27 - |-----|-------------|-------------|
28 - | Feed | Items list (All) | Current feed items, sorted by selected order |
29 - | Saved | Starred items | Saved/starred articles |
30 - | Sources | Source list | Feed sources with unread counts, tap to filter |
31 - | + | Add Feed | Opens add-feed modal |
32 - | More | Popover | Settings, Sync, OPML, Shortcuts |
33 -
34 - ### Navigation Flow
35 -
36 - ```
37 - Sources tab → tap source → Feed tab (filtered to that source)
38 - Feed tab → tap item → Detail view (slides in full-screen)
39 - Detail view → back button / swipe-right → Items list
40 - ```
41 -
42 - ### Panel Visibility Rules
43 -
44 - | State | Sidebar | Items | Detail | Tab Bar |
45 - |-------|---------|-------|--------|---------|
46 - | Feed tab | hidden | visible | hidden | visible |
47 - | Saved tab | hidden | visible (starred) | hidden | visible |
48 - | Sources tab | visible (full-width) | hidden | hidden | visible |
49 - | Reading article | hidden | hidden | visible | hidden |
3 + Done: Phases 1-5, Phase 6 partial. Active: None. Next: Phase 6 remaining, Phase 7 polish.
50 4
51 5 ---
52 6
53 - ## Phase 1: CSS Foundation
54 -
55 - ### Layout
56 - - [ ] `viewport-fit=cover` meta tag
57 - - [ ] `@media (max-width: 768px)` master breakpoint (replace existing 768px + 600px rules)
58 - - [ ] Hide header (`display: none`) — search/sort/actions move to appropriate locations
59 - - [ ] Body padding: `env(safe-area-inset-top)` top, `calc(52px + env(safe-area-inset-bottom))` bottom
60 - - [ ] Sidebar: `display: none` by default, full-width when `.mobile-sources-visible`
61 - - [ ] Items panel: full-width, single column
62 - - [ ] Detail panel: fixed full-screen overlay when `.mobile-detail-open`
63 - - [ ] Remove all three-panel flex layout on mobile (`.main` → single column)
64 -
65 - ### Components
66 - - [ ] Modal → bottom sheet (full-width, rounded top corners, drag handle)
67 - - [ ] Toasts positioned above tab bar
68 - - [ ] `@media (hover: none)` disable hover effects on touch devices
69 - - [ ] Item cards: increase tap target size, larger star button
70 - - [ ] Source list items: full-width rows, larger tap targets
71 -
72 - ### Mobile Search
73 - - [ ] Search bar at top of Feed/Saved views (replaces header search)
74 - - [ ] Sort dropdown integrated into search bar area or as a pill row
75 -
76 - ---
77 -
78 - ## Phase 2: Bottom Tab Bar
79 -
80 - ### HTML
81 - - [ ] `<nav id="mobile-tab-bar" class="mobile-tab-bar">` before closing `</div>` of `#app`
82 - - [ ] 5 buttons: Feed, Saved, Sources, +, More
83 - - [ ] More popover: Settings, Sync, OPML Import/Export, Shortcuts
84 -
85 - ### CSS
86 - - [ ] Base styles: `display: none`, fixed bottom, z-index 1100, safe-area padding
87 - - [ ] Show at 768px: `display: flex`
88 - - [ ] Active tab highlight (accent color)
89 - - [ ] More popover positioned above tab bar
90 -
91 - ### JS (`navigation.js` — new file)
92 - - [ ] `initMobileTabBar()` — click handlers for each tab
93 - - [ ] Feed tab: show items panel, set source filter to current/All
94 - - [ ] Saved tab: show items panel, filter to starred
95 - - [ ] Sources tab: show sidebar full-width
96 - - [ ] + button: open add-feed modal
97 - - [ ] More button: toggle popover
98 - - [ ] `updateMobileTabBar(view)` — sync active tab state
99 - - [ ] Source tap handler: switch to Feed tab filtered to that source
100 -
101 - ---
102 -
103 - ## Phase 3: Detail View Navigation
104 -
105 - ### Back Button
106 - - [ ] Detail header: add back arrow button (visible only on mobile)
107 - - [ ] Back button returns to items list, closes detail panel
108 - - [ ] Update `BB.detail` to track mobile open/close state
109 -
110 - ### Transitions
111 - - [ ] Detail slides in from right (CSS transform + transition)
112 - - [ ] Items list slides out left (or stays, detail overlays)
113 - - [ ] Tab bar hidden when detail is open (full reading experience)
114 -
115 - ### Reader State
116 - - [ ] `BB.state.mobileDetailOpen` flag
117 - - [ ] On item select (mobile): set flag, show detail full-screen
118 - - [ ] On back: clear flag, return to items
119 -
120 - ---
121 -
122 - ## Phase 4: Touch Module (`js/touch.js` — new file)
123 -
124 - Port from GoingsOn's touch.js, adapted for BB's domain.
125 -
126 - ### Utilities
127 - - [ ] `isTouchDevice` detection
128 - - [ ] `addLongPress(element, callback, duration)` — 500ms hold
129 - - [ ] `addSwipeActions(element, config)` — reveal action behind item
130 - - [ ] `addPullToRefresh(container, callback)` — pull indicator + callback
131 - - [ ] `addDragToDismiss(element, onDismiss)` — swipe down to close
132 - - [ ] All functions return cleanup functions, no-op on non-touch
133 -
134 - ### Mobile Wiring (`js/mobile.js` — new file)
135 - - [ ] Items: swipe right = toggle star, swipe left = toggle read
136 - - [ ] Pull-to-refresh on items list → `BB.feeds.refreshAll()`
137 - - [ ] Pull-to-refresh on sources list → `BB.feeds.refreshAll()`
138 - - [ ] Long-press on item → show action sheet (star, read/unread, open in browser, save)
139 - - [ ] Detail view: swipe right → back to items
140 - - [ ] Modal swipe-to-dismiss wiring
141 -
142 - ---
143 -
144 - ## Phase 5: View Adaptations
145 -
146 - ### Items List (mobile)
147 - - [ ] Item rows: larger padding, bigger star toggle target
148 - - [ ] Unread indicator: bold title + accent left border (like GO task priority borders)
149 - - [ ] Source name badge on items (needed since sidebar isn't visible in Feed tab)
150 - - [ ] Inline sort pills above items (Newest / Score / Unread / Starred)
151 -
152 - ### Sources List (mobile)
153 - - [ ] Full-width card layout with unread counts
154 - - [ ] All/Saved as top sticky rows
155 - - [ ] Tag filter bar: horizontal scroll pills (like GO pill nav)
156 - - [ ] Edit/delete actions via long-press action sheet
157 -
158 - ### Detail View (mobile)
159 - - [ ] Full-screen reader with comfortable margins
160 - - [ ] Sticky header: back button + title + star + open-in-browser
161 - - [ ] Safe area padding top and bottom
162 - - [ ] Article body scrollable with momentum
163 -
164 - ### Keyboard
165 - - [ ] Disable keyboard shortcuts on touch devices
166 -
167 - ---
168 -
169 - ## Phase 6: Tauri Mobile Build Config
170 -
171 - ### Cargo.toml
172 - - [ ] Desktop-only deps gated: `tauri-plugin-shell`, `tauri-plugin-notification`, `tauri-plugin-window-state`
173 - - [ ] `crate-type = ["staticlib", "cdylib", "lib"]` for iOS
7 + ## Phase 6: Tauri Mobile Build Config — Remaining
174 8 - [ ] Platform-conditional `rusqlite` bundled feature for Android
175 -
176 - ### Source Gating
177 - - [ ] `lib.rs`: mobile entry point (`build_mobile_app()` + `#[cfg(mobile)]`)
178 - - [ ] Desktop-only plugin init gated in `main.rs`
179 - - [ ] Desktop-only window commands gated
180 -
181 - ### Capabilities
182 - - [ ] Split: `default.json` (cross-platform) + `desktop.json` (shell, notifications)
183 -
184 - ### Build & Test
9 + - [ ] Split capabilities: `default.json` (cross-platform) + `desktop.json` (shell, notifications)
185 10 - [ ] `cargo tauri ios init` → generates `gen/apple/`
186 11 - [ ] `cargo tauri android init` → generates `gen/android/`
187 12 - [ ] iOS simulator test
@@ -191,7 +16,7 @@ Port from GoingsOn's touch.js, adapted for BB's domain.
191 16 ---
192 17
193 18 ## Phase 7: Polish
194 -
19 + - [ ] Inline sort pills above items
195 20 - [ ] Physical device testing (iOS + Android)
196 21 - [ ] Safe area insets on various device models
197 22 - [ ] VoiceOver / TalkBack accessibility
@@ -207,25 +32,8 @@ Port from GoingsOn's touch.js, adapted for BB's domain.
207 32 | File | Role |
208 33 |------|------|
209 34 | `src-tauri/frontend/css/styles.css` | All responsive CSS |
210 - | `src-tauri/frontend/js/touch.js` | Gesture utilities (new) |
211 - | `src-tauri/frontend/js/mobile.js` | Mobile interaction wiring (new) |
212 - | `src-tauri/frontend/js/navigation.js` | Bottom tab bar + view switching (new) |
213 - | `src-tauri/frontend/js/sources.js` | Source list (mobile adaptations) |
214 - | `src-tauri/frontend/js/items.js` | Item list (mobile adaptations) |
215 - | `src-tauri/frontend/js/detail.js` | Detail panel (mobile full-screen) |
216 - | `src-tauri/frontend/js/components.js` | Action sheets, modal swipe-dismiss |
217 - | `src-tauri/frontend/js/app.js` | Bootstrap, keyboard gating |
218 - | `src-tauri/frontend/index.html` | Tab bar HTML, viewport meta |
35 + | `src-tauri/frontend/js/touch.js` | Gesture utilities |
36 + | `src-tauri/frontend/js/mobile.js` | Mobile interaction wiring |
37 + | `src-tauri/frontend/js/navigation.js` | Bottom tab bar + view switching |
219 38 | `src-tauri/Cargo.toml` | Desktop-only dep gating |
220 39 | `src-tauri/src/main.rs` | Desktop-only plugin/service gating |
221 - | `src-tauri/src/lib.rs` | Desktop-only module gating |
222 -
223 - ---
224 -
225 - ## Reference
226 -
227 - GoingsOn mobile implementation (fully complete):
228 - - `Apps/goingson/docs/todo/todo_mobile.md` — completed phases 1-6
229 - - `Apps/goingson/src-tauri/frontend/js/touch.js` — gesture utilities to port
230 - - `Apps/goingson/src-tauri/frontend/js/mobile.js` — wiring patterns to adapt
231 - - `Apps/goingson/src-tauri/frontend/js/navigation.js` — tab bar reference
@@ -57,6 +57,10 @@ toml.workspace = true
57 57
58 58 # HTTP (download_and_open command)
59 59 ureq = "2.12.1"
60 + open.workspace = true
61 +
62 + # Concurrent fetching
63 + futures.workspace = true
60 64
61 65 # Logging
62 66 tracing.workspace = true
@@ -999,6 +999,9 @@ body {
999 999 border: 0;
1000 1000 }
1001 1001
1002 + /* Source badge on items — visible only on mobile where sidebar is hidden */
1003 + .item-source-badge { display: none; }
1004 +
1002 1005 /* Mobile layout */
1003 1006 @media (max-width: 768px) {
1004 1007 body {
@@ -1087,6 +1090,40 @@ body {
1087 1090 bottom: calc(60px + env(safe-area-inset-bottom));
1088 1091 }
1089 1092
1093 + /* Source badge on items */
1094 + .item-source-badge {
1095 + display: inline-block;
1096 + background-color: var(--bg-tertiary);
1097 + color: var(--text-secondary);
1098 + font-size: 0.65rem;
1099 + font-weight: 600;
1100 + padding: 0.05rem 0.35rem;
1101 + border-radius: var(--radius-sm);
1102 + margin-top: 0.15rem;
1103 + }
1104 +
1105 + /* Bold unread item titles */
1106 + .item.unread .item-text { font-weight: 700; }
1107 +
1108 + /* Sticky detail header */
1109 + .detail-header {
1110 + position: sticky;
1111 + top: 0;
1112 + z-index: 5;
1113 + }
1114 + .detail-panel { -webkit-overflow-scrolling: touch; }
1115 + .item-detail { padding: 1rem 1.25rem; }
1116 + .detail-body { line-height: 1.8; }
1117 +
1118 + /* Horizontal-scroll tag filter bar */
1119 + .tag-filter-bar {
1120 + flex-wrap: nowrap;
1121 + overflow-x: auto;
1122 + -webkit-overflow-scrolling: touch;
1123 + scrollbar-width: none;
1124 + }
1125 + .tag-filter-bar::-webkit-scrollbar { display: none; }
1126 +
1090 1127 /* Help shortcuts single column */
1091 1128 .help-shortcuts { grid-template-columns: 1fr; }
1092 1129 }
@@ -1274,6 +1311,18 @@ body {
1274 1311
1275 1312 .mobile-back-btn { display: none; }
1276 1313
1314 + /* Pull-to-refresh indicator */
1315 + .pull-to-refresh-indicator {
1316 + display: none;
1317 + text-align: center;
1318 + padding: 0.5rem;
1319 + font-size: 0.8rem;
1320 + color: var(--text-muted);
1321 + }
1322 + .pull-to-refresh-indicator.visible {
1323 + display: block;
1324 + }
1325 +
1277 1326 /* Scrollbar */
1278 1327 ::-webkit-scrollbar { width: 6px; height: 6px; }
1279 1328 ::-webkit-scrollbar-track { background: var(--bg-secondary); }
@@ -124,6 +124,8 @@
124 124 <script src="js/shared-updater.js"></script>
125 125 <script src="js/updater.js"></script>
126 126 <script src="js/navigation.js"></script>
127 + <script src="js/touch.js"></script>
128 + <script src="js/mobile.js"></script>
127 129 <script src="js/app.js"></script>
128 130 </body>
129 131 </html>
@@ -110,6 +110,8 @@
110 110 * Google Reader, /=search from vim, ?=help from many CLI tools.
111 111 */
112 112 function handleKeyboard(e) {
113 + if (BB.state.isTouchDevice) return;
114 +
113 115 // Don't handle when typing in inputs
114 116 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
115 117 if (e.key === 'Escape') {
@@ -284,7 +284,7 @@
284 284 const author = currentItem.author ? `<p>By ${escapeHtml(currentItem.author)}</p>` : '';
285 285 const source = currentItem.sourceName ? `<p>Source: ${escapeHtml(currentItem.sourceName)}</p>` : '';
286 286 const link = currentItem.url ? `<p><a href="${escapeHtml(currentItem.url)}">Original article</a></p>` : '';
287 - const body = currentItem.body || escapeHtml(currentItem.text || '');
287 + const body = currentItem.body ? sanitizeHtml(currentItem.body) : escapeHtml(currentItem.text || '');
288 288
289 289 // Read current theme colors for the exported HTML
290 290 const style = getComputedStyle(document.documentElement);
@@ -83,8 +83,8 @@
83 83 if (p === lastPage) BB.state.set('hasMore', data.hasMore);
84 84 }
85 85 BB.state.set('items', allItems);
86 - } catch (_) {
87 - // Silent failure — existing data remains displayed
86 + } catch (err) {
87 + console.warn('Reload failed:', err);
88 88 }
89 89 }
90 90
@@ -137,6 +137,7 @@
137 137 <span class="item-author">${escapeHtml(item.author)}</span>
138 138 <span class="item-time">${escapeHtml(item.timeAgo)}</span>
139 139 </div>
140 + <span class="item-source-badge">${escapeHtml(item.sourceName)}</span>
140 141 <div class="item-text">${escapeHtml(item.title || item.text)}</div>
141 142 ${item.secondary ? `<div class="item-secondary">${escapeHtml(item.secondary)}</div>` : ''}
142 143 </div>
@@ -235,7 +236,7 @@
235 236 /** Increment the page counter and fetch the next page (appended to list). */
236 237 function loadMore() {
237 238 BB.state.set('currentPage', BB.state.currentPage + 1);
238 - load(true);
239 + load(true).catch(err => console.warn('Load more failed:', err));
239 240 }
240 241
241 242 /**
@@ -0,0 +1,321 @@
1 + /**
2 + * Balanced Breakfast - Mobile Interaction Wiring
3 + * Connects touch.js gesture utilities to list views using event delegation.
4 + * Handles swipe-to-action, pull-to-refresh, and long-press selection.
5 + * All no-ops on non-touch devices.
6 + */
7 +
8 + (function() {
9 + 'use strict';
10 +
11 + if (!BB.touch?.isTouchDevice) return;
12 +
13 + // ============ Swipe Delegation ============
14 +
15 + /**
16 + * Add delegated swipe handling to a container. Tracks the swiped row
17 + * internally so DOM recycling doesn't cause issues.
18 + *
19 + * @param {HTMLElement} container - Scrollable container (e.g. #items-list)
20 + * @param {Object} config
21 + * @param {string} config.rowSelector - CSS selector for swipeable rows
22 + * @param {Function} config.getActions - (rowEl) => { left?: { action }, right?: { action } } | null
23 + * @param {number} [config.threshold=80] - Pixels to trigger action
24 + */
25 + function addSwipeDelegate(container, config) {
26 + const threshold = config.threshold || 80;
27 + let activeRow = null;
28 + let startX = 0;
29 + let startY = 0;
30 + let currentX = 0;
31 + let isDragging = false;
32 + let isHorizontal = null;
33 + let actions = null;
34 +
35 + container.addEventListener('touchstart', function(e) {
36 + const row = e.target.closest(config.rowSelector);
37 + if (!row) return;
38 +
39 + actions = config.getActions(row);
40 + if (!actions) return;
41 +
42 + activeRow = row;
43 + const touch = e.touches[0];
44 + startX = touch.clientX;
45 + startY = touch.clientY;
46 + currentX = 0;
47 + isDragging = true;
48 + isHorizontal = null;
49 + activeRow.style.transition = 'none';
50 + }, { passive: true });
51 +
52 + container.addEventListener('touchmove', function(e) {
53 + if (!isDragging || !activeRow) return;
54 +
55 + const touch = e.touches[0];
56 + const dx = touch.clientX - startX;
57 + const dy = touch.clientY - startY;
58 +
59 + // Determine direction on first significant move
60 + if (isHorizontal === null) {
61 + if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
62 + isHorizontal = Math.abs(dx) > Math.abs(dy);
63 + }
64 + if (!isHorizontal) return;
65 + }
66 + if (!isHorizontal) return;
67 +
68 + e.preventDefault();
69 + currentX = dx;
70 +
71 + // Clamp to allowed directions
72 + if (dx < 0 && !actions.left) currentX = 0;
73 + if (dx > 0 && !actions.right) currentX = 0;
74 +
75 + // Rubber-band past threshold
76 + const maxSwipe = threshold * 1.5;
77 + if (Math.abs(currentX) > threshold) {
78 + const overshoot = Math.abs(currentX) - threshold;
79 + const dampened = threshold + overshoot * 0.3;
80 + currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe);
81 + }
82 +
83 + activeRow.style.transform = `translateX(${currentX}px)`;
84 + }, { passive: false });
85 +
86 + function onEnd() {
87 + if (!isDragging || !activeRow) return;
88 + isDragging = false;
89 +
90 + activeRow.style.transition = 'transform 0.2s ease';
91 +
92 + if (Math.abs(currentX) >= threshold) {
93 + if (currentX < 0 && actions?.left?.action) {
94 + actions.left.action();
95 + } else if (currentX > 0 && actions?.right?.action) {
96 + actions.right.action();
97 + }
98 + }
99 +
100 + activeRow.style.transform = 'translateX(0)';
101 + activeRow = null;
102 + actions = null;
103 + currentX = 0;
104 + isHorizontal = null;
105 + }
106 +
107 + container.addEventListener('touchend', onEnd, { passive: true });
108 + container.addEventListener('touchcancel', onEnd, { passive: true });
109 + }
110 +
111 + // ============ Long-Press Delegation ============
112 +
113 + /**
114 + * Add delegated long-press handling to a container.
115 + * @param {HTMLElement} container
116 + * @param {string} rowSelector - CSS selector for pressable rows
117 + * @param {Function} onLongPress - (rowEl) => void
118 + */
119 + function addLongPressDelegate(container, rowSelector, onLongPress) {
120 + let timer = null;
121 + let startX = 0;
122 + let startY = 0;
123 + let activeRow = null;
124 + const MOVE_THRESHOLD = 10;
125 + const DURATION = 500;
126 +
127 + container.addEventListener('touchstart', function(e) {
128 + const row = e.target.closest(rowSelector);
129 + if (!row) return;
130 + activeRow = row;
131 +
132 + const touch = e.touches[0];
133 + startX = touch.clientX;
134 + startY = touch.clientY;
135 +
136 + timer = setTimeout(() => {
137 + timer = null;
138 + // Prevent subsequent click
139 + activeRow.addEventListener('click', function prevent(ev) {
140 + ev.preventDefault();
141 + ev.stopPropagation();
142 + }, { once: true, capture: true });
143 + onLongPress(activeRow);
144 + }, DURATION);
145 + }, { passive: true });
146 +
147 + container.addEventListener('touchmove', function(e) {
148 + if (!timer) return;
149 + const touch = e.touches[0];
150 + if (Math.abs(touch.clientX - startX) > MOVE_THRESHOLD ||
151 + Math.abs(touch.clientY - startY) > MOVE_THRESHOLD) {
152 + clearTimeout(timer);
153 + timer = null;
154 + }
155 + }, { passive: true });
156 +
157 + function cancel() {
158 + if (timer) {
159 + clearTimeout(timer);
160 + timer = null;
161 + }
162 + activeRow = null;
163 + }
164 +
165 + container.addEventListener('touchend', cancel, { passive: true });
166 + container.addEventListener('touchcancel', cancel, { passive: true });
167 + }
168 +
169 + // ============ Wire Everything on Init ============
170 +
171 + function init() {
172 + wireItemSwipe();
173 + wirePullToRefresh();
174 + wireLongPress();
175 + wireDetailSwipe();
176 + wireModalDismiss();
177 + wireSourceLongPress();
178 + }
179 +
180 + // --- Items: swipe right = star, swipe left = toggle read ---
181 +
182 + function wireItemSwipe() {
183 + const container = document.getElementById('items-list');
184 + if (!container) return;
185 +
186 + addSwipeDelegate(container, {
187 + rowSelector: '.item',
188 + getActions: (row) => {
189 + const id = row.dataset.id;
190 + if (!id) return null;
191 + const item = BB.state.items.find(i => i.id === id);
192 + if (!item) return null;
193 + return {
194 + right: { action: () => BB.items.toggleStar(id, item.isStarred) },
195 + left: { action: () => BB.items.toggleRead(id, item.isRead) },
196 + };
197 + },
198 + });
199 + }
200 +
201 + // --- Pull-to-refresh on items list and sources list ---
202 +
203 + function wirePullToRefresh() {
204 + const itemsList = document.getElementById('items-list');
205 + const sourcesList = document.getElementById('sources-list');
206 + if (itemsList) BB.touch.addPullToRefresh(itemsList, () => BB.feeds.refresh());
207 + if (sourcesList) BB.touch.addPullToRefresh(sourcesList, () => BB.feeds.refresh());
208 + }
209 +
210 + // --- Long-press on item → action sheet ---
211 +
212 + function wireLongPress() {
213 + const container = document.getElementById('items-list');
214 + if (!container) return;
215 +
216 + addLongPressDelegate(container, '.item', (row) => {
217 + const id = row.dataset.id;
218 + const item = BB.state.items.find(i => i.id === id);
219 + if (!item) return;
220 + showItemActionSheet(item);
221 + });
222 + }
223 +
224 + // --- Detail view: swipe right → back to items ---
225 +
226 + function wireDetailSwipe() {
227 + const detail = document.getElementById('detail-panel');
228 + if (!detail) return;
229 +
230 + BB.touch.addSwipeNavigation(detail, {
231 + onRight: () => BB.detail.close(),
232 + });
233 + }
234 +
235 + // --- Modal: swipe-down-to-dismiss ---
236 +
237 + function wireModalDismiss() {
238 + const modal = document.querySelector('.modal-content');
239 + if (!modal) return;
240 +
241 + BB.touch.addDragToDismiss(modal, () => BB.ui.closeModal());
242 + }
243 +
244 + // --- Sources: long-press → action sheet ---
245 +
246 + function wireSourceLongPress() {
247 + const container = document.getElementById('sources-list');
248 + if (!container) return;
249 + addLongPressDelegate(container, '.source-item', (row) => {
250 + const sourceId = row.dataset.source;
251 + if (!sourceId) return; // Skip "All" row
252 + const source = BB.state.sources.find(s => s.id === sourceId);
253 + if (!source) return;
254 + showSourceActionSheet(source);
255 + });
256 + }
257 +
258 + function showSourceActionSheet(source) {
259 + const body = document.getElementById('modal-body');
260 + const title = document.getElementById('modal-title');
261 + title.textContent = source.name;
262 + body.innerHTML = '';
263 + const actions = [
264 + { label: 'Edit Feed', action: () => BB.sources.editFeed(source) },
265 + { label: 'Edit Tags', action: () => BB.sources.editTags(source) },
266 + { label: 'Delete', action: () => BB.sources.deleteFeed(source), danger: true },
267 + ];
268 + for (const a of actions) {
269 + const btn = document.createElement('button');
270 + btn.className = 'btn';
271 + btn.style.display = 'block';
272 + btn.style.width = '100%';
273 + btn.style.marginBottom = '0.5rem';
274 + btn.textContent = a.label;
275 + if (a.danger) btn.style.color = 'var(--accent-red)';
276 + btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); });
277 + body.appendChild(btn);
278 + }
279 + BB.ui.openModal();
280 + }
281 +
282 + // --- Action sheet for long-press on item ---
283 +
284 + function showItemActionSheet(item) {
285 + const body = document.getElementById('modal-body');
286 + const title = document.getElementById('modal-title');
287 + title.textContent = 'Actions';
288 + body.innerHTML = '';
289 +
290 + const actions = [
291 + { label: item.isStarred ? 'Unstar' : 'Star', action: () => BB.items.toggleStar(item.id, item.isStarred) },
292 + { label: item.isRead ? 'Mark Unread' : 'Mark Read', action: () => BB.items.toggleRead(item.id, item.isRead) },
293 + ];
294 + if (item.url) {
295 + actions.push({ label: 'Open in Browser', action: () => BB.detail.openUrl() });
296 + }
297 +
298 + for (const a of actions) {
299 + const btn = document.createElement('button');
300 + btn.className = 'btn';
301 + btn.style.display = 'block';
302 + btn.style.width = '100%';
303 + btn.style.marginBottom = '0.5rem';
304 + btn.textContent = a.label;
305 + btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); });
306 + body.appendChild(btn);
307 + }
308 +
309 + BB.ui.openModal();
310 + }
311 +
312 + // ============ Populate Namespace ============
313 +
314 + BB.mobile = { init };
315 +
316 + if (document.readyState === 'loading') {
317 + document.addEventListener('DOMContentLoaded', init);
318 + } else {
319 + setTimeout(init, 0);
320 + }
321 + })();
@@ -179,7 +179,6 @@
179 179 BB.ui.showToast('Query feed created');
180 180 }
181 181 document.getElementById('modal-overlay').style.display = 'none';
182 - load();
183 182 BB.sources.load();
184 183 } catch (err) {
185 184 BB.ui.showToast('Failed to save: ' + BB.utils.getErrorMessage(err), 'error');
@@ -217,7 +216,6 @@
217 216 if (BB.state.currentQueryFeed === feed.id) {
218 217 BB.state.set('currentQueryFeed', null);
219 218 }
220 - load();
221 219 BB.sources.load();
222 220 } catch (err) {
223 221 BB.ui.showToast('Failed to delete: ' + BB.utils.getErrorMessage(err), 'error');