feat(desktop): add NIP-ER reminder UI — create, view, and manage encrypted reminders

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-11 15:32:26 -04:00
committed by Will Pfleger
co-authored by Will Pfleger
parent 275ad4be4a
commit 757998cb03
16 changed files with 819 additions and 0 deletions
+15
View File
@@ -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() {
}}
>
<HuddleProvider>
<RemindMeLaterProvider>
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
{!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() {
</SidebarProvider>
<HuddleBar />
</div>
</RemindMeLaterProvider>
</HuddleProvider>
</AppShellProvider>
</ChannelNavigationProvider>
@@ -90,6 +90,17 @@ export function useAppNavigation() {
[commitNavigation],
);
const goReminders = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/reminders",
},
behavior,
),
[commitNavigation],
);
const goProject = React.useCallback(
(projectId: string, behavior?: NavigationBehavior) =>
commitNavigation(
@@ -239,6 +250,7 @@ export function useAppNavigation() {
goProject,
goProjects,
goPulse,
goReminders,
goWorkflow,
goWorkflows,
openSearchHit,
+21
View File
@@ -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,
+1
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("/workflows", "workflows.tsx"),
route("/workflows/$workflowId", "workflows.$workflowId.tsx"),
route("/projects", "projects.tsx"),
+19
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>
);
}
@@ -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({
</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}`}
@@ -265,6 +279,7 @@ export function MessageActionBar({
onMarkUnread,
onReactionBadgeBurstRequest,
onReactionSelect,
onRemindLater,
onReply,
onUnfollowThread,
reactionErrorMessage = null,
@@ -281,6 +296,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;
@@ -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}
@@ -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}
@@ -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<Reminder | null> {
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<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 = 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<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.",
);
}
@@ -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;
};
@@ -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 (
<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-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>
);
}
@@ -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>
);
}
@@ -0,0 +1,275 @@
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 || "Message"}
</p>
{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 || "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>
);
}
@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { getIdentity } from "@/shared/api/tauri";
import { RemindersPanel } from "./RemindersPanel";
export function RemindersScreen() {
const identityQuery = useQuery({
queryKey: ["identity"],
queryFn: getIdentity,
});
if (!identityQuery.data?.pubkey) {
return null;
}
return <RemindersPanel pubkey={identityQuery.data.pubkey} />;
}
@@ -3,6 +3,7 @@ import {
Activity,
ArrowDown,
ArrowUp,
Bell,
Bot,
FolderGit2,
Home,
@@ -95,6 +96,7 @@ type AppSidebarProps = {
| "home"
| "channel"
| "agents"
| "reminders"
| "workflows"
| "pulse"
| "projects";
@@ -135,6 +137,7 @@ type AppSidebarProps = {
onSelectAgents: () => void;
onSelectProjects: () => void;
onSelectPulse: () => void;
onSelectReminders: () => void;
onSelectWorkflows: () => void;
onSelectHome: () => void;
onSelectChannel: (channelId: string) => void;
@@ -196,6 +199,7 @@ export function AppSidebar({
onSelectAgents,
onSelectProjects,
onSelectPulse,
onSelectReminders,
onSelectWorkflows,
onSelectHome,
onSelectChannel,
@@ -492,6 +496,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
+1
View File
@@ -32,6 +32,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).
+15
View File
@@ -69,6 +69,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>
@@ -98,6 +112,7 @@ export {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPortal,
DialogTitle,