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:
Will Pfleger
2026-06-17 11:03:45 -04:00
committed by GitHub
parent 26563968cb
commit ff824a365e
25 changed files with 1670 additions and 236 deletions

View File

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

View File

@@ -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) =>

View File

@@ -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"],

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"),

View 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>
);
}

View File

@@ -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}

View File

@@ -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}

View 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);
});

View 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.",
);
}

View 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;
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View File

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

View File

@@ -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).

View File

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

View File

@@ -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;

View 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 },
});
});
});

View 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)),
);
}

View File

@@ -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
? (() => {