experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

polish(permissions): nav count badge + relative timestamps + clearAll button reset

Three small Settings UX improvements + one bug fix:

1. **Live count badge on the sidebar nav.** "Permissions (3)" reads
the current decision count without entering the section. Updates on
refresh, single Revoke, and Clear All. Implementation:
`window._setPermissionsNavCount(n)` is exposed during nav setup;
the section's refresh path calls it from a single helper. Empty
when 0.

2. **Relative timestamps.** Decisions now read "5 minutes ago" /
"3 hours ago" / "2 days ago" instead of the verbose locale
timestamp. Falls back to the absolute date for anything older than
a week so the user can still see the day. Helper `relativeTime`
lives next to the renderer (no shared util surface needed).

3. **Bug fix: Clear-all button stuck disabled after success.** The
click handler set `clearAllBtn.disabled = true` on entry; the
success branch only hid the button (`display = 'none'`) without
resetting `disabled`. On the next visit (which un-hides via
refresh) the button was still disabled and silently un-clickable.
Refresh now sets `disabled = false` in both the populated and
empty branches. Caught by the new badge test failing on the
subsequent Clear-all test (which reused the settings window with
the disabled button).

Tests:
* `tests/desktop/permissions-settings.spec.ts` 6/6 (was 5; +badge).
* The new badge test seeds 2 decisions, asserts "Permissions (2)"
on the nav, revokes one (asserts "(1)"), then clears all
(asserts the badge disappears).
* Bonus: switched the `dialog` listener in the Clear-all test to
`.once()` so it doesn't leak across tests reusing the same
settings window.

No backend changes.

