Skip to content

Commit 228a03c

Browse files
u
1 parent 780687d commit 228a03c

9 files changed

Lines changed: 239 additions & 122 deletions

File tree

‎src/components/inbox/conversation/conversation-view.tsx‎

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton"
99
import { AiSummary } from "@/components/ai/ai-summary"
1010
import { ApiClientError } from "@/lib/api-client"
1111
import { firstName } from "@/lib/inbox/format"
12+
import { keysFor, type ShortcutAction } from "@/lib/inbox/shortcuts"
1213
import type { ComposingKind, ConversationDetail } from "@/lib/inbox/types"
1314
import { cn } from "@/lib/utils"
1415
import { useThread } from "@/hooks/inbox/queries"
@@ -72,7 +73,7 @@ function TypingIndicator({ conversationId }: { conversationId: string }) {
7273
}
7374

7475
export function ConversationView({ id, box }: { id: string; box: string }) {
75-
const { slug, shortcutsEnabled } = useInbox()
76+
const { slug, shortcutsEnabled, shortcutScheme } = useInbox()
7677
const router = useRouter()
7778
const { find, run } = useConversationActions()
7879
const { data: thread, error, isPending } = useThread(slug, id)
@@ -104,23 +105,27 @@ export function ConversationView({ id, box }: { id: string; box: string }) {
104105
}, [title])
105106

106107
const writable = !!conv && conv.level !== "read" && conv.kind === "email"
108+
const k = (action: ShortcutAction) => keysFor(shortcutScheme, action)
107109
useHotkeys(
108110
[
109-
{ keys: ["Escape"], when: () => !inboxUI.get().selected.size, handler: () => cmd?.back() },
110-
{ keys: ["e"], when: () => writable, handler: () => cmd?.toggleStatus() },
111-
{ keys: ["s"], handler: () => cmd?.toggleStar() },
112-
{ keys: ["h"], when: () => writable, handler: () => inboxUI.set({ picker: "snooze" }) },
113-
{ keys: ["l"], when: () => writable, handler: () => inboxUI.set({ picker: "label" }) },
114-
{ keys: ["i"], when: () => writable, handler: () => inboxUI.set({ picker: "assign" }) },
115-
{ keys: ["m"], when: () => writable, handler: () => cmd?.assignToMe() },
116-
{ keys: ["u"], handler: () => cmd?.markUnread() },
117-
{ keys: ["#", "shift+#"], when: () => writable, handler: () => cmd?.toggleTrash() },
118-
{ keys: ["!", "shift+!"], when: () => writable, handler: () => cmd?.toggleSpam() },
119-
{ keys: ["r"], when: () => writable, handler: () => inboxUI.openComposer(id, "reply") },
120-
{ keys: ["a"], when: () => writable, handler: () => inboxUI.openComposer(id, "reply_all") },
121-
{ keys: ["f"], when: () => writable, handler: () => inboxUI.openComposer(id, "forward") },
122-
{ keys: ["c"], handler: () => inboxUI.openComposer(id, "comment") },
123-
{ keys: ["shift+p"], when: () => writable, handler: () => cmd?.togglePriority() },
111+
{ keys: k("back"), when: () => !inboxUI.get().selected.size, handler: () => cmd?.back() },
112+
{ keys: k("close"), when: () => writable, handler: () => cmd?.toggleStatus() },
113+
{ keys: k("star"), handler: () => cmd?.toggleStar() },
114+
{ keys: k("snooze"), when: () => writable, handler: () => inboxUI.set({ picker: "snooze" }) },
115+
{ keys: k("label"), when: () => writable, handler: () => inboxUI.set({ picker: "label" }) },
116+
{ keys: k("assign"), when: () => writable, handler: () => inboxUI.set({ picker: "assign" }) },
117+
{ keys: k("moveTeam"), when: () => writable, handler: () => inboxUI.set({ picker: "team" }) },
118+
{ keys: k("assignMe"), when: () => writable, handler: () => cmd?.assignToMe() },
119+
{ keys: k("mute"), handler: () => cmd?.toggleMute() },
120+
{ keys: k("markRead"), handler: () => cmd?.markRead() },
121+
{ keys: k("markUnread"), handler: () => cmd?.markUnread() },
122+
{ keys: k("trash"), when: () => writable, handler: () => cmd?.toggleTrash() },
123+
{ keys: k("spam"), when: () => writable, handler: () => cmd?.toggleSpam() },
124+
{ keys: k("reply"), when: () => writable, handler: () => inboxUI.openComposer(id, "reply") },
125+
{ keys: k("replyAll"), when: () => writable, handler: () => inboxUI.openComposer(id, "reply_all") },
126+
{ keys: k("forward"), when: () => writable, handler: () => inboxUI.openComposer(id, "forward") },
127+
{ keys: k("comment"), handler: () => inboxUI.openComposer(id, "comment") },
128+
{ keys: k("priority"), when: () => writable, handler: () => cmd?.togglePriority() },
124129
],
125130
shortcutsEnabled && !!conv
126131
)

