Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ dist-ssr
playwright-report
test-results
*.local
playwright-report
test-results

# Editor directories and files
.vscode/*
Expand Down
77 changes: 77 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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");
Expand Down
167 changes: 115 additions & 52 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,38 +20,50 @@ 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(
channels,
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 (
Expand All @@ -61,6 +75,7 @@ export function AppShell() {
? channelsQuery.error.message
: undefined
}
homeUrgentCount={homeUrgentCount}
isLoading={channelsQuery.isLoading}
isCreatingChannel={createChannelMutation.isPending}
onCreateChannel={async ({ description, name }) => {
Expand All @@ -71,58 +86,106 @@ 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">
<ChatHeader
channelType={selectedChannel?.channelType}
description={channelDescription}
title={selectedChannel?.name ?? "Channels"}
/>

<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<MessageTimeline
emptyDescription={
selectedChannel?.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"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={messagesQuery.isLoading}
messages={timelineMessages}
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
<MessageComposer
channelName={selectedChannel?.name ?? "channel"}
disabled={
!selectedChannel ||
selectedChannel.channelType === "forum" ||
sendMessageMutation.isPending
}
isSending={sendMessageMutation.isPending}
key={selectedChannel?.id ?? "no-channel"}
onSend={async (content) => {
await sendMessageMutation.mutateAsync(content);
}}
placeholder={
selectedChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: selectedChannel
? `Message #${selectedChannel.name}`
: "Select a channel"
}
) : (
<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={
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={
activeChannel
? activeChannel.channelType === "forum"
? "Forum channels are next"
: "No messages yet"
: "No channel selected"
}
isLoading={messagesQuery.isLoading}
messages={timelineMessages}
/>
<MessageComposer
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
activeChannel.channelType === "forum" ||
sendMessageMutation.isPending
}
isSending={sendMessageMutation.isPending}
key={activeChannel?.id ?? "no-channel"}
onSend={async (content) => {
await sendMessageMutation.mutateAsync(content);
}}
placeholder={
activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
/>
</>
)}
</div>
</SidebarInset>
</SidebarProvider>
Expand Down
18 changes: 15 additions & 3 deletions desktop/src/features/chat/ui/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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" />;
}
Expand All @@ -25,6 +36,7 @@ export function ChatHeader({
title,
description,
channelType,
mode = "channel",
}: ChatHeaderProps) {
return (
<header
Expand All @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/features/home/hooks.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading