Desktop: Reviews keyboard: J/K move, R reply, C copy, O open in the console, with a key hint

This commit is contained in:
CRTkafa 2026-10-23 09:46:09 +08:00
parent ef6651c8d2
commit e32d0772e9
4 changed files with 32 additions and 3 deletions

View file

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

View file

@ -73,6 +73,7 @@ export const tr: Record<keyof typeof en, string> = {
'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ç',

View file

@ -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<HTMLElement>(`[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<HTMLTextAreaElement>('.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 }) {
<Seg value={f.store} onChange={store => setF(x => ({ ...x, store }))} label={t('reviews.store')} options={[['all', t('reviews.all')], ['apple', 'App Store'], ['play', 'Google Play']]} />
<label className="check"><input type="checkbox" checked={f.unanswered} onChange={e => setF(x => ({ ...x, unanswered: e.target.checked }))} />{t('reviews.onlyUnanswered')}</label>
<span className="grow" />
<span className="rv2-keys" aria-label={t('reviews.keysHint')} title={t('reviews.keysHint')}><Kbd keys={['J']} /><Kbd keys={['K']} /><Kbd keys={['R']} /><Kbd keys={['C']} /><Kbd keys={['O']} /></span>
<span className="rv2-shown">{t('reviews.shown', { count: shown.length })}</span>
</Toolbar>
<div className="rv2">
@ -167,7 +192,7 @@ export function ReviewsTab({ app }: { app: UnifiedApp }) {
const dr = byFile.get(draftName(r));
const k = `${r.store}:${r.id}`;
return (
<button key={k} type="button" role="option" aria-selected={cur === r} className="rv2-card inbox-item" onClick={() => setSel(k)} {...cm(() => reviewMenu(r, k, dr))}>
<button key={k} type="button" role="option" data-rv={k} aria-selected={cur === r} className="rv2-card inbox-item" onClick={() => setSel(k)} {...cm(() => reviewMenu(r, k, dr))}>
<span className="rv2-c-top">
<Stars n={r.rating} />
<span className="rv2-c-date">{date(r.date)}</span>

View file

@ -79,3 +79,5 @@
.scr-reviews .rv2-draft-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.scr-reviews .rv2-draft .reply-edit { border-radius: var(--ds-r); min-height: 120px; }
.scr-reviews .rv2-count { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; font-size: var(--ds-t-micro); color: var(--muted); }
.scr-reviews .rv2-keys { display: inline-flex; gap: 3px; opacity: .75; }
@media (max-width: 1200px) { .scr-reviews .rv2-keys { display: none; } }