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:
Will Pfleger
2026-06-22 15:06:56 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent de3f21abcd
commit 1186ea48b9
4 changed files with 400 additions and 6 deletions
@@ -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`,
});
});
});