ui: refine text scaling and activity privacy

This commit is contained in:
DeFiDude 2026-08-03 12:42:50 -06:00
parent 6a2dc011dc
commit 860d814ae6
15 changed files with 597 additions and 152 deletions

View file

@ -953,26 +953,39 @@ pub async fn set_auto_announce(state: State<'_, Arc<AppState>>, interval: u64) -
#[tauri::command]
pub async fn api_app_settings(state: State<'_, Arc<AppState>>) -> AppResult<Value> {
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::<u64>().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::<u64>().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::<u16>().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<AppState>>) -> AppResult<Valu
"developer_mode": developer_mode,
"window_decorations": window_decorations,
"channel_hosting_enabled": channel_hosting_enabled,
"activity_identity_protection": activity_identity_protection,
"text_scale_percent": text_scale_percent,
}))
}
#[tauri::command]
pub async fn set_text_scale(state: State<'_, Arc<AppState>>, percent: u16) -> AppResult<Value> {
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<AppState>>,
enabled: bool,
) -> AppResult<Value> {
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]

View file

@ -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();

View file

@ -1205,14 +1205,47 @@
</div>
<div class="settings-row settings-text-scale-row">
<div class="settings-row-info">
<label class="settings-row-label" for="settings-text-scale">Text size</label>
<span class="settings-row-desc" id="settings-text-scale-desc">Scale text without changing controls or touch targets</span>
</div>
<div class="settings-text-scale-controls">
<input id="settings-text-scale" class="settings-text-scale-input" type="range" min="100" max="200" step="10" value="100" aria-describedby="settings-text-scale-desc">
<output id="settings-text-scale-value" class="settings-text-scale-value" for="settings-text-scale">100%</output>
<button id="settings-text-scale-reset" class="settings-text-scale-reset" type="button">Reset</button>
<span class="settings-row-label" id="settings-text-scale-label">Text size</span>
<span class="settings-row-desc" id="settings-text-scale-desc">Choose a comfortable reading size without enlarging controls or touch targets.</span>
</div>
<fieldset class="settings-type-presets" aria-labelledby="settings-text-scale-label" aria-describedby="settings-text-scale-desc">
<legend class="sr-only">Text size</legend>
<label class="settings-type-preset" data-scale="100">
<input type="radio" name="settings-text-scale" value="100" checked>
<span class="settings-type-preset-card">
<span class="settings-type-preset-sample" aria-hidden="true">Aa</span>
<span class="settings-type-preset-value">100%</span>
</span>
</label>
<label class="settings-type-preset" data-scale="110">
<input type="radio" name="settings-text-scale" value="110">
<span class="settings-type-preset-card">
<span class="settings-type-preset-sample" aria-hidden="true">Aa</span>
<span class="settings-type-preset-value">110%</span>
</span>
</label>
<label class="settings-type-preset" data-scale="120">
<input type="radio" name="settings-text-scale" value="120">
<span class="settings-type-preset-card">
<span class="settings-type-preset-sample" aria-hidden="true">Aa</span>
<span class="settings-type-preset-value">120%</span>
</span>
</label>
<label class="settings-type-preset" data-scale="130">
<input type="radio" name="settings-text-scale" value="130">
<span class="settings-type-preset-card">
<span class="settings-type-preset-sample" aria-hidden="true">Aa</span>
<span class="settings-type-preset-value">130%</span>
</span>
</label>
<label class="settings-type-preset" data-scale="140">
<input type="radio" name="settings-text-scale" value="140">
<span class="settings-type-preset-card">
<span class="settings-type-preset-sample" aria-hidden="true">Aa</span>
<span class="settings-type-preset-value">140%</span>
</span>
</label>
</fieldset>
</div>
<div class="settings-row">
<div class="settings-row-info">
@ -1255,11 +1288,11 @@
<div class="settings-radio-group" role="radiogroup" aria-label="Channel hosting">
<label class="settings-radio-option">
<input type="radio" name="settings-channel-hosting" id="settings-channel-hosting-off" value="off" checked>
<span>Off</span>
<span>OFF</span>
</label>
<label class="settings-radio-option">
<input type="radio" name="settings-channel-hosting" id="settings-channel-hosting-on" value="on">
<span>On</span>
<span>ON</span>
</label>
</div>
</div>
@ -1313,6 +1346,22 @@
<div class="panel settings-panel" id="panel-settings-privacy">
<div class="panel-header">Privacy</div>
<div class="panel-body">
<div class="settings-row">
<div class="settings-row-info">
<span class="settings-row-label">Protect Activity identities</span>
<span class="settings-row-desc" id="settings-activity-identity-protection-desc" aria-live="polite">Hide peer names and addresses until you reveal an Activity event.</span>
</div>
<div class="settings-radio-group" role="radiogroup" aria-label="Protect Activity identities">
<label class="settings-radio-option">
<input type="radio" name="settings-activity-identity-protection" id="settings-activity-identity-protection-off" value="off">
<span>OFF</span>
</label>
<label class="settings-radio-option">
<input type="radio" name="settings-activity-identity-protection" id="settings-activity-identity-protection-on" value="on" checked>
<span>ON</span>
</label>
</div>
</div>
<div class="settings-row" style="border-bottom:none;">
<div class="settings-row-info">
<span class="settings-row-label">Announce Ratspeak usage</span>
@ -1375,11 +1424,11 @@
<div class="settings-radio-group" role="radiogroup" aria-label="Developer Mode">
<label class="settings-radio-option">
<input type="radio" name="settings-developer-mode" id="settings-developer-mode-off" value="off" checked>
<span>Off</span>
<span>OFF</span>
</label>
<label class="settings-radio-option">
<input type="radio" name="settings-developer-mode" id="settings-developer-mode-on" value="on">
<span>On</span>
<span>ON</span>
</label>
</div>
</div>
@ -1391,15 +1440,15 @@
<div class="settings-radio-group" role="radiogroup" aria-label="Window Decorations">
<label class="settings-radio-option">
<input type="radio" name="settings-window-decorations" id="settings-window-decorations-auto" value="auto" checked>
<span>Auto</span>
<span>AUTO</span>
</label>
<label class="settings-radio-option">
<input type="radio" name="settings-window-decorations" id="settings-window-decorations-on" value="on">
<span>On</span>
<span>ON</span>
</label>
<label class="settings-radio-option">
<input type="radio" name="settings-window-decorations" id="settings-window-decorations-off" value="off">
<span>Off</span>
<span>OFF</span>
</label>
</div>
</div>

View file

@ -1150,6 +1150,7 @@ test('identifier controls reveal first and copy only after disclosure', async fu
assert(hiddenHtml.indexOf('<code>Reveal</code>') !== -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;

View file

@ -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');

View file

@ -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(/<label\b[^>]*\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'));

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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 events 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);

View file

@ -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);
});

View file

@ -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';
}

View file

@ -55,11 +55,13 @@
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:screenOrientation="sensorPortrait"
android:windowSoftInputMode="adjustResize"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
android:exported="true"
tools:ignore="DiscouragedApi,LockedOrientationActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View file

@ -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:

View file

@ -30,15 +30,11 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Ratspeak uses Bluetooth to connect to hardware nodes and other Bluetooth peers when enabled.</string>

View file

@ -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,