From 860d814ae6255b8ccbd0a555bd3c23cf827b503d Mon Sep 17 00:00:00 2001 From: DeFiDude <59237470+DeFiDude@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:42:50 -0600 Subject: [PATCH] ui: refine text scaling and activity privacy --- .../ratspeak-tauri/src/commands/interfaces.rs | 97 +++++++++--- .../ratspeak-tauri/tests/source_contracts.rs | 59 ++++++++ dashboard/index.html | 77 ++++++++-- dashboard/scripts/test_activity_bootstrap.js | 91 ++++++++++++ dashboard/scripts/test_css_contract.js | 7 +- .../scripts/test_frontend_foundations.js | 17 ++- dashboard/static/css/10-views.css | 118 ++++++++++----- dashboard/static/css/13-responsive.css | 33 ++--- dashboard/static/js/activity.js | 90 ++++++++++-- dashboard/static/js/settings.js | 139 ++++++++++++++---- dashboard/static/js/text_scale.js | 7 +- .../android/app/src/main/AndroidManifest.xml | 4 +- src-tauri/gen/apple/project.yml | 4 - src-tauri/gen/apple/ratspeak_iOS/Info.plist | 4 - src-tauri/src/lib.rs | 2 + 15 files changed, 597 insertions(+), 152 deletions(-) diff --git a/crates/ratspeak-tauri/src/commands/interfaces.rs b/crates/ratspeak-tauri/src/commands/interfaces.rs index 8f0a7fb..b8959b3 100644 --- a/crates/ratspeak-tauri/src/commands/interfaces.rs +++ b/crates/ratspeak-tauri/src/commands/interfaces.rs @@ -953,26 +953,39 @@ pub async fn set_auto_announce(state: State<'_, Arc>, interval: u64) - #[tauri::command] pub async fn api_app_settings(state: State<'_, Arc>) -> AppResult { - let (hw_timeout, developer_mode, window_decorations, channel_hosting_enabled) = - db::spawn_db(state.db.clone(), |p| { - let hw_timeout = db::get_setting(&p, "hardware_session_timeout") - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - let developer_mode = - db::get_setting(&p, "developer_mode_enabled").is_some_and(|v| v == "true"); - let window_decorations = - db::get_setting(&p, "window_decorations").unwrap_or_else(|| "auto".to_string()); - let channel_hosting_enabled = - ratspeak_runtime::channel_hub::channel_hosting_enabled(&p); - ( - hw_timeout, - developer_mode, - window_decorations, - channel_hosting_enabled, - ) - }) - .await - .unwrap_or((0, false, "auto".to_string(), false)); + let ( + hw_timeout, + developer_mode, + window_decorations, + channel_hosting_enabled, + activity_identity_protection, + text_scale_percent, + ) = db::spawn_db(state.db.clone(), |p| { + let hw_timeout = db::get_setting(&p, "hardware_session_timeout") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let developer_mode = + db::get_setting(&p, "developer_mode_enabled").is_some_and(|v| v == "true"); + let window_decorations = + db::get_setting(&p, "window_decorations").unwrap_or_else(|| "auto".to_string()); + let channel_hosting_enabled = ratspeak_runtime::channel_hub::channel_hosting_enabled(&p); + let activity_identity_protection = db::get_setting(&p, "activity_identity_protection") + .is_none_or(|value| value != "false"); + let text_scale_percent = db::get_setting(&p, "text_scale_percent") + .and_then(|value| value.parse::().ok()) + .map(|value| (value.clamp(100, 140) + 5) / 10 * 10) + .unwrap_or(100); + ( + hw_timeout, + developer_mode, + window_decorations, + channel_hosting_enabled, + activity_identity_protection, + text_scale_percent, + ) + }) + .await + .unwrap_or((0, false, "auto".to_string(), false, true, 100)); Ok(json!({ "auto_announce_interval": *state.announce_interval_rx.borrow(), "announce_ratspeak_usage": state.announce_ratspeak_usage_enabled(), @@ -981,9 +994,53 @@ pub async fn api_app_settings(state: State<'_, Arc>) -> AppResult>, percent: u16) -> AppResult { + let percent = (percent.clamp(100, 140) + 5) / 10 * 10; + db::spawn_db(state.db.clone(), move |p| { + db::try_set_setting(&p, "text_scale_percent", &percent.to_string()) + }) + .await + .map_err(|_| AppError::internal("set_text_scale db task panicked"))? + .map_err(|error| { + AppError::database_unavailable(format!("Failed to save text size: {error}")) + })?; + state.emit_to_all( + "app_settings_updated", + json!({ "text_scale_percent": percent }), + ); + Ok(json!({ "percent": percent })) +} + +#[tauri::command] +pub async fn set_activity_identity_protection( + state: State<'_, Arc>, + enabled: bool, +) -> AppResult { + db::spawn_db(state.db.clone(), move |p| { + db::try_set_setting( + &p, + "activity_identity_protection", + if enabled { "true" } else { "false" }, + ) + }) + .await + .map_err(|_| AppError::internal("set_activity_identity_protection db task panicked"))? + .map_err(|error| { + AppError::database_unavailable(format!("Failed to save Activity privacy setting: {error}")) + })?; + state.emit_to_all( + "app_settings_updated", + json!({ "activity_identity_protection": enabled }), + ); + Ok(json!({ "enabled": enabled })) +} + /// Developer mode lives in SQLite, not WebView localStorage: WKWebView does /// not reliably persist localStorage for custom-scheme origins (macOS/iOS). #[tauri::command] diff --git a/crates/ratspeak-tauri/tests/source_contracts.rs b/crates/ratspeak-tauri/tests/source_contracts.rs index d21df5e..d7fe303 100644 --- a/crates/ratspeak-tauri/tests/source_contracts.rs +++ b/crates/ratspeak-tauri/tests/source_contracts.rs @@ -1148,6 +1148,65 @@ fn privacy_announce_usage_setting_is_wired() { assert!(!reset_body.contains("\"settings\"")); } +#[test] +fn activity_identity_protection_is_default_on_durable_and_event_scoped() { + let root = repo_root(); + let index = read_source(root.join("dashboard/index.html")).expect("dashboard index"); + let settings = read_source(root.join("dashboard/static/js/settings.js")).expect("settings js"); + let activity = read_source(root.join("dashboard/static/js/activity.js")).expect("activity js"); + let interfaces = read_source(root.join("crates/ratspeak-tauri/src/commands/interfaces.rs")) + .expect("interfaces commands"); + let tauri_lib = read_source(root.join("src-tauri/src/lib.rs")).expect("tauri lib"); + + assert!(index.contains("Protect Activity identities")); + assert!(index.contains("id=\"settings-activity-identity-protection-on\" value=\"on\" checked")); + assert!(settings.contains("set_activity_identity_protection")); + assert!(settings.contains("adoptActivityIdentityProtectionFromBackend")); + assert!(activity.contains("function activityRevealEvent(event)")); + assert!(activity.contains("activityIdentityProtectionEnabled = true")); + assert!(!activity.contains("activityRevealField(event, 'destination');")); + assert!(interfaces.contains("pub async fn set_activity_identity_protection")); + assert!(interfaces.contains("\"activity_identity_protection\"")); + assert!(interfaces.contains(".unwrap_or((0, false, \"auto\".to_string(), false, true, 100))")); + assert!(tauri_lib.contains("set_activity_identity_protection")); +} + +#[test] +fn text_scale_presets_are_durable_and_backend_validated() { + let root = repo_root(); + let settings = read_source(root.join("dashboard/static/js/settings.js")).expect("settings js"); + let scale = read_source(root.join("dashboard/static/js/text_scale.js")).expect("scale js"); + let interfaces = read_source(root.join("crates/ratspeak-tauri/src/commands/interfaces.rs")) + .expect("interfaces commands"); + let tauri_lib = read_source(root.join("src-tauri/src/lib.rs")).expect("tauri lib"); + + assert!(settings.contains("RS.invoke('set_text_scale'")); + assert!(settings.contains("data.text_scale_percent")); + assert!(scale.contains("var MAX = 140")); + assert!(interfaces.contains("pub async fn set_text_scale")); + assert!(interfaces.contains("\"text_scale_percent\"")); + assert!(interfaces.contains("(percent.clamp(100, 140) + 5) / 10 * 10")); + assert!(tauri_lib.contains("set_text_scale")); +} + +#[test] +fn mobile_shells_advertise_only_portrait_orientations() { + let root = repo_root(); + let manifest = read_source(root.join("src-tauri/gen/android/app/src/main/AndroidManifest.xml")) + .expect("android manifest"); + let ios_info = read_source(root.join("src-tauri/gen/apple/ratspeak_iOS/Info.plist")) + .expect("iOS Info.plist"); + let ios_project = + read_source(root.join("src-tauri/gen/apple/project.yml")).expect("iOS project source"); + + assert!(manifest.contains("android:screenOrientation=\"sensorPortrait\"")); + assert!(manifest.contains("tools:ignore=\"DiscouragedApi,LockedOrientationActivity\"")); + assert!(ios_info.contains("UIInterfaceOrientationPortrait")); + assert!(!ios_info.contains("UIInterfaceOrientationLandscape")); + assert!(ios_project.contains("UISupportedInterfaceOrientations:")); + assert!(!ios_project.contains("UIInterfaceOrientationLandscape")); +} + #[test] fn ble_rnode_runtime_spawns_enable_flow_control() { let root = repo_root(); diff --git a/dashboard/index.html b/dashboard/index.html index 52814d8..2d16f89 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -1205,14 +1205,47 @@
- - Scale text without changing controls or touch targets -
-
- - 100% - + Text size + Choose a comfortable reading size without enlarging controls or touch targets.
+
+ Text size + + + + + +
@@ -1255,11 +1288,11 @@
@@ -1313,6 +1346,22 @@
Privacy
+
+
+ Protect Activity identities + Hide peer names and addresses until you reveal an Activity event. +
+
+ + +
+
Announce Ratspeak usage @@ -1375,11 +1424,11 @@
@@ -1391,15 +1440,15 @@
diff --git a/dashboard/scripts/test_activity_bootstrap.js b/dashboard/scripts/test_activity_bootstrap.js index f600957..4a57f1e 100644 --- a/dashboard/scripts/test_activity_bootstrap.js +++ b/dashboard/scripts/test_activity_bootstrap.js @@ -1150,6 +1150,7 @@ test('identifier controls reveal first and copy only after disclosure', async fu assert(hiddenHtml.indexOf('Reveal') !== -1); assert(hiddenHtml.indexOf('data-action="reveal"') !== -1); assert(hiddenHtml.indexOf('data-icon="reveal"') !== -1); + assert(hiddenHtml.indexOf('Alice') === -1, 'peer names stay private before explicit reveal'); var copied = null; ctx.RS.copyText = function(value) { @@ -1176,6 +1177,96 @@ test('identifier controls reveal first and copy only after disclosure', async fu assert.strictEqual(revealCalls, 1, 'copy should reuse the prior explicit reveal'); }); +test('one Activity disclosure reveals every identifier in only that event', async function() { + var ui = loadUiHarness(); + var ctx = ui.context; + var destination = '00112233445566778899aabbccddeeff'; + var identity = 'ffeeddccbbaa99887766554433221100'; + var peerEvent = event(SESSION_A, '10', 'rns.link.identified'); + peerEvent.area = 'links'; + peerEvent.kind = 'rns.link.identified'; + peerEvent.summary_code = 'rns.link.identified'; + peerEvent.attributes = [{ + key: 'destination', + value: { + type: 'identifier', + value: { kind: 'destination', pseudonym: 'masked-destination', ordinal: 1 } + } + }, { + key: 'identity', + value: { + type: 'identifier', + value: { kind: 'peer', pseudonym: 'masked-identity', ordinal: 1 } + } + }]; + ctx.activityEvents = [peerEvent]; + ctx.activityExpandedSequence = '10'; + var fields = []; + ui.setInvoke(function(name, args) { + assert.strictEqual(name, 'activity_reveal'); + fields.push(args.args.field); + var value = args.args.field === 'destination' ? destination : identity; + return Promise.resolve({ + result: 'identifier', + key: args.args.field, + kind: args.args.field, + value: value + }); + }); + + ctx.renderActivityFeed(); + assert.strictEqual((ui.ids['activity-feed'].innerHTML.match(/data-action="reveal"/g) || []).length, 2); + assert.strictEqual(await ctx.activateActivityIdentifier('10', 'destination'), true); + await flush(); + ctx.renderActivityFeed(); + assert.deepStrictEqual(fields.sort(), ['destination', 'identity']); + assert.strictEqual((ui.ids['activity-feed'].innerHTML.match(/data-action="copy"/g) || []).length, 2); + assert(ui.ids['activity-feed'].innerHTML.indexOf('001122334…eeff') !== -1); + assert(ui.ids['activity-feed'].innerHTML.indexOf('ffeeddccb…1100') !== -1); + + ctx.setActivityIdentityProtectionEnabled(false); + ctx.setActivityIdentityProtectionEnabled(true); + ctx.renderActivityFeed(); + assert.strictEqual((ui.ids['activity-feed'].innerHTML.match(/data-action="reveal"/g) || []).length, 2, + 'turning protection on clears every transient disclosure'); +}); + +test('disabling Activity identity protection removes reveal friction without changing capture data', async function() { + var ui = loadUiHarness(); + var ctx = ui.context; + var raw = '1234567890abcdef1234567890abcdef'; + var peerEvent = event(SESSION_A, '11', 'lxmf.delivery.queued'); + peerEvent.area = 'messages'; + peerEvent.kind = 'lxmf.delivery.queued'; + peerEvent.summary_code = 'lxmf.delivery.queued'; + peerEvent.attributes = [{ + key: 'destination', + value: { + type: 'identifier', + value: { kind: 'destination', pseudonym: 'masked-lxmf-address', ordinal: 3 } + } + }]; + ctx.activityEvents = [peerEvent]; + ctx.activityExpandedSequence = '11'; + ui.setInvoke(function(name, args) { + assert.strictEqual(name, 'activity_reveal'); + return Promise.resolve({ + result: 'identifier', + key: args.args.field, + kind: 'destination', + value: raw + }); + }); + + ctx.setActivityIdentityProtectionEnabled(false); + await flush(); + ctx.renderActivityFeed(); + assert(ui.ids['activity-feed'].innerHTML.indexOf('data-action="copy"') !== -1); + assert(ui.ids['activity-feed'].innerHTML.indexOf('data-action="reveal"') === -1); + assert(ui.ids['activity-feed'].innerHTML.indexOf(raw) === -1, + 'even unprotected mode keeps full addresses out of the DOM'); +}); + test('identity UI reset clears canonical visible state without issuing a Stop command', async function() { var ui = loadUiHarness(); var ctx = ui.context; diff --git a/dashboard/scripts/test_css_contract.js b/dashboard/scripts/test_css_contract.js index accee4b..1df62fe 100644 --- a/dashboard/scripts/test_css_contract.js +++ b/dashboard/scripts/test_css_contract.js @@ -98,7 +98,10 @@ assert(settingsFieldRule && /min-height:\s*34px/.test(settingsFieldRule[1]) && / 'settings fields must grow with scaled text'); assert(/data-text-scale-tier="xlarge"[\s\S]*?\.channels-layout/.test(sources[14].text), 'the largest text tier must simplify the Channels layout'); -assert(/data-text-scale-tier="xlarge"[\s\S]*?\.settings-text-scale-input/.test(sources[14].text), - 'the largest mobile text tier must give the slider its own row'); +assert(/\.settings-type-presets\s*\{[\s\S]*?repeat\(5,/.test(sources[11].text), + 'text sizing must expose five deliberate presets instead of a continuous slider'); +assert(/data-scale="100"[\s\S]*?--type-preview-size:\s*18px/.test(sources[11].text) && + /data-scale="140"[\s\S]*?--type-preview-size:\s*34px/.test(sources[11].text), + 'text-size specimens must make the preset progression visually distinct'); console.log('CSS contract tests passed'); diff --git a/dashboard/scripts/test_frontend_foundations.js b/dashboard/scripts/test_frontend_foundations.js index cae8adf..bba85be 100644 --- a/dashboard/scripts/test_frontend_foundations.js +++ b/dashboard/scripts/test_frontend_foundations.js @@ -67,11 +67,11 @@ scaleContext.window.localStorage = scaleContext.localStorage; vm.createContext(scaleContext); vm.runInContext(read('static/js/text_scale.js'), scaleContext); assert.strictEqual(scaleContext.window.RS.textScale.get(), 100); -assert.strictEqual(scaleContext.window.RS.textScale.commit(147), 150); -assert.strictEqual(root.style.fontSize, '150%'); +assert.strictEqual(scaleContext.window.RS.textScale.commit(127), 130); +assert.strictEqual(root.style.fontSize, '130%'); assert.strictEqual(root.dataset.textScaleTier, 'large'); -assert.strictEqual(storage['rs-text-scale-percent'], '150'); -assert.strictEqual(scaleContext.window.RS.textScale.commit(200), 200); +assert.strictEqual(storage['rs-text-scale-percent'], '130'); +assert.strictEqual(scaleContext.window.RS.textScale.commit(200), 140); assert.strictEqual(root.dataset.textScaleTier, 'xlarge'); assert.strictEqual(scaleContext.window.RS.textScale.reset(), 100); assert.strictEqual(storage['rs-text-scale-percent'], undefined); @@ -95,10 +95,13 @@ var labelTargets = Array.from(html.matchAll(/]*\sfor="([^"]+)"/g), fu assert.deepStrictEqual(Array.from(new Set(labelTargets.filter(function(id) { return ids.indexOf(id) === -1; }))), [], 'label for attributes must resolve to dashboard controls'); -assert(html.includes('id="settings-text-scale"')); -assert(html.includes('min="100" max="200" step="10"')); +assert.strictEqual((html.match(/name="settings-text-scale"/g) || []).length, 5); +['100', '110', '120', '130', '140'].forEach(function(value) { + assert(html.includes('name="settings-text-scale" value="' + value + '"')); +}); +assert(html.includes('class="settings-type-presets"')); +assert(html.includes('aria-labelledby="settings-text-scale-label"')); assert(html.includes('aria-describedby="settings-text-scale-desc"')); -assert(!html.includes('aria-describedby="settings-text-scale-desc settings-text-scale-value"')); assert(!html.includes('no_pinch.js'), 'browser zoom must remain available'); assert(html.includes('lxmf-compose message-composer')); assert(html.includes('channel-compose message-composer')); diff --git a/dashboard/static/css/10-views.css b/dashboard/static/css/10-views.css index f76b9df..13af013 100644 --- a/dashboard/static/css/10-views.css +++ b/dashboard/static/css/10-views.css @@ -1817,6 +1817,13 @@ padding: var(--space-1) var(--space-1) var(--space-3); border-bottom: 1px solid var(--border-row); } +.settings-detail-header > div, +.settings-row-info { + min-width: 0; +} +.settings-detail-header > div { + flex: 1 1 auto; +} .settings-detail-eyebrow { display: block; color: var(--text-secondary); @@ -1832,12 +1839,14 @@ line-height: 1.15; } .settings-detail-header p { + flex: 0 1 360px; max-width: 360px; margin: 0; color: var(--text-secondary); font-size: var(--text-base); line-height: var(--type-leading-body); text-align: right; + overflow-wrap: anywhere; } .settings-page-inner { max-width: none; @@ -1871,57 +1880,84 @@ flex-shrink: 0; } -.settings-text-scale-controls { - display: grid; - grid-template-columns: minmax(140px, 210px) 3.5rem auto; - align-items: center; - justify-content: end; - gap: var(--space-4); +.settings-text-scale-row { + align-items: stretch; + flex-direction: column; + gap: var(--space-5); +} +.settings-text-scale-row .settings-row-info { + max-width: none; +} +.settings-type-presets { + width: min(100%, 680px); min-width: 0; -} -.settings-text-scale-input { - width: 100%; - height: 44px; margin: 0; - accent-color: var(--accent); + padding: 0; + border: 0; + display: grid; + grid-template-columns: repeat(5, minmax(64px, 112px)); + gap: var(--space-4); +} +.settings-type-preset { + position: relative; + min-width: 0; cursor: pointer; + -webkit-tap-highlight-color: transparent; } -.settings-text-scale-input:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 3px; - border-radius: var(--radius-sm); +/* The specimen is a choice cue, not a literal rendering sample. Use a wider + progression than the applied percentages so adjacent presets scan clearly. */ +.settings-type-preset[data-scale="100"] { --type-preview-size: 18px; } +.settings-type-preset[data-scale="110"] { --type-preview-size: 22px; } +.settings-type-preset[data-scale="120"] { --type-preview-size: 26px; } +.settings-type-preset[data-scale="130"] { --type-preview-size: 30px; } +.settings-type-preset[data-scale="140"] { --type-preview-size: 34px; } +.settings-type-preset input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; } -.settings-text-scale-value { - color: var(--text-primary); - font-family: var(--font-mono); - font-size: var(--text-xs); - font-variant-numeric: tabular-nums; - text-align: right; -} -.settings-text-scale-reset { - min-height: 44px; - padding: 0 var(--space-5); +.settings-type-preset-card { + min-height: 76px; + padding: var(--space-4) var(--space-3); border: 1px solid var(--border-control); - border-radius: var(--radius-md); + border-radius: var(--radius-lg); background: var(--surface-control); color: var(--text-secondary); - font: inherit; - font-size: var(--text-xs); - font-weight: var(--type-weight-semibold); - cursor: pointer; - transition: background var(--transition-fast), color var(--transition-fast), opacity var(--transition-fast); + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + gap: var(--space-3); + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast); } -.settings-text-scale-reset:hover:not(:disabled) { +.settings-type-preset:hover .settings-type-preset-card { background: var(--surface-control-hover); color: var(--text-primary); } -.settings-text-scale-reset:focus-visible { +.settings-type-preset input:checked + .settings-type-preset-card { + border-color: var(--accent); + background: var(--accent-bg-strong); + color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent-border); +} +.settings-type-preset input:focus-visible + .settings-type-preset-card { outline: 2px solid var(--focus-ring); outline-offset: 2px; } -.settings-text-scale-reset:disabled { - opacity: var(--opacity-disabled); - cursor: default; +.settings-type-preset-sample { + font-family: var(--font-sans); + font-size: var(--type-preview-size); + font-weight: var(--type-weight-semibold); + line-height: 1; +} +.settings-type-preset-value { + max-width: 100%; + font-family: var(--font-mono); + font-size: var(--type-badge-size); + font-variant-numeric: tabular-nums; + line-height: 1; } html[data-text-scale-tier="large"] .settings-row { @@ -1932,10 +1968,15 @@ html[data-text-scale-tier="large"] .settings-row-info { min-width: min(100%, 250px); flex: 1 1 250px; } -html[data-text-scale-tier="large"] .settings-row-actions, -html[data-text-scale-tier="large"] .settings-text-scale-controls { +html[data-text-scale-tier="large"] .settings-row-actions { flex: 1 1 280px; } +html[data-text-scale-tier="large"] .settings-detail-header { + align-items: flex-start; +} +html[data-text-scale-tier="large"] .settings-detail-header p { + max-width: 48%; +} @media (min-width: 769px) { html[data-text-scale-tier="xlarge"] .settings-page-shell { @@ -1956,6 +1997,7 @@ html[data-text-scale-tier="large"] .settings-text-scale-controls { flex-wrap: wrap; } html[data-text-scale-tier="xlarge"] .settings-detail-header p { + flex-basis: 100%; max-width: 100%; text-align: left; } diff --git a/dashboard/static/css/13-responsive.css b/dashboard/static/css/13-responsive.css index aa4032c..95c5bb1 100644 --- a/dashboard/static/css/13-responsive.css +++ b/dashboard/static/css/13-responsive.css @@ -1154,26 +1154,14 @@ max-width: 48%; flex-wrap: wrap; } - .settings-text-scale-row { - flex-direction: column; - } - .settings-text-scale-controls { + .settings-type-presets { width: 100%; - grid-template-columns: minmax(0, 1fr) 3.75rem auto; - justify-content: stretch; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-2); } - html[data-text-scale-tier="xlarge"] .settings-text-scale-controls { - grid-template-columns: minmax(0, 1fr) auto; - } - html[data-text-scale-tier="xlarge"] .settings-text-scale-input { - grid-column: 1 / -1; - } - html[data-text-scale-tier="xlarge"] .settings-text-scale-value { - grid-column: 1; - justify-self: start; - } - html[data-text-scale-tier="xlarge"] .settings-text-scale-reset { - grid-column: 2; + .settings-type-preset-card { + min-height: 72px; + padding-inline: var(--space-2); } .settings-row-label { font-size: 1rem; @@ -3485,6 +3473,10 @@ html[data-text-scale-tier="large"] .dashboard-action-row { flex-wrap: wrap; } +html[data-text-scale-tier="large"] .activity-event-details > div { + grid-template-columns: minmax(96px, 0.28fr) minmax(0, 1fr); +} + @media (min-width: 769px) { html[data-text-scale-tier="xlarge"] .lxmf-sidebar { width: clamp(320px, 34vw, 440px); @@ -3502,6 +3494,11 @@ html[data-text-scale-tier="xlarge"] .bento-grid-dashboard { grid-template-columns: minmax(0, 1fr); } +html[data-text-scale-tier="xlarge"] .activity-event-details > div { + grid-template-columns: minmax(0, 1fr); + gap: var(--space-1); +} + html[data-text-scale-tier="xlarge"] .channels-layout { grid-template-columns: minmax(272px, 34vw) minmax(360px, 1fr); } diff --git a/dashboard/static/js/activity.js b/dashboard/static/js/activity.js index 6961a3a..cc56fda 100644 --- a/dashboard/static/js/activity.js +++ b/dashboard/static/js/activity.js @@ -8,6 +8,8 @@ var activitySearchQuery = ''; var activityExpandedSequence = null; var activityRevealedFields = Object.create(null); var activityRevealPending = Object.create(null); +var activityRevealEventPending = Object.create(null); +var activityIdentityProtectionEnabled = true; var ACTIVITY_MAX_RENDERED = 500; var _activityRenderScheduled = false; @@ -1177,12 +1179,6 @@ function toggleActivityEvent(sequence) { activityExpandedSequence = expanding ? sequence : null; activityStickToBottom = false; renderActivityFeed(); - if (expanding) { - var event = activityEventBySequence(sequence); - if (event && event.kind === 'rns.announce.observed') { - activityRevealField(event, 'destination'); - } - } var row = document.querySelector('[data-activity-sequence="' + sequence + '"]'); var toggle = row && row.querySelector ? row.querySelector('.activity-event-toggle') : null; if (toggle && typeof toggle.focus === 'function') { @@ -1272,12 +1268,35 @@ function activityPresentationArea(event) { function activityResetReveals() { activityRevealedFields = Object.create(null); activityRevealPending = Object.create(null); + activityRevealEventPending = Object.create(null); } function activityRevealKey(event, field) { return event.capture_session + ':' + event.sequence + ':' + field; } +function activityEventRevealKey(event) { + return event.capture_session + ':' + event.sequence; +} + +function activityIdentifierAttributeIsRevealable(attribute) { + return !!(attribute && attribute.value && attribute.value.type === 'identifier') && + ['destination', 'endpoint', 'hub', 'identity', 'link', 'message', 'room'].indexOf(attribute.key) !== -1; +} + +function activityIdentifierAttributes(event) { + return (event && Array.isArray(event.attributes) ? event.attributes : []).filter( + activityIdentifierAttributeIsRevealable + ); +} + +function activityEventIdentifiersRevealed(event) { + var identifiers = activityIdentifierAttributes(event); + return identifiers.length === 0 || identifiers.every(function(attribute) { + return !!activityRevealedField(event, attribute.key); + }); +} + function activityRevealedField(event, field) { var key = activityRevealKey(event, field); return Object.prototype.hasOwnProperty.call(activityRevealedFields, key) @@ -1328,6 +1347,47 @@ function activityRevealField(event, field) { return activityRevealPending[key]; } +function activityRevealEvent(event) { + if (!event || !activityIsCanonicalU64(event.sequence, false) || !event.capture_session) { + return Promise.resolve(false); + } + var identifiers = activityIdentifierAttributes(event); + if (!identifiers.length) return Promise.resolve(false); + if (activityEventIdentifiersRevealed(event)) return Promise.resolve(true); + var key = activityEventRevealKey(event); + if (activityRevealEventPending[key]) return activityRevealEventPending[key]; + activityRevealEventPending[key] = Promise.resolve().then(function() { + return Promise.all(identifiers.map(function(attribute) { + return activityRevealField(event, attribute.key); + })); + }).then(function(values) { + delete activityRevealEventPending[key]; + scheduleActivityRender(); + return values.every(function(value) { return !!value; }); + }, function() { + delete activityRevealEventPending[key]; + scheduleActivityRender(); + return false; + }); + scheduleActivityRender(); + return activityRevealEventPending[key]; +} + +function setActivityIdentityProtectionEnabled(enabled) { + var next = enabled !== false; + if (next === activityIdentityProtectionEnabled) return; + activityIdentityProtectionEnabled = next; + if (next) activityResetReveals(); + scheduleActivityRender(); +} + +if (typeof RS !== 'undefined') { + RS.activityIdentityProtection = { + get: function() { return activityIdentityProtectionEnabled; }, + set: setActivityIdentityProtectionEnabled + }; +} + function activityKnownPeerForHash(hash) { if (!hash) return null; if (typeof PeersCache !== 'undefined' && PeersCache && typeof PeersCache.get === 'function') { @@ -1388,6 +1448,7 @@ function activityIdentifierLabel(event, field) { if (field === 'destination' && announce && announce.isLxmf) return 'LXMF address'; var labels = { destination: 'Destination', + endpoint: 'Endpoint', hub: 'Hub address', identity: 'Identity hash', link: 'Link identifier', @@ -1415,7 +1476,8 @@ function activityRevealIcon() { function activityIdentifierControl(event, attribute) { var field = attribute.key; var raw = activityRevealedField(event, field); - var pending = !!activityRevealPending[activityRevealKey(event, field)]; + var pending = !!activityRevealEventPending[activityEventRevealKey(event)] || + !!activityRevealPending[activityRevealKey(event, field)]; var label = activityIdentifierLabel(event, field); var action = raw ? 'copy' : 'reveal'; var text = raw ? activityShortToken(raw) : (pending ? 'Revealing…' : 'Reveal'); @@ -1434,12 +1496,11 @@ function activateActivityIdentifier(sequence, field) { if (activityRevealedField(event, field)) { return copyActivityIdentifier(sequence, field); } - var label = activityIdentifierLabel(event, field); - return activityRevealField(event, field).then(function(value) { - if (!value && typeof showToast === 'function') { - showToast('Could not reveal ' + label.toLowerCase(), 'toast-red', 3000); + return activityRevealEvent(event).then(function(revealed) { + if (!revealed && typeof showToast === 'function') { + showToast('Could not reveal this event’s identities', 'toast-red', 3000); } - return !!value; + return !!revealed; }); } @@ -1718,7 +1779,7 @@ function activityEventDetails(event) { ]); } (event.attributes || []).forEach(function(attribute) { - if (attribute.value && attribute.value.type === 'identifier') { + if (activityIdentifierAttributeIsRevealable(attribute)) { rows.push([ activityIdentifierLabel(event, attribute.key), activityIdentifierControl(event, attribute) @@ -1778,6 +1839,9 @@ function renderActivityFeed() { for (var i = 0; i < visible.length; i++) { var event = visible[i]; var expanded = event.sequence === activityExpandedSequence; + if (expanded && !activityIdentityProtectionEnabled && !activityEventIdentifiersRevealed(event)) { + activityRevealEvent(event); + } var problem = activityIsProblem(event); var outcome = event.outcome && event.outcome !== 'none' ? event.outcome : ''; var presentationArea = activityPresentationArea(event); diff --git a/dashboard/static/js/settings.js b/dashboard/static/js/settings.js index 118435c..257cbb0 100644 --- a/dashboard/static/js/settings.js +++ b/dashboard/static/js/settings.js @@ -4,6 +4,7 @@ function openSettings() { showSettingsMobileSectionIndex({ restoreFocus: false }); initHapticsToggle(); initChannelHostingToggle(); + initActivityIdentityProtectionToggle(); initDeveloperModeToggle(); initWindowDecorationsToggle(); syncSettingsIdentityActions(); @@ -25,6 +26,9 @@ var _settingsChannelHostingBusy = false; var _settingsChannelHostingEnabled = false; var _settingsChannelHostingRequested = null; var _settingsChannelHostingSupported = null; +var _settingsActivityIdentityProtectionBound = false; +var _settingsActivityIdentityProtectionBusy = false; +var _settingsActivityIdentityProtectionEnabled = true; var RATSPEAK_RELEASE_LATEST_URL = 'https://api.github.com/repos/ratspeak/Ratspeak/releases/latest'; var RATSPEAK_RELEASES_URL = 'https://github.com/ratspeak/Ratspeak/releases'; @@ -135,6 +139,73 @@ function initChannelHostingToggle() { } } +function syncActivityIdentityProtectionRadioState() { + var off = document.getElementById('settings-activity-identity-protection-off'); + var on = document.getElementById('settings-activity-identity-protection-on'); + var group = on && on.closest('.settings-radio-group'); + if (off) { + off.checked = !_settingsActivityIdentityProtectionEnabled; + off.disabled = _settingsActivityIdentityProtectionBusy; + } + if (on) { + on.checked = _settingsActivityIdentityProtectionEnabled; + on.disabled = _settingsActivityIdentityProtectionBusy; + } + if (group) { + group.setAttribute('aria-busy', _settingsActivityIdentityProtectionBusy ? 'true' : 'false'); + } +} + +function adoptActivityIdentityProtectionFromBackend(enabled) { + _settingsActivityIdentityProtectionEnabled = enabled !== false; + syncActivityIdentityProtectionRadioState(); + if (RS.activityIdentityProtection && typeof RS.activityIdentityProtection.set === 'function') { + RS.activityIdentityProtection.set(_settingsActivityIdentityProtectionEnabled); + } +} + +function setActivityIdentityProtectionEnabled(enabled) { + if (_settingsActivityIdentityProtectionBusy) return; + var previous = _settingsActivityIdentityProtectionEnabled; + _settingsActivityIdentityProtectionEnabled = !!enabled; + _settingsActivityIdentityProtectionBusy = true; + syncActivityIdentityProtectionRadioState(); + if (RS.activityIdentityProtection && typeof RS.activityIdentityProtection.set === 'function') { + RS.activityIdentityProtection.set(_settingsActivityIdentityProtectionEnabled); + } + RS.invoke('set_activity_identity_protection', { enabled: _settingsActivityIdentityProtectionEnabled }) + .then(function(result) { + adoptActivityIdentityProtectionFromBackend( + result && result.enabled !== undefined ? result.enabled : enabled + ); + }) + .catch(function(error) { + adoptActivityIdentityProtectionFromBackend(previous); + if (typeof showToast === 'function') { + showToast((error && error.message) || 'Could not update Activity privacy', 'toast-red', 4000); + } + }) + .then(function() { + _settingsActivityIdentityProtectionBusy = false; + syncActivityIdentityProtectionRadioState(); + }); +} + +function initActivityIdentityProtectionToggle() { + var off = document.getElementById('settings-activity-identity-protection-off'); + var on = document.getElementById('settings-activity-identity-protection-on'); + if (!off || !on) return; + syncActivityIdentityProtectionRadioState(); + if (_settingsActivityIdentityProtectionBound) return; + _settingsActivityIdentityProtectionBound = true; + off.addEventListener('change', function() { + if (off.checked) setActivityIdentityProtectionEnabled(false); + }); + on.addEventListener('change', function() { + if (on.checked) setActivityIdentityProtectionEnabled(true); + }); +} + function readDeveloperModePreference() { try { return window.localStorage.getItem(_settingsDeveloperModeStorageKey) === 'true'; @@ -1416,6 +1487,12 @@ function applyAppSettingsPayload(data) { if (usageToggle && data.announce_ratspeak_usage !== undefined) { usageToggle.checked = !!data.announce_ratspeak_usage; } + if (data.activity_identity_protection !== undefined) { + adoptActivityIdentityProtectionFromBackend(data.activity_identity_protection); + } + if (data.text_scale_percent !== undefined && RS.textScale) { + RS.textScale.commit(data.text_scale_percent); + } var hwBadge = document.getElementById('hw-lock-timeout-select'); if (hwBadge && data.hardware_session_timeout !== undefined) { var t = parseInt(data.hardware_session_timeout, 10); @@ -1489,6 +1566,7 @@ document.addEventListener('DOMContentLoaded', function() { (function() { var usageToggle = document.getElementById('announce-ratspeak-usage-toggle'); + initActivityIdentityProtectionToggle(); RS.invoke('api_app_settings').then(applyAppSettingsPayload).catch(function() {}); if (!usageToggle) return; usageToggle.addEventListener('change', function() { @@ -1842,7 +1920,7 @@ function confirmDangerAction(action, onClose) { var _themeToggleInitialized = false; var _hapticsToggleInitialized = false; var _textScaleInitialized = false; -var _textScalePreviewFrame = null; +var _textScaleSaving = false; function initThemeToggle() { var toggle = document.getElementById('theme-toggle'); @@ -1887,44 +1965,49 @@ function initHapticsToggle() { } function initTextScaleControl() { - var input = document.getElementById('settings-text-scale'); - var output = document.getElementById('settings-text-scale-value'); - var reset = document.getElementById('settings-text-scale-reset'); - if (!input || !output || !reset || !RS.textScale) return; + var inputs = document.querySelectorAll('input[name="settings-text-scale"]'); + if (!inputs.length || !RS.textScale) return; function sync(value) { var percent = RS.textScale.normalize(value); - input.value = String(percent); - input.setAttribute('aria-valuetext', percent + ' percent'); - output.value = percent + '%'; - output.textContent = percent + '%'; - reset.disabled = percent === RS.textScale.MIN; + inputs.forEach(function(input) { + var selected = Number(input.value) === percent; + input.checked = selected; + input.disabled = _textScaleSaving; + }); + var fieldset = inputs[0].closest('fieldset'); + if (fieldset) fieldset.setAttribute('aria-busy', _textScaleSaving ? 'true' : 'false'); + } + + function save(value) { + if (_textScaleSaving) return; + var previous = RS.textScale.get(); + var percent = RS.textScale.commit(value); + _textScaleSaving = true; + sync(percent); + RS.invoke('set_text_scale', { percent: percent }).then(function(result) { + RS.textScale.commit(result && result.percent !== undefined ? result.percent : percent); + }).catch(function(error) { + RS.textScale.commit(previous); + if (typeof showToast === 'function') { + showToast((error && error.message) || 'Could not save text size', 'toast-red', 4000); + } + }).then(function() { + _textScaleSaving = false; + sync(RS.textScale.get()); + }); } sync(RS.textScale.get()); if (_textScaleInitialized) return; _textScaleInitialized = true; - input.addEventListener('input', function() { - var next = input.value; - sync(next); - if (_textScalePreviewFrame) cancelAnimationFrame(_textScalePreviewFrame); - _textScalePreviewFrame = requestAnimationFrame(function() { - _textScalePreviewFrame = null; - RS.textScale.preview(next); + inputs.forEach(function(input) { + input.addEventListener('change', function() { + if (!input.checked) return; + save(input.value); }); }); - input.addEventListener('change', function() { - if (_textScalePreviewFrame) { - cancelAnimationFrame(_textScalePreviewFrame); - _textScalePreviewFrame = null; - } - sync(RS.textScale.commit(input.value)); - }); - reset.addEventListener('click', function() { - sync(RS.textScale.reset()); - input.focus({ preventScroll: true }); - }); window.addEventListener('ratspeak-text-scale-changed', function(event) { if (event.detail) sync(event.detail.percent); }); diff --git a/dashboard/static/js/text_scale.js b/dashboard/static/js/text_scale.js index a499b92..ec30090 100644 --- a/dashboard/static/js/text_scale.js +++ b/dashboard/static/js/text_scale.js @@ -3,7 +3,7 @@ var STORAGE_KEY = 'rs-text-scale-percent'; var MIN = 100; - var MAX = 200; + var MAX = 140; var STEP = 10; function normalize(value) { @@ -19,8 +19,9 @@ } function tier(percent) { - if (percent >= 180) return 'xlarge'; - if (percent >= 140) return 'large'; + if (percent >= 140) return 'xlarge'; + if (percent >= 130) return 'large'; + if (percent >= 120) return 'medium'; return 'normal'; } diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml index ab2b5fd..80b3525 100644 --- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -55,11 +55,13 @@ android:usesCleartextTraffic="${usesCleartextTraffic}"> + android:exported="true" + tools:ignore="DiscouragedApi,LockedOrientationActivity"> diff --git a/src-tauri/gen/apple/project.yml b/src-tauri/gen/apple/project.yml index 54129fa..3a97186 100644 --- a/src-tauri/gen/apple/project.yml +++ b/src-tauri/gen/apple/project.yml @@ -46,13 +46,9 @@ targets: UIRequiredDeviceCapabilities: [arm64, metal] UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - - UIInterfaceOrientationLandscapeLeft - - UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad: - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - - UIInterfaceOrientationLandscapeLeft - - UIInterfaceOrientationLandscapeRight CFBundleShortVersionString: 1.0.26 CFBundleVersion: "1.0.26" entitlements: diff --git a/src-tauri/gen/apple/ratspeak_iOS/Info.plist b/src-tauri/gen/apple/ratspeak_iOS/Info.plist index 2335b78..7a6560b 100644 --- a/src-tauri/gen/apple/ratspeak_iOS/Info.plist +++ b/src-tauri/gen/apple/ratspeak_iOS/Info.plist @@ -30,15 +30,11 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight NSBluetoothAlwaysUsageDescription Ratspeak uses Bluetooth to connect to hardware nodes and other Bluetooth peers when enabled. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index af2f120..7622677 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -769,6 +769,8 @@ pub fn run() { ratspeak_tauri::commands::interfaces::set_hardware_lock_timeout, ratspeak_tauri::commands::interfaces::set_developer_mode, ratspeak_tauri::commands::interfaces::set_announce_ratspeak_usage, + ratspeak_tauri::commands::interfaces::set_activity_identity_protection, + ratspeak_tauri::commands::interfaces::set_text_scale, ratspeak_tauri::commands::interfaces::api_notification_settings, ratspeak_tauri::commands::interfaces::set_desktop_notifications, ratspeak_tauri::commands::interfaces::add_lora_interface,