mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Add desktop Home feed (#12)
This commit is contained in:
2
desktop/.gitignore
vendored
2
desktop/.gitignore
vendored
@@ -14,6 +14,8 @@ dist-ssr
|
||||
playwright-report
|
||||
test-results
|
||||
*.local
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
13
desktop/src/features/home/hooks.ts
Normal file
13
desktop/src/features/home/hooks.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
507
desktop/src/features/home/ui/HomeView.tsx
Normal file
507
desktop/src/features/home/ui/HomeView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user