Skip to content
Open
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
10 changes: 9 additions & 1 deletion desktop/src/app/AppShell.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export type AppView =
| "agents"
| "workflows"
| "pulse"
| "projects";
| "projects"
| "saved";

const WINDOW_DRAG_HANDLE_HEIGHT = 44;
const TAURI_DRAG_REGION_ATTR = "data-tauri-drag-region";
Expand Down Expand Up @@ -153,6 +154,13 @@ export function deriveShellRoute(pathname: string): {
};
}

if (pathname === "/saved") {
return {
selectedChannelId: null,
selectedView: "saved",
};
}

return {
selectedChannelId: null,
selectedView: "home",
Expand Down
9 changes: 8 additions & 1 deletion desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ import { requestFocusedThreadClose } from "@/features/channels/focusedThreadClos
import { CommunityRail } from "@/features/sidebar/ui/CommunityRail";
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
import { BookmarksProvider } from "@/features/bookmarks/lib/BookmarksContext";
import { useBookmarks } from "@/features/bookmarks/lib/useBookmarks";
import { useCommunities } from "@/features/communities/useCommunities";
import {
consumePendingCommunityRestore,
Expand Down Expand Up @@ -129,6 +131,7 @@ export function AppShell() {
goNewMessage,
goProjects,
goPulse,
goSaved,
goSettings,
goWorkflows,
closeSettings,
Expand Down Expand Up @@ -167,6 +170,7 @@ export function AppShell() {
const { starredChannelIds, starChannel, unstarChannel } = useChannelStars(
identityQuery.data?.pubkey,
);
const bookmarks = useBookmarks(identityQuery.data?.pubkey);
usePersonaSync(
identityQuery.data?.pubkey,
communitiesHook.activeCommunity?.relayUrl,
Expand Down Expand Up @@ -884,6 +888,7 @@ export function AppShell() {
onSelectHome={() => void goHome()}
onSelectProjects={() => void goProjects()}
onSelectPulse={() => void goPulse()}
onSelectSaved={() => void goSaved()}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => void goWorkflows()}
onSetPresenceStatus={(status) =>
Expand Down Expand Up @@ -926,7 +931,9 @@ export function AppShell() {
style={chromeCssVarDefaults as React.CSSProperties}
>
<BuzzTheme.ContentSurface>
<Outlet />
<BookmarksProvider value={bookmarks}>
<Outlet />
</BookmarksProvider>
</BuzzTheme.ContentSurface>
</SidebarInset>
</MainInsetProvider>
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ export function useAppNavigation() {
),
[commitNavigation],
);
const goSaved = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/saved",
},
behavior,
),
[commitNavigation],
);

const goProjects = React.useCallback(
(behavior?: NavigationBehavior) =>
Expand Down Expand Up @@ -303,6 +313,7 @@ export function useAppNavigation() {
goProject,
goProjects,
goPulse,
goSaved,
goSettings,
goWorkflow,
goWorkflows,
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/app/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { Route as rootRouteImport } from "./routes/root";
import { Route as workflowsRouteImport } from "./routes/workflows";
import { Route as settingsRouteImport } from "./routes/settings";
import { Route as savedRouteImport } from "./routes/saved";
import { Route as remindersRouteImport } from "./routes/reminders";
import { Route as pulseRouteImport } from "./routes/pulse";
import { Route as projectsRouteImport } from "./routes/projects";
Expand All @@ -28,6 +29,11 @@ const settingsRoute = settingsRouteImport.update({
path: "/settings",
getParentRoute: () => rootRouteImport,
} as any);
const savedRoute = savedRouteImport.update({
id: "/saved",
path: "/saved",
getParentRoute: () => rootRouteImport,
} as any);
const remindersRoute = remindersRouteImport.update({
id: "/reminders",
path: "/reminders",
Expand Down Expand Up @@ -86,6 +92,7 @@ export interface FileRoutesByFullPath {
"/projects": typeof projectsRoute;
"/pulse": typeof pulseRoute;
"/reminders": typeof remindersRoute;
"/saved": typeof savedRoute;
"/settings": typeof settingsRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
Expand All @@ -100,6 +107,7 @@ export interface FileRoutesByTo {
"/projects": typeof projectsRoute;
"/pulse": typeof pulseRoute;
"/reminders": typeof remindersRoute;
"/saved": typeof savedRoute;
"/settings": typeof settingsRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
Expand All @@ -115,6 +123,7 @@ export interface FileRoutesById {
"/projects": typeof projectsRoute;
"/pulse": typeof pulseRoute;
"/reminders": typeof remindersRoute;
"/saved": typeof savedRoute;
"/settings": typeof settingsRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
Expand All @@ -131,6 +140,7 @@ export interface FileRouteTypes {
| "/projects"
| "/pulse"
| "/reminders"
| "/saved"
| "/settings"
| "/workflows"
| "/channels/$channelId"
Expand All @@ -145,6 +155,7 @@ export interface FileRouteTypes {
| "/projects"
| "/pulse"
| "/reminders"
| "/saved"
| "/settings"
| "/workflows"
| "/channels/$channelId"
Expand All @@ -159,6 +170,7 @@ export interface FileRouteTypes {
| "/projects"
| "/pulse"
| "/reminders"
| "/saved"
| "/settings"
| "/workflows"
| "/channels/$channelId"
Expand All @@ -174,6 +186,7 @@ export interface RootRouteChildren {
projectsRoute: typeof projectsRoute;
pulseRoute: typeof pulseRoute;
remindersRoute: typeof remindersRoute;
savedRoute: typeof savedRoute;
settingsRoute: typeof settingsRoute;
workflowsRoute: typeof workflowsRoute;
channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute;
Expand All @@ -199,6 +212,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof settingsRouteImport;
parentRoute: typeof rootRouteImport;
};
"/saved": {
id: "/saved";
path: "/saved";
fullPath: "/saved";
preLoaderRoute: typeof savedRouteImport;
parentRoute: typeof rootRouteImport;
};
"/reminders": {
id: "/reminders";
path: "/reminders";
Expand Down Expand Up @@ -278,6 +298,7 @@ const rootRouteChildren: RootRouteChildren = {
projectsRoute: projectsRoute,
pulseRoute: pulseRoute,
remindersRoute: remindersRoute,
savedRoute: savedRoute,
settingsRoute: settingsRoute,
workflowsRoute: workflowsRoute,
channelsDotchannelIdRoute: channelsDotchannelIdRoute,
Expand Down
1 change: 1 addition & 0 deletions desktop/src/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const routes = rootRoute("root.tsx", [
index("index.tsx"),
route("/agents", "agents.tsx"),
route("/pulse", "pulse.tsx"),
route("/saved", "saved.tsx"),
route("/reminders", "reminders.tsx"),
route("/settings", "settings.tsx"),
route("/workflows", "workflows.tsx"),
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/app/routes/saved.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";

import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

const SavedScreen = React.lazy(async () => {
const module = await import("@/features/bookmarks/ui/SavedScreen");
return { default: module.SavedScreen };
});

export const Route = createFileRoute("/saved")({
component: SavedRouteComponent,
});

function SavedRouteComponent() {
return (
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
<SavedScreen />
</React.Suspense>
);
}
80 changes: 80 additions & 0 deletions desktop/src/features/bookmarks/lib/BookmarksContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import * as React from "react";

import type { TimelineMessage } from "@/features/messages/types";
import { PREVIEW_MAX_LENGTH } from "./bookmarksStorage";
import type { BookmarkTarget, UseBookmarks } from "./useBookmarks";

/**
* The per-message toggle surface consumed by every message row. Split out from
* the saved-list channel so that a no-op remote sync (which rebuilds the saved
* array) never re-renders the timeline — only a genuine membership change to
* `isBookmarked` does. `enabled` is false when no provider is mounted so
* consumers can render standalone (tests, pre-auth).
*/
export type BookmarkActions = {
enabled: boolean;
isBookmarked: (eventId: string) => boolean;
toggleBookmark: (target: BookmarkTarget) => void;
};

type SavedEntries = UseBookmarks["savedEntries"];

const DISABLED_ACTIONS: BookmarkActions = {
enabled: false,
isBookmarked: () => false,
toggleBookmark: () => {},
};

const BookmarkActionsContext =
React.createContext<BookmarkActions>(DISABLED_ACTIONS);
const SavedEntriesContext = React.createContext<SavedEntries>([]);

export function BookmarksProvider({
value,
children,
}: {
value: UseBookmarks;
children: React.ReactNode;
}) {
const actions = React.useMemo<BookmarkActions>(
() => ({
enabled: true,
isBookmarked: value.isBookmarked,
toggleBookmark: value.toggleBookmark,
}),
[value.isBookmarked, value.toggleBookmark],
);
return (
<BookmarkActionsContext.Provider value={actions}>
<SavedEntriesContext.Provider value={value.savedEntries}>
{children}
</SavedEntriesContext.Provider>
</BookmarkActionsContext.Provider>
);
}

/** Timeline rows: the stable toggle + membership check. */
export function useBookmarkActions(): BookmarkActions {
return React.useContext(BookmarkActionsContext);
}

/** The Saved view: the current user's bookmarked messages, newest-first. */
export function useSavedBookmarks(): SavedEntries {
return React.useContext(SavedEntriesContext);
}

/** Build the persisted snapshot for a timeline message in a given channel. */
export function messageBookmarkTarget(
message: TimelineMessage,
channelId: string,
): BookmarkTarget {
return {
eventId: message.id,
channelId,
authorPubkey: message.pubkey,
authorName: message.author,
preview: message.body?.slice(0, PREVIEW_MAX_LENGTH),
createdAt: message.createdAt,
threadRootId: message.rootId ?? undefined,
};
}
Loading