From e32d0772e9e2c7f7acd6cfad2bd3ac9e4574b01c Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 23 Oct 2026 09:46:09 +0800 Subject: [PATCH] Desktop: Reviews keyboard: J/K move, R reply, C copy, O open in the console, with a key hint --- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/tr.ts | 1 + apps/desktop/src/screens/tabs/Reviews.tsx | 31 ++++++++++++++++++++--- apps/desktop/src/styles/reviews.css | 2 ++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 492149c..2c96816 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -71,6 +71,7 @@ export const en = { 'pf.noShotsStore': "Store only: no app folder with screenshots", 'tabs.more': "{count} more tabs", 'tabs.moreShort': "More", + 'reviews.keysHint': "Keyboard: J / K next and previous, R reply, C copy, O open in the console", 'palette.gActions': 'Actions', 'palette.openApp': 'Open', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 1d73311..8159d4a 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -73,6 +73,7 @@ export const tr: Record = { 'pf.noShotsStore': "Yalnız mağazada: ekran görüntülü uygulama klasörü yok", 'tabs.more': "{count} sekme daha", 'tabs.moreShort': "Daha fazla", + 'reviews.keysHint': "Klavye: J / K sonraki ve önceki, R yanıtla, C kopyala, O konsolda aç", 'palette.gActions': 'İşlemler', 'palette.openApp': 'Aç', diff --git a/apps/desktop/src/screens/tabs/Reviews.tsx b/apps/desktop/src/screens/tabs/Reviews.tsx index a4b0407..aecc2df 100644 --- a/apps/desktop/src/screens/tabs/Reviews.tsx +++ b/apps/desktop/src/screens/tabs/Reviews.tsx @@ -2,7 +2,8 @@ // (.storebound/replies) with the send checks. Approving a draft is a local, confirmed write; sending stays in the // terminal (`storebound reviews send`): replies are public. import { type MenuInput, useContextMenu } from '../../ui/ContextMenu'; -import { consoleItem, copyToast } from '../../ui/app-menu'; +import { consoleItem, copyToast, openUrl, ASC_APP, playUrlFor } from '../../ui/app-menu'; +import { isEditable } from '../../ui/menu-logic'; import { useEffect, useMemo, useState } from 'react'; import type { DraftsResult, ReplyDraft } from '../../api'; import type { UnifiedApp } from '../../lib/identity'; @@ -12,7 +13,7 @@ import { draftName, filterReviews, type Review, type ReviewFilter, starStats } f import { useData, useRead, useWrite } from '../../store'; import { WriteButton } from '../../ui/WriteButton'; import { Icon, StoreGlyph } from '../../ui/icons'; -import { Bar, ErrorFoot, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit'; +import { Bar, ErrorFoot, Kbd, ReadControl, Seg, Skeleton, Stars } from '../../ui/kit'; import { EmptyState, Section, StatusPill, tintStyle, Toolbar, useTint } from '../../ui/ds'; import '../../styles/reviews.css'; import { exportName, reviewsCsv } from '../../lib/exports'; @@ -112,6 +113,29 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) { const cur = shown.find(r => `${r.store}:${r.id}` === sel) ?? shown.find(r => !r.reply && byFile.has(draftName(r))) ?? shown[0]; const changed = () => { drafts.refresh(); }; + // keyboard: j / k (or arrows in the list) move, r writes a reply (the draft box, or drafts when there are none), + // c copies the review, o opens it in its console + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey || isEditable(e.target as HTMLInputElement) || document.querySelector('.ds-scrim, .ds-menu')) return; + if (!cur) return; + const i = shown.indexOf(cur); + const go = (n: number) => { const x = shown[Math.max(0, Math.min(shown.length - 1, n))]; if (!x) return; const k = `${x.store}:${x.id}`; setSel(k); document.querySelector(`[data-rv="${CSS.escape(k)}"]`)?.scrollIntoView({ block: 'nearest' }); }; + const k = e.key.toLowerCase(); + if (k === 'j') { e.preventDefault(); go(i + 1); } + else if (k === 'k') { e.preventDefault(); go(i - 1); } + else if (k === 'c') { e.preventDefault(); copyToast(d, [cur.title, cur.body].filter(Boolean).join('\n\n')); } + else if (k === 'o') { e.preventDefault(); openUrl(d, cur.store === 'apple' ? ASC_APP(app.ios?.app.id) : playUrlFor(d, app)); } + else if (k === 'r' && !cur.reply) { + e.preventDefault(); + const box = document.querySelector('.rv2-detail textarea'); + if (box) box.focus(); + else if (project) void w.run({ method: 'reviews.draft', args: { days: 30 }, title: t('reviews.makeDrafts'), done: t('reviews.draftsMade'), after: changed }); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); useExports('reviews', [{ id: 'reviews-csv', label: t('export.reviews'), icon: 'message', kind: 'csv', personal: true, file: exportName(app.name, 'reviews'), disabled: list.data ? false : t('export.readFirst'), build: () => reviewsCsv(shown.length ? shown : all) }]); @@ -157,6 +181,7 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) { setF(x => ({ ...x, store }))} label={t('reviews.store')} options={[['all', t('reviews.all')], ['apple', 'App Store'], ['play', 'Google Play']]} /> + {t('reviews.shown', { count: shown.length })}
@@ -167,7 +192,7 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) { const dr = byFile.get(draftName(r)); const k = `${r.store}:${r.id}`; return ( -