Add desktop Home feed (#12)

This commit is contained in:
Wes
2026-03-09 19:32:56 -07:00
committed by GitHub
parent 6e51cc0b12
commit 194ddc66e9
12 changed files with 1073 additions and 58 deletions

2
desktop/.gitignore vendored
View File

@@ -14,6 +14,8 @@ dist-ssr
playwright-report
test-results
*.local
playwright-report
test-results
# Editor directories and files
.vscode/*

View File

@@ -33,6 +33,50 @@ struct CreateChannelBody<'a> {
description: Option<&'a str>,
}
#[derive(Serialize)]
struct GetFeedQuery<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
types: Option<&'a str>,
}
#[derive(Serialize, Deserialize)]
pub struct FeedItemInfo {
pub id: String,
pub kind: u32,
pub pubkey: String,
pub content: String,
pub created_at: u64,
pub channel_id: Option<String>,
pub channel_name: String,
pub tags: Vec<Vec<String>>,
pub category: String,
}
#[derive(Serialize, Deserialize)]
pub struct FeedSections {
pub mentions: Vec<FeedItemInfo>,
pub needs_action: Vec<FeedItemInfo>,
pub activity: Vec<FeedItemInfo>,
pub agent_activity: Vec<FeedItemInfo>,
}
#[derive(Serialize, Deserialize)]
pub struct FeedMeta {
pub since: i64,
pub total: u64,
pub generated_at: i64,
}
#[derive(Serialize, Deserialize)]
pub struct FeedResponse {
pub feed: FeedSections,
pub meta: FeedMeta,
}
fn relay_ws_url() -> String {
std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
@@ -197,6 +241,38 @@ async fn create_channel(
.map_err(|e| format!("parse failed: {e}"))
}
#[tauri::command]
async fn get_feed(
since: Option<i64>,
limit: Option<u32>,
types: Option<String>,
state: tauri::State<'_, AppState>,
) -> Result<FeedResponse, String> {
let pubkey_hex = auth_pubkey_header(&state)?;
let url = format!("{}{}", relay_api_base_url(), "/api/feed");
let response = state
.http_client
.get(url)
.header("X-Pubkey", pubkey_hex)
.query(&GetFeedQuery {
since,
limit,
types: types.as_deref(),
})
.send()
.await
.map_err(|e| format!("request failed: {e}"))?;
if !response.status().is_success() {
return Err(relay_error_message(response).await);
}
response
.json::<FeedResponse>()
.await
.map_err(|e| format!("parse failed: {e}"))
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app_state = AppState {
@@ -222,6 +298,7 @@ pub fn run() {
create_auth_event,
get_channels,
create_channel,
get_feed,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -6,6 +6,8 @@ import {
useChannelsQuery,
useSelectedChannel,
} from "@/features/channels/hooks";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
import {
useChannelMessagesQuery,
useChannelSubscription,
@@ -18,8 +20,12 @@ import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { useIdentityQuery } from "@/shared/api/hooks";
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
type AppView = "home" | "channel";
export function AppShell() {
const [selectedView, setSelectedView] = React.useState<AppView>("home");
const identityQuery = useIdentityQuery();
const homeFeedQuery = useHomeFeedQuery();
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
const { selectedChannel, setSelectedChannelId } = useSelectedChannel(
@@ -27,29 +33,37 @@ export function AppShell() {
null,
);
const createChannelMutation = useCreateChannelMutation();
const activeChannel = selectedView === "channel" ? selectedChannel : null;
const messagesQuery = useChannelMessagesQuery(selectedChannel);
useChannelSubscription(selectedChannel);
const messagesQuery = useChannelMessagesQuery(activeChannel);
useChannelSubscription(activeChannel);
const sendMessageMutation = useSendMessageMutation(
selectedChannel,
activeChannel,
identityQuery.data,
);
const homeUrgentCount =
(homeFeedQuery.data?.feed.mentions.length ?? 0) +
(homeFeedQuery.data?.feed.needsAction.length ?? 0);
const availableChannelIds = React.useMemo(
() => new Set(channels.map((channel) => channel.id)),
[channels],
);
const timelineMessages = React.useMemo(
() =>
formatTimelineMessages(
messagesQuery.data ?? [],
selectedChannel,
activeChannel,
identityQuery.data?.pubkey,
),
[identityQuery.data?.pubkey, messagesQuery.data, selectedChannel],
[activeChannel, identityQuery.data?.pubkey, messagesQuery.data],
);
const channelDescription = selectedChannel
? selectedChannel.channelType === "forum"
? `${selectedChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.`
: selectedChannel.description
const channelDescription = activeChannel
? activeChannel.channelType === "forum"
? `${activeChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.`
: activeChannel.description
: "Connect to the relay to browse channels and read messages.";
return (
@@ -61,6 +75,7 @@ export function AppShell() {
? channelsQuery.error.message
: undefined
}
homeUrgentCount={homeUrgentCount}
isLoading={channelsQuery.isLoading}
isCreatingChannel={createChannelMutation.isPending}
onCreateChannel={async ({ description, name }) => {
@@ -71,31 +86,77 @@ export function AppShell() {
visibility: "open",
});
React.startTransition(() => setSelectedChannelId(createdChannel.id));
React.startTransition(() => {
setSelectedChannelId(createdChannel.id);
setSelectedView("channel");
});
}}
onSelectHome={() => {
React.startTransition(() => {
setSelectedView("home");
});
void homeFeedQuery.refetch();
}}
onSelectChannel={(channelId) => {
React.startTransition(() => setSelectedChannelId(channelId));
React.startTransition(() => {
setSelectedChannelId(channelId);
setSelectedView("channel");
});
}}
selectedChannelId={selectedChannel?.id ?? null}
selectedView={selectedView}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{selectedView === "home" ? (
<ChatHeader
channelType={selectedChannel?.channelType}
description={channelDescription}
title={selectedChannel?.name ?? "Channels"}
description="Personalized feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
) : (
<ChatHeader
channelType={activeChannel?.channelType}
description={channelDescription}
title={activeChannel?.name ?? "Channels"}
/>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{selectedView === "home" ? (
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
isRefreshing={homeFeedQuery.isRefetching}
onOpenChannel={(channelId) => {
React.startTransition(() => {
setSelectedChannelId(channelId);
setSelectedView("channel");
});
}}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
) : (
<>
<MessageTimeline
emptyDescription={
selectedChannel?.channelType === "forum"
activeChannel?.channelType === "forum"
? "Select a stream or DM to load real message history in this first integration pass."
: "Messages will appear here once the relay has history for this channel."
}
emptyTitle={
selectedChannel
? selectedChannel.channelType === "forum"
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
@@ -104,25 +165,27 @@ export function AppShell() {
messages={timelineMessages}
/>
<MessageComposer
channelName={selectedChannel?.name ?? "channel"}
channelName={activeChannel?.name ?? "channel"}
disabled={
!selectedChannel ||
selectedChannel.channelType === "forum" ||
!activeChannel ||
activeChannel.channelType === "forum" ||
sendMessageMutation.isPending
}
isSending={sendMessageMutation.isPending}
key={selectedChannel?.id ?? "no-channel"}
key={activeChannel?.id ?? "no-channel"}
onSend={async (content) => {
await sendMessageMutation.mutateAsync(content);
}}
placeholder={
selectedChannel?.channelType === "forum"
activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: selectedChannel
? `Message #${selectedChannel.name}`
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
/>
</>
)}
</div>
</SidebarInset>
</SidebarProvider>

View File

@@ -1,4 +1,4 @@
import { CircleDot, FileText, Hash } from "lucide-react";
import { CircleDot, FileText, Hash, Home } from "lucide-react";
import type { ChannelType } from "@/shared/api/types";
import { SidebarTrigger } from "@/shared/ui/sidebar";
@@ -7,9 +7,20 @@ type ChatHeaderProps = {
title: string;
description: string;
channelType?: ChannelType;
mode?: "home" | "channel";
};
function ChannelIcon({ channelType }: { channelType?: ChannelType }) {
function ChannelIcon({
channelType,
mode = "channel",
}: {
channelType?: ChannelType;
mode?: "home" | "channel";
}) {
if (mode === "home") {
return <Home className="h-5 w-5 text-primary" />;
}
if (channelType === "dm") {
return <CircleDot className="h-5 w-5 text-primary" />;
}
@@ -25,6 +36,7 @@ export function ChatHeader({
title,
description,
channelType,
mode = "channel",
}: ChatHeaderProps) {
return (
<header
@@ -35,7 +47,7 @@ export function ChatHeader({
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<ChannelIcon channelType={channelType} />
<ChannelIcon channelType={channelType} mode={mode} />
<h1
className="truncate text-lg font-semibold tracking-tight"
data-testid="chat-title"

View File

@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { getHomeFeed } from "@/shared/api/tauri";
export function useHomeFeedQuery() {
return useQuery({
queryKey: ["home-feed"],
queryFn: () => getHomeFeed({ limit: 12 }),
staleTime: 15_000,
gcTime: 5 * 60 * 1_000,
refetchInterval: 30_000,
});
}

View File

@@ -0,0 +1,507 @@
import {
Activity,
AtSign,
Bot,
CircleAlert,
RefreshCcw,
Sparkles,
type LucideIcon,
} from "lucide-react";
import type { FeedItem, HomeFeedResponse } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Skeleton } from "@/shared/ui/skeleton";
const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", {
numeric: "auto",
});
function truncatePubkey(pubkey: string) {
return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
}
function formatActor(pubkey: string, currentPubkey: string | undefined) {
if (currentPubkey && pubkey === currentPubkey) {
return "You";
}
return truncatePubkey(pubkey);
}
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 formatUpdatedAt(unixSeconds: number) {
return new Intl.DateTimeFormat("en-US", {
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;
availableChannelIds: ReadonlySet<string>;
onOpenChannel: (channelId: string) => void;
};
function FeedSection({
title,
description,
emptyTitle,
emptyDescription,
icon: Icon,
items,
currentPubkey,
availableChannelIds,
onOpenChannel,
}: FeedSectionProps) {
return (
<section className="rounded-[1.75rem] 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-2xl 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-3xl 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-3xl 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-2xl 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">
{formatActor(item.pubkey, currentPubkey)}
</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>
);
}
function SummaryCard({
title,
value,
icon: Icon,
tone,
}: {
title: string;
value: number;
icon: LucideIcon;
tone: "urgent" | "calm";
}) {
return (
<div
className={cn(
"rounded-3xl border p-4 shadow-sm",
tone === "urgent"
? "border-primary/20 bg-primary/10"
: "border-border/80 bg-background/70",
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-2xl",
tone === "urgent"
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
>
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
{title}
</p>
<p className="text-2xl font-semibold tracking-tight">{value}</p>
</div>
</div>
</div>
);
}
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-[1.75rem] border border-border/80 bg-card/80 p-5 shadow-sm">
<Skeleton className="h-6 w-44" />
<Skeleton className="mt-3 h-4 w-full max-w-xl" />
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{["first", "second", "third", "fourth"].map((item) => (
<Skeleton className="h-24 rounded-3xl" key={item} />
))}
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
{["mentions", "actions", "activity", "agents"].map((section) => (
<div
className="rounded-[1.75rem] 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">
{["a", "b", "c"].map((row) => (
<Skeleton className="h-28 rounded-3xl" key={row} />
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
type HomeViewProps = {
feed?: HomeFeedResponse;
isLoading?: boolean;
isRefreshing?: boolean;
errorMessage?: string;
currentPubkey?: string;
availableChannelIds: ReadonlySet<string>;
onOpenChannel: (channelId: string) => void;
onRefresh: () => void;
};
export function HomeView({
feed,
isLoading = false,
isRefreshing = false,
errorMessage,
currentPubkey,
availableChannelIds,
onOpenChannel,
onRefresh,
}: HomeViewProps) {
if (isLoading && !feed) {
return <HomeLoadingState />;
}
if (!feed) {
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-3xl flex-col gap-4">
<div className="rounded-[1.75rem] border border-destructive/30 bg-destructive/5 px-6 py-8 shadow-sm">
<p className="text-base font-semibold tracking-tight">
Home feed unavailable
</p>
<p className="mt-2 text-sm text-muted-foreground">
{errorMessage ?? "The relay did not return a feed response."}
</p>
<Button className="mt-5" onClick={onRefresh} type="button">
<RefreshCcw className="h-4 w-4" />
Try again
</Button>
</div>
</div>
</div>
);
}
const totalUrgent = feed.feed.mentions.length + feed.feed.needsAction.length;
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">
<section className="rounded-[1.75rem] border border-border/80 bg-card/80 p-5 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-[1.25rem] bg-primary text-primary-foreground shadow-sm">
<Sparkles className="h-5 w-5" />
</div>
<div>
<h2 className="text-xl font-semibold tracking-tight">
Focus queue
</h2>
<p className="text-sm text-muted-foreground">
Mentions, reminders, channel activity, and agent work in one
feed.
</p>
</div>
</div>
{errorMessage ? (
<p className="mt-3 text-sm text-destructive">{errorMessage}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
Updated {formatUpdatedAt(feed.meta.generatedAt)}
</p>
<Button
onClick={onRefresh}
size="sm"
type="button"
variant="outline"
>
<RefreshCcw
className={cn("h-4 w-4", isRefreshing ? "animate-spin" : "")}
/>
{isRefreshing ? "Refreshing" : "Refresh"}
</Button>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<SummaryCard
icon={CircleAlert}
title="Urgent"
tone="urgent"
value={totalUrgent}
/>
<SummaryCard
icon={AtSign}
title="Mentions"
tone="urgent"
value={feed.feed.mentions.length}
/>
<SummaryCard
icon={Activity}
title="Channels"
tone="calm"
value={feed.feed.activity.length}
/>
<SummaryCard
icon={Bot}
title="Agents"
tone="calm"
value={feed.feed.agentActivity.length}
/>
</div>
</section>
<div className="grid gap-4 xl:grid-cols-2">
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
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}
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"
/>
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
description="Recent updates from channels you can access."
emptyDescription="Channel activity will populate here once the relay has recent events."
emptyTitle="No recent channel activity"
icon={Activity}
items={feed.feed.activity}
onOpenChannel={onOpenChannel}
title="Channel Activity"
/>
<FeedSection
availableChannelIds={availableChannelIds}
currentPubkey={currentPubkey}
description="Agent jobs, progress, and results."
emptyDescription="Agent activity appears here once agents start posting into accessible channels."
emptyTitle="No agent activity yet"
icon={Bot}
items={feed.feed.agentActivity}
onOpenChannel={onOpenChannel}
title="Agent Activity"
/>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { CircleDot, FileText, Hash, Plus } from "lucide-react";
import { CircleDot, FileText, Hash, Home, Plus } from "lucide-react";
import * as React from "react";
import type { Channel } from "@/shared/api/types";
@@ -27,11 +27,14 @@ type AppSidebarProps = {
isLoading: boolean;
isCreatingChannel: boolean;
errorMessage?: string;
homeUrgentCount?: number;
selectedChannelId: string | null;
selectedView: "home" | "channel";
onCreateChannel: (input: {
name: string;
description?: string;
}) => Promise<void>;
onSelectHome: () => void;
onSelectChannel: (channelId: string) => void;
};
@@ -49,12 +52,14 @@ function SidebarChannelIcon({ channel }: { channel: Channel }) {
function SidebarSection({
items,
isActiveChannel,
selectedChannelId,
title,
testId,
onSelectChannel,
}: {
items: Channel[];
isActiveChannel: boolean;
selectedChannelId: string | null;
title: string;
testId: string;
@@ -73,7 +78,7 @@ function SidebarSection({
<SidebarMenuItem key={channel.id}>
<SidebarMenuButton
data-testid={`channel-${channel.name}`}
isActive={selectedChannelId === channel.id}
isActive={isActiveChannel && selectedChannelId === channel.id}
onClick={() => onSelectChannel(channel.id)}
tooltip={channel.name}
type="button"
@@ -103,6 +108,7 @@ function StreamsSection({
onCreateChannel,
onCancelCreate,
onSelectChannel,
isActiveChannel,
selectedChannelId,
}: {
items: Channel[];
@@ -118,6 +124,7 @@ function StreamsSection({
onCreateChannel: (event: React.FormEvent<HTMLFormElement>) => void;
onCancelCreate: () => void;
onSelectChannel: (channelId: string) => void;
isActiveChannel: boolean;
selectedChannelId: string | null;
}) {
return (
@@ -194,7 +201,7 @@ function StreamsSection({
<SidebarMenuItem key={channel.id}>
<SidebarMenuButton
data-testid={`channel-${channel.name}`}
isActive={selectedChannelId === channel.id}
isActive={isActiveChannel && selectedChannelId === channel.id}
onClick={() => onSelectChannel(channel.id)}
tooltip={channel.name}
type="button"
@@ -216,8 +223,11 @@ export function AppSidebar({
isLoading,
isCreatingChannel,
errorMessage,
homeUrgentCount,
selectedChannelId,
selectedView,
onCreateChannel,
onSelectHome,
onSelectChannel,
}: AppSidebarProps) {
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
@@ -316,6 +326,29 @@ export function AppSidebar({
<SidebarSeparator />
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={selectedView === "home"}
onClick={onSelectHome}
tooltip="Home"
type="button"
>
<Home className="h-4 w-4" />
<span>Home</span>
{homeUrgentCount && homeUrgentCount > 0 ? (
<span className="ml-auto rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-primary-foreground">
{homeUrgentCount}
</span>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{isLoading ? (
<SidebarGroup>
<SidebarGroupLabel>Channels</SidebarGroupLabel>
@@ -338,6 +371,7 @@ export function AppSidebar({
draftName={draftName}
isCreateOpen={isCreateOpen}
isCreatingChannel={isCreatingChannel}
isActiveChannel={selectedView === "channel"}
items={streamChannels}
onCancelCreate={() => {
setCreateErrorMessage(undefined);
@@ -364,6 +398,7 @@ export function AppSidebar({
selectedChannelId={selectedChannelId}
/>
<SidebarSection
isActiveChannel={selectedView === "channel"}
items={forumChannels}
onSelectChannel={onSelectChannel}
selectedChannelId={selectedChannelId}
@@ -371,6 +406,7 @@ export function AppSidebar({
title="Forums"
/>
<SidebarSection
isActiveChannel={selectedView === "channel"}
items={directMessages}
onSelectChannel={onSelectChannel}
selectedChannelId={selectedChannelId}

View File

@@ -4,6 +4,8 @@ import type {
Channel,
ChannelType,
CreateChannelInput,
GetHomeFeedInput,
HomeFeedResponse,
Identity,
RelayEvent,
} from "@/shared/api/types";
@@ -22,6 +24,32 @@ type RawChannel = {
participant_pubkeys: string[];
};
type RawFeedItem = {
id: string;
kind: number;
pubkey: string;
content: string;
created_at: number;
channel_id: string | null;
channel_name: string;
tags: string[][];
category: "mention" | "needs_action" | "activity" | "agent_activity";
};
type RawHomeFeedResponse = {
feed: {
mentions: RawFeedItem[];
needs_action: RawFeedItem[];
activity: RawFeedItem[];
agent_activity: RawFeedItem[];
};
meta: {
since: number;
total: number;
generated_at: number;
};
};
function fromRawChannel(channel: RawChannel): Channel {
return {
id: channel.id,
@@ -33,6 +61,20 @@ function fromRawChannel(channel: RawChannel): Channel {
};
}
function fromRawFeedItem(item: RawFeedItem) {
return {
id: item.id,
kind: item.kind,
pubkey: item.pubkey,
content: item.content,
createdAt: item.created_at,
channelId: item.channel_id,
channelName: item.channel_name,
tags: item.tags,
category: item.category,
};
}
export async function getIdentity(): Promise<Identity> {
const identity = await invoke<RawIdentity>("get_identity");
@@ -58,6 +100,26 @@ export async function createChannel(
return fromRawChannel(channel);
}
export async function getHomeFeed(
input: GetHomeFeedInput = {},
): Promise<HomeFeedResponse> {
const response = await invoke<RawHomeFeedResponse>("get_feed", input);
return {
feed: {
mentions: response.feed.mentions.map(fromRawFeedItem),
needsAction: response.feed.needs_action.map(fromRawFeedItem),
activity: response.feed.activity.map(fromRawFeedItem),
agentActivity: response.feed.agent_activity.map(fromRawFeedItem),
},
meta: {
since: response.meta.since,
total: response.meta.total,
generatedAt: response.meta.generated_at,
},
};
}
export async function signRelayEvent(input: {
kind: number;
content: string;

View File

@@ -32,3 +32,45 @@ export type RelayEvent = {
sig: string;
pending?: boolean;
};
export type FeedItemCategory =
| "mention"
| "needs_action"
| "activity"
| "agent_activity";
export type FeedItem = {
id: string;
kind: number;
pubkey: string;
content: string;
createdAt: number;
channelId: string | null;
channelName: string;
tags: string[][];
category: FeedItemCategory;
};
export type HomeFeed = {
mentions: FeedItem[];
needsAction: FeedItem[];
activity: FeedItem[];
agentActivity: FeedItem[];
};
export type HomeFeedMeta = {
since: number;
total: number;
generatedAt: number;
};
export type HomeFeedResponse = {
feed: HomeFeed;
meta: HomeFeedMeta;
};
export type GetHomeFeedInput = {
since?: number;
limit?: number;
types?: string;
};

View File

@@ -26,6 +26,32 @@ type RawChannel = {
participant_pubkeys: string[];
};
type RawFeedItem = {
id: string;
kind: number;
pubkey: string;
content: string;
created_at: number;
channel_id: string | null;
channel_name: string;
tags: string[][];
category: "mention" | "needs_action" | "activity" | "agent_activity";
};
type RawHomeFeedResponse = {
feed: {
mentions: RawFeedItem[];
needs_action: RawFeedItem[];
activity: RawFeedItem[];
agent_activity: RawFeedItem[];
};
meta: {
since: number;
total: number;
generated_at: number;
};
};
type WsHandler = (message: unknown) => void;
type MockSocket = {
@@ -334,6 +360,139 @@ async function handleCreateChannel(
return response.json();
}
async function handleGetFeed(
args: {
since?: number;
limit?: number;
types?: string;
},
config: E2eConfig | undefined,
): Promise<RawHomeFeedResponse> {
const identity = getIdentity(config);
if (!identity) {
const now = Math.floor(Date.now() / 1000);
const limit = args.limit ?? 50;
const wantedTypes =
args.types
?.split(",")
.map((value) => value.trim())
.filter((value) => value.length > 0) ?? [];
const includeType = (type: string) =>
wantedTypes.length === 0 || wantedTypes.includes(type);
const mentions = includeType("mentions")
? [
{
id: "mock-feed-mention",
kind: 40001,
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
content: "Please review the release checklist.",
created_at: now - 90,
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
channel_name: "general",
tags: [
["e", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
["p", DEFAULT_MOCK_IDENTITY.pubkey],
],
category: "mention" as const,
},
].slice(0, limit)
: [];
const needsAction = includeType("needs_action")
? [
{
id: "mock-feed-reminder",
kind: 40007,
pubkey:
"0000000000000000000000000000000000000000000000000000000000000000",
content: "Reminder: update the launch plan before lunch.",
created_at: now - 15 * 60,
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
channel_name: "agents",
tags: [
["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"],
["p", DEFAULT_MOCK_IDENTITY.pubkey],
],
category: "needs_action" as const,
},
].slice(0, limit)
: [];
const activity = includeType("activity")
? [
{
id: "mock-feed-activity",
kind: 40001,
pubkey:
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
content: "Engineering shipped the desktop build.",
created_at: now - 42 * 60,
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
channel_name: "engineering",
tags: [["e", "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9"]],
category: "activity" as const,
},
].slice(0, limit)
: [];
const agentActivity = includeType("agent_activity")
? [
{
id: "mock-feed-agent",
kind: 43003,
pubkey:
"db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36",
content: "Agent progress: channel index complete.",
created_at: now - 2 * 60 * 60,
channel_id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
channel_name: "agents",
tags: [["e", "94a444a4-c0a3-5966-ab05-530c6ddc2301"]],
category: "agent_activity" as const,
},
].slice(0, limit)
: [];
return {
feed: {
mentions,
needs_action: needsAction,
activity,
agent_activity: agentActivity,
},
meta: {
since: args.since ?? now - 7 * 24 * 60 * 60,
total:
mentions.length +
needsAction.length +
activity.length +
agentActivity.length,
generated_at: now,
},
};
}
const url = new URL("/api/feed", getRelayHttpUrl(config));
if (args.since !== undefined) {
url.searchParams.set("since", String(args.since));
}
if (args.limit !== undefined) {
url.searchParams.set("limit", String(args.limit));
}
if (args.types) {
url.searchParams.set("types", args.types);
}
const response = await fetch(url, {
headers: {
"X-Pubkey": identity.pubkey,
},
});
await assertOk(response);
return response.json();
}
async function connectRealSocket(args: { url?: string; onMessage: unknown }) {
const wsId = nextSocketId++;
const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL);
@@ -516,6 +675,11 @@ export function maybeInstallE2eTauriMocks() {
return getRelayWsUrl(activeConfig);
case "get_channels":
return handleGetChannels(activeConfig);
case "get_feed":
return handleGetFeed(
(payload as Parameters<typeof handleGetFeed>[0]) ?? {},
activeConfig,
);
case "create_channel":
return handleCreateChannel(
payload as Parameters<typeof handleCreateChannel>[0],

View File

@@ -30,6 +30,29 @@ test("creates a new mocked stream", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("opens a mocked channel from the home feed", async ({ page }) => {
const mentionsSection = page.locator("section").filter({
has: page.getByRole("heading", { name: "@Mentions" }),
});
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expect(
page.getByRole("heading", { name: "Focus queue" }),
).toBeVisible();
await expect(
page.getByText("Please review the release checklist."),
).toBeVisible();
await mentionsSection.getByRole("button", { name: "Open" }).click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
);
});
test("sends a mocked channel message", async ({ page }) => {
const message = `Smoke message ${Date.now()}`;

View File

@@ -17,6 +17,20 @@ test("loads channels from the relay", async ({ page }) => {
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
});
test("loads the home feed from the relay", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await expect(
page.getByRole("heading", { name: "Focus queue" }),
).toBeVisible();
await expect(page.getByRole("heading", { name: "@Mentions" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Needs Action" }),
).toBeVisible();
});
test("creates a relay-backed stream", async ({ page }) => {
const channelName = `desktop-e2e-${Date.now()}`;