‎src/components/inbox/conversation/use-conversation-commands.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export function useConversationCommands(conv: ConversationDetail | undefined, bo
7878
run([conv.id], { read: false })
7979
router.push(listHref)
8080
},
81+
markRead: () => run([conv.id], { read: true }),
8182
toggleSpam: () => apply({ spam: !conv.isSpam }, conv.isSpam ? "Marked as not spam" : "Marked as spam"),
8283
toggleTrash: () => apply({ trash: !conv.isTrash }, conv.isTrash ? "Restored from trash" : "Moved to trash"),
8384
deleteForever: () => del.mutate(),

‎src/components/inbox/inbox-provider.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"
55
import { api } from "@/lib/api-client"
66
import { useOrg } from "@/components/app/org-provider"
77
import { inboxKeys } from "@/hooks/inbox/queries"
8+
import { schemeFromPreference, type ShortcutScheme } from "@/lib/inbox/shortcuts"
89
import type { AccountSummary, Bootstrap, LabelSummary, MemberSummary, SignatureSummary, TeamSummary } from "@/lib/inbox/types"
910

1011
/**
@@ -26,6 +27,7 @@ export type InboxContextValue = {
2627
/** Addresses of accounts I can see (to tell "me" apart from customers) */
2728
ownAddresses: Set<string>
2829
shortcutsEnabled: boolean
30+
shortcutScheme: ShortcutScheme
2931
}
3032

