From b21bc83b3d05526fb6814edc5be893182e4370af Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 27 May 2026 15:33:47 -0700 Subject: [PATCH 01/36] Fix home tab overflow Signed-off-by: Wes --- .../src/features/home/ui/InboxListPane.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index e2a560b1da..8d1ad4b762 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -31,20 +31,22 @@ export function InboxListPane({ return (
-
- {FILTER_OPTIONS.map((option) => ( - - ))} +
+
+ {FILTER_OPTIONS.map((option) => ( + + ))} +
From 5ecc01e2017d2f35c9060e5300512a74d3699aa7 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 27 May 2026 15:34:37 -0700 Subject: [PATCH 02/36] Show home inbox update type labels Signed-off-by: Wes --- desktop/src/features/home/lib/inbox.ts | 20 +++++++++++++++ .../src/features/home/ui/InboxDetailPane.tsx | 5 ++-- .../src/features/home/ui/InboxListPane.tsx | 25 +++++++++++-------- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index 0d6e7a99a9..3e40b187ae 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -159,6 +159,26 @@ function categoryLabelFor(category: FeedItemCategory) { : "Activity"; } +export function formatInboxTypeLabel(item: InboxItem) { + const channelName = item.channelLabel; + const channelSuffix = channelName ? ` in #${channelName}` : ""; + + if (item.item.channelType === "dm") { + return item.senderLabel ? `DM from ${item.senderLabel}` : "DM"; + } + + const category = item.categories[0] ?? item.item.category; + if (category === "mention") { + return channelName ? `Mentioned in #${channelName}` : "Mentioned"; + } + + if (category === "needs_action") { + return channelName ? `Needs action in #${channelName}` : "Needs action"; + } + + return `${feedHeadline(item.item)}${channelSuffix}`; +} + function categoryPriority(category: FeedItemCategory) { switch (category) { case "needs_action": diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 70da049c06..7875c3ab64 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -12,6 +12,7 @@ import type { InboxItem, InboxReply, } from "@/features/home/lib/inbox"; +import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; import { type InboxDisplayMessage, InboxMessageRow, @@ -193,8 +194,8 @@ export function InboxDetailPane({ : null; const channelContextName = contextChannelName ?? item.channelLabel; const contextLabel = channelContextName - ? `#${channelContextName}` - : item.categoryLabel; + ? formatInboxTypeLabel({ ...item, channelLabel: channelContextName }) + : formatInboxTypeLabel(item); const contextChannelId = item.item.channelId; const handleSelectReplyTarget = (message: InboxDisplayMessage) => { diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 8d1ad4b762..0f8fe1048a 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -1,4 +1,8 @@ -import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox"; +import { + formatInboxTypeLabel, + type InboxFilter, + type InboxItem, +} from "@/features/home/lib/inbox"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -70,6 +74,7 @@ export function InboxListPane({ {items.map((item) => { const isSelected = item.id === selectedId; const isDone = doneSet.has(item.id); + const typeLabel = formatInboxTypeLabel(item); return (
From 86e84756eb4e076e2792bf689b33ee24913ab9c8 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 27 May 2026 15:35:28 -0700 Subject: [PATCH 03/36] Restyle Pulse tab bar Signed-off-by: Wes --- desktop/src/features/pulse/ui/PulseView.tsx | 278 ++++++++++---------- 1 file changed, 142 insertions(+), 136 deletions(-) diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 52333e9d45..c346b89a6b 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -30,13 +30,12 @@ import { } from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; import { Skeleton } from "@/shared/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { UserAvatar } from "@/shared/ui/UserAvatar"; type PulseTab = "search" | "foryou" | "people" | "agents" | "mine"; -const tabTriggerClassName = - "h-7 rounded-full px-3.5 py-0 text-xs font-semibold shadow-none transition-colors !text-muted-foreground hover:!text-foreground data-[state=active]:bg-background data-[state=active]:!text-foreground data-[state=active]:shadow-xs dark:!text-white/35 dark:hover:!text-white/70 dark:data-[state=active]:!text-white"; +const tabButtonClassName = + "h-7 rounded-full border border-transparent px-1.5 text-[10.5px] font-medium text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-xs data-[active=true]:backdrop-blur-sm"; type PulseViewProps = { currentPubkey?: string; @@ -67,8 +66,6 @@ function TimelineSkeleton() { ); } -// ── Agent filter dropdown ────────────────────────────────────────────────── - function AgentFilter({ agents, profiles, @@ -132,14 +129,11 @@ function AgentFilter({ ); } -// ── Main PulseView ───────────────────────────────────────────────────────── - export function PulseView({ currentPubkey }: PulseViewProps) { const [activeTab, setActiveTab] = React.useState("foryou"); const [agentFilter, setAgentFilter] = React.useState(null); const [searchQuery, setSearchQuery] = React.useState(""); - // ── Contact list & follow state ──────────────────────────────────────── const contactListQuery = useContactListQuery(currentPubkey); const contacts = contactListQuery.data?.contacts ?? []; const contactPubkeys = React.useMemo( @@ -151,7 +145,6 @@ export function PulseView({ currentPubkey }: PulseViewProps) { [contactPubkeys], ); - // People-only pubkeys (contacts + self, no agents) const peoplePubkeys = React.useMemo( () => currentPubkey @@ -160,7 +153,6 @@ export function PulseView({ currentPubkey }: PulseViewProps) { [currentPubkey, contactPubkeys], ); - // ── Agents ───────────────────────────────────────────────────────────── const relayAgentsQuery = useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data ?? []; const agentPubkeys = React.useMemo( @@ -179,13 +171,11 @@ export function PulseView({ currentPubkey }: PulseViewProps) { return map; }, [relayAgents]); - // ── "For You" combined pubkeys (contacts + agents + self) ────────────── const forYouPubkeys = React.useMemo( () => [...new Set([...peoplePubkeys, ...agentPubkeys])], [peoplePubkeys, agentPubkeys], ); - // ── Queries per tab ──────────────────────────────────────────────────── const forYouQuery = useTimelineQuery(forYouPubkeys, activeTab === "foryou"); const peopleQuery = useTimelineQuery(peoplePubkeys, activeTab === "people"); const agentTimelineQuery = useTimelineQuery( @@ -199,7 +189,6 @@ export function PulseView({ currentPubkey }: PulseViewProps) { const followMutation = useFollowMutation(currentPubkey); const unfollowMutation = useUnfollowMutation(currentPubkey); - // ── Visible notes per tab ────────────────────────────────────────────── const visibleNotes: UserNote[] = React.useMemo(() => { if (activeTab === "foryou") { return forYouQuery.data?.notes ?? []; @@ -223,13 +212,11 @@ export function PulseView({ currentPubkey }: PulseViewProps) { agentPubkeySet, ]); - // Agent note groups for the agents tab. const agentNoteGroups = React.useMemo( () => (activeTab === "agents" ? groupAgentNotes(visibleNotes) : []), [activeTab, visibleNotes], ); - // ── Profile lookups ──────────────────────────────────────────────────── const notePubkeys = React.useMemo( () => [...new Set(visibleNotes.map((n) => n.pubkey))], [visibleNotes], @@ -240,7 +227,6 @@ export function PulseView({ currentPubkey }: PulseViewProps) { const profiles: Record = profilesQuery.data?.profiles ?? {}; - // ── Mention members for ForumComposer ───────────────────────────────── const mentionProfilesQuery = useUsersBatchQuery(forYouPubkeys, { enabled: forYouPubkeys.length > 0, }); @@ -266,7 +252,6 @@ export function PulseView({ currentPubkey }: PulseViewProps) { return members; }, [forYouPubkeys, mentionProfiles]); - // ── Loading / refresh state ──────────────────────────────────────────── const activeQuery = activeTab === "foryou" ? forYouQuery @@ -285,7 +270,6 @@ export function PulseView({ currentPubkey }: PulseViewProps) { unfollowMutation.mutate(pubkey); } - // ── Render ───────────────────────────────────────────────────────────── const emptyMessages: Record = { search: "Search Pulse notes by author or text.", foryou: @@ -337,146 +321,168 @@ export function PulseView({ currentPubkey }: PulseViewProps) { } return ( -
- setActiveTab(v as PulseTab)} - className="flex min-h-0 flex-1 flex-col overflow-hidden" - > - {/* Tab bar */} -
-
-
- - +
+
+
+
+
+ + + + + +
+
-
- {activeTab === "agents" && relayAgents.length > 1 ? ( - - ) : null} -
+
+ {activeTab === "agents" && relayAgents.length > 1 ? ( + + ) : null}
+
- {/* Timeline — TabsContent with forceMount so aria-controls resolves */} - +
-
- {activeTab === "search" ? ( -
-
-

- What are you looking for? -

-
-
- - setSearchQuery(event.target.value)} - placeholder="What would you like to know?" - type="search" - value={searchQuery} - /> - -
+ {activeTab === "search" ? ( +
+
+

+ What are you looking for? +

+
+
+ + setSearchQuery(event.target.value)} + placeholder="What would you like to know?" + type="search" + value={searchQuery} + /> +
- ) : activeTab !== "agents" ? ( -
- - ) : null} + } + members={pulseMentionMembers} + placeholder="What's on your mind?" + isSending={publishMutation.isPending} + onSubmit={(content, mentionPubkeys, mediaTags) => + publishMutation.mutateAsync({ + content, + mentionPubkeys, + mediaTags, + }) + } + profiles={mentionProfiles} + /> +
+ ) : null} - {activeTab !== "search" ? ( -
{renderTimeline()}
- ) : null} -
- - + {activeTab !== "search" ? ( +
{renderTimeline()}
+ ) : null} +
+
); } From bfdc7e861512cc5205cb71f4d80d41226eb1898e Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 27 May 2026 15:36:27 -0700 Subject: [PATCH 04/36] Add global Everyone feed Signed-off-by: Wes --- desktop/src-tauri/src/commands/social.rs | 23 ++++++++++++++ desktop/src-tauri/src/lib.rs | 2 +- desktop/src/features/pulse/hooks.ts | 13 ++++++++ desktop/src/features/pulse/ui/PulseView.tsx | 34 ++++++++++----------- desktop/src/shared/api/social.ts | 22 +++++++++++++ 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/commands/social.rs b/desktop/src-tauri/src/commands/social.rs index 4efd381831..7c459604ed 100644 --- a/desktop/src-tauri/src/commands/social.rs +++ b/desktop/src-tauri/src/commands/social.rs @@ -73,6 +73,29 @@ pub async fn set_contact_list( submit_event(builder, &state).await } +/// Fetch global NIP-01 kind:1 notes without an author filter. +#[tauri::command] +pub async fn get_global_notes( + limit: Option, + before: Option, + before_id: Option, + state: State<'_, AppState>, +) -> Result { + let _ = before_id; + let mut filter = serde_json::Map::new(); + filter.insert("kinds".to_string(), serde_json::json!([1])); + filter.insert( + "limit".to_string(), + serde_json::json!(limit.unwrap_or(50).min(200)), + ); + if let Some(t) = before { + filter.insert("until".to_string(), serde_json::json!(t)); + } + + let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?; + Ok(nostr_convert::user_notes_from_events(&events)) +} + /// Maximum number of pubkeys per timeline request to keep filter size bounded. const MAX_TIMELINE_PUBKEYS: usize = 100; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a8029911f5..447fb51d5d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -455,7 +455,6 @@ pub fn run() { // Pre-download voice models in the background so they're ready // when the user starts their first huddle. Idempotent — no-op if - // already downloaded. ~289 MB total (~100 MB Parakeet STT + ~189 MB Pocket TTS). if let Some(mgr) = huddle::models::global_model_manager() { mgr.start_stt_download(state.http_client.clone()); mgr.start_tts_download(state.http_client.clone()); @@ -614,6 +613,7 @@ pub fn run() { get_contact_list, set_contact_list, get_notes_timeline, + get_global_notes, start_huddle, join_huddle, leave_huddle, diff --git a/desktop/src/features/pulse/hooks.ts b/desktop/src/features/pulse/hooks.ts index 8bfc2af2c0..d5a16d4b9a 100644 --- a/desktop/src/features/pulse/hooks.ts +++ b/desktop/src/features/pulse/hooks.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getContactList, + getGlobalNotes, getNotesTimeline, getUserNotes, publishNote, @@ -16,6 +17,7 @@ import type { export const pulseQueryKeys = { contactList: (pubkey: string) => ["contact-list", pubkey] as const, + globalNotes: ["global-notes"] as const, myNotes: (pubkey: string) => ["my-notes", pubkey] as const, // Use a stable sorted string key to avoid reference-equality refetch churn. timeline: (pubkeys: string[]) => @@ -63,6 +65,17 @@ export function useTimelineQuery(contactPubkeys: string[], enabled: boolean) { }); } +export function useGlobalNotesQuery(enabled: boolean) { + return useQuery({ + queryKey: pulseQueryKeys.globalNotes, + queryFn: () => getGlobalNotes({ limit: 50 }), + enabled, + staleTime: 15_000, + gcTime: 5 * 60_000, + refetchInterval: 30_000, + }); +} + // ── Publish note mutation ─────────────────────────────────────────────────── export function usePublishNoteMutation(currentPubkey?: string) { diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index c346b89a6b..dc5bf49a64 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -6,6 +6,7 @@ import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useContactListQuery, useFollowMutation, + useGlobalNotesQuery, useMyNotesQuery, usePublishNoteMutation, useTimelineQuery, @@ -32,7 +33,7 @@ import { Input } from "@/shared/ui/input"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -type PulseTab = "search" | "foryou" | "people" | "agents" | "mine"; +type PulseTab = "search" | "everyone" | "people" | "agents" | "mine"; const tabButtonClassName = "h-7 rounded-full border border-transparent px-1.5 text-[10.5px] font-medium text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-xs data-[active=true]:backdrop-blur-sm"; @@ -130,7 +131,7 @@ function AgentFilter({ } export function PulseView({ currentPubkey }: PulseViewProps) { - const [activeTab, setActiveTab] = React.useState("foryou"); + const [activeTab, setActiveTab] = React.useState("everyone"); const [agentFilter, setAgentFilter] = React.useState(null); const [searchQuery, setSearchQuery] = React.useState(""); @@ -171,12 +172,12 @@ export function PulseView({ currentPubkey }: PulseViewProps) { return map; }, [relayAgents]); - const forYouPubkeys = React.useMemo( + const mentionPubkeys = React.useMemo( () => [...new Set([...peoplePubkeys, ...agentPubkeys])], [peoplePubkeys, agentPubkeys], ); - const forYouQuery = useTimelineQuery(forYouPubkeys, activeTab === "foryou"); + const everyoneQuery = useGlobalNotesQuery(activeTab === "everyone"); const peopleQuery = useTimelineQuery(peoplePubkeys, activeTab === "people"); const agentTimelineQuery = useTimelineQuery( agentFilter ? [agentFilter] : agentPubkeys, @@ -190,8 +191,8 @@ export function PulseView({ currentPubkey }: PulseViewProps) { const unfollowMutation = useUnfollowMutation(currentPubkey); const visibleNotes: UserNote[] = React.useMemo(() => { - if (activeTab === "foryou") { - return forYouQuery.data?.notes ?? []; + if (activeTab === "everyone") { + return everyoneQuery.data?.notes ?? []; } if (activeTab === "people") { // Filter out agent notes from the people timeline. @@ -205,7 +206,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { return myNotesQuery.data?.notes ?? []; }, [ activeTab, - forYouQuery.data, + everyoneQuery.data, peopleQuery.data, agentTimelineQuery.data, myNotesQuery.data, @@ -227,8 +228,8 @@ export function PulseView({ currentPubkey }: PulseViewProps) { const profiles: Record = profilesQuery.data?.profiles ?? {}; - const mentionProfilesQuery = useUsersBatchQuery(forYouPubkeys, { - enabled: forYouPubkeys.length > 0, + const mentionProfilesQuery = useUsersBatchQuery(mentionPubkeys, { + enabled: mentionPubkeys.length > 0, }); const mentionProfiles = mentionProfilesQuery.data?.profiles ?? {}; const currentProfile = currentPubkey @@ -240,7 +241,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { const pulseMentionMembers = React.useMemo(() => { const members: ChannelMember[] = []; - for (const pubkey of forYouPubkeys) { + for (const pubkey of mentionPubkeys) { const profile = mentionProfiles[pubkey.toLowerCase()]; members.push({ pubkey, @@ -250,11 +251,11 @@ export function PulseView({ currentPubkey }: PulseViewProps) { }); } return members; - }, [forYouPubkeys, mentionProfiles]); + }, [mentionPubkeys, mentionProfiles]); const activeQuery = - activeTab === "foryou" - ? forYouQuery + activeTab === "everyone" + ? everyoneQuery : activeTab === "people" ? peopleQuery : activeTab === "agents" @@ -272,8 +273,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) { const emptyMessages: Record = { search: "Search Pulse notes by author or text.", - foryou: - "No notes yet. Follow people and agents to build your personalized feed.", + everyone: "No public notes yet.", people: "No notes yet. Follow people to see their updates here.", agents: agentPubkeys.length === 0 @@ -340,8 +340,8 @@ export function PulseView({ currentPubkey }: PulseViewProps) { - + {!isOwnNote ? ( + + ) : null} {!isOwnNote ? ( isFollowing ? (
{isReplyComposerOpen ? ( -
- -
-