+95 -5
+56 -4
app/settings/settings.js
··· 1135 1135 try { return new URL(origin).hostname || null; } catch { return null; } 1136 1136 }; 1137 1137 1138 + // Human-readable relative timestamp. "just now" / "5 minutes ago" / 1139 + // "3 hours ago" / "2 days ago" — falls back to the absolute date for 1140 + // anything older than a week so the user can still see the exact day. 1141 + const relativeTime = (ms) => { 1142 + if (!ms) return ''; 1143 + const diff = Date.now() - ms; 1144 + if (diff < 60_000) return 'just now'; 1145 + if (diff < 3_600_000) { 1146 + const m = Math.floor(diff / 60_000); 1147 + return m === 1 ? '1 minute ago' : `${m} minutes ago`; 1148 + } 1149 + if (diff < 86_400_000) { 1150 + const h = Math.floor(diff / 3_600_000); 1151 + return h === 1 ? '1 hour ago' : `${h} hours ago`; 1152 + } 1153 + if (diff < 604_800_000) { 1154 + const d = Math.floor(diff / 86_400_000); 1155 + return d === 1 ? '1 day ago' : `${d} days ago`; 1156 + } 1157 + return new Date(ms).toLocaleDateString(); 1158 + }; 1159 + 1138 1160 const renderRow = (decision) => { 1139 1161 const row = document.createElement('div'); 1140 1162 row.className = 'item-card no-collapse'; ··· 1172 1194 const label = PERMISSION_LABELS[decision.permission] || decision.permission; 1173 1195 const glyph = PERMISSION_GLYPHS[decision.permission] || '\u{1F512}'; 1174 1196 const verdict = decision.allowed ? 'Allowed' : 'Denied'; 1175 - const when = decision.timestamp ? new Date(decision.timestamp).toLocaleString() : ''; 1197 + const when = relativeTime(decision.timestamp); 1176 1198 meta.textContent = `${glyph} ${label} · ${verdict}${when ? ' · ' + when : ''}`; 1177 1199 textCol.appendChild(meta); 1178 1200 left.appendChild(textCol); ··· 1191 1213 emptyMsg.style.display = ''; 1192 1214 clearAllBtn.style.display = 'none'; 1193 1215 } 1216 + if (typeof window._setPermissionsNavCount === 'function') { 1217 + window._setPermissionsNavCount(list.children.length); 1218 + } 1194 1219 } else { 1195 1220 revokeBtn.disabled = false; 1196 1221 console.error('[settings] permissions.forget failed:', res?.error); ··· 1221 1246 list.replaceChildren(); 1222 1247 emptyMsg.style.display = ''; 1223 1248 clearAllBtn.style.display = 'none'; 1249 + if (typeof window._setPermissionsNavCount === 'function') { 1250 + window._setPermissionsNavCount(0); 1251 + } 1224 1252 } else { 1225 1253 clearAllBtn.disabled = false; 1226 1254 console.error('[settings] permissions.forgetAll failed:', res?.error); ··· 1240 1268 // grouping every saved decision for a single site together makes the 1241 1269 // common case ("revoke everything I gave example.com") two clicks 1242 1270 // away instead of scattered through the list. 1271 + // 1272 + // Also updates the sidebar nav badge so the user sees how many 1273 + // saved decisions exist without having to enter the section. 1274 + const updateNavCount = (n) => { 1275 + if (typeof window._setPermissionsNavCount === 'function') { 1276 + window._setPermissionsNavCount(n); 1277 + } 1278 + }; 1243 1279 const refresh = async () => { 1244 1280 try { 1245 1281 const res = await api.permissions.list(); 1246 1282 list.replaceChildren(); 1247 - if (res?.success && Array.isArray(res.data) && res.data.length > 0) { 1248 - const sorted = [...res.data].sort((a, b) => { 1283 + const data = (res?.success && Array.isArray(res.data)) ? res.data : []; 1284 + if (data.length > 0) { 1285 + const sorted = [...data].sort((a, b) => { 1249 1286 if (a.origin !== b.origin) return a.origin.localeCompare(b.origin); 1250 1287 return a.permission.localeCompare(b.permission); 1251 1288 }); 1252 1289 for (const decision of sorted) list.appendChild(renderRow(decision)); 1253 1290 emptyMsg.style.display = 'none'; 1254 1291 clearAllBtn.style.display = ''; 1292 + clearAllBtn.disabled = false; 1255 1293 } else { 1256 1294 emptyMsg.style.display = ''; 1257 1295 clearAllBtn.style.display = 'none'; 1296 + clearAllBtn.disabled = false; 1258 1297 } 1298 + updateNavCount(data.length); 1259 1299 } catch (err) { 1260 1300 console.error('[settings] permissions.list threw:', err); 1261 1301 list.replaceChildren(); 1262 1302 emptyMsg.textContent = 'Failed to load permissions.'; 1263 1303 emptyMsg.style.display = ''; 1264 1304 clearAllBtn.style.display = 'none'; 1305 + updateNavCount(0); 1265 1306 } 1266 1307 }; 1267 1308 ··· 3405 3446 // permission-store.ts. 3406 3447 const permissionsNav = document.createElement('a'); 3407 3448 permissionsNav.className = 'nav-item'; 3408 - permissionsNav.textContent = 'Permissions'; 3409 3449 permissionsNav.dataset.section = 'permissions'; 3450 + // Label + live count badge — reads "Permissions (3)" when there are 3 3451 + // stored decisions, just "Permissions" when there are none. Updated by 3452 + // window._refreshPermissionsCount whenever the list changes. 3453 + const permissionsNavLabel = document.createElement('span'); 3454 + permissionsNavLabel.textContent = 'Permissions'; 3455 + permissionsNav.appendChild(permissionsNavLabel); 3456 + const permissionsNavCount = document.createElement('span'); 3457 + permissionsNavCount.style.cssText = 'margin-left: 6px; opacity: 0.6;'; 3458 + permissionsNav.appendChild(permissionsNavCount); 3459 + window._setPermissionsNavCount = (n) => { 3460 + permissionsNavCount.textContent = (typeof n === 'number' && n > 0) ? `(${n})` : ''; 3461 + }; 3410 3462 permissionsNav.addEventListener('click', () => { 3411 3463 showSection('permissions'); 3412 3464 if (window._refreshPermissionsList) {
+39 -1
tests/desktop/permissions-settings.spec.ts
··· 169 169 expect(origins[3]).toBe('https://meet.example.com'); 170 170 }); 171 171 172 + test('Sidebar nav shows live count badge that tracks the stored decision count', async () => { 173 + await seedDecision('https://a.com', 'geolocation', false); 174 + await seedDecision('https://b.com', 'media', true); 175 + 176 + const settings = await openSettings(); 177 + 178 + // Click into Permissions to trigger the initial refresh + nav update. 179 + await settings.locator('a.nav-item[data-section="permissions"]').click(); 180 + 181 + // Wait for both rows to render (proxy for refresh having completed). 182 + await settings.waitForFunction( 183 + () => document.querySelectorAll('#section-permissions .item-card').length === 2, 184 + undefined, 185 + { timeout: 5000 }, 186 + ); 187 + 188 + // Nav label should now read "Permissions (2)". 189 + const navText1 = await settings.locator('a.nav-item[data-section="permissions"]').innerText(); 190 + expect(navText1).toMatch(/Permissions\s*\(2\)/); 191 + 192 + // Revoke one — badge should drop to (1). 193 + await settings.locator('#section-permissions .item-card button:has-text("Revoke")').first().click(); 194 + await settings.waitForFunction( 195 + () => /\(1\)/.test(document.querySelector('a.nav-item[data-section="permissions"]')?.textContent || ''), 196 + undefined, 197 + { timeout: 5000 }, 198 + ); 199 + 200 + // Clear all — badge should disappear (no parens). 201 + settings.once('dialog', dialog => dialog.accept()); 202 + await settings.locator('#section-permissions button:has-text("Clear all")').click(); 203 + await settings.waitForFunction( 204 + () => !/\(\d+\)/.test(document.querySelector('a.nav-item[data-section="permissions"]')?.textContent || ''), 205 + undefined, 206 + { timeout: 5000 }, 207 + ); 208 + }); 209 + 172 210 test('Phase 5 — favicon img renders next to each decision row', async () => { 173 211 await seedDecision('https://example.com', 'geolocation', false); 174 212 await seedDecision('https://meet.example.com', 'media', true); ··· 202 240 ); 203 241 204 242 // Auto-accept the confirm() dialog. 205 - settings.on('dialog', dialog => dialog.accept()); 243 + settings.once('dialog', dialog => dialog.accept()); 206 244 207 245 await settings.locator('#section-permissions button:has-text("Clear all")').click(); 208 246