3133
const Ctx = createContext<InboxContextValue | null>(null)
@@ -50,7 +52,7 @@ export function InboxProvider({ slug, initialBootstrap, children }: { slug: stri
5052
own.add(a.email.toLowerCase())
5153
for (const al of a.aliases) own.add(al.toLowerCase())
5254
}
53-
const prefs = bootstrap.me.preferences as { shortcuts?: string }
55+
const shortcuts = schemeFromPreference((bootstrap.me.preferences as { shortcuts?: string }).shortcuts)
5456
return {
5557
slug,
5658
meId: user.id,
@@ -62,7 +64,8 @@ export function InboxProvider({ slug, initialBootstrap, children }: { slug: stri
6264
team: byId(bootstrap.teams),
6365
signature: byId(bootstrap.signatures),
6466
ownAddresses: own,
65-
shortcutsEnabled: prefs.shortcuts !== "off",
67+
shortcutsEnabled: shortcuts.enabled,
68+
shortcutScheme: shortcuts.scheme,
6669
}
6770
}, [bootstrap, slug, user.id])
6871

‎src/components/inbox/list/conversation-list.tsx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
import { Skeleton } from "@/components/ui/skeleton"
2020
import { useOrg } from "@/components/app/org-provider"
2121
import { defaultStatus, parseBox, supportsStatusFilter, type ListFilters, type StatusFilter } from "@/lib/inbox/boxes"
22+
import { keysFor } from "@/lib/inbox/shortcuts"
2223
import type { ConversationListItem } from "@/lib/inbox/types"
2324
import { cn } from "@/lib/utils"
2425
import { inboxKeys, useConversationList } from "@/hooks/inbox/queries"
@@ -52,7 +53,7 @@ function RowSkeleton() {
5253
}
5354

5455
export function ConversationList({ box }: { box: string }) {
55-
const { slug, shortcutsEnabled } = useInbox()
56+
const { slug, shortcutsEnabled, shortcutScheme } = useInbox()
5657
const { can } = useOrg()
5758
const router = useRouter()
5859
const qc = useQueryClient()
@@ -189,22 +190,22 @@ export function ConversationList({ box }: { box: string }) {
189190
}
190191
useHotkeys(
191192
[
192-
{ keys: ["j", "ArrowDown"], handler: () => move(1) },
193-
{ keys: ["k", "ArrowUp"], handler: () => move(-1) },
193+
{ keys: keysFor(shortcutScheme, "next"), handler: () => move(1) },
194+
{ keys: keysFor(shortcutScheme, "prev"), handler: () => move(-1) },
194195
{
195-
keys: ["Enter", "o"],
196+
keys: keysFor(shortcutScheme, "open"),
196197
when: () => !openId && !!inboxUI.get().cursorId,
197198
handler: () => router.push(hrefFor(inboxUI.get().cursorId!)),
198199
},
199200
{
200-
keys: ["x"],
201+
keys: keysFor(shortcutScheme, "select"),
201202
handler: () => {
202203
const id = openId ?? inboxUI.get().cursorId ?? ids[0]
203204
const item = items.find((i) => i.id === id)
204205
if (item) toggleSelect(item, false)
205206
},
206207
},
207-
{ keys: ["mod+a"], when: () => !openId, handler: () => inboxUI.set({ selected: new Set(ids) }) },
208+
{ keys: keysFor(shortcutScheme, "selectAll"), when: () => !openId, handler: () => inboxUI.set({ selected: new Set(ids) }) },
208209
{ keys: ["Escape"], when: () => inboxUI.get().selected.size > 0, handler: () => inboxUI.clearSelection() },
209210
],
210211
shortcutsEnabled

‎src/components/inbox/mail-shell.tsx‎

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"use client"
22

3-
import { useEffect } from "react"
3+
import { Suspense, useEffect } from "react"
44
import dynamic from "next/dynamic"
5-
import { useRouter } from "next/navigation"
5+
import { usePathname, useRouter, useSearchParams } from "next/navigation"
66
import { PanelLeft } from "lucide-react"
77
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"
88
import { Button } from "@/components/ui/button"
@@ -11,6 +11,8 @@ import { inboxUI, useInboxUI } from "@/hooks/inbox/store"
1111
import { useHotkeys } from "@/hooks/inbox/use-hotkeys"
1212
import { usePersistentState } from "@/hooks/inbox/use-persistent-state"
1313
import { useRealtime } from "@/hooks/inbox/use-realtime"
14+
import { parseAddresses } from "@/lib/inbox/format"
15+
import { keysFor, type ShortcutAction } from "@/lib/inbox/shortcuts"
1416
import type { Bootstrap } from "@/lib/inbox/types"
1517
import { InboxProvider, useInbox } from "./inbox-provider"
1618
import { ResizeHandle } from "./resize-handle"
@@ -37,7 +39,7 @@ export function MailShell({ slug, bootstrap, children }: { slug: string; bootstr
3739
}
3840

3941
function ShellInner({ children }: { children: React.ReactNode }) {
40-
const { slug, meId, bootstrap, shortcutsEnabled } = useInbox()
42+
const { slug, meId, bootstrap, shortcutsEnabled, shortcutScheme } = useInbox()
4143
const router = useRouter()
4244
const [width, setWidth] = usePersistentState("dispatch:sidebar:width", SIDEBAR.default)
4345
const [collapsed, setCollapsed] = usePersistentState("dispatch:sidebar:collapsed", false)
@@ -72,28 +74,29 @@ function ShellInner({ children }: { children: React.ReactNode }) {
7274

7375
const go = (box: string) => router.push(`/w/${slug}/${box}`)
7476
const firstTeam = bootstrap.teams.find((t) => t.isMember) ?? bootstrap.teams[0]
77+
const k = (action: ShortcutAction) => keysFor(shortcutScheme, action)
78+
const togglePalette = () => inboxUI.set((s) => ({ paletteOpen: !s.paletteOpen, paletteQuery: "" }))
7579
useHotkeys(
7680
[
77-
{ keys: ["mod+k"], allowInInput: true, handler: () => inboxUI.set((s) => ({ paletteOpen: !s.paletteOpen, paletteQuery: "" })) },
78-
{ keys: ["/"], handler: () => inboxUI.set({ paletteOpen: true, paletteQuery: "" }) },
79-
{ keys: ["n"], handler: () => inboxUI.openCompose() },
80-
{ keys: ["?", "shift+?"], handler: () => inboxUI.set({ shortcutsOpen: true }) },
81-
{ keys: ["g i"], handler: () => go("inbox") },
82-
{ keys: ["g a"], handler: () => go("assigned") },
83-
{ keys: ["g s"], handler: () => go("starred") },
84-
{ keys: ["g d"], handler: () => go("drafts") },
85-
{ keys: ["g m"], handler: () => go("mentions") },
86-
{ keys: ["g c"], handler: () => go("chats") },
87-
{ keys: ["g t"], handler: () => (firstTeam ? go(`team.${firstTeam.id}`) : go("unassigned")) },
88-
{ keys: ["mod+shift+n"], allowInInput: true, handler: () => inboxUI.set({ newChatOpen: true }) },
81+
{ keys: ["mod+k"], allowInInput: true, handler: togglePalette },
82+
{ keys: k("search").filter((key) => key !== "mod+k"), handler: () => inboxUI.set({ paletteOpen: true, paletteQuery: "" }) },
83+
{ keys: k("compose"), handler: () => inboxUI.openCompose() },
84+
{ keys: [...k("help"), "shift+?"], handler: () => inboxUI.set({ shortcutsOpen: true }) },
85+
{ keys: k("goInbox"), handler: () => go("inbox") },
86+
{ keys: k("goAssigned"), handler: () => go("assigned") },
87+
{ keys: k("goStarred"), handler: () => go("starred") },
88+
{ keys: k("goDrafts"), handler: () => go("drafts") },
89+
{ keys: k("goMentions"), handler: () => go("mentions") },
90+
{ keys: k("goChats"), handler: () => go("chats") },
91+
{ keys: k("goSent"), handler: () => go("sent") },
92+
{ keys: k("goAll"), handler: () => go("all") },
93+
{ keys: k("goTeam"), handler: () => (firstTeam ? go(`team.${firstTeam.id}`) : go("unassigned")) },
94+
{ keys: k("newChat"), allowInInput: true, handler: () => inboxUI.set({ newChatOpen: true }) },
8995
],
9096
shortcutsEnabled
9197
)
9298
// ⌘K must work even with shortcuts turned off.
93-
useHotkeys(
94-
[{ keys: ["mod+k"], allowInInput: true, handler: () => inboxUI.set((s) => ({ paletteOpen: !s.paletteOpen, paletteQuery: "" })) }],
95-
!shortcutsEnabled
96-
)
99+
useHotkeys([{ keys: ["mod+k"], allowInInput: true, handler: togglePalette }], !shortcutsEnabled)
97100

98101
return (
99102
<div className="flex h-full min-h-0 bg-background">
@@ -135,6 +138,9 @@ function ShellInner({ children }: { children: React.ReactNode }) {
135138
</Sheet>
136139

137140
<main className="flex h-full min-w-0 flex-1 flex-col">{children}</main>
141+
<Suspense>
142+
<ComposeFromUrl />
143+
</Suspense>
138144

139145
{paletteOpen && <CommandPalette />}
140146
{composeOpen && <ComposeDialog />}
@@ -144,6 +150,24 @@ function ShellInner({ children }: { children: React.ReactNode }) {
144150
)
145151
}
146152

153+
/** `?compose=<email>` (links from contacts etc.) opens a new message to that address. */
154+
function ComposeFromUrl() {
155+
const params = useSearchParams()
156+
const router = useRouter()
157+
const pathname = usePathname()
158+
const to = params.get("compose")
159+
useEffect(() => {
160+
if (!to) return
161+
const emails = parseAddresses(to)
162+
inboxUI.openCompose(emails.length ? { to: emails } : undefined)
163+
const next = new URLSearchParams(params.toString())
164+
next.delete("compose")
165+
const qs = next.toString()
166+
router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false })
167+
}, [to, params, pathname, router])
168+
return null
169+
}
170+
147171
/** Hamburger that opens the sidebar sheet below `lg` (also when the desktop sidebar is collapsed). */
148172
export function MobileNavButton({ className }: { className?: string }) {
149173
return (

‎src/components/inbox/shortcuts-dialog.tsx‎

Lines changed: 33 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,64 +5,32 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
55
import { Kbd } from "@/components/ui/kbd"
66
import { inboxUI, useInboxUI } from "@/hooks/inbox/store"
77
import { shortcutLabel } from "@/hooks/inbox/use-hotkeys"
8+
import { SHORTCUTS, type ShortcutAction, type ShortcutGroup, type ShortcutScheme } from "@/lib/inbox/shortcuts"
9+
import { useInbox } from "./inbox-provider"
810

911
type Shortcut = { keys: string[]; label: string }
1012
type Group = { title: string; items: Shortcut[] }
1113

12-
const GROUPS: Group[] = [
13-
{
14-
title: "Navigation",
15-
items: [
16-
{ keys: ["j", "ArrowDown"], label: "Next conversation" },
17-
{ keys: ["k", "ArrowUp"], label: "Previous conversation" },
18-
{ keys: ["Enter", "o"], label: "Open conversation" },
19-
{ keys: ["Escape"], label: "Back to the list" },
20-
{ keys: ["g i"], label: "Go to Inbox" },
21-
{ keys: ["g a"], label: "Go to Assigned to me" },
22-
{ keys: ["g s"], label: "Go to Starred" },
23-
{ keys: ["g d"], label: "Go to Drafts" },
24-
{ keys: ["g m"], label: "Go to Mentions" },
25-
{ keys: ["g c"], label: "Go to Chats" },
26-
{ keys: ["g t"], label: "Go to your team" },
27-
{ keys: ["/", "mod+k"], label: "Search & commands" },
28-
],
29-
},
30-
{
31-
title: "Conversation",
32-
items: [
33-
{ keys: ["e"], label: "Close / reopen" },
34-
{ keys: ["r"], label: "Reply" },
35-
{ keys: ["a"], label: "Reply all" },
36-
{ keys: ["f"], label: "Forward" },
37-
{ keys: ["c"], label: "Comment" },
38-
{ keys: ["s"], label: "Star" },
39-
{ keys: ["h"], label: "Snooze" },
40-
{ keys: ["l"], label: "Labels" },
41-
{ keys: ["i"], label: "Assign" },
42-
{ keys: ["m"], label: "Assign to me" },
43-
{ keys: ["u"], label: "Mark as unread" },
44-
{ keys: ["#"], label: "Move to trash" },
45-
{ keys: ["!"], label: "Mark as spam" },
46-
{ keys: ["x"], label: "Select" },
47-
],
48-
},
49-
{
50-
title: "Composer",
51-
items: [
52-
{ keys: ["mod+Enter"], label: "Send" },
53-
{ keys: ["mod+shift+Enter"], label: "Send & close" },
54-
{ keys: ["Escape"], label: "Close the composer" },
55-
],
56-
},
57-
{
58-
title: "General",
59-
items: [
60-
{ keys: ["n"], label: "New message" },
61-
{ keys: ["mod+shift+n"], label: "New chat" },
62-
{ keys: ["?"], label: "Keyboard shortcuts" },
63-
],
64-
},
65-
]
14+
const COMPOSER: Group = {
15+
title: "Composer",
16+
items: [
17+
{ keys: ["mod+Enter"], label: "Send" },
18+
{ keys: ["mod+shift+Enter"], label: "Send & close" },
19+
{ keys: ["Escape"], label: "Close the composer" },
20+
],
21+
}
22+
23+
/** Help groups for the active scheme, from the shared shortcut table. */
24+
function groupsFor(scheme: ShortcutScheme): Group[] {
25+
const order: ShortcutGroup[] = ["Navigation", "Conversation", "General"]
26+
const groups = order.map((title) => ({
27+
title,
28+
items: (Object.keys(SHORTCUTS) as ShortcutAction[])
29+
.filter((a) => SHORTCUTS[a].group === title && SHORTCUTS[a][scheme].length > 0)
30+
.map((a) => ({ keys: SHORTCUTS[a][scheme], label: SHORTCUTS[a].label })),
31+
}))
32+
return [groups[0]!, groups[1]!, COMPOSER, groups[2]!]
33+
}
6634

6735
function Keys({ combo }: { combo: string }) {
6836
const parts = shortcutLabel(combo)
@@ -81,18 +49,26 @@ function Keys({ combo }: { combo: string }) {
8149
/** "?" — every keyboard shortcut of the inbox, grouped. */
8250
export function ShortcutsDialog() {
8351
const open = useInboxUI((s) => s.shortcutsOpen)
52+
const { shortcutScheme, shortcutsEnabled } = useInbox()
53+
const groups = groupsFor(shortcutScheme)
8454
return (
8555
<Dialog open={open} onOpenChange={(o) => (o ? undefined : inboxUI.set({ shortcutsOpen: false }))}>
8656
<DialogContent className="flex max-h-[min(44rem,calc(100dvh-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl">
8757
<DialogHeader className="border-b px-5 py-4">
8858
<DialogTitle className="text-base font-semibold tracking-tight">
8959
Keyboard shortcuts <span className="text-quiet">for flying through your inbox</span>
9060
</DialogTitle>
91-
<DialogDescription className="text-[13px]">Shortcuts are paused while you type in a field.</DialogDescription>
61+
<DialogDescription className="text-[13px]">
62+
{!shortcutsEnabled
63+
? "Keyboard shortcuts are turned off — only ⌘K / Ctrl K works."
64+
: shortcutScheme === "gmail"
65+
? "Gmail-compatible scheme. Shortcuts are paused while you type in a field."
66+
: "Shortcuts are paused while you type in a field."}
67+
</DialogDescription>
9268
</DialogHeader>
9369
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-5 py-4">
9470
<div className="gap-x-10 md:columns-2">
95-
{GROUPS.map((group) => (
71+
{groups.map((group) => (
9672
<section key={group.title} aria-labelledby={`shortcuts-${group.title}`} className="mb-6 break-inside-avoid last:mb-0">
9773
<h3 id={`shortcuts-${group.title}`} className="mb-2 font-mono text-[11px] tracking-wider text-muted-foreground uppercase">
9874
{group.title}
@@ -117,7 +93,7 @@ export function ShortcutsDialog() {
11793
</div>
11894
</div>
11995
<p className="border-t bg-muted/40 px-5 py-3 text-xs text-muted-foreground">
120-
You can turn keyboard shortcuts off in Settings → Profile.
96+
Switch between the Dispatch and Gmail schemes, or turn shortcuts off, in Settings → Profile.
12197
</p>
12298
</DialogContent>
12399
</Dialog>

0 commit comments

Comments
 (0)