feat(desktop): add custom date/time picker to Remind Me Later dialog

Add a "Custom date & time" section below the time presets in the
RemindMeLaterDialog, using native date and time inputs styled with the
existing Input component. The date picker defaults to today and prevents
selecting past dates. A "Set reminder" button creates the reminder at
the user-selected timestamp.

Also extends the screenshot spec to 6 tests: widens the action menu
screenshot clip, adds tests for the reminders panel with active and
fired/overdue reminders, and adds KIND_EVENT_REMINDER mock support to
the e2e bridge for seeding reminder data in tests.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-11 15:33:58 -04:00
co-authored by Will Pfleger
parent 7388a58d76
commit 134e0f75ea
3 changed files with 235 additions and 3 deletions
@@ -1,10 +1,11 @@
import { Clock } from "lucide-react";
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,
@@ -58,6 +59,14 @@ const TIME_PRESETS: TimePreset[] = [
},
];
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,
@@ -69,6 +78,8 @@ export function RemindMeLaterDialog({
}) {
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;
@@ -86,6 +97,29 @@ export function RemindMeLaterDialog({
}
};
const handleCustomSubmit = async () => {
if (!target || isSubmitting || !customDate || !customTime) return;
setIsSubmitting(true);
try {
const timestamp = Math.floor(
new Date(`${customDate}T${customTime}`).getTime() / 1_000,
);
if (Number.isNaN(timestamp)) {
toast.error("Invalid date or time");
return;
}
await createReminder(target, timestamp, note || undefined);
toast.success("Reminder set");
onOpenChange(false);
setNote("");
} catch (error) {
toast.error("Failed to create reminder");
console.error("[RemindMeLaterDialog] custom create failed:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
@@ -113,6 +147,38 @@ export function RemindMeLaterDialog({
))}
</div>
<div className="space-y-3 border-t pt-3">
<p className="text-sm font-medium flex items-center gap-2">
<CalendarClock className="h-4 w-4" />
Custom date & time
</p>
<div className="flex gap-2">
<Input
type="date"
value={customDate}
onChange={(e) => setCustomDate(e.target.value)}
min={todayDateString()}
className="flex-1"
aria-label="Reminder date"
/>
<Input
type="time"
value={customTime}
onChange={(e) => setCustomTime(e.target.value)}
className="w-[120px]"
aria-label="Reminder time"
/>
</div>
<Button
variant="default"
className="w-full"
disabled={isSubmitting || !customDate || !customTime}
onClick={() => void handleCustomSubmit()}
>
Set reminder
</Button>
</div>
<div className="space-y-2">
<label
htmlFor="reminder-note"
+35
View File
@@ -11,6 +11,7 @@ import {
} from "@/shared/api/customEmoji";
import {
KIND_DM_VISIBILITY,
KIND_EVENT_REMINDER,
KIND_STREAM_MESSAGE_EDIT,
KIND_SYSTEM_MESSAGE,
KIND_USER_STATUS,
@@ -594,6 +595,7 @@ declare global {
createdAt: number;
slotId: string;
}) => unknown;
__BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void;
}
}
@@ -1452,6 +1454,7 @@ const mockChannels: MockChannel[] = [
const mockMessages = new Map<string, RelayEvent[]>();
const mockUserStatuses: RelayEvent[] = [];
const mockReminderEvents: RelayEvent[] = [];
let mockRelayMembers: RawRelayMember[] = [];
const mockSockets = new Map<number, MockSocket>();
let mockWebsocketSendMutexWedged = false;
@@ -5554,6 +5557,16 @@ function sendToMockSocket(args: {
return;
}
if (filter.kinds?.includes(KIND_EVENT_REMINDER)) {
const authors = filter.authors?.map((a) => a.toLowerCase());
for (const event of mockReminderEvents) {
if (authors && !authors.includes(event.pubkey.toLowerCase())) continue;
sendWsText(socket.handler, ["EVENT", subId, event]);
}
sendWsText(socket.handler, ["EOSE", subId]);
return;
}
const channelId = filter["#h"]?.[0];
if (!channelId) {
sendWsText(socket.handler, ["EOSE", subId]);
@@ -5611,6 +5624,22 @@ function sendToMockSocket(args: {
return;
}
if (event.kind === KIND_EVENT_REMINDER) {
// Upsert by d-tag (replaceable event)
const dTag = event.tags.find((t) => t[0] === "d")?.[1];
if (dTag) {
const idx = mockReminderEvents.findIndex(
(e) =>
e.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
e.tags.some((t) => t[0] === "d" && t[1] === dTag),
);
if (idx >= 0) mockReminderEvents.splice(idx, 1);
}
mockReminderEvents.push(event);
sendWsText(socket.handler, ["OK", event.id, true, ""]);
return;
}
if (event.kind === 20001) {
const status = event.content;
if (status === "online" || status === "away" || status === "offline") {
@@ -5769,6 +5798,12 @@ export function maybeInstallE2eTauriMocks() {
emitMockLiveEvent(GLOBAL_MOCK_SUBSCRIPTION, event);
return event;
};
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__ = (reminders) => {
mockReminderEvents.length = 0;
for (const r of reminders) {
mockReminderEvents.push(r);
}
};
window.__BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__ = (stall) => {
const config = getConfig();
if (!config?.mock) return;
+133 -2
View File
@@ -4,6 +4,28 @@ import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/reminders";
const MOCK_PUBKEY = "deadbeef".repeat(8);
function mockReminderEvent(opts: {
id: string;
dTag: string;
content: string;
notBefore: number;
createdAt?: number;
}) {
return {
id: opts.id,
pubkey: MOCK_PUBKEY,
created_at: opts.createdAt ?? Math.floor(Date.now() / 1000) - 300,
kind: 30300,
tags: [
["d", opts.dTag],
["not_before", String(opts.notBefore)],
],
content: opts.content,
sig: "mocksig".repeat(20).slice(0, 128),
};
}
test.describe("reminders screenshots", () => {
test.beforeEach(async ({ page }) => {
@@ -47,7 +69,7 @@ test.describe("reminders screenshots", () => {
await page.screenshot({
path: `${SHOTS}/02-message-action-remind-later.png`,
clip: { x: 0, y: 0, width: 450, height: 720 },
clip: { x: 0, y: 0, width: 900, height: 720 },
});
});
@@ -75,11 +97,12 @@ test.describe("reminders screenshots", () => {
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Remind me later")).toBeVisible();
await expect(dialog.getByText("In 30 minutes")).toBeVisible();
await expect(dialog.getByText("Custom date & time")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/03-remind-me-later-dialog.png`,
clip: { x: 300, y: 100, width: 680, height: 520 },
clip: { x: 300, y: 50, width: 680, height: 620 },
});
});
@@ -97,4 +120,112 @@ test.describe("reminders screenshots", () => {
clip: { x: 0, y: 0, width: 900, height: 720 },
});
});
test("05 — Reminders panel with active pending reminder", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Seed a pending reminder due in the future
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
const reminderContent = JSON.stringify({
target: {
eventId: "mock-general-welcome",
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
preview: "Welcome to #general",
authorPubkey: MOCK_PUBKEY,
},
note: "Follow up on this message",
status: "pending",
});
await page.evaluate(
({ event }) => {
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.([event]);
},
{
event: mockReminderEvent({
id: "reminder-active-01",
dTag: "rem-active-01",
content: reminderContent,
notBefore: futureTimestamp,
}),
},
);
await page.getByTestId("open-reminders-view").click();
await expect(page.getByText("Follow up on this message")).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/05-reminders-panel-active.png`,
clip: { x: 0, y: 0, width: 900, height: 720 },
});
});
test("06 — Reminders panel with fired/overdue reminder", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Seed a reminder that has already fired (notBefore in the past)
const pastTimestamp = Math.floor(Date.now() / 1000) - 7200;
const overdueContent = JSON.stringify({
target: {
eventId: "mock-general-alice",
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
preview: "Hey team — checking in.",
authorPubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
},
note: "Reply to Alice",
status: "pending",
});
// Also seed a future reminder so both states are visible
const futureTimestamp = Math.floor(Date.now() / 1000) + 7200;
const activeContent = JSON.stringify({
target: {
eventId: "mock-general-welcome",
channelId: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
preview: "Welcome to #general",
authorPubkey: MOCK_PUBKEY,
},
status: "pending",
});
await page.evaluate(
({ events }) => {
window.__BUZZ_E2E_SEED_MOCK_REMINDERS__?.(events);
},
{
events: [
mockReminderEvent({
id: "reminder-overdue-01",
dTag: "rem-overdue-01",
content: overdueContent,
notBefore: pastTimestamp,
}),
mockReminderEvent({
id: "reminder-upcoming-01",
dTag: "rem-upcoming-01",
content: activeContent,
notBefore: futureTimestamp,
}),
],
},
);
await page.getByTestId("open-reminders-view").click();
await expect(page.getByText("Reply to Alice")).toBeVisible();
await expect(page.getByRole("heading", { name: "Overdue" })).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/06-reminders-panel-fired.png`,
clip: { x: 0, y: 0, width: 900, height: 720 },
});
});
});