mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(desktop): add NIP-ER reminder UI — create, view, and manage encrypted reminders (#963)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -34,6 +34,8 @@ jobs:
|
||||
filters: |
|
||||
rust:
|
||||
- 'crates/**'
|
||||
- 'migrations/**'
|
||||
- 'schema/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- 'rust-toolchain.toml'
|
||||
@@ -357,21 +359,6 @@ jobs:
|
||||
wait_healthy "Redis" "buzz-redis"
|
||||
wait_healthy "Typesense" "buzz-typesense"
|
||||
wait_healthy "MinIO" "buzz-minio"
|
||||
- name: Apply database schema
|
||||
run: ./bin/pgschema apply --file schema/schema.sql --auto-approve
|
||||
env:
|
||||
PGHOST: localhost
|
||||
PGPORT: "5432"
|
||||
PGUSER: buzz
|
||||
PGPASSWORD: buzz_dev
|
||||
PGDATABASE: buzz
|
||||
# Use the already-running docker postgres for desired-state planning instead of
|
||||
# downloading an embedded Postgres from Maven Central (transient-fetch flake source).
|
||||
PGSCHEMA_PLAN_HOST: localhost
|
||||
PGSCHEMA_PLAN_PORT: "5432"
|
||||
PGSCHEMA_PLAN_DB: buzz
|
||||
PGSCHEMA_PLAN_USER: buzz
|
||||
PGSCHEMA_PLAN_PASSWORD: buzz_dev
|
||||
- name: Download relay binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -382,6 +369,7 @@ jobs:
|
||||
chmod +x ./target/ci/buzz-relay
|
||||
nohup env \
|
||||
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
|
||||
BUZZ_AUTO_MIGRATE=true \
|
||||
REDIS_URL=redis://localhost:6379 \
|
||||
TYPESENSE_URL=http://localhost:8108 \
|
||||
TYPESENSE_API_KEY=buzz_dev_key \
|
||||
@@ -390,6 +378,7 @@ jobs:
|
||||
BUZZ_REQUIRE_AUTH_TOKEN=false \
|
||||
BUZZ_RECONCILE_CHANNELS=true \
|
||||
BUZZ_GIT_PROBE_WRITERS=8 \
|
||||
SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \
|
||||
./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 &
|
||||
echo $! > /tmp/buzz-relay.pid
|
||||
for attempt in $(seq 1 60); do
|
||||
@@ -407,6 +396,15 @@ jobs:
|
||||
exit 1
|
||||
- name: Seed desktop e2e data
|
||||
run: bash scripts/setup-desktop-test-data.sh
|
||||
- name: Reminder runtime e2e (proves migrations provisioned the schema)
|
||||
# Runs the NIP-ER reminder e2e against the migration-provisioned relay.
|
||||
# If migrations/0003 is missing or wrong, every reminder INSERT/query
|
||||
# 500s and these tests fail — the guard that the snapshot and the sqlx
|
||||
# migration runner can never silently diverge again. One shard suffices.
|
||||
if: matrix.shard == 1
|
||||
run: cargo test --profile ci -p buzz-test-client --test e2e_event_reminder -- --ignored
|
||||
env:
|
||||
RELAY_URL: ws://localhost:3000
|
||||
- name: Desktop relay-backed e2e
|
||||
run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2
|
||||
- name: Upload desktop integration artifacts
|
||||
|
||||
17
AGENTS.md
17
AGENTS.md
@@ -355,7 +355,22 @@ subscription. Navigate to the channel first (triggers subscription), then away
|
||||
(so unread indicators appear), then inject.
|
||||
|
||||
**Animation timing:** Radix components animate in via CSS. `toBeVisible()`
|
||||
resolves mid-animation — wait for completion before screenshotting:
|
||||
resolves mid-animation — wait for completion before screenshotting. Use the
|
||||
shared helper (mandatory before any `page.screenshot()` or
|
||||
`locator.screenshot()` in specs):
|
||||
|
||||
```ts
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
// ... after the element is visible but before capturing:
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: "...", clip: { ... } });
|
||||
```
|
||||
|
||||
The `just desktop-screenshot` path (`screenshot.mjs`) calls
|
||||
`waitForAnimations` automatically — no manual step needed there.
|
||||
|
||||
For per-element waits (rare — prefer the page-level helper above):
|
||||
|
||||
```ts
|
||||
await menuItem.evaluate((el) =>
|
||||
|
||||
@@ -47,6 +47,7 @@ export default defineConfig({
|
||||
"**/unread-pill-screenshots.spec.ts",
|
||||
"**/thread-unread-screenshots.spec.ts",
|
||||
"**/animated-avatar-screenshots.spec.ts",
|
||||
"**/reminders-screenshots.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AppView =
|
||||
| "home"
|
||||
| "channel"
|
||||
| "agents"
|
||||
| "reminders"
|
||||
| "workflows"
|
||||
| "pulse"
|
||||
| "projects";
|
||||
@@ -85,6 +86,13 @@ export function deriveShellRoute(pathname: string): {
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname === "/reminders") {
|
||||
return {
|
||||
selectedChannelId: null,
|
||||
selectedView: "reminders",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
selectedChannelId: null,
|
||||
selectedView: "home",
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
isSettingsSection,
|
||||
} from "@/features/settings/ui/SettingsPanels";
|
||||
import { HuddleBar, HuddleProvider } from "@/features/huddle";
|
||||
import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider";
|
||||
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
||||
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
|
||||
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
|
||||
@@ -114,6 +115,7 @@ export function AppShell() {
|
||||
goChannel,
|
||||
goHome,
|
||||
goProjects,
|
||||
goReminders,
|
||||
goPulse,
|
||||
goSettings,
|
||||
goWorkflows,
|
||||
@@ -700,236 +702,250 @@ export function AppShell() {
|
||||
}}
|
||||
>
|
||||
<HuddleProvider>
|
||||
<div
|
||||
className="buzz-huddle-shell relative h-dvh overflow-hidden overscroll-none"
|
||||
data-huddle-open={isHuddleDrawerOpen}
|
||||
>
|
||||
<RemindMeLaterProvider>
|
||||
<div
|
||||
className={cn(
|
||||
"buzz-huddle-app-surface z-10 flex min-h-0 flex-col overflow-hidden bg-background",
|
||||
isHuddleDrawerOpen && "buzz-huddle-app-surface-open",
|
||||
)}
|
||||
className="buzz-huddle-shell relative h-dvh overflow-hidden overscroll-none"
|
||||
data-huddle-open={isHuddleDrawerOpen}
|
||||
>
|
||||
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
|
||||
{!settingsOpen ? (
|
||||
<AppTopChrome
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
<div
|
||||
className={cn(
|
||||
"buzz-huddle-app-surface z-10 flex min-h-0 flex-col overflow-hidden bg-background",
|
||||
isHuddleDrawerOpen && "buzz-huddle-app-surface-open",
|
||||
)}
|
||||
>
|
||||
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
|
||||
{!settingsOpen ? (
|
||||
<AppTopChrome
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onGoBack={goBack}
|
||||
onGoForward={goForward}
|
||||
onOpenChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
onOpenResult={handleOpenSearchResult}
|
||||
searchHidden={topbarSearchHidden}
|
||||
searchLoading={topbarSearchLoading}
|
||||
searchFocusRequest={searchFocusRequest}
|
||||
/>
|
||||
) : null}
|
||||
{settingsOpen ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<LazySettingsScreen
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={
|
||||
notificationSettings.errorMessage
|
||||
}
|
||||
notificationPermission={
|
||||
notificationSettings.permission
|
||||
}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={handleSettingsSectionChange}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={
|
||||
notificationSettings.setHomeBadgeEnabled
|
||||
}
|
||||
onSetSlotAlertsEnabled={
|
||||
notificationSettings.setSlotAlertsEnabled
|
||||
}
|
||||
onSetNotifyWhileViewing={
|
||||
notificationSettings.setNotifyWhileViewing
|
||||
}
|
||||
onSetAllSlotAlertsEnabled={
|
||||
notificationSettings.setAllSlotAlertsEnabled
|
||||
}
|
||||
onSetSoundForSlot={
|
||||
notificationSettings.setSoundForSlot
|
||||
}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<>
|
||||
<AppSidebar
|
||||
activeWorkspace={workspacesHook.activeWorkspace}
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount}
|
||||
isAddWorkspaceOpen={isAddWorkspaceOpen}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isOpeningDm={openDmMutation.isPending}
|
||||
isNewDmOpen={isNewDmOpen}
|
||||
isCreateChannelOpen={isCreateChannelOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onAddWorkspace={(workspace) => {
|
||||
const id = workspacesHook.addWorkspace(workspace);
|
||||
workspacesHook.switchWorkspace(id);
|
||||
}}
|
||||
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
|
||||
onNewDmOpenChange={setIsNewDmOpen}
|
||||
onCreateChannelOpenChange={setIsCreateChannelOpen}
|
||||
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
|
||||
onUpdateWorkspace={workspacesHook.updateWorkspace}
|
||||
onRemoveWorkspace={workspacesHook.removeWorkspace}
|
||||
onSwitchWorkspace={workspacesHook.switchWorkspace}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
workspaces={workspacesHook.workspaces}
|
||||
onCreateChannel={async ({
|
||||
description,
|
||||
name,
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
templateId,
|
||||
}) => {
|
||||
const createdChannel =
|
||||
await createChannelMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "stream",
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
});
|
||||
|
||||
await applyCanvas(
|
||||
templateId,
|
||||
createdChannel.id,
|
||||
name,
|
||||
);
|
||||
await goChannel(createdChannel.id);
|
||||
void applyAgents(templateId, createdChannel.id);
|
||||
}}
|
||||
onCreateForum={async ({
|
||||
description,
|
||||
name,
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
templateId,
|
||||
}) => {
|
||||
const createdForum =
|
||||
await createForumMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "forum",
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
});
|
||||
|
||||
await applyCanvas(
|
||||
templateId,
|
||||
createdForum.id,
|
||||
name,
|
||||
);
|
||||
await goChannel(createdForum.id);
|
||||
void applyAgents(templateId, createdForum.id);
|
||||
}}
|
||||
onHideDm={handleHideDm}
|
||||
onMarkAllChannelsRead={markAllChannelsRead}
|
||||
onMarkChannelRead={markChannelRead}
|
||||
onMarkChannelUnread={markChannelUnread}
|
||||
onOpenBrowseChannels={handleOpenBrowseChannels}
|
||||
onOpenBrowseForums={handleOpenBrowseForums}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage =
|
||||
await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onSelectAgents={() => void goAgents()}
|
||||
onSelectChannel={(channelId) =>
|
||||
void goChannel(channelId)
|
||||
}
|
||||
onSelectHome={() => void goHome()}
|
||||
onSelectProjects={() => void goProjects()}
|
||||
onSelectPulse={() => void goPulse()}
|
||||
onSelectReminders={() => void goReminders()}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSelectWorkflows={() => void goWorkflows()}
|
||||
onSetPresenceStatus={(status) =>
|
||||
presenceSession.setStatus(status)
|
||||
}
|
||||
onSetUserStatus={(text, emoji) =>
|
||||
setUserStatusMutation.mutate({ text, emoji })
|
||||
}
|
||||
onClearUserStatus={() =>
|
||||
setUserStatusMutation.mutate({
|
||||
text: "",
|
||||
emoji: "",
|
||||
})
|
||||
}
|
||||
profile={profileQuery.data}
|
||||
selfUserStatus={
|
||||
deferredPubkey
|
||||
? (selfStatusQuery.data?.[
|
||||
deferredPubkey.toLowerCase()
|
||||
] ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
selectedChannelId={selectedChannelId}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
onMuteChannel={muteChannel}
|
||||
onUnmuteChannel={unmuteChannel}
|
||||
starredChannelIds={starredChannelIds}
|
||||
onStarChannel={starChannel}
|
||||
onUnstarChannel={unstarChannel}
|
||||
/>
|
||||
|
||||
<MainInsetProvider mainInsetRef={mainInsetRef}>
|
||||
<SidebarInset
|
||||
ref={mainInsetRef}
|
||||
className="min-h-0 min-w-0 overflow-hidden"
|
||||
style={chromeCssVarDefaults}
|
||||
>
|
||||
<ConnectionBanner />
|
||||
<Outlet />
|
||||
</SidebarInset>
|
||||
</MainInsetProvider>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppShellOverlays
|
||||
activeChannel={activeChannel}
|
||||
browseDialogType={browseDialogType}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onGoBack={goBack}
|
||||
onGoForward={goForward}
|
||||
onOpenChannel={(channelId) => {
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={setIsChannelManagementOpen}
|
||||
onDeleteActiveChannel={() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
onOpenResult={handleOpenSearchResult}
|
||||
searchHidden={topbarSearchHidden}
|
||||
searchLoading={topbarSearchLoading}
|
||||
searchFocusRequest={searchFocusRequest}
|
||||
/>
|
||||
) : null}
|
||||
{settingsOpen ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<LazySettingsScreen
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={
|
||||
notificationSettings.errorMessage
|
||||
}
|
||||
notificationPermission={notificationSettings.permission}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={handleSettingsSectionChange}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={
|
||||
notificationSettings.setHomeBadgeEnabled
|
||||
}
|
||||
onSetSlotAlertsEnabled={
|
||||
notificationSettings.setSlotAlertsEnabled
|
||||
}
|
||||
onSetNotifyWhileViewing={
|
||||
notificationSettings.setNotifyWhileViewing
|
||||
}
|
||||
onSetAllSlotAlertsEnabled={
|
||||
notificationSettings.setAllSlotAlertsEnabled
|
||||
}
|
||||
onSetSoundForSlot={notificationSettings.setSoundForSlot}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<>
|
||||
<AppSidebar
|
||||
activeWorkspace={workspacesHook.activeWorkspace}
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount}
|
||||
isAddWorkspaceOpen={isAddWorkspaceOpen}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isOpeningDm={openDmMutation.isPending}
|
||||
isNewDmOpen={isNewDmOpen}
|
||||
isCreateChannelOpen={isCreateChannelOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onAddWorkspace={(workspace) => {
|
||||
const id = workspacesHook.addWorkspace(workspace);
|
||||
workspacesHook.switchWorkspace(id);
|
||||
}}
|
||||
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
|
||||
onNewDmOpenChange={setIsNewDmOpen}
|
||||
onCreateChannelOpenChange={setIsCreateChannelOpen}
|
||||
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
|
||||
onUpdateWorkspace={workspacesHook.updateWorkspace}
|
||||
onRemoveWorkspace={workspacesHook.removeWorkspace}
|
||||
onSwitchWorkspace={workspacesHook.switchWorkspace}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
workspaces={workspacesHook.workspaces}
|
||||
onCreateChannel={async ({
|
||||
description,
|
||||
name,
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
templateId,
|
||||
}) => {
|
||||
const createdChannel =
|
||||
await createChannelMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "stream",
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
});
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
|
||||
await applyCanvas(
|
||||
templateId,
|
||||
createdChannel.id,
|
||||
name,
|
||||
);
|
||||
await goChannel(createdChannel.id);
|
||||
void applyAgents(templateId, createdChannel.id);
|
||||
}}
|
||||
onCreateForum={async ({
|
||||
description,
|
||||
name,
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
templateId,
|
||||
}) => {
|
||||
const createdForum =
|
||||
await createForumMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "forum",
|
||||
visibility,
|
||||
ttlSeconds,
|
||||
});
|
||||
|
||||
await applyCanvas(templateId, createdForum.id, name);
|
||||
await goChannel(createdForum.id);
|
||||
void applyAgents(templateId, createdForum.id);
|
||||
}}
|
||||
onHideDm={handleHideDm}
|
||||
onMarkAllChannelsRead={markAllChannelsRead}
|
||||
onMarkChannelRead={markChannelRead}
|
||||
onMarkChannelUnread={markChannelUnread}
|
||||
onOpenBrowseChannels={handleOpenBrowseChannels}
|
||||
onOpenBrowseForums={handleOpenBrowseForums}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage =
|
||||
await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onSelectAgents={() => void goAgents()}
|
||||
onSelectChannel={(channelId) =>
|
||||
void goChannel(channelId)
|
||||
}
|
||||
onSelectHome={() => void goHome()}
|
||||
onSelectProjects={() => void goProjects()}
|
||||
onSelectPulse={() => void goPulse()}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSelectWorkflows={() => void goWorkflows()}
|
||||
onSetPresenceStatus={(status) =>
|
||||
presenceSession.setStatus(status)
|
||||
}
|
||||
onSetUserStatus={(text, emoji) =>
|
||||
setUserStatusMutation.mutate({ text, emoji })
|
||||
}
|
||||
onClearUserStatus={() =>
|
||||
setUserStatusMutation.mutate({ text: "", emoji: "" })
|
||||
}
|
||||
profile={profileQuery.data}
|
||||
selfUserStatus={
|
||||
deferredPubkey
|
||||
? (selfStatusQuery.data?.[
|
||||
deferredPubkey.toLowerCase()
|
||||
] ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
selectedChannelId={selectedChannelId}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
onMuteChannel={muteChannel}
|
||||
onUnmuteChannel={unmuteChannel}
|
||||
starredChannelIds={starredChannelIds}
|
||||
onStarChannel={starChannel}
|
||||
onUnstarChannel={unstarChannel}
|
||||
/>
|
||||
|
||||
<MainInsetProvider mainInsetRef={mainInsetRef}>
|
||||
<SidebarInset
|
||||
ref={mainInsetRef}
|
||||
className="min-h-0 min-w-0 overflow-hidden"
|
||||
style={chromeCssVarDefaults}
|
||||
>
|
||||
<ConnectionBanner />
|
||||
<Outlet />
|
||||
</SidebarInset>
|
||||
</MainInsetProvider>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppShellOverlays
|
||||
activeChannel={activeChannel}
|
||||
browseDialogType={browseDialogType}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={setIsChannelManagementOpen}
|
||||
onDeleteActiveChannel={() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
<div className="absolute inset-x-0 bottom-0 z-0 h-(--buzz-huddle-drawer-height)">
|
||||
<HuddleBar
|
||||
className="h-full"
|
||||
onVisibilityChange={setIsHuddleDrawerOpen}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-0 h-(--buzz-huddle-drawer-height)">
|
||||
<HuddleBar
|
||||
className="h-full"
|
||||
onVisibilityChange={setIsHuddleDrawerOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RemindMeLaterProvider>
|
||||
</HuddleProvider>
|
||||
</AppShellProvider>
|
||||
</ChannelNavigationProvider>
|
||||
|
||||
@@ -90,6 +90,17 @@ export function useAppNavigation() {
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goReminders = React.useCallback(
|
||||
(behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/reminders",
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goProject = React.useCallback(
|
||||
(projectId: string, behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
@@ -261,6 +272,7 @@ export function useAppNavigation() {
|
||||
goProject,
|
||||
goProjects,
|
||||
goPulse,
|
||||
goReminders,
|
||||
goSettings,
|
||||
goWorkflow,
|
||||
goWorkflows,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Route as rootRouteImport } from "./routes/root";
|
||||
import { Route as workflowsRouteImport } from "./routes/workflows";
|
||||
import { Route as settingsRouteImport } from "./routes/settings";
|
||||
import { Route as remindersRouteImport } from "./routes/reminders";
|
||||
import { Route as pulseRouteImport } from "./routes/pulse";
|
||||
import { Route as projectsRouteImport } from "./routes/projects";
|
||||
import { Route as agentsRouteImport } from "./routes/agents";
|
||||
@@ -26,6 +27,11 @@ const settingsRoute = settingsRouteImport.update({
|
||||
path: "/settings",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const remindersRoute = remindersRouteImport.update({
|
||||
id: "/reminders",
|
||||
path: "/reminders",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const pulseRoute = pulseRouteImport.update({
|
||||
id: "/pulse",
|
||||
path: "/pulse",
|
||||
@@ -73,6 +79,7 @@ export interface FileRoutesByFullPath {
|
||||
"/agents": typeof agentsRoute;
|
||||
"/projects": typeof projectsRoute;
|
||||
"/pulse": typeof pulseRoute;
|
||||
"/reminders": typeof remindersRoute;
|
||||
"/settings": typeof settingsRoute;
|
||||
"/workflows": typeof workflowsRoute;
|
||||
"/channels/$channelId": typeof channelsDotchannelIdRoute;
|
||||
@@ -85,6 +92,7 @@ export interface FileRoutesByTo {
|
||||
"/agents": typeof agentsRoute;
|
||||
"/projects": typeof projectsRoute;
|
||||
"/pulse": typeof pulseRoute;
|
||||
"/reminders": typeof remindersRoute;
|
||||
"/settings": typeof settingsRoute;
|
||||
"/workflows": typeof workflowsRoute;
|
||||
"/channels/$channelId": typeof channelsDotchannelIdRoute;
|
||||
@@ -98,6 +106,7 @@ export interface FileRoutesById {
|
||||
"/agents": typeof agentsRoute;
|
||||
"/projects": typeof projectsRoute;
|
||||
"/pulse": typeof pulseRoute;
|
||||
"/reminders": typeof remindersRoute;
|
||||
"/settings": typeof settingsRoute;
|
||||
"/workflows": typeof workflowsRoute;
|
||||
"/channels/$channelId": typeof channelsDotchannelIdRoute;
|
||||
@@ -112,6 +121,7 @@ export interface FileRouteTypes {
|
||||
| "/agents"
|
||||
| "/projects"
|
||||
| "/pulse"
|
||||
| "/reminders"
|
||||
| "/settings"
|
||||
| "/workflows"
|
||||
| "/channels/$channelId"
|
||||
@@ -124,6 +134,7 @@ export interface FileRouteTypes {
|
||||
| "/agents"
|
||||
| "/projects"
|
||||
| "/pulse"
|
||||
| "/reminders"
|
||||
| "/settings"
|
||||
| "/workflows"
|
||||
| "/channels/$channelId"
|
||||
@@ -136,6 +147,7 @@ export interface FileRouteTypes {
|
||||
| "/agents"
|
||||
| "/projects"
|
||||
| "/pulse"
|
||||
| "/reminders"
|
||||
| "/settings"
|
||||
| "/workflows"
|
||||
| "/channels/$channelId"
|
||||
@@ -149,6 +161,7 @@ export interface RootRouteChildren {
|
||||
agentsRoute: typeof agentsRoute;
|
||||
projectsRoute: typeof projectsRoute;
|
||||
pulseRoute: typeof pulseRoute;
|
||||
remindersRoute: typeof remindersRoute;
|
||||
settingsRoute: typeof settingsRoute;
|
||||
workflowsRoute: typeof workflowsRoute;
|
||||
channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute;
|
||||
@@ -173,6 +186,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof settingsRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/reminders": {
|
||||
id: "/reminders";
|
||||
path: "/reminders";
|
||||
fullPath: "/reminders";
|
||||
preLoaderRoute: typeof remindersRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/pulse": {
|
||||
id: "/pulse";
|
||||
path: "/pulse";
|
||||
@@ -237,6 +257,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
agentsRoute: agentsRoute,
|
||||
projectsRoute: projectsRoute,
|
||||
pulseRoute: pulseRoute,
|
||||
remindersRoute: remindersRoute,
|
||||
settingsRoute: settingsRoute,
|
||||
workflowsRoute: workflowsRoute,
|
||||
channelsDotchannelIdRoute: channelsDotchannelIdRoute,
|
||||
|
||||
@@ -4,6 +4,7 @@ export const routes = rootRoute("root.tsx", [
|
||||
index("index.tsx"),
|
||||
route("/agents", "agents.tsx"),
|
||||
route("/pulse", "pulse.tsx"),
|
||||
route("/reminders", "reminders.tsx"),
|
||||
route("/settings", "settings.tsx"),
|
||||
route("/workflows", "workflows.tsx"),
|
||||
route("/workflows/$workflowId", "workflows.$workflowId.tsx"),
|
||||
|
||||
19
desktop/src/app/routes/reminders.tsx
Normal file
19
desktop/src/app/routes/reminders.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
const RemindersScreen = React.lazy(async () => {
|
||||
const module = await import("@/features/reminders/ui/RemindersScreen");
|
||||
return { default: module.RemindersScreen };
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/reminders")({
|
||||
component: RemindersRouteComponent,
|
||||
});
|
||||
|
||||
function RemindersRouteComponent() {
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<RemindersScreen />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BellOff,
|
||||
BellRing,
|
||||
Clock,
|
||||
Copy,
|
||||
CornerUpLeft,
|
||||
EllipsisVertical,
|
||||
@@ -77,6 +78,7 @@ function MoreActionsMenu({
|
||||
onFollowThread,
|
||||
onMarkUnread,
|
||||
onOpenChange,
|
||||
onRemindLater,
|
||||
onUnfollowThread,
|
||||
open,
|
||||
isFollowingThread,
|
||||
@@ -90,6 +92,7 @@ function MoreActionsMenu({
|
||||
onFollowThread?: (message: TimelineMessage) => void;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRemindLater?: (message: TimelineMessage) => void;
|
||||
onUnfollowThread?: (message: TimelineMessage) => void;
|
||||
open: boolean;
|
||||
isFollowingThread?: boolean;
|
||||
@@ -192,6 +195,17 @@ function MoreActionsMenu({
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
{onRemindLater ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onRemindLater(message);
|
||||
}}
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
Remind me later
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
{hasCopyActions && channelId ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`copy-message-link-${message.id}`}
|
||||
@@ -321,6 +335,7 @@ export function MessageActionBar({
|
||||
onMarkUnread,
|
||||
onReactionBadgeBurstRequest,
|
||||
onReactionSelect,
|
||||
onRemindLater,
|
||||
onReply,
|
||||
onUnfollowThread,
|
||||
reactionErrorMessage = null,
|
||||
@@ -337,6 +352,7 @@ export function MessageActionBar({
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onReactionBadgeBurstRequest?: (emoji: string) => void;
|
||||
onReactionSelect?: (emoji: string) => Promise<void>;
|
||||
onRemindLater?: (message: TimelineMessage) => void;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
onUnfollowThread?: (message: TimelineMessage) => void;
|
||||
reactionErrorMessage?: string | null;
|
||||
@@ -368,6 +384,7 @@ export function MessageActionBar({
|
||||
Boolean(onMarkUnread) ||
|
||||
Boolean(onFollowThread) ||
|
||||
Boolean(onUnfollowThread) ||
|
||||
Boolean(onRemindLater) ||
|
||||
!message.pending;
|
||||
|
||||
const wouldAddReaction = React.useCallback(
|
||||
@@ -513,6 +530,7 @@ export function MessageActionBar({
|
||||
onFollowThread={onFollowThread}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
onRemindLater={onRemindLater}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
open={isDropdownOpen}
|
||||
isFollowingThread={isFollowingThread}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
@@ -89,6 +90,7 @@ export const MessageRow = React.memo(
|
||||
errorMessage: reactionErrorMessage,
|
||||
select: handleReactionSelect,
|
||||
} = useReactionHandler(message, onToggleReaction);
|
||||
const { openReminder } = useRemindLater();
|
||||
const mentionNames = React.useMemo(
|
||||
() => resolveMentionNames(message.tags, profiles),
|
||||
[profiles, message.tags],
|
||||
@@ -278,6 +280,14 @@ export const MessageRow = React.memo(
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onRemindLater={(msg) => {
|
||||
openReminder({
|
||||
eventId: msg.id,
|
||||
channelId: channelId ?? "",
|
||||
preview: msg.body.slice(0, 100),
|
||||
authorPubkey: msg.pubkey ?? "",
|
||||
});
|
||||
}}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
|
||||
141
desktop/src/features/reminders/lib/reminderService.test.mjs
Normal file
141
desktop/src/features/reminders/lib/reminderService.test.mjs
Normal file
@@ -0,0 +1,141 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { parseNotBefore, parseReminderContent } from "./reminderService.ts";
|
||||
|
||||
const VALID_TARGET = {
|
||||
eventId: "abc123",
|
||||
channelId: "chan1",
|
||||
preview: "hello world",
|
||||
authorPubkey: "pk1",
|
||||
};
|
||||
|
||||
function content(overrides = {}) {
|
||||
return JSON.stringify({
|
||||
target: VALID_TARGET,
|
||||
status: "pending",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("parseReminderContent_valid_target_reminder_returns_content", () => {
|
||||
const result = parseReminderContent(content());
|
||||
assert.deepEqual(result, {
|
||||
status: "pending",
|
||||
target: VALID_TARGET,
|
||||
note: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseReminderContent_note_only_reminder_returns_content", () => {
|
||||
const result = parseReminderContent(
|
||||
JSON.stringify({ status: "pending", note: "buy milk" }),
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
status: "pending",
|
||||
target: undefined,
|
||||
note: "buy milk",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseReminderContent_done_and_cancelled_statuses_accepted", () => {
|
||||
assert.equal(
|
||||
parseReminderContent(content({ status: "done" }))?.status,
|
||||
"done",
|
||||
);
|
||||
assert.equal(
|
||||
parseReminderContent(content({ status: "cancelled" }))?.status,
|
||||
"cancelled",
|
||||
);
|
||||
});
|
||||
|
||||
test("parseReminderContent_invalid_json_returns_null", () => {
|
||||
assert.equal(parseReminderContent("not json {"), null);
|
||||
});
|
||||
|
||||
test("parseReminderContent_non_object_returns_null", () => {
|
||||
assert.equal(parseReminderContent("42"), null);
|
||||
assert.equal(parseReminderContent('"a string"'), null);
|
||||
assert.equal(parseReminderContent("[1,2,3]"), null);
|
||||
assert.equal(parseReminderContent("null"), null);
|
||||
});
|
||||
|
||||
test("parseReminderContent_unknown_status_returns_null", () => {
|
||||
assert.equal(parseReminderContent(content({ status: "bogus" })), null);
|
||||
assert.equal(
|
||||
parseReminderContent(JSON.stringify({ target: VALID_TARGET })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseReminderContent_neither_target_nor_note_returns_null", () => {
|
||||
assert.equal(
|
||||
parseReminderContent(JSON.stringify({ status: "pending" })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseReminderContent_empty_note_without_target_returns_null", () => {
|
||||
assert.equal(
|
||||
parseReminderContent(JSON.stringify({ status: "pending", note: "" })),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseReminderContent_non_string_note_returns_null", () => {
|
||||
assert.equal(parseReminderContent(content({ note: 5 })), null);
|
||||
});
|
||||
|
||||
test("parseReminderContent_malformed_target_returns_null", () => {
|
||||
assert.equal(
|
||||
parseReminderContent(JSON.stringify({ status: "pending", target: {} })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
parseReminderContent(
|
||||
JSON.stringify({ status: "pending", target: "string" }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
parseReminderContent(
|
||||
JSON.stringify({
|
||||
status: "pending",
|
||||
target: { ...VALID_TARGET, preview: 7 },
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseReminderContent_unknown_fields_are_ignored", () => {
|
||||
const result = parseReminderContent(content({ extra: "ignored" }));
|
||||
assert.deepEqual(result, {
|
||||
status: "pending",
|
||||
target: VALID_TARGET,
|
||||
note: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("parseNotBefore_valid_digits_returns_number", () => {
|
||||
assert.equal(parseNotBefore("0"), 0);
|
||||
assert.equal(parseNotBefore("1700000000"), 1_700_000_000);
|
||||
});
|
||||
|
||||
test("parseNotBefore_leading_zero_returns_undefined", () => {
|
||||
assert.equal(parseNotBefore("007"), undefined);
|
||||
assert.equal(parseNotBefore("01"), undefined);
|
||||
});
|
||||
|
||||
test("parseNotBefore_non_digit_returns_undefined", () => {
|
||||
assert.equal(parseNotBefore("123abc"), undefined);
|
||||
assert.equal(parseNotBefore("12.5"), undefined);
|
||||
assert.equal(parseNotBefore("-5"), undefined);
|
||||
assert.equal(parseNotBefore(" 5"), undefined);
|
||||
assert.equal(parseNotBefore(""), undefined);
|
||||
});
|
||||
|
||||
test("parseNotBefore_above_max_safe_integer_returns_undefined", () => {
|
||||
assert.equal(parseNotBefore("9007199254740991"), 9_007_199_254_740_991);
|
||||
assert.equal(parseNotBefore("9007199254740992"), undefined);
|
||||
});
|
||||
274
desktop/src/features/reminders/lib/reminderService.ts
Normal file
274
desktop/src/features/reminders/lib/reminderService.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
nip44DecryptFromSelf,
|
||||
nip44EncryptToSelf,
|
||||
signRelayEvent,
|
||||
} from "@/shared/api/tauri";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import { KIND_EVENT_REMINDER } from "@/shared/constants/kinds";
|
||||
import type {
|
||||
Reminder,
|
||||
ReminderContent,
|
||||
ReminderTarget,
|
||||
} from "./reminderTypes";
|
||||
|
||||
// Jittered expiration for completed/cancelled reminders (30-90 days).
|
||||
function jitteredExpiration(): number {
|
||||
const days = 30 + Math.floor(Math.random() * 60);
|
||||
return Math.floor(Date.now() / 1_000) + days * 86_400;
|
||||
}
|
||||
|
||||
function extractDTag(event: RelayEvent): string | null {
|
||||
const tag = event.tags.find((t) => t[0] === "d");
|
||||
return tag?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a reminder `d`-tag with 128 bits of entropy (NIP-ER line 58 MUST).
|
||||
* `crypto.randomUUID()` is UUIDv4 = only 122 random bits, so use 16 raw bytes.
|
||||
*/
|
||||
function randomDTag(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function extractNotBefore(event: RelayEvent): number | undefined {
|
||||
const tag = event.tags.find((t) => t[0] === "not_before");
|
||||
return tag?.[1] ? parseNotBefore(tag[1]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a NIP-ER `not_before` tag value, mirroring the relay's strict
|
||||
* validator (NIP-ER line 60): ASCII digits only, no leading zero except "0",
|
||||
* and within `Number.MAX_SAFE_INTEGER`. Returns undefined for any value the
|
||||
* relay would reject, so the client ignores reminders the relay calls malformed.
|
||||
*/
|
||||
export function parseNotBefore(raw: string): number | undefined {
|
||||
if (!/^(0|[1-9][0-9]*)$/.test(raw)) return undefined;
|
||||
const val = Number(raw);
|
||||
return val <= Number.MAX_SAFE_INTEGER ? val : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate decrypted reminder plaintext against the shape this client writes,
|
||||
* returning a typed content object or null. NIP-ER (Content section) requires
|
||||
* clients to ignore plaintext that is not a JSON object, has an unknown
|
||||
* `status`, or has a malformed target/note — so anything off-shape fails closed.
|
||||
*/
|
||||
export function parseReminderContent(
|
||||
plaintext: string,
|
||||
): ReminderContent | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(plaintext);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (
|
||||
obj.status !== "pending" &&
|
||||
obj.status !== "done" &&
|
||||
obj.status !== "cancelled"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (obj.note !== undefined && typeof obj.note !== "string") return null;
|
||||
|
||||
let target: ReminderTarget | undefined;
|
||||
if (obj.target !== undefined) {
|
||||
const parsedTarget = parseTarget(obj.target);
|
||||
if (!parsedTarget) return null;
|
||||
target = parsedTarget;
|
||||
}
|
||||
|
||||
// A reminder must reference a target or carry a non-empty note.
|
||||
if (!target && !(typeof obj.note === "string" && obj.note.length > 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { status: obj.status, target, note: obj.note as string | undefined };
|
||||
}
|
||||
|
||||
function parseTarget(value: unknown): ReminderTarget | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const t = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof t.eventId !== "string" ||
|
||||
typeof t.channelId !== "string" ||
|
||||
typeof t.preview !== "string" ||
|
||||
typeof t.authorPubkey !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
eventId: t.eventId,
|
||||
channelId: t.channelId,
|
||||
preview: t.preview,
|
||||
authorPubkey: t.authorPubkey,
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptReminder(event: RelayEvent): Promise<Reminder | null> {
|
||||
const dTag = extractDTag(event);
|
||||
if (!dTag) return null;
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = await nip44DecryptFromSelf(event.content);
|
||||
} catch {
|
||||
console.warn("[reminderService] failed to decrypt reminder:", event.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = parseReminderContent(plaintext);
|
||||
if (!content) {
|
||||
console.warn("[reminderService] ignoring malformed reminder:", event.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: dTag,
|
||||
notBefore: extractNotBefore(event),
|
||||
content,
|
||||
createdAt: event.created_at,
|
||||
eventId: event.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchReminders(pubkey: string): Promise<Reminder[]> {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [KIND_EVENT_REMINDER],
|
||||
authors: [pubkey],
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
const results = await Promise.all(events.map(decryptReminder));
|
||||
return results.filter((r): r is Reminder => r !== null);
|
||||
}
|
||||
|
||||
export async function createReminder(
|
||||
target: ReminderTarget,
|
||||
notBefore: number,
|
||||
note?: string,
|
||||
): Promise<RelayEvent> {
|
||||
const dTag = randomDTag();
|
||||
const content: ReminderContent = {
|
||||
target,
|
||||
note,
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
const ciphertext = await nip44EncryptToSelf(JSON.stringify(content));
|
||||
const tags: string[][] = [
|
||||
["d", dTag],
|
||||
["not_before", String(notBefore)],
|
||||
];
|
||||
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_EVENT_REMINDER,
|
||||
content: ciphertext,
|
||||
tags,
|
||||
});
|
||||
|
||||
return relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out creating reminder.",
|
||||
"Failed to create reminder.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeReminder(
|
||||
_pubkey: string,
|
||||
reminder: Reminder,
|
||||
): Promise<RelayEvent> {
|
||||
const content: ReminderContent = {
|
||||
...reminder.content,
|
||||
status: "done",
|
||||
};
|
||||
|
||||
const ciphertext = await nip44EncryptToSelf(JSON.stringify(content));
|
||||
const expiration = jitteredExpiration();
|
||||
const tags: string[][] = [
|
||||
["d", reminder.id],
|
||||
["expiration", String(expiration)],
|
||||
];
|
||||
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_EVENT_REMINDER,
|
||||
content: ciphertext,
|
||||
createdAt: Math.max(Math.floor(Date.now() / 1_000), reminder.createdAt + 1),
|
||||
tags,
|
||||
});
|
||||
|
||||
return relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out completing reminder.",
|
||||
"Failed to complete reminder.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function snoozeReminder(
|
||||
_pubkey: string,
|
||||
reminder: Reminder,
|
||||
newNotBefore: number,
|
||||
): Promise<RelayEvent> {
|
||||
const content: ReminderContent = {
|
||||
...reminder.content,
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
const ciphertext = await nip44EncryptToSelf(JSON.stringify(content));
|
||||
const tags: string[][] = [
|
||||
["d", reminder.id],
|
||||
["not_before", String(newNotBefore)],
|
||||
];
|
||||
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_EVENT_REMINDER,
|
||||
content: ciphertext,
|
||||
createdAt: Math.max(Math.floor(Date.now() / 1_000), reminder.createdAt + 1),
|
||||
tags,
|
||||
});
|
||||
|
||||
return relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out snoozing reminder.",
|
||||
"Failed to snooze reminder.",
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelReminder(
|
||||
_pubkey: string,
|
||||
reminder: Reminder,
|
||||
): Promise<RelayEvent> {
|
||||
const content: ReminderContent = {
|
||||
...reminder.content,
|
||||
status: "cancelled",
|
||||
};
|
||||
|
||||
const ciphertext = await nip44EncryptToSelf(JSON.stringify(content));
|
||||
const expiration = jitteredExpiration();
|
||||
const tags: string[][] = [
|
||||
["d", reminder.id],
|
||||
["expiration", String(expiration)],
|
||||
];
|
||||
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_EVENT_REMINDER,
|
||||
content: ciphertext,
|
||||
createdAt: Math.max(Math.floor(Date.now() / 1_000), reminder.createdAt + 1),
|
||||
tags,
|
||||
});
|
||||
|
||||
return relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out cancelling reminder.",
|
||||
"Failed to cancel reminder.",
|
||||
);
|
||||
}
|
||||
33
desktop/src/features/reminders/lib/reminderTypes.ts
Normal file
33
desktop/src/features/reminders/lib/reminderTypes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export type ReminderStatus = "pending" | "done" | "cancelled";
|
||||
|
||||
export type ReminderTarget = {
|
||||
/** Event ID of the message being reminded about. */
|
||||
eventId: string;
|
||||
/** Channel ID where the message lives. */
|
||||
channelId: string;
|
||||
/** Preview text of the target message (truncated). */
|
||||
preview: string;
|
||||
/** Author pubkey of the target message. */
|
||||
authorPubkey: string;
|
||||
};
|
||||
|
||||
export type ReminderContent = {
|
||||
/** Target message. Absent for note-only reminders (NIP-ER allows either). */
|
||||
target?: ReminderTarget;
|
||||
/** Optional user-provided note. */
|
||||
note?: string;
|
||||
status: ReminderStatus;
|
||||
};
|
||||
|
||||
export type Reminder = {
|
||||
/** The d-tag (unique identifier for this reminder). */
|
||||
id: string;
|
||||
/** Unix timestamp (seconds) when the reminder is due. Absent for done/cancelled. */
|
||||
notBefore?: number;
|
||||
/** Decrypted reminder content. */
|
||||
content: ReminderContent;
|
||||
/** Event created_at timestamp. */
|
||||
createdAt: number;
|
||||
/** The raw event ID on the relay. */
|
||||
eventId: string;
|
||||
};
|
||||
211
desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx
Normal file
211
desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { CalendarClock, Clock } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { createReminder } from "@/features/reminders/lib/reminderService";
|
||||
import type { ReminderTarget } from "@/features/reminders/lib/reminderTypes";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
type TimePreset = {
|
||||
label: string;
|
||||
getTimestamp: () => number;
|
||||
};
|
||||
|
||||
function getNextWeekday9am(dayOffset: number): number {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
target.setDate(target.getDate() + dayOffset);
|
||||
target.setHours(9, 0, 0, 0);
|
||||
if (target.getTime() <= now.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
return Math.floor(target.getTime() / 1_000);
|
||||
}
|
||||
|
||||
const TIME_PRESETS: TimePreset[] = [
|
||||
{
|
||||
label: "In 30 minutes",
|
||||
getTimestamp: () => Math.floor(Date.now() / 1_000) + 30 * 60,
|
||||
},
|
||||
{
|
||||
label: "In 1 hour",
|
||||
getTimestamp: () => Math.floor(Date.now() / 1_000) + 60 * 60,
|
||||
},
|
||||
{
|
||||
label: "In 3 hours",
|
||||
getTimestamp: () => Math.floor(Date.now() / 1_000) + 3 * 60 * 60,
|
||||
},
|
||||
{
|
||||
label: "Tomorrow at 9am",
|
||||
getTimestamp: () => getNextWeekday9am(1),
|
||||
},
|
||||
{
|
||||
label: "Next Monday at 9am",
|
||||
getTimestamp: () => {
|
||||
const now = new Date();
|
||||
const daysUntilMonday = (8 - now.getDay()) % 7 || 7;
|
||||
return getNextWeekday9am(daysUntilMonday);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function todayDateString(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function RemindMeLaterDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
target,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
target: ReminderTarget | null;
|
||||
}) {
|
||||
const [note, setNote] = React.useState("");
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [customDate, setCustomDate] = React.useState(todayDateString);
|
||||
const [customTime, setCustomTime] = React.useState("09:00");
|
||||
|
||||
const handleSelect = async (preset: TimePreset) => {
|
||||
if (!target || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createReminder(target, preset.getTimestamp(), note || undefined);
|
||||
toast.success("Reminder set");
|
||||
onOpenChange(false);
|
||||
setNote("");
|
||||
} catch (error) {
|
||||
toast.error("Failed to create reminder");
|
||||
console.error("[RemindMeLaterDialog] create failed:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomSubmit = async () => {
|
||||
if (!target || isSubmitting || !customDate || !customTime) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const timestamp = Math.floor(
|
||||
new Date(`${customDate}T${customTime}`).getTime() / 1_000,
|
||||
);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
toast.error("Invalid date or time");
|
||||
return;
|
||||
}
|
||||
await createReminder(target, timestamp, note || undefined);
|
||||
toast.success("Reminder set");
|
||||
onOpenChange(false);
|
||||
setNote("");
|
||||
} catch (error) {
|
||||
toast.error("Failed to create reminder");
|
||||
console.error("[RemindMeLaterDialog] custom create failed:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Remind me later
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose when you want to be reminded about this message.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{TIME_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.label}
|
||||
variant="outline"
|
||||
className="justify-start"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void handleSelect(preset)}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
Custom date & time
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={customDate}
|
||||
onChange={(e) => setCustomDate(e.target.value)}
|
||||
min={todayDateString()}
|
||||
className="flex-1"
|
||||
aria-label="Reminder date"
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
value={customTime}
|
||||
onChange={(e) => setCustomTime(e.target.value)}
|
||||
className="w-[120px]"
|
||||
aria-label="Reminder time"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || !customDate || !customTime}
|
||||
onClick={() => void handleCustomSubmit()}
|
||||
>
|
||||
Set reminder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="reminder-note"
|
||||
className="text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
Note (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
id="reminder-note"
|
||||
placeholder="Add a note..."
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
39
desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx
Normal file
39
desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ReminderTarget } from "@/features/reminders/lib/reminderTypes";
|
||||
import { RemindMeLaterDialog } from "./RemindMeLaterDialog";
|
||||
|
||||
type RemindMeLaterContextValue = {
|
||||
openReminder: (target: ReminderTarget) => void;
|
||||
};
|
||||
|
||||
const RemindMeLaterContext = React.createContext<RemindMeLaterContextValue>({
|
||||
openReminder: () => {},
|
||||
});
|
||||
|
||||
export function useRemindLater() {
|
||||
return React.useContext(RemindMeLaterContext);
|
||||
}
|
||||
|
||||
export function RemindMeLaterProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [target, setTarget] = React.useState<ReminderTarget | null>(null);
|
||||
|
||||
const openReminder = React.useCallback((t: ReminderTarget) => {
|
||||
setTarget(t);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const contextValue = React.useMemo(() => ({ openReminder }), [openReminder]);
|
||||
|
||||
return (
|
||||
<RemindMeLaterContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<RemindMeLaterDialog open={open} onOpenChange={setOpen} target={target} />
|
||||
</RemindMeLaterContext.Provider>
|
||||
);
|
||||
}
|
||||
280
desktop/src/features/reminders/ui/RemindersPanel.tsx
Normal file
280
desktop/src/features/reminders/ui/RemindersPanel.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { Bell, Check, Clock, RotateCcw, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
cancelReminder,
|
||||
completeReminder,
|
||||
fetchReminders,
|
||||
snoozeReminder,
|
||||
} from "@/features/reminders/lib/reminderService";
|
||||
import type { Reminder } from "@/features/reminders/lib/reminderTypes";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const diff = timestamp - now;
|
||||
|
||||
if (diff < 0) {
|
||||
const absDiff = Math.abs(diff);
|
||||
if (absDiff < 60) return "just now";
|
||||
if (absDiff < 3600) return `${Math.floor(absDiff / 60)}m overdue`;
|
||||
if (absDiff < 86400) return `${Math.floor(absDiff / 3600)}h overdue`;
|
||||
return `${Math.floor(absDiff / 86400)}d overdue`;
|
||||
}
|
||||
|
||||
if (diff < 60) return "in less than a minute";
|
||||
if (diff < 3600) return `in ${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `in ${Math.floor(diff / 3600)}h`;
|
||||
return `in ${Math.floor(diff / 86400)}d`;
|
||||
}
|
||||
|
||||
type ReminderGroup = {
|
||||
label: string;
|
||||
reminders: Reminder[];
|
||||
};
|
||||
|
||||
function groupReminders(reminders: Reminder[]): ReminderGroup[] {
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const endOfToday = new Date();
|
||||
endOfToday.setHours(23, 59, 59, 999);
|
||||
const endOfTodaySecs = Math.floor(endOfToday.getTime() / 1_000);
|
||||
|
||||
const overdue: Reminder[] = [];
|
||||
const today: Reminder[] = [];
|
||||
const upcoming: Reminder[] = [];
|
||||
|
||||
for (const r of reminders) {
|
||||
if (r.content.status !== "pending") continue;
|
||||
if (!r.notBefore) continue;
|
||||
if (r.notBefore <= now) {
|
||||
overdue.push(r);
|
||||
} else if (r.notBefore <= endOfTodaySecs) {
|
||||
today.push(r);
|
||||
} else {
|
||||
upcoming.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
const groups: ReminderGroup[] = [];
|
||||
if (overdue.length > 0) groups.push({ label: "Overdue", reminders: overdue });
|
||||
if (today.length > 0) groups.push({ label: "Today", reminders: today });
|
||||
if (upcoming.length > 0)
|
||||
groups.push({ label: "Upcoming", reminders: upcoming });
|
||||
return groups;
|
||||
}
|
||||
|
||||
function ReminderRow({
|
||||
reminder,
|
||||
pubkey,
|
||||
onUpdate,
|
||||
}: {
|
||||
reminder: Reminder;
|
||||
pubkey: string;
|
||||
onUpdate: () => void;
|
||||
}) {
|
||||
const [isActing, setIsActing] = React.useState(false);
|
||||
|
||||
const handleComplete = async () => {
|
||||
setIsActing(true);
|
||||
try {
|
||||
await completeReminder(pubkey, reminder);
|
||||
toast.success("Reminder completed");
|
||||
onUpdate();
|
||||
} catch {
|
||||
toast.error("Failed to complete reminder");
|
||||
} finally {
|
||||
setIsActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSnooze = async () => {
|
||||
setIsActing(true);
|
||||
try {
|
||||
const newNotBefore = Math.floor(Date.now() / 1_000) + 3600;
|
||||
await snoozeReminder(pubkey, reminder, newNotBefore);
|
||||
toast.success("Snoozed for 1 hour");
|
||||
onUpdate();
|
||||
} catch {
|
||||
toast.error("Failed to snooze reminder");
|
||||
} finally {
|
||||
setIsActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setIsActing(true);
|
||||
try {
|
||||
await cancelReminder(pubkey, reminder);
|
||||
toast.success("Reminder cancelled");
|
||||
onUpdate();
|
||||
} catch {
|
||||
toast.error("Failed to cancel reminder");
|
||||
} finally {
|
||||
setIsActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isOverdue = reminder.notBefore
|
||||
? reminder.notBefore <= Math.floor(Date.now() / 1_000)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{reminder.content.target?.preview ||
|
||||
reminder.content.note ||
|
||||
"Reminder"}
|
||||
</p>
|
||||
{reminder.content.target && reminder.content.note ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||
{reminder.content.note}
|
||||
</p>
|
||||
) : null}
|
||||
{reminder.notBefore ? (
|
||||
<p
|
||||
className={`text-xs mt-1 ${isOverdue ? "text-destructive font-medium" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Clock className="inline h-3 w-3 mr-1" />
|
||||
{formatRelativeTime(reminder.notBefore)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isActing}
|
||||
onClick={() => void handleComplete()}
|
||||
title="Complete"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isActing}
|
||||
onClick={() => void handleSnooze()}
|
||||
title="Snooze 1 hour"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={isActing}
|
||||
onClick={() => void handleCancel()}
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RemindersPanel({ pubkey }: { pubkey: string }) {
|
||||
const [reminders, setReminders] = React.useState<Reminder[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
const loadReminders = React.useCallback(async () => {
|
||||
try {
|
||||
const fetched = await fetchReminders(pubkey);
|
||||
setReminders(fetched);
|
||||
} catch (error) {
|
||||
console.error("[RemindersPanel] fetch failed:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [pubkey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadReminders();
|
||||
}, [loadReminders]);
|
||||
|
||||
// Due-detection: check every 60s and show toast for newly due reminders.
|
||||
const lastDueCheckRef = React.useRef<number>(Math.floor(Date.now() / 1_000));
|
||||
React.useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const lastCheck = lastDueCheckRef.current;
|
||||
lastDueCheckRef.current = now;
|
||||
|
||||
for (const r of reminders) {
|
||||
if (r.content.status !== "pending") continue;
|
||||
if (!r.notBefore) continue;
|
||||
if (r.notBefore > lastCheck && r.notBefore <= now) {
|
||||
toast("Reminder due", {
|
||||
description:
|
||||
r.content.target?.preview ||
|
||||
r.content.note ||
|
||||
"A reminder is waiting",
|
||||
icon: <Bell className="h-4 w-4" />,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [reminders]);
|
||||
|
||||
const groups = groupReminders(reminders);
|
||||
const pendingCount = reminders.filter(
|
||||
(r) => r.content.status === "pending",
|
||||
).length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Loading reminders...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingCount === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-8">
|
||||
<Bell className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No pending reminders</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Use "Remind me later" on any message to create one.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b px-4 py-3">
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Reminders
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
({pendingCount})
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{group.label}
|
||||
</h3>
|
||||
{group.reminders.map((r) => (
|
||||
<ReminderRow
|
||||
key={r.id}
|
||||
reminder={r}
|
||||
pubkey={pubkey}
|
||||
onUpdate={() => void loadReminders()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
desktop/src/features/reminders/ui/RemindersScreen.tsx
Normal file
17
desktop/src/features/reminders/ui/RemindersScreen.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getIdentity } from "@/shared/api/tauri";
|
||||
import { RemindersPanel } from "./RemindersPanel";
|
||||
|
||||
export function RemindersScreen() {
|
||||
const identityQuery = useQuery({
|
||||
queryKey: ["identity"],
|
||||
queryFn: getIdentity,
|
||||
});
|
||||
|
||||
if (!identityQuery.data?.pubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RemindersPanel pubkey={identityQuery.data.pubkey} />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Activity,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Bell,
|
||||
Bot,
|
||||
FolderGit2,
|
||||
Home,
|
||||
@@ -102,6 +103,7 @@ type AppSidebarProps = {
|
||||
| "home"
|
||||
| "channel"
|
||||
| "agents"
|
||||
| "reminders"
|
||||
| "workflows"
|
||||
| "pulse"
|
||||
| "projects";
|
||||
@@ -142,6 +144,7 @@ type AppSidebarProps = {
|
||||
onSelectAgents: () => void;
|
||||
onSelectProjects: () => void;
|
||||
onSelectPulse: () => void;
|
||||
onSelectReminders: () => void;
|
||||
onSelectWorkflows: () => void;
|
||||
onSelectHome: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
@@ -203,6 +206,7 @@ export function AppSidebar({
|
||||
onSelectAgents,
|
||||
onSelectProjects,
|
||||
onSelectPulse,
|
||||
onSelectReminders,
|
||||
onSelectWorkflows,
|
||||
onSelectHome,
|
||||
onSelectChannel,
|
||||
@@ -520,6 +524,18 @@ export function AppSidebar({
|
||||
</SidebarMenuBadge>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
data-testid="open-reminders-view"
|
||||
isActive={selectedView === "reminders"}
|
||||
onClick={onSelectReminders}
|
||||
tooltip="Reminders"
|
||||
type="button"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
<span>Reminders</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<FeatureGate feature="workflows">
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
|
||||
@@ -33,6 +33,7 @@ export const KIND_AGENT_OBSERVER_FRAME = 24200;
|
||||
export const KIND_MESH_STATUS_REPORT = 24620;
|
||||
export const KIND_MESH_CONNECT_REQUEST = 24621;
|
||||
export const KIND_MESH_CALL_ME_NOW = 24622;
|
||||
export const KIND_EVENT_REMINDER = 30300;
|
||||
export const KIND_REPO_ANNOUNCEMENT = 30617;
|
||||
// NIP-DV: relay-signed per-viewer DM visibility snapshot (d=viewer pubkey,
|
||||
// h-tags = currently-hidden DM channel ids).
|
||||
|
||||
@@ -79,6 +79,20 @@ const DialogHeader = ({
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
@@ -108,6 +122,7 @@ export {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/shared/api/customEmoji";
|
||||
import {
|
||||
KIND_DM_VISIBILITY,
|
||||
KIND_EVENT_REMINDER,
|
||||
KIND_STREAM_MESSAGE_EDIT,
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
KIND_USER_STATUS,
|
||||
@@ -616,6 +617,7 @@ declare global {
|
||||
createdAt: number;
|
||||
slotId: string;
|
||||
}) => unknown;
|
||||
__BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1485,6 +1487,7 @@ const mockChannels: MockChannel[] = [
|
||||
|
||||
const mockMessages = new Map<string, RelayEvent[]>();
|
||||
const mockUserStatuses: RelayEvent[] = [];
|
||||
const mockReminderEvents: RelayEvent[] = [];
|
||||
let mockRelayMembers: RawRelayMember[] = [];
|
||||
const mockSockets = new Map<number, MockSocket>();
|
||||
let mockWebsocketSendMutexWedged = false;
|
||||
@@ -5729,6 +5732,16 @@ function sendToMockSocket(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filter.kinds?.includes(KIND_EVENT_REMINDER)) {
|
||||
const authors = filter.authors?.map((a) => a.toLowerCase());
|
||||
for (const event of mockReminderEvents) {
|
||||
if (authors && !authors.includes(event.pubkey.toLowerCase())) continue;
|
||||
sendWsText(socket.handler, ["EVENT", subId, event]);
|
||||
}
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const channelId = filter["#h"]?.[0];
|
||||
if (!channelId) {
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
@@ -5786,6 +5799,22 @@ function sendToMockSocket(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.kind === KIND_EVENT_REMINDER) {
|
||||
// Upsert by d-tag (replaceable event)
|
||||
const dTag = event.tags.find((t) => t[0] === "d")?.[1];
|
||||
if (dTag) {
|
||||
const idx = mockReminderEvents.findIndex(
|
||||
(e) =>
|
||||
e.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
|
||||
e.tags.some((t) => t[0] === "d" && t[1] === dTag),
|
||||
);
|
||||
if (idx >= 0) mockReminderEvents.splice(idx, 1);
|
||||
}
|
||||
mockReminderEvents.push(event);
|
||||
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.kind === 20001) {
|
||||
const status = event.content;
|
||||
if (status === "online" || status === "away" || status === "offline") {
|
||||
@@ -5959,6 +5988,13 @@ export function maybeInstallE2eTauriMocks() {
|
||||
).connectionStateEmitter.set(state);
|
||||
};
|
||||
|
||||
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__ = (reminders) => {
|
||||
mockReminderEvents.length = 0;
|
||||
for (const r of reminders) {
|
||||
mockReminderEvents.push(r);
|
||||
}
|
||||
};
|
||||
|
||||
window.__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__ = (stall) => {
|
||||
const config = getConfig();
|
||||
if (!config?.mock) return;
|
||||
|
||||
230
desktop/tests/e2e/reminders-screenshots.spec.ts
Normal file
230
desktop/tests/e2e/reminders-screenshots.spec.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/reminders";
|
||||
const MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
|
||||
function mockReminderEvent(opts: {
|
||||
id: string;
|
||||
dTag: string;
|
||||
content: string;
|
||||
notBefore: number;
|
||||
createdAt?: number;
|
||||
}) {
|
||||
return {
|
||||
id: opts.id,
|
||||
pubkey: MOCK_PUBKEY,
|
||||
created_at: opts.createdAt ?? Math.floor(Date.now() / 1000) - 300,
|
||||
kind: 30300,
|
||||
tags: [
|
||||
["d", opts.dTag],
|
||||
["not_before", String(opts.notBefore)],
|
||||
],
|
||||
content: opts.content,
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("reminders screenshots", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
test("01 — sidebar shows Reminders nav item", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const remindersNav = page.getByTestId("open-reminders-view");
|
||||
await expect(remindersNav).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/01-sidebar-reminders-nav.png`,
|
||||
clip: { x: 0, y: 0, width: 256, height: 720 },
|
||||
});
|
||||
});
|
||||
|
||||
test("02 — message action menu shows Remind me later", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const messageRow = page.getByTestId("message-row").first();
|
||||
await messageRow.hover();
|
||||
|
||||
const moreActionsButton = messageRow.getByRole("button", {
|
||||
name: "More actions",
|
||||
});
|
||||
await expect(moreActionsButton).toBeVisible();
|
||||
await moreActionsButton.click();
|
||||
|
||||
const remindItem = page.getByRole("menuitem", {
|
||||
name: "Remind me later",
|
||||
});
|
||||
await expect(remindItem).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/02-message-action-remind-later.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("03 — Remind me later dialog with time presets", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const messageRow = page.getByTestId("message-row").first();
|
||||
await messageRow.hover();
|
||||
|
||||
const moreActionsButton = messageRow.getByRole("button", {
|
||||
name: "More actions",
|
||||
});
|
||||
await moreActionsButton.click();
|
||||
|
||||
const remindItem = page.getByRole("menuitem", {
|
||||
name: "Remind me later",
|
||||
});
|
||||
await expect(remindItem).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await remindItem.click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("Remind me later")).toBeVisible();
|
||||
await expect(dialog.getByText("In 30 minutes")).toBeVisible();
|
||||
await expect(dialog.getByText("Custom date & time")).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/03-remind-me-later-dialog.png`,
|
||||
clip: { x: 300, y: 50, width: 680, height: 620 },
|
||||
});
|
||||
});
|
||||
|
||||
test("04 — Reminders panel empty state", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await page.getByTestId("open-reminders-view").click();
|
||||
await expect(page.getByText("No pending reminders")).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/04-reminders-panel-empty.png`,
|
||||
clip: { x: 0, y: 0, width: 900, height: 720 },
|
||||
});
|
||||
});
|
||||
|
||||
test("05 — Reminders panel with active pending reminder", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
// Seed a pending reminder due in the future
|
||||
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const reminderContent = JSON.stringify({
|
||||
target: {
|
||||
eventId: "mock-general-welcome",
|
||||
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
preview: "Welcome to #general",
|
||||
authorPubkey: MOCK_PUBKEY,
|
||||
},
|
||||
note: "Follow up on this message",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ event }) => {
|
||||
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([event]);
|
||||
},
|
||||
{
|
||||
event: mockReminderEvent({
|
||||
id: "reminder-active-01",
|
||||
dTag: "rem-active-01",
|
||||
content: reminderContent,
|
||||
notBefore: futureTimestamp,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await page.getByTestId("open-reminders-view").click();
|
||||
await expect(page.getByText("Follow up on this message")).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/05-reminders-panel-active.png`,
|
||||
clip: { x: 0, y: 0, width: 900, height: 720 },
|
||||
});
|
||||
});
|
||||
|
||||
test("06 — Reminders panel with fired/overdue reminder", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
// Seed a reminder that has already fired (notBefore in the past)
|
||||
const pastTimestamp = Math.floor(Date.now() / 1000) - 7200;
|
||||
const overdueContent = JSON.stringify({
|
||||
target: {
|
||||
eventId: "mock-general-alice",
|
||||
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
preview: "Hey team — checking in.",
|
||||
authorPubkey:
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
},
|
||||
note: "Reply to Alice",
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
// Also seed a future reminder so both states are visible
|
||||
const futureTimestamp = Math.floor(Date.now() / 1000) + 7200;
|
||||
const activeContent = JSON.stringify({
|
||||
target: {
|
||||
eventId: "mock-general-welcome",
|
||||
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
preview: "Welcome to #general",
|
||||
authorPubkey: MOCK_PUBKEY,
|
||||
},
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
({ events }) => {
|
||||
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.(events);
|
||||
},
|
||||
{
|
||||
events: [
|
||||
mockReminderEvent({
|
||||
id: "reminder-overdue-01",
|
||||
dTag: "rem-overdue-01",
|
||||
content: overdueContent,
|
||||
notBefore: pastTimestamp,
|
||||
}),
|
||||
mockReminderEvent({
|
||||
id: "reminder-upcoming-01",
|
||||
dTag: "rem-upcoming-01",
|
||||
content: activeContent,
|
||||
notBefore: futureTimestamp,
|
||||
}),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await page.getByTestId("open-reminders-view").click();
|
||||
await expect(page.getByText("Reply to Alice")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Overdue" })).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/06-reminders-panel-fired.png`,
|
||||
clip: { x: 0, y: 0, width: 900, height: 720 },
|
||||
});
|
||||
});
|
||||
});
|
||||
15
desktop/tests/helpers/animations.ts
Normal file
15
desktop/tests/helpers/animations.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Wait for all in-flight CSS/Web animations on the page to finish.
|
||||
*
|
||||
* Radix UI components animate in via CSS transitions. Playwright's
|
||||
* `toBeVisible()` resolves mid-animation, producing greyed-out or
|
||||
* partially-rendered screenshots. Call this before any `page.screenshot()`
|
||||
* or `locator.screenshot()` to guarantee a fully-rendered frame.
|
||||
*/
|
||||
export async function waitForAnimations(page: Page): Promise<void> {
|
||||
await page.evaluate(() =>
|
||||
Promise.all(document.getAnimations().map((a) => a.finished)),
|
||||
);
|
||||
}
|
||||
@@ -232,6 +232,13 @@ try {
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Wait for all CSS/Web animations to finish before capturing.
|
||||
// Radix components animate in via CSS — without this, screenshots
|
||||
// are taken mid-transition and appear greyed-out or partially rendered.
|
||||
await page.evaluate(() =>
|
||||
Promise.all(document.getAnimations().map((a) => a.finished)),
|
||||
);
|
||||
|
||||
const filepath = join(outdir, `${args.name}.png`);
|
||||
const clipOpts = args.clip
|
||||
? (() => {
|
||||
|
||||
Reference in New Issue
Block a user