Files
Ali WaseemandDanny White 3d9fb2f98d feat(realtime): shortcuts for realtime (#46001)
## What kind of change does this PR introduce?

Feature — adds keyboard shortcuts for the Realtime section in Studio.

## What is the current behavior?

No keyboard shortcuts exist for Realtime navigation or inspector
actions.

## What is the new behavior?

### Navigation chords (layout-scoped, active on any Realtime page)

| Shortcut | Action |
|---|---|
| `R I` | Go to Inspector |
| `R P` | Go to Policies |
| `R S` | Go to Settings |

### Inspector actions (active on the Inspector page)

| Shortcut | Action | Gating |
|---|---|---|
| `Shift+J` | Join a channel | Only when no channel is joined |
| `Shift+L` | Start/Stop listening | Only when a channel is joined |
| `Shift+F` | Open filter popover | Only when a channel is joined |
| `Shift+B` | Broadcast a message | Only when listening |
| `Mod+Shift+C` | Copy selected message | Only when a message is
selected |


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Realtime Inspector: new keyboard shortcuts (join channel, toggle
listening, toggle filters, broadcast) and a copy-message shortcut with
toast feedback.
* Channel & Filter popovers support controlled/uncontrolled open state;
header now wires popover state through props.
* Shortcut tooltips added to copy and broadcast actions; realtime page
navigation shortcuts and menu shortcuts added.
* **Tests**
* Shortcut reference sheet tests updated to include realtime navigation
and inspector groups.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46001?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
2026-05-19 11:12:54 -06:00

133 lines
4.7 KiB
TypeScript

import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM, useParams } from 'common'
import { PlayCircle, StopCircle } from 'lucide-react'
import { Dispatch, SetStateAction, useCallback } from 'react'
import { Button } from 'ui'
import { ChooseChannelPopover } from './ChooseChannelPopover'
import { RealtimeFilterPopover } from './RealtimeFilterPopover'
import { RealtimeTokensPopover } from './RealtimeTokensPopover'
import { RealtimeConfig } from './useRealtimeMessages'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { getTemporaryAPIKey } from '@/data/api-keys/temp-api-keys-query'
import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
interface HeaderProps {
config: RealtimeConfig
onChangeConfig: Dispatch<SetStateAction<RealtimeConfig>>
channelPopoverOpen: boolean
onChannelPopoverChange: (open: boolean) => void
filterPopoverOpen: boolean
onFilterPopoverChange: (open: boolean) => void
}
export const Header = ({
config,
onChangeConfig,
channelPopoverOpen,
onChannelPopoverChange,
filterPopoverOpen,
onFilterPopoverChange,
}: HeaderProps) => {
const { mutate: sendEvent } = useSendEventMutation()
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(
PermissionAction.READ,
'service_api_keys'
)
const canToggleListening = canReadAPIKeys && config.channelName.length > 0
const handleToggleListening = useCallback(async () => {
const willStartListening = !config.enabled
// [Joshen] Refresh if starting to listen + using temp API key, since it has a low refresh rate
if (willStartListening && (config.token.startsWith('sb_temp') || !IS_PLATFORM)) {
const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 })
const token = data.api_key
onChangeConfig({ ...config, token, enabled: !config.enabled })
} else {
onChangeConfig({ ...config, enabled: !config.enabled })
}
if (willStartListening) {
sendEvent({
action: 'realtime_inspector_listen_channel_clicked',
groups: {
project: ref ?? 'Unknown',
organization: org?.slug ?? 'Unknown',
},
})
}
}, [config, onChangeConfig, sendEvent, ref, org])
useShortcut(SHORTCUT_IDS.INSPECTOR_TOGGLE_LISTENING, handleToggleListening, {
enabled: canToggleListening,
})
const listeningButton = (
<Button
size="tiny"
type={config.enabled ? 'warning' : 'primary'}
className="rounded-l-none border-l-0"
disabled={!canToggleListening}
icon={config.enabled ? <StopCircle size="16" /> : <PlayCircle size="16" />}
onClick={handleToggleListening}
>
{config.enabled ? `Stop listening` : `Start listening`}
</Button>
)
return (
<div className="flex flex-row min-h-14 md:min-h-(--header-height) gap-2.5 items-center px-4 border-b ">
<div className="flex flex-row">
<ChooseChannelPopover
config={config}
onChangeConfig={onChangeConfig}
open={channelPopoverOpen}
onOpenChange={onChannelPopoverChange}
/>
<RealtimeTokensPopover config={config} onChangeConfig={onChangeConfig} />
{canToggleListening ? (
<ShortcutTooltip shortcutId={SHORTCUT_IDS.INSPECTOR_TOGGLE_LISTENING} side="bottom">
{listeningButton}
</ShortcutTooltip>
) : (
<ButtonTooltip
size="tiny"
type={config.enabled ? 'warning' : 'primary'}
className="rounded-l-none border-l-0"
disabled
icon={config.enabled ? <StopCircle size="16" /> : <PlayCircle size="16" />}
onClick={handleToggleListening}
tooltip={{
content: {
side: 'bottom',
text: !canReadAPIKeys
? 'You need additional permissions to use the realtime inspector'
: config.channelName.length === 0
? 'You need to join a channel first'
: undefined,
},
}}
>
{config.enabled ? `Stop listening` : `Start listening`}
</ButtonTooltip>
)}
</div>
<RealtimeFilterPopover
config={config}
onChangeConfig={onChangeConfig}
open={filterPopoverOpen}
onOpenChange={onFilterPopoverChange}
/>
</div>
)
}