mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Home feed polish: filters, mark done, compact layout (#108)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { Check, type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { FeedItem } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", {
|
||||
numeric: "auto",
|
||||
});
|
||||
|
||||
function formatRelativeTime(unixSeconds: number) {
|
||||
const diff = unixSeconds - Math.floor(Date.now() / 1_000);
|
||||
const absoluteDiff = Math.abs(diff);
|
||||
|
||||
if (absoluteDiff < 60) {
|
||||
return relativeTimeFormatter.format(diff, "second");
|
||||
}
|
||||
|
||||
if (absoluteDiff < 60 * 60) {
|
||||
return relativeTimeFormatter.format(Math.round(diff / 60), "minute");
|
||||
}
|
||||
|
||||
if (absoluteDiff < 60 * 60 * 24) {
|
||||
return relativeTimeFormatter.format(Math.round(diff / (60 * 60)), "hour");
|
||||
}
|
||||
|
||||
if (absoluteDiff < 60 * 60 * 24 * 7) {
|
||||
return relativeTimeFormatter.format(
|
||||
Math.round(diff / (60 * 60 * 24)),
|
||||
"day",
|
||||
);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(unixSeconds * 1_000));
|
||||
}
|
||||
|
||||
function feedHeadline(item: FeedItem) {
|
||||
switch (item.kind) {
|
||||
case 40007:
|
||||
return "Reminder";
|
||||
case 43001:
|
||||
return "Job requested";
|
||||
case 43002:
|
||||
return "Job accepted";
|
||||
case 43003:
|
||||
return "Progress update";
|
||||
case 43004:
|
||||
return "Job result";
|
||||
case 43005:
|
||||
return "Job cancelled";
|
||||
case 43006:
|
||||
return "Job failed";
|
||||
case 45001:
|
||||
return "Forum post";
|
||||
case 45003:
|
||||
return "Forum reply";
|
||||
case 46010:
|
||||
return "Approval requested";
|
||||
default:
|
||||
if (item.category === "mention") {
|
||||
return "Mention";
|
||||
}
|
||||
|
||||
if (item.category === "agent_activity") {
|
||||
return "Agent update";
|
||||
}
|
||||
|
||||
return "Channel update";
|
||||
}
|
||||
}
|
||||
|
||||
function feedContent(item: FeedItem) {
|
||||
const content = item.content.trim();
|
||||
if (content.length > 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (item.kind === 46010) {
|
||||
return "A workflow is waiting for approval.";
|
||||
}
|
||||
|
||||
if (item.kind === 40007) {
|
||||
return "A reminder is waiting for you.";
|
||||
}
|
||||
|
||||
return "No additional details were attached to this event.";
|
||||
}
|
||||
|
||||
type FeedSectionProps = {
|
||||
title: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
icon: LucideIcon;
|
||||
items: FeedItem[];
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
availableChannelIds: ReadonlySet<string>;
|
||||
doneSet: ReadonlySet<string>;
|
||||
showDoneAction: boolean;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
onMarkDone: (id: string) => void;
|
||||
onUndoDone: (id: string) => void;
|
||||
};
|
||||
|
||||
export function FeedSection({
|
||||
title,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
icon: Icon,
|
||||
items,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
availableChannelIds,
|
||||
doneSet,
|
||||
showDoneAction,
|
||||
onOpenChannel,
|
||||
onMarkDone,
|
||||
onUndoDone,
|
||||
}: FeedSectionProps) {
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground/70">{items.length}</span>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border/60 px-4 py-5 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{emptyTitle}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/70">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60 rounded-md border border-border/60">
|
||||
{items.map((item) => {
|
||||
const channelId = item.channelId;
|
||||
const canOpenChannel =
|
||||
channelId !== null && availableChannelIds.has(channelId);
|
||||
const isDone = doneSet.has(item.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative px-3 py-2.5 transition-colors hover:bg-muted/40 ${isDone ? "opacity-50" : ""} ${canOpenChannel ? "cursor-pointer" : ""}`}
|
||||
key={item.id}
|
||||
>
|
||||
{canOpenChannel ? (
|
||||
<button
|
||||
aria-label={`Open ${item.channelName || "channel"}`}
|
||||
className="absolute inset-0"
|
||||
onClick={() => {
|
||||
if (channelId) {
|
||||
onOpenChannel(channelId);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="pointer-events-none relative flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`text-[13px] font-medium ${isDone ? "line-through text-muted-foreground" : ""}`}
|
||||
>
|
||||
{feedHeadline(item)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{resolveUserLabel({
|
||||
pubkey: item.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
})}
|
||||
</span>
|
||||
{item.channelName ? (
|
||||
<span className="text-[11px] text-primary/80">
|
||||
#{item.channelName}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground/60">
|
||||
{formatRelativeTime(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Markdown
|
||||
className="pointer-events-none relative mt-0.5 max-w-none text-[13px] leading-snug text-muted-foreground"
|
||||
compact
|
||||
content={feedContent(item)}
|
||||
/>
|
||||
|
||||
{showDoneAction ? (
|
||||
<Button
|
||||
aria-label={isDone ? "Undo done" : "Mark done"}
|
||||
onClick={() => {
|
||||
if (isDone) {
|
||||
onUndoDone(item.id);
|
||||
} else {
|
||||
onMarkDone(item.id);
|
||||
}
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={`pointer-events-auto absolute right-1.5 top-1.5 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100 ${isDone ? "text-green-500 opacity-100" : "text-muted-foreground"}`}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,266 +1,26 @@
|
||||
import { AtSign, CircleAlert, RefreshCcw, type LucideIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { AtSign, CircleAlert, RefreshCcw } from "lucide-react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { useFeedItemState } from "@/features/home/useFeedItemState";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
|
||||
import type { HomeFeedResponse } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { FeedSection } from "./FeedSection";
|
||||
|
||||
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", {
|
||||
numeric: "auto",
|
||||
});
|
||||
|
||||
function formatRelativeTime(unixSeconds: number) {
|
||||
const diff = unixSeconds - Math.floor(Date.now() / 1_000);
|
||||
const absoluteDiff = Math.abs(diff);
|
||||
|
||||
if (absoluteDiff < 60) {
|
||||
return relativeTimeFormatter.format(diff, "second");
|
||||
}
|
||||
|
||||
if (absoluteDiff < 60 * 60) {
|
||||
return relativeTimeFormatter.format(Math.round(diff / 60), "minute");
|
||||
}
|
||||
|
||||
if (absoluteDiff < 60 * 60 * 24) {
|
||||
return relativeTimeFormatter.format(Math.round(diff / (60 * 60)), "hour");
|
||||
}
|
||||
|
||||
if (absoluteDiff < 60 * 60 * 24 * 7) {
|
||||
return relativeTimeFormatter.format(
|
||||
Math.round(diff / (60 * 60 * 24)),
|
||||
"day",
|
||||
);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(unixSeconds * 1_000));
|
||||
}
|
||||
|
||||
function feedHeadline(item: FeedItem) {
|
||||
switch (item.kind) {
|
||||
case 40007:
|
||||
return "Reminder";
|
||||
case 43001:
|
||||
return "Job requested";
|
||||
case 43002:
|
||||
return "Job accepted";
|
||||
case 43003:
|
||||
return "Progress update";
|
||||
case 43004:
|
||||
return "Job result";
|
||||
case 43005:
|
||||
return "Job cancelled";
|
||||
case 43006:
|
||||
return "Job failed";
|
||||
case 45001:
|
||||
return "Forum post";
|
||||
case 45003:
|
||||
return "Forum reply";
|
||||
case 46010:
|
||||
return "Approval requested";
|
||||
default:
|
||||
if (item.category === "mention") {
|
||||
return "Mention";
|
||||
}
|
||||
|
||||
if (item.category === "agent_activity") {
|
||||
return "Agent update";
|
||||
}
|
||||
|
||||
return "Channel update";
|
||||
}
|
||||
}
|
||||
|
||||
function feedContent(item: FeedItem) {
|
||||
const content = item.content.trim();
|
||||
if (content.length > 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (item.kind === 46010) {
|
||||
return "A workflow is waiting for approval.";
|
||||
}
|
||||
|
||||
if (item.kind === 40007) {
|
||||
return "A reminder is waiting for you.";
|
||||
}
|
||||
|
||||
return "No additional details were attached to this event.";
|
||||
}
|
||||
|
||||
type FeedSectionProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
icon: LucideIcon;
|
||||
items: FeedItem[];
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
availableChannelIds: ReadonlySet<string>;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
};
|
||||
|
||||
function FeedSection({
|
||||
title,
|
||||
description,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
icon: Icon,
|
||||
items,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
availableChannelIds,
|
||||
onOpenChannel,
|
||||
}: FeedSectionProps) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-muted px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{items.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/80 bg-background/60 px-5 py-7 text-center">
|
||||
<p className="text-sm font-semibold tracking-tight">{emptyTitle}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{items.map((item) => {
|
||||
const channelId = item.channelId;
|
||||
const canOpenChannel =
|
||||
channelId !== null && availableChannelIds.has(channelId);
|
||||
|
||||
return (
|
||||
<article
|
||||
className="rounded-lg border border-border/70 bg-background/70 p-4 shadow-sm"
|
||||
key={item.id}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-secondary/70 text-secondary-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold tracking-tight">
|
||||
{feedHeadline(item)}
|
||||
</h3>
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{resolveUserLabel({
|
||||
pubkey: item.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
})}
|
||||
</p>
|
||||
{item.channelName ? (
|
||||
<p className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-primary">
|
||||
{item.channelName}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatRelativeTime(item.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Markdown
|
||||
className="mt-2 max-w-none"
|
||||
compact
|
||||
content={feedContent(item)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canOpenChannel ? (
|
||||
<div className="hidden shrink-0 sm:block">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (channelId) {
|
||||
onOpenChannel(channelId);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canOpenChannel ? (
|
||||
<div className="mt-3 sm:hidden">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
if (channelId) {
|
||||
onOpenChannel(channelId);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Open channel
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
type FeedFilter = "all" | "mention" | "needs_action";
|
||||
|
||||
function HomeLoadingState() {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
<div className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="mt-3 h-4 w-full max-w-lg" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{["mentions", "actions"].map((section) => (
|
||||
<div
|
||||
className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm"
|
||||
key={section}
|
||||
>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="mt-3 h-4 w-full max-w-xs" />
|
||||
<div className="mt-5 space-y-3">
|
||||
<div key={section}>
|
||||
<Skeleton className="mb-2 h-4 w-24" />
|
||||
<div className="space-y-0 rounded-md border border-border/60">
|
||||
{["a", "b", "c"].map((row) => (
|
||||
<Skeleton className="h-28 rounded-lg" key={row} />
|
||||
<Skeleton className="h-16" key={row} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,6 +31,12 @@ function HomeLoadingState() {
|
||||
);
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: { value: FeedFilter; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "mention", label: "Mentions" },
|
||||
{ value: "needs_action", label: "Needs Action" },
|
||||
];
|
||||
|
||||
type HomeViewProps = {
|
||||
feed?: HomeFeedResponse;
|
||||
isLoading?: boolean;
|
||||
@@ -290,6 +56,9 @@ export function HomeView({
|
||||
onOpenChannel,
|
||||
onRefresh,
|
||||
}: HomeViewProps) {
|
||||
const [filter, setFilter] = React.useState<FeedFilter>("all");
|
||||
const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey);
|
||||
|
||||
const feedItems = feed
|
||||
? [...feed.feed.mentions, ...feed.feed.needsAction]
|
||||
: [];
|
||||
@@ -307,9 +76,9 @@ export function HomeView({
|
||||
|
||||
if (!feed) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4">
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-6 py-8 shadow-sm">
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-5">
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
Home feed unavailable
|
||||
</p>
|
||||
@@ -326,34 +95,63 @@ export function HomeView({
|
||||
);
|
||||
}
|
||||
|
||||
const showMentions = filter === "all" || filter === "mention";
|
||||
const showNeedsAction = filter === "all" || filter === "needs_action";
|
||||
const singleColumn = filter !== "all";
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<FeedSection
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={feedProfiles}
|
||||
description="Messages where your pubkey was tagged."
|
||||
emptyDescription="When someone mentions you in an accessible channel, it will land here."
|
||||
emptyTitle="No mentions right now"
|
||||
icon={AtSign}
|
||||
items={feed.feed.mentions}
|
||||
onOpenChannel={onOpenChannel}
|
||||
title="@Mentions"
|
||||
/>
|
||||
<FeedSection
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={feedProfiles}
|
||||
description="Approvals and reminders that need you."
|
||||
emptyDescription="Workflow approval requests and reminders will appear here."
|
||||
emptyTitle="Nothing needs action"
|
||||
icon={CircleAlert}
|
||||
items={feed.feed.needsAction}
|
||||
onOpenChannel={onOpenChannel}
|
||||
title="Needs Action"
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
onClick={() => setFilter(option.value)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={filter === option.value ? "default" : "ghost"}
|
||||
className="h-7 px-2.5 text-xs"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-5 ${singleColumn ? "" : "xl:grid-cols-2"}`}>
|
||||
{showMentions ? (
|
||||
<FeedSection
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={feedProfiles}
|
||||
doneSet={doneSet}
|
||||
emptyDescription="When someone mentions you, it will land here."
|
||||
emptyTitle="No mentions right now"
|
||||
icon={AtSign}
|
||||
items={feed.feed.mentions}
|
||||
onMarkDone={markDone}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onUndoDone={undoDone}
|
||||
showDoneAction={false}
|
||||
title="Mentions"
|
||||
/>
|
||||
) : null}
|
||||
{showNeedsAction ? (
|
||||
<FeedSection
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={feedProfiles}
|
||||
doneSet={doneSet}
|
||||
emptyDescription="Approval requests and reminders will appear here."
|
||||
emptyTitle="Nothing needs action"
|
||||
icon={CircleAlert}
|
||||
items={feed.feed.needsAction}
|
||||
onMarkDone={markDone}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onUndoDone={undoDone}
|
||||
showDoneAction={true}
|
||||
title="Needs Action"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react";
|
||||
|
||||
const DONE_STORAGE_KEY = "sprout-home-feed-done.v1";
|
||||
const MAX_ITEMS = 500;
|
||||
|
||||
function doneStorageKey(pubkey: string) {
|
||||
return `${DONE_STORAGE_KEY}:${pubkey}`;
|
||||
}
|
||||
|
||||
function readStoredIds(key: string): string[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.slice(-MAX_ITEMS)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredIds(key: string, ids: string[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(key, JSON.stringify(ids.slice(-MAX_ITEMS)));
|
||||
}
|
||||
|
||||
export function useFeedItemState(pubkey: string | undefined) {
|
||||
const normalizedPubkey = pubkey?.trim().toLowerCase() ?? "";
|
||||
const key = doneStorageKey(normalizedPubkey);
|
||||
|
||||
const [doneIds, setDoneIds] = React.useState<string[]>(() =>
|
||||
readStoredIds(key),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setDoneIds(readStoredIds(doneStorageKey(normalizedPubkey)));
|
||||
}, [normalizedPubkey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
writeStoredIds(doneStorageKey(normalizedPubkey), doneIds);
|
||||
}, [normalizedPubkey, doneIds]);
|
||||
|
||||
const doneSet = React.useMemo(() => new Set(doneIds), [doneIds]);
|
||||
|
||||
const markDone = React.useCallback((id: string) => {
|
||||
setDoneIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||
}, []);
|
||||
|
||||
const undoDone = React.useCallback((id: string) => {
|
||||
setDoneIds((prev) => prev.filter((v) => v !== id));
|
||||
}, []);
|
||||
|
||||
return { doneSet, markDone, undoDone };
|
||||
}
|
||||
@@ -100,18 +100,18 @@ test("create agent supports parallelism and system prompt overrides", async ({
|
||||
|
||||
test("opens a mocked channel from the home feed", async ({ page }) => {
|
||||
const mentionsSection = page.locator("section").filter({
|
||||
has: page.getByRole("heading", { name: "@Mentions" }),
|
||||
has: page.getByRole("heading", { name: "Mentions" }),
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
await expect(page.getByRole("heading", { name: "@Mentions" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Mentions" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Please review the release checklist."),
|
||||
).toBeVisible();
|
||||
|
||||
await mentionsSection.getByRole("button", { name: "Open" }).click();
|
||||
await mentionsSection.getByRole("button", { name: "Open general" }).click();
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
@@ -121,7 +121,7 @@ test("opens a mocked channel from the home feed", async ({ page }) => {
|
||||
|
||||
test("home feed renders resolved author labels", async ({ page }) => {
|
||||
const mentionsSection = page.locator("section").filter({
|
||||
has: page.getByRole("heading", { name: "@Mentions" }),
|
||||
has: page.getByRole("heading", { name: "Mentions" }),
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
@@ -102,7 +102,7 @@ test("loads the home feed from the relay", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
await expect(page.getByRole("heading", { name: "@Mentions" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Mentions" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Needs Action" }),
|
||||
).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user