mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
275ad4be4a
commit
757998cb03
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user