From 757998cb032d8a2e9139150054b41e4ab80a0e21 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 10 Jun 2026 20:11:26 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat(desktop):=20add=20NIP-ER=20reminder=20?= =?UTF-8?q?UI=20=E2=80=94=20create,=20view,=20and=20manage=20encrypted=20r?= =?UTF-8?q?eminders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the desktop client UI for kind:30300 event reminders (NIP-ER): - Service layer (reminderService.ts): create, complete, snooze, and cancel reminders as individual parameterized-replaceable events with NIP-44 encrypt-to-self. Each reminder has a random d-tag, not_before timestamp, and encrypted content containing the target message reference and optional note. - "Remind me later" action: new menu item in MessageActionBar opens a dialog with time presets (30min, 1hr, 3hr, tomorrow 9am, next Monday 9am) and an optional note field. - Reminders panel: accessible from sidebar (Bell icon), shows pending reminders grouped by due status (overdue/today/upcoming) with complete/snooze/cancel action buttons per row. - Due-detection: local 60-second interval checks not_before against Date.now() and fires toast notifications when reminders become due. No relay scheduler dependency for v1 — purely client-side. - RemindMeLaterProvider context avoids prop-threading through 4 intermediate components; MessageRow uses the hook directly. All reminder content is NIP-44 encrypted — never stored or transmitted in plaintext. Completed/cancelled reminders get jittered 30-90 day expiration tags for eventual relay-side cleanup. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src/app/AppShell.tsx | 15 + .../src/app/navigation/useAppNavigation.ts | 12 + desktop/src/app/routeTree.gen.ts | 21 ++ desktop/src/app/routes.ts | 1 + desktop/src/app/routes/reminders.tsx | 19 ++ .../features/messages/ui/MessageActionBar.tsx | 18 ++ .../src/features/messages/ui/MessageRow.tsx | 10 + .../features/reminders/lib/reminderService.ts | 183 ++++++++++++ .../features/reminders/lib/reminderTypes.ts | 32 ++ .../reminders/ui/RemindMeLaterDialog.tsx | 145 +++++++++ .../reminders/ui/RemindMeLaterProvider.tsx | 39 +++ .../features/reminders/ui/RemindersPanel.tsx | 275 ++++++++++++++++++ .../features/reminders/ui/RemindersScreen.tsx | 17 ++ .../src/features/sidebar/ui/AppSidebar.tsx | 16 + desktop/src/shared/constants/kinds.ts | 1 + desktop/src/shared/ui/dialog.tsx | 15 + 16 files changed, 819 insertions(+) create mode 100644 desktop/src/app/routes/reminders.tsx create mode 100644 desktop/src/features/reminders/lib/reminderService.ts create mode 100644 desktop/src/features/reminders/lib/reminderTypes.ts create mode 100644 desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx create mode 100644 desktop/src/features/reminders/ui/RemindMeLaterProvider.tsx create mode 100644 desktop/src/features/reminders/ui/RemindersPanel.tsx create mode 100644 desktop/src/features/reminders/ui/RemindersScreen.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 642895101..d1b3b5faa 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -59,6 +59,7 @@ import { type SettingsSection, } 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"; @@ -80,6 +81,7 @@ type AppView = | "home" | "channel" | "agents" + | "reminders" | "workflows" | "pulse" | "projects"; @@ -163,6 +165,13 @@ function deriveShellRoute(pathname: string): { }; } + if (pathname === "/reminders") { + return { + selectedChannelId: null, + selectedView: "reminders", + }; + } + return { selectedChannelId: null, selectedView: "home", @@ -196,6 +205,7 @@ export function AppShell() { goChannel, goHome, goProjects, + goReminders, goPulse, goWorkflows, openSearchHit, @@ -746,6 +756,7 @@ export function AppShell() { }} > +
{!settingsOpen ? ( @@ -899,6 +910,9 @@ export function AppShell() { onSelectPulse={() => { void goPulse(); }} + onSelectReminders={() => { + void goReminders(); + }} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => { void goWorkflows(); @@ -963,6 +977,7 @@ export function AppShell() {
+
diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 5efd18c17..e2a7cea75 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -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( @@ -239,6 +250,7 @@ export function useAppNavigation() { goProject, goProjects, goPulse, + goReminders, goWorkflow, goWorkflows, openSearchHit, diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2e3c5c190..c8586dddb 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -6,6 +6,7 @@ import { Route as rootRouteImport } from "./routes/root"; import { Route as workflowsRouteImport } from "./routes/workflows"; +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"; @@ -20,6 +21,11 @@ const workflowsRoute = workflowsRouteImport.update({ path: "/workflows", getParentRoute: () => rootRouteImport, } as any); +const remindersRoute = remindersRouteImport.update({ + id: "/reminders", + path: "/reminders", + getParentRoute: () => rootRouteImport, +} as any); const pulseRoute = pulseRouteImport.update({ id: "/pulse", path: "/pulse", @@ -67,6 +73,7 @@ export interface FileRoutesByFullPath { "/agents": typeof agentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; + "/reminders": typeof remindersRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; @@ -78,6 +85,7 @@ export interface FileRoutesByTo { "/agents": typeof agentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; + "/reminders": typeof remindersRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; @@ -90,6 +98,7 @@ export interface FileRoutesById { "/agents": typeof agentsRoute; "/projects": typeof projectsRoute; "/pulse": typeof pulseRoute; + "/reminders": typeof remindersRoute; "/workflows": typeof workflowsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; @@ -103,6 +112,7 @@ export interface FileRouteTypes { | "/agents" | "/projects" | "/pulse" + | "/reminders" | "/workflows" | "/channels/$channelId" | "/projects/$projectId" @@ -114,6 +124,7 @@ export interface FileRouteTypes { | "/agents" | "/projects" | "/pulse" + | "/reminders" | "/workflows" | "/channels/$channelId" | "/projects/$projectId" @@ -125,6 +136,7 @@ export interface FileRouteTypes { | "/agents" | "/projects" | "/pulse" + | "/reminders" | "/workflows" | "/channels/$channelId" | "/projects/$projectId" @@ -137,6 +149,7 @@ export interface RootRouteChildren { agentsRoute: typeof agentsRoute; projectsRoute: typeof projectsRoute; pulseRoute: typeof pulseRoute; + remindersRoute: typeof remindersRoute; workflowsRoute: typeof workflowsRoute; channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute; projectsDotprojectIdRoute: typeof projectsDotprojectIdRoute; @@ -160,6 +173,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof pulseRouteImport; parentRoute: typeof rootRouteImport; }; + "/reminders": { + id: "/reminders"; + path: "/reminders"; + fullPath: "/reminders"; + preLoaderRoute: typeof remindersRouteImport; + parentRoute: typeof rootRouteImport; + }; "/projects": { id: "/projects"; path: "/projects"; @@ -217,6 +237,7 @@ const rootRouteChildren: RootRouteChildren = { agentsRoute: agentsRoute, projectsRoute: projectsRoute, pulseRoute: pulseRoute, + remindersRoute: remindersRoute, workflowsRoute: workflowsRoute, channelsDotchannelIdRoute: channelsDotchannelIdRoute, projectsDotprojectIdRoute: projectsDotprojectIdRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index 6b59b469a..b4d2f7066 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -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("/workflows", "workflows.tsx"), route("/workflows/$workflowId", "workflows.$workflowId.tsx"), route("/projects", "projects.tsx"), diff --git a/desktop/src/app/routes/reminders.tsx b/desktop/src/app/routes/reminders.tsx new file mode 100644 index 000000000..37599762b --- /dev/null +++ b/desktop/src/app/routes/reminders.tsx @@ -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 ( + + + + ); +} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index a0d42c456..9aadb2b38 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -1,6 +1,7 @@ import { BellOff, BellRing, + Clock, Copy, CornerUpLeft, EllipsisVertical, @@ -66,6 +67,7 @@ function MoreActionsMenu({ onFollowThread, onMarkUnread, onOpenChange, + onRemindLater, onUnfollowThread, open, isFollowingThread, @@ -79,6 +81,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; @@ -181,6 +184,17 @@ function MoreActionsMenu({ ) : null} + {onRemindLater ? ( + { + onRemindLater(message); + }} + > + + Remind me later + + ) : null} + {hasCopyActions && channelId ? ( void; onReactionBadgeBurstRequest?: (emoji: string) => void; onReactionSelect?: (emoji: string) => Promise; + onRemindLater?: (message: TimelineMessage) => void; onReply?: (message: TimelineMessage) => void; onUnfollowThread?: (message: TimelineMessage) => void; reactionErrorMessage?: string | null; @@ -298,6 +314,7 @@ export function MessageActionBar({ Boolean(onMarkUnread) || Boolean(onFollowThread) || Boolean(onUnfollowThread) || + Boolean(onRemindLater) || !message.pending; if (!hasReplyAction && !hasReactionAction && !hasMoreMenuActions) { @@ -418,6 +435,7 @@ export function MessageActionBar({ onFollowThread={onFollowThread} onMarkUnread={onMarkUnread} onOpenChange={setIsDropdownOpen} + onRemindLater={onRemindLater} onUnfollowThread={onUnfollowThread} open={isDropdownOpen} isFollowingThread={isFollowingThread} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index cfaf0e874..46c51da33 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -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"; @@ -81,6 +82,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], @@ -266,6 +268,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} diff --git a/desktop/src/features/reminders/lib/reminderService.ts b/desktop/src/features/reminders/lib/reminderService.ts new file mode 100644 index 000000000..155ade65a --- /dev/null +++ b/desktop/src/features/reminders/lib/reminderService.ts @@ -0,0 +1,183 @@ +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; +} + +function extractNotBefore(event: RelayEvent): number | undefined { + const tag = event.tags.find((t) => t[0] === "not_before"); + if (!tag?.[1]) return undefined; + const val = Number.parseInt(tag[1], 10); + return Number.isNaN(val) ? undefined : val; +} + +async function decryptReminder(event: RelayEvent): Promise { + const dTag = extractDTag(event); + if (!dTag) return null; + + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const content = JSON.parse(plaintext) as ReminderContent; + return { + id: dTag, + notBefore: extractNotBefore(event), + content, + createdAt: event.created_at, + eventId: event.id, + }; + } catch { + console.warn("[reminderService] failed to decrypt reminder:", event.id); + return null; + } +} + +export async function fetchReminders(pubkey: string): Promise { + 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 { + const dTag = crypto.randomUUID(); + 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 { + 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 { + 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 { + 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.", + ); +} diff --git a/desktop/src/features/reminders/lib/reminderTypes.ts b/desktop/src/features/reminders/lib/reminderTypes.ts new file mode 100644 index 000000000..acb56913d --- /dev/null +++ b/desktop/src/features/reminders/lib/reminderTypes.ts @@ -0,0 +1,32 @@ +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: 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; +}; diff --git a/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx b/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx new file mode 100644 index 000000000..b61b8ee08 --- /dev/null +++ b/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx @@ -0,0 +1,145 @@ +import { 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 { + 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); + }, + }, +]; + +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 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); + } + }; + + return ( + + + + + + Remind me later + + + Choose when you want to be reminded about this message. + + + +
+ {TIME_PRESETS.map((preset) => ( + + ))} +
+ +
+ +