Desktop: background store reads from the tray (no document.hidden skip, backoff), notifications by kind (rejected, approved, live, halted, crash, reviews) with switches, quiet hours and mute, click-through on next show; tray menu with apps and their states, read now, mute, updates, settings, tooltip summary and attention dot; tray_update command; tests
This commit is contained in:
parent
5d8a0f5e9a
commit
03ef999dac
27 changed files with 1050 additions and 55 deletions
|
|
@ -1,12 +1,12 @@
|
|||
// Shell: sidebar (studios, collapsible, with their apps; the workspace), top bar (breadcrumbs, last read, refresh),
|
||||
// the screen, the command palette (Cmd/Ctrl+K), toasts and the first-run welcome guide.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FIXTURES, onDeepLink, onDeepLinkFolder } from './api';
|
||||
import { FIXTURES, onDeepLink, onDeepLinkFolder, onTrayRoute } from './api';
|
||||
import { devSwitch } from './dev/switches';
|
||||
import { type Key, t } from './i18n';
|
||||
import { ago, num } from './lib/format';
|
||||
import { groupByStudio, type UnifiedApp } from './lib/identity';
|
||||
import { NavCtx, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
|
||||
import { NavCtx, parseRouteJson, parseStart, type Route, sameRoute, TAB_ICON, TAB_KEY, tabsFor, WORKSPACE, type WorkspaceScreen } from './nav';
|
||||
import { usePrefs } from './prefs-ctx';
|
||||
import { Accounts } from './screens/Accounts';
|
||||
import { AppFolders } from './screens/AppFolders';
|
||||
|
|
@ -67,7 +67,9 @@ export function App({ onTheme }: { onTheme: () => void }) {
|
|||
const un = onDeepLink(s => { const r = parseStart(s, []); if (r) go(r); });
|
||||
// `storebound desktop` in an app folder: the host added (after its native dialog) or found that folder
|
||||
const unFolder = onDeepLinkFolder(id => { d.reload().catch(() => {}).finally(() => go({ screen: 'app', appId: id, tab: 'releases' })); });
|
||||
return () => { un.then(f => f()); unFolder.then(f => f()); };
|
||||
// the tray menu (an app, Settings, Check for updates) and the first show after a notification
|
||||
const unTray = onTrayRoute(s => { const r = parseRouteJson(s); if (r) go(r); });
|
||||
return () => { un.then(f => f()); unFolder.then(f => f()); unTray.then(f => f()); };
|
||||
}, [go, d.reload]);
|
||||
|
||||
const app = route.screen === 'app' ? d.appByKey(route.appId) : undefined;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { listen as tauriListen, type UnlistenFn } from '@tauri-apps/api/event';
|
|||
import type { AccountCheck, AppsListResult, DraftsResult, IconResult, ListingsResult, MethodDescriptor, PortfolioResult, ProgressParams, ToolResult } from '../../../cli/lib/desktop-protocol';
|
||||
import type { DeclarationsResult, ListingText, RendersResult } from '../../../cli/lib/desktop-protocol';
|
||||
import type { Overrides } from './lib/identity';
|
||||
import type { TraySummary } from './lib/tray';
|
||||
|
||||
export type {
|
||||
AccountCheck, AppIdentity, AppsListResult, AppStatus, CatalogParity, DraftsResult, StoreApp, IconResult, ListingField, ListingLocale, ListingsResult, MethodDescriptor, PortfolioResult,
|
||||
|
|
@ -33,10 +34,11 @@ export interface Settings {
|
|||
/** autoRead / everyMinutes: the background reader (store.tsx); autoMinutes / onLaunch: older builds' settings, kept for them. */
|
||||
refresh: { autoMinutes: 0 | 15 | 30 | 60 | 180 | 720; onLaunch: boolean; parallel: number; icons: boolean; autoRead: boolean; everyMinutes: 0 | 5 | 15 | 30 | 60 };
|
||||
cache: { keepDays: 0 | 7 | 30 | 90 | 365 };
|
||||
notify: { reviewState: boolean; lowReviews: boolean; crash: boolean };
|
||||
/** reviewState: other state changes (submitted, in review); rejected / approved / live: own toggles; crash also covers a halted rollout. quietHours: no notification between from and to (HH:MM, may cross midnight). */
|
||||
notify: { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: { enabled: boolean; from: string; to: string } };
|
||||
channel: 'stable' | 'beta';
|
||||
}
|
||||
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, lowReviews: true, crash: true }, channel: 'stable' };
|
||||
export const DEFAULT_SETTINGS: Settings = { refresh: { autoMinutes: 0, onLaunch: false, parallel: 4, icons: true, autoRead: true, everyMinutes: 15 }, cache: { keepDays: 30 }, notify: { reviewState: true, rejected: true, approved: true, live: true, lowReviews: true, crash: true, quietHours: { enabled: false, from: '23:00', to: '08:00' } }, channel: 'stable' };
|
||||
export interface Workspace { studios: Studio[]; overrides: Overrides; settings: Settings }
|
||||
export interface AccountApps { account: string; entry: CacheEntry<AppsListResult> | null }
|
||||
export interface CacheInfo { rows: number; bytes: number; oldest: string | null; file_bytes: number }
|
||||
|
|
@ -121,7 +123,10 @@ export const api = {
|
|||
setSettings: (settings: Settings) => invoke<Settings>('settings_set', { settings }),
|
||||
cacheInfo: () => invoke<CacheInfo>('cache_info'),
|
||||
revealData: () => invoke<void>('data_reveal'),
|
||||
notify: (title: string, body: string) => invoke<void>('notify', { title, body }),
|
||||
/** target: a route (JSON) the window opens on when it is next shown from the tray (the notification plugin has no click callback on desktop). */
|
||||
notify: (title: string, body: string, target?: string) => invoke<void>('notify', { title, body, target: target ?? null }),
|
||||
/** Pushes the tray's tooltip, attention flag and app list after a read (lib/tray.ts). */
|
||||
trayUpdate: (summary: TraySummary) => invoke<void>('tray_update', { summary }),
|
||||
/** Native confirmation first; true when everything was forgotten. */
|
||||
reset: () => invoke<boolean>('app_reset'),
|
||||
/** Offline (folders) or live; live also reads the given store-only apps (store targets). */
|
||||
|
|
@ -172,4 +177,8 @@ export const onDownload = (cb: (e: DownloadEvent) => void): Promise<UnlistenFn>
|
|||
export const onDeepLink = (cb: (screen: string) => void): Promise<UnlistenFn> => listen<string>('deep-link', cb);
|
||||
/** storebound://open?path=... selected or added an app folder (its project id). */
|
||||
export const onDeepLinkFolder = (cb: (projectId: string) => void): Promise<UnlistenFn> => listen<string>('deep-link-folder', cb);
|
||||
/** A tray menu click (or the first window show after a notification) that opens a route (JSON, see parseRouteJson). */
|
||||
export const onTrayRoute = (cb: (route: string) => void): Promise<UnlistenFn> => listen<string>('tray-route', cb);
|
||||
/** A tray menu click that is not navigation: read the stores now, toggle mute. */
|
||||
export const onTrayAction = (cb: (action: 'read' | 'mute-toggle') => void): Promise<UnlistenFn> => listen<'read' | 'mute-toggle'>('tray-action', cb);
|
||||
export const onUpdateProgress = (cb: (p: { received: number; total: number | null }) => void): Promise<UnlistenFn> => listen('update-progress', cb);
|
||||
|
|
|
|||
|
|
@ -569,7 +569,7 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
|
|||
case 'overrides_set': overrides = a.overrides as Overrides; return overrides;
|
||||
case 'settings_set': settings = a.settings as Settings; return settings;
|
||||
case 'cache_info': return { rows: 214, bytes: 3_412_000, oldest: iso(60 * 24 * 26), file_bytes: 4_718_592 };
|
||||
case 'data_reveal': case 'notify': return null;
|
||||
case 'data_reveal': case 'notify': case 'tray_update': return null;
|
||||
case 'app_reset': return false;
|
||||
case 'update_check': await sleep(300); return { current: '0.1.0', channel: settings.channel, available: null, notes: null, date: null };
|
||||
case 'update_install': return false;
|
||||
|
|
|
|||
|
|
@ -782,6 +782,41 @@ export const en = {
|
|||
'notify.lowTitle': '{name}: {count} new low ratings',
|
||||
'notify.lowTitle_one': '{name}: a new low rating',
|
||||
'notify.crashTitle': '{name}: over the Play threshold',
|
||||
'notify.rejectedTitle': '{name}: rejected by {store}',
|
||||
'notify.rejectedBody': 'Version {version} was rejected.',
|
||||
'notify.approvedTitle': '{name}: approved',
|
||||
'notify.approvedBody': '{store} {version} passed review and is ready for release.',
|
||||
'notify.liveTitle': '{name}: live',
|
||||
'notify.liveBody': '{store} {version} is live.',
|
||||
'notify.haltedTitle': '{name}: rollout halted',
|
||||
'notify.haltedBody': '{store} {version} was halted.',
|
||||
'tray.open': 'Open storebound',
|
||||
'tray.readNow': 'Read the stores now',
|
||||
'tray.apps': 'Apps',
|
||||
'tray.noApps': 'No apps yet',
|
||||
'tray.noState': 'no release read yet',
|
||||
'tray.mute': 'Mute notifications for 1 hour',
|
||||
'tray.unmute': 'Unmute notifications',
|
||||
'tray.update': 'Check for updates',
|
||||
'tray.settings': 'Settings',
|
||||
'tray.quit': 'Quit storebound',
|
||||
'tray.tipReview': '{count} in review',
|
||||
'tray.tipAttention': '{count} needs you',
|
||||
'tray.mutedTip': 'muted',
|
||||
'set.nRejected': 'Rejected',
|
||||
'set.nRejectedHint': 'A version the store rejected, with its reason when the store gives one',
|
||||
'set.nApproved': 'Approved and ready for release',
|
||||
'set.nApprovedHint': 'A version passed review',
|
||||
'set.nLive': 'Live',
|
||||
'set.nLiveHint': 'A version reached the store page or a rollout',
|
||||
'set.quiet': 'Quiet hours',
|
||||
'set.quietHint': 'No notifications in this window (they are dropped, not saved for later)',
|
||||
'set.quietFrom': 'From',
|
||||
'set.quietTo': 'To',
|
||||
'set.muteHour': 'Mute for 1 hour',
|
||||
'set.unmute': 'Unmute ({until})',
|
||||
'set.muted': 'Muted until {until}',
|
||||
'set.clickNote': "Clicking a notification does not open the app on desktop; the next time you open the window from the tray, it goes to that app's Releases tab.",
|
||||
'set.lead': 'Studios, accounts, store reads, the offline cache, language, theme, notifications and more. Everything stays on this computer.',
|
||||
'set.sections': 'Settings sections',
|
||||
'set.studios': 'Studios & accounts',
|
||||
|
|
@ -854,11 +889,11 @@ export const en = {
|
|||
'set.always': 'Always',
|
||||
'set.notifications': 'Notifications',
|
||||
'set.notificationsSub': 'After a read, when something changed since the previous one',
|
||||
'set.nReview': 'Review and release state changes',
|
||||
'set.nReviewHint': 'In review, approved, rejected, live, halted',
|
||||
'set.nReview': 'Other release state changes',
|
||||
'set.nReviewHint': 'Submitted, in review',
|
||||
'set.nLow': 'New 1 or 2 star reviews',
|
||||
'set.nLowHint': 'When the Reviews tab reads new ones',
|
||||
'set.nCrash': 'Crash or ANR rate over the threshold',
|
||||
'set.nCrash': 'Rollout halted, or crash / ANR over the threshold',
|
||||
'set.nCrashHint': 'Google Play bad behavior thresholds',
|
||||
'set.testNotify': 'Send a test notification',
|
||||
'set.testTitle': 'storebound',
|
||||
|
|
|
|||
|
|
@ -784,6 +784,41 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'notify.lowTitle': '{name}: {count} yeni düşük puan',
|
||||
'notify.lowTitle_one': '{name}: yeni bir düşük puan',
|
||||
'notify.crashTitle': '{name}: Play eşiğinin üstünde',
|
||||
'notify.rejectedTitle': '{name}: {store} reddetti',
|
||||
'notify.rejectedBody': '{version} sürümü reddedildi.',
|
||||
'notify.approvedTitle': '{name}: onaylandı',
|
||||
'notify.approvedBody': '{store} {version} incelemeyi geçti, yayına hazır.',
|
||||
'notify.liveTitle': '{name}: yayında',
|
||||
'notify.liveBody': '{store} {version} yayında.',
|
||||
'notify.haltedTitle': '{name}: kademeli yayın durduruldu',
|
||||
'notify.haltedBody': '{store} {version} durduruldu.',
|
||||
'tray.open': "storebound'u aç",
|
||||
'tray.readNow': 'Mağazaları şimdi oku',
|
||||
'tray.apps': 'Uygulamalar',
|
||||
'tray.noApps': 'Henüz uygulama yok',
|
||||
'tray.noState': 'henüz okunmadı',
|
||||
'tray.mute': 'Bildirimleri 1 saat sustur',
|
||||
'tray.unmute': 'Bildirimlerin sesini aç',
|
||||
'tray.update': 'Güncellemeleri denetle',
|
||||
'tray.settings': 'Ayarlar',
|
||||
'tray.quit': "storebound'dan çık",
|
||||
'tray.tipReview': '{count} incelemede',
|
||||
'tray.tipAttention': '{count} sizi bekliyor',
|
||||
'tray.mutedTip': 'sessiz',
|
||||
'set.nRejected': 'Reddedildi',
|
||||
'set.nRejectedHint': 'Mağazanın reddettiği sürüm; mağaza verirse gerekçesiyle',
|
||||
'set.nApproved': 'Onaylandı, yayına hazır',
|
||||
'set.nApprovedHint': 'Bir sürüm incelemeyi geçti',
|
||||
'set.nLive': 'Yayında',
|
||||
'set.nLiveHint': 'Bir sürüm mağaza sayfasına ya da kademeli yayına ulaştı',
|
||||
'set.quiet': 'Sessiz saatler',
|
||||
'set.quietHint': 'Bu aralıkta bildirim gelmez (sonraya saklanmaz, atılır)',
|
||||
'set.quietFrom': 'Başlangıç',
|
||||
'set.quietTo': 'Bitiş',
|
||||
'set.muteHour': '1 saat sustur',
|
||||
'set.unmute': 'Sesi aç ({until})',
|
||||
'set.muted': '{until} saatine kadar sessiz',
|
||||
'set.clickNote': 'Masaüstünde bildirime tıklamak uygulamayı açmaz; pencereyi bir sonraki sefer tepsiden açtığınızda o uygulamanın Yayınlar sekmesine gider.',
|
||||
'set.lead': 'Stüdyolar, hesaplar, mağaza okumaları, çevrimdışı önbellek, dil, tema, bildirimler ve dahası. Hepsi bu bilgisayarda kalır.',
|
||||
'set.sections': 'Ayar bölümleri',
|
||||
'set.studios': 'Stüdyolar ve hesaplar',
|
||||
|
|
@ -856,11 +891,11 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'set.always': 'Her zaman',
|
||||
'set.notifications': 'Bildirimler',
|
||||
'set.notificationsSub': 'Bir okumadan sonra, öncekinden beri bir şey değiştiyse',
|
||||
'set.nReview': 'İnceleme ve yayın durumu değişiklikleri',
|
||||
'set.nReviewHint': 'İncelemede, onaylandı, reddedildi, yayında, durduruldu',
|
||||
'set.nReview': 'Diğer yayın durumu değişiklikleri',
|
||||
'set.nReviewHint': 'Gönderildi, incelemede',
|
||||
'set.nLow': 'Yeni 1 ya da 2 yıldızlı yorumlar',
|
||||
'set.nLowHint': 'Yorumlar sekmesi yenilerini okuduğunda',
|
||||
'set.nCrash': 'Çökme ya da ANR oranı eşiğin üstünde',
|
||||
'set.nCrash': 'Kademeli yayın durdu, ya da çökme / ANR eşiğin üstünde',
|
||||
'set.nCrashHint': 'Google Play kötü davranış eşikleri',
|
||||
'set.testNotify': 'Deneme bildirimi gönder',
|
||||
'set.testTitle': 'storebound',
|
||||
|
|
|
|||
|
|
@ -49,16 +49,22 @@ function overrides(v: unknown): Overrides {
|
|||
function pick<T>(v: unknown, d: T, ok: (x: unknown) => boolean): T { return ok(v) ? (v as T) : d; }
|
||||
const isNum = (x: unknown) => typeof x === 'number' && Number.isFinite(x);
|
||||
const isBool = (x: unknown) => typeof x === 'boolean';
|
||||
const isHm = (x: unknown) => typeof x === 'string' && /^(?:[01][0-9]|2[0-3]):[0-5][0-9]$/.test(x);
|
||||
|
||||
export function normalizeSettings(v: unknown): Settings {
|
||||
const d = DEFAULT_SETTINGS;
|
||||
if (!isRec(v)) return d;
|
||||
const r = isRec(v.refresh) ? v.refresh : {}, c = isRec(v.cache) ? v.cache : {}, n = isRec(v.notify) ? v.notify : {};
|
||||
const q = isRec(n.quietHours) ? n.quietHours : {};
|
||||
return {
|
||||
refresh: { autoMinutes: pick(r.autoMinutes, d.refresh.autoMinutes, isNum), onLaunch: pick(r.onLaunch, d.refresh.onLaunch, isBool), parallel: pick(r.parallel, d.refresh.parallel, isNum), icons: pick(r.icons, d.refresh.icons, isBool),
|
||||
autoRead: pick(r.autoRead, d.refresh.autoRead, isBool), everyMinutes: pick(r.everyMinutes, d.refresh.everyMinutes, (x: unknown) => [0, 5, 15, 30, 60].includes(x as number)) },
|
||||
cache: { keepDays: pick(c.keepDays, d.cache.keepDays, isNum) },
|
||||
notify: { reviewState: pick(n.reviewState, d.notify.reviewState, isBool), lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool) },
|
||||
notify: {
|
||||
reviewState: pick(n.reviewState, d.notify.reviewState, isBool), rejected: pick(n.rejected, d.notify.rejected, isBool), approved: pick(n.approved, d.notify.approved, isBool), live: pick(n.live, d.notify.live, isBool),
|
||||
lowReviews: pick(n.lowReviews, d.notify.lowReviews, isBool), crash: pick(n.crash, d.notify.crash, isBool),
|
||||
quietHours: { enabled: pick(q.enabled, d.notify.quietHours.enabled, isBool), from: pick(q.from, d.notify.quietHours.from, isHm), to: pick(q.to, d.notify.quietHours.to, isHm) },
|
||||
},
|
||||
channel: v.channel === 'beta' ? 'beta' : 'stable',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,18 @@ import type { PortfolioResult } from '../api';
|
|||
import { sameVersion } from '../../../../cli/lib/portfolio';
|
||||
import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from './model';
|
||||
|
||||
export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string }
|
||||
/** Which Settings > Notifications toggle a release change belongs to. */
|
||||
export type ReleaseKind = 'rejected' | 'approved' | 'live' | 'halted' | 'state';
|
||||
export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string; kind: ReleaseKind; /** The store's reason text, when the status carries one. */ reason?: string }
|
||||
|
||||
/** The notification kind of a transition into phase `to`.*/
|
||||
export function releaseKind(to: string): ReleaseKind {
|
||||
if (to === 'rejected') return 'rejected';
|
||||
if (to === 'approved') return 'approved';
|
||||
if (to === 'live' || to === 'rollout') return 'live';
|
||||
if (to === 'halted') return 'halted';
|
||||
return 'state';
|
||||
}
|
||||
|
||||
/** Phase changes of the same version on the same store that the owner waits for: review in / out, approved, rejected, live, halted. */
|
||||
export function releaseChanges(prev: PortfolioResult | null | undefined, next: PortfolioResult): ReleaseChange[] {
|
||||
|
|
@ -18,7 +29,12 @@ export function releaseChanges(prev: PortfolioResult | null | undefined, next: P
|
|||
if (!r.version) continue;
|
||||
const old = (before.release.all ?? []).find(x => x.store === r.store && (x.version === r.version || sameVersion(x.version, r.version)));
|
||||
if (!old || old.phase === r.phase) continue;
|
||||
if (WATCH.has(r.phase) || WATCH.has(old.phase)) out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase });
|
||||
// a version the developer withdrew on their own is not a store decision
|
||||
if (r.state === 'DEVELOPER_REJECTED') continue;
|
||||
if (WATCH.has(r.phase) || WATCH.has(old.phase)) {
|
||||
const reason = typeof r.reason === 'string' && r.reason.trim() ? r.reason.trim().slice(0, 160) : undefined;
|
||||
out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase, kind: releaseKind(r.phase), ...(reason ? { reason } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
|
@ -40,3 +56,55 @@ export function crashCrossed(prev: HealthSnapshot | null | undefined, next: Heal
|
|||
for (const k of ['crash', 'anr'] as const) if (now[k].tone === 'bad' && was[k].tone !== 'bad') out.push(k);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- when a notification may show: kind toggle, quiet hours, mute ----
|
||||
|
||||
export interface QuietHours { enabled: boolean; from: string; to: string }
|
||||
export interface NotifyPrefs { reviewState: boolean; rejected: boolean; approved: boolean; live: boolean; lowReviews: boolean; crash: boolean; quietHours: QuietHours }
|
||||
export type NoticeKind = ReleaseKind | 'lowReviews' | 'crash' | 'test';
|
||||
|
||||
/** The toggle of a notification kind ("halted" shares the crash toggle: both are "something is going wrong"). */
|
||||
export function kindEnabled(n: NotifyPrefs, kind: NoticeKind): boolean {
|
||||
switch (kind) {
|
||||
case 'rejected': return n.rejected;
|
||||
case 'approved': return n.approved;
|
||||
case 'live': return n.live;
|
||||
case 'halted': case 'crash': return n.crash;
|
||||
case 'state': return n.reviewState;
|
||||
case 'lowReviews': return n.lowReviews;
|
||||
case 'test': return true;
|
||||
}
|
||||
}
|
||||
|
||||
const minutesOf = (hhmm: string): number | null => {
|
||||
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(hhmm);
|
||||
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
|
||||
};
|
||||
|
||||
/** True inside the quiet window (it may cross midnight: 23:00-08:00). Off, equal ends or a bad time = never quiet. */
|
||||
export function inQuietHours(q: QuietHours, now: Date): boolean {
|
||||
if (!q.enabled) return false;
|
||||
const from = minutesOf(q.from), to = minutesOf(q.to);
|
||||
if (from === null || to === null || from === to) return false;
|
||||
const cur = now.getHours() * 60 + now.getMinutes();
|
||||
return from < to ? cur >= from && cur < to : cur >= from || cur < to;
|
||||
}
|
||||
|
||||
/** "Mute for 1 hour": muted while `muteUntil` (epoch ms) is in the future. */
|
||||
export const isMuted = (muteUntil: number, now = Date.now()): boolean => muteUntil > now;
|
||||
|
||||
/** Whether a notification of `kind` may show now. A test notification ignores quiet hours and mute (it checks delivery). */
|
||||
export function mayNotify(n: NotifyPrefs, kind: NoticeKind, o: { now?: Date; muteUntil?: number } = {}): boolean {
|
||||
if (kind === 'test') return true;
|
||||
const now = o.now ?? new Date();
|
||||
return kindEnabled(n, kind) && !inQuietHours(n.quietHours, now) && !isMuted(o.muteUntil ?? 0, now.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay before the next background pass: the interval with +-10% jitter, doubled for every failure in a row (at most
|
||||
* x16, capped at 2 hours before the jitter). `rand` is in [0, 1).
|
||||
*/
|
||||
export function nextReadDelay(everyMinutes: number, failures: number, rand = Math.random()): number {
|
||||
const base = everyMinutes * 60_000 * 2 ** Math.min(Math.max(failures, 0), 4);
|
||||
return Math.round(Math.min(base, 2 * 3_600_000) * (0.9 + rand * 0.2));
|
||||
}
|
||||
|
|
|
|||
48
apps/desktop/src/lib/tray.ts
Normal file
48
apps/desktop/src/lib/tray.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// What the tray shows: one short state per app and store, the tooltip counts and the attention flag. Pure; the host
|
||||
// (src-tauri/src/tray.rs) only draws it.
|
||||
import type { AppStatus } from '../api';
|
||||
import type { ReleasePhase } from '../../../../cli/lib/desktop-protocol';
|
||||
|
||||
export interface TrayApp { key: string; name: string; state: string }
|
||||
export interface TrayLabels { open: string; readNow: string; apps: string; noApps: string; mute: string; unmute: string; update: string; settings: string; quit: string }
|
||||
export interface TraySummary { tooltip: string; attention: number; muted: boolean; apps: TrayApp[]; labels: TrayLabels }
|
||||
|
||||
type Phase = ReleasePhase;
|
||||
|
||||
/** What matters most first: a rejection, a halt, a review, an approval, a rollout, live. */
|
||||
const RANK: Record<string, number> = { rejected: 0, halted: 1, review: 2, sent: 2, approved: 3, rollout: 4, live: 5, released: 6 };
|
||||
|
||||
/** The release of each store that the tray reports: the one with the most pressing phase (testing / draft only when nothing else). */
|
||||
export function trayPhases(s: AppStatus | undefined): { ios?: Phase; android?: Phase } {
|
||||
const out: { ios?: Phase; android?: Phase } = {};
|
||||
for (const r of s?.release.all ?? []) {
|
||||
const cur = out[r.store];
|
||||
if (!cur || (RANK[r.phase] ?? 9) < (RANK[cur] ?? 9)) out[r.store] = r.phase;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True for a phase that needs the owner (rejected, halted). */
|
||||
export const needsYou = (p: string | undefined): boolean => p === 'rejected' || p === 'halted';
|
||||
export const inReview = (p: string | undefined): boolean => p === 'review' || p === 'sent';
|
||||
|
||||
/**
|
||||
* Builds the summary the host shows. `phaseText` gives the words of a phase, `text` the tray strings (all i18n by the
|
||||
* caller). The attention count is the number of apps with a rejected or halted release; the tooltip counts apps.
|
||||
*/
|
||||
export function traySummary(
|
||||
apps: { key: string; name: string; status: AppStatus | undefined }[], muted: boolean,
|
||||
tr: { phaseText: (p: string) => string; store: (s: 'ios' | 'android') => string; tipReview: (n: number) => string; tipAttention: (n: number) => string; muted: string; none: string; labels: TrayLabels },
|
||||
): TraySummary {
|
||||
let review = 0, attention = 0;
|
||||
const list: TrayApp[] = [];
|
||||
for (const a of apps) {
|
||||
const ph = trayPhases(a.status);
|
||||
if (inReview(ph.ios) || inReview(ph.android)) review++;
|
||||
if (needsYou(ph.ios) || needsYou(ph.android)) attention++;
|
||||
const parts = (['ios', 'android'] as const).filter(s => ph[s]).map(s => `${tr.store(s)}: ${tr.phaseText(ph[s]!)}`);
|
||||
list.push({ key: a.key, name: a.name, state: parts.length ? parts.join(' · ') : tr.none });
|
||||
}
|
||||
const bits = [review ? tr.tipReview(review) : '', attention ? tr.tipAttention(attention) : '', muted ? tr.muted : ''].filter(Boolean);
|
||||
return { tooltip: ['storebound', ...bits].join(' · '), attention, muted, apps: list, labels: tr.labels };
|
||||
}
|
||||
|
|
@ -34,6 +34,18 @@ export function parseStart(v: string, apps: string[]): Route | null {
|
|||
return tab ? { screen: screen as WorkspaceScreen, section: tab } : { screen: screen as WorkspaceScreen };
|
||||
}
|
||||
|
||||
/** A route sent as JSON by the host (tray menu, notification target); anything that is not a known route is null. */
|
||||
export function parseRouteJson(v: string): Route | null {
|
||||
let o: unknown;
|
||||
try { o = JSON.parse(v); } catch { return null; }
|
||||
if (!o || typeof o !== 'object') return null;
|
||||
const r = o as Record<string, unknown>;
|
||||
if (r.screen === 'portfolio') return { screen: 'portfolio' };
|
||||
if (r.screen === 'app') return typeof r.appId === 'string' && r.appId ? { screen: 'app', appId: r.appId, tab: APP_TABS.includes(r.tab as AppTab) ? r.tab as AppTab : 'releases' } : null;
|
||||
if (typeof r.screen === 'string' && (WORKSPACE as readonly string[]).includes(r.screen)) return typeof r.section === 'string' ? { screen: r.screen as WorkspaceScreen, section: r.section } : { screen: r.screen as WorkspaceScreen };
|
||||
return null;
|
||||
}
|
||||
|
||||
export const sameRoute = (a: Route, b: Route): boolean => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
export interface Nav { route: Route; go: (r: Route) => void; openPalette: () => void; openWelcome: () => void }
|
||||
|
|
|
|||
|
|
@ -167,9 +167,20 @@ export function Settings({ onTheme: _onTheme, section }: { onTheme: () => void;
|
|||
|
||||
<Card id="set-notifications" title={t('set.notifications')} icon="bell" sub={t('set.notificationsSub')} className="set-card">
|
||||
<div className="form">
|
||||
<Toggle id="n-rejected" label={t('set.nRejected')} hint={t('set.nRejectedHint')} checked={s.notify.rejected} onChange={v => set(x => ({ ...x, notify: { ...x.notify, rejected: v } }))} />
|
||||
<Toggle id="n-approved" label={t('set.nApproved')} hint={t('set.nApprovedHint')} checked={s.notify.approved} onChange={v => set(x => ({ ...x, notify: { ...x.notify, approved: v } }))} />
|
||||
<Toggle id="n-live" label={t('set.nLive')} hint={t('set.nLiveHint')} checked={s.notify.live} onChange={v => set(x => ({ ...x, notify: { ...x.notify, live: v } }))} />
|
||||
<Toggle id="n-review" label={t('set.nReview')} hint={t('set.nReviewHint')} checked={s.notify.reviewState} onChange={v => set(x => ({ ...x, notify: { ...x.notify, reviewState: v } }))} />
|
||||
<Toggle id="n-low" label={t('set.nLow')} hint={t('set.nLowHint')} checked={s.notify.lowReviews} onChange={v => set(x => ({ ...x, notify: { ...x.notify, lowReviews: v } }))} />
|
||||
<Toggle id="n-crash" label={t('set.nCrash')} hint={t('set.nCrashHint')} checked={s.notify.crash} onChange={v => set(x => ({ ...x, notify: { ...x.notify, crash: v } }))} />
|
||||
<Toggle id="n-quiet" label={t('set.quiet')} hint={t('set.quietHint')} checked={s.notify.quietHours.enabled} onChange={v => set(x => ({ ...x, notify: { ...x.notify, quietHours: { ...x.notify.quietHours, enabled: v } } }))} />
|
||||
{s.notify.quietHours.enabled && <div className="row" style={{ gap: 12 }}>
|
||||
<label className="field"><span className="legend">{t('set.quietFrom')}</span><input type="time" className="input" style={{ width: 120 }} value={s.notify.quietHours.from} onChange={e => { const v = e.target.value; if (v) set(x => ({ ...x, notify: { ...x.notify, quietHours: { ...x.notify.quietHours, from: v } } })); }} /></label>
|
||||
<label className="field"><span className="legend">{t('set.quietTo')}</span><input type="time" className="input" style={{ width: 120 }} value={s.notify.quietHours.to} onChange={e => { const v = e.target.value; if (v) set(x => ({ ...x, notify: { ...x.notify, quietHours: { ...x.notify.quietHours, to: v } } })); }} /></label>
|
||||
</div>}
|
||||
{d.muteUntil > Date.now() && <p className="small muted" role="status">{t('set.muted', { until: new Date(d.muteUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) })}</p>}
|
||||
<p className="small muted">{t('set.clickNote')}</p>
|
||||
<div className="actions"><button className="btn btn-sm" onClick={() => d.setMute(d.muteUntil > Date.now() ? 0 : 60)}><Icon name="bell" />{d.muteUntil > Date.now() ? t('set.unmute', { until: new Date(d.muteUntil).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : t('set.muteHour')}</button></div>
|
||||
<div className="actions"><button className="btn btn-sm" onClick={() => api.notify(t('set.testTitle'), t('set.testBody')).catch(e => setError(errorText(e)))}><Icon name="bell" />{t('set.testNotify')}</button></div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -4,18 +4,20 @@
|
|||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type Account, type AccountCheck, api, type AppInfo, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, errorText, newJob, onDownload,
|
||||
onProgress, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult,
|
||||
onProgress, onTrayAction, type PortfolioResult, type Project, reads, type Settings, type SidecarStatus, type Studio, type ToolResult,
|
||||
} from './api';
|
||||
import { t } from './i18n';
|
||||
import { buildApps, NO_OVERRIDES, type Overrides, type UnifiedApp } from './lib/identity';
|
||||
import { isError, type HealthSnapshot, type Review } from './lib/model';
|
||||
import { crashCrossed, newLowReviews, releaseChanges } from './lib/notify';
|
||||
import { crashCrossed, mayNotify, type NoticeKind, newLowReviews, nextReadDelay, type ReleaseChange, releaseChanges } from './lib/notify';
|
||||
import { traySummary } from './lib/tray';
|
||||
import { setCrashContext } from './lib/crash';
|
||||
import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize';
|
||||
import { keepLines, progressOf } from './lib/progress';
|
||||
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
|
||||
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
|
||||
import { fixHint } from './lib/views';
|
||||
import type { Route } from './nav';
|
||||
import { withLastGood } from '../../../cli/lib/train';
|
||||
import { failureText, usable } from './lib/results';
|
||||
|
||||
|
|
@ -92,12 +94,17 @@ export interface Data {
|
|||
onRead: (key: string, fn: (e: ReadEvent) => void) => () => void;
|
||||
isReading: (key: string) => boolean;
|
||||
/** Reads everything of every app that is past its TTL (force: everything). */
|
||||
readAll: (force?: boolean) => Promise<void>;
|
||||
readAll: (force?: boolean) => Promise<boolean>;
|
||||
/** Notifications are muted until this time (epoch ms; 0 = not muted). */
|
||||
muteUntil: number;
|
||||
/** Mutes notifications for `minutes` (0 = unmute). */
|
||||
setMute: (minutes: number) => void;
|
||||
/** After a write: the app's reads are read again. */
|
||||
invalidate: (target: string) => void;
|
||||
/** The app on screen (its reads go first). */
|
||||
setFocus: (key: string | null) => void;
|
||||
refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise<void>;
|
||||
/** True when the read succeeded. */
|
||||
refreshPortfolio: (live?: boolean, quiet?: boolean) => Promise<boolean>;
|
||||
/** Reads the apps of one account (or all) from the stores. */
|
||||
importApps: (account?: string) => Promise<void>;
|
||||
checkAccount: (id: string) => Promise<void>;
|
||||
|
|
@ -128,6 +135,19 @@ const lastLine = (s: string): string => s.trim().split('\n').filter(Boolean).at(
|
|||
/** A failure line with how to fix it, when the error is a known one. */
|
||||
const withHint = (msg: string | undefined): string | undefined => { const h = fixHint(msg); return h && msg ? `${msg} · ${t(h)}` : msg; };
|
||||
|
||||
const storeName = (s: 'ios' | 'android'): string => (s === 'ios' ? 'App Store' : 'Google Play');
|
||||
/** Title and body of the notification of one release change (by kind; a rejection adds the store's reason when it has one). */
|
||||
export function releaseText(c: ReleaseChange): [string, string] {
|
||||
const v = { name: c.name, store: storeName(c.store), version: c.version };
|
||||
switch (c.kind) {
|
||||
case 'rejected': return [t('notify.rejectedTitle', v), [t('notify.rejectedBody', v), c.reason].filter(Boolean).join(' ')];
|
||||
case 'approved': return [t('notify.approvedTitle', v), t('notify.approvedBody', v)];
|
||||
case 'live': return [t('notify.liveTitle', v), t('notify.liveBody', v)];
|
||||
case 'halted': return [t('notify.haltedTitle', v), t('notify.haltedBody', v)];
|
||||
default: return [t('notify.releaseTitle', v), t('notify.releaseBody', { ...v, phase: t(`phase.${c.to}` as 'phase.live') })];
|
||||
}
|
||||
}
|
||||
|
||||
export function DataProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [info, setInfo] = useState<AppInfo | null>(null);
|
||||
|
|
@ -153,6 +173,21 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const settingsRef = useRef(settings);
|
||||
settingsRef.current = settings;
|
||||
|
||||
// Mute for a while (the tray's "Mute notifications for 1 hour"): kept across restarts
|
||||
const [muteUntil, setMuteUntil] = useState<number>(() => { try { return Number(localStorage.getItem('storebound.muteUntil')) || 0; } catch { return 0; } });
|
||||
const muteRef = useRef(muteUntil);
|
||||
muteRef.current = muteUntil;
|
||||
const setMute = useCallback((minutes: number) => {
|
||||
const until = minutes > 0 ? Date.now() + minutes * 60_000 : 0;
|
||||
setMuteUntil(until); muteRef.current = until;
|
||||
try { localStorage.setItem('storebound.muteUntil', String(until)); } catch { /* private mode: mute lasts until the window closes */ }
|
||||
}, []);
|
||||
/** One desktop notification: its kind's toggle, quiet hours and mute decide; `route` is where the window opens when it is next shown from the tray. */
|
||||
const notice = useCallback((kind: NoticeKind, title: string, body: string, route?: Route): void => {
|
||||
if (!mayNotify(settingsRef.current.notify, kind, { muteUntil: muteRef.current })) return;
|
||||
api.notify(title, body, route ? JSON.stringify(route) : undefined).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setToasts(ts => ts.filter(x => x.id !== id));
|
||||
const tm = timers.current.get(id);
|
||||
|
|
@ -196,7 +231,7 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const appsRef = useRef(apps);
|
||||
appsRef.current = apps;
|
||||
|
||||
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false) => {
|
||||
const refreshPortfolio = useCallback(async (liveRead = false, quiet = false): Promise<boolean> => {
|
||||
const job = newJob(liveRead ? 'portfolio-live' : 'portfolio');
|
||||
setBusy(liveRead ? 'live' : 'offline'); setPortfolioError(null);
|
||||
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
|
||||
|
|
@ -210,8 +245,11 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
if (liveRead) {
|
||||
const changes = releaseChanges(liveRef.current?.value, r);
|
||||
setLive({ value: r, at: r.at });
|
||||
if (settingsRef.current.notify.reviewState) for (const c of changes.slice(0, 5))
|
||||
api.notify(t('notify.releaseTitle', { name: c.name }), t('notify.releaseBody', { store: c.store === 'ios' ? 'App Store' : 'Google Play', version: c.version, phase: t(`phase.${c.to}` as 'phase.live') })).catch(() => {});
|
||||
for (const c of changes.slice(0, 5)) {
|
||||
const [title, body] = releaseText(c);
|
||||
const app = appsRef.current.find(a => a.target === c.project);
|
||||
notice(c.kind, title, body, app ? { screen: 'app', appId: app.key, tab: 'releases' } : undefined);
|
||||
}
|
||||
const failed = r.apps.filter(a => a.storeErrors.ios || a.storeErrors.android).length;
|
||||
if (tid) updateToast(tid, failed ? { kind: 'info', title: t('toast.readDonePartial'), detail: t('toast.readDonePartialDetail', { count: failed }), progress: undefined, job: undefined }
|
||||
: { kind: 'ok', title: t('toast.readDone'), detail: t('toast.readDoneDetail', { count: r.apps.length }), progress: undefined, job: undefined });
|
||||
|
|
@ -220,8 +258,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const msg = errorText(e);
|
||||
setPortfolioError(msg);
|
||||
if (tid) updateToast(tid, { kind: /cancel/i.test(msg) ? 'info' : 'bad', title: /cancel/i.test(msg) ? t('toast.cancelled') : t('toast.readFailed'), detail: msg, progress: undefined, job: undefined });
|
||||
return false;
|
||||
} finally { setBusy(null); }
|
||||
}, [toast, updateToast]);
|
||||
return true;
|
||||
}, [toast, updateToast, notice]);
|
||||
|
||||
// ---- background reader: one scheduler for every store read of the window (lib/scheduler.ts) ----
|
||||
const schedRef = useRef<ReadScheduler | null>(null);
|
||||
|
|
@ -252,22 +292,42 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
return c;
|
||||
};
|
||||
const statusRef = useRef<(a: UnifiedApp) => AppStatus | undefined>(() => undefined);
|
||||
const lastData = useRef(new Map<string, unknown>());
|
||||
|
||||
const schedule = useCallback((target: string, method: string, args: Record<string, unknown>, priority: Priority): Promise<ToolResult> => {
|
||||
const key = readKey(target, method, args);
|
||||
const app = appsRef.current.find(a => a.target === target);
|
||||
const pf = platformsOf(app, app ? statusRef.current(app) : undefined);
|
||||
const lanes = lanesOf(method, pf);
|
||||
const p = sched.enqueue<ToolResult>({ key, lanes, accounts: accountsOf(app, lanes), priority, run: () => api.call<ToolResult>(method, target, { args }, newJob(method)) });
|
||||
// reviews and health: compared with the previous read here (not in a card), so the tray app notices them with no window open
|
||||
const watch = method === 'reviews.list' || method === 'health';
|
||||
const run = async (): Promise<ToolResult> => {
|
||||
const before = watch ? (lastData.current.has(key) ? lastData.current.get(key) : (await api.cached<ToolResult>(method, target, args).catch(() => null))?.value.data ?? null) : null;
|
||||
const result = await api.call<ToolResult>(method, target, { args }, newJob(method));
|
||||
if (watch && usable(method, result)) {
|
||||
notifyAfterRead(method, before, result.data, notice, app?.name, app?.key);
|
||||
lastData.current.set(key, result.data);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const p = sched.enqueue<ToolResult>({ key, lanes, accounts: accountsOf(app, lanes), priority, run });
|
||||
emitRead(key, { kind: 'start' });
|
||||
p.then(result => emitRead(key, { kind: 'done', result }), e => emitRead(key, { kind: 'error', error: errorText(e) }));
|
||||
return p;
|
||||
}, [sched, emitRead]);
|
||||
}, [sched, emitRead, notice]);
|
||||
|
||||
const readAll = useCallback(async (force = false) => {
|
||||
if (!force && !settingsRef.current.refresh.autoRead) return;
|
||||
if (force || isStale('portfolio.status', liveRef.current?.at)) {
|
||||
sched.enqueue({ key: '*\u001fportfolio.status', lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) }).catch(() => {});
|
||||
/**
|
||||
* Reads everything past its TTL. The portfolio read is a LIVE one (refreshPortfolio(true)): it is what releaseChanges
|
||||
* compares, so a rejection is seen even when no screen asked. Resolves when that read is done: false when it failed.
|
||||
*/
|
||||
const readAll = useCallback(async (force = false): Promise<boolean> => {
|
||||
if (!force && !settingsRef.current.refresh.autoRead) return true;
|
||||
let ok = true;
|
||||
let portfolioRead: Promise<void> | null = null;
|
||||
const pkey = '*\u001fportfolio.status';
|
||||
if (!sched.has(pkey) && (force || isStale('portfolio.status', liveRef.current?.at))) {
|
||||
portfolioRead = sched.enqueue<boolean>({ key: pkey, lanes: ['apple', 'play'], accounts: [], priority: 1, run: () => refreshPortfolio(true, true) })
|
||||
.then(r => { ok = r !== false; }, () => { ok = false; });
|
||||
}
|
||||
const list = [...appsRef.current].filter(a => !a.ignored && !a.project?.missing)
|
||||
.sort((a, b) => Number(b.key === focusRef.current) - Number(a.key === focusRef.current));
|
||||
|
|
@ -283,6 +343,8 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
schedule(a.target, r.method, r.args, a.key === focusRef.current ? 0 : 2).catch(() => {});
|
||||
}
|
||||
}
|
||||
await portfolioRead;
|
||||
return ok;
|
||||
}, [sched, schedule, refreshPortfolio]);
|
||||
|
||||
const invalidate = useCallback((target: string) => {
|
||||
|
|
@ -353,27 +415,48 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
useEffect(() => { if (projects.length) refreshPortfolio(false); }, [projects.length, refreshPortfolio]);
|
||||
|
||||
// Settings > Reading: everything past its TTL is read in the background on start, when the window shows again after
|
||||
// a while, and every N minutes while it is visible (the scheduler keeps it inside the stores' limits).
|
||||
// a while, and every N minutes (with a little jitter) EVEN WHILE THE WINDOW IS HIDDEN: the app lives in the tray, and
|
||||
// that is where a rejection has to be noticed. One pass at a time; after a failed pass the wait doubles (nextReadDelay).
|
||||
const launched = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || launched.current || !apps.length) return;
|
||||
launched.current = true;
|
||||
if (settings.refresh.autoRead) readAll(false).catch(() => {});
|
||||
}, [ready, apps.length, settings.refresh.autoRead, readAll]);
|
||||
const passing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!settings.refresh.autoRead) return;
|
||||
let hiddenAt = 0;
|
||||
const pass = async (): Promise<boolean> => {
|
||||
if (passing.current) return true;
|
||||
passing.current = true;
|
||||
try { return await readAll(false); } catch { return false; } finally { passing.current = false; }
|
||||
};
|
||||
const vis = () => {
|
||||
if (document.hidden) { hiddenAt = Date.now(); return; }
|
||||
if (hiddenAt && Date.now() - hiddenAt > 60_000) readAll(false).catch(() => {});
|
||||
if (hiddenAt && Date.now() - hiddenAt > 60_000) pass().catch(() => {});
|
||||
hiddenAt = 0;
|
||||
};
|
||||
document.addEventListener('visibilitychange', vis);
|
||||
const m = settings.refresh.everyMinutes;
|
||||
const tm = m ? setInterval(() => { if (!document.hidden) readAll(false).catch(() => {}); }, m * 60_000) : null;
|
||||
return () => { document.removeEventListener('visibilitychange', vis); if (tm) clearInterval(tm); };
|
||||
let tm: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false, failures = 0;
|
||||
const next = () => {
|
||||
if (stopped || !m) return;
|
||||
tm = setTimeout(async () => { failures = (await pass()) ? 0 : failures + 1; next(); }, nextReadDelay(m, failures));
|
||||
};
|
||||
next();
|
||||
return () => { stopped = true; document.removeEventListener('visibilitychange', vis); if (tm) clearTimeout(tm); };
|
||||
}, [settings.refresh.autoRead, settings.refresh.everyMinutes, readAll]);
|
||||
|
||||
// the tray: tooltip, attention icon and the apps list follow every read; its menu asks for a read or the mute
|
||||
const readRef = useRef(readAll);
|
||||
readRef.current = readAll;
|
||||
useEffect(() => {
|
||||
const un = onTrayAction(a => { if (a === 'read') readRef.current(true).catch(() => {}); else setMute(isMutedNow() ? 0 : 60); });
|
||||
return () => { un.then(f => f()); };
|
||||
}, [setMute]);
|
||||
const isMutedNow = () => muteRef.current > Date.now();
|
||||
// icons: cached first, then read (offline, cheap)
|
||||
useEffect(() => {
|
||||
for (const p of projects) {
|
||||
|
|
@ -419,14 +502,33 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const iconFor = useCallback((a: UnifiedApp) => (a.project ? icons[a.project.id] ?? a.storeIcon : a.storeIcon), [icons]);
|
||||
const appByKey = useCallback((key: string) => apps.find(a => a.key === key), [apps]);
|
||||
statusRef.current = statusFor;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const muted = muteUntil > Date.now();
|
||||
const sum = traySummary(
|
||||
apps.filter(a => !a.ignored).map(a => ({ key: a.key, name: a.name, status: liveFor(a) ?? statusFor(a) })), muted,
|
||||
{ phaseText: p => t(`phase.${p}` as 'phase.live'), store: s => (s === 'ios' ? 'App Store' : 'Play'), none: t('tray.noState'), muted: t('tray.mutedTip'),
|
||||
tipReview: n => t('tray.tipReview', { count: n }), tipAttention: n => t('tray.tipAttention', { count: n }),
|
||||
labels: { open: t('tray.open'), readNow: t('tray.readNow'), apps: t('tray.apps'), noApps: t('tray.noApps'), mute: t('tray.mute'), unmute: t('tray.unmute'), update: t('tray.update'), settings: t('tray.settings'), quit: t('tray.quit') } },
|
||||
);
|
||||
api.trayUpdate(sum).catch(() => {});
|
||||
}, [ready, apps, live, portfolio, muteUntil, statusFor, liveFor]);
|
||||
// the mute ends by itself: the tray menu follows
|
||||
useEffect(() => {
|
||||
if (muteUntil <= Date.now()) return;
|
||||
const tm = setTimeout(() => setMuteUntil(0), muteUntil - Date.now() + 500);
|
||||
return () => clearTimeout(tm);
|
||||
}, [muteUntil]);
|
||||
|
||||
const readerState = useMemo<ReaderState>(() => ({ ...reader, autoRead: settings.refresh.autoRead }), [reader, settings.refresh.autoRead]);
|
||||
|
||||
const value = useMemo<Data>(() => ({
|
||||
ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
importBusy, icons, reader: readerState, schedule, onRead, isReading, readAll, invalidate, setFocus,
|
||||
importBusy, icons, reader: readerState, muteUntil, setMute, schedule, onRead, isReading, readAll, invalidate, setFocus,
|
||||
refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
toasts, toast, updateToast, dismiss, cancelJob,
|
||||
}), [readerState, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
}), [readerState, muteUntil, setMute, schedule, onRead, isReading, readAll, invalidate, setFocus, ready, info, projects, accounts, studios, overrides, settings, storeApps, accountHealth, apps, sidecar, sidecarError, portfolio, live, portfolioBusy, portfolioError,
|
||||
importBusy, icons, refreshPortfolio, importApps, checkAccount, reload, updateOverrides, updateSettings, statusOf, liveOf, statusFor, liveFor, iconFor, appByKey,
|
||||
toasts, toast, updateToast, dismiss, cancelJob]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
|
|
@ -481,16 +583,19 @@ export function useWrite(projectId: string | null) {
|
|||
return { busy, run, isBusy: (method: string, args: Record<string, unknown> = {}) => busy === `${method}:${JSON.stringify(args)}` };
|
||||
}
|
||||
|
||||
type Notice = (kind: NoticeKind, title: string, body: string, route?: Route) => void;
|
||||
|
||||
/** Notifications a fresh read may trigger (Settings > Notifications), compared with the read it replaces. */
|
||||
function notifyAfterRead(method: string, prev: unknown, next: unknown, settings: Settings, appName: string | undefined): void {
|
||||
function notifyAfterRead(method: string, prev: unknown, next: unknown, notice: Notice, appName: string | undefined, appKey: string | undefined): void {
|
||||
const name = appName ?? '';
|
||||
if (method === 'reviews.list' && settings.notify.lowReviews && Array.isArray(next)) {
|
||||
const route = (tab: 'reviews' | 'health'): Route | undefined => (appKey ? { screen: 'app', appId: appKey, tab } : undefined);
|
||||
if (method === 'reviews.list' && Array.isArray(next)) {
|
||||
const fresh = newLowReviews(prev as Review[] | null, next as Review[]);
|
||||
if (fresh.length) api.notify(t('notify.lowTitle', { name, count: fresh.length }), `${'★'.repeat(fresh[0]!.rating)} ${fresh[0]!.title ?? ''} ${fresh[0]!.body}`.trim()).catch(() => {});
|
||||
if (fresh.length) notice('lowReviews', t('notify.lowTitle', { name, count: fresh.length }), `${'★'.repeat(fresh[0]!.rating)} ${fresh[0]!.title ?? ''} ${fresh[0]!.body}`.trim(), route('reviews'));
|
||||
}
|
||||
if (method === 'health' && settings.notify.crash && next && typeof next === 'object') {
|
||||
if (method === 'health' && next && typeof next === 'object') {
|
||||
const crossed = crashCrossed(prev as HealthSnapshot | null, next as HealthSnapshot);
|
||||
if (crossed.length && !isError((next as HealthSnapshot).play)) api.notify(t('notify.crashTitle', { name }), crossed.map(k => (k === 'crash' ? t('health.crash') : t('health.anr'))).join(', ')).catch(() => {});
|
||||
if (crossed.length && !isError((next as HealthSnapshot).play)) notice('crash', t('notify.crashTitle', { name }), crossed.map(k => (k === 'crash' ? t('health.crash') : t('health.anr'))).join(', '), route('health'));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -528,7 +633,6 @@ export function useRead<T>(target: string, method: string, o: { network: boolean
|
|||
else {
|
||||
const r = e.result;
|
||||
if (usable(method, r)) {
|
||||
notifyAfterRead(method, dataRef.current, r.data, dRef.current.settings, o.appName);
|
||||
set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, stale: false, error: null, command: r.command });
|
||||
} else set(x => ({ ...x, loading: false, error: oneLine(failureText(r)), command: r.command }));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue