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