mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): show reminder author + source and click-to-navigate in inbox (#1176)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
de3f21abcd
commit
1186ea48b9
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
hasNavigableTarget,
|
||||
resolveReminderDestination,
|
||||
} from "./reminderNavigation.ts";
|
||||
|
||||
const FULL_TARGET = {
|
||||
eventId: "evt-1",
|
||||
channelId: "chan-1",
|
||||
preview: "hello",
|
||||
authorPubkey: "author-1",
|
||||
};
|
||||
|
||||
/** Build a RelayEvent with the given e-tags. */
|
||||
function eventWithTags(tags) {
|
||||
return { id: FULL_TARGET.eventId, tags };
|
||||
}
|
||||
|
||||
// ── hasNavigableTarget ──────────────────────────────────────────────────────
|
||||
|
||||
test("hasNavigableTarget_full_target_is_navigable", () => {
|
||||
assert.equal(hasNavigableTarget(FULL_TARGET), true);
|
||||
});
|
||||
|
||||
test("hasNavigableTarget_absent_target_is_not_navigable", () => {
|
||||
assert.equal(hasNavigableTarget(undefined), false);
|
||||
});
|
||||
|
||||
test("hasNavigableTarget_empty_channelId_is_not_navigable", () => {
|
||||
assert.equal(hasNavigableTarget({ ...FULL_TARGET, channelId: "" }), false);
|
||||
});
|
||||
|
||||
test("hasNavigableTarget_empty_eventId_is_not_navigable", () => {
|
||||
assert.equal(hasNavigableTarget({ ...FULL_TARGET, eventId: "" }), false);
|
||||
});
|
||||
|
||||
test("hasNavigableTarget_empty_authorPubkey_is_not_navigable", () => {
|
||||
assert.equal(hasNavigableTarget({ ...FULL_TARGET, authorPubkey: "" }), false);
|
||||
});
|
||||
|
||||
// ── resolveReminderDestination ──────────────────────────────────────────────
|
||||
|
||||
test("resolveReminderDestination_nested_reply_lands_in_thread", async () => {
|
||||
const fetchEvent = async () =>
|
||||
eventWithTags([
|
||||
["e", "root-evt", "", "root"],
|
||||
["e", "parent-evt", "", "reply"],
|
||||
]);
|
||||
|
||||
const destination = await resolveReminderDestination(FULL_TARGET, fetchEvent);
|
||||
|
||||
assert.deepEqual(destination, {
|
||||
channelId: "chan-1",
|
||||
messageId: "evt-1",
|
||||
threadRootId: "root-evt",
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveReminderDestination_top_level_message_lands_channel_level", async () => {
|
||||
// A non-reply message has no reply tag -> getThreadReference yields null root.
|
||||
const fetchEvent = async () => eventWithTags([["h", "chan-1"]]);
|
||||
|
||||
const destination = await resolveReminderDestination(FULL_TARGET, fetchEvent);
|
||||
|
||||
assert.deepEqual(destination, {
|
||||
channelId: "chan-1",
|
||||
messageId: "evt-1",
|
||||
threadRootId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveReminderDestination_fetch_failure_degrades_to_channel_level", async () => {
|
||||
const fetchEvent = async () => {
|
||||
throw new Error("event not cached");
|
||||
};
|
||||
|
||||
const destination = await resolveReminderDestination(FULL_TARGET, fetchEvent);
|
||||
|
||||
assert.deepEqual(destination, {
|
||||
channelId: "chan-1",
|
||||
messageId: "evt-1",
|
||||
threadRootId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveReminderDestination_non_navigable_target_returns_null", async () => {
|
||||
let fetched = false;
|
||||
const fetchEvent = async () => {
|
||||
fetched = true;
|
||||
return eventWithTags([]);
|
||||
};
|
||||
|
||||
const destination = await resolveReminderDestination(
|
||||
{ ...FULL_TARGET, channelId: "" },
|
||||
fetchEvent,
|
||||
);
|
||||
|
||||
assert.equal(destination, null);
|
||||
assert.equal(fetched, false);
|
||||
});
|
||||
|
||||
test("resolveReminderDestination_absent_target_returns_null", async () => {
|
||||
const destination = await resolveReminderDestination(undefined, async () => {
|
||||
throw new Error("should not fetch");
|
||||
});
|
||||
|
||||
assert.equal(destination, null);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import type { ReminderTarget } from "@/features/reminders/lib/reminderTypes";
|
||||
import { getEventById } from "@/shared/api/tauri";
|
||||
|
||||
/**
|
||||
* Where a reminder click should land. Mirrors the `kind: "channel"` arm of the
|
||||
* search-hit destination: a message inside a channel, optionally inside a thread.
|
||||
*/
|
||||
export type ReminderDestination = {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
threadRootId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A target is navigable only when it carries a non-empty channelId, eventId,
|
||||
* and authorPubkey. The creation site stores `channelId ?? ""` /
|
||||
* `authorPubkey ?? ""`, so a *present* target can still hold empty strings —
|
||||
* those route to `/channels/` with an empty param or render a meaningless
|
||||
* author, and must be treated as non-navigable, same as note-only reminders
|
||||
* (no target at all).
|
||||
*/
|
||||
export function hasNavigableTarget(
|
||||
target: ReminderTarget | undefined,
|
||||
): target is ReminderTarget {
|
||||
return (
|
||||
target !== undefined &&
|
||||
target.channelId !== "" &&
|
||||
target.eventId !== "" &&
|
||||
target.authorPubkey !== ""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the in-thread destination for a reminder target.
|
||||
*
|
||||
* Reminder targets store no thread context, so — like the forum-comment branch
|
||||
* of `resolveSearchHitDestination` — we fetch the target event and derive its
|
||||
* thread root from the tags. A top-level (non-reply) message yields a null root
|
||||
* (channel-level, no thread to enter); a fetch failure degrades the same way.
|
||||
*
|
||||
* Returns null when the target is non-navigable (absent or empty fields).
|
||||
*/
|
||||
export async function resolveReminderDestination(
|
||||
target: ReminderTarget | undefined,
|
||||
fetchEvent: typeof getEventById = getEventById,
|
||||
): Promise<ReminderDestination | null> {
|
||||
if (!hasNavigableTarget(target)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await fetchEvent(target.eventId);
|
||||
return {
|
||||
channelId: target.channelId,
|
||||
messageId: target.eventId,
|
||||
threadRootId: getThreadReference(event.tags).rootId,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to resolve reminder thread destination",
|
||||
target.eventId,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
channelId: target.channelId,
|
||||
messageId: target.eventId,
|
||||
threadRootId: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,38 @@ import { Bell, Check, Clock, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import {
|
||||
useRemindersQuery,
|
||||
useReminderMutations,
|
||||
} from "@/features/reminders/hooks";
|
||||
import { groupReminders } from "@/features/reminders/lib/reminderFilters";
|
||||
import {
|
||||
hasNavigableTarget,
|
||||
resolveReminderDestination,
|
||||
} from "@/features/reminders/lib/reminderNavigation";
|
||||
import type { Reminder } from "@/features/reminders/lib/reminderTypes";
|
||||
import { SnoozeMenu } from "@/features/reminders/ui/SnoozeMenu";
|
||||
import { resolveChannelDisplayLabel } from "@/features/sidebar/lib/channelLabels";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
const UNKNOWN_CHANNEL_LABEL = "Unknown channel";
|
||||
|
||||
/** Author identity + source channel resolved for a reminder's target. */
|
||||
type ReminderSource = {
|
||||
authorLabel: string;
|
||||
avatarUrl: string | null;
|
||||
channelLabel: string;
|
||||
};
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
@@ -32,13 +56,18 @@ function formatRelativeTime(timestamp: number): string {
|
||||
function ReminderRow({
|
||||
reminder,
|
||||
pubkey,
|
||||
source,
|
||||
onNavigate,
|
||||
}: {
|
||||
reminder: Reminder;
|
||||
pubkey: string;
|
||||
source: ReminderSource | null;
|
||||
onNavigate: (reminder: Reminder) => void;
|
||||
}) {
|
||||
const { complete, snooze, cancel } = useReminderMutations(pubkey);
|
||||
const isDone = reminder.content.status === "done";
|
||||
const isActing = complete.isPending || snooze.isPending || cancel.isPending;
|
||||
const isNavigable = hasNavigableTarget(reminder.content.target);
|
||||
|
||||
const handleComplete = () => {
|
||||
complete.mutate(reminder, {
|
||||
@@ -71,26 +100,46 @@ function ReminderRow({
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-md border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 flex-col items-start gap-1 text-left enabled:hover:opacity-80 disabled:cursor-default"
|
||||
disabled={!isNavigable}
|
||||
onClick={isNavigable ? () => onNavigate(reminder) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{source ? (
|
||||
<div className="flex min-w-0 max-w-full items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<UserAvatar
|
||||
avatarUrl={source.avatarUrl}
|
||||
className="h-4 w-4 shrink-0"
|
||||
displayName={source.authorLabel}
|
||||
size="xs"
|
||||
/>
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{source.authorLabel}
|
||||
</span>
|
||||
<span className="shrink-0">in</span>
|
||||
<span className="truncate">{source.channelLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="max-w-full truncate text-sm font-medium">
|
||||
{reminder.content.target?.preview ||
|
||||
reminder.content.note ||
|
||||
"Reminder"}
|
||||
</p>
|
||||
{reminder.content.target && reminder.content.note ? (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
<p className="max-w-full truncate text-xs text-muted-foreground">
|
||||
{reminder.content.note}
|
||||
</p>
|
||||
) : null}
|
||||
{reminder.notBefore ? (
|
||||
<p
|
||||
className={`mt-1 text-xs ${isOverdue ? "font-medium text-destructive" : "text-muted-foreground"}`}
|
||||
className={`text-xs ${isOverdue ? "font-medium text-destructive" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Clock className="mr-1 inline h-3 w-3" />
|
||||
{formatRelativeTime(reminder.notBefore)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
{isDone ? null : (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
@@ -135,6 +184,70 @@ export function RemindersPanel({
|
||||
}) {
|
||||
const remindersQuery = useRemindersQuery(pubkey);
|
||||
const reminders = remindersQuery.data;
|
||||
const { goChannel } = useAppNavigation();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const currentPubkey = identityQuery.data?.pubkey;
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channels = channelsQuery.data;
|
||||
|
||||
const authorPubkeys = React.useMemo(
|
||||
() =>
|
||||
(reminders ?? [])
|
||||
.map((reminder) => reminder.content.target?.authorPubkey)
|
||||
.filter((authorPubkey): authorPubkey is string => !!authorPubkey),
|
||||
[reminders],
|
||||
);
|
||||
const usersBatchQuery = useUsersBatchQuery(authorPubkeys);
|
||||
const profiles: UserProfileLookup | undefined =
|
||||
usersBatchQuery.data?.profiles;
|
||||
|
||||
// Look up each reminder's author + source channel from the live profile and
|
||||
// channel queries. Channels/profiles can be missing — reminders outlive the
|
||||
// context they were set in (left/archived channel, hidden DM) — so fall back
|
||||
// to a resolved-or-truncated author label and a neutral channel label.
|
||||
const sources = React.useMemo(() => {
|
||||
const channelsById = new Map(
|
||||
(channels ?? []).map((channel) => [channel.id, channel]),
|
||||
);
|
||||
const map = new Map<string, ReminderSource>();
|
||||
for (const reminder of reminders ?? []) {
|
||||
const target = reminder.content.target;
|
||||
if (!hasNavigableTarget(target)) {
|
||||
continue;
|
||||
}
|
||||
const channel = channelsById.get(target.channelId);
|
||||
map.set(reminder.id, {
|
||||
authorLabel: resolveUserLabel({
|
||||
currentPubkey,
|
||||
profiles,
|
||||
pubkey: target.authorPubkey,
|
||||
}),
|
||||
avatarUrl:
|
||||
profiles?.[normalizePubkey(target.authorPubkey)]?.avatarUrl ?? null,
|
||||
channelLabel: channel
|
||||
? resolveChannelDisplayLabel(channel, currentPubkey, profiles)
|
||||
: UNKNOWN_CHANNEL_LABEL,
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [reminders, channels, profiles, currentPubkey]);
|
||||
|
||||
const handleNavigate = React.useCallback(
|
||||
async (reminder: Reminder) => {
|
||||
const destination = await resolveReminderDestination(
|
||||
reminder.content.target,
|
||||
);
|
||||
if (!destination) {
|
||||
return;
|
||||
}
|
||||
void goChannel(destination.channelId, {
|
||||
messageId: destination.messageId,
|
||||
threadRootId: destination.threadRootId,
|
||||
});
|
||||
},
|
||||
[goChannel],
|
||||
);
|
||||
|
||||
const groups = React.useMemo(
|
||||
() => groupReminders(reminders ?? [], includeDone),
|
||||
[reminders, includeDone],
|
||||
@@ -168,7 +281,13 @@ export function RemindersPanel({
|
||||
{group.label}
|
||||
</h3>
|
||||
{group.reminders.map((r) => (
|
||||
<ReminderRow key={r.id} reminder={r} pubkey={pubkey} />
|
||||
<ReminderRow
|
||||
key={r.id}
|
||||
onNavigate={handleNavigate}
|
||||
pubkey={pubkey}
|
||||
reminder={r}
|
||||
source={sources.get(r.id) ?? null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -246,3 +246,97 @@ test.describe("reminders screenshots", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Phase 2 — author + source at a glance, and click-to-navigate. Both cases
|
||||
// seed a reminder targeting Alice's seeded message in #general (event
|
||||
// `mock-general-alice`, channel `9a1657ac-…`), so the author resolves to
|
||||
// "alice" and the channel label to "general" from the live profile/channel
|
||||
// queries — no "Unknown channel" fallback.
|
||||
const PHASE2_SHOTS = "test-results/reminders-phase2";
|
||||
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const ALICE_PUBKEY =
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
|
||||
|
||||
function aliceReminderContent() {
|
||||
return JSON.stringify({
|
||||
target: {
|
||||
eventId: "mock-general-alice",
|
||||
channelId: GENERAL_CHANNEL_ID,
|
||||
preview: "Hey team — checking in.",
|
||||
authorPubkey: ALICE_PUBKEY,
|
||||
},
|
||||
note: "Reply to Alice",
|
||||
status: "pending",
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("reminders phase 2 — author, source, navigation", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
test("07 — reminder row shows author and source channel", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoInboxHome(page);
|
||||
|
||||
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
|
||||
await seedReminders(page, [
|
||||
mockReminderEvent({
|
||||
id: "reminder-phase2-source-01",
|
||||
dTag: "rem-phase2-source-01",
|
||||
content: aliceReminderContent(),
|
||||
notBefore: futureTimestamp,
|
||||
}),
|
||||
]);
|
||||
|
||||
await openRemindersFilter(page);
|
||||
// Author + source line resolves from the live profile/channel queries.
|
||||
// Scope to the reminders panel to avoid matching the "general" entry in
|
||||
// the sidebar channel list.
|
||||
const remindersPanel = page.getByTestId("home-inbox-reminders");
|
||||
await expect(
|
||||
remindersPanel.getByText("alice", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
remindersPanel.getByText("general", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(remindersPanel.getByText("Reply to Alice")).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${PHASE2_SHOTS}/01-reminder-row-author-channel.png`,
|
||||
clip: { x: 0, y: 0, width: 900, height: 720 },
|
||||
});
|
||||
});
|
||||
|
||||
test("08 — clicking a reminder navigates to the message in context", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoInboxHome(page);
|
||||
|
||||
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600;
|
||||
await seedReminders(page, [
|
||||
mockReminderEvent({
|
||||
id: "reminder-phase2-nav-01",
|
||||
dTag: "rem-phase2-nav-01",
|
||||
content: aliceReminderContent(),
|
||||
notBefore: futureTimestamp,
|
||||
}),
|
||||
]);
|
||||
|
||||
await openRemindersFilter(page);
|
||||
// The reminder row body is a button whose preview text is the target
|
||||
// message preview; clicking it navigates to the message in its channel.
|
||||
await page.getByText("Reply to Alice").click();
|
||||
|
||||
// Lands in the #general chat view with the target message in context.
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByText("Hey team — checking in.")).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${PHASE2_SHOTS}/02-click-navigates-to-message.png`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user