// The context menu of one app (sidebar row, portfolio card): open, a tab, the store consoles, the folder, ids, // reads, and the ignore actions the app view and the Ignored screen already have. import { api, errorText } from '../api'; import { t } from '../i18n'; import { copyText } from '../lib/crash'; import { withIgnored, withSideIgnored, withUnpair, type UnifiedApp } from '../lib/identity'; import { setShowIgnored } from '../lib/show-ignored'; import { type AppTab, type Nav, TAB_ICON, TAB_KEY, tabsFor } from '../nav'; import type { Data } from '../store'; import type { MenuInput, MenuItem } from './menu-logic'; export const ASC_APP = (id: string | null | undefined): string => (id ? `https://appstoreconnect.apple.com/apps/${encodeURIComponent(id)}/distribution` : 'https://appstoreconnect.apple.com/apps'); export const PLAY_HOME = 'https://play.google.com/console/'; export function openUrl(d: Data, url: string): void { api.openExternal(url).catch(e => d.toast({ kind: 'bad', title: t('link.openFailed'), detail: errorText(e) })); } export function copyToast(d: Data, text: string): void { void copyText(text).then(ok => d.toast(ok ? { kind: 'ok', title: t('cm.copied') } : { kind: 'bad', title: t('health.copyFailed') })); } export function appMenu(app: UnifiedApp, d: Data, nav: Nav, opts: { showIgnored: boolean }): MenuInput { const ios = app.ios?.app ?? null, android = app.android?.app ?? null; const p = app.project; const go = (tab: AppTab) => nav.go({ screen: 'app', appId: app.key, tab }); const sides = (['ios', 'android'] as const).filter(k => app[k] || app.ignoredStores.includes(k)); const storeName = (k: 'ios' | 'android') => (k === 'ios' ? 'App Store' : 'Google Play'); return [ { label: t('cm.open'), icon: 'arrowRight', onSelect: () => go('releases') }, { label: t('cm.openTab'), icon: 'grid', submenu: tabsFor(!!p).map(tab => ({ label: t(TAB_KEY[tab]), icon: TAB_ICON[tab], onSelect: () => go(tab) })) }, 'sep', (ios || android) && { label: t('cm.openConsole'), icon: 'external', submenu: [ ios && { label: 'App Store Connect', icon: 'external', onSelect: () => openUrl(d, ASC_APP(ios.id)) }, android && { label: 'Google Play Console', icon: 'external', onSelect: () => openUrl(d, PLAY_HOME) }, ] }, p && !p.missing && { label: t('cm.openFolder'), icon: 'folder', onSelect: () => { api.reveal(p.id).catch(e => d.toast({ kind: 'bad', title: t('cm.folderFailed'), detail: errorText(e) })); } }, { label: t('cm.copy'), icon: 'copy', submenu: [ { label: t('cm.copyName'), icon: 'text', onSelect: () => copyToast(d, app.name) }, ios && { label: t('cm.copyBundleId'), icon: 'tag', shortcut: ios.bundleId, onSelect: () => copyToast(d, ios.bundleId) }, android && { label: t('cm.copyPackage'), icon: 'tag', shortcut: android.id, onSelect: () => copyToast(d, android.id) }, ios && { label: t('cm.copyAscId'), icon: 'key', onSelect: () => copyToast(d, ios.id) }, p && { label: t('cm.copyPath'), icon: 'folder', onSelect: () => copyToast(d, p.path) }, ] }, 'sep', { label: t('cm.refreshReads'), icon: 'refresh', disabled: app.ignored ? t('cm.ignoredNoReads') : false, onSelect: () => { d.invalidate(app.target); d.toast({ kind: 'info', title: t('cm.refreshing', { name: app.name }) }); } }, 'sep', app.ignored ? { label: t('app.unignore'), icon: 'eye', onSelect: () => { void d.updateOverrides(o => withIgnored(o, app.ignoreKeys, false)); } } : { label: t('cm.ignore'), icon: 'eyeOff', submenu: [ { label: t('app.ignore'), icon: 'eyeOff', danger: true, onSelect: () => { void d.updateOverrides(o => withIgnored(o, app.ignoreKeys, true)).then(() => { if (nav.route.screen === 'app' && nav.route.appId === app.key) nav.go({ screen: 'portfolio' }); }); } }, sides.length === 2 && 'sep', ...(sides.length === 2 ? sides.map(k => { const off = app.ignoredStores.includes(k); return { label: off ? t('app.unignoreSide', { store: storeName(k) }) : t('app.ignoreSide', { store: storeName(k) }), icon: off ? 'eye' as const : 'eyeOff' as const, onSelect: () => { void d.updateOverrides(o => withSideIgnored(o, app, k, !off)); } }; }) : []), ] }, !p && !!app.ios && !!app.android && { label: t('app.unpair'), icon: 'unlink', onSelect: () => { void d.updateOverrides(o => withUnpair(o, app.ios!.key, app.android!.key)).then(() => nav.go({ screen: 'portfolio' })); } }, { label: t('cm.showIgnored'), icon: 'filter', checked: opts.showIgnored, onSelect: () => setShowIgnored(!opts.showIgnored) }, ]; } /** "Open in App Store Connect / Play Console" for one store side of an app. */ export function consoleItem(d: Data, app: UnifiedApp, store: 'ios' | 'android'): MenuItem { return store === 'ios' ? { label: t('cm.openIn', { place: 'App Store Connect' }), icon: 'external', onSelect: () => openUrl(d, ASC_APP(app.ios?.app.id)) } : { label: t('cm.openIn', { place: 'Google Play Console' }), icon: 'external', onSelect: () => openUrl(d, PLAY_HOME) }; } /** Menu of one store release: copy version / build, open the console. */ export function releaseMenu(d: Data, app: UnifiedApp, store: 'ios' | 'android', version: string | null | undefined, build: string | number | null | undefined): MenuInput { return [ version && { label: t('cm.copyVersion'), icon: 'copy', shortcut: String(version), onSelect: () => copyToast(d, String(version)) }, build != null && build !== '' && { label: store === 'ios' ? t('cm.copyBuild') : t('cm.copyVersionCode'), icon: 'copy', shortcut: String(build), onSelect: () => copyToast(d, String(build)) }, 'sep', consoleItem(d, app, store), ]; } /** Both consoles of an app folder (App Store Connect with the app's id when the status knows it). */ export function projectConsoles(d: Data, ascAppId: string | null | undefined, ios: boolean, android: boolean): MenuInput { return [ ios && { label: t('cm.openIn', { place: 'App Store Connect' }), icon: 'external', onSelect: () => openUrl(d, ASC_APP(ascAppId)) }, android && { label: t('cm.openIn', { place: 'Google Play Console' }), icon: 'external', onSelect: () => openUrl(d, PLAY_HOME) }, ]; }