diff --git a/AGENTS.md b/AGENTS.md index 7fb96adb7..1687d8649 100644 --- a/AGENTS.md +++ b/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) => diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 526589e57..5710b1094 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -37,6 +37,7 @@ export default defineConfig({ "**/workflows.spec.ts", "**/identity-archive.spec.ts", "**/identity-archive-hide.spec.ts", + "**/reminders-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 642895101..892bf7e6b 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,223 +756,233 @@ export function AppShell() { }} > -
- - {!settingsOpen ? ( - +
+ + {!settingsOpen ? ( + { + void goChannel(channelId); + }} + onOpenResult={handleOpenSearchResult} + searchHidden={topbarSearchHidden} + searchFocusRequest={searchFocusRequest} + /> + ) : null} + {settingsOpen ? ( + + + + ) : ( + <> + { + 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} + /> + + + + + + + + )} + + { + isChannelManagementOpen={isChannelManagementOpen} + onBrowseChannelJoin={handleBrowseChannelJoin} + onBrowseDialogOpenChange={handleBrowseDialogOpenChange} + onChannelManagementOpenChange={setIsChannelManagementOpen} + onDeleteActiveChannel={() => { + setIsChannelManagementOpen(false); + void goHome({ replace: true }); + }} + onSelectChannel={(channelId) => { void goChannel(channelId); }} - onOpenResult={handleOpenSearchResult} - searchHidden={topbarSearchHidden} - searchFocusRequest={searchFocusRequest} /> - ) : null} - {settingsOpen ? ( - - - - ) : ( - <> - { - 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(); - }} - 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} - /> - - - - - - - - )} - - { - setIsChannelManagementOpen(false); - void goHome({ replace: true }); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - /> - - -
+
+ +
+
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..c1f2597bd 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; @@ -153,6 +166,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof workflowsRouteImport; parentRoute: typeof rootRouteImport; }; + "/reminders": { + id: "/reminders"; + path: "/reminders"; + fullPath: "/reminders"; + preLoaderRoute: typeof remindersRouteImport; + parentRoute: typeof rootRouteImport; + }; "/pulse": { id: "/pulse"; path: "/pulse"; @@ -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..f17ea2793 --- /dev/null +++ b/desktop/src/features/reminders/ui/RemindMeLaterDialog.tsx @@ -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 ( + + + + + + Remind me later + + + Choose when you want to be reminded about this message. + + + +
+ {TIME_PRESETS.map((preset) => ( + + ))} +
+ +
+

+ + Custom date & time +

+
+ setCustomDate(e.target.value)} + min={todayDateString()} + className="flex-1" + aria-label="Reminder date" + /> + setCustomTime(e.target.value)} + className="w-[120px]" + aria-label="Reminder time" + /> +
+ +
+ +
+ +