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
3 changes: 3 additions & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
"tauri:build": "tauri build"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
Expand Down
5 changes: 3 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,15 @@ const overrides = new Map([
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/channels/ui/ChannelPane.tsx", 540], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget + thread follow props passthrough
["src/features/channels/ui/ChannelScreen.tsx", 580], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget + thread follow wiring from AppShell context
["src/features/channels/useUnreadChannels.ts", 715], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks
["src/features/channels/useUnreadChannels.ts", 717], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks + markChannelRead latestByChannelRef fallback chain (matches markChannelUnread)
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/lib/useRichTextEditor.ts", 560], // editor setup + 3 inline Tiptap keymap extensions (macEmacs/smartShiftEnter/submitOnEnter) + editorProps.handleKeyDown for ↑-to-edit + editable-toggle focus-restore (records isFocused before disable on send, re-focuses on re-enable so the WebView blur-on-disable doesn't strand focus on body). Split candidate: extract the 3 keymap extensions to a sibling module (tracked follow-up).
["src/features/messages/ui/MessageComposer.tsx", 820], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/features/sidebar/ui/AppSidebar.tsx", 830], // channels + forums creation forms + Pulse nav + channel sections state/dialogs + SidebarDndContext wrapper + sectionIds memo for DnD section reorder + controlled create-channel dialog for ⌘⇧N shortcut
["src/features/sidebar/ui/CustomChannelSection.tsx", 615], // ChannelGroupSection + CustomChannelSection + SectionHeaderActions + ChannelContextMenuItems + MoveToSectionSubmenu + per-section mark-all-read + DnD wrappers (SortableSectionShell, DraggableChannelRow, DroppableSectionBody, DroppableUngroupedBody) + draggable prop on ChannelGroupSection
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export class ReadStateManager {
}

markContextRead(contextId: string, unixTimestamp: number): void {
this.forcedContexts.delete(contextId);
this.advanceContext(contextId, unixTimestamp, { publishable: true });
}

Expand Down
5 changes: 4 additions & 1 deletion desktop/src/features/channels/useUnreadChannels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,10 @@ export function useUnreadChannels(

const markChannelRead = React.useCallback(
(channelId: string, readAt: string | null | undefined) => {
const unixSeconds = toUnixSeconds(readAt);
const unixSeconds =
toUnixSeconds(readAt) ??
latestByChannelRef.current.get(channelId) ??
null;
if (unixSeconds === null) return;
// Reading clears any prior manual mark-unread.
if (forcedUnreadRef.current.delete(channelId)) {
Expand Down
91 changes: 91 additions & 0 deletions desktop/src/features/sidebar/lib/channelSectionsStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
const STORAGE_KEY_PREFIX = "sprout-channel-sections.v1";

export type ChannelSection = {
id: string;
name: string;
order: number;
};

export type ChannelSectionStore = {
version: 1;
sections: ChannelSection[];
assignments: Record<string, string>;
};

export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({
version: 1,
sections: [],
assignments: {},
});

export function storageKey(pubkey: string): string {
return `${STORAGE_KEY_PREFIX}:${pubkey}`;
}

export function stripOrphanedAssignments(
store: ChannelSectionStore,
): ChannelSectionStore {
const sectionIds = new Set(store.sections.map((s) => s.id));
const cleaned = Object.fromEntries(
Object.entries(store.assignments).filter(([, sid]) => sectionIds.has(sid)),
);
if (Object.keys(cleaned).length === Object.keys(store.assignments).length)
return store;
return { ...store, assignments: cleaned };
}

export function parseChannelSectionPayload(
json: unknown,
): ChannelSectionStore | null {
if (typeof json !== "object" || json === null) return null;
const obj = json as Record<string, unknown>;
const sections: ChannelSection[] = Array.isArray(obj.sections)
? obj.sections.filter(
(entry: unknown): entry is ChannelSection =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as Record<string, unknown>).id === "string" &&
typeof (entry as Record<string, unknown>).name === "string" &&
typeof (entry as Record<string, unknown>).order === "number",
)
: [];
const assignments: Record<string, string> =
typeof obj.assignments === "object" &&
obj.assignments !== null &&
!Array.isArray(obj.assignments)
? Object.fromEntries(
Object.entries(obj.assignments as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
)
: {};
return stripOrphanedAssignments({ version: 1, sections, assignments });
}

export function readChannelSectionsStore(pubkey: string): ChannelSectionStore {
try {
const raw = window.localStorage.getItem(storageKey(pubkey));
if (!raw) {
return DEFAULT_STORE;
}
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) {
return DEFAULT_STORE;
}
return parseChannelSectionPayload(parsed) ?? DEFAULT_STORE;
} catch {
return DEFAULT_STORE;
}
}

export function writeChannelSectionsStore(
pubkey: string,
store: ChannelSectionStore,
): boolean {
try {
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store));
return true;
} catch {
return false;
}
}
Loading