From da0506f4ea77b6929c3c2dc1c64be4ea82c0e151 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 23 Apr 2026 15:13:31 -0600 Subject: [PATCH 001/120] feat(desktop): improve auto-updater UX and add global update indicator (#394) Co-authored-by: Claude Opus 4.6 (1M context) --- desktop/src/app/AppShell.tsx | 358 +++++++++--------- .../src/features/settings/UpdateChecker.tsx | 152 ++------ .../src/features/settings/UpdateIndicator.tsx | 48 +++ .../settings/hooks/UpdaterProvider.tsx | 19 + .../features/settings/hooks/use-updater.ts | 105 +++++ 5 files changed, 392 insertions(+), 290 deletions(-) create mode 100644 desktop/src/features/settings/UpdateIndicator.tsx create mode 100644 desktop/src/features/settings/hooks/UpdaterProvider.tsx create mode 100644 desktop/src/features/settings/hooks/use-updater.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 9e3742e9c7..a32fe0e437 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -46,6 +46,8 @@ import { SidebarProvider, SidebarTrigger, } from "@/shared/ui/sidebar"; +import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider"; +import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; type AppView = "home" | "channel" | "agents" | "workflows" | "pulse"; @@ -117,6 +119,7 @@ export function AppShell() { const [settingsSection, setSettingsSection] = React.useState( DEFAULT_SETTINGS_SECTION, ); + const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); @@ -407,188 +410,195 @@ export function AppShell() { }, [handleCloseSettings, handleOpenSettings, settingsOpen]); return ( - - { - setIsChannelManagementOpen(true); - }, - }} - > - -
- -
- - - -
- { - const createdChannel = - await createChannelMutation.mutateAsync({ + + + { + setIsChannelManagementOpen(true); + }, + }} + > + +
+ +
+ + + + handleOpenSettings("updates")} + /> +
+ { + const createdChannel = + await createChannelMutation.mutateAsync({ + name, + description, + channelType: "stream", + visibility, + ttlSeconds, + }); + + await goChannel(createdChannel.id); + }} + onCreateForum={async ({ + description, + name, + visibility, + ttlSeconds, + }) => { + const createdForum = await createForumMutation.mutateAsync({ name, description, - channelType: "stream", + channelType: "forum", visibility, ttlSeconds, }); - await goChannel(createdChannel.id); - }} - onCreateForum={async ({ - description, - name, - visibility, - ttlSeconds, - }) => { - const createdForum = await createForumMutation.mutateAsync({ - name, - description, - channelType: "forum", - visibility, - ttlSeconds, - }); + await goChannel(createdForum.id); + }} + onHideDm={handleHideDm} + onOpenBrowseChannels={handleOpenBrowseChannels} + onOpenBrowseForums={handleOpenBrowseForums} + onOpenDm={async ({ pubkeys }) => { + const directMessage = await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); + }} + onOpenSearch={handleOpenSearch} + onSelectAgents={() => { + void goAgents(); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + onSelectHome={() => { + void goHome(); + }} + onSelectPulse={() => { + void goPulse(); + }} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => { + void goWorkflows(); + }} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) + } + profile={profileQuery.data} + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + /> - await goChannel(createdForum.id); - }} - onHideDm={handleHideDm} - onOpenBrowseChannels={handleOpenBrowseChannels} - onOpenBrowseForums={handleOpenBrowseForums} - onOpenDm={async ({ pubkeys }) => { - const directMessage = await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} - onOpenSearch={handleOpenSearch} - onSelectAgents={() => { - void goAgents(); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - onSelectHome={() => { - void goHome(); - }} - onSelectPulse={() => { - void goPulse(); - }} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => { - void goWorkflows(); - }} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - profile={profileQuery.data} - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - /> + + + - - - + { + setIsChannelManagementOpen(false); + void goHome({ replace: true }); + }} + onOpenSearchResult={handleOpenSearchResult} + onSearchOpenChange={setIsSearchOpen} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> - { - setIsChannelManagementOpen(false); - void goHome({ replace: true }); - }} - onOpenSearchResult={handleOpenSearchResult} - onSearchOpenChange={setIsSearchOpen} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - /> - - {settingsOpen ? ( - - - - ) : null} -
- -
-
-
-
+ {settingsOpen ? ( + + + + ) : null} +
+ +
+
+
+
+ ); } diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx index a88d26a23a..3efb5e784a 100644 --- a/desktop/src/features/settings/UpdateChecker.tsx +++ b/desktop/src/features/settings/UpdateChecker.tsx @@ -1,168 +1,88 @@ -import { useState, useRef, useCallback } from "react"; -import { check, type Update } from "@tauri-apps/plugin-updater"; -import { relaunch } from "@tauri-apps/plugin-process"; - -type UpdateStatus = - | { state: "idle" } - | { state: "checking" } - | { state: "up-to-date" } - | { state: "available"; version: string } - | { state: "downloading" } - | { state: "installing" } - | { state: "ready" } - | { state: "error"; message: string }; +import { useUpdaterContext } from "./hooks/UpdaterProvider"; +import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; export function UpdateChecker() { - const [status, setStatus] = useState({ state: "idle" }); - const updateRef = useRef(null); - - const closeUpdate = useCallback(async () => { - if (updateRef.current) { - await updateRef.current.close(); - updateRef.current = null; - } - }, []); - - async function checkForUpdate() { - try { - await closeUpdate(); - setStatus({ state: "checking" }); - const update = await check(); - - if (update) { - updateRef.current = update; - setStatus({ state: "available", version: update.version }); - } else { - setStatus({ state: "up-to-date" }); - } - } catch (err) { - setStatus({ - state: "error", - message: err instanceof Error ? err.message : String(err), - }); - } - } - - async function downloadAndInstall() { - try { - const update = updateRef.current; - if (!update) { - setStatus({ state: "up-to-date" }); - return; - } - - setStatus({ state: "downloading" }); - - await update.downloadAndInstall((event) => { - if (event.event === "Finished") { - setStatus({ state: "installing" }); - } - }); - - setStatus({ state: "ready" }); - } catch (err) { - setStatus({ - state: "error", - message: err instanceof Error ? err.message : String(err), - }); - } - } - - async function handleRelaunch() { - await relaunch(); - } + const { status, checkForUpdate, downloadAndInstall, relaunch } = + useUpdaterContext(); return ( -
-

- Software Updates -

+
+
+

+ Software Updates +

+

+ Keep Sprout up to date with the latest features and fixes. +

+
{status.state === "idle" && (
-

+

Check if a new version is available.

- +
)} {status.state === "checking" && ( -

Checking for updates...

+

Checking for updates...

)} {status.state === "up-to-date" && (
-

You're on the latest version.

- +
)} {status.state === "available" && (
-

- Version {status.version} is - available. +

+ Version {status.version} is available.

- +
)} {status.state === "downloading" && ( -

Downloading update...

+

Downloading update...

)} {status.state === "installing" && ( -

Installing update...

+

Installing update...

)} {status.state === "ready" && (
-

+

Update installed. Restart to apply.

- +
)} {status.state === "error" && (
-

+

Update failed: {status.message}

- +
)} -
+ ); } diff --git a/desktop/src/features/settings/UpdateIndicator.tsx b/desktop/src/features/settings/UpdateIndicator.tsx new file mode 100644 index 0000000000..15697729b9 --- /dev/null +++ b/desktop/src/features/settings/UpdateIndicator.tsx @@ -0,0 +1,48 @@ +import { Download, RefreshCw } from "lucide-react"; + +import { Button } from "@/shared/ui/button"; + +import { useUpdaterContext } from "./hooks/UpdaterProvider"; + +const indicatorButtonClass = + "relative h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"; + +export function UpdateIndicator({ + onOpenUpdates, +}: { + onOpenUpdates: () => void; +}) { + const { status } = useUpdaterContext(); + + if (status.state === "available") { + return ( + + ); + } + + if (status.state === "ready") { + return ( + + ); + } + + return null; +} diff --git a/desktop/src/features/settings/hooks/UpdaterProvider.tsx b/desktop/src/features/settings/hooks/UpdaterProvider.tsx new file mode 100644 index 0000000000..186c78d043 --- /dev/null +++ b/desktop/src/features/settings/hooks/UpdaterProvider.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { useUpdater } from "./use-updater"; + +type UpdaterContextValue = ReturnType; + +const UpdaterContext = createContext(null); + +export function UpdaterProvider({ children }: { children: ReactNode }) { + const updater = useUpdater(); + return {children}; +} + +export function useUpdaterContext(): UpdaterContextValue { + const ctx = useContext(UpdaterContext); + if (!ctx) { + throw new Error("useUpdaterContext must be used within an UpdaterProvider"); + } + return ctx; +} diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts new file mode 100644 index 0000000000..9e728ddcf5 --- /dev/null +++ b/desktop/src/features/settings/hooks/use-updater.ts @@ -0,0 +1,105 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import { check, type Update } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { toast } from "sonner"; + +export type UpdateStatus = + | { state: "idle" } + | { state: "checking" } + | { state: "up-to-date" } + | { state: "available"; version: string } + | { state: "downloading" } + | { state: "installing" } + | { state: "ready" } + | { state: "error"; message: string }; + +export function useUpdater() { + const [status, setStatus] = useState({ state: "idle" }); + const updateRef = useRef(null); + + const closeUpdate = useCallback(async () => { + if (updateRef.current) { + await updateRef.current.close(); + updateRef.current = null; + } + }, []); + + const checkForUpdate = useCallback(async () => { + try { + await closeUpdate(); + setStatus({ state: "checking" }); + const update = await check(); + + if (update) { + updateRef.current = update; + setStatus({ state: "available", version: update.version }); + toast("Update Available", { + id: "update-available", + description: `Version ${update.version} is ready to download.`, + }); + } else { + setStatus({ state: "up-to-date" }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if ( + msg.includes("plugin updater not found") || + msg.includes("not initialized") + ) { + setStatus({ state: "idle" }); + return; + } + setStatus({ state: "error", message: msg }); + } + }, [closeUpdate]); + + const downloadAndInstall = useCallback(async () => { + try { + const update = updateRef.current; + if (!update) { + setStatus({ state: "up-to-date" }); + return; + } + + setStatus({ state: "downloading" }); + + await update.downloadAndInstall((event) => { + if (event.event === "Finished") { + setStatus({ state: "installing" }); + } + }); + + setStatus({ state: "ready" }); + } catch (err) { + setStatus({ + state: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + }, []); + + const handleRelaunch = useCallback(async () => { + try { + await relaunch(); + } catch (err) { + setStatus({ + state: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + }, []); + + useEffect(() => { + checkForUpdate(); + return () => { + closeUpdate(); + }; + }, [checkForUpdate, closeUpdate]); + + return { + status, + checkForUpdate, + downloadAndInstall, + relaunch: handleRelaunch, + }; +} From 4cc8c1df71306932bd021e012f84f3b200e716d5 Mon Sep 17 00:00:00 2001 From: Luis Padron Date: Fri, 24 Apr 2026 12:14:57 -0400 Subject: [PATCH 002/120] feat: add emoji auto-complete via :name: (#395) --- .../messages/lib/useEmojiAutocomplete.ts | 223 ++++++++++++++++++ .../messages/ui/EmojiAutocomplete.tsx | 68 ++++++ .../features/messages/ui/MessageComposer.tsx | 49 +++- package-lock.json | 6 + 4 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 desktop/src/features/messages/lib/useEmojiAutocomplete.ts create mode 100644 desktop/src/features/messages/ui/EmojiAutocomplete.tsx create mode 100644 package-lock.json diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts new file mode 100644 index 0000000000..d655911d21 --- /dev/null +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -0,0 +1,223 @@ +import * as React from "react"; + +import { init, SearchIndex } from "emoji-mart"; +import data from "@emoji-mart/data"; + +export type EmojiSuggestion = { + id: string; + name: string; + native: string; +}; + +const EMOJI_DEBOUNCE_MS = 120; +const MIN_QUERY_LENGTH = 2; +const MAX_RESULTS = 8; + +// Initialize emoji-mart search index once +init({ data }); + +/** + * Detect an emoji shortcode query at the cursor position. + * Matches `:query` where `:` is preceded by whitespace or start-of-string, + * and `query` contains no whitespace or `:`. + */ +function detectEmojiQuery( + value: string, + cursorPosition: number, +): { query: string; startIndex: number } | null { + const beforeCursor = value.slice(0, cursorPosition); + const match = beforeCursor.match(/(?:^|[\s])(:([^\s:]{2,})?)$/); + if (!match) return null; + + const full = match[1]; // includes the `:` + const query = match[2]; // just the text after `:` + if (!query || query.length < MIN_QUERY_LENGTH) return null; + + const startIndex = beforeCursor.length - full.length; + return { query, startIndex }; +} + +export function useEmojiAutocomplete() { + const [emojiQuery, setEmojiQuery] = React.useState(null); + const [emojiStartIndex, setEmojiStartIndex] = React.useState(0); + const [emojiSelectedIndex, setEmojiSelectedIndex] = React.useState(0); + const [suggestions, setSuggestions] = React.useState([]); + + const debounceTimerRef = React.useRef | null>( + null, + ); + const latestValueRef = React.useRef(""); + const latestCursorRef = React.useRef(0); + + // Clean up pending timeout on unmount + React.useEffect(() => { + return () => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // Search emoji-mart when query changes + React.useEffect(() => { + if (emojiQuery === null) { + setSuggestions([]); + return; + } + + let cancelled = false; + SearchIndex.search(emojiQuery) + .then( + ( + results: Array<{ + id: string; + name: string; + skins: Array<{ native: string }>; + }> | null, + ) => { + if (cancelled) return; + const mapped: EmojiSuggestion[] = (results ?? []) + .slice(0, MAX_RESULTS) + .map((emoji) => ({ + id: emoji.id, + name: emoji.name, + native: emoji.skins[0]?.native ?? "", + })) + .filter((e) => e.native !== ""); + setSuggestions(mapped); + setEmojiSelectedIndex(0); + }, + ) + .catch(() => { + if (cancelled) return; + setSuggestions([]); + }); + + return () => { + cancelled = true; + }; + }, [emojiQuery]); + + const isEmojiAutocompleteOpen = emojiQuery !== null && suggestions.length > 0; + + const insertEmoji = React.useCallback( + ( + suggestion: EmojiSuggestion, + content: string, + selectionEnd: number, + ): { nextContent: string; nextCursor: number } => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + const before = content.slice(0, emojiStartIndex); + const after = content.slice(selectionEnd); + const inserted = `${suggestion.native} `; + const nextContent = `${before}${inserted}${after}`; + const nextCursor = before.length + inserted.length; + + setEmojiQuery(null); + setEmojiSelectedIndex(0); + + return { nextContent, nextCursor }; + }, + [emojiStartIndex], + ); + + const updateEmojiQuery = React.useCallback( + (value: string, cursorPosition: number) => { + latestValueRef.current = value; + latestCursorRef.current = cursorPosition; + + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + const result = detectEmojiQuery( + latestValueRef.current, + latestCursorRef.current, + ); + if (result) { + setEmojiQuery(result.query); + setEmojiStartIndex(result.startIndex); + } else { + setEmojiQuery(null); + } + }, EMOJI_DEBOUNCE_MS); + }, + [], + ); + + const clearEmojis = React.useCallback(() => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + setEmojiQuery(null); + setEmojiSelectedIndex(0); + setSuggestions([]); + }, []); + + const handleEmojiKeyDown = React.useCallback( + ( + event: React.KeyboardEvent, + ): { handled: boolean; suggestion?: EmojiSuggestion } => { + if (!isEmojiAutocompleteOpen) { + return { handled: false }; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setEmojiSelectedIndex((current) => + current < suggestions.length - 1 ? current + 1 : 0, + ); + return { handled: true }; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setEmojiSelectedIndex((current) => + current > 0 ? current - 1 : suggestions.length - 1, + ); + return { handled: true }; + } + + if ( + event.key === "Tab" || + (event.key === "Enter" && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey) + ) { + event.preventDefault(); + return { + handled: true, + suggestion: suggestions[emojiSelectedIndex], + }; + } + + if (event.key === "Escape") { + event.preventDefault(); + setEmojiQuery(null); + return { handled: true }; + } + + return { handled: false }; + }, + [isEmojiAutocompleteOpen, emojiSelectedIndex, suggestions], + ); + + return { + clearEmojis, + emojiSelectedIndex, + emojiSuggestions: suggestions, + handleEmojiKeyDown, + insertEmoji, + isEmojiAutocompleteOpen, + updateEmojiQuery, + }; +} diff --git a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx new file mode 100644 index 0000000000..e458d8cce0 --- /dev/null +++ b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; + +import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; +import { cn } from "@/shared/lib/cn"; + +type EmojiAutocompleteProps = { + suggestions: EmojiSuggestion[]; + selectedIndex: number; + onSelect: (suggestion: EmojiSuggestion) => void; + position?: "above" | "below"; +}; + +export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({ + suggestions, + selectedIndex, + onSelect, + position = "above", +}: EmojiAutocompleteProps) { + const listRef = React.useRef(null); + + React.useEffect(() => { + const activeItem = listRef.current?.children[selectedIndex] as + | HTMLElement + | undefined; + activeItem?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + if (suggestions.length === 0) { + return null; + } + + return ( +
+
+ {suggestions.map((suggestion, index) => ( + + ))} +
+
+ ); +}); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 06bad1f233..3a5380a822 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -5,6 +5,8 @@ import { X } from "lucide-react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; +import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { ALLOWED_MEDIA_TYPES, @@ -20,6 +22,7 @@ import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { Button } from "@/shared/ui/button"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerAttachments } from "./ComposerAttachments"; +import { EmojiAutocomplete } from "./EmojiAutocomplete"; import { MentionAutocomplete, type MentionSuggestion, @@ -90,6 +93,7 @@ export function MessageComposer({ const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); + const emojiAutocomplete = useEmojiAutocomplete(); const notifyTyping = useTypingBroadcast( channelId, typingParentEventId, @@ -118,7 +122,9 @@ export function MessageComposer({ // ── Refs consumed by Tiptap's submitOnEnter extension ────────────── const isAutocompleteOpenRef = React.useRef(false); isAutocompleteOpenRef.current = - mentions.isMentionOpen || channelLinks.isChannelOpen; + mentions.isMentionOpen || + channelLinks.isChannelOpen || + emojiAutocomplete.isEmojiAutocompleteOpen; const submitMessageRef = React.useRef<() => void>(() => {}); @@ -142,10 +148,11 @@ export function MessageComposer({ setContent(markdown); contentRef.current = markdown; - // Bridge to existing mention/channel detection hooks. + // Bridge to existing mention/channel/emoji detection hooks. const { cursor } = richText.getTextAndCursor(); mentions.updateMentionQuery(text, cursor); channelLinks.updateChannelQuery(text, cursor); + emojiAutocomplete.updateEmojiQuery(text, cursor); if (text.trim().length > 0) { notifyTyping(); @@ -180,6 +187,7 @@ export function MessageComposer({ setIsEmojiPickerOpen(false); mentions.clearMentions(); channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); return () => { if (effectiveDraftKey) { @@ -237,6 +245,21 @@ export function MessageComposer({ ], ); + const applyEmojiInsert = React.useCallback( + (suggestion: EmojiSuggestion) => { + const { text, cursor } = richText.getTextAndCursor(); + const result = emojiAutocomplete.insertEmoji(suggestion, text, cursor); + richText.setContentWithTrailingSpace(result.nextContent); + setContent(result.nextContent); + contentRef.current = result.nextContent; + }, + [ + emojiAutocomplete.insertEmoji, + richText.getTextAndCursor, + richText.setContentWithTrailingSpace, + ], + ); + // ── Emoji insertion ───────────────────────────────────────────────── const insertEmoji = React.useCallback( (emoji: string) => { @@ -293,6 +316,7 @@ export function MessageComposer({ richText.clearContent(); mentions.clearMentions(); channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); setIsEmojiPickerOpen(false); try { @@ -350,6 +374,7 @@ export function MessageComposer({ media.setPendingImeta([]); mentions.clearMentions(); channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); setIsEmojiPickerOpen(false); const sendChannelId = channelIdRef.current; @@ -373,6 +398,7 @@ export function MessageComposer({ channelLinks.clearChannels, richText.clearContent, richText.setContent, + emojiAutocomplete.clearEmojis, ]); submitMessageRef.current = submitMessage; @@ -392,6 +418,14 @@ export function MessageComposer({ const handleEditorKeyDown = React.useCallback( (event: React.KeyboardEvent) => { // Let autocomplete handle keys first + const emojiResult = emojiAutocomplete.handleEmojiKeyDown(event); + if (emojiResult.handled) { + if (emojiResult.suggestion) { + applyEmojiInsert(emojiResult.suggestion); + } + return; + } + const channelResult = channelLinks.handleChannelKeyDown(event); if (channelResult.handled) { if (channelResult.suggestion) { @@ -416,6 +450,8 @@ export function MessageComposer({ } }, [ + emojiAutocomplete.handleEmojiKeyDown, + applyEmojiInsert, channelLinks.handleChannelKeyDown, applyChannelInsert, mentions.handleMentionKeyDown, @@ -511,6 +547,15 @@ export function MessageComposer({ handleSubmit(event); }} > + Date: Fri, 24 Apr 2026 10:32:36 -0600 Subject: [PATCH 003/120] feat(mobile): day dividers, video upload, emoji picker & UX polish (#396) Co-authored-by: Claude Opus 4.6 (1M context) --- .../com/sprout/sprout_mobile/MainActivity.kt | 65 ++ mobile/ios/Runner/AppDelegate.swift | 62 ++ .../channels/channel_detail_page.dart | 876 +----------------- mobile/lib/features/channels/compose_bar.dart | 379 ++------ .../features/channels/date_formatters.dart | 43 + mobile/lib/features/channels/day_divider.dart | 34 + .../lib/features/channels/emoji_picker.dart | 300 ++++++ .../channels/manage_channel_sheet.dart | 308 ++++++ .../lib/features/channels/members_sheet.dart | 295 ++++++ .../features/channels/message_actions.dart | 266 ++++++ .../features/channels/message_content.dart | 21 +- mobile/lib/shared/relay/media_upload.dart | 103 +- mobile/pubspec.lock | 8 + mobile/pubspec.yaml | 1 + .../features/channels/compose_bar_test.dart | 104 ++- .../channels/date_formatters_test.dart | 59 ++ .../features/forum/forum_widgets_test.dart | 10 +- .../test/shared/relay/media_upload_test.dart | 230 +++++ 18 files changed, 2006 insertions(+), 1158 deletions(-) create mode 100644 mobile/lib/features/channels/date_formatters.dart create mode 100644 mobile/lib/features/channels/day_divider.dart create mode 100644 mobile/lib/features/channels/emoji_picker.dart create mode 100644 mobile/lib/features/channels/manage_channel_sheet.dart create mode 100644 mobile/lib/features/channels/members_sheet.dart create mode 100644 mobile/lib/features/channels/message_actions.dart create mode 100644 mobile/test/features/channels/date_formatters_test.dart diff --git a/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt index 455c9b4bed..a1081be852 100644 --- a/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/sprout/sprout_mobile/MainActivity.kt @@ -3,12 +3,16 @@ package com.sprout.sprout_mobile import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.ImageDecoder +import android.media.MediaExtractor +import android.media.MediaMuxer import android.os.Build import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import java.io.ByteArrayOutputStream +import java.io.File import java.nio.ByteBuffer +import java.util.UUID class MainActivity : FlutterActivity() { private var mediaUploadChannel: MethodChannel? = null @@ -28,6 +32,9 @@ class MainActivity : FlutterActivity() { TRANSCODE_IMAGE_TO_JPEG_METHOD -> { handleTranscodeImageToJpeg(call.arguments, result) } + TRANSCODE_VIDEO_TO_MP4_METHOD -> { + handleTranscodeVideoToMp4(call.arguments, result) + } else -> result.notImplemented() } } @@ -150,6 +157,63 @@ class MainActivity : FlutterActivity() { result.success(transformedBytes) } + private fun handleTranscodeVideoToMp4( + arguments: Any?, + result: MethodChannel.Result, + ) { + val sourcePath = arguments as? String ?: run { + invalidArguments(result, "Expected source file path as String.") + return + } + + Thread { + val outputFile = File(cacheDir, "${UUID.randomUUID()}.mp4") + var muxer: MediaMuxer? = null + val extractor = MediaExtractor() + try { + extractor.setDataSource(sourcePath) + muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + + val trackIndices = mutableMapOf() + for (i in 0 until extractor.trackCount) { + val format = extractor.getTrackFormat(i) + val newIndex = muxer.addTrack(format) + trackIndices[i] = newIndex + extractor.selectTrack(i) + } + + muxer.start() + val buffer = ByteBuffer.allocate(1024 * 1024) // 1MB buffer + val bufferInfo = android.media.MediaCodec.BufferInfo() + + while (true) { + val sampleSize = extractor.readSampleData(buffer, 0) + if (sampleSize < 0) break + val muxerTrack = trackIndices[extractor.sampleTrackIndex]!! + bufferInfo.offset = 0 + bufferInfo.size = sampleSize + bufferInfo.presentationTimeUs = extractor.sampleTime + bufferInfo.flags = extractor.sampleFlags + muxer.writeSampleData(muxerTrack, buffer, bufferInfo) + extractor.advance() + } + + muxer.stop() + result.success(outputFile.absolutePath) + } catch (e: Exception) { + outputFile.delete() + result.error( + "transcode_failed", + e.message ?: "Video transcoding failed.", + null, + ) + } finally { + try { muxer?.release() } catch (_: Exception) {} + extractor.release() + } + }.start() + } + private fun invalidArguments( result: MethodChannel.Result, message: String, @@ -161,5 +225,6 @@ class MainActivity : FlutterActivity() { private const val MEDIA_UPLOAD_CHANNEL = "sprout/media_upload" private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload" private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg" + private const val TRANSCODE_VIDEO_TO_MP4_METHOD = "transcodeVideoToMp4" } } diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index db858f3998..e5361f2961 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,3 +1,4 @@ +import AVFoundation import Flutter import UIKit @@ -103,8 +104,69 @@ import UIKit } result(FlutterStandardTypedData(bytes: jpegData)) + case "transcodeVideoToMp4": + guard let sourcePath = call.arguments as? String else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected source file path as String.", + details: nil + ) + ) + return + } + transcodeVideoToMp4(sourcePath: sourcePath, result: result) default: result(FlutterMethodNotImplemented) } } + + private func transcodeVideoToMp4( + sourcePath: String, + result: @escaping FlutterResult + ) { + let sourceURL = URL(fileURLWithPath: sourcePath) + let asset = AVURLAsset(url: sourceURL) + + guard let exportSession = AVAssetExportSession( + asset: asset, + presetName: AVAssetExportPresetPassthrough + ) else { + result( + FlutterError( + code: "transcode_failed", + message: "Unable to create export session.", + details: nil + ) + ) + return + } + + let outputURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("mp4") + + exportSession.outputURL = outputURL + exportSession.outputFileType = .mp4 + exportSession.shouldOptimizeForNetworkUse = true + + exportSession.exportAsynchronously { + switch exportSession.status { + case .completed: + result(outputURL.path) + default: + let errorMessage = exportSession.error?.localizedDescription + ?? "Video transcoding failed with status \(exportSession.status.rawValue)." + result( + FlutterError( + code: "transcode_failed", + message: errorMessage, + details: nil + ) + ) + // Clean up partial output on failure. + try? FileManager.default.removeItem(at: outputURL) + } + } + } } diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index f2fdbee7d6..3cf7095644 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -19,6 +18,11 @@ import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; +import 'date_formatters.dart'; +import 'day_divider.dart'; +import 'manage_channel_sheet.dart'; +import 'members_sheet.dart'; +import 'message_actions.dart'; import 'message_content.dart'; import 'send_message_provider.dart'; import 'thread_detail_page.dart'; @@ -115,7 +119,7 @@ class ChannelDetailPage extends HookConsumerWidget { context: context, isScrollControlled: true, showDragHandle: true, - builder: (_) => _MembersSheet( + builder: (_) => MembersSheet( channel: resolvedChannel, currentPubkey: currentPubkey, ), @@ -131,7 +135,7 @@ class ChannelDetailPage extends HookConsumerWidget { context: context, isScrollControlled: true, showDragHandle: true, - builder: (_) => _ManageChannelSheet(channel: resolvedChannel), + builder: (_) => ManageChannelSheet(channel: resolvedChannel), ); if (shouldClose == true && context.mounted) { Navigator.of(context).pop(); @@ -317,42 +321,51 @@ class _MessageList extends HookConsumerWidget { final entry = entries[chronIdx]; final message = entry.message; - if (message.isSystem) { - return _SystemMessageRow(message: message); - } - - // The message visually above is the one earlier in time. + // Day boundary check — applies to all messages including system. final prevEntry = chronIdx > 0 ? entries[chronIdx - 1] : null; final prevMessage = prevEntry?.message; - final showAuthor = + final showDayDivider = prevMessage == null || - prevMessage.isSystem || - prevMessage.pubkey.toLowerCase() != message.pubkey.toLowerCase() || - (message.createdAt - prevMessage.createdAt) > 300; + !isSameDay(prevMessage.createdAt, message.createdAt); + + final showAuthor = + !message.isSystem && + (prevMessage == null || + prevMessage.isSystem || + showDayDivider || + prevMessage.pubkey.toLowerCase() != + message.pubkey.toLowerCase() || + (message.createdAt - prevMessage.createdAt) > 300); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _MessageBubble( - message: message, - showAuthor: showAuthor, - channelNames: channelNamesMap, - currentChannelId: channelId, - currentPubkey: currentPubkey, - allMessages: allMessages, - isMember: isMember, - isArchived: isArchived, - ), - if (entry.summary != null) - _ThreadSummaryRow( - summary: entry.summary!, + if (showDayDivider) + DayDivider(label: formatDayHeading(message.createdAt)), + if (message.isSystem) + _SystemMessageRow(message: message) + else ...[ + _MessageBubble( message: message, - allMessages: allMessages, - channelId: channelId, + showAuthor: showAuthor, + channelNames: channelNamesMap, + currentChannelId: channelId, currentPubkey: currentPubkey, + allMessages: allMessages, isMember: isMember, isArchived: isArchived, ), + if (entry.summary != null) + _ThreadSummaryRow( + summary: entry.summary!, + message: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ], ], ); }, @@ -387,7 +400,7 @@ class _SystemMessageRow extends ConsumerWidget { final description = systemEvent.describe(resolveLabel); return Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.half), + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), child: Row( children: [ Container( @@ -408,14 +421,14 @@ class _SystemMessageRow extends ConsumerWidget { child: Text( description, style: context.textTheme.bodySmall?.copyWith( - color: context.colors.outline, + color: context.colors.onSurfaceVariant, ), ), ), Text( _formatTime(message.createdAt), style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline.withValues(alpha: 0.6), + color: context.colors.outline, ), ), ], @@ -598,7 +611,7 @@ class _MessageBubble extends ConsumerWidget { return GestureDetector( behavior: HitTestBehavior.opaque, - onLongPress: () => _showMessageActions( + onLongPress: () => showMessageActions( context: context, ref: ref, message: message, @@ -611,7 +624,7 @@ class _MessageBubble extends ConsumerWidget { isArchived: isArchived, ), child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.quarter), + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : Grid.half), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -804,228 +817,6 @@ class _ReactionRow extends StatelessWidget { } } -// --------------------------------------------------------------------------- -// Message actions (long-press sheet) -// --------------------------------------------------------------------------- - -const _quickEmojis = ['👍', '❤️', '😂', '🎉', '👀', '🙏']; - -void _showMessageActions({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required String channelId, - required bool isOwnMessage, - List? allMessages, - String? currentPubkey, - bool isMember = false, - bool isArchived = false, -}) { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Quick emoji row - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - for (final emoji in _quickEmojis) - GestureDetector( - onTap: () { - Navigator.of(sheetContext).pop(); - ref - .read(channelActionsProvider) - .addReaction(message.id, emoji); - }, - child: Container( - width: 44, - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Theme.of( - sheetContext, - ).colorScheme.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Text(emoji, style: const TextStyle(fontSize: 20)), - ), - ), - ], - ), - const SizedBox(height: Grid.xs), - if (allMessages != null) - ListTile( - leading: const Icon(LucideIcons.messageSquareReply), - title: const Text('Reply in thread'), - onTap: () { - Navigator.of(sheetContext).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - ), - ListTile( - leading: const Icon(LucideIcons.copy), - title: const Text('Copy text'), - onTap: () { - Navigator.of(sheetContext).pop(); - // Copy to clipboard - final data = ClipboardData(text: message.content); - Clipboard.setData(data); - }, - ), - if (isOwnMessage) ...[ - ListTile( - leading: const Icon(LucideIcons.pencil), - title: const Text('Edit message'), - onTap: () { - Navigator.of(sheetContext).pop(); - _showEditSheet( - context: context, - ref: ref, - message: message, - channelId: channelId, - ); - }, - ), - ListTile( - leading: Icon( - LucideIcons.trash2, - color: Theme.of(sheetContext).colorScheme.error, - ), - title: Text( - 'Delete message', - style: TextStyle( - color: Theme.of(sheetContext).colorScheme.error, - ), - ), - onTap: () { - Navigator.of(sheetContext).pop(); - _confirmDelete( - context: context, - ref: ref, - messageId: message.id, - ); - }, - ), - ], - ], - ), - ), - ), - ); -} - -void _showEditSheet({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required String channelId, -}) { - final controller = TextEditingController(text: message.content); - showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (sheetContext) => Padding( - padding: EdgeInsets.fromLTRB( - Grid.xs, - 0, - Grid.xs, - MediaQuery.viewInsetsOf(sheetContext).bottom + Grid.xs, - ), - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: controller, - autofocus: true, - minLines: 1, - maxLines: 5, - decoration: const InputDecoration(hintText: 'Edit message'), - ), - const SizedBox(height: Grid.xxs), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(sheetContext).pop(), - child: const Text('Cancel'), - ), - const SizedBox(width: Grid.half), - FilledButton( - onPressed: () { - final text = controller.text.trim(); - if (text.isEmpty || text == message.content) { - Navigator.of(sheetContext).pop(); - return; - } - ref - .read(channelActionsProvider) - .editMessage( - channelId: channelId, - eventId: message.id, - content: text, - ); - Navigator.of(sheetContext).pop(); - }, - child: const Text('Save'), - ), - ], - ), - ], - ), - ), - ), - ); -} - -void _confirmDelete({ - required BuildContext context, - required WidgetRef ref, - required String messageId, -}) { - showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete message'), - content: const Text('This cannot be undone.'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - ref.read(channelActionsProvider).deleteMessage(messageId); - }, - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - ), - child: const Text('Delete'), - ), - ], - ), - ); -} - // --------------------------------------------------------------------------- // Channel management // --------------------------------------------------------------------------- @@ -1069,583 +860,6 @@ class _ReadOnlyNotice extends StatelessWidget { } } -class _MembersSheet extends HookConsumerWidget { - final Channel channel; - final String? currentPubkey; - - const _MembersSheet({required this.channel, required this.currentPubkey}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final membersAsync = ref.watch(channelMembersProvider(channel.id)); - final allMembers = membersAsync.asData?.value ?? const []; - final people = allMembers.where((member) => !member.isBot).toList(); - final userCache = ref.watch(userCacheProvider); - - // Determine if the current user can manage members. - final currentMember = allMembers.cast().firstWhere( - (m) => m!.pubkey.toLowerCase() == currentPubkey?.toLowerCase(), - orElse: () => null, - ); - final canManage = - currentMember != null && - currentMember.isElevated && - !channel.isArchived; - - // Preload profiles for all members so avatars appear. - useEffect(() { - if (people.isNotEmpty) { - ref - .read(userCacheProvider.notifier) - .preload(people.map((m) => m.pubkey).toList()); - } - return null; - }, [people.length]); - - return Padding( - padding: EdgeInsets.fromLTRB( - Grid.xs, - 0, - Grid.xs, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, - ), - child: SafeArea( - top: false, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Members', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.xxs), - Text( - 'People in ${channel.displayLabel(currentPubkey: currentPubkey)}.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - if (!channel.isDm) ...[const Divider(height: Grid.sm)], - SizedBox( - height: 280, - child: membersAsync.when( - data: (_) => people.isEmpty - ? Center( - child: Text( - 'No people found.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.outline, - ), - ), - ) - : ListView( - shrinkWrap: true, - children: [ - for (final member in people) - _MemberTile( - member: member, - currentPubkey: currentPubkey, - profile: userCache[member.pubkey.toLowerCase()], - canManage: canManage, - isSelf: - member.pubkey.toLowerCase() == - currentPubkey?.toLowerCase(), - channelId: channel.id, - ), - ], - ), - loading: () => - const Center(child: CircularProgressIndicator()), - error: (error, _) => Center( - child: Text( - error.toString(), - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -const _changeableRoles = ['admin', 'member', 'guest']; - -class _MemberTile extends ConsumerWidget { - final ChannelMember member; - final String? currentPubkey; - final UserProfile? profile; - final bool canManage; - final bool isSelf; - final String channelId; - - const _MemberTile({ - required this.member, - required this.currentPubkey, - required this.profile, - required this.canManage, - required this.isSelf, - required this.channelId, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final label = member.labelFor(currentPubkey); - final initial = label.substring(0, 1).toUpperCase(); - final showMenu = canManage && !isSelf && !member.isOwner; - - return ListTile( - contentPadding: EdgeInsets.zero, - leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial), - title: Text(label), - subtitle: Text( - _roleLabel(member.role), - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.outline, - ), - ), - trailing: showMenu - ? IconButton( - icon: const Icon(LucideIcons.ellipsis, size: 18), - onPressed: () => _showMemberActions(context, ref), - visualDensity: VisualDensity.compact, - ) - : null, - ); - } - - String _roleLabel(String role) { - if (role.isEmpty) return 'Member'; - return '${role[0].toUpperCase()}${role.substring(1)}'; - } - - void _showMemberActions(BuildContext context, WidgetRef ref) { - final label = member.labelFor(currentPubkey); - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (_) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - child: Text(label, style: context.textTheme.titleSmall), - ), - const SizedBox(height: Grid.xxs), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - child: Text( - 'Change role', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.outline, - ), - ), - ), - const SizedBox(height: Grid.half), - for (final role in _changeableRoles) - ListTile( - title: Text(_roleLabel(role)), - trailing: role == member.role - ? Icon( - LucideIcons.check, - size: 16, - color: context.colors.primary, - ) - : null, - enabled: role != member.role, - onTap: role == member.role - ? null - : () async { - Navigator.of(context).pop(); - await ref - .read(channelActionsProvider) - .changeMemberRole( - channelId: channelId, - pubkey: member.pubkey, - role: role, - ); - }, - ), - const Divider(), - ListTile( - leading: Icon( - LucideIcons.userMinus, - size: 18, - color: context.colors.error, - ), - title: Text( - 'Remove from channel', - style: TextStyle(color: context.colors.error), - ), - onTap: () async { - Navigator.of(context).pop(); - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Remove member'), - content: Text('Remove $label from this channel?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), - child: Text( - 'Remove', - style: TextStyle(color: context.colors.error), - ), - ), - ], - ), - ); - if (confirmed == true) { - await ref - .read(channelActionsProvider) - .removeMember( - channelId: channelId, - pubkey: member.pubkey, - ); - } - }, - ), - const SizedBox(height: Grid.xxs), - ], - ), - ), - ); - } -} - -class _MemberAvatar extends HookWidget { - final String? avatarUrl; - final String initial; - - const _MemberAvatar({required this.avatarUrl, required this.initial}); - - @override - Widget build(BuildContext context) { - final failed = useState(false); - - useEffect(() { - failed.value = false; - return null; - }, [avatarUrl]); - - final url = avatarUrl; - if (url == null || failed.value) { - return CircleAvatar(child: Text(initial)); - } - return CircleAvatar( - backgroundImage: NetworkImage(url), - onBackgroundImageError: (_, _) => failed.value = true, - child: null, - ); - } -} - -class _ManageChannelSheet extends HookConsumerWidget { - final Channel channel; - - const _ManageChannelSheet({required this.channel}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final canvasAsync = ref.watch(channelCanvasProvider(channel.id)); - final isEditingCanvas = useState(false); - final isSavingCanvas = useState(false); - final isBusy = useState(false); - final actionError = useState(null); - final canvasController = useTextEditingController(); - - useEffect(() { - final canvas = canvasAsync.asData?.value; - if (!isEditingCanvas.value) { - canvasController.text = canvas?.content ?? ''; - } - return null; - }, [canvasAsync.asData?.value.content, isEditingCanvas.value]); - - final canJoin = - channel.visibility == 'open' && - !channel.isArchived && - !channel.isMember && - !channel.isDm; - final canLeave = channel.isMember && !channel.isArchived && !channel.isDm; - final canEditCanvas = channel.isMember && !channel.isArchived; - - Future joinChannel() async { - if (isBusy.value) return; - isBusy.value = true; - actionError.value = null; - try { - await ref.read(channelActionsProvider).joinChannel(channel.id); - if (context.mounted) { - Navigator.of(context).pop(false); - } - } catch (error) { - actionError.value = error.toString(); - } finally { - isBusy.value = false; - } - } - - Future leaveChannel() async { - if (isBusy.value) return; - isBusy.value = true; - actionError.value = null; - try { - await ref.read(channelActionsProvider).leaveChannel(channel.id); - if (context.mounted) { - Navigator.of(context).pop(true); - } - } catch (error) { - actionError.value = error.toString(); - } finally { - isBusy.value = false; - } - } - - Future saveCanvas() async { - if (isSavingCanvas.value) { - return; - } - isSavingCanvas.value = true; - actionError.value = null; - try { - await ref - .read(channelActionsProvider) - .setCanvas( - channelId: channel.id, - content: canvasController.text.trim(), - ); - if (context.mounted) { - isEditingCanvas.value = false; - } - } catch (error) { - actionError.value = error.toString(); - } finally { - isSavingCanvas.value = false; - } - } - - return Padding( - padding: EdgeInsets.fromLTRB( - Grid.xs, - 0, - Grid.xs, - MediaQuery.viewInsetsOf(context).bottom + Grid.xs, - ), - child: SafeArea( - top: false, - child: ListView( - shrinkWrap: true, - children: [ - Text('Manage channel', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.xxs), - Text( - 'Basic management for ${channel.name}.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - if (actionError.value case final error?) ...[ - const SizedBox(height: Grid.xs), - Text( - error, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ], - if (canJoin || canLeave) ...[ - const SizedBox(height: Grid.xs), - Wrap( - spacing: Grid.xxs, - children: [ - if (canJoin) - FilledButton.tonal( - onPressed: isBusy.value ? null : joinChannel, - child: Text(isBusy.value ? 'Joining…' : 'Join channel'), - ), - if (canLeave) - OutlinedButton( - onPressed: isBusy.value ? null : leaveChannel, - child: Text(isBusy.value ? 'Leaving…' : 'Leave channel'), - ), - ], - ), - ], - const SizedBox(height: Grid.sm), - Text('Context', style: context.textTheme.labelLarge), - const SizedBox(height: Grid.xxs), - _ContextCard( - label: 'Description', - value: channel.description, - emptyLabel: 'No description set', - ), - const SizedBox(height: Grid.xxs), - _ContextCard( - label: 'Topic', - value: channel.topic, - emptyLabel: 'No topic set', - ), - const SizedBox(height: Grid.xxs), - _ContextCard( - label: 'Purpose', - value: channel.purpose, - emptyLabel: 'No purpose set', - ), - if (!channel.isDm) ...[ - const SizedBox(height: Grid.sm), - Text('Canvas', style: context.textTheme.labelLarge), - const SizedBox(height: Grid.xxs), - canvasAsync.when( - data: (canvas) { - if (isEditingCanvas.value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: canvasController, - maxLines: 8, - minLines: 6, - decoration: const InputDecoration( - hintText: 'Write your canvas content in Markdown…', - ), - ), - const SizedBox(height: Grid.xxs), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: isSavingCanvas.value - ? null - : () { - isEditingCanvas.value = false; - canvasController.text = - canvas.content ?? ''; - }, - child: const Text('Cancel'), - ), - const SizedBox(width: Grid.half), - FilledButton( - onPressed: isSavingCanvas.value - ? null - : saveCanvas, - child: Text( - isSavingCanvas.value - ? 'Saving…' - : 'Save canvas', - ), - ), - ], - ), - ], - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(Grid.xs), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Text( - canvas.content?.trim().isNotEmpty == true - ? canvas.content! - : 'No canvas set for this channel.', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - const SizedBox(height: Grid.xxs), - Align( - alignment: Alignment.centerRight, - child: FilledButton.tonal( - onPressed: canEditCanvas - ? () => isEditingCanvas.value = true - : null, - child: Text( - canvas.content?.trim().isNotEmpty == true - ? 'Edit canvas' - : 'Create canvas', - ), - ), - ), - ], - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => Text( - error.toString(), - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, - ), - ), - ), - ], - ], - ), - ), - ); - } -} - -class _ContextCard extends StatelessWidget { - final String label; - final String? value; - final String emptyLabel; - - const _ContextCard({ - required this.label, - required this.value, - required this.emptyLabel, - }); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(Grid.xs), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Grid.half), - Text( - value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ); - } -} - // --------------------------------------------------------------------------- // Typing indicator // --------------------------------------------------------------------------- diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 124d8d8f7e..f4b4ad5514 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -10,6 +10,7 @@ import '../../shared/theme/theme.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_management_provider.dart'; +import 'emoji_picker.dart'; /// Rich compose bar with @mention autocomplete, emoji picker, and a markdown /// formatting toolbar. Used in both channel and thread views — the caller @@ -226,13 +227,11 @@ class ComposeBar extends HookConsumerWidget { } } - Future pickAndUploadAttachment() async { + Future pickAndUpload(Future Function() pick) async { uploadError.value = null; uploadingCount.value += 1; try { - final uploaded = await ref - .read(mediaUploadServiceProvider) - .pickAndUploadImage(); + final uploaded = await pick(); if (uploaded != null && context.mounted) { attachments.value = [...attachments.value, uploaded]; } @@ -389,11 +388,47 @@ class ComposeBar extends HookConsumerWidget { children: [ _ComposeAction( icon: LucideIcons.paperclip, - onTap: pickAndUploadAttachment, + onTap: () { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.image), + title: const Text('Photo'), + onTap: () { + Navigator.of(sheetContext).pop(); + pickAndUpload( + ref + .read(mediaUploadServiceProvider) + .pickAndUploadImage, + ); + }, + ), + ListTile( + leading: const Icon(LucideIcons.video), + title: const Text('Video'), + onTap: () { + Navigator.of(sheetContext).pop(); + pickAndUpload( + ref + .read(mediaUploadServiceProvider) + .pickAndUploadVideo, + ); + }, + ), + ], + ), + ), + ); + }, ), _ComposeAction( icon: LucideIcons.smilePlus, - onTap: () => _showEmojiPicker( + onTap: () => showEmojiPicker( context: context, onSelect: insertEmoji, ), @@ -474,304 +509,6 @@ void _sendTypingIndicator( } } -// --------------------------------------------------------------------------- -// Emoji picker -// --------------------------------------------------------------------------- - -void _showEmojiPicker({ - required BuildContext context, - required void Function(String emoji) onSelect, -}) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - builder: (sheetContext) => _EmojiPickerSheet( - onSelect: (emoji) { - Navigator.of(sheetContext).pop(); - onSelect(emoji); - }, - ), - ); -} - -/// Emoji categories for the picker. System Unicode emoji — no packages needed. -const _emojiCategories = <({String label, IconData icon, List emoji})>[ - ( - label: 'Popular', - icon: LucideIcons.clock, - emoji: [ - '\u{1F44D}', - '\u{2764}\u{FE0F}', - '\u{1F602}', - '\u{1F389}', - '\u{1F440}', - '\u{1F64F}', - '\u{1F525}', - '\u{2705}', - ], - ), - ( - label: 'Smileys', - icon: LucideIcons.smile, - emoji: [ - '\u{1F600}', - '\u{1F603}', - '\u{1F604}', - '\u{1F601}', - '\u{1F605}', - '\u{1F602}', - '\u{1F923}', - '\u{1F607}', - '\u{1F60A}', - '\u{1F60D}', - '\u{1F618}', - '\u{1F617}', - '\u{1F61A}', - '\u{1F619}', - '\u{1F60B}', - '\u{1F61B}', - '\u{1F61D}', - '\u{1F61C}', - '\u{1F911}', - '\u{1F917}', - '\u{1F914}', - '\u{1F910}', - '\u{1F928}', - '\u{1F610}', - '\u{1F611}', - '\u{1F636}', - '\u{1F60F}', - '\u{1F612}', - '\u{1F644}', - '\u{1F62C}', - '\u{1F925}', - '\u{1F60C}', - '\u{1F614}', - '\u{1F62A}', - '\u{1F924}', - '\u{1F634}', - '\u{1F637}', - '\u{1F912}', - '\u{1F915}', - '\u{1F922}', - '\u{1F92E}', - '\u{1F927}', - '\u{1F975}', - '\u{1F976}', - '\u{1F974}', - '\u{1F635}', - '\u{1F92F}', - '\u{1F920}', - '\u{1F973}', - '\u{1F978}', - ], - ), - ( - label: 'Gestures', - icon: LucideIcons.hand, - emoji: [ - '\u{1F44D}', - '\u{1F44E}', - '\u{1F44A}', - '\u{270A}', - '\u{1F91B}', - '\u{1F91C}', - '\u{1F44F}', - '\u{1F64C}', - '\u{1F450}', - '\u{1F64F}', - '\u{1F91D}', - '\u{270C}\u{FE0F}', - '\u{1F91E}', - '\u{1F91F}', - '\u{1F918}', - '\u{1F448}', - '\u{1F449}', - '\u{1F446}', - '\u{1F447}', - '\u{261D}\u{FE0F}', - '\u{1F4AA}', - '\u{1F44B}', - '\u{1F590}\u{FE0F}', - ], - ), - ( - label: 'Objects', - icon: LucideIcons.lightbulb, - emoji: [ - '\u{2764}\u{FE0F}', - '\u{1F525}', - '\u{2B50}', - '\u{1F31F}', - '\u{1F4A5}', - '\u{1F389}', - '\u{1F38A}', - '\u{1F3C6}', - '\u{1F947}', - '\u{1F4A1}', - '\u{1F4AF}', - '\u{2705}', - '\u{274C}', - '\u{26A0}\u{FE0F}', - '\u{1F6A8}', - '\u{1F4DD}', - '\u{1F4CB}', - '\u{1F4CC}', - '\u{1F517}', - '\u{1F4E3}', - '\u{1F514}', - '\u{1F3B5}', - '\u{1F3B6}', - '\u{1F680}', - ], - ), - ( - label: 'Nature', - icon: LucideIcons.sprout, - emoji: [ - '\u{1F331}', - '\u{1F332}', - '\u{1F333}', - '\u{1F334}', - '\u{1F335}', - '\u{1F33B}', - '\u{1F33A}', - '\u{1F337}', - '\u{1F339}', - '\u{1F340}', - '\u{1F341}', - '\u{1F343}', - '\u{1F31E}', - '\u{1F308}', - '\u{2600}\u{FE0F}', - '\u{1F327}\u{FE0F}', - '\u{26A1}', - '\u{2744}\u{FE0F}', - '\u{1F30A}', - '\u{1F436}', - '\u{1F431}', - '\u{1F98A}', - '\u{1F42C}', - '\u{1F985}', - ], - ), -]; - -class _EmojiPickerSheet extends StatefulWidget { - final void Function(String emoji) onSelect; - - const _EmojiPickerSheet({required this.onSelect}); - - @override - State<_EmojiPickerSheet> createState() => _EmojiPickerSheetState(); -} - -class _EmojiPickerSheetState extends State<_EmojiPickerSheet> { - /// -1 = "All", 0..N = specific category. - int _selectedCategory = -1; - - static final _allEmoji = () { - final seen = {}; - return [ - for (final cat in _emojiCategories) - for (final e in cat.emoji) - if (seen.add(e)) e, - ]; - }(); - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final emoji = _selectedCategory < 0 - ? _allEmoji - : _emojiCategories[_selectedCategory].emoji; - - return SizedBox( - height: 340, - child: Column( - children: [ - // Category icon bar. - SizedBox( - height: 40, - child: Row( - children: [ - const SizedBox(width: Grid.twelve), - _CategoryIcon( - icon: LucideIcons.layoutGrid, - selected: _selectedCategory < 0, - onTap: () => setState(() => _selectedCategory = -1), - ), - for (var i = 0; i < _emojiCategories.length; i++) - _CategoryIcon( - icon: _emojiCategories[i].icon, - selected: _selectedCategory == i, - onTap: () => setState(() => _selectedCategory = i), - ), - ], - ), - ), - Divider(height: 1, color: colors.outlineVariant), - const SizedBox(height: Grid.xxs), - // Emoji grid. - Expanded( - child: GridView.builder( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, - mainAxisSpacing: Grid.half, - crossAxisSpacing: Grid.half, - ), - itemCount: emoji.length, - itemBuilder: (context, index) { - final e = emoji[index]; - return GestureDetector( - onTap: () => widget.onSelect(e), - child: Center( - child: Text(e, style: const TextStyle(fontSize: 28)), - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class _CategoryIcon extends StatelessWidget { - final IconData icon; - final bool selected; - final VoidCallback onTap; - - const _CategoryIcon({ - required this.icon, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - return SizedBox( - width: 40, - height: 40, - child: IconButton( - onPressed: onTap, - icon: Icon( - icon, - size: 18, - color: selected ? colors.primary : colors.onSurfaceVariant, - ), - padding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - ), - ); - } -} - // --------------------------------------------------------------------------- // Mention suggestions // --------------------------------------------------------------------------- @@ -1048,6 +785,7 @@ class _AttachmentStrip extends StatelessWidget { } final attachment = attachments[index]; + final isVideo = attachment.type.startsWith('video/'); final previewUrl = attachment.thumb ?? attachment.url; return Container( key: ValueKey('compose-attachment:${attachment.url}'), @@ -1061,17 +799,28 @@ class _AttachmentStrip extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(Radii.md), - child: Image.network( - previewUrl, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => ColoredBox( - color: context.colors.surface, - child: Icon( - LucideIcons.image, - color: context.colors.onSurfaceVariant, - ), - ), - ), + child: isVideo + ? ColoredBox( + color: Colors.black, + child: Center( + child: Icon( + LucideIcons.video, + color: Colors.white, + size: 24, + ), + ), + ) + : Image.network( + previewUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => ColoredBox( + color: context.colors.surface, + child: Icon( + LucideIcons.image, + color: context.colors.onSurfaceVariant, + ), + ), + ), ), Positioned( top: Grid.quarter, diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart new file mode 100644 index 0000000000..c268144854 --- /dev/null +++ b/mobile/lib/features/channels/date_formatters.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; + +final _fullDateFormat = DateFormat('EEEE, MMMM d, y'); + +/// Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026". +/// +/// [now] is exposed for testing; production callers should omit it. +String formatDayHeading(int unixSeconds, {@visibleForTesting DateTime? now}) { + final date = DateTime.fromMillisecondsSinceEpoch( + unixSeconds * 1000, + isUtc: true, + ).toLocal(); + now ??= DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final messageDay = DateTime(date.year, date.month, date.day); + + if (today.year == messageDay.year && + today.month == messageDay.month && + today.day == messageDay.day) { + return 'Today'; + } + final yesterday = DateTime(now.year, now.month, now.day - 1); + if (yesterday.year == messageDay.year && + yesterday.month == messageDay.month && + yesterday.day == messageDay.day) { + return 'Yesterday'; + } + return _fullDateFormat.format(date); +} + +/// Whether two unix-second timestamps fall on the same calendar day (local time). +bool isSameDay(int a, int b) { + final dtA = DateTime.fromMillisecondsSinceEpoch( + a * 1000, + isUtc: true, + ).toLocal(); + final dtB = DateTime.fromMillisecondsSinceEpoch( + b * 1000, + isUtc: true, + ).toLocal(); + return dtA.year == dtB.year && dtA.month == dtB.month && dtA.day == dtB.day; +} diff --git a/mobile/lib/features/channels/day_divider.dart b/mobile/lib/features/channels/day_divider.dart new file mode 100644 index 0000000000..d9b9b1d875 --- /dev/null +++ b/mobile/lib/features/channels/day_divider.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../shared/theme/theme.dart'; + +/// A centered label between two horizontal dividers, used to separate +/// messages by calendar day ("TODAY", "YESTERDAY", full dates). +class DayDivider extends StatelessWidget { + final String label; + + const DayDivider({super.key, required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Row( + children: [ + Expanded(child: Divider(color: context.colors.outlineVariant)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + child: Text( + label.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + letterSpacing: 2.0, + ), + ), + ), + Expanded(child: Divider(color: context.colors.outlineVariant)), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart new file mode 100644 index 0000000000..63e3f40cae --- /dev/null +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; + +/// Opens the full emoji picker as a modal bottom sheet. +void showEmojiPicker({ + required BuildContext context, + required void Function(String emoji) onSelect, +}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + builder: (sheetContext) => EmojiPickerSheet( + onSelect: (emoji) { + Navigator.of(sheetContext).pop(); + onSelect(emoji); + }, + ), + ); +} + +/// Emoji categories for the picker. System Unicode emoji — no packages needed. +const emojiCategories = <({String label, IconData icon, List emoji})>[ + ( + label: 'Popular', + icon: LucideIcons.clock, + emoji: [ + '\u{1F44D}', + '\u{2764}\u{FE0F}', + '\u{1F602}', + '\u{1F389}', + '\u{1F440}', + '\u{1F64F}', + '\u{1F525}', + '\u{2705}', + ], + ), + ( + label: 'Smileys', + icon: LucideIcons.smile, + emoji: [ + '\u{1F600}', + '\u{1F603}', + '\u{1F604}', + '\u{1F601}', + '\u{1F605}', + '\u{1F602}', + '\u{1F923}', + '\u{1F607}', + '\u{1F60A}', + '\u{1F60D}', + '\u{1F618}', + '\u{1F617}', + '\u{1F61A}', + '\u{1F619}', + '\u{1F60B}', + '\u{1F61B}', + '\u{1F61D}', + '\u{1F61C}', + '\u{1F911}', + '\u{1F917}', + '\u{1F914}', + '\u{1F910}', + '\u{1F928}', + '\u{1F610}', + '\u{1F611}', + '\u{1F636}', + '\u{1F60F}', + '\u{1F612}', + '\u{1F644}', + '\u{1F62C}', + '\u{1F925}', + '\u{1F60C}', + '\u{1F614}', + '\u{1F62A}', + '\u{1F924}', + '\u{1F634}', + '\u{1F637}', + '\u{1F912}', + '\u{1F915}', + '\u{1F922}', + '\u{1F92E}', + '\u{1F927}', + '\u{1F975}', + '\u{1F976}', + '\u{1F974}', + '\u{1F635}', + '\u{1F92F}', + '\u{1F920}', + '\u{1F973}', + '\u{1F978}', + ], + ), + ( + label: 'Gestures', + icon: LucideIcons.hand, + emoji: [ + '\u{1F44D}', + '\u{1F44E}', + '\u{1F44A}', + '\u{270A}', + '\u{1F91B}', + '\u{1F91C}', + '\u{1F44F}', + '\u{1F64C}', + '\u{1F450}', + '\u{1F64F}', + '\u{1F91D}', + '\u{270C}\u{FE0F}', + '\u{1F91E}', + '\u{1F91F}', + '\u{1F918}', + '\u{1F448}', + '\u{1F449}', + '\u{1F446}', + '\u{1F447}', + '\u{261D}\u{FE0F}', + '\u{1F4AA}', + '\u{1F44B}', + '\u{1F590}\u{FE0F}', + ], + ), + ( + label: 'Objects', + icon: LucideIcons.lightbulb, + emoji: [ + '\u{2764}\u{FE0F}', + '\u{1F525}', + '\u{2B50}', + '\u{1F31F}', + '\u{1F4A5}', + '\u{1F389}', + '\u{1F38A}', + '\u{1F3C6}', + '\u{1F947}', + '\u{1F4A1}', + '\u{1F4AF}', + '\u{2705}', + '\u{274C}', + '\u{26A0}\u{FE0F}', + '\u{1F6A8}', + '\u{1F4DD}', + '\u{1F4CB}', + '\u{1F4CC}', + '\u{1F517}', + '\u{1F4E3}', + '\u{1F514}', + '\u{1F3B5}', + '\u{1F3B6}', + '\u{1F680}', + ], + ), + ( + label: 'Nature', + icon: LucideIcons.sprout, + emoji: [ + '\u{1F331}', + '\u{1F332}', + '\u{1F333}', + '\u{1F334}', + '\u{1F335}', + '\u{1F33B}', + '\u{1F33A}', + '\u{1F337}', + '\u{1F339}', + '\u{1F340}', + '\u{1F341}', + '\u{1F343}', + '\u{1F31E}', + '\u{1F308}', + '\u{2600}\u{FE0F}', + '\u{1F327}\u{FE0F}', + '\u{26A1}', + '\u{2744}\u{FE0F}', + '\u{1F30A}', + '\u{1F436}', + '\u{1F431}', + '\u{1F98A}', + '\u{1F42C}', + '\u{1F985}', + ], + ), +]; + +class EmojiPickerSheet extends StatefulWidget { + final void Function(String emoji) onSelect; + + const EmojiPickerSheet({super.key, required this.onSelect}); + + @override + State createState() => _EmojiPickerSheetState(); +} + +class _EmojiPickerSheetState extends State { + /// -1 = "All", 0..N = specific category. + int _selectedCategory = -1; + + static final _allEmoji = () { + final seen = {}; + return [ + for (final cat in emojiCategories) + for (final e in cat.emoji) + if (seen.add(e)) e, + ]; + }(); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final emoji = _selectedCategory < 0 + ? _allEmoji + : emojiCategories[_selectedCategory].emoji; + + return SizedBox( + height: 340, + child: Column( + children: [ + // Category icon bar. + SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox(width: Grid.twelve), + CategoryIcon( + icon: LucideIcons.layoutGrid, + selected: _selectedCategory < 0, + onTap: () => setState(() => _selectedCategory = -1), + ), + for (var i = 0; i < emojiCategories.length; i++) + CategoryIcon( + icon: emojiCategories[i].icon, + selected: _selectedCategory == i, + onTap: () => setState(() => _selectedCategory = i), + ), + ], + ), + ), + Divider(height: 1, color: colors.outlineVariant), + const SizedBox(height: Grid.xxs), + // Emoji grid. + Expanded( + child: GridView.builder( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, + mainAxisSpacing: Grid.half, + crossAxisSpacing: Grid.half, + ), + itemCount: emoji.length, + itemBuilder: (context, index) { + final e = emoji[index]; + return GestureDetector( + onTap: () => widget.onSelect(e), + child: Center( + child: Text(e, style: const TextStyle(fontSize: 28)), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class CategoryIcon extends StatelessWidget { + final IconData icon; + final bool selected; + final VoidCallback onTap; + + const CategoryIcon({ + super.key, + required this.icon, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return SizedBox( + width: 40, + height: 40, + child: IconButton( + onPressed: onTap, + icon: Icon( + icon, + size: 18, + color: selected ? colors.primary : colors.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + ), + ); + } +} diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart new file mode 100644 index 0000000000..19d67cab92 --- /dev/null +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/theme.dart'; +import 'channel.dart'; +import 'channel_management_provider.dart'; + +class ManageChannelSheet extends HookConsumerWidget { + final Channel channel; + + const ManageChannelSheet({super.key, required this.channel}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canvasAsync = ref.watch(channelCanvasProvider(channel.id)); + final isEditingCanvas = useState(false); + final isSavingCanvas = useState(false); + final isBusy = useState(false); + final actionError = useState(null); + final canvasController = useTextEditingController(); + + useEffect(() { + final canvas = canvasAsync.asData?.value; + if (!isEditingCanvas.value) { + canvasController.text = canvas?.content ?? ''; + } + return null; + }, [canvasAsync.asData?.value.content, isEditingCanvas.value]); + + final canJoin = + channel.visibility == 'open' && + !channel.isArchived && + !channel.isMember && + !channel.isDm; + final canLeave = channel.isMember && !channel.isArchived && !channel.isDm; + final canEditCanvas = channel.isMember && !channel.isArchived; + + Future joinChannel() async { + if (isBusy.value) return; + isBusy.value = true; + actionError.value = null; + try { + await ref.read(channelActionsProvider).joinChannel(channel.id); + if (context.mounted) { + Navigator.of(context).pop(false); + } + } catch (error) { + actionError.value = error.toString(); + } finally { + isBusy.value = false; + } + } + + Future leaveChannel() async { + if (isBusy.value) return; + isBusy.value = true; + actionError.value = null; + try { + await ref.read(channelActionsProvider).leaveChannel(channel.id); + if (context.mounted) { + Navigator.of(context).pop(true); + } + } catch (error) { + actionError.value = error.toString(); + } finally { + isBusy.value = false; + } + } + + Future saveCanvas() async { + if (isSavingCanvas.value) { + return; + } + isSavingCanvas.value = true; + actionError.value = null; + try { + await ref + .read(channelActionsProvider) + .setCanvas( + channelId: channel.id, + content: canvasController.text.trim(), + ); + if (context.mounted) { + isEditingCanvas.value = false; + } + } catch (error) { + actionError.value = error.toString(); + } finally { + isSavingCanvas.value = false; + } + } + + return Padding( + padding: EdgeInsets.fromLTRB( + Grid.xs, + 0, + Grid.xs, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: SafeArea( + top: false, + child: ListView( + shrinkWrap: true, + children: [ + Text('Manage channel', style: context.textTheme.titleMedium), + const SizedBox(height: Grid.xxs), + Text( + 'Basic management for ${channel.name}.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (actionError.value case final error?) ...[ + const SizedBox(height: Grid.xs), + Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + if (canJoin || canLeave) ...[ + const SizedBox(height: Grid.xs), + Wrap( + spacing: Grid.xxs, + children: [ + if (canJoin) + FilledButton.tonal( + onPressed: isBusy.value ? null : joinChannel, + child: Text( + isBusy.value ? 'Joining\u2026' : 'Join channel', + ), + ), + if (canLeave) + OutlinedButton( + onPressed: isBusy.value ? null : leaveChannel, + child: Text( + isBusy.value ? 'Leaving\u2026' : 'Leave channel', + ), + ), + ], + ), + ], + const SizedBox(height: Grid.sm), + Text('Context', style: context.textTheme.labelLarge), + const SizedBox(height: Grid.xxs), + _ContextCard( + label: 'Description', + value: channel.description, + emptyLabel: 'No description set', + ), + const SizedBox(height: Grid.xxs), + _ContextCard( + label: 'Topic', + value: channel.topic, + emptyLabel: 'No topic set', + ), + const SizedBox(height: Grid.xxs), + _ContextCard( + label: 'Purpose', + value: channel.purpose, + emptyLabel: 'No purpose set', + ), + if (!channel.isDm) ...[ + const SizedBox(height: Grid.sm), + Text('Canvas', style: context.textTheme.labelLarge), + const SizedBox(height: Grid.xxs), + canvasAsync.when( + data: (canvas) { + if (isEditingCanvas.value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: canvasController, + maxLines: 8, + minLines: 6, + decoration: const InputDecoration( + hintText: + 'Write your canvas content in Markdown\u2026', + ), + ), + const SizedBox(height: Grid.xxs), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: isSavingCanvas.value + ? null + : () { + isEditingCanvas.value = false; + canvasController.text = + canvas.content ?? ''; + }, + child: const Text('Cancel'), + ), + const SizedBox(width: Grid.half), + FilledButton( + onPressed: isSavingCanvas.value + ? null + : saveCanvas, + child: Text( + isSavingCanvas.value + ? 'Saving\u2026' + : 'Save canvas', + ), + ), + ], + ), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(Grid.xs), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.md), + ), + child: Text( + canvas.content?.trim().isNotEmpty == true + ? canvas.content! + : 'No canvas set for this channel.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: Grid.xxs), + Align( + alignment: Alignment.centerRight, + child: FilledButton.tonal( + onPressed: canEditCanvas + ? () => isEditingCanvas.value = true + : null, + child: Text( + canvas.content?.trim().isNotEmpty == true + ? 'Edit canvas' + : 'Create canvas', + ), + ), + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Text( + error.toString(), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +class _ContextCard extends StatelessWidget { + final String label; + final String? value; + final String emptyLabel; + + const _ContextCard({ + required this.label, + required this.value, + required this.emptyLabel, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(Grid.xs), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.half), + Text( + value?.trim().isNotEmpty == true ? value!.trim() : emptyLabel, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart new file mode 100644 index 0000000000..bfc7c55c05 --- /dev/null +++ b/mobile/lib/features/channels/members_sheet.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import 'channel.dart'; +import 'channel_management_provider.dart'; + +class MembersSheet extends HookConsumerWidget { + final Channel channel; + final String? currentPubkey; + + const MembersSheet({ + super.key, + required this.channel, + required this.currentPubkey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final membersAsync = ref.watch(channelMembersProvider(channel.id)); + final allMembers = membersAsync.asData?.value ?? const []; + final people = allMembers.where((member) => !member.isBot).toList(); + final userCache = ref.watch(userCacheProvider); + + // Determine if the current user can manage members. + final currentMember = allMembers.cast().firstWhere( + (m) => m!.pubkey.toLowerCase() == currentPubkey?.toLowerCase(), + orElse: () => null, + ); + final canManage = + currentMember != null && + currentMember.isElevated && + !channel.isArchived; + + // Preload profiles for all members so avatars appear. + useEffect(() { + if (people.isNotEmpty) { + ref + .read(userCacheProvider.notifier) + .preload(people.map((m) => m.pubkey).toList()); + } + return null; + }, [people.length]); + + return Padding( + padding: EdgeInsets.fromLTRB( + Grid.xs, + 0, + Grid.xs, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: SafeArea( + top: false, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Members', style: context.textTheme.titleMedium), + const SizedBox(height: Grid.xxs), + Text( + 'People in ${channel.displayLabel(currentPubkey: currentPubkey)}.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (!channel.isDm) ...[const Divider(height: Grid.sm)], + SizedBox( + height: 280, + child: membersAsync.when( + data: (_) => people.isEmpty + ? Center( + child: Text( + 'No people found.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), + ) + : ListView( + shrinkWrap: true, + children: [ + for (final member in people) + _MemberTile( + member: member, + currentPubkey: currentPubkey, + profile: userCache[member.pubkey.toLowerCase()], + canManage: canManage, + isSelf: + member.pubkey.toLowerCase() == + currentPubkey?.toLowerCase(), + channelId: channel.id, + ), + ], + ), + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text( + error.toString(), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +const _changeableRoles = ['admin', 'member', 'guest']; + +class _MemberTile extends ConsumerWidget { + final ChannelMember member; + final String? currentPubkey; + final UserProfile? profile; + final bool canManage; + final bool isSelf; + final String channelId; + + const _MemberTile({ + required this.member, + required this.currentPubkey, + required this.profile, + required this.canManage, + required this.isSelf, + required this.channelId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final label = member.labelFor(currentPubkey); + final initial = label.substring(0, 1).toUpperCase(); + final showMenu = canManage && !isSelf && !member.isOwner; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial), + title: Text(label), + subtitle: Text( + _roleLabel(member.role), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), + trailing: showMenu + ? IconButton( + icon: const Icon(LucideIcons.ellipsis, size: 18), + onPressed: () => _showMemberActions(context, ref), + visualDensity: VisualDensity.compact, + ) + : null, + ); + } + + String _roleLabel(String role) { + if (role.isEmpty) return 'Member'; + return '${role[0].toUpperCase()}${role.substring(1)}'; + } + + void _showMemberActions(BuildContext context, WidgetRef ref) { + final label = member.labelFor(currentPubkey); + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Text(label, style: context.textTheme.titleSmall), + ), + const SizedBox(height: Grid.xxs), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Text( + 'Change role', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.outline, + ), + ), + ), + const SizedBox(height: Grid.half), + for (final role in _changeableRoles) + ListTile( + title: Text(_roleLabel(role)), + trailing: role == member.role + ? Icon( + LucideIcons.check, + size: 16, + color: context.colors.primary, + ) + : null, + enabled: role != member.role, + onTap: role == member.role + ? null + : () async { + Navigator.of(context).pop(); + await ref + .read(channelActionsProvider) + .changeMemberRole( + channelId: channelId, + pubkey: member.pubkey, + role: role, + ); + }, + ), + const Divider(), + ListTile( + leading: Icon( + LucideIcons.userMinus, + size: 18, + color: context.colors.error, + ), + title: Text( + 'Remove from channel', + style: TextStyle(color: context.colors.error), + ), + onTap: () async { + Navigator.of(context).pop(); + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Remove member'), + content: Text('Remove $label from this channel?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + 'Remove', + style: TextStyle(color: context.colors.error), + ), + ), + ], + ), + ); + if (confirmed == true) { + await ref + .read(channelActionsProvider) + .removeMember( + channelId: channelId, + pubkey: member.pubkey, + ); + } + }, + ), + const SizedBox(height: Grid.xxs), + ], + ), + ), + ); + } +} + +class _MemberAvatar extends HookWidget { + final String? avatarUrl; + final String initial; + + const _MemberAvatar({required this.avatarUrl, required this.initial}); + + @override + Widget build(BuildContext context) { + final failed = useState(false); + + useEffect(() { + failed.value = false; + return null; + }, [avatarUrl]); + + final url = avatarUrl; + if (url == null || failed.value) { + return CircleAvatar(child: Text(initial)); + } + return CircleAvatar( + backgroundImage: NetworkImage(url), + onBackgroundImageError: (_, _) => failed.value = true, + child: null, + ); + } +} diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart new file mode 100644 index 0000000000..9e29f4ec22 --- /dev/null +++ b/mobile/lib/features/channels/message_actions.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import 'channel_management_provider.dart'; +import 'emoji_picker.dart'; +import 'thread_detail_page.dart'; +import 'timeline_message.dart'; + +const quickEmojis = [ + '\u{1F44D}', + '\u{2764}\u{FE0F}', + '\u{1F602}', + '\u{1F389}', + '\u{1F440}', + '\u{1F64F}', +]; + +void showMessageActions({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool isOwnMessage, + List? allMessages, + String? currentPubkey, + bool isMember = false, + bool isArchived = false, +}) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Quick emoji row + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final emoji in quickEmojis) + GestureDetector( + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelActionsProvider) + .addReaction(message.id, emoji); + }, + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of( + sheetContext, + ).colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Text(emoji, style: const TextStyle(fontSize: 20)), + ), + ), + GestureDetector( + onTap: () { + Navigator.of(sheetContext).pop(); + showEmojiPicker( + context: context, + onSelect: (emoji) { + ref + .read(channelActionsProvider) + .addReaction(message.id, emoji); + }, + ); + }, + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: Theme.of( + sheetContext, + ).colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.plus, + size: 20, + color: Theme.of( + sheetContext, + ).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: Grid.xs), + if (allMessages != null) + ListTile( + leading: const Icon(LucideIcons.messageSquareReply), + title: const Text('Reply in thread'), + onTap: () { + Navigator.of(sheetContext).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + ), + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + // Copy to clipboard + final data = ClipboardData(text: message.content); + Clipboard.setData(data); + }, + ), + if (isOwnMessage) ...[ + ListTile( + leading: const Icon(LucideIcons.pencil), + title: const Text('Edit message'), + onTap: () { + Navigator.of(sheetContext).pop(); + _showEditSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + ); + }, + ), + ListTile( + leading: Icon( + LucideIcons.trash2, + color: Theme.of(sheetContext).colorScheme.error, + ), + title: Text( + 'Delete message', + style: TextStyle( + color: Theme.of(sheetContext).colorScheme.error, + ), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete( + context: context, + ref: ref, + messageId: message.id, + ); + }, + ), + ], + ], + ), + ), + ), + ); +} + +void _showEditSheet({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, +}) { + final controller = TextEditingController(text: message.content); + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) => Padding( + padding: EdgeInsets.fromLTRB( + Grid.xs, + 0, + Grid.xs, + MediaQuery.viewInsetsOf(sheetContext).bottom + Grid.xs, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + autofocus: true, + minLines: 1, + maxLines: 5, + decoration: const InputDecoration(hintText: 'Edit message'), + ), + const SizedBox(height: Grid.xxs), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(sheetContext).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: Grid.half), + FilledButton( + onPressed: () { + final text = controller.text.trim(); + if (text.isEmpty || text == message.content) { + Navigator.of(sheetContext).pop(); + return; + } + ref + .read(channelActionsProvider) + .editMessage( + channelId: channelId, + eventId: message.id, + content: text, + ); + Navigator.of(sheetContext).pop(); + }, + child: const Text('Save'), + ), + ], + ), + ], + ), + ), + ), + ); +} + +void _confirmDelete({ + required BuildContext context, + required WidgetRef ref, + required String messageId, +}) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Delete message'), + content: const Text('This cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + ref.read(channelActionsProvider).deleteMessage(messageId); + }, + style: FilledButton.styleFrom( + backgroundColor: Theme.of(dialogContext).colorScheme.error, + ), + child: const Text('Delete'), + ), + ], + ), + ); +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 02eb05469f..d43efb78bd 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -51,8 +51,27 @@ class MessageContent extends StatelessWidget { context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); final imetaByUrl = parseImetaTags(tags); + // Convert angle-bracket autolinks to standard markdown links, + // but skip content inside backticks (inline code / fenced blocks). + final buffer = StringBuffer(); + final parts = content.split('`'); + for (var i = 0; i < parts.length; i++) { + if (i.isOdd) { + // Inside backticks — preserve as-is. + buffer.write('`${parts[i]}`'); + } else { + buffer.write( + parts[i].replaceAllMapped( + RegExp(r'<(https?://[^>]+)>'), + (m) => '[${m[1]}](${m[1]})', + ), + ); + } + } + final processed = buffer.toString(); + return GptMarkdown( - content, + processed, style: style, followLinkColor: false, linkBuilder: (context, linkText, url, linkStyle) => diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index d29ad9ff71..a238307e26 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -13,6 +14,7 @@ import 'relay_provider.dart'; const _mediaUploadPath = '/media/upload'; const _mediaUploadPlatformChannelName = 'sprout/media_upload'; const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; +const _transcodeVideoToMp4Method = 'transcodeVideoToMp4'; const _transcodeImageToJpegMethod = 'transcodeImageToJpeg'; const _uploadAuthKind = 24242; const _uploadAuthLifetimeSeconds = 300; @@ -31,6 +33,8 @@ final _mediaUploadPlatformChannel = MethodChannel( ); const _allowedImageMimeTypes = {'image/jpeg', 'image/png', 'image/webp'}; +const _allowedVideoMimeTypes = {'video/mp4'}; +const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB const _unsupportedAnimatedImageMimeTypes = {'image/gif'}; const _unsupportedGifUploadMessage = 'GIF uploads are not supported on mobile yet'; @@ -40,9 +44,11 @@ const _unsupportedAnimatedWebpUploadMessage = 'Animated WebP uploads are not supported on mobile yet'; typedef PickGalleryImage = Future Function(); +typedef PickGalleryVideo = Future Function(); typedef SanitizeImageBytes = Future Function(Uint8List bytes, String mimeType); typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); +typedef TranscodeVideoToMp4 = Future Function(String filePath); @immutable class _PreparedUploadImage { @@ -113,8 +119,10 @@ class MediaUploadService { final String? _apiToken; final String? _nsec; final PickGalleryImage _pickGalleryImage; + final PickGalleryVideo _pickGalleryVideo; final SanitizeImageBytes _sanitizeImageBytes; final TranscodeImageToJpeg _transcodeImageToJpeg; + final TranscodeVideoToMp4 _transcodeVideoToMp4; final DateTime Function() _now; final http.Client _http; final bool _ownsHttpClient; @@ -124,17 +132,21 @@ class MediaUploadService { required String? apiToken, required String? nsec, required PickGalleryImage pickGalleryImage, + required PickGalleryVideo pickGalleryVideo, SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, + TranscodeVideoToMp4? transcodeVideoToMp4, DateTime Function()? now, http.Client? httpClient, }) : _baseUrl = baseUrl, _apiToken = apiToken, _nsec = nsec, _pickGalleryImage = pickGalleryImage, + _pickGalleryVideo = pickGalleryVideo, _sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes, _transcodeImageToJpeg = transcodeImageToJpeg ?? _transcodePickedImageToJpeg, + _transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4, _now = now ?? DateTime.now, _http = httpClient ?? http.Client(), _ownsHttpClient = httpClient == null; @@ -152,12 +164,56 @@ class MediaUploadService { return uploadBytes(preparedImage.bytes, mimeType: preparedImage.mimeType); } + Future pickAndUploadVideo() async { + final pickedVideo = await _pickGalleryVideo(); + if (pickedVideo == null) return null; + final length = await pickedVideo.length(); + if (length > _maxVideoSizeBytes) { + throw Exception( + 'Video is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.', + ); + } + + // Read first 32 bytes to check if it's already an MP4 container. + final header = await _readFileHeader(pickedVideo.path, 32); + + if (_isAlreadyMp4Container(header)) { + // Already MP4 — upload directly. + final bytes = await pickedVideo.readAsBytes(); + return uploadBytes(bytes, mimeType: 'video/mp4'); + } + + // Non-MP4 container (e.g. QuickTime .mov) — remux to MP4 via platform. + String? transcodedPath; + try { + transcodedPath = await _transcodeVideoToMp4(pickedVideo.path); + final transcodedFile = File(transcodedPath); + final transcodedLength = await transcodedFile.length(); + if (transcodedLength > _maxVideoSizeBytes) { + throw Exception( + 'Transcoded video is too large (${(transcodedLength / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.', + ); + } + final bytes = await transcodedFile.readAsBytes(); + return uploadBytes(bytes, mimeType: 'video/mp4'); + } finally { + if (transcodedPath != null) { + try { + await File(transcodedPath).delete(); + } catch (_) { + // Best-effort temp file cleanup. + } + } + } + } + Future uploadBytes( Uint8List bytes, { required String mimeType, }) async { _validateUpload(bytes, mimeType); - if (!_allowedImageMimeTypes.contains(mimeType)) { + if (!_allowedImageMimeTypes.contains(mimeType) && + !_allowedVideoMimeTypes.contains(mimeType)) { throw Exception('unsupported file type: $mimeType'); } @@ -495,6 +551,50 @@ int _readUint32LittleEndian(Uint8List bytes, int offset) { (bytes[offset + 3] << 24); } +/// Always returns `video/mp4` — the relay only accepts MP4 and does its own +/// magic-byte validation. Most iPhone `.mov` files are ftyp-isom containers +/// that the relay accepts as MP4. +/// Known MP4 ftyp major brands. If the file's major brand (bytes 8–11) +/// matches one of these, it's already an MP4-compatible container. +const _mp4FtypBrands = {'isom', 'mp41', 'mp42', 'M4V ', 'avc1', 'iso5'}; + +/// Checks whether [bytes] (at least 12 bytes of file header) represent +/// an MP4-family container by inspecting the ftyp box major brand. +/// +/// Exposed for testing as [isAlreadyMp4Container]. +@visibleForTesting +bool isAlreadyMp4Container(Uint8List bytes) => _isAlreadyMp4Container(bytes); + +bool _isAlreadyMp4Container(Uint8List bytes) { + if (bytes.length < 12) return false; + if (!_matchesAscii(bytes, 4, 'ftyp')) return false; + final brand = ascii.decode(bytes.sublist(8, 12), allowInvalid: true); + return _mp4FtypBrands.contains(brand); +} + +/// Reads the first [count] bytes of a file without loading it entirely. +Future _readFileHeader(String path, int count) async { + final file = File(path); + final raf = await file.open(mode: FileMode.read); + try { + final bytes = await raf.read(count); + return bytes; + } finally { + await raf.close(); + } +} + +Future _transcodePickedVideoToMp4(String filePath) async { + final result = await _mediaUploadPlatformChannel.invokeMethod( + _transcodeVideoToMp4Method, + filePath, + ); + if (result == null || result.isEmpty) { + throw Exception('Failed to convert video to MP4.'); + } + return result; +} + String? _extractServerAuthority(String baseUrl) { final uri = Uri.parse(baseUrl); if (uri.host.isEmpty) return null; @@ -547,6 +647,7 @@ final mediaUploadServiceProvider = Provider((ref) { source: ImageSource.gallery, requestFullMetadata: false, ), + pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), ); ref.onDispose(service.dispose); return service; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index f3bb61645e..6fc0e7354d 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -568,6 +568,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 830af7c4af..e6948dcaf2 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: pointycastle: ^3.7.3 url_launcher: ^6.3.2 gpt_markdown: ^1.1.6 + intl: ^0.20.2 image_picker: ^1.1.2 video_player: ^2.10.1 diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index cc8ec74a4d..ca328dc9c0 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -174,6 +175,7 @@ void main() { 200, ); }), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); @@ -196,7 +198,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); expect(find.byTooltip('Remove attachment'), findsOneWidget); @@ -237,6 +240,7 @@ void main() { 200, ); }), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); @@ -254,7 +258,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); final attachmentFinder = find.byKey( @@ -293,6 +298,7 @@ void main() { httpClient: http_testing.MockClient((request) async { return http.Response('bad upload', 401); }), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); @@ -310,7 +316,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); expect(find.textContaining('upload failed'), findsOneWidget); @@ -323,6 +330,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: 'sprout_test_token', nsec: nsec, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_gifBytes, name: 'animated.gif'), ); @@ -340,7 +348,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); expect( @@ -358,6 +367,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: 'sprout_test_token', nsec: nsec, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_apngBytes, name: 'animated.png'), ); @@ -375,7 +385,8 @@ void main() { ); await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo')); await tester.pumpAndSettle(); expect( @@ -383,5 +394,88 @@ void main() { findsOneWidget, ); }); + + // Skip: video upload relies on native platform bridging + // (transcodeVideoToMp4) that can't be fully mocked in widget tests. + testWidgets('taps Video in chooser sheet and uploads video', skip: true, ( + tester, + ) async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + + // Build a temp file with a valid MP4 ftyp header (isom brand). + final mp4Bytes = Uint8List(32); + mp4Bytes[3] = 32; + mp4Bytes[4] = 0x66; // f + mp4Bytes[5] = 0x74; // t + mp4Bytes[6] = 0x79; // y + mp4Bytes[7] = 0x70; // p + mp4Bytes[8] = 0x69; // i + mp4Bytes[9] = 0x73; // s + mp4Bytes[10] = 0x6F; // o + mp4Bytes[11] = 0x6D; // m + final tempDir = await Directory.systemTemp.createTemp('compose_video_'); + final tempFile = File('${tempDir.path}/clip.mp4'); + await tempFile.writeAsBytes(mp4Bytes); + + try { + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: 'sprout_test_token', + nsec: nsec, + httpClient: http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.mp4', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 1024, + 'type': 'video/mp4', + 'uploaded': 1, + }), + 200, + ); + }), + pickGalleryVideo: () async => XFile(tempFile.path), + pickGalleryImage: () async => null, + ); + + String? sentContent; + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sentContent = content; + }, + ), + ); + + await tester.tap(find.byIcon(LucideIcons.paperclip)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Video')); + // Pump enough frames for the async file read + upload to complete. + // Can't use pumpAndSettle here — the upload spinner's animation + // prevents settling while the async upload is in-flight. + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + // Video attachment should show a video icon (not a broken image). + expect(find.byIcon(LucideIcons.video), findsOneWidget); + + await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(sentContent, '\n![video](https://relay.example/media/test.mp4)'); + } finally { + await tempDir.delete(recursive: true); + } + }); }); } diff --git a/mobile/test/features/channels/date_formatters_test.dart b/mobile/test/features/channels/date_formatters_test.dart new file mode 100644 index 0000000000..8ab571419e --- /dev/null +++ b/mobile/test/features/channels/date_formatters_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sprout_mobile/features/channels/date_formatters.dart'; + +/// Helper: build a unix-second timestamp from a local DateTime. +int _ts(DateTime local) => local.millisecondsSinceEpoch ~/ 1000; + +void main() { + group('formatDayHeading', () { + // Fix "now" so tests are deterministic. + final now = DateTime(2026, 4, 23, 14, 30); // Apr 23 2026, 2:30 PM local + + test('same day returns "Today"', () { + final morning = _ts(DateTime(2026, 4, 23, 8, 0)); + expect(formatDayHeading(morning, now: now), 'Today'); + }); + + test('yesterday returns "Yesterday"', () { + final yesterday = _ts(DateTime(2026, 4, 22, 18, 0)); + expect(formatDayHeading(yesterday, now: now), 'Yesterday'); + }); + + test('older date returns full formatted date', () { + final older = _ts(DateTime(2026, 3, 31, 12, 0)); + expect(formatDayHeading(older, now: now), 'Tuesday, March 31, 2026'); + }); + + test('midnight boundary: 11:59 PM today vs 12:01 AM tomorrow', () { + final lateTonight = _ts(DateTime(2026, 4, 23, 23, 59)); + final earlyTomorrow = _ts(DateTime(2026, 4, 24, 0, 1)); + + expect(formatDayHeading(lateTonight, now: now), 'Today'); + // Tomorrow relative to our fixed "now" is not today or yesterday. + expect( + formatDayHeading(earlyTomorrow, now: now), + 'Friday, April 24, 2026', + ); + }); + + test('cross-month boundary: April 1 → March 31 is yesterday', () { + final april1 = DateTime(2026, 4, 1, 10, 0); + final march31 = _ts(DateTime(2026, 3, 31, 20, 0)); + expect(formatDayHeading(march31, now: april1), 'Yesterday'); + }); + }); + + group('isSameDay', () { + test('same calendar day returns true', () { + final a = _ts(DateTime(2026, 4, 23, 1, 0)); + final b = _ts(DateTime(2026, 4, 23, 23, 59)); + expect(isSameDay(a, b), isTrue); + }); + + test('different days returns false', () { + final a = _ts(DateTime(2026, 4, 23, 23, 59)); + final b = _ts(DateTime(2026, 4, 24, 0, 1)); + expect(isSameDay(a, b), isFalse); + }); + }); +} diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 1228e9b9e9..44948e121c 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -236,7 +236,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.text('Hello forum')); + await tester.tap(find.byType(ForumPostCard)); expect(tapped, isTrue); }); @@ -284,7 +284,7 @@ void main() { await tester.pumpWidget(_buildPostCard(post: _makePost())); await tester.pumpAndSettle(); - await tester.longPress(find.text('Hello forum')); + await tester.longPress(find.byType(ForumPostCard)); await tester.pumpAndSettle(); expect(find.text('Copy text'), findsOneWidget); @@ -301,7 +301,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.longPress(find.text('Hello forum')); + await tester.longPress(find.byType(ForumPostCard)); await tester.pumpAndSettle(); expect(find.text('Delete post'), findsOneWidget); @@ -319,7 +319,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.longPress(find.text('Hello forum')); + await tester.longPress(find.byType(ForumPostCard)); await tester.pumpAndSettle(); expect(find.text('Delete post'), findsNothing); }); @@ -336,7 +336,7 @@ void main() { await tester.pumpAndSettle(); // Long press → action sheet. - await tester.longPress(find.text('Hello forum')); + await tester.longPress(find.byType(ForumPostCard)); await tester.pumpAndSettle(); // Tap Delete post. diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index 951bb785a7..18c529519c 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -260,6 +261,7 @@ void main() { apiToken: 'sprout_test_token', nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), @@ -312,6 +314,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: null, nsec: null, + pickGalleryVideo: () async => null, pickGalleryImage: () async => null, ); @@ -344,6 +347,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'tiny.png'), ); @@ -396,6 +400,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_heicBytes, name: 'photo.heic'), transcodeImageToJpeg: (bytes) async { @@ -446,6 +451,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_jpegBytes, name: 'photo.jpg'), sanitizeImageBytes: (bytes, mimeType) async { @@ -497,6 +503,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_heicBytes, name: 'photo.heic'), transcodeImageToJpeg: (bytes) async { @@ -547,6 +554,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_jpegBytes, name: 'photo.jpg'), sanitizeImageBytes: (bytes, mimeType) async { @@ -599,6 +607,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_pngBytes, name: 'photo.png'), sanitizeImageBytes: (bytes, mimeType) async { @@ -627,6 +636,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: null, nsec: nsec, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_gifBytes, name: 'animated.gif'), ); @@ -654,6 +664,7 @@ void main() { httpClient: http_testing.MockClient( (request) async => http.Response('{}', 200), ), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_apngBytes, name: 'animated.png'), ); @@ -695,6 +706,7 @@ void main() { apiToken: null, nsec: nsec, httpClient: client, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_staticPngWithActlPayloadBytes, name: 'static.png'), ); @@ -719,6 +731,7 @@ void main() { httpClient: http_testing.MockClient( (request) async => http.Response('{}', 200), ), + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData(_animatedWebpBytes, name: 'animated.webp'), ); @@ -743,6 +756,7 @@ void main() { baseUrl: 'https://relay.example', apiToken: null, nsec: nsec, + pickGalleryVideo: () async => null, pickGalleryImage: () async => XFile.fromData( Uint8List.fromList(utf8.encode('not an image')), name: 'note.txt', @@ -761,4 +775,220 @@ void main() { ); }); }); + + group('_isAlreadyMp4Container', () { + // ftyp box: [size(4 bytes)][ftyp(4 bytes)][major_brand(4 bytes)]... + Uint8List buildFtypHeader(String brand) { + final bytes = Uint8List(32); + // Box size = 32 + bytes[0] = 0; + bytes[1] = 0; + bytes[2] = 0; + bytes[3] = 32; + // 'ftyp' + bytes[4] = 0x66; // f + bytes[5] = 0x74; // t + bytes[6] = 0x79; // y + bytes[7] = 0x70; // p + // Major brand + final brandBytes = ascii.encode(brand); + for (var i = 0; i < 4 && i < brandBytes.length; i++) { + bytes[8 + i] = brandBytes[i]; + } + return bytes; + } + + test('returns true for isom brand', () { + expect(isAlreadyMp4Container(buildFtypHeader('isom')), isTrue); + }); + + test('returns true for mp41 brand', () { + expect(isAlreadyMp4Container(buildFtypHeader('mp41')), isTrue); + }); + + test('returns true for mp42 brand', () { + expect(isAlreadyMp4Container(buildFtypHeader('mp42')), isTrue); + }); + + test('returns false for QuickTime qt brand', () { + expect(isAlreadyMp4Container(buildFtypHeader('qt ')), isFalse); + }); + + test('returns false for too-short header', () { + expect(isAlreadyMp4Container(Uint8List(8)), isFalse); + }); + + test('returns false when no ftyp box', () { + final bytes = Uint8List(32); + bytes[4] = 0x6D; // m + bytes[5] = 0x6F; // o + bytes[6] = 0x6F; // o + bytes[7] = 0x76; // v + expect(isAlreadyMp4Container(bytes), isFalse); + }); + }); + + group('pickAndUploadVideo', () { + // Helper: build ftyp header bytes for a given brand. + Uint8List buildFtypHeader(String brand) { + final bytes = Uint8List(32); + bytes[3] = 32; + bytes[4] = 0x66; + bytes[5] = 0x74; + bytes[6] = 0x79; + bytes[7] = 0x70; + final brandBytes = ascii.encode(brand); + for (var i = 0; i < 4 && i < brandBytes.length; i++) { + bytes[8 + i] = brandBytes[i]; + } + return bytes; + } + + // Helper: write bytes to a temp file, return its XFile. + Future<(XFile, File)> writeTempVideo(Uint8List bytes, String name) async { + final dir = await Directory.systemTemp.createTemp('video_test_'); + final file = File('${dir.path}/$name'); + await file.writeAsBytes(bytes); + return (XFile(file.path), file); + } + + test('uploads MP4 container directly without transcoding', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + var transcodeCalled = false; + final client = http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.mp4', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 32, + 'type': 'video/mp4', + 'uploaded': 1, + }), + 200, + ); + }); + + final mp4Bytes = buildFtypHeader('isom'); + final (xfile, tempFile) = await writeTempVideo(mp4Bytes, 'clip.mp4'); + try { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryVideo: () async => xfile, + pickGalleryImage: () async => null, + transcodeVideoToMp4: (path) async { + transcodeCalled = true; + return path; + }, + now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), + ); + + final descriptor = await service.pickAndUploadVideo(); + expect(descriptor, isNotNull); + expect(descriptor!.type, 'video/mp4'); + expect(transcodeCalled, isFalse); + } finally { + await tempFile.parent.delete(recursive: true); + } + }); + + test('transcodes non-MP4 container before uploading', () async { + final keychain = nostr.Keychain.generate(); + final nsec = nostr.Nip19.encodePrivkey(keychain.private); + var transcodeCalled = false; + final client = http_testing.MockClient((request) async { + expect(request.headers['Content-Type'], 'video/mp4'); + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.mp4', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 32, + 'type': 'video/mp4', + 'uploaded': 1, + }), + 200, + ); + }); + + // QuickTime container ('qt ' brand) — needs transcoding. + final movBytes = buildFtypHeader('qt '); + final (xfile, tempFile) = await writeTempVideo(movBytes, 'clip.mov'); + try { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: nsec, + httpClient: client, + pickGalleryVideo: () async => xfile, + pickGalleryImage: () async => null, + transcodeVideoToMp4: (path) async { + transcodeCalled = true; + // Mock transcoding: write an "MP4" file + final outDir = await Directory.systemTemp.createTemp('transcode_'); + final outFile = File('${outDir.path}/out.mp4'); + await outFile.writeAsBytes(buildFtypHeader('isom')); + return outFile.path; + }, + now: () => DateTime.fromMillisecondsSinceEpoch(1_700_000_000_000), + ); + + final descriptor = await service.pickAndUploadVideo(); + expect(descriptor, isNotNull); + expect(descriptor!.type, 'video/mp4'); + expect(transcodeCalled, isTrue); + } finally { + await tempFile.parent.delete(recursive: true); + } + }); + + test('returns null when video picker is cancelled', () async { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: null, + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + ); + + final result = await service.pickAndUploadVideo(); + expect(result, isNull); + }); + + test('rejects videos over 100MB', () async { + // Create a temp file with 101MB of zeros. + final dir = await Directory.systemTemp.createTemp('video_size_test_'); + final file = File('${dir.path}/huge.mp4'); + final raf = await file.open(mode: FileMode.write); + await raf.truncate(101 * 1024 * 1024); + await raf.close(); + + try { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + apiToken: null, + nsec: null, + pickGalleryVideo: () async => XFile(file.path), + pickGalleryImage: () async => null, + ); + + await expectLater( + () => service.pickAndUploadVideo(), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('too large'), + ), + ), + ); + } finally { + await dir.delete(recursive: true); + } + }); + }); } From 353729a56d572f82ca9884a067b481fd193cbfdd Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 11:16:34 -0600 Subject: [PATCH 004/120] =?UTF-8?q?fix(mobile):=206=20bug=20fixes=20?= =?UTF-8?q?=E2=80=94=20QR,=20resume,=20bots,=20typing,=20errors,=20keyboar?= =?UTF-8?q?d=20(#397)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.6 (1M context) --- .../android/app/src/main/AndroidManifest.xml | 1 + mobile/ios/Runner/Info.plist | 2 + .../channels/channel_detail_page.dart | 65 ++++++++++++-- .../channels/channel_messages_provider.dart | 39 +++++++-- .../lib/features/channels/channels_page.dart | 5 +- .../lib/features/channels/members_sheet.dart | 86 +++++++++++++------ .../features/channels/thread_detail_page.dart | 7 +- mobile/lib/features/pairing/pairing_page.dart | 35 ++++++++ .../features/pairing/pairing_provider.dart | 27 +++++- 9 files changed, 230 insertions(+), 37 deletions(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 206cfb5e4e..f386b412b6 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + Sprout needs camera access to scan QR codes for device pairing. NSPhotoLibraryUsageDescription Sprout needs photo library access so you can attach images to messages. UIApplicationSceneManifest diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 3cf7095644..c6cde15f23 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -59,15 +59,20 @@ class ChannelDetailPage extends HookConsumerWidget { final detailsAsync = ref.watch(channelDetailsProvider(channel.id)); final channelsAsync = ref.watch(channelsProvider); final messagesState = ref.watch(channelMessagesProvider(channel.id)); - // Only show channel-level typing (exclude thread-scoped entries). - final typingEntries = ref - .watch(channelTypingProvider(channel.id)) - .where((e) => e.threadHeadId == null) - .toList(); final currentPubkey = ref .watch(profileProvider) .whenData((value) => value?.pubkey) .value; + // Only show channel-level typing (exclude thread-scoped entries and self). + final typingEntries = ref + .watch(channelTypingProvider(channel.id)) + .where((e) => e.threadHeadId == null) + .where( + (e) => + currentPubkey == null || + e.pubkey.toLowerCase() != currentPubkey.toLowerCase(), + ) + .toList(); final baseChannel = channelsAsync .whenData( @@ -148,6 +153,9 @@ class ChannelDetailPage extends HookConsumerWidget { ), body: Column( children: [ + _DetailConnectionBanner( + status: ref.watch(relaySessionProvider).status, + ), Expanded( child: resolvedChannel.isForum ? ForumPostsView( @@ -860,6 +868,53 @@ class _ReadOnlyNotice extends StatelessWidget { } } +// --------------------------------------------------------------------------- +// Connection banner (shown inside channel detail during reconnect) +// --------------------------------------------------------------------------- + +class _DetailConnectionBanner extends StatelessWidget { + final SessionStatus status; + + const _DetailConnectionBanner({required this.status}); + + @override + Widget build(BuildContext context) { + if (status == SessionStatus.connected || + status == SessionStatus.disconnected) { + return const SizedBox.shrink(); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.quarter + 2, + ), + color: context.colors.surfaceContainerHighest, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(width: Grid.xxs), + Text( + 'Reconnecting…', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + // --------------------------------------------------------------------------- // Typing indicator // --------------------------------------------------------------------------- diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index 851673eb8b..0419403ee0 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -9,9 +9,14 @@ class ChannelMessagesNotifier extends Notifier>> { final String channelId; void Function()? _unsubscribe; bool _reachedOldest = false; + bool _initInFlight = false; ChannelMessagesNotifier(this.channelId); + /// Last successfully loaded messages, preserved across reconnections so the + /// UI can show stale data instead of a blank loading spinner. + List? _lastKnownMessages; + @override AsyncValue> build() { final sessionState = ref.watch(relaySessionProvider); @@ -21,16 +26,23 @@ class ChannelMessagesNotifier extends Notifier>> { }); if (sessionState.status != SessionStatus.connected) { - return const AsyncData([]); + // Return cached messages if available so the UI remains usable while + // disconnected/reconnecting, instead of showing an empty screen. + return AsyncData(_lastKnownMessages ?? const []); } // Reset pagination state on rebuild (e.g. after reconnect). _reachedOldest = false; _init(); + // Show previous messages while fetching fresh ones, instead of a spinner. + if (_lastKnownMessages case final cached? when cached.isNotEmpty) { + return AsyncData(cached); + } return const AsyncLoading(); } Future _init() async { + _initInFlight = true; try { final session = ref.read(relaySessionProvider.notifier); @@ -57,15 +69,31 @@ class ChannelMessagesNotifier extends Notifier>> { _handleLiveEvent, ); - history.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - state = AsyncData(history); + // Merge fresh history with any events already in state (e.g. from + // fetchOlder() or live events that arrived while _init was in flight) + // to avoid discarding data the user has already scrolled through. + final existing = state.value ?? const []; + final existingIds = existing.map((e) => e.id).toSet(); + final newEvents = history + .where((e) => !existingIds.contains(e.id)) + .toList(); + final merged = [...existing, ...newEvents]; + merged.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + _lastKnownMessages = merged; + state = AsyncData(merged); } catch (e, st) { state = AsyncError(e, st); + } finally { + _initInFlight = false; } } void _handleLiveEvent(NostrEvent event) { - state = state.whenData((events) => _mergeEvent(events, event)); + state = state.whenData((events) { + final merged = _mergeEvent(events, event); + _lastKnownMessages = merged; + return merged; + }); // When a membership system event arrives, refresh the channel member list // so the @mention autocomplete picks up new members without a restart. @@ -98,7 +126,7 @@ class ChannelMessagesNotifier extends Notifier>> { /// Fetch older messages (pagination). Call this when the user scrolls up. /// Returns `true` if new messages were loaded. Future fetchOlder() async { - if (_reachedOldest) return false; + if (_reachedOldest || _initInFlight) return false; final currentEvents = state.value; if (currentEvents == null || currentEvents.isEmpty) return false; @@ -136,6 +164,7 @@ class ChannelMessagesNotifier extends Notifier>> { state = state.whenData((events) { final merged = [...deduped, ...events]; merged.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + _lastKnownMessages = merged; return merged; }); return true; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 4474943fd2..9525ab8ad7 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -855,7 +855,10 @@ class _BrowseChannelsSheet extends HookConsumerWidget { ) : ListView( controller: scrollController, - padding: const EdgeInsets.only(top: Grid.xxs), + padding: EdgeInsets.only( + top: Grid.xxs, + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), children: [ if (notJoined.isNotEmpty) ...[ _MiniHeader( diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index bfc7c55c05..8ec29a0ffe 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -24,6 +24,7 @@ class MembersSheet extends HookConsumerWidget { final membersAsync = ref.watch(channelMembersProvider(channel.id)); final allMembers = membersAsync.asData?.value ?? const []; final people = allMembers.where((member) => !member.isBot).toList(); + final bots = allMembers.where((member) => member.isBot).toList(); final userCache = ref.watch(userCacheProvider); // Determine if the current user can manage members. @@ -38,13 +39,13 @@ class MembersSheet extends HookConsumerWidget { // Preload profiles for all members so avatars appear. useEffect(() { - if (people.isNotEmpty) { + if (allMembers.isNotEmpty) { ref .read(userCacheProvider.notifier) - .preload(people.map((m) => m.pubkey).toList()); + .preload(allMembers.map((m) => m.pubkey).toList()); } return null; - }, [people.length]); + }, [allMembers.length]); return Padding( padding: EdgeInsets.fromLTRB( @@ -69,34 +70,50 @@ class MembersSheet extends HookConsumerWidget { ), ), if (!channel.isDm) ...[const Divider(height: Grid.sm)], - SizedBox( - height: 280, + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), child: membersAsync.when( - data: (_) => people.isEmpty - ? Center( + data: (_) => ListView( + shrinkWrap: true, + children: [ + if (people.isNotEmpty) ...[ + _SectionLabel(label: 'People — ${people.length}'), + for (final member in people) + _MemberTile( + member: member, + currentPubkey: currentPubkey, + profile: userCache[member.pubkey.toLowerCase()], + canManage: canManage, + isSelf: + member.pubkey.toLowerCase() == + currentPubkey?.toLowerCase(), + channelId: channel.id, + ), + ], + if (bots.isNotEmpty) ...[ + const SizedBox(height: Grid.xxs), + _SectionLabel(label: 'Bots — ${bots.length}'), + for (final bot in bots) + _MemberTile( + member: bot, + currentPubkey: currentPubkey, + profile: userCache[bot.pubkey.toLowerCase()], + canManage: canManage, + isSelf: false, + channelId: channel.id, + ), + ], + if (people.isEmpty && bots.isEmpty) + Center( child: Text( - 'No people found.', + 'No members found.', style: context.textTheme.bodySmall?.copyWith( color: context.colors.outline, ), ), - ) - : ListView( - shrinkWrap: true, - children: [ - for (final member in people) - _MemberTile( - member: member, - currentPubkey: currentPubkey, - profile: userCache[member.pubkey.toLowerCase()], - canManage: canManage, - isSelf: - member.pubkey.toLowerCase() == - currentPubkey?.toLowerCase(), - channelId: channel.id, - ), - ], ), + ], + ), loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center( @@ -117,6 +134,27 @@ class MembersSheet extends HookConsumerWidget { } } +class _SectionLabel extends StatelessWidget { + final String label; + + const _SectionLabel({required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: Grid.half, bottom: Grid.half), + child: Text( + label.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ); + } +} + const _changeableRoles = ['admin', 'member', 'guest']; class _MemberTile extends ConsumerWidget { diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 5d57220caf..fd5550c3a1 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -58,10 +58,15 @@ class ThreadDetailPage extends HookConsumerWidget { final replies = childrenByParent[threadHead.id] ?? const []; - // Thread-scoped typing indicators. + // Thread-scoped typing indicators (exclude self). final allTyping = ref.watch(channelTypingProvider(channelId)); final threadTyping = allTyping .where((e) => e.threadHeadId == threadHead.id) + .where( + (e) => + currentPubkey == null || + e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(), + ) .toList(); // Resolve thread head from live data (reactions/edits may have changed). diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 84869a48a9..8531c21c06 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -319,6 +319,9 @@ class _ScannerPage extends HookWidget { @override Widget build(BuildContext context) { final handled = useState(false); + final controller = useMemoized(() => MobileScannerController()); + + useEffect(() => controller.dispose, const []); return Scaffold( appBar: AppBar( @@ -329,6 +332,38 @@ class _ScannerPage extends HookWidget { ), ), body: MobileScanner( + controller: controller, + errorBuilder: (context, error) { + final message = switch (error.errorCode) { + MobileScannerErrorCode.permissionDenied => + 'Camera permission is required to scan QR codes.\n\nPlease grant camera access in your device settings.', + _ => + 'Could not start camera: ${error.errorDetails?.message ?? 'unknown error'}', + }; + return Center( + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.cameraOff, + size: 48, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(height: Grid.xs), + Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + }, onDetect: (capture) { if (handled.value) return; final barcodes = capture.barcodes; diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 9707ba1469..c910c825a3 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -229,14 +229,39 @@ class PairingNotifier extends Notifier { errorMessage: 'Invalid pairing code: ${e.message}', ); } catch (e) { + debugPrint('Pairing connection error: $e'); _cleanup(); state = PairingState( status: PairingStatus.error, - errorMessage: 'Connection failed: $e', + errorMessage: _friendlyErrorMessage(e), ); } } + static String _friendlyErrorMessage(Object error) { + final message = error.toString(); + if (message.contains('SocketException') || + message.contains('Connection refused') || + message.contains('Network is unreachable') || + message.contains('No route to host') || + message.contains('Failed to connect') || + message.contains('Null check operator used on a null value')) { + return 'Could not reach the pairing relay. Check your internet ' + 'connection and VPN, then try again.'; + } + if (message.contains('HandshakeException') || + message.contains('CERTIFICATE_VERIFY_FAILED')) { + return 'Secure connection failed. Check your network settings ' + 'and try again.'; + } + if (message.contains('TimeoutException') || message.contains('timed out')) { + return 'Connection timed out. Check your internet connection and ' + 'try again.'; + } + return 'Connection failed. Please check your internet connection ' + 'and try again.'; + } + void _handleRelayMessage(List data) { if (data.isEmpty) return; final type = data[0] as String; From ee76396d005c34cbd46a97550dca212095c5185f Mon Sep 17 00:00:00 2001 From: luthen-seas Date: Fri, 24 Apr 2026 13:35:19 -0400 Subject: [PATCH 005/120] Fix profile settings first launch parse failure (#383) Signed-off-by: luthen-seas <229396760+luthen-seas@users.noreply.github.com> Co-authored-by: luthen-seas <229396760+luthen-seas@users.noreply.github.com> --- desktop/src-tauri/src/commands/profile.rs | 193 ++++++++++++++++++++-- 1 file changed, 183 insertions(+), 10 deletions(-) diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index fc06c186c1..a1ea1a96e6 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use reqwest::Method; +use serde_json::{Map, Value}; use sprout_core::PresenceStatus; use tauri::State; @@ -16,13 +17,14 @@ use crate::{ #[tauri::command] pub async fn get_profile(state: State<'_, AppState>) -> Result { + let fallback_pubkey = current_pubkey_hex(&state)?; let request = build_authed_request( &state.http_client, Method::GET, "/api/users/me/profile", &state, )?; - send_json_request(request).await + fetch_profile_info(request, &fallback_pubkey, true).await } #[tauri::command] @@ -48,29 +50,30 @@ pub async fn update_profile( let dn = display_name .as_deref() - .or_else(|| current.get("display_name").and_then(|v| v.as_str())); - let name = current.get("name").and_then(|v| v.as_str()); + .or_else(|| profile_field_str(¤t, "display_name")); + let name = profile_field_str(¤t, "name"); let picture = avatar_url .as_deref() - .or_else(|| current.get("avatar_url").and_then(|v| v.as_str())); + .or_else(|| profile_field_str(¤t, "avatar_url")); let ab = about .as_deref() - .or_else(|| current.get("about").and_then(|v| v.as_str())); + .or_else(|| profile_field_str(¤t, "about")); let nip05 = nip05_handle .as_deref() - .or_else(|| current.get("nip05_handle").and_then(|v| v.as_str())); + .or_else(|| profile_field_str(¤t, "nip05_handle")); let builder = events::build_profile(dn, name, picture, ab, nip05)?; submit_event(builder, &state).await?; // Re-fetch to return the canonical profile the frontend expects. + let fallback_pubkey = current_pubkey_hex(&state)?; let request = build_authed_request( &state.http_client, Method::GET, "/api/users/me/profile", &state, )?; - send_json_request(request).await + fetch_profile_info(request, &fallback_pubkey, true).await } // ── Unchanged reads below ──────────────────────────────────────────────────── @@ -80,12 +83,16 @@ pub async fn get_user_profile( pubkey: Option, state: State<'_, AppState>, ) -> Result { - let path = match pubkey { - Some(pubkey) => api_path(&["users", &pubkey, "profile"]), + let path = match pubkey.as_deref() { + Some(pubkey) => api_path(&["users", pubkey, "profile"]), None => "/api/users/me/profile".to_string(), }; + let fallback_pubkey = match pubkey { + Some(pubkey) => pubkey, + None => current_pubkey_hex(&state)?, + }; let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + fetch_profile_info(request, &fallback_pubkey, false).await } #[tauri::command] @@ -160,3 +167,169 @@ pub async fn set_presence( .json(&SetPresenceBody { status }); send_json_request(request).await } + +fn current_pubkey_hex(state: &AppState) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + Ok(keys.public_key().to_hex()) +} + +fn empty_profile_info(pubkey: &str) -> ProfileInfo { + ProfileInfo { + pubkey: pubkey.to_string(), + display_name: None, + avatar_url: None, + about: None, + nip05_handle: None, + } +} + +fn is_missing_profile_error(error: &str) -> bool { + error.starts_with("relay returned 404") && error.contains("user not found") +} + +fn profile_object(value: &Value) -> Option<&Map> { + value + .get("profile") + .and_then(Value::as_object) + .or_else(|| value.as_object()) +} + +fn profile_field_str<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + profile_object(value) + .and_then(|object| object.get(key)) + .and_then(Value::as_str) +} + +fn optional_profile_string( + object: &Map, + key: &str, +) -> Result, String> { + match object.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(format!("parse failed: invalid profile field `{key}`")), + } +} + +fn profile_info_from_value(value: Value, fallback_pubkey: &str) -> Result { + let object = profile_object(&value) + .ok_or_else(|| "parse failed: expected profile object".to_string())?; + + let pubkey = match object.get("pubkey") { + None | Some(Value::Null) => fallback_pubkey.to_string(), + Some(Value::String(value)) => value.clone(), + Some(_) => return Err("parse failed: invalid profile field `pubkey`".to_string()), + }; + + Ok(ProfileInfo { + pubkey, + display_name: optional_profile_string(object, "display_name")?, + avatar_url: optional_profile_string(object, "avatar_url")?, + about: optional_profile_string(object, "about")?, + nip05_handle: optional_profile_string(object, "nip05_handle")?, + }) +} + +async fn fetch_profile_info( + request: reqwest::RequestBuilder, + fallback_pubkey: &str, + missing_profile_ok: bool, +) -> Result { + match send_json_request::(request).await { + Ok(value) => profile_info_from_value(value, fallback_pubkey), + Err(error) if missing_profile_ok && is_missing_profile_error(&error) => { + Ok(empty_profile_info(fallback_pubkey)) + } + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::{ + empty_profile_info, is_missing_profile_error, profile_field_str, profile_info_from_value, + }; + + #[test] + fn profile_info_from_value_accepts_top_level_shape() { + let value = serde_json::json!({ + "pubkey": "abc123", + "display_name": "Sprout User", + "avatar_url": "https://example.com/avatar.png", + "about": "Hello", + "nip05_handle": "sprout@example.com" + }); + + let profile = profile_info_from_value(value, "fallback").expect("profile"); + + assert_eq!(profile.pubkey, "abc123"); + assert_eq!(profile.display_name.as_deref(), Some("Sprout User")); + assert_eq!( + profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + assert_eq!(profile.about.as_deref(), Some("Hello")); + assert_eq!(profile.nip05_handle.as_deref(), Some("sprout@example.com")); + } + + #[test] + fn profile_info_from_value_accepts_nested_profile_shape() { + let value = serde_json::json!({ + "profile": { + "display_name": "Nested User", + "avatar_url": null, + "about": null, + "nip05_handle": null + } + }); + + let profile = profile_info_from_value(value, "fallback-pubkey").expect("profile"); + + assert_eq!(profile.pubkey, "fallback-pubkey"); + assert_eq!(profile.display_name.as_deref(), Some("Nested User")); + assert_eq!(profile.avatar_url, None); + assert_eq!(profile.about, None); + assert_eq!(profile.nip05_handle, None); + } + + #[test] + fn missing_profile_errors_are_detected() { + assert!(is_missing_profile_error( + "relay returned 404: user not found" + )); + assert!(is_missing_profile_error( + "relay returned 404 Not Found: user not found" + )); + assert!(!is_missing_profile_error( + "relay returned 401: authentication failed" + )); + } + + #[test] + fn profile_field_str_accepts_nested_profile_shape() { + let value = serde_json::json!({ + "profile": { + "display_name": "Nested User", + "about": "Nested about" + } + }); + + assert_eq!( + profile_field_str(&value, "display_name"), + Some("Nested User") + ); + assert_eq!(profile_field_str(&value, "about"), Some("Nested about")); + assert_eq!(profile_field_str(&value, "avatar_url"), None); + } + + #[test] + fn empty_profile_info_preserves_pubkey() { + let profile = empty_profile_info("fallback-pubkey"); + + assert_eq!(profile.pubkey, "fallback-pubkey"); + assert_eq!(profile.display_name, None); + assert_eq!(profile.avatar_url, None); + assert_eq!(profile.about, None); + assert_eq!(profile.nip05_handle, None); + } +} From fa9f3f136d68047e7aba921435d450e73f5bb117 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 12:16:24 -0600 Subject: [PATCH 006/120] fix: remove container border/bg from image previews in chat (#399) --- desktop/src/shared/ui/markdown.tsx | 6 +- .../features/channels/media_viewer_page.dart | 115 +++++++++++++++--- 2 files changed, 99 insertions(+), 22 deletions(-) diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 14cae1575f..6cc3dcdf84 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -192,10 +192,10 @@ function createMarkdownComponents( return ( -
+
{alt}
@@ -262,7 +262,7 @@ function createMarkdownComponents( if (isImageOnlyParagraph(childArray)) { return ( -
+
{imageChildren}
); diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart index a2be88a456..0c7acb57fd 100644 --- a/mobile/lib/features/channels/media_viewer_page.dart +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -116,22 +116,34 @@ class MediaImageViewerPage extends StatefulWidget { State createState() => _MediaImageViewerPageState(); } -class _MediaImageViewerPageState extends State { +class _MediaImageViewerPageState extends State + with SingleTickerProviderStateMixin { late final TransformationController _transformationController; + late final AnimationController _snapBackController; bool _isTransformed = false; bool _disableHeroOnDismiss = false; + double _dragOffset = 0; + bool _isDragging = false; + + static const _dismissThreshold = 100.0; + static const _backgroundFadeDivisor = 300.0; @override void initState() { super.initState(); _transformationController = TransformationController(); _transformationController.addListener(_handleTransformChanged); + _snapBackController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); } @override void dispose() { _transformationController.removeListener(_handleTransformChanged); _transformationController.dispose(); + _snapBackController.dispose(); super.dispose(); } @@ -143,6 +155,58 @@ class _MediaImageViewerPageState extends State { setState(() { _isTransformed = isTransformed; + // If the user zooms in while dragging, cancel the drag. + if (_isTransformed && _isDragging) { + _isDragging = false; + _dragOffset = 0; + } + }); + } + + void _onInteractionStart(ScaleStartDetails details) { + if (!_isTransformed && details.pointerCount == 1) { + _isDragging = true; + } + } + + void _onInteractionUpdate(ScaleUpdateDetails details) { + if (_isDragging && !_isTransformed) { + setState(() { + _dragOffset += details.focalPointDelta.dy; + }); + } + } + + void _onInteractionEnd(ScaleEndDetails details) { + if (!_isDragging) return; + _isDragging = false; + + if (_dragOffset.abs() > _dismissThreshold) { + _dismiss(); + } else { + _animateSnapBack(); + } + } + + void _animateSnapBack() { + final startOffset = _dragOffset; + final tween = Tween(begin: startOffset, end: 0); + final curved = CurvedAnimation( + parent: _snapBackController, + curve: Curves.easeOut, + ); + + void listener() { + setState(() { + _dragOffset = tween.evaluate(curved); + }); + } + + _snapBackController + ..reset() + ..addListener(listener); + _snapBackController.forward().whenCompleteOrCancel(() { + _snapBackController.removeListener(listener); }); } @@ -180,27 +244,40 @@ class _MediaImageViewerPageState extends State { }, child: Scaffold( key: const ValueKey('message-media-image-viewer'), - backgroundColor: Colors.black, + backgroundColor: Colors.black.withValues( + alpha: (1 - (_dragOffset.abs() / _backgroundFadeDivisor)).clamp( + 0.3, + 1.0, + ), + ), body: Stack( children: [ Positioned.fill( - child: InteractiveViewer( - transformationController: _transformationController, - minScale: 1, - maxScale: 4, - child: Center( - child: HeroMode( - key: const ValueKey('message-media-image-viewer-hero-mode'), - enabled: !_disableHeroOnDismiss, - child: Hero( - tag: widget.heroTag, - child: Image.network( - widget.imageUrl, - fit: BoxFit.contain, - semanticLabel: widget.semanticLabel, - errorBuilder: (_, _, _) => const _MediaLoadFailure( - message: 'Failed to load image', - icon: LucideIcons.imageOff, + child: Transform.translate( + offset: Offset(0, _dragOffset), + child: InteractiveViewer( + transformationController: _transformationController, + onInteractionStart: _onInteractionStart, + onInteractionUpdate: _onInteractionUpdate, + onInteractionEnd: _onInteractionEnd, + minScale: 1, + maxScale: 4, + child: Center( + child: HeroMode( + key: const ValueKey( + 'message-media-image-viewer-hero-mode', + ), + enabled: !_disableHeroOnDismiss, + child: Hero( + tag: widget.heroTag, + child: Image.network( + widget.imageUrl, + fit: BoxFit.contain, + semanticLabel: widget.semanticLabel, + errorBuilder: (_, _, _) => const _MediaLoadFailure( + message: 'Failed to load image', + icon: LucideIcons.imageOff, + ), ), ), ), From 480d78cb917e0fcf802ddbd0e62a64fdf2dab814 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 13:15:07 -0600 Subject: [PATCH 007/120] feat: CMD+F find-in-channel search (#400) Co-authored-by: Claude Opus 4.6 --- crates/sprout-relay/src/api/search.rs | 12 +- desktop/src-tauri/src/commands/messages.rs | 7 +- desktop/src-tauri/src/models.rs | 2 + .../src/features/channels/ui/ChannelPane.tsx | 18 ++ .../features/channels/ui/ChannelScreen.tsx | 8 +- .../src/features/messages/ui/MessageRow.tsx | 6 +- .../features/messages/ui/MessageTimeline.tsx | 35 ++++ .../messages/ui/TimelineMessageList.tsx | 15 +- desktop/src/features/search/hooks.ts | 5 +- .../src/features/search/ui/ChannelFindBar.tsx | 116 +++++++++++++ desktop/src/features/search/useChannelFind.ts | 161 ++++++++++++++++++ desktop/src/shared/api/tauri.ts | 9 +- desktop/src/shared/api/types.ts | 1 + desktop/src/shared/lib/keyboard-shortcuts.ts | 8 + .../src/shared/lib/rehypeSearchHighlight.ts | 98 +++++++++++ desktop/src/shared/styles/globals.css | 5 + desktop/src/shared/ui/markdown.tsx | 33 +++- 17 files changed, 520 insertions(+), 19 deletions(-) create mode 100644 desktop/src/features/search/ui/ChannelFindBar.tsx create mode 100644 desktop/src/features/search/useChannelFind.ts create mode 100644 desktop/src/shared/lib/rehypeSearchHighlight.ts diff --git a/crates/sprout-relay/src/api/search.rs b/crates/sprout-relay/src/api/search.rs index 34d5006c35..70de6d29de 100644 --- a/crates/sprout-relay/src/api/search.rs +++ b/crates/sprout-relay/src/api/search.rs @@ -23,6 +23,10 @@ pub struct SearchParams { pub q: Option, /// Maximum number of results to return. Defaults to 20, capped at 100. pub limit: Option, + /// Restrict results to a single channel. When present, ANDed with the + /// ACL-based channel filter so the caller can only see results they already + /// have access to. + pub channel_id: Option, } /// Full-text search over messages accessible to the authenticated user. @@ -56,7 +60,13 @@ pub async fn search_handler( if channel_ids.is_empty() && !include_global { return Ok(Json(serde_json::json!({ "hits": [], "found": 0 }))); } - let filter_by = if channel_ids.is_empty() { + let filter_by = if let Some(ref cid) = params.channel_id { + // Scoped to a single channel — verify it's in the accessible set. + if !channel_ids.iter().any(|id| id.to_string() == *cid) { + return Ok(Json(serde_json::json!({ "hits": [], "found": 0 }))); + } + Some(format!("channel_id:={cid}")) + } else if channel_ids.is_empty() { Some("channel_id:=__global__".to_string()) } else if include_global { let ids: Vec = channel_ids.iter().map(|id| id.to_string()).collect(); diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 967bf87c8a..b0dfdf85ce 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -35,10 +35,15 @@ pub async fn get_feed( pub async fn search_messages( q: String, limit: Option, + channel_id: Option, state: State<'_, AppState>, ) -> Result { let request = build_authed_request(&state.http_client, Method::GET, "/api/search", &state)? - .query(&SearchQueryParams { q: q.trim(), limit }); + .query(&SearchQueryParams { + q: q.trim(), + limit, + channel_id: channel_id.as_deref(), + }); send_json_request(request).await } diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index ced3e08632..ba54c91501 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -164,6 +164,8 @@ pub struct SearchQueryParams<'a> { pub q: &'a str, #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_id: Option<&'a str>, } #[derive(Serialize)] diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 7832c8b7cb..0b9ea96902 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -4,6 +4,8 @@ import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; +import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; +import type { useChannelFind } from "@/features/search/useChannelFind"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; @@ -45,6 +47,7 @@ function getInitialThreadPanelWidth(): number { type ChannelPaneProps = { activeChannel: Channel | null; + channelFind: ReturnType; currentPubkey?: string; editTarget?: { author: string; @@ -99,6 +102,7 @@ type ChannelPaneProps = { export const ChannelPane = React.memo(function ChannelPane({ activeChannel, + channelFind, currentPubkey, editTarget = null, fetchOlder, @@ -198,6 +202,17 @@ export const ChannelPane = React.memo(function ChannelPane({ return (
+ {channelFind.isOpen ? ( + + ) : null} { @@ -237,6 +238,11 @@ export function ChannelScreen({ resolvedMessages, ], ); + const channelFind = useChannelFind({ + channelId: activeChannelId, + messages: timelineMessages, + }); + const directReplyIdsByParentId = React.useMemo(() => { const map = new Map(); @@ -341,7 +347,6 @@ export function ChannelScreen({ React.useEffect(() => { resetComposerTargets(activeChannelId); }, [activeChannelId, resetComposerTargets]); - React.useEffect(() => { if (openThreadHeadId && !openThreadHeadMessage) { setOpenThreadHeadId(null); @@ -430,6 +435,7 @@ export function ChannelScreen({ }> Promise; onReply?: (message: TimelineMessage) => void; profiles?: UserProfileLookup; + searchQuery?: string; }) { const [expandedDiffId, setExpandedDiffId] = React.useState( null, @@ -111,6 +113,7 @@ export const MessageRow = React.memo( content={message.body} imetaByUrl={imetaByUrl} mentionNames={mentionNames} + searchQuery={searchQuery} tight /> ); @@ -393,7 +396,8 @@ export const MessageRow = React.memo( prev.highlighted === next.highlighted && prev.activeReplyTargetId === next.activeReplyTargetId && prev.layoutVariant === next.layoutVariant && - prev.profiles === next.profiles, + prev.profiles === next.profiles && + prev.searchQuery === next.searchQuery, ); MessageRow.displayName = "MessageRow"; diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 6a014ee243..0d1e4774d8 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -35,6 +35,12 @@ type MessageTimelineProps = { emoji: string, remove: boolean, ) => Promise; + /** The message ID of the currently active find-in-channel match. */ + searchActiveMessageId?: string | null; + /** Set of message IDs that match the current find-in-channel query. */ + searchMatchingMessageIds?: Set; + /** The current find-in-channel query string. */ + searchQuery?: string; targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; }; @@ -56,6 +62,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({ onEdit, onReply, onToggleReaction, + searchActiveMessageId = null, + searchMatchingMessageIds, + searchQuery, targetMessageId = null, onTargetReached, }: MessageTimelineProps) { @@ -80,6 +89,29 @@ export const MessageTimeline = React.memo(function MessageTimeline({ targetMessageId, }); + // Scroll to the active search match when it changes. + const prevSearchActiveRef = React.useRef(null); + React.useEffect(() => { + if ( + !searchActiveMessageId || + searchActiveMessageId === prevSearchActiveRef.current + ) { + prevSearchActiveRef.current = searchActiveMessageId; + return; + } + prevSearchActiveRef.current = searchActiveMessageId; + + const container = scrollContainerRef.current; + if (!container) return; + + const el = container.querySelector( + `[data-message-id="${searchActiveMessageId}"]`, + ); + if (el) { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + } + }, [searchActiveMessageId]); + useLoadOlderOnScroll({ fetchOlder, hasOlderMessages, @@ -161,6 +193,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({ onToggleReaction={onToggleReaction} personaLookup={personaLookup} profiles={profiles} + searchActiveMessageId={searchActiveMessageId} + searchMatchingMessageIds={searchMatchingMessageIds} + searchQuery={searchQuery} /> ) : null} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 0dd0a52461..647f231bbc 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -29,6 +29,12 @@ type TimelineMessageListProps = { /** Map from lowercase pubkey → persona display name for bot members. */ personaLookup?: Map; profiles?: UserProfileLookup; + /** The message ID of the currently active find-in-channel match. */ + searchActiveMessageId?: string | null; + /** Set of message IDs that match the current find-in-channel query. */ + searchMatchingMessageIds?: Set; + /** The current find-in-channel query string. */ + searchQuery?: string; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -42,6 +48,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onToggleReaction, personaLookup, profiles, + searchActiveMessageId = null, + searchMatchingMessageIds, + searchQuery, }: TimelineMessageListProps) { const elements: React.ReactNode[] = []; const entries = React.useMemo( @@ -75,11 +84,14 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ />, ); } else { + const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === searchActiveMessageId; + elements.push( , ); diff --git a/desktop/src/features/search/hooks.ts b/desktop/src/features/search/hooks.ts index ebbc5ad02a..038c6511f6 100644 --- a/desktop/src/features/search/hooks.ts +++ b/desktop/src/features/search/hooks.ts @@ -5,6 +5,7 @@ import { searchMessages } from "@/shared/api/tauri"; export function useSearchMessagesQuery( query: string, options?: { + channelId?: string; enabled?: boolean; limit?: number; }, @@ -12,13 +13,15 @@ export function useSearchMessagesQuery( const trimmedQuery = query.trim(); const enabled = options?.enabled ?? true; const limit = options?.limit ?? 12; + const channelId = options?.channelId; return useQuery({ - queryKey: ["search-messages", trimmedQuery, limit], + queryKey: ["search-messages", trimmedQuery, limit, channelId ?? null], queryFn: () => searchMessages({ q: trimmedQuery, limit, + channelId, }), enabled: enabled && trimmedQuery.length >= 2, staleTime: 30_000, diff --git a/desktop/src/features/search/ui/ChannelFindBar.tsx b/desktop/src/features/search/ui/ChannelFindBar.tsx new file mode 100644 index 0000000000..b9c97dc54d --- /dev/null +++ b/desktop/src/features/search/ui/ChannelFindBar.tsx @@ -0,0 +1,116 @@ +import { ChevronDown, ChevronUp, X } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; + +type ChannelFindBarProps = { + matchCount: number; + matchIndex: number; + onClose: () => void; + onNext: () => void; + onPrevious: () => void; + onQueryChange: (query: string) => void; + query: string; +}; + +export function ChannelFindBar({ + matchCount, + matchIndex, + onClose, + onNext, + onPrevious, + onQueryChange, + query, +}: ChannelFindBarProps) { + const inputRef = React.useRef(null); + + React.useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + if (event.shiftKey) { + onPrevious(); + } else { + onNext(); + } + } + }; + + const matchLabel = + query.length >= 2 + ? matchCount > 0 + ? `${matchIndex + 1} of ${matchCount}` + : "No results" + : null; + + return ( +
+
+ onQueryChange(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="Find in channel" + type="text" + value={query} + /> + {matchLabel ? ( + + {matchLabel} + + ) : null} +
+ + + + + + +
+ ); +} diff --git a/desktop/src/features/search/useChannelFind.ts b/desktop/src/features/search/useChannelFind.ts new file mode 100644 index 0000000000..c31ea4f61e --- /dev/null +++ b/desktop/src/features/search/useChannelFind.ts @@ -0,0 +1,161 @@ +import * as React from "react"; + +import { useSearchMessagesQuery } from "@/features/search/hooks"; +import type { TimelineMessage } from "@/features/messages/types"; + +const MIN_QUERY_LENGTH = 2; +const DEBOUNCE_MS = 300; + +type UseChannelFindOptions = { + channelId: string | null; + messages: TimelineMessage[]; +}; + +export function useChannelFind({ channelId, messages }: UseChannelFindOptions) { + const [isOpen, setIsOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [debouncedQuery, setDebouncedQuery] = React.useState(""); + const [activeIndex, setActiveIndex] = React.useState(0); + + const reset = React.useCallback(() => { + setIsOpen(false); + setQuery(""); + setDebouncedQuery(""); + setActiveIndex(0); + }, []); + + // Debounce the query for relay search. + React.useEffect(() => { + const trimmed = query.trim(); + if (trimmed.length < MIN_QUERY_LENGTH) { + setDebouncedQuery(""); + return; + } + + const timeout = window.setTimeout(() => { + setDebouncedQuery(trimmed); + }, DEBOUNCE_MS); + + return () => window.clearTimeout(timeout); + }, [query]); + + // Client-side search: instant matches against loaded messages. + const clientMatchIds = React.useMemo(() => { + const trimmed = query.trim().toLowerCase(); + if (trimmed.length < MIN_QUERY_LENGTH) { + return []; + } + + const found: string[] = []; + for (const message of messages) { + if (message.body.toLowerCase().includes(trimmed)) { + found.push(message.id); + } + } + + return found; + }, [messages, query]); + + // Relay-backed search: full history via Typesense. + const relaySearch = useSearchMessagesQuery(debouncedQuery, { + channelId: channelId ?? undefined, + enabled: isOpen && debouncedQuery.length >= MIN_QUERY_LENGTH, + limit: 100, + }); + + // Merge: start with client-side matches, then supplement with relay hits + // that are loaded in the timeline but were missed by exact substring match + // (e.g. Typesense stemming). Only loaded messages are kept so the match + // count stays accurate relative to what's visible on screen. + const loadedMessageIds = React.useMemo( + () => new Set(messages.map((m) => m.id)), + [messages], + ); + + const matchedIds = React.useMemo(() => { + const merged = [...clientMatchIds]; + const seen = new Set(merged); + + if (relaySearch.data?.hits) { + for (const hit of relaySearch.data.hits) { + if (!seen.has(hit.eventId) && loadedMessageIds.has(hit.eventId)) { + merged.push(hit.eventId); + seen.add(hit.eventId); + } + } + } + + return merged; + }, [clientMatchIds, relaySearch.data?.hits, loadedMessageIds]); + + // Clamp active index when results change. + React.useEffect(() => { + setActiveIndex((current) => { + if (matchedIds.length === 0) return 0; + return current >= matchedIds.length ? 0 : current; + }); + }, [matchedIds.length]); + + const activeMatch = + matchedIds.length > 0 ? { messageId: matchedIds[activeIndex] } : null; + + const matchingMessageIds = React.useMemo(() => { + return new Set(matchedIds); + }, [matchedIds]); + + const close = React.useCallback(() => { + reset(); + }, [reset]); + + const goToNext = React.useCallback(() => { + if (matchedIds.length === 0) return; + setActiveIndex((current) => (current + 1) % matchedIds.length); + }, [matchedIds.length]); + + const goToPrevious = React.useCallback(() => { + if (matchedIds.length === 0) return; + setActiveIndex((current) => + current === 0 ? matchedIds.length - 1 : current - 1, + ); + }, [matchedIds.length]); + + // Register CMD+F keyboard shortcut. + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if ( + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "f" + ) { + event.preventDefault(); + setIsOpen(true); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + // Close find bar when switching channels. + const prevChannelIdRef = React.useRef(channelId); + React.useEffect(() => { + if (prevChannelIdRef.current !== channelId) { + prevChannelIdRef.current = channelId; + reset(); + } + }, [channelId, reset]); + + return { + activeIndex, + activeMatch, + close, + goToNext, + goToPrevious, + isOpen, + matchCount: matchedIds.length, + matchingMessageIds, + query, + setQuery, + }; +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 9f12ee9c8e..41564d5e6b 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -681,10 +681,11 @@ export async function getHomeFeed( export async function searchMessages( input: SearchMessagesInput, ): Promise { - const response = await invokeTauri( - "search_messages", - input, - ); + const response = await invokeTauri("search_messages", { + q: input.q, + limit: input.limit, + channelId: input.channelId, + }); return { hits: response.hits.map(fromRawSearchHit), diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 7459547f7c..8db839c410 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -209,6 +209,7 @@ export type GetHomeFeedInput = { export type SearchMessagesInput = { q: string; limit?: number; + channelId?: string; }; export type SearchHit = { diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index 1853fbb377..d5cf1db24f 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -63,6 +63,14 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ keysWindows: "Alt+→", category: "Navigation", }, + { + id: "find-in-channel", + label: "Find in channel", + description: "Search messages in current channel", + keys: "⌘F", + keysWindows: "Ctrl+F", + category: "Navigation", + }, { id: "go-home", label: "Home", diff --git a/desktop/src/shared/lib/rehypeSearchHighlight.ts b/desktop/src/shared/lib/rehypeSearchHighlight.ts new file mode 100644 index 0000000000..8e4b3e388f --- /dev/null +++ b/desktop/src/shared/lib/rehypeSearchHighlight.ts @@ -0,0 +1,98 @@ +/** + * Rehype plugin that highlights text matching a search query by wrapping + * matches in `` elements during the HAST (HTML AST) phase. + * + * This runs inside the react-markdown pipeline, so it works correctly with + * ReactMarkdown's architecture — no post-render tree walking needed. + */ + +// Minimal HAST types — matches the pattern in rehypeImageGallery.ts. +interface HastText { + type: "text"; + value: string; +} + +interface HastElement { + type: "element"; + tagName: string; + properties: Record; + children: HastNode[]; +} + +type HastNode = HastElement | HastText | { type: string }; + +interface HastRoot { + type: "root"; + children: HastNode[]; +} + +function isElement(node: HastNode): node is HastElement { + return node.type === "element"; +} + +function isText(node: HastNode): node is HastText { + return node.type === "text"; +} + +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export default function rehypeSearchHighlight({ query }: { query: string }) { + return (tree: HastRoot) => { + const trimmed = query.trim(); + if (trimmed.length < 2) return; + + const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "i"); + + function walk(nodes: HastNode[]): HastNode[] { + const result: HastNode[] = []; + + for (const node of nodes) { + if (isText(node)) { + const parts = node.value.split(pattern); + if (parts.length === 1) { + result.push(node); + continue; + } + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (!part) continue; + + if (i % 2 === 1) { + // Odd indices from split-with-capture are always the match. + result.push({ + type: "element", + tagName: "mark", + properties: { + className: + "rounded-sm bg-primary/20 text-foreground dark:bg-primary/30", + }, + children: [{ type: "text", value: part }], + }); + } else { + result.push({ type: "text", value: part }); + } + } + } else if (isElement(node)) { + // Don't descend into or
 — keep code blocks untouched.
+          if (node.tagName === "code" || node.tagName === "pre") {
+            result.push(node);
+          } else {
+            result.push({
+              ...node,
+              children: walk(node.children),
+            });
+          }
+        } else {
+          result.push(node);
+        }
+      }
+
+      return result;
+    }
+
+    tree.children = walk(tree.children);
+  };
+}
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index 18fc24b1e0..3af36816c4 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -188,6 +188,11 @@
     @apply border-border;
   }
 
+  mark {
+    background-color: transparent;
+    color: inherit;
+  }
+
   html.dark {
     color-scheme: dark;
   }
diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx
index 6cc3dcdf84..cb2520fb05 100644
--- a/desktop/src/shared/ui/markdown.tsx
+++ b/desktop/src/shared/ui/markdown.tsx
@@ -10,6 +10,7 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"
 import { cn } from "@/shared/lib/cn";
 import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
 import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
+import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
 import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
 import remarkMentions from "@/shared/lib/remarkMentions";
 
@@ -30,6 +31,7 @@ type MarkdownProps = {
   content: string;
   imetaByUrl?: ImetaLookup;
   mentionNames?: string[];
+  searchQuery?: string;
   tight?: boolean;
 };
 
@@ -354,6 +356,7 @@ function MarkdownInner({
   content,
   imetaByUrl,
   mentionNames,
+  searchQuery,
   tight = false,
 }: MarkdownProps) {
   const variant: MarkdownVariant = tight
@@ -389,7 +392,14 @@ function MarkdownInner({
   );
 
   // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
-  const rehypePlugins = React.useMemo(() => [rehypeImageGallery], []);
+  const rehypePlugins = React.useMemo(() => {
+    // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
+    const plugins: any[] = [rehypeImageGallery];
+    if (searchQuery && searchQuery.trim().length >= 2) {
+      plugins.push([rehypeSearchHighlight, { query: searchQuery }]);
+    }
+    return plugins;
+  }, [searchQuery]);
 
   let processedContent = content;
 
@@ -401,6 +411,16 @@ function MarkdownInner({
     processedContent = `${processedContent}\u200B`;
   }
 
+  const markdownNode = (
+    
+      {processedContent}
+    
+  );
+
   return (
     
- - {processedContent} - + {markdownNode}
); } @@ -432,7 +446,8 @@ export const Markdown = React.memo( prev.tight === next.tight && shallowArrayEqual(prev.mentionNames, next.mentionNames) && shallowArrayEqual(prev.channelNames, next.channelNames) && - prev.imetaByUrl === next.imetaByUrl, + prev.imetaByUrl === next.imetaByUrl && + prev.searchQuery === next.searchQuery, ); Markdown.displayName = "Markdown"; From 3c0295331d19c8cf46c08f990e07b9162ee807b6 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Apr 2026 12:38:15 -0600 Subject: [PATCH 008/120] feat(mobile): full-screen search with messages, channels, and people (#402) Co-authored-by: Claude Opus 4.6 --- .../lib/features/channels/channels_page.dart | 289 +---------- .../features/channels/date_formatters.dart | 23 + .../features/channels/message_content.dart | 26 +- mobile/lib/features/home/home_page.dart | 8 +- mobile/lib/features/search/search_page.dart | 466 ++++++++++++++++++ .../lib/features/search/search_provider.dart | 188 +++++++ .../features/channels/channels_page_test.dart | 9 +- .../channels/message_content_test.dart | 6 +- 8 files changed, 723 insertions(+), 292 deletions(-) create mode 100644 mobile/lib/features/search/search_page.dart create mode 100644 mobile/lib/features/search/search_provider.dart diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 9525ab8ad7..94396fae7b 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -49,23 +49,6 @@ class ChannelsPage extends HookConsumerWidget { ); } - Future browseChannels() async { - final channels = channelsAsync.asData?.value; - if (channels == null || channels.isEmpty) { - return; - } - - final selected = await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - builder: (_) => _BrowseChannelsSheet(channels: channels), - ); - if (selected != null && context.mounted) { - await openChannel(selected); - } - } - Future openQuickActions() async { final action = await showModalBottomSheet<_QuickAction>( context: context, @@ -110,46 +93,20 @@ class ChannelsPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( titleSpacing: Grid.xs, - title: Row( - children: [ - Expanded( - child: GestureDetector( - onTap: channelsAsync.hasValue ? browseChannels : null, - child: Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.lg), - border: Border.all(color: context.colors.outlineVariant), - ), - child: Row( - children: [ - Icon( - LucideIcons.search, - size: 16, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(width: Grid.xxs), - Text( - 'Search', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: Grid.twelve), - ProfileAvatar( - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsPage()), - ), - ), - ], + title: Text( + '\u{1F331} Sprout', + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), + actions: [ + ProfileAvatar( + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsPage()), + ), + ), + const SizedBox(width: Grid.xs), + ], ), floatingActionButton: FloatingActionButton( onPressed: openQuickActions, @@ -730,203 +687,6 @@ class _CreateChannelSheet extends HookConsumerWidget { } } -class _BrowseChannelsSheet extends HookConsumerWidget { - final List channels; - - const _BrowseChannelsSheet({required this.channels}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final query = useState(''); - final busyChannelId = useState(null); - - final normalizedQuery = query.value.trim().toLowerCase(); - final browsableChannels = channels.where((channel) { - if (channel.isDm) return false; - final visible = channel.isArchived - ? channel.isMember - : channel.visibility == 'open' || channel.isMember; - if (!visible) return false; - if (normalizedQuery.isEmpty) return true; - return channel.name.toLowerCase().contains(normalizedQuery) || - channel.description.toLowerCase().contains(normalizedQuery); - }).toList(); - - final notJoined = browsableChannels - .where((channel) => !channel.isMember) - .toList(); - final joined = browsableChannels - .where((channel) => channel.isMember) - .toList(); - - Future openOrJoin(Channel channel) async { - if (busyChannelId.value != null) return; - - if (channel.isMember) { - Navigator.of(context).pop(channel); - return; - } - - busyChannelId.value = channel.id; - try { - await ref.read(channelActionsProvider).joinChannel(channel.id); - final refreshed = await ref.read(channelsProvider.future); - final joinedChannel = refreshed.firstWhere( - (candidate) => candidate.id == channel.id, - orElse: () => channel, - ); - if (context.mounted) { - Navigator.of(context).pop(joinedChannel); - } - } catch (error) { - if (!context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error.toString()))); - } finally { - busyChannelId.value = null; - } - } - - return DraggableScrollableSheet( - initialChildSize: 0.75, - minChildSize: 0.4, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) => Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.xs, - Grid.twelve, - Grid.xs, - Grid.xxs, - ), - child: GestureDetector( - onTap: () {}, - child: Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.lg), - border: Border.all(color: context.colors.outlineVariant), - ), - child: Row( - children: [ - Icon( - LucideIcons.search, - size: 16, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: TextField( - autofocus: true, - decoration: InputDecoration( - hintText: 'Search channels, forums…', - hintStyle: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - style: context.textTheme.bodyMedium, - onChanged: (value) => query.value = value, - ), - ), - ], - ), - ), - ), - ), - Expanded( - child: browsableChannels.isEmpty - ? Center( - child: Text( - 'No matching results', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ) - : ListView( - controller: scrollController, - padding: EdgeInsets.only( - top: Grid.xxs, - bottom: MediaQuery.viewInsetsOf(context).bottom, - ), - children: [ - if (notJoined.isNotEmpty) ...[ - _MiniHeader( - label: '${notJoined.length} available to join', - ), - for (final channel in notJoined) - _BrowseTile( - channel: channel, - isBusy: busyChannelId.value == channel.id, - onTap: () => openOrJoin(channel), - ), - ], - if (joined.isNotEmpty) ...[ - _MiniHeader(label: '${joined.length} joined'), - for (final channel in joined) - _BrowseTile( - channel: channel, - isBusy: false, - onTap: () => openOrJoin(channel), - ), - ], - ], - ), - ), - ], - ), - ); - } -} - -class _BrowseTile extends StatelessWidget { - final Channel channel; - final bool isBusy; - final VoidCallback onTap; - - const _BrowseTile({ - required this.channel, - required this.isBusy, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon( - channel.isForum ? LucideIcons.messageSquareText : LucideIcons.hash, - ), - title: Text(channel.name), - subtitle: Text( - channel.description.isEmpty ? 'No description' : channel.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - trailing: FilledButton.tonal( - onPressed: isBusy ? null : onTap, - child: Text( - isBusy - ? 'Joining…' - : channel.isMember - ? 'Open' - : 'Join', - ), - ), - onTap: isBusy ? null : onTap, - ); - } -} - class _NewDirectMessageSheet extends HookConsumerWidget { final String? currentPubkey; @@ -1128,29 +888,6 @@ class _NewDirectMessageSheet extends HookConsumerWidget { } } -class _MiniHeader extends StatelessWidget { - final String label; - - const _MiniHeader({required this.label}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.half, - ), - child: Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} - class _EphemeralBadge extends StatelessWidget { final Channel channel; diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart index c268144854..ee7d2f69e0 100644 --- a/mobile/lib/features/channels/date_formatters.dart +++ b/mobile/lib/features/channels/date_formatters.dart @@ -41,3 +41,26 @@ bool isSameDay(int a, int b) { ).toLocal(); return dtA.year == dtB.year && dtA.month == dtB.month && dtA.day == dtB.day; } + +/// Returns a compact relative time string like "just now", "5m ago", "3h ago", +/// "2d ago", or a short date for older timestamps. +String relativeTime(int unixSeconds) { + final now = DateTime.now(); + final time = DateTime.fromMillisecondsSinceEpoch( + unixSeconds * 1000, + isUtc: true, + ).toLocal(); + final diff = now.difference(time); + + if (diff.inMinutes < 1) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${time.month}/${time.day}/${time.year}'; +} + +/// Truncates a hex pubkey to the first 8 characters with an ellipsis. +String shortPubkey(String pubkey) { + if (pubkey.length > 12) return '${pubkey.substring(0, 8)}\u2026'; + return pubkey; +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index d43efb78bd..9801dd114e 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -51,7 +51,7 @@ class MessageContent extends StatelessWidget { context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); final imetaByUrl = parseImetaTags(tags); - // Convert angle-bracket autolinks to standard markdown links, + // Convert autolinks and bare URLs to standard markdown links, // but skip content inside backticks (inline code / fenced blocks). final buffer = StringBuffer(); final parts = content.split('`'); @@ -60,12 +60,26 @@ class MessageContent extends StatelessWidget { // Inside backticks — preserve as-is. buffer.write('`${parts[i]}`'); } else { - buffer.write( - parts[i].replaceAllMapped( - RegExp(r'<(https?://[^>]+)>'), - (m) => '[${m[1]}](${m[1]})', - ), + // 1. Angle-bracket autolinks: + var segment = parts[i].replaceAllMapped( + RegExp(r'<(https?://[^>]+)>'), + (m) => '[${m[1]}](${m[1]})', ); + // 2. Bare URLs not already inside markdown link/image syntax. + // Negative lookbehind avoids matching URLs preceded by ]( or = + // which are already part of markdown links or imeta tags. + segment = segment.replaceAllMapped( + RegExp(r'(?\]]+'), + (m) { + final url = m[0]!; + // Skip if this URL is already a markdown link label that equals + // the URL (produced by step 1 or authored as [url](url)). + final start = m.start; + if (start >= 1 && segment[start - 1] == '[') return url; + return '[$url]($url)'; + }, + ); + buffer.write(segment); } } final processed = buffer.toString(); diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 987d090aae..c235f38c7c 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../activity/activity_page.dart'; import '../channels/channels_page.dart'; +import '../search/search_page.dart'; class HomePage extends HookConsumerWidget { const HomePage({super.key}); @@ -13,7 +14,7 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); - const pages = [ChannelsPage(), ActivityPage()]; + const pages = [ChannelsPage(), ActivityPage(), SearchPage()]; return Scaffold( body: IndexedStack(index: tabIndex.value, children: pages), @@ -31,6 +32,11 @@ class HomePage extends HookConsumerWidget { selectedIcon: Icon(LucideIcons.bell), label: 'Activity', ), + NavigationDestination( + icon: Icon(LucideIcons.search), + selectedIcon: Icon(LucideIcons.search), + label: 'Search', + ), ], ), ); diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart new file mode 100644 index 0000000000..7fdfcdcc4b --- /dev/null +++ b/mobile/lib/features/search/search_page.dart @@ -0,0 +1,466 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import '../channels/channel.dart'; +import '../channels/channel_detail_page.dart'; +import '../channels/channel_management_provider.dart'; +import '../channels/channels_provider.dart'; +import '../channels/date_formatters.dart'; +import '../forum/forum_thread_page.dart'; +import '../profile/profile_provider.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import 'search_provider.dart'; + +enum _SearchFilter { all, messages, channels, people } + +class SearchPage extends HookConsumerWidget { + const SearchPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchState = ref.watch(searchProvider); + final currentPubkey = ref + .watch(profileProvider) + .whenData((value) => value?.pubkey) + .value; + final activeFilter = useState(_SearchFilter.all); + final textController = useTextEditingController(); + final hasText = useListenableSelector( + textController, + () => textController.text.isNotEmpty, + ); + + return Scaffold( + appBar: AppBar( + titleSpacing: Grid.xs, + title: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: Grid.half), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.lg), + ), + child: TextField( + controller: textController, + decoration: InputDecoration( + hintText: 'Search messages, channels, people\u2026', + hintStyle: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + prefixIcon: const Icon(LucideIcons.search, size: 16), + prefixIconConstraints: const BoxConstraints(minWidth: 32), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs), + ), + style: context.textTheme.bodyMedium, + onChanged: (value) => + ref.read(searchProvider.notifier).search(value), + ), + ), + actions: [ + if (hasText) + IconButton( + icon: const Icon(LucideIcons.x, size: 20), + onPressed: () { + textController.clear(); + ref.read(searchProvider.notifier).clear(); + }, + ), + ], + ), + body: Column( + children: [ + _FilterChips( + active: activeFilter.value, + onChanged: (f) => activeFilter.value = f, + ), + Expanded( + child: _SearchBody( + state: searchState, + filter: activeFilter.value, + currentPubkey: currentPubkey, + ), + ), + ], + ), + ); + } +} + +class _FilterChips extends StatelessWidget { + final _SearchFilter active; + final ValueChanged<_SearchFilter> onChanged; + + const _FilterChips({required this.active, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.half, Grid.xs, Grid.xxs), + child: Row( + children: [ + for (final filter in _SearchFilter.values) ...[ + if (filter != _SearchFilter.values.first) + const SizedBox(width: Grid.xxs), + GestureDetector( + onTap: () => onChanged(filter), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.half + 2, + ), + decoration: BoxDecoration( + color: active == filter + ? context.colors.primary + : context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.lg), + border: active == filter + ? null + : Border.all(color: context.colors.outlineVariant), + ), + child: Text( + filter.label, + style: context.textTheme.labelMedium?.copyWith( + color: active == filter + ? context.colors.onPrimary + : context.colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +class _SearchBody extends ConsumerWidget { + final SearchState state; + final _SearchFilter filter; + final String? currentPubkey; + + const _SearchBody({ + required this.state, + required this.filter, + required this.currentPubkey, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (state.query.isEmpty) { + return Center( + child: Text( + 'Search messages, channels, and people', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ); + } + + final showChannels = + filter == _SearchFilter.all || filter == _SearchFilter.channels; + final showPeople = + filter == _SearchFilter.all || filter == _SearchFilter.people; + final showMessages = + filter == _SearchFilter.all || filter == _SearchFilter.messages; + + final hasAnyResults = + state.channelResults.isNotEmpty || + state.userResults.isNotEmpty || + state.messageResults.isNotEmpty; + + if (!state.isLoading && !hasAnyResults) { + return Center( + child: Text( + "No results for '${state.query}'", + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ); + } + + return ListView( + padding: const EdgeInsets.only(bottom: Grid.xl), + children: [ + if (showChannels && state.channelResults.isNotEmpty) + _ChannelsSection(channels: state.channelResults), + if (showPeople && state.userResults.isNotEmpty) + _PeopleSection(users: state.userResults), + if (showMessages && state.messageResults.isNotEmpty) + _MessagesSection( + hits: state.messageResults, + currentPubkey: currentPubkey, + ), + if (state.isLoading) + const Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center(child: CircularProgressIndicator()), + ), + ], + ); + } +} + +class _ChannelsSection extends StatelessWidget { + final List channels; + + const _ChannelsSection({required this.channels}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel(label: 'Channels'), + for (final channel in channels) + ListTile( + leading: Icon(channelIcon(channel), size: 20), + title: Text(channel.name), + subtitle: Text( + '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + trailing: !channel.isMember && !channel.isDm + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half + 2, + vertical: 3, + ), + decoration: BoxDecoration( + color: context.colors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Text( + 'Open', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ), + ), + ], + ); + } +} + +class _PeopleSection extends ConsumerWidget { + final List users; + + const _PeopleSection({required this.users}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel(label: 'People'), + for (final user in users) + ListTile( + leading: CircleAvatar( + backgroundImage: user.avatarUrl != null + ? NetworkImage(user.avatarUrl!) + : null, + child: user.avatarUrl == null + ? Text(user.label.substring(0, 1).toUpperCase()) + : null, + ), + title: Text(user.label), + subtitle: Text( + user.secondaryLabel, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + onTap: () async { + final channel = await ref + .read(channelActionsProvider) + .openDm(pubkeys: [user.pubkey]); + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + }, + ), + ], + ); + } +} + +class _MessagesSection extends ConsumerWidget { + final List hits; + final String? currentPubkey; + + const _MessagesSection({required this.hits, required this.currentPubkey}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profiles = ref.watch(userCacheProvider); + final channels = ref.watch(channelsProvider).value ?? []; + + // Preload author profiles. + final pubkeys = hits.map((h) => h.pubkey.toLowerCase()).toSet().toList(); + ref.read(userCacheProvider.notifier).preload(pubkeys); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionLabel(label: 'Messages'), + for (final hit in hits) + _MessageTile( + hit: hit, + authorProfile: profiles[hit.pubkey.toLowerCase()], + channel: channels.where((c) => c.id == hit.channelId).firstOrNull, + currentPubkey: currentPubkey, + ), + ], + ); + } +} + +class _MessageTile extends StatelessWidget { + final SearchHit hit; + final UserProfile? authorProfile; + final Channel? channel; + final String? currentPubkey; + + const _MessageTile({ + required this.hit, + required this.authorProfile, + required this.channel, + required this.currentPubkey, + }); + + @override + Widget build(BuildContext context) { + final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey); + final timeAgo = relativeTime(hit.createdAt); + + return ListTile( + title: Row( + children: [ + Expanded( + child: Text( + authorName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (hit.channelName != null) ...[ + const SizedBox(width: Grid.half), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half, + vertical: 2, + ), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Text( + hit.channelName!, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 2), + Text(hit.content, maxLines: 2, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), + Text( + timeAgo, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + ], + ), + onTap: () => _navigateToHit(context, hit, channel), + ); + } + + void _navigateToHit(BuildContext context, SearchHit hit, Channel? channel) { + if (channel == null) return; + + if (hit.kind == 45001) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ForumThreadPage( + channelId: channel.id, + postEventId: hit.eventId, + currentPubkey: currentPubkey, + isMember: channel.isMember, + isArchived: channel.isArchived, + ), + ), + ); + } else { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + } + } +} + +class _SectionLabel extends StatelessWidget { + final String label; + + const _SectionLabel({required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.xs, Grid.xs, Grid.half), + child: Text( + label.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ); + } +} + +extension on _SearchFilter { + String get label => switch (this) { + _SearchFilter.all => 'All', + _SearchFilter.messages => 'Messages', + _SearchFilter.channels => 'Channels', + _SearchFilter.people => 'People', + }; +} diff --git a/mobile/lib/features/search/search_provider.dart b/mobile/lib/features/search/search_provider.dart new file mode 100644 index 0000000000..07a41cf487 --- /dev/null +++ b/mobile/lib/features/search/search_provider.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; +import '../channels/channel.dart'; +import '../channels/channel_management_provider.dart'; +import '../channels/channels_provider.dart'; + +@immutable +class SearchHit { + final String eventId; + final String content; + final int kind; + final String pubkey; + final String? channelId; + final String? channelName; + final int createdAt; + final double score; + + const SearchHit({ + required this.eventId, + required this.content, + required this.kind, + required this.pubkey, + this.channelId, + this.channelName, + required this.createdAt, + required this.score, + }); + + factory SearchHit.fromJson(Map json) => SearchHit( + eventId: json['event_id'] as String, + content: json['content'] as String? ?? '', + kind: json['kind'] as int? ?? 9, + pubkey: json['pubkey'] as String, + channelId: json['channel_id'] as String?, + channelName: json['channel_name'] as String?, + createdAt: json['created_at'] as int? ?? 0, + score: (json['score'] as num?)?.toDouble() ?? 0.0, + ); +} + +@immutable +class SearchState { + final String query; + final List messageResults; + final List userResults; + final List channelResults; + final bool isLoading; + final String? error; + + const SearchState({ + this.query = '', + this.messageResults = const [], + this.userResults = const [], + this.channelResults = const [], + this.isLoading = false, + this.error, + }); + + const SearchState.initial() + : query = '', + messageResults = const [], + userResults = const [], + channelResults = const [], + isLoading = false, + error = null; + + SearchState copyWith({ + String? query, + List? messageResults, + List? userResults, + List? channelResults, + bool? isLoading, + String? error, + }) => SearchState( + query: query ?? this.query, + messageResults: messageResults ?? this.messageResults, + userResults: userResults ?? this.userResults, + channelResults: channelResults ?? this.channelResults, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); +} + +class SearchNotifier extends Notifier { + Timer? _debounce; + + @override + SearchState build() { + ref.onDispose(() { + _debounce?.cancel(); + }); + return const SearchState.initial(); + } + + void search(String query) { + _debounce?.cancel(); + + final trimmed = query.trim(); + if (trimmed.isEmpty) { + state = const SearchState.initial(); + return; + } + + state = SearchState(query: trimmed, isLoading: true); + + _debounce = Timer(const Duration(milliseconds: 300), () { + _executeSearch(trimmed); + }); + } + + Future _executeSearch(String query) async { + // If the query changed while debouncing, bail out. + if (state.query != query) return; + + // Fire all three lookups in parallel. + _searchMessages(query); + _searchUsers(query); + _searchChannels(query); + } + + Future _searchMessages(String query) async { + try { + final client = ref.read(relayClientProvider); + final json = + await client.get( + '/api/search', + queryParams: {'q': query, 'limit': '20'}, + ) + as Map; + + final hits = (json['hits'] as List? ?? []) + .cast>() + .map(SearchHit.fromJson) + .toList(); + + if (state.query != query) return; + state = state.copyWith(messageResults: hits, isLoading: false); + } catch (e) { + if (state.query != query) return; + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future _searchUsers(String query) async { + try { + final users = await ref + .read(channelActionsProvider) + .searchUsers(query, limit: 8); + + if (state.query != query) return; + state = state.copyWith( + userResults: users, + isLoading: state.messageResults.isEmpty && state.channelResults.isEmpty, + ); + } catch (_) { + // User search failure is non-critical — keep existing results. + } + } + + void _searchChannels(String query) { + final channels = ref.read(channelsProvider).value ?? []; + final lowerQuery = query.toLowerCase(); + final matches = channels.where((c) { + if (c.isDm) return false; + return c.name.toLowerCase().contains(lowerQuery) || + c.description.toLowerCase().contains(lowerQuery); + }).toList(); + + if (state.query != query) return; + state = state.copyWith( + channelResults: matches, + isLoading: state.messageResults.isEmpty && state.userResults.isEmpty, + ); + } + + void clear() { + _debounce?.cancel(); + state = const SearchState.initial(); + } +} + +final searchProvider = NotifierProvider( + SearchNotifier.new, +); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 85cb67ffc1..5c1955b2c9 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -79,7 +79,7 @@ void main() { expect(find.text('CHANNELS'), findsOneWidget); expect(find.text('FORUMS'), findsOneWidget); expect(find.text('DMS'), findsOneWidget); - expect(find.text('Search'), findsOneWidget); + expect(find.text('\u{1F331} Sprout'), findsOneWidget); expect(find.byTooltip('Create or start conversation'), findsOneWidget); }); @@ -122,15 +122,10 @@ void main() { ); await tester.pumpAndSettle(); + // Unjoined and archived channels should not appear in the main list. expect(find.text('general'), findsOneWidget); expect(find.text('open-stream'), findsNothing); expect(find.text('archived-stream'), findsNothing); - - await tester.tap(find.text('Search')); - await tester.pumpAndSettle(); - - expect(find.text('open-stream'), findsOneWidget); - expect(find.text('archived-stream'), findsOneWidget); }); testWidgets('shows empty state when no channels', (tester) async { diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 16e4007c5c..50658d88d9 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -226,8 +226,10 @@ void main() { ), ); - final allText = _allRichText(tester); - expect(allText, contains('https://example.com')); + // The URL text should be rendered and tappable. + expect(find.text('https://example.com'), findsOneWidget); + final urlWidget = tester.widget(find.text('https://example.com')); + expect(urlWidget.style?.decoration, TextDecoration.underline); }); }); From a05565e68738e04e5978754202572915da2f3ef2 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Apr 2026 17:51:28 -0600 Subject: [PATCH 009/120] feat: improve desktop app update UX (#401) Co-authored-by: Claude Opus 4.6 --- desktop/src/app/AppShell.tsx | 2 + .../src/features/settings/UpdateIndicator.tsx | 75 ++++++++++------ .../features/settings/hooks/use-updater.ts | 89 +++++++++++-------- 3 files changed, 98 insertions(+), 68 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index a32fe0e437..26facfc3dc 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -447,6 +447,8 @@ export function AppShell() { > +
+
handleOpenSettings("updates")} /> diff --git a/desktop/src/features/settings/UpdateIndicator.tsx b/desktop/src/features/settings/UpdateIndicator.tsx index 15697729b9..7c9590053a 100644 --- a/desktop/src/features/settings/UpdateIndicator.tsx +++ b/desktop/src/features/settings/UpdateIndicator.tsx @@ -3,9 +3,35 @@ import { Download, RefreshCw } from "lucide-react"; import { Button } from "@/shared/ui/button"; import { useUpdaterContext } from "./hooks/UpdaterProvider"; +import type { UpdateStatus } from "./hooks/use-updater"; const indicatorButtonClass = - "relative h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"; + "relative text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"; + +const iconClass = "h-3.5 w-3.5"; + +const variants: Record< + "available" | "ready", + { Icon: typeof Download; label: string; badgeColor: string } +> = { + available: { + Icon: Download, + label: "Update available", + badgeColor: "bg-primary", + }, + ready: { + Icon: RefreshCw, + label: "Restart to update", + badgeColor: "bg-emerald-500", + }, +}; + +function getVariant(state: UpdateStatus["state"]) { + if (state === "available" || state === "ready") { + return variants[state]; + } + return null; +} export function UpdateIndicator({ onOpenUpdates, @@ -13,36 +39,27 @@ export function UpdateIndicator({ onOpenUpdates: () => void; }) { const { status } = useUpdaterContext(); + const variant = getVariant(status.state); - if (status.state === "available") { - return ( - - ); + if (!variant) { + return null; } - if (status.state === "ready") { - return ( - - ); - } + const { Icon, label, badgeColor } = variant; - return null; + return ( + + ); } diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts index 9e728ddcf5..7e27986e46 100644 --- a/desktop/src/features/settings/hooks/use-updater.ts +++ b/desktop/src/features/settings/hooks/use-updater.ts @@ -13,14 +13,51 @@ export type UpdateStatus = | { state: "ready" } | { state: "error"; message: string }; +const TOAST_ID = "update-available"; + +function toErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isUpdaterUnavailable(message: string): boolean { + return ( + message.includes("plugin updater not found") || + message.includes("not initialized") + ); +} + export function useUpdater() { const [status, setStatus] = useState({ state: "idle" }); const updateRef = useRef(null); const closeUpdate = useCallback(async () => { - if (updateRef.current) { - await updateRef.current.close(); + const current = updateRef.current; + if (current) { updateRef.current = null; + await current.close(); + } + }, []); + + const downloadAndInstall = useCallback(async () => { + try { + const update = updateRef.current; + if (!update) { + return; + } + + toast.dismiss(TOAST_ID); + setStatus({ state: "downloading" }); + + await update.downloadAndInstall((event) => { + if (event.event === "Finished") { + setStatus({ state: "installing" }); + } + }); + + updateRef.current = null; + setStatus({ state: "ready" }); + } catch (err) { + setStatus({ state: "error", message: toErrorMessage(err) }); } }, []); @@ -34,58 +71,32 @@ export function useUpdater() { updateRef.current = update; setStatus({ state: "available", version: update.version }); toast("Update Available", { - id: "update-available", + id: TOAST_ID, description: `Version ${update.version} is ready to download.`, + duration: Infinity, + action: { + label: "Download & install", + onClick: () => downloadAndInstall(), + }, }); } else { setStatus({ state: "up-to-date" }); } } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if ( - msg.includes("plugin updater not found") || - msg.includes("not initialized") - ) { + const message = toErrorMessage(err); + if (isUpdaterUnavailable(message)) { setStatus({ state: "idle" }); return; } - setStatus({ state: "error", message: msg }); + setStatus({ state: "error", message }); } - }, [closeUpdate]); - - const downloadAndInstall = useCallback(async () => { - try { - const update = updateRef.current; - if (!update) { - setStatus({ state: "up-to-date" }); - return; - } - - setStatus({ state: "downloading" }); - - await update.downloadAndInstall((event) => { - if (event.event === "Finished") { - setStatus({ state: "installing" }); - } - }); - - setStatus({ state: "ready" }); - } catch (err) { - setStatus({ - state: "error", - message: err instanceof Error ? err.message : String(err), - }); - } - }, []); + }, [closeUpdate, downloadAndInstall]); const handleRelaunch = useCallback(async () => { try { await relaunch(); } catch (err) { - setStatus({ - state: "error", - message: err instanceof Error ? err.message : String(err), - }); + setStatus({ state: "error", message: toErrorMessage(err) }); } }, []); From 4fc720cc0d150f24e673f4f48d2e134fa111730c Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Apr 2026 20:09:14 -0600 Subject: [PATCH 010/120] feat(mobile): frosted glass navbar + channel detail polish (#404) Co-authored-by: Claude Opus 4.6 --- .../lib/features/activity/activity_page.dart | 13 +- .../channels/channel_detail_page.dart | 79 +++++++---- .../lib/features/channels/channels_page.dart | 125 +++++++++++++----- .../features/channels/thread_detail_page.dart | 14 +- .../lib/features/forum/forum_posts_view.dart | 26 +++- .../lib/features/forum/forum_thread_page.dart | 29 ++-- mobile/lib/features/search/search_page.dart | 9 +- .../lib/features/settings/settings_page.dart | 13 +- .../features/settings/theme_picker_page.dart | 7 +- mobile/lib/shared/theme/app_theme.dart | 7 +- .../lib/shared/widgets/frosted_app_bar.dart | 111 ++++++++++++++++ .../lib/shared/widgets/frosted_scaffold.dart | 40 ++++++ .../features/activity/activity_page_test.dart | 5 +- .../channels/channel_detail_page_test.dart | 4 +- .../features/channels/channels_page_test.dart | 7 +- .../features/channels/compose_bar_test.dart | 2 +- .../channels/message_content_test.dart | 2 +- .../features/forum/forum_widgets_test.dart | 6 +- mobile/test/helpers/widget_helpers.dart | 2 +- 19 files changed, 391 insertions(+), 110 deletions(-) create mode 100644 mobile/lib/shared/widgets/frosted_app_bar.dart create mode 100644 mobile/lib/shared/widgets/frosted_scaffold.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index c3f46d65fb..88d84bd1f6 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channels_provider.dart'; @@ -83,7 +85,16 @@ class ActivityPage extends HookConsumerWidget { body = const _LoadingSkeleton(); } - return Scaffold(body: SafeArea(child: body)); + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Activity')), + body: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: body, + ), + ), + ); } List _filteredItems(HomeFeedResponse feed, _Filter filter) { diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index c6cde15f23..c26615a358 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -7,6 +7,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/profile_provider.dart'; import '../profile/user_cache_provider.dart'; @@ -92,8 +94,8 @@ class ChannelDetailPage extends HookConsumerWidget { return null; }, [channel.id]); - return Scaffold( - appBar: AppBar( + return FrostedScaffold( + appBar: FrostedAppBar( title: resolvedChannel.isDm ? _DmAppBarTitle( channel: resolvedChannel, @@ -108,11 +110,28 @@ class ChannelDetailPage extends HookConsumerWidget { ), const SizedBox(width: Grid.half), Expanded( - child: Text( - resolvedChannel.displayLabel( - currentPubkey: currentPubkey, - ), - overflow: TextOverflow.ellipsis, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + resolvedChannel.displayLabel( + currentPubkey: currentPubkey, + ), + overflow: TextOverflow.ellipsis, + ), + if (resolvedChannel.isStream) + Text( + resolvedChannel.description.isNotEmpty + ? resolvedChannel.description + : '${resolvedChannel.memberCount} member${resolvedChannel.memberCount == 1 ? '' : 's'}', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ], @@ -153,9 +172,6 @@ class ChannelDetailPage extends HookConsumerWidget { ), body: Column( children: [ - _DetailConnectionBanner( - status: ref.watch(relaySessionProvider).status, - ), Expanded( child: resolvedChannel.isForum ? ForumPostsView( @@ -163,13 +179,22 @@ class ChannelDetailPage extends HookConsumerWidget { currentPubkey: currentPubkey, ) : messagesState.when( - loading: () => - const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text( - 'Failed to load messages', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, + loading: () => Padding( + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + ), + child: const Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + ), + child: Center( + child: Text( + 'Failed to load messages', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), ), ), ), @@ -190,6 +215,9 @@ class ChannelDetailPage extends HookConsumerWidget { }, ), ), + _DetailConnectionBanner( + status: ref.watch(relaySessionProvider).status, + ), if (!resolvedChannel.isForum && typingEntries.isNotEmpty) _TypingIndicator(entries: typingEntries), if (!resolvedChannel.isForum && @@ -304,9 +332,11 @@ class _MessageList extends HookConsumerWidget { return ListView.builder( controller: scrollController, reverse: true, - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.xxs, + padding: EdgeInsets.only( + left: Grid.xs, + right: Grid.xs, + top: frostedAppBarHeight(context), + bottom: Grid.xxs, ), itemCount: entries.length + (isLoadingOlder.value ? 1 : 0), itemBuilder: (context, index) { @@ -1065,9 +1095,7 @@ class _DmAppBarTitle extends ConsumerWidget { }, shape: BoxShape.circle, border: Border.all( - color: - context.theme.appBarTheme.backgroundColor ?? - context.theme.scaffoldBackgroundColor, + color: context.colors.surface, width: 1.5, ), ), @@ -1080,6 +1108,7 @@ class _DmAppBarTitle extends ConsumerWidget { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Text( channel.displayLabel(currentPubkey: currentPubkey), @@ -1089,8 +1118,8 @@ class _DmAppBarTitle extends ConsumerWidget { ), Text( presenceLabel, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, ), ), ], diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 94396fae7b..3dccffbdf3 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -8,6 +8,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/profile_avatar.dart'; import '../profile/profile_provider.dart'; import '../settings/settings_page.dart'; @@ -20,6 +22,10 @@ import 'channels_provider.dart'; enum _QuickAction { createChannel, createForum, newDm } +/// Height of the [_ConnectionBanner]: vertical padding (Grid.quarter + 2) × 2 +/// plus the ~16px row content (12px spinner / labelSmall text). +const double _kBannerHeight = 24.0; + class ChannelsPage extends HookConsumerWidget { const ChannelsPage({super.key}); @@ -90,15 +96,13 @@ class ChannelsPage extends HookConsumerWidget { } } - return Scaffold( - appBar: AppBar( - titleSpacing: Grid.xs, - title: Text( - '\u{1F331} Sprout', - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + return FrostedScaffold( + appBar: FrostedAppBar( + leading: const Padding( + padding: EdgeInsets.only(left: Grid.xs), + child: Text('\u{1F331}', style: TextStyle(fontSize: 28)), ), + title: const SizedBox.shrink(), actions: [ ProfileAvatar( onTap: () => Navigator.of(context).push( @@ -114,35 +118,91 @@ class ChannelsPage extends HookConsumerWidget { shape: const CircleBorder(), child: const Icon(LucideIcons.plus), ), - body: channels != null - ? Column( - children: [ - _ConnectionBanner(status: sessionState.status), - Expanded( - child: _ChannelsList( - channels: channels, - currentPubkey: currentPubkey, - onSelectChannel: openChannel, - ), - ), - ], - ) - : channelsAsync.hasError - ? _ErrorView( - error: channelsAsync.error!, - onRetry: () => ref.read(channelsProvider.notifier).refresh(), - ) - : const _ConnectionBanner(status: SessionStatus.connecting), + body: _ChannelsBody( + channels: channels, + channelsAsync: channelsAsync, + sessionStatus: sessionState.status, + currentPubkey: currentPubkey, + onRefresh: () => ref.read(channelsProvider.notifier).refresh(), + onSelectChannel: openChannel, + ), ); } } -class _ChannelsList extends HookConsumerWidget { +class _ChannelsBody extends StatelessWidget { + final List? channels; + final AsyncValue> channelsAsync; + final SessionStatus sessionStatus; + final String? currentPubkey; + final Future Function() onRefresh; + final Future Function(Channel channel) onSelectChannel; + + const _ChannelsBody({ + required this.channels, + required this.channelsAsync, + required this.sessionStatus, + required this.currentPubkey, + required this.onRefresh, + required this.onSelectChannel, + }); + + @override + Widget build(BuildContext context) { + final barHeight = frostedAppBarHeight(context); + + if (channels != null) { + return Stack( + children: [ + RefreshIndicator( + onRefresh: onRefresh, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: barHeight)), + // Extra space for the connection banner when visible. + if (sessionStatus != SessionStatus.connected && + sessionStatus != SessionStatus.disconnected) + const SliverToBoxAdapter( + child: SizedBox(height: _kBannerHeight), + ), + _SliverChannelsList( + channels: channels!, + currentPubkey: currentPubkey, + onSelectChannel: onSelectChannel, + ), + ], + ), + ), + Positioned( + top: barHeight, + left: 0, + right: 0, + child: _ConnectionBanner(status: sessionStatus), + ), + ], + ); + } + + if (channelsAsync.hasError) { + return Padding( + padding: EdgeInsets.only(top: barHeight), + child: _ErrorView(error: channelsAsync.error!, onRetry: onRefresh), + ); + } + + return Padding( + padding: EdgeInsets.only(top: barHeight), + child: const _ConnectionBanner(status: SessionStatus.connecting), + ); + } +} + +class _SliverChannelsList extends HookConsumerWidget { final List channels; final String? currentPubkey; final Future Function(Channel channel) onSelectChannel; - const _ChannelsList({ + const _SliverChannelsList({ required this.channels, required this.currentPubkey, required this.onSelectChannel, @@ -167,10 +227,9 @@ class _ChannelsList extends HookConsumerWidget { final forumsExpanded = useState(true); final dmsExpanded = useState(true); - return RefreshIndicator( - onRefresh: () => ref.read(channelsProvider.notifier).refresh(), - child: ListView( - padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80), + return SliverPadding( + padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80), + sliver: SliverList.list( children: [ if (visibleChannels.isEmpty) const _EmptyState() diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fd5550c3a1..818722483f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_management_provider.dart'; @@ -86,15 +88,17 @@ class ThreadDetailPage extends HookConsumerWidget { } }); - return Scaffold( - appBar: AppBar(title: const Text('Thread')), + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Thread')), body: Column( children: [ Expanded( child: ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.xxs, + padding: EdgeInsets.only( + left: Grid.xs, + right: Grid.xs, + top: frostedAppBarHeight(context), + bottom: Grid.xxs, ), itemCount: replies.length + 1, // +1 for thread head itemBuilder: (context, index) { diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index 4f13c73257..708c5ea31e 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; import '../channels/channel.dart'; import '../channels/compose_bar.dart'; import 'forum_models.dart'; @@ -57,12 +58,18 @@ class ForumPostsView extends HookConsumerWidget { ) : null, body: postsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text( - 'Failed to load posts', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, + loading: () => Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: const Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: Center( + child: Text( + 'Failed to load posts', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), ), ), ), @@ -80,7 +87,12 @@ class ForumPostsView extends HookConsumerWidget { await ref.read(forumPostsProvider(channel.id).future); }, child: ListView.separated( - padding: const EdgeInsets.all(Grid.xs), + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + left: Grid.xs, + right: Grid.xs, + bottom: Grid.xs, + ), itemCount: posts.length, separatorBuilder: (_, _) => const SizedBox(height: Grid.xxs), diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index cadaa45f02..473b4341c6 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -5,6 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/compose_bar.dart'; import '../channels/message_content.dart'; import '../profile/user_cache_provider.dart'; @@ -55,8 +57,8 @@ class ForumThreadPage extends HookConsumerWidget { .value ?? false; - return Scaffold( - appBar: AppBar( + return FrostedScaffold( + appBar: FrostedAppBar( title: const Text('Thread'), actions: [ if (isOwnPost) @@ -69,12 +71,18 @@ class ForumThreadPage extends HookConsumerWidget { ], ), body: threadAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center( - child: Text( - 'Failed to load thread', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, + loading: () => Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: const Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: EdgeInsets.only(top: frostedAppBarHeight(context)), + child: Center( + child: Text( + 'Failed to load thread', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), ), ), ), @@ -208,7 +216,10 @@ class _ThreadContent extends HookConsumerWidget { children: [ Expanded( child: ListView( - padding: const EdgeInsets.only(bottom: Grid.xs), + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + bottom: Grid.xs, + ), children: [ // Original post _OriginalPost(post: post), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 7fdfcdcc4b..d5243649cb 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; @@ -34,9 +36,9 @@ class SearchPage extends HookConsumerWidget { () => textController.text.isNotEmpty, ); - return Scaffold( - appBar: AppBar( - titleSpacing: Grid.xs, + return FrostedScaffold( + resizeToAvoidBottomInset: true, + appBar: FrostedAppBar( title: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: Grid.half), @@ -77,6 +79,7 @@ class SearchPage extends HookConsumerWidget { ), body: Column( children: [ + SizedBox(height: frostedAppBarHeight(context)), _FilterChips( active: activeFilter.value, onChanged: (f) => activeFilter.value = f, diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 10e3b4d4e4..7a0fac434b 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -7,6 +7,8 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; import 'theme_picker_page.dart'; class SettingsPage extends HookConsumerWidget { @@ -18,10 +20,15 @@ class SettingsPage extends HookConsumerWidget { final selectedAccent = ref.watch(accentProvider); final selectedScheme = ref.watch(schemeProvider); - return Scaffold( - appBar: AppBar(title: const Text('Settings')), + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Settings')), body: ListView( - padding: const EdgeInsets.all(Grid.xs), + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + left: Grid.xs, + right: Grid.xs, + bottom: Grid.xs, + ), children: [ // Connection info Text('Connection', style: context.textTheme.titleMedium), diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index 755af24e81..e1bf81cf64 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; class ThemePickerPage extends HookConsumerWidget { const ThemePickerPage({super.key}); @@ -64,10 +66,11 @@ class ThemePickerPage extends HookConsumerWidget { return null; }, const []); - return Scaffold( - appBar: AppBar(title: const Text('Color Scheme')), + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Color Scheme')), body: Column( children: [ + SizedBox(height: frostedAppBarHeight(context)), // Always-visible search bar Padding( padding: const EdgeInsets.symmetric( diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index c9d8d5951e..05579deb16 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -53,10 +53,6 @@ class AppTheme { ); } - /// Legacy getters for backwards compatibility. - static ThemeData get lightTheme => light(); - static ThemeData get darkTheme => dark(); - static ThemeData _buildTheme({ required ColorScheme scheme, required AppColors appColors, @@ -71,10 +67,11 @@ class AppTheme { fontFamily: 'Geist', textTheme: textTheme, appBarTheme: AppBarTheme( - backgroundColor: scheme.surface, + backgroundColor: Colors.transparent, foregroundColor: scheme.onSurface, surfaceTintColor: Colors.transparent, elevation: 0, + scrolledUnderElevation: 0, titleTextStyle: textTheme.titleMedium?.copyWith( color: scheme.onSurface, ), diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart new file mode 100644 index 0000000000..a6dd0cf11d --- /dev/null +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -0,0 +1,111 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../theme/theme.dart'; + +/// Height of the frosted app bar content area below the safe area. +const _kBarContentHeight = Grid.xxs + 32 + Grid.xxs; // 48 + +/// Returns the total height of the [FrostedAppBar] including safe area padding. +/// +/// Use this to add top spacing to body content so it starts below the bar. +double frostedAppBarHeight(BuildContext context) { + return MediaQuery.paddingOf(context).top + _kBarContentHeight; +} + +/// A frosted-glass floating app bar designed to sit inside a [Stack]. +/// +/// Renders as a [Positioned] widget pinned to the top of its parent Stack. +/// Content scrolls underneath with a translucent backdrop blur effect. +class FrostedAppBar extends StatelessWidget { + /// Widget displayed on the leading (left) side. If null and the navigator + /// can pop, a back button is shown automatically. + final Widget? leading; + + /// Widget displayed in the center/title area. + final Widget? title; + + /// Widgets displayed on the trailing (right) side. + final List actions; + + const FrostedAppBar({ + super.key, + this.leading, + this.title, + this.actions = const [], + }); + + @override + Widget build(BuildContext context) { + final topPadding = MediaQuery.paddingOf(context).top; + final canPop = Navigator.canPop(context); + + final effectiveLeading = + leading ?? + (canPop + ? SizedBox( + width: 48, + height: 48, + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(LucideIcons.chevronLeft), + tooltip: 'Back', + ), + ) + : null); + + return Positioned( + top: 0, + left: 0, + right: 0, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: EdgeInsets.only(top: topPadding), + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.5), + border: Border( + bottom: BorderSide( + color: context.colors.outlineVariant.withValues(alpha: 0.3), + ), + ), + ), + child: SizedBox( + height: _kBarContentHeight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), + child: Row( + children: [ + ?effectiveLeading, + if (title != null) + Expanded( + child: Padding( + padding: EdgeInsets.only( + left: effectiveLeading != null ? 0 : Grid.xs, + ), + child: DefaultTextStyle.merge( + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + child: title!, + ), + ), + ) + else + const Spacer(), + ...actions, + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart new file mode 100644 index 0000000000..6b7e39fd75 --- /dev/null +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import 'frosted_app_bar.dart'; + +/// A convenience [Scaffold] that overlays a [FrostedAppBar] on top of its body. +/// +/// The body is rendered full-bleed inside a [Stack] with the frosted app bar +/// floating above it. The body is responsible for adding its own top spacing +/// using [frostedAppBarHeight] so content starts below the bar. +class FrostedScaffold extends StatelessWidget { + /// The frosted app bar displayed at the top of the screen. + final FrostedAppBar appBar; + + /// The primary content of the scaffold. Must handle its own top spacing + /// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding. + final Widget body; + + /// Optional floating action button, passed through to [Scaffold]. + final Widget? floatingActionButton; + + /// Whether the body should resize when the on-screen keyboard appears. + final bool? resizeToAvoidBottomInset; + + const FrostedScaffold({ + super.key, + required this.appBar, + required this.body, + this.floatingActionButton, + this.resizeToAvoidBottomInset, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + floatingActionButton: floatingActionButton, + body: Stack(children: [body, appBar]), + ); + } +} diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index c4aa7c3113..bb4100cd26 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -17,10 +17,7 @@ void main() { Widget buildTestable({required List overrides}) { return ProviderScope( overrides: overrides, - child: MaterialApp( - theme: AppTheme.lightTheme, - home: const ActivityPage(), - ), + child: MaterialApp(theme: AppTheme.light(), home: const ActivityPage()), ); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index ce449ee0d7..6834679fe9 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -156,7 +156,7 @@ Widget _buildTestable({ ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), navigatorObservers: navigatorObservers, home: ChannelDetailPage(channel: resolvedChannel), ), @@ -949,7 +949,7 @@ void main() { ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: ChannelDetailPage(channel: _testChannel), ), ), diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 5c1955b2c9..ce77e8a84b 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -18,10 +18,7 @@ void main() { presenceProvider.overrideWith(() => _FakePresenceNotifier()), ...overrides, ], - child: MaterialApp( - theme: AppTheme.lightTheme, - home: const ChannelsPage(), - ), + child: MaterialApp(theme: AppTheme.light(), home: const ChannelsPage()), ); } @@ -79,7 +76,7 @@ void main() { expect(find.text('CHANNELS'), findsOneWidget); expect(find.text('FORUMS'), findsOneWidget); expect(find.text('DMS'), findsOneWidget); - expect(find.text('\u{1F331} Sprout'), findsOneWidget); + expect(find.text('\u{1F331}'), findsOneWidget); expect(find.byTooltip('Create or start conversation'), findsOneWidget); }); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index ca328dc9c0..65d8e16cbf 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -120,7 +120,7 @@ Widget _buildComposeBar({ ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold( body: SafeArea( child: ComposeBar(channelId: 'channel-1', onSend: onSend), diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 50658d88d9..4f8a3a058f 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -7,7 +7,7 @@ import 'package:sprout_mobile/shared/theme/theme.dart'; Widget _testable(Widget child) { return MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold(body: child), ); } diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 44948e121c..464c2f7134 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -74,7 +74,7 @@ Widget _buildPostCard({ userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold( body: ForumPostCard( post: post, @@ -103,7 +103,7 @@ Widget _buildPostsView({ ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold( body: ForumPostsView(channel: ch, currentPubkey: 'self'), ), @@ -132,7 +132,7 @@ Widget _buildThreadPage({ ), ], child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: ForumThreadPage( channelId: _channelId, postEventId: postEventId, diff --git a/mobile/test/helpers/widget_helpers.dart b/mobile/test/helpers/widget_helpers.dart index f51acf7517..543f5e9dd5 100644 --- a/mobile/test/helpers/widget_helpers.dart +++ b/mobile/test/helpers/widget_helpers.dart @@ -11,7 +11,7 @@ class WidgetHelpers { return ProviderScope( overrides: overrides, child: MaterialApp( - theme: AppTheme.lightTheme, + theme: AppTheme.light(), home: Scaffold(body: child), ), ); From 16214cfa293eaf316874dcc8efbe8ca78633eba2 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:19:00 -0400 Subject: [PATCH 011/120] NIP-OA: Owner Attestation for agent key provenance (#398) --- NIP-OA.md | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 NIP-OA.md diff --git a/NIP-OA.md b/NIP-OA.md new file mode 100644 index 0000000000..d148841440 --- /dev/null +++ b/NIP-OA.md @@ -0,0 +1,150 @@ +NIP-OA +====== + +Owner Attestation +----------------- + +`draft` `optional` + +This NIP defines an optional `auth` tag by which an owner key authorizes an agent key to publish events under the agent's own authorship. + +## Motivation + +NIP-26 defines a sound Schnorr-signature mechanism for proving that one key authorized another key subject to explicit conditions. +NIP-26 assigns the event to the delegator semantically, and that semantic MUST NOT be reused for agent provenance. +This NIP reuses NIP-26 as prior art for the credential format and signing flow and defines the credential as authorization evidence only. +A valid `auth` tag is a reusable capability: the same tag MAY appear on multiple events by the same agent key provided each event satisfies the conditions. +An event that includes a valid `auth` tag remains authored by `event.pubkey`. + +## Non-Goals + +This NIP does not define impersonation. +This NIP does not define key derivation. +This NIP does not define relay-side author rewriting. + +## The Tag + +Events MAY include zero or one `auth` tag. +If an event contains more than one `auth` tag, verifiers and clients MUST treat the event as having no valid `auth` tag. +Agents MAY publish events without an `auth` tag. +Agents that require provenance to be respected by verifiers SHOULD include a valid `auth` tag. +The `auth` tag MUST contain exactly four elements: + +```json +["auth", "", "", ""] +``` + +- ``: 64-character lowercase hex encoding of the owner's 32-byte x-only public key as defined in BIP-340. +- ``: UTF-8 string containing zero or more clauses separated by `&`. +- ``: 128-character lowercase hex encoding of the 64-byte Schnorr signature. + +An `auth` tag with fewer or more than four elements is malformed and MUST be rejected. + +The signing preimage is the UTF-8 byte sequence of `nostr:agent-auth:` || `event.pubkey` || `:` || ``. +The domain separator string is exactly `nostr:agent-auth:`. +The signed message is `SHA256(preimage)`. +The owner MUST produce `` as a BIP-340 Schnorr signature over the signed message with the owner's secret key. + +Each clause in `` MUST be one of: + +- `kind=` +- `created_at` +- `created_at>unix-timestamp` + +The `` string MUST be either the empty string or a non-empty ASCII string of the form `clause` or `clause&clause&...`. +Whitespace is not permitted anywhere in ``. +Empty clauses are invalid. +Clause names and operators are case-sensitive and MUST appear exactly as specified above. +A trailing `&`, a leading `&`, or `&&` is malformed and MUST be rejected. + +The decimal encoding in a clause MUST be canonical base-10 with no leading zeroes except `0`. +Values in `kind=` clauses MUST be in the range `0` to `65535`. +Values in `created_at<` and `created_at>` clauses MUST be in the range `0` to `4294967295`. +An empty `` string imposes no additional event constraints. +Verifiers MUST evaluate every clause. +Verifiers MUST reject an `auth` tag that contains an unsupported clause, malformed decimal encoding, invalid public key, or invalid signature. +If `` equals `event.pubkey`, the `auth` tag is invalid and MUST be rejected. +An event satisfies `kind=` if and only if `event.kind = n`. +An event satisfies `created_at` if and only if `event.created_at < t`. +An event satisfies `created_at>t` if and only if `event.created_at > t`. +Clause order is part of the signed preimage and verifiers MUST use the exact `` string from the tag when verifying the signature. +Implementers MUST NOT reorder, deduplicate, normalize, or canonicalize the `` string before computing the preimage. +Verifiers MUST NOT reinterpret a valid `auth` tag as an identity override. + +## Relay Behavior + +Relays require no changes to support this NIP. +Relays MAY store, index, and forward the `auth` tag as any other event tag. +Relays MUST NOT rewrite event authorship on the basis of an `auth` tag. +Relays MUST NOT be required to verify an `auth` tag. + +## Client Behavior + +Clients MUST validate the event according to the core Nostr event rules, including that `id` and `sig` are valid for `event.pubkey`, before treating an `auth` tag as verified provenance. +A valid `auth` tag on an otherwise invalid event does not establish provenance. +Clients that process an `auth` tag SHOULD verify the owner signature and the conditions against the event. +Clients MUST treat the agent key in `event.pubkey` as the only author key for the event. +Clients MUST NOT display the owner key as the author of the event solely because of a valid `auth` tag. +Clients MUST NOT merge the event into owner-authored timelines, author indexes, or pubkey-filtered results for the owner solely because of a valid `auth` tag. +Clients SHOULD display provenance only when the `auth` tag verifies successfully, and any such display MUST be clearly distinguished from authorship (for example, "authorized by \"). +Clients SHOULD ignore an invalid `auth` tag for protocol purposes. +Clients MUST NOT display owner provenance when the `auth` tag is invalid. + +## Security Properties + +The owner key and the agent key are independent keys. +Compromise of the agent secret key MUST NOT imply compromise of the owner secret key. +Compromise of the agent secret key permits only signatures by the compromised agent key. +Owners SHOULD bound authorization lifetime with a `created_at<...` clause when revocation latency matters. +Owners MAY revoke future authorization by refusing to issue new `auth` tags. +A `created_at<...` or `created_at>...` clause constrains the event's self-declared `created_at` field, which the agent controls. +These clauses do not enforce wall-clock expiry; a misbehaving agent can backdate `event.created_at` to satisfy an expired window. +Relays or clients that require wall-clock freshness MUST enforce it independently of this NIP. +Verification MUST NOT depend on the verifier's local clock, receipt time, or relay storage time. + +## Privacy Considerations + +Including an `auth` tag intentionally links the owner key and the agent key. +Verifiers MAY correlate all events that reuse the same owner key and agent key pair. +Agents that omit the `auth` tag avoid this disclosure but also omit the provenance claim defined by this NIP. + +## Test Vectors + +The following vector uses `owner_secret = 0000000000000000000000000000000000000000000000000000000000000001`. +The corresponding `owner_pubkey` is `79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798`. +The following vector uses `agent_secret = 0000000000000000000000000000000000000000000000000000000000000002`. +The corresponding `agent_pubkey` is `c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5`. + +```text +conditions=kind=1&created_at<1713957000 +preimage=nostr:agent-auth:c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5:kind=1&created_at<1713957000 +sha256(preimage)=08cdecd55af4c28d3801fd69615dcf5cc04fab3bc134b38a840bf157197069a6 +auth_sig=8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369 +tag=["auth","79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798","kind=1&created_at<1713957000","8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369"] +tag-bytes-hex=5b2261757468222c2237396265363637656639646362626163353561303632393563653837306230373032396266636462326463653238643935396632383135623136663831373938222c226b696e643d3126637265617465645f61743c31373133393537303030222c223862376466323537356361663061313038333734663834373137323262323333633533663966663832376138623066393138363139363663336239646435636232653138396561653966343964373231383736373463326635626432343431343565313066663836633966323537666665363561316565356631303862333639225d +``` + +## Signed Event Example + +```json +{ + "id": "d892a65e7677e0554ebb70ee16deeb6a0727dba46450fb4bc001291d7bff971b", + "pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "created_at": 1713956400, + "kind": 1, + "tags": [["auth", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", "kind=1&created_at<1713957000", "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369"]], + "content": "owner-attested agent event", + "sig": "7fd38992b70b5e9e113644e51b4c8ee2227f3bdd402b1855f8786c0600394ab3ec2621742a7bad0b0000b93d4d1ae6e39525f286a3c1029f43f46c3359a6c76f" +} +``` + +## Invalid Test Vectors + +Verifiers MUST reject each of the following: + +- An event containing two `auth` tags. +- An `auth` tag with fewer or more than four elements. +- An `auth` tag whose `` string is `kind=1&` (trailing delimiter). +- An `auth` tag whose `` string is `kind=01` (leading zero). +- An `auth` tag whose `` equals `event.pubkey` (self-attestation). +- An otherwise well-formed `auth` tag attached to an event whose Nostr `id` or `sig` is invalid. From 8abeb3d3ca43da4781d65dacd9b9ae4240f47585 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 27 Apr 2026 14:10:11 -0600 Subject: [PATCH 012/120] feat: always notify on DM messages like Slack/Discord (#405) Co-authored-by: Claude Opus 4.6 --- crates/sprout-db/src/feed.rs | 134 ++++++++---------- crates/sprout-relay/src/api/feed.rs | 17 ++- desktop/src-tauri/src/models.rs | 2 + desktop/src/app/AppShell.tsx | 35 ++++- .../channels/useLiveChannelUpdates.ts | 88 ++++++++++-- desktop/src/features/notifications/hooks.ts | 30 ++-- .../src/features/notifications/lib/feed.ts | 10 +- desktop/src/shared/api/tauri.ts | 2 + desktop/src/shared/api/types.ts | 1 + desktop/src/testing/e2eBridge.ts | 1 + 10 files changed, 216 insertions(+), 104 deletions(-) diff --git a/crates/sprout-db/src/feed.rs b/crates/sprout-db/src/feed.rs index 1ed6a702b1..f90e95df14 100644 --- a/crates/sprout-db/src/feed.rs +++ b/crates/sprout-db/src/feed.rs @@ -28,6 +28,7 @@ pub const FEED_MAX_LIMIT: i64 = 100; use chrono::{DateTime, Utc}; +use sqlx::postgres::PgRow; use sqlx::{PgPool, QueryBuilder}; use uuid::Uuid; @@ -41,6 +42,39 @@ use sprout_core::StoredEvent; use crate::error::Result; use crate::event::row_to_stored_event; +/// Column list shared by every feed subquery that aliases the `events` table as `e`. +const EVENT_COLS: &str = + "e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.received_at, e.channel_id"; + +/// Column list for queries that select directly from `events` (no table alias). +const EVENT_COLS_UNALIASED: &str = + "id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id"; + +/// Append `AND IN ($1, $2, …)` for the given channel IDs. +/// +/// No-ops when the slice is empty so callers don't need a guard. +fn push_channel_id_filter(qb: &mut QueryBuilder, col: &str, ids: &[Uuid]) { + if !ids.is_empty() { + qb.push(format!(" AND {col} IN (")); + let mut sep = qb.separated(", "); + for id in ids { + sep.push_bind(*id); + } + qb.push(")"); + } +} + +/// Convert fetched rows into `Vec`, skipping any that fail conversion. +fn collect_stored_events(rows: Vec) -> Result> { + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + if let Some(ev) = row_to_stored_event(row)? { + out.push(ev); + } + } + Ok(out) +} + /// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags). /// /// Joins against the `event_mentions` table -- Phase 2 implementation. @@ -58,44 +92,26 @@ pub async fn query_mentions( let limit = limit.min(FEED_MAX_LIMIT); let pubkey_hex = hex::encode(pubkey_bytes); - let mut qb: QueryBuilder = QueryBuilder::new( - "SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \ - e.received_at, e.channel_id \ - FROM events e \ + let mut qb: QueryBuilder = QueryBuilder::new(format!( + "SELECT {EVENT_COLS} FROM events e \ INNER JOIN event_mentions m ON e.id = m.event_id \ - WHERE m.pubkey_hex = ", - ); + WHERE m.pubkey_hex = " + )); qb.push_bind(&pubkey_hex); qb.push(" AND e.deleted_at IS NULL"); - qb.push(format!( - " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" + " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \ + {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" )); - - if !accessible_channel_ids.is_empty() { - qb.push(" AND e.channel_id IN ("); - let mut sep = qb.separated(", "); - for id in accessible_channel_ids { - sep.push_bind(*id); - } - qb.push(")"); - } - + push_channel_id_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { qb.push(" AND m.event_created_at >= ").push_bind(s); } - qb.push(" ORDER BY m.event_created_at DESC LIMIT ") .push_bind(limit); let rows = qb.build().fetch_all(pool).await?; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - if let Some(ev) = row_to_stored_event(row)? { - out.push(ev); - } - } - Ok(out) + collect_stored_events(rows) } /// Find events that require action from the given pubkey: @@ -117,44 +133,25 @@ pub async fn query_needs_action( let limit = limit.min(FEED_MAX_LIMIT); let pubkey_hex = hex::encode(pubkey_bytes); - let mut qb: QueryBuilder = QueryBuilder::new( - "SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \ - e.received_at, e.channel_id \ - FROM events e \ + let mut qb: QueryBuilder = QueryBuilder::new(format!( + "SELECT {EVENT_COLS} FROM events e \ INNER JOIN event_mentions m ON e.id = m.event_id \ - WHERE m.pubkey_hex = ", - ); + WHERE m.pubkey_hex = " + )); qb.push_bind(&pubkey_hex); qb.push(" AND e.deleted_at IS NULL"); - qb.push(format!( " AND e.kind IN ({KIND_WORKFLOW_APPROVAL_REQUESTED}, {KIND_STREAM_REMINDER})" )); - - if !accessible_channel_ids.is_empty() { - qb.push(" AND e.channel_id IN ("); - let mut sep = qb.separated(", "); - for id in accessible_channel_ids { - sep.push_bind(*id); - } - qb.push(")"); - } - + push_channel_id_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { qb.push(" AND m.event_created_at >= ").push_bind(s); } - qb.push(" ORDER BY m.event_created_at DESC LIMIT ") .push_bind(limit); let rows = qb.build().fetch_all(pool).await?; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - if let Some(ev) = row_to_stored_event(row)? { - out.push(ev); - } - } - Ok(out) + collect_stored_events(rows) } /// Find recent activity across accessible channels (for watched topics / agent activity). @@ -170,40 +167,21 @@ pub async fn query_activity( limit: i64, ) -> Result> { let limit = limit.min(FEED_MAX_LIMIT); - let mut qb: QueryBuilder = QueryBuilder::new( - "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE 1=1", - ); - - qb.push(" AND deleted_at IS NULL"); - - qb.push(format!( - " AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})" + let mut qb: QueryBuilder = QueryBuilder::new(format!( + "SELECT {EVENT_COLS_UNALIASED} FROM events WHERE deleted_at IS NULL" )); - - if !accessible_channel_ids.is_empty() { - qb.push(" AND channel_id IN ("); - let mut sep = qb.separated(", "); - for id in accessible_channel_ids { - sep.push_bind(*id); - } - qb.push(")"); - } - + qb.push(format!( + " AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, \ + {KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})" + )); + push_channel_id_filter(&mut qb, "channel_id", accessible_channel_ids); if let Some(s) = since { qb.push(" AND created_at >= ").push_bind(s); } - qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit); let rows = qb.build().fetch_all(pool).await?; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - if let Some(ev) = row_to_stored_event(row)? { - out.push(ev); - } - } - Ok(out) + collect_stored_events(rows) } // -- Tests -------------------------------------------------------------------- diff --git a/crates/sprout-relay/src/api/feed.rs b/crates/sprout-relay/src/api/feed.rs index 633f7aa6dc..c430a14deb 100644 --- a/crates/sprout-relay/src/api/feed.rs +++ b/crates/sprout-relay/src/api/feed.rs @@ -121,8 +121,14 @@ pub async fn feed_handler( tracing::warn!("feed: failed to load channel names for enrichment: {e}"); vec![] }); - let channel_name_map: HashMap = - all_channels.into_iter().map(|c| (c.id, c.name)).collect(); + let channel_name_map: HashMap = all_channels + .iter() + .map(|c| (c.id, c.name.clone())) + .collect(); + let channel_type_map: HashMap = all_channels + .into_iter() + .map(|c| (c.id, c.channel_type)) + .collect(); let to_feed_item = |event: &sprout_core::StoredEvent, category: &str| -> serde_json::Value { let channel_name = event @@ -131,6 +137,12 @@ pub async fn feed_handler( .cloned() .unwrap_or_default(); + let channel_type = event + .channel_id + .and_then(|id| channel_type_map.get(&id)) + .cloned() + .unwrap_or_default(); + let tags: Vec = event .event .tags @@ -149,6 +161,7 @@ pub async fn feed_handler( "created_at": event.event.created_at.as_u64(), "channel_id": event.channel_id.map(|id| id.to_string()), "channel_name": channel_name, + "channel_type": channel_type, "tags": tags, "category": category, }) diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index ba54c91501..9c94a96c97 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -224,6 +224,8 @@ pub struct FeedItemInfo { pub created_at: u64, pub channel_id: Option, pub channel_name: String, + #[serde(default)] + pub channel_type: Option, pub tags: Vec>, pub category: String, } diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 26facfc3dc..fd37c70852 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -23,6 +23,7 @@ import { useHomeFeedNotifications } from "@/features/notifications/hooks"; import { listenForDesktopNotificationActions, revealDesktopAppWindow, + sendDesktopNotification, setDesktopAppBadgeCount, type DesktopNotificationTarget, } from "@/features/notifications/lib/desktop"; @@ -38,7 +39,7 @@ import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { joinChannel } from "@/shared/api/tauri"; -import type { SearchHit } from "@/shared/api/types"; +import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; import { Button } from "@/shared/ui/button"; import { @@ -152,6 +153,37 @@ export function AppShell() { void homeFeedQuery.refetch(); }); + const handleDmNotification = React.useEffectEvent( + (event: RelayEvent, channel: Channel) => { + if (!notificationSettings.settings.desktopEnabled) { + return; + } + + const channelName = channel.name?.trim() || "Direct message"; + const content = event.content.trim(); + const body = + content.length > 0 + ? content.length > 140 + ? `${content.slice(0, 137).trimEnd()}...` + : content + : "New message"; + + void sendDesktopNotification({ + title: "Direct message", + body, + target: { + channelId: channel.id, + channelName, + content: event.content, + createdAt: event.created_at, + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + }, + }); + }, + ); + const channelsQuery = useChannelsQuery(); const { refetch: refetchChannels } = channelsQuery; const channels = channelsQuery.data ?? []; @@ -179,6 +211,7 @@ export function AppShell() { null, { currentPubkey: identityQuery.data?.pubkey, + onDmMessage: handleDmNotification, onLiveMention: refetchHomeFeedOnLiveMention, }, ); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index e413f6fc9b..40f23f90bd 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -11,6 +11,7 @@ import type { Channel, RelayEvent } from "@/shared/api/types"; export type UseLiveChannelUpdatesOptions = { currentPubkey?: string; + onDmMessage?: (event: RelayEvent, channel: Channel) => void; onLiveMention?: () => void; }; @@ -27,19 +28,16 @@ function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { ); } -function rememberMentionEvent( - seenMentionEventIds: Set, - eventId: string, -): boolean { - if (seenMentionEventIds.has(eventId)) { +function trackSeenEvent(seenEventIds: Set, eventId: string): boolean { + if (seenEventIds.has(eventId)) { return false; } - seenMentionEventIds.add(eventId); - if (seenMentionEventIds.size > 200) { - const oldestEventId = seenMentionEventIds.values().next().value; + seenEventIds.add(eventId); + if (seenEventIds.size > 200) { + const oldestEventId = seenEventIds.values().next().value; if (oldestEventId) { - seenMentionEventIds.delete(oldestEventId); + seenEventIds.delete(oldestEventId); } } @@ -64,6 +62,24 @@ export function useLiveChannelUpdates( ), [channels], ); + const dmChannelMap = React.useMemo( + () => + new Map( + channels + .filter((channel) => channel.channelType === "dm") + .map((channel) => [channel.id, channel]), + ), + [channels], + ); + const seenDmEventIdsRef = React.useRef(new Set()); + const dmSubscriptionStartedAtRef = React.useRef(0); + + // Reset subscription timestamp when identity changes. + React.useEffect(() => { + void normalizedCurrentPubkey; + dmSubscriptionStartedAtRef.current = 0; + }, [normalizedCurrentPubkey]); + // Effect deps use primitive keys so refetches that produce new refs with // identical contents don't churn subscriptions. The Set/array memos are // still handy for closure reads via useEffectEvent. @@ -73,9 +89,51 @@ export function useLiveChannelUpdates( [channels], ); + const handleDmEvent = React.useEffectEvent((event: RelayEvent) => { + // Suppress backlog events that predate our subscription — these are + // historical replays, not live messages. + if (event.created_at < dmSubscriptionStartedAtRef.current) { + return; + } + + const channelId = getChannelIdFromTags(event.tags); + if (!channelId) { + return; + } + + if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { + return; + } + + const dmChannel = dmChannelMap.get(channelId); + if (!dmChannel) { + return; + } + + if (!trackSeenEvent(seenDmEventIdsRef.current, event.id)) { + return; + } + + // Don't fire a notification for the channel the user is already viewing. + if (channelId === activeChannelId) { + return; + } + + options.onDmMessage?.(event, dmChannel); + }); + const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { const channelId = getChannelIdFromTags(event.tags); - if (!channelId || channelId === activeChannelId) { + if (!channelId) { + return; + } + + // Track DM events even for the active channel so the dedup set stays + // current. The handler itself skips firing the notification callback + // when the user is already viewing the DM. + handleDmEvent(event); + + if (channelId === activeChannelId) { return; } @@ -104,7 +162,7 @@ export function useLiveChannelUpdates( return; } - if (!rememberMentionEvent(seenMentionEventIdsRef.current, event.id)) { + if (!trackSeenEvent(seenMentionEventIdsRef.current, event.id)) { return; } @@ -114,6 +172,10 @@ export function useLiveChannelUpdates( React.useEffect(() => { return relayClient.subscribeToReconnects(() => { void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + + // Update the subscription timestamp so replayed backlog events + // (which have created_at in the past) are naturally suppressed. + dmSubscriptionStartedAtRef.current = Math.floor(Date.now() / 1000); }); }, [queryClient]); @@ -125,6 +187,10 @@ export function useLiveChannelUpdates( let isDisposed = false; let cleanup: (() => Promise) | undefined; + // Record the subscription start time so handleDmEvent can distinguish + // backlog replays (created_at < startedAt) from live messages. + dmSubscriptionStartedAtRef.current = Math.floor(Date.now() / 1000); + relayClient .subscribeToAllStreamMessages((event) => { if (!isDisposed) { diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 3f008cf383..7636b0beec 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -285,13 +285,12 @@ export function useFeedDesktopNotifications( settings: NotificationSettings, ) { const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; - const seenItemIdsRef = React.useRef>(new Set()); - const hasInitializedFeedRef = React.useRef(false); + const seenItemIdsRef = React.useRef>( + new Set(readStoredSeenFeedIds(normalizedPubkey)), + ); React.useEffect(() => { - void normalizedPubkey; - seenItemIdsRef.current = new Set(); - hasInitializedFeedRef.current = false; + seenItemIdsRef.current = new Set(readStoredSeenFeedIds(normalizedPubkey)); }, [normalizedPubkey]); const deliverFeedNotification = React.useEffectEvent( @@ -318,9 +317,12 @@ export function useFeedDesktopNotifications( } const currentFeedItems = collectHomeAlertItems(feed); - if (!hasInitializedFeedRef.current) { - hasInitializedFeedRef.current = true; + + // Guard: empty seen set + populated feed means first load or cleared + // storage. Seed the seen set without notifying to prevent a flood. + if (seenItemIdsRef.current.size === 0 && currentFeedItems.length > 0) { seenItemIdsRef.current = new Set(currentFeedItems.map((item) => item.id)); + writeStoredSeenFeedIds(normalizedPubkey, [...seenItemIdsRef.current]); return; } @@ -337,9 +339,8 @@ export function useFeedDesktopNotifications( } // Prevent unbounded growth — keep only the most recent entries. - const MAX_SEEN_FEED_ITEMS = 500; - if (nextSeenItemIds.size > MAX_SEEN_FEED_ITEMS) { - const excess = nextSeenItemIds.size - MAX_SEEN_FEED_ITEMS; + if (nextSeenItemIds.size > HOME_FEED_SEEN_MAX_ITEMS) { + const excess = nextSeenItemIds.size - HOME_FEED_SEEN_MAX_ITEMS; let removed = 0; for (const id of nextSeenItemIds) { if (removed >= excess) break; @@ -349,11 +350,18 @@ export function useFeedDesktopNotifications( } seenItemIdsRef.current = nextSeenItemIds; + writeStoredSeenFeedIds(normalizedPubkey, [...nextSeenItemIds]); for (const item of newItems) { void deliverFeedNotification(item); } - }, [feed, settings.desktopEnabled, settings.mentions, settings.needsAction]); + }, [ + feed, + normalizedPubkey, + settings.desktopEnabled, + settings.mentions, + settings.needsAction, + ]); } export function useHomeFeedNotificationState( diff --git a/desktop/src/features/notifications/lib/feed.ts b/desktop/src/features/notifications/lib/feed.ts index 6572de63ef..0114f87e8a 100644 --- a/desktop/src/features/notifications/lib/feed.ts +++ b/desktop/src/features/notifications/lib/feed.ts @@ -7,6 +7,10 @@ export function notificationTitle(item: FeedItem) { ? ` in #${item.channelName.trim()}` : ""; + if (item.channelType === "dm") { + return "Direct message"; + } + if (item.category === "mention") { return `@Mention${channelLabel}`; } @@ -43,8 +47,12 @@ export function eligibleFeedNotificationItems( ) { const items: FeedItem[] = []; + // DM notifications are handled by the real-time WebSocket hook, so we + // exclude DM items here to avoid duplicate toasts. if (options.mentions) { - items.push(...feed.feed.mentions); + items.push( + ...feed.feed.mentions.filter((item) => item.channelType !== "dm"), + ); } if (options.needsAction) { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 41564d5e6b..1dd078a726 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -148,6 +148,7 @@ type RawFeedItem = { created_at: number; channel_id: string | null; channel_name: string; + channel_type: string; tags: string[][]; category: "mention" | "needs_action" | "activity" | "agent_activity"; }; @@ -394,6 +395,7 @@ function fromRawFeedItem(item: RawFeedItem) { createdAt: item.created_at, channelId: item.channel_id, channelName: item.channel_name, + channelType: item.channel_type, tags: item.tags, category: item.category, }; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 8db839c410..337cc1ee0d 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -178,6 +178,7 @@ export type FeedItem = { createdAt: number; channelId: string | null; channelName: string; + channelType?: string; tags: string[][]; category: FeedItemCategory; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 47daefd6bf..4ce5acaac3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -167,6 +167,7 @@ type RawFeedItem = { created_at: number; channel_id: string | null; channel_name: string; + channel_type?: string; tags: string[][]; category: "mention" | "needs_action" | "activity" | "agent_activity"; }; From 57186963e3775ef3888e28342309ac80ddc63e0c Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 27 Apr 2026 14:47:06 -0600 Subject: [PATCH 013/120] feat(mobile): multi-workspace support (#408) Co-authored-by: Claude Opus 4.6 --- mobile/lib/app.dart | 2 +- .../channels/channel_management_provider.dart | 2 +- .../lib/features/channels/channels_page.dart | 353 +++++++++++++++++- mobile/lib/features/pairing/pairing_page.dart | 302 ++++++++------- .../features/pairing/pairing_provider.dart | 33 +- .../lib/features/search/search_provider.dart | 2 + .../lib/features/settings/settings_page.dart | 10 +- mobile/lib/shared/auth/auth.dart | 4 +- mobile/lib/shared/auth/auth_provider.dart | 125 +++++-- .../lib/shared/auth/credential_storage.dart | 60 --- mobile/lib/shared/relay/relay_provider.dart | 26 +- mobile/lib/shared/relay/relay_socket.dart | 7 + mobile/lib/shared/workspace/workspace.dart | 93 +++++ .../shared/workspace/workspace_provider.dart | 128 +++++++ .../shared/workspace/workspace_storage.dart | 97 +++++ mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../pairing/pairing_provider_test.dart | 24 +- .../workspace/workspace_provider_test.dart | 159 ++++++++ .../workspace/workspace_storage_test.dart | 224 +++++++++++ 20 files changed, 1375 insertions(+), 279 deletions(-) delete mode 100644 mobile/lib/shared/auth/credential_storage.dart create mode 100644 mobile/lib/shared/workspace/workspace.dart create mode 100644 mobile/lib/shared/workspace/workspace_provider.dart create mode 100644 mobile/lib/shared/workspace/workspace_storage.dart create mode 100644 mobile/test/shared/workspace/workspace_provider_test.dart create mode 100644 mobile/test/shared/workspace/workspace_storage_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 1ed8dd8e13..ee255bff3d 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -100,7 +100,7 @@ class _OfflineScreen extends ConsumerWidget { TextButton( onPressed: () => ref.read(authProvider.notifier).signOut(), child: Text( - 'Sign out and re-pair', + 'Remove workspace and re-pair', style: TextStyle(color: context.colors.outline), ), ), diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index a2162aa836..1be2180e47 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -120,7 +120,7 @@ final currentPubkeyProvider = Provider((ref) { } final authState = ref.watch(authProvider).whenData((value) => value).value; - final credentialPubkey = authState?.credentials?.pubkey?.trim(); + final credentialPubkey = authState?.workspace?.pubkey?.trim(); if (credentialPubkey != null && credentialPubkey.isNotEmpty) { return credentialPubkey.toLowerCase(); } diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 3dccffbdf3..137b8dc5c3 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/auth/auth.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/frosted_app_bar.dart'; @@ -15,6 +16,8 @@ import '../profile/profile_provider.dart'; import '../settings/settings_page.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/user_cache_provider.dart'; +import '../pairing/pairing_page.dart'; +import '../pairing/pairing_provider.dart'; import 'channel.dart'; import 'channel_detail_page.dart'; import 'channel_management_provider.dart'; @@ -39,8 +42,19 @@ class ChannelsPage extends HookConsumerWidget { .value; // Cache the last successfully loaded channels so the UI never flashes - // back to a loading state when the provider rebuilds. + // back to a loading state when the provider rebuilds (e.g. reconnect). + // Clear the cache on workspace switch so we show a full loader instead of + // stale channels from the previous workspace. unwrapPrevious() ensures the + // selector sees null during loading (not the previous workspace's ID). + final activeWorkspaceId = ref.watch( + activeWorkspaceProvider.select((v) => v.unwrapPrevious().value?.id), + ); final cachedChannels = useRef?>(null); + final lastWorkspaceId = useRef(null); + if (lastWorkspaceId.value != activeWorkspaceId) { + cachedChannels.value = null; + lastWorkspaceId.value = activeWorkspaceId; + } if (channelsAsync.asData?.value case final data?) { cachedChannels.value = data; } @@ -98,9 +112,12 @@ class ChannelsPage extends HookConsumerWidget { return FrostedScaffold( appBar: FrostedAppBar( - leading: const Padding( - padding: EdgeInsets.only(left: Grid.xs), - child: Text('\u{1F331}', style: TextStyle(fontSize: 28)), + leading: _WorkspaceIndicator( + onTap: () => showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => const _WorkspaceSwitcherSheet(), + ), ), title: const SizedBox.shrink(), actions: [ @@ -1113,3 +1130,331 @@ class _ErrorView extends StatelessWidget { ); } } + +class _WorkspaceSwitcherSheet extends ConsumerWidget { + const _WorkspaceSwitcherSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final workspacesAsync = ref.watch(workspaceListProvider); + final activeAsync = ref.watch(activeWorkspaceProvider); + final sessionState = ref.watch(relaySessionProvider); + + return SafeArea( + child: workspacesAsync.when( + loading: () => const SizedBox( + height: 120, + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(Grid.xs), + child: Text('Error loading workspaces: $e'), + ), + data: (workspaces) { + final activeId = activeAsync.value?.id; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final workspace in workspaces) + _WorkspaceSwitcherTile( + workspace: workspace, + isActive: workspace.id == activeId, + sessionStatus: workspace.id == activeId + ? sessionState.status + : null, + onTap: () async { + if (workspace.id != activeId) { + await ref + .read(workspaceListProvider.notifier) + .switchWorkspace(workspace.id); + } + if (context.mounted) Navigator.of(context).pop(); + }, + onRename: () async { + final nav = Navigator.of(context, rootNavigator: true); + final notifier = ref.read(workspaceListProvider.notifier); + Navigator.of(context).pop(); + final name = await showDialog( + context: nav.context, + useRootNavigator: true, + builder: (_) => + _RenameWorkspaceDialog(currentName: workspace.name), + ); + if (name != null && name.isNotEmpty) { + await notifier.renameWorkspace(workspace.id, name); + } + }, + onRemove: () async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Remove Workspace'), + content: Text( + 'Remove "${workspace.name}"? You can re-pair later.', + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => + Navigator.of(dialogContext).pop(true), + child: const Text('Remove'), + ), + ], + ), + ); + if (confirmed == true && context.mounted) { + final messenger = ScaffoldMessenger.of(context); + try { + await ref + .read(workspaceListProvider.notifier) + .removeWorkspace(workspace.id); + if (context.mounted) Navigator.of(context).pop(); + } catch (e) { + messenger.showSnackBar( + SnackBar( + content: Text('Failed to remove workspace: $e'), + ), + ); + } + } + }, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(LucideIcons.plus), + title: const Text('Add Workspace'), + onTap: () { + final nav = Navigator.of(context, rootNavigator: true); + ref.read(pairingProvider.notifier).reset(); + Navigator.of(context).pop(); + nav.push( + MaterialPageRoute( + builder: (_) => const PairingPage(addingWorkspace: true), + ), + ); + }, + ), + ], + ); + }, + ), + ); + } +} + +class _WorkspaceSwitcherTile extends StatelessWidget { + final Workspace workspace; + final bool isActive; + final SessionStatus? sessionStatus; + final VoidCallback onTap; + final VoidCallback onRename; + final VoidCallback onRemove; + + const _WorkspaceSwitcherTile({ + required this.workspace, + required this.isActive, + required this.sessionStatus, + required this.onTap, + required this.onRename, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + final host = Uri.tryParse(workspace.relayUrl)?.host ?? workspace.relayUrl; + + return ListTile( + leading: _StatusDot(isActive: isActive, sessionStatus: sessionStatus), + title: Text( + workspace.name, + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + ), + subtitle: Text( + host, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + trailing: PopupMenuButton( + icon: Icon( + LucideIcons.ellipsisVertical, + size: 18, + color: context.colors.onSurfaceVariant, + ), + onSelected: (value) { + switch (value) { + case 'rename': + onRename(); + case 'remove': + onRemove(); + } + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'rename', child: Text('Rename')), + const PopupMenuItem(value: 'remove', child: Text('Remove')), + ], + ), + onTap: onTap, + ); + } +} + +class _StatusDot extends StatelessWidget { + final bool isActive; + final SessionStatus? sessionStatus; + + const _StatusDot({required this.isActive, required this.sessionStatus}); + + @override + Widget build(BuildContext context) { + if (!isActive) { + return Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.outline.withValues(alpha: 0.3), + ), + ); + } + + final color = switch (sessionStatus) { + SessionStatus.connected => context.appColors.success, + SessionStatus.connecting || + SessionStatus.reconnecting => context.appColors.warning, + _ => context.colors.outline, + }; + + return Container( + width: 10, + height: 10, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + ); + } +} + +class _RenameWorkspaceDialog extends HookWidget { + final String currentName; + + const _RenameWorkspaceDialog({required this.currentName}); + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: currentName); + + return AlertDialog( + title: const Text('Rename Workspace'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Name'), + onSubmitted: (value) { + final name = value.trim(); + if (name.isNotEmpty) Navigator.of(context).pop(name); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final name = controller.text.trim(); + if (name.isNotEmpty) Navigator.of(context).pop(name); + }, + child: const Text('Rename'), + ), + ], + ); + } +} + +class _WorkspaceIndicator extends ConsumerWidget { + final VoidCallback onTap; + + const _WorkspaceIndicator({required this.onTap}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeAsync = ref.watch(activeWorkspaceProvider); + final sessionState = ref.watch(relaySessionProvider); + + final name = activeAsync.value?.name; + final host = activeAsync.value != null + ? Uri.tryParse(activeAsync.value!.relayUrl)?.host + : null; + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: Grid.xs), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _statusColor(context, sessionState.status), + ), + ), + const SizedBox(width: Grid.half), + if (name != null) + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (host != null) + Text( + host, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontSize: 10, + ), + ), + ], + ), + ) + else + const Text('\u{1F331}', style: TextStyle(fontSize: 28)), + const SizedBox(width: Grid.quarter), + Icon( + LucideIcons.chevronDown, + size: 14, + color: context.colors.onSurfaceVariant, + ), + ], + ), + ), + ); + } + + Color _statusColor(BuildContext context, SessionStatus status) { + return switch (status) { + SessionStatus.connected => context.appColors.success, + SessionStatus.connecting || + SessionStatus.reconnecting => context.appColors.warning, + _ => context.colors.outline, + }; + } +} diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 8531c21c06..193257cf82 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -8,7 +8,11 @@ import '../../shared/theme/theme.dart'; import 'pairing_provider.dart'; class PairingPage extends HookConsumerWidget { - const PairingPage({super.key}); + /// When true, the pairing page is being used to add a new workspace + /// (user is already authenticated with at least one workspace). + final bool addingWorkspace; + + const PairingPage({super.key, this.addingWorkspace = false}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -19,156 +23,184 @@ class PairingPage extends HookConsumerWidget { pairingState.status == PairingStatus.transferring || pairingState.status == PairingStatus.storing; - return Scaffold( - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.sm), - child: pairingState.status == PairingStatus.confirmingSas - ? _SasVerificationView( - sasCode: pairingState.sasCode ?? '------', - confirmed: pairingState.userConfirmedSas, - onConfirm: () => - ref.read(pairingProvider.notifier).confirmSas(), - onDeny: () => ref.read(pairingProvider.notifier).denySas(), - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Spacer(flex: 2), + // When adding a workspace and pairing succeeds, pop back. + if (addingWorkspace && pairingState.status == PairingStatus.success) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + ref.read(pairingProvider.notifier).reset(); + Navigator.of(context).pop(); + } + }); + } - // Branding - Icon( - LucideIcons.sprout, - size: 64, - color: context.colors.primary, - ), - const SizedBox(height: Grid.xs), - Text( - 'Welcome to Sprout', - style: context.textTheme.headlineSmall, - ), - const SizedBox(height: Grid.xxs), - Text( - 'Scan the QR code from your desktop app\nor paste a pairing code to connect.', - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + return PopScope( + onPopInvokedWithResult: (didPop, _) { + if (didPop) { + ref.read(pairingProvider.notifier).reset(); + } + }, + child: Scaffold( + appBar: addingWorkspace + ? AppBar( + leading: IconButton( + icon: const Icon(LucideIcons.arrowLeft), + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text('Add Workspace'), + ) + : null, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.sm), + child: pairingState.status == PairingStatus.confirmingSas + ? _SasVerificationView( + sasCode: pairingState.sasCode ?? '------', + confirmed: pairingState.userConfirmedSas, + onConfirm: () => + ref.read(pairingProvider.notifier).confirmSas(), + onDeny: () => ref.read(pairingProvider.notifier).denySas(), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(flex: 2), + + // Branding + Icon( + LucideIcons.sprout, + size: 64, + color: context.colors.primary, ), - ), - - const SizedBox(height: Grid.lg), - - // Scan QR button - FilledButton.icon( - onPressed: isBusy - ? null - : () => _openScanner(context, ref), - icon: const Icon(LucideIcons.scanLine), - label: const Text('Scan QR Code'), - ), - - const SizedBox(height: Grid.sm), - - // Divider - Row( - children: [ - const Expanded(child: Divider()), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - ), - child: Text( - 'or paste pairing code', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), + const SizedBox(height: Grid.xs), + Text( + 'Welcome to Sprout', + style: context.textTheme.headlineSmall, + ), + const SizedBox(height: Grid.xxs), + Text( + 'Scan the QR code from your desktop app\nor paste a pairing code to connect.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), - const Expanded(child: Divider()), - ], - ), - - const SizedBox(height: Grid.sm), - - // Paste field - TextField( - controller: codeController, - decoration: const InputDecoration( - hintText: 'nostrpair://... or sprout://...', - prefixIcon: Icon(LucideIcons.link), - isDense: true, ), - autocorrect: false, - enableSuggestions: false, - enabled: !isBusy, - contextMenuBuilder: (context, editableTextState) { - return AdaptiveTextSelectionToolbar.editableText( - editableTextState: editableTextState, - ); - }, - ), - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.lg), - // Connect button - SizedBox( - width: double.infinity, - child: FilledButton( + // Scan QR button + FilledButton.icon( onPressed: isBusy ? null - : () { - final code = codeController.text.trim(); - if (code.isNotEmpty) { - ref.read(pairingProvider.notifier).pair(code); - } - }, - child: isBusy - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Text('Connect'), + : () => _openScanner(context, ref), + icon: const Icon(LucideIcons.scanLine), + label: const Text('Scan QR Code'), ), - ), - // Error message - if (pairingState.status == PairingStatus.error && - pairingState.errorMessage != null) ...[ - const SizedBox(height: Grid.twelve), - Container( - padding: const EdgeInsets.all(Grid.twelve), - decoration: BoxDecoration( - color: context.colors.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - LucideIcons.triangleAlert, - size: 16, - color: context.colors.onErrorContainer, + const SizedBox(height: Grid.sm), + + // Divider + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Text( - pairingState.errorMessage!, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onErrorContainer, - ), + child: Text( + 'or paste pairing code', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, ), ), - ], + ), + const Expanded(child: Divider()), + ], + ), + + const SizedBox(height: Grid.sm), + + // Paste field + TextField( + controller: codeController, + decoration: const InputDecoration( + hintText: 'nostrpair://... or sprout://...', + prefixIcon: Icon(LucideIcons.link), + isDense: true, + ), + autocorrect: false, + enableSuggestions: false, + enabled: !isBusy, + contextMenuBuilder: (context, editableTextState) { + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + }, + ), + + const SizedBox(height: Grid.twelve), + + // Connect button + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: isBusy + ? null + : () { + final code = codeController.text.trim(); + if (code.isNotEmpty) { + ref + .read(pairingProvider.notifier) + .pair(code); + } + }, + child: isBusy + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Connect'), ), ), - ], - const Spacer(flex: 3), - ], - ), + // Error message + if (pairingState.status == PairingStatus.error && + pairingState.errorMessage != null) ...[ + const SizedBox(height: Grid.twelve), + Container( + padding: const EdgeInsets.all(Grid.twelve), + decoration: BoxDecoration( + color: context.colors.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + LucideIcons.triangleAlert, + size: 16, + color: context.colors.onErrorContainer, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + pairingState.errorMessage!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onErrorContainer, + ), + ), + ), + ], + ), + ), + ], + + const Spacer(flex: 3), + ], + ), + ), ), ), ); diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index c910c825a3..04673312d2 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -444,17 +444,17 @@ class PairingNotifier extends Notifier { // Send complete only after credentials are validated. _sendComplete(true); - // Store credentials. + // Store as workspace and switch to it. + final workspace = Workspace.create( + name: Workspace.nameFromUrl(relayUrl), + relayUrl: relayUrl, + token: token, + pubkey: pubkey, + nsec: nsec, + ); await ref .read(authProvider.notifier) - .authenticate( - StoredCredentials( - relayUrl: relayUrl, - token: token, - pubkey: pubkey, - nsec: nsec, - ), - ); + .authenticateWithWorkspace(workspace); _cleanup(); state = const PairingState(status: PairingStatus.success); @@ -543,11 +543,11 @@ class PairingNotifier extends Notifier { state = const PairingState(status: PairingStatus.connecting); try { - final creds = _parseLegacyInput(rawInput); + final workspace = _parseLegacyInput(rawInput); final client = RelayClient( - baseUrl: creds.relayUrl, - apiToken: creds.token, + baseUrl: workspace.relayUrl, + apiToken: workspace.token, httpClient: ref.read(pairingHttpClientProvider), ); try { @@ -556,7 +556,9 @@ class PairingNotifier extends Notifier { client.dispose(); } - await ref.read(authProvider.notifier).authenticate(creds); + await ref + .read(authProvider.notifier) + .authenticateWithWorkspace(workspace); state = const PairingState(status: PairingStatus.success); } on FormatException catch (e) { state = PairingState( @@ -580,7 +582,7 @@ class PairingNotifier extends Notifier { } } - StoredCredentials _parseLegacyInput(String raw) { + Workspace _parseLegacyInput(String raw) { var payload = raw.trim(); if (payload.startsWith('sprout://')) { @@ -602,7 +604,8 @@ class PairingNotifier extends Notifier { _validateRelayUrl(relayUrl); - return StoredCredentials( + return Workspace.create( + name: Workspace.nameFromUrl(relayUrl), relayUrl: relayUrl, token: token, pubkey: decoded['pubkey'] as String?, diff --git a/mobile/lib/features/search/search_provider.dart b/mobile/lib/features/search/search_provider.dart index 07a41cf487..13a092d5b0 100644 --- a/mobile/lib/features/search/search_provider.dart +++ b/mobile/lib/features/search/search_provider.dart @@ -90,6 +90,8 @@ class SearchNotifier extends Notifier { @override SearchState build() { + // Watch relayClientProvider so search state resets on workspace switch. + ref.watch(relayClientProvider); ref.onDispose(() { _debounce?.cancel(); }); diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 7a0fac434b..9a5c3ba9b4 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -92,7 +92,7 @@ class SettingsPage extends HookConsumerWidget { OutlinedButton.icon( onPressed: () => _confirmSignOut(context, ref), icon: const Icon(LucideIcons.logOut), - label: const Text('Sign Out'), + label: const Text('Remove Workspace'), style: OutlinedButton.styleFrom( foregroundColor: context.colors.error, ), @@ -162,10 +162,10 @@ class SettingsPage extends HookConsumerWidget { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Sign Out'), + title: const Text('Remove Workspace'), content: const Text( - 'You will need to scan a new pairing code from your ' - 'desktop app to reconnect.', + 'This will disconnect this workspace. You will need ' + 'to scan a new pairing code to reconnect.', ), actions: [ TextButton( @@ -183,7 +183,7 @@ class SettingsPage extends HookConsumerWidget { style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, ), - child: const Text('Sign Out'), + child: const Text('Remove'), ), ], ), diff --git a/mobile/lib/shared/auth/auth.dart b/mobile/lib/shared/auth/auth.dart index e412d8b13a..f093cf7c82 100644 --- a/mobile/lib/shared/auth/auth.dart +++ b/mobile/lib/shared/auth/auth.dart @@ -1,2 +1,4 @@ export 'auth_provider.dart'; -export 'credential_storage.dart'; +export '../workspace/workspace.dart'; +export '../workspace/workspace_provider.dart'; +export '../workspace/workspace_storage.dart'; diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index 1adbfdb146..bf1336fe0f 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -1,47 +1,74 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../relay/relay.dart'; -import 'credential_storage.dart'; +import '../workspace/workspace.dart'; +import '../workspace/workspace_provider.dart'; enum AuthStatus { unknown, unauthenticated, authenticated, offline } class AuthState { final AuthStatus status; - final StoredCredentials? credentials; + final Workspace? workspace; - const AuthState({required this.status, this.credentials}); + const AuthState({required this.status, this.workspace}); } class AuthNotifier extends AsyncNotifier { @override Future build() async { - final storage = CredentialStorage(); - final creds = await storage.load(); - if (creds == null) { + // Read from storage directly — NOT from workspace providers. + // Watching workspace providers here would create a circular dependency + // because authenticateWithWorkspace() writes to those providers. + final storage = ref.read(workspaceStorageProvider); + final workspaces = await storage.loadAll(); + if (workspaces.isEmpty) { return const AuthState(status: AuthStatus.unauthenticated); } - // Validate stored credentials against the relay. - final client = RelayClient(baseUrl: creds.relayUrl, apiToken: creds.token); + final activeId = await storage.loadActiveId(); + final Workspace active; + if (activeId != null && workspaces.any((w) => w.id == activeId)) { + active = workspaces.firstWhere((w) => w.id == activeId); + } else { + // activeId is null or points to a workspace that no longer exists. + // Fall back to first workspace and persist the choice. + active = workspaces.first; + await storage.saveActiveId(active.id); + } + + // Validate the active workspace credentials against the relay. + final client = RelayClient( + baseUrl: active.relayUrl, + apiToken: active.token, + ); try { await client.get('/api/users/me/profile'); - // Credentials are valid — propagate to relay config. - ref - .read(relayConfigProvider.notifier) - .update( - baseUrl: creds.relayUrl, - apiToken: creds.token, - devPubkey: null, - nsec: creds.nsec, - ); - return AuthState(status: AuthStatus.authenticated, credentials: creds); - } on RelayException { - // Token is invalid or expired — clear and require re-pairing. - await storage.clear(); - return const AuthState(status: AuthStatus.unauthenticated); + return AuthState(status: AuthStatus.authenticated, workspace: active); + } on RelayException catch (e) { + if (e.statusCode == 401 || e.statusCode == 403) { + // Token is invalid or revoked — remove this workspace. + await storage.remove(active.id); + + // Check if other workspaces remain — fall through to the next one + // instead of sending the user back to the pairing screen. + final remaining = await storage.loadAll(); + if (remaining.isNotEmpty) { + final next = remaining.first; + await storage.saveActiveId(next.id); + ref.invalidate(workspaceListProvider); + ref.invalidate(activeWorkspaceProvider); + // Re-run build() to validate the next workspace's credentials. + ref.invalidateSelf(); + return await future; + } + + return const AuthState(status: AuthStatus.unauthenticated); + } + // Transient server error (5xx, 429, etc.) — keep workspace, go offline. + return AuthState(status: AuthStatus.offline, workspace: active); } catch (_) { - // Network error — keep credentials and let the user retry. - return AuthState(status: AuthStatus.offline, credentials: creds); + // Network error — keep workspace and let the user retry. + return AuthState(status: AuthStatus.offline, workspace: active); } finally { client.dispose(); } @@ -53,28 +80,48 @@ class AuthNotifier extends AsyncNotifier { await future; } - Future authenticate(StoredCredentials creds) async { - final storage = CredentialStorage(); - await storage.save(creds); + /// Authenticate with a workspace. Saves it and switches to it. + /// Writes to storage directly to avoid circular dependency with workspace + /// providers. + Future authenticateWithWorkspace(Workspace workspace) async { + final storage = ref.read(workspaceStorageProvider); + await storage.save(workspace); + await storage.saveActiveId(workspace.id); - ref - .read(relayConfigProvider.notifier) - .update( - baseUrl: creds.relayUrl, - apiToken: creds.token, - devPubkey: null, - nsec: creds.nsec, - ); + // Invalidate workspace providers so other consumers pick up the new data. + ref.invalidate(workspaceListProvider); + ref.invalidate(activeWorkspaceProvider); state = AsyncData( - AuthState(status: AuthStatus.authenticated, credentials: creds), + AuthState(status: AuthStatus.authenticated, workspace: workspace), ); } Future signOut() async { - final storage = CredentialStorage(); - await storage.clear(); - state = const AsyncData(AuthState(status: AuthStatus.unauthenticated)); + final storage = ref.read(workspaceStorageProvider); + final activeId = await storage.loadActiveId(); + if (activeId != null) { + await storage.remove(activeId); + await storage.clearActiveId(); + } + + // Check if other workspaces remain — switch to the next one instead of + // forcing the user back to the pairing screen. + final remaining = await storage.loadAll(); + + // Invalidate workspace providers so other consumers pick up the change. + ref.invalidate(workspaceListProvider); + ref.invalidate(activeWorkspaceProvider); + + if (remaining.isNotEmpty) { + final next = remaining.first; + await storage.saveActiveId(next.id); + // Re-run build() to validate the next workspace's credentials. + ref.invalidateSelf(); + await future; + } else { + state = const AsyncData(AuthState(status: AuthStatus.unauthenticated)); + } } } diff --git a/mobile/lib/shared/auth/credential_storage.dart b/mobile/lib/shared/auth/credential_storage.dart deleted file mode 100644 index 4ff324f19a..0000000000 --- a/mobile/lib/shared/auth/credential_storage.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -class StoredCredentials { - final String relayUrl; - final String token; - final String? pubkey; - final String? nsec; - - const StoredCredentials({ - required this.relayUrl, - required this.token, - this.pubkey, - this.nsec, - }); -} - -class CredentialStorage { - static const _keyRelayUrl = 'sprout_relay_url'; - static const _keyToken = 'sprout_token'; - static const _keyPubkey = 'sprout_pubkey'; - static const _keyNsec = 'sprout_nsec'; - - final FlutterSecureStorage _storage; - - CredentialStorage([FlutterSecureStorage? storage]) - : _storage = storage ?? const FlutterSecureStorage(); - - Future load() async { - final relayUrl = await _storage.read(key: _keyRelayUrl); - final token = await _storage.read(key: _keyToken); - if (relayUrl == null || token == null) return null; - - final pubkey = await _storage.read(key: _keyPubkey); - final nsec = await _storage.read(key: _keyNsec); - return StoredCredentials( - relayUrl: relayUrl, - token: token, - pubkey: pubkey, - nsec: nsec, - ); - } - - Future save(StoredCredentials credentials) async { - await _storage.write(key: _keyRelayUrl, value: credentials.relayUrl); - await _storage.write(key: _keyToken, value: credentials.token); - if (credentials.pubkey != null) { - await _storage.write(key: _keyPubkey, value: credentials.pubkey); - } - if (credentials.nsec != null) { - await _storage.write(key: _keyNsec, value: credentials.nsec); - } - } - - Future clear() async { - await _storage.delete(key: _keyRelayUrl); - await _storage.delete(key: _keyToken); - await _storage.delete(key: _keyPubkey); - await _storage.delete(key: _keyNsec); - } -} diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index fa1ff12713..f755414830 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../workspace/workspace_provider.dart'; import 'relay_client.dart'; /// Relay connection configuration. @@ -49,11 +50,26 @@ class Env { class RelayConfigNotifier extends Notifier { @override - RelayConfig build() => RelayConfig( - baseUrl: Env.relayUrl, - apiToken: Env.apiToken.isEmpty ? null : Env.apiToken, - devPubkey: Env.devPubkey.isEmpty ? null : Env.devPubkey, - ); + RelayConfig build() { + // Watch the active workspace so that when it changes (workspace switch), + // the config rebuilds, triggering the full provider cascade. + final activeAsync = ref.watch(activeWorkspaceProvider); + final active = activeAsync.value; + if (active != null) { + return RelayConfig( + baseUrl: active.relayUrl, + apiToken: active.token, + nsec: active.nsec, + ); + } + + // Fallback to compile-time env config (dev mode). + return RelayConfig( + baseUrl: Env.relayUrl, + apiToken: Env.apiToken.isEmpty ? null : Env.apiToken, + devPubkey: Env.devPubkey.isEmpty ? null : Env.devPubkey, + ); + } void update({ required String baseUrl, diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index ecf2b7afa4..58ddbe6fa2 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -59,6 +59,13 @@ class RelaySocket { return; } + // The channel may have been disposed while we were awaiting ready + // (e.g. provider rebuild triggered dispose() concurrently). + if (_channel == null) { + _state = SocketState.disconnected; + return; + } + _state = SocketState.authenticating; _authCompleter = Completer(); diff --git a/mobile/lib/shared/workspace/workspace.dart b/mobile/lib/shared/workspace/workspace.dart new file mode 100644 index 0000000000..98e641bea0 --- /dev/null +++ b/mobile/lib/shared/workspace/workspace.dart @@ -0,0 +1,93 @@ +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); +const _sentinel = Object(); + +class Workspace { + final String id; + final String name; + final String relayUrl; + final String token; + final String? pubkey; + final String? nsec; + final DateTime addedAt; + + const Workspace({ + required this.id, + required this.name, + required this.relayUrl, + required this.token, + this.pubkey, + this.nsec, + required this.addedAt, + }); + + factory Workspace.create({ + required String name, + required String relayUrl, + required String token, + String? pubkey, + String? nsec, + }) { + return Workspace( + id: _uuid.v4(), + name: name, + relayUrl: relayUrl, + token: token, + pubkey: pubkey, + nsec: nsec, + addedAt: DateTime.now(), + ); + } + + Workspace copyWith({ + String? name, + String? relayUrl, + String? token, + Object? pubkey = _sentinel, + Object? nsec = _sentinel, + }) { + return Workspace( + id: id, + name: name ?? this.name, + relayUrl: relayUrl ?? this.relayUrl, + token: token ?? this.token, + pubkey: pubkey == _sentinel ? this.pubkey : pubkey as String?, + nsec: nsec == _sentinel ? this.nsec : nsec as String?, + addedAt: addedAt, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'relayUrl': relayUrl, + 'token': token, + if (pubkey != null) 'pubkey': pubkey, + if (nsec != null) 'nsec': nsec, + 'addedAt': addedAt.toIso8601String(), + }; + + factory Workspace.fromJson(Map json) => Workspace( + id: json['id'] as String, + name: json['name'] as String, + relayUrl: json['relayUrl'] as String, + token: json['token'] as String, + pubkey: json['pubkey'] as String?, + nsec: json['nsec'] as String?, + addedAt: DateTime.parse(json['addedAt'] as String), + ); + + /// Derive a human-friendly workspace name from a relay URL. + static String nameFromUrl(String url) { + try { + final host = Uri.parse(url).host; + if (host.contains('localhost') || host == '127.0.0.1') return 'Local Dev'; + final parts = host.split('.'); + if (parts.length > 2) return parts.first; + return host; + } catch (_) { + return 'Workspace'; + } + } +} diff --git a/mobile/lib/shared/workspace/workspace_provider.dart b/mobile/lib/shared/workspace/workspace_provider.dart new file mode 100644 index 0000000000..0f5fda1767 --- /dev/null +++ b/mobile/lib/shared/workspace/workspace_provider.dart @@ -0,0 +1,128 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../auth/auth_provider.dart'; +import 'workspace.dart'; +import 'workspace_storage.dart'; + +final workspaceStorageProvider = Provider((ref) { + return WorkspaceStorage(); +}); + +class WorkspaceListNotifier extends AsyncNotifier> { + @override + Future> build() async { + final storage = ref.read(workspaceStorageProvider); + return storage.loadAll(); + } + + /// Add a workspace. If one with the same relay URL already exists, update + /// its credentials instead. Returns the effective workspace ID. + Future addWorkspace(Workspace workspace) async { + final storage = ref.read(workspaceStorageProvider); + final current = state.value ?? []; + + // If a workspace with the same relay URL exists, update its credentials + // instead of creating a duplicate entry. + final existingIndex = current.indexWhere( + (w) => w.relayUrl == workspace.relayUrl, + ); + if (existingIndex >= 0) { + final existing = current[existingIndex]; + final updated = existing.copyWith( + token: workspace.token, + pubkey: workspace.pubkey, + nsec: workspace.nsec, + ); + await storage.save(updated); + final updatedList = [...current]; + updatedList[existingIndex] = updated; + state = AsyncData(updatedList); + return existing.id; + } + + await storage.save(workspace); + state = AsyncData([...current, workspace]); + return workspace.id; + } + + Future removeWorkspace(String id) async { + final storage = ref.read(workspaceStorageProvider); + await storage.remove(id); + + final current = state.value ?? []; + state = AsyncData(current.where((w) => w.id != id).toList()); + + // If we removed the active workspace, switch to another or sign out. + final activeId = await storage.loadActiveId(); + if (activeId == id) { + final remaining = state.value ?? []; + if (remaining.isNotEmpty) { + await switchWorkspace(remaining.first.id); + } else { + await storage.clearActiveId(); + // Invalidate auth so it re-evaluates against the now-empty storage + // and transitions to unauthenticated. + ref.invalidate(authProvider); + } + } + } + + Future switchWorkspace(String id) async { + final storage = ref.read(workspaceStorageProvider); + await storage.saveActiveId(id); + // Reassign list state to trigger activeWorkspaceProvider (which watches + // workspaceListProvider.future) to rebuild and pick up the new active ID. + // We can't use ref.invalidate(activeWorkspaceProvider) here because that + // creates a circular dependency — activeWorkspaceProvider watches us. + state = AsyncData([...state.value ?? []]); + // Invalidate auth so AuthState.workspace reflects the new active workspace. + ref.invalidate(authProvider); + } + + Future renameWorkspace(String id, String name) async { + final storage = ref.read(workspaceStorageProvider); + final current = state.value ?? []; + final index = current.indexWhere((w) => w.id == id); + if (index < 0) return; + + final updated = current[index].copyWith(name: name); + await storage.save(updated); + + final updatedList = [...current]; + updatedList[index] = updated; + state = AsyncData(updatedList); + } +} + +final workspaceListProvider = + AsyncNotifierProvider>( + WorkspaceListNotifier.new, + ); + +/// The currently active workspace, derived from the stored active ID and +/// the workspace list. +final activeWorkspaceProvider = FutureProvider((ref) async { + final workspaces = await ref.watch(workspaceListProvider.future); + final storage = ref.read(workspaceStorageProvider); + final activeId = await storage.loadActiveId(); + + if (workspaces.isEmpty) return null; + + if (activeId == null) { + // No active ID stored but workspaces exist — fall back to first. + await storage.saveActiveId(workspaces.first.id); + return workspaces.first; + } + + try { + return workspaces.firstWhere((w) => w.id == activeId); + } on StateError { + // Active ID points to a workspace that no longer exists. + // Fall back to first workspace. + if (workspaces.isNotEmpty) { + await storage.saveActiveId(workspaces.first.id); + return workspaces.first; + } + return null; + } +}); diff --git a/mobile/lib/shared/workspace/workspace_storage.dart b/mobile/lib/shared/workspace/workspace_storage.dart new file mode 100644 index 0000000000..53456f3d4b --- /dev/null +++ b/mobile/lib/shared/workspace/workspace_storage.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'workspace.dart'; + +class WorkspaceStorage { + static const _keyWorkspaces = 'sprout_workspaces'; + static const _keyActiveId = 'sprout_active_workspace_id'; + + // Legacy keys for migration. + static const _legacyRelayUrl = 'sprout_relay_url'; + static const _legacyToken = 'sprout_token'; + static const _legacyPubkey = 'sprout_pubkey'; + static const _legacyNsec = 'sprout_nsec'; + + final FlutterSecureStorage _secure; + + WorkspaceStorage({FlutterSecureStorage? secure}) + : _secure = secure ?? const FlutterSecureStorage(); + + /// Load all workspaces. On first call, migrates legacy single-workspace + /// credentials if present. + Future> loadAll() async { + final raw = await _secure.read(key: _keyWorkspaces); + if (raw != null) { + final list = jsonDecode(raw) as List; + return list + .map((e) => Workspace.fromJson(e as Map)) + .toList(); + } + + // Migration: check for legacy keys. + final legacyUrl = await _secure.read(key: _legacyRelayUrl); + final legacyToken = await _secure.read(key: _legacyToken); + if (legacyUrl != null && legacyToken != null) { + final legacyPubkey = await _secure.read(key: _legacyPubkey); + final legacyNsec = await _secure.read(key: _legacyNsec); + + final name = Workspace.nameFromUrl(legacyUrl); + final workspace = Workspace.create( + name: name, + relayUrl: legacyUrl, + token: legacyToken, + pubkey: legacyPubkey, + nsec: legacyNsec, + ); + + await _saveList([workspace]); + await saveActiveId(workspace.id); + + // Delete legacy keys. + await _secure.delete(key: _legacyRelayUrl); + await _secure.delete(key: _legacyToken); + await _secure.delete(key: _legacyPubkey); + await _secure.delete(key: _legacyNsec); + + return [workspace]; + } + + return []; + } + + Future save(Workspace workspace) async { + final all = await loadAll(); + final index = all.indexWhere((w) => w.id == workspace.id); + if (index >= 0) { + all[index] = workspace; + } else { + all.add(workspace); + } + await _saveList(all); + } + + Future remove(String id) async { + final all = await loadAll(); + all.removeWhere((w) => w.id == id); + await _saveList(all); + } + + Future loadActiveId() async { + return _secure.read(key: _keyActiveId); + } + + Future saveActiveId(String id) async { + await _secure.write(key: _keyActiveId, value: id); + } + + Future clearActiveId() async { + await _secure.delete(key: _keyActiveId); + } + + Future _saveList(List workspaces) async { + final json = jsonEncode(workspaces.map((w) => w.toJson()).toList()); + await _secure.write(key: _keyWorkspaces, value: json); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 6fc0e7354d..11439cf395 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1198,7 +1198,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index e6948dcaf2..617752b283 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: url_launcher: ^6.3.2 gpt_markdown: ^1.1.6 intl: ^0.20.2 + uuid: ^4.5.1 image_picker: ^1.1.2 video_player: ^2.10.1 diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index 6ca86d707b..bc2f68cf2b 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -25,21 +25,13 @@ String _encodePairingCode({ /// A fake [AuthNotifier] that records calls instead of touching secure storage. class FakeAuthNotifier extends AsyncNotifier implements AuthNotifier { - StoredCredentials? lastCredentials; + Workspace? lastWorkspace; bool signedOut = false; @override Future build() async => const AuthState(status: AuthStatus.unauthenticated); - @override - Future authenticate(StoredCredentials creds) async { - lastCredentials = creds; - state = AsyncData( - AuthState(status: AuthStatus.authenticated, credentials: creds), - ); - } - @override Future signOut() async { signedOut = true; @@ -48,6 +40,14 @@ class FakeAuthNotifier extends AsyncNotifier @override Future retry() async {} + + @override + Future authenticateWithWorkspace(Workspace workspace) async { + lastWorkspace = workspace; + state = AsyncData( + AuthState(status: AuthStatus.authenticated, workspace: workspace), + ); + } } void main() { @@ -90,8 +90,8 @@ void main() { final state = container.read(pairingProvider); expect(state.status, PairingStatus.success); - expect(fakeAuth.lastCredentials?.relayUrl, 'http://test:3000'); - expect(fakeAuth.lastCredentials?.token, 'sprout_test_token'); + expect(fakeAuth.lastWorkspace?.relayUrl, 'http://test:3000'); + expect(fakeAuth.lastWorkspace?.token, 'sprout_test_token'); }); test('successful pairing with sprout:// prefix', () async { @@ -116,7 +116,7 @@ void main() { await container.read(pairingProvider.notifier).pair(code); expect(container.read(pairingProvider).status, PairingStatus.success); - expect(fakeAuth.lastCredentials?.pubkey, 'abc123'); + expect(fakeAuth.lastWorkspace?.pubkey, 'abc123'); }); test('relay 401 sets error state', () async { diff --git a/mobile/test/shared/workspace/workspace_provider_test.dart b/mobile/test/shared/workspace/workspace_provider_test.dart new file mode 100644 index 0000000000..33fd30e8ab --- /dev/null +++ b/mobile/test/shared/workspace/workspace_provider_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sprout_mobile/shared/workspace/workspace.dart'; +import 'package:sprout_mobile/shared/workspace/workspace_provider.dart'; +import 'package:sprout_mobile/shared/workspace/workspace_storage.dart'; + +import 'workspace_storage_test.dart'; + +void main() { + late FakeSecureStorage fakeSecure; + late WorkspaceStorage workspaceStorage; + late ProviderContainer container; + + setUp(() { + fakeSecure = FakeSecureStorage(); + workspaceStorage = WorkspaceStorage(secure: fakeSecure); + }); + + tearDown(() => container.dispose()); + + ProviderContainer createContainer() { + return ProviderContainer( + overrides: [workspaceStorageProvider.overrideWithValue(workspaceStorage)], + ); + } + + group('WorkspaceListNotifier', () { + test('loads empty list initially', () async { + container = createContainer(); + final workspaces = await container.read(workspaceListProvider.future); + expect(workspaces, isEmpty); + }); + + test('addWorkspace adds to list', () async { + container = createContainer(); + await container.read(workspaceListProvider.future); + + final ws = Workspace.create( + name: 'Test', + relayUrl: 'https://test.example.com', + token: 'tok', + ); + await container.read(workspaceListProvider.notifier).addWorkspace(ws); + + final workspaces = await container.read(workspaceListProvider.future); + expect(workspaces, hasLength(1)); + expect(workspaces.first.name, 'Test'); + }); + + test('removeWorkspace removes from list', () async { + container = createContainer(); + await container.read(workspaceListProvider.future); + + final ws = Workspace.create( + name: 'Test', + relayUrl: 'https://test.example.com', + token: 'tok', + ); + await container.read(workspaceListProvider.notifier).addWorkspace(ws); + await container + .read(workspaceListProvider.notifier) + .removeWorkspace(ws.id); + + final workspaces = await container.read(workspaceListProvider.future); + expect(workspaces, isEmpty); + }); + + test('renameWorkspace updates name', () async { + container = createContainer(); + await container.read(workspaceListProvider.future); + + final ws = Workspace.create( + name: 'Original', + relayUrl: 'https://test.example.com', + token: 'tok', + ); + await container.read(workspaceListProvider.notifier).addWorkspace(ws); + await container + .read(workspaceListProvider.notifier) + .renameWorkspace(ws.id, 'Renamed'); + + final workspaces = await container.read(workspaceListProvider.future); + expect(workspaces.first.name, 'Renamed'); + }); + + test('switchWorkspace updates active ID', () async { + container = createContainer(); + await container.read(workspaceListProvider.future); + + final ws1 = Workspace.create( + name: 'One', + relayUrl: 'https://one.example.com', + token: 'tok1', + ); + final ws2 = Workspace.create( + name: 'Two', + relayUrl: 'https://two.example.com', + token: 'tok2', + ); + + final notifier = container.read(workspaceListProvider.notifier); + await notifier.addWorkspace(ws1); + await notifier.addWorkspace(ws2); + await notifier.switchWorkspace(ws2.id); + + final activeId = await workspaceStorage.loadActiveId(); + expect(activeId, ws2.id); + }); + }); + + group('activeWorkspaceProvider', () { + test('returns null when no workspaces', () async { + container = createContainer(); + final active = await container.read(activeWorkspaceProvider.future); + expect(active, isNull); + }); + + test('returns workspace matching active ID', () async { + container = createContainer(); + await container.read(workspaceListProvider.future); + + final ws = Workspace.create( + name: 'Test', + relayUrl: 'https://test.example.com', + token: 'tok', + ); + final notifier = container.read(workspaceListProvider.notifier); + await notifier.addWorkspace(ws); + await notifier.switchWorkspace(ws.id); + + final active = await container.read(activeWorkspaceProvider.future); + expect(active, isNotNull); + expect(active!.id, ws.id); + expect(active.name, 'Test'); + }); + + test('falls back to first workspace if active ID is invalid', () async { + container = createContainer(); + await container.read(workspaceListProvider.future); + + final ws = Workspace.create( + name: 'Fallback', + relayUrl: 'https://test.example.com', + token: 'tok', + ); + final notifier = container.read(workspaceListProvider.notifier); + await notifier.addWorkspace(ws); + + // Set an invalid active ID. + await workspaceStorage.saveActiveId('nonexistent-id'); + + // Re-read — should fall back. + container.invalidate(activeWorkspaceProvider); + final active = await container.read(activeWorkspaceProvider.future); + expect(active, isNotNull); + expect(active!.id, ws.id); + }); + }); +} diff --git a/mobile/test/shared/workspace/workspace_storage_test.dart b/mobile/test/shared/workspace/workspace_storage_test.dart new file mode 100644 index 0000000000..88089a08bc --- /dev/null +++ b/mobile/test/shared/workspace/workspace_storage_test.dart @@ -0,0 +1,224 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sprout_mobile/shared/workspace/workspace.dart'; +import 'package:sprout_mobile/shared/workspace/workspace_storage.dart'; + +/// In-memory fake that extends Fake to satisfy all FlutterSecureStorage +/// interface methods, but implements the core read/write/delete with real +/// in-memory logic. +class FakeSecureStorage extends Fake implements FlutterSecureStorage { + final Map _data = {}; + + @override + Future read({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => _data[key]; + + @override + Future write({ + required String key, + required String? value, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async { + if (value != null) { + _data[key] = value; + } else { + _data.remove(key); + } + } + + @override + Future delete({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => _data.remove(key); + + @override + Future> readAll({ + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => Map.from(_data); + + @override + Future deleteAll({ + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => _data.clear(); + + @override + Future containsKey({ + required String key, + IOSOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + MacOsOptions? mOptions, + WindowsOptions? wOptions, + }) async => _data.containsKey(key); + + // Convenience for setting up test data. + String? operator [](String key) => _data[key]; + void operator []=(String key, String value) => _data[key] = value; +} + +void main() { + late FakeSecureStorage fakeSecure; + late WorkspaceStorage storage; + + setUp(() { + fakeSecure = FakeSecureStorage(); + storage = WorkspaceStorage(secure: fakeSecure); + }); + + group('WorkspaceStorage', () { + test('loadAll returns empty list when no data', () async { + final result = await storage.loadAll(); + expect(result, isEmpty); + }); + + test('save and loadAll round-trips a workspace', () async { + final ws = Workspace.create( + name: 'Test', + relayUrl: 'https://relay.example.com', + token: 'tok_123', + pubkey: 'abc123', + ); + + await storage.save(ws); + final loaded = await storage.loadAll(); + + expect(loaded, hasLength(1)); + expect(loaded.first.id, ws.id); + expect(loaded.first.name, 'Test'); + expect(loaded.first.relayUrl, 'https://relay.example.com'); + expect(loaded.first.token, 'tok_123'); + expect(loaded.first.pubkey, 'abc123'); + }); + + test('save updates existing workspace with same id', () async { + final ws = Workspace.create( + name: 'Original', + relayUrl: 'https://relay.example.com', + token: 'tok_123', + ); + + await storage.save(ws); + await storage.save(ws.copyWith(name: 'Updated')); + + final loaded = await storage.loadAll(); + expect(loaded, hasLength(1)); + expect(loaded.first.name, 'Updated'); + }); + + test('remove deletes a workspace', () async { + final ws1 = Workspace.create( + name: 'One', + relayUrl: 'https://one.example.com', + token: 'tok_1', + ); + final ws2 = Workspace.create( + name: 'Two', + relayUrl: 'https://two.example.com', + token: 'tok_2', + ); + + await storage.save(ws1); + await storage.save(ws2); + await storage.remove(ws1.id); + + final loaded = await storage.loadAll(); + expect(loaded, hasLength(1)); + expect(loaded.first.id, ws2.id); + }); + + test('active workspace ID persists', () async { + await storage.saveActiveId('ws-123'); + final id = await storage.loadActiveId(); + expect(id, 'ws-123'); + }); + + test('clearActiveId removes active ID', () async { + await storage.saveActiveId('ws-123'); + await storage.clearActiveId(); + final id = await storage.loadActiveId(); + expect(id, isNull); + }); + + group('migration', () { + test('migrates legacy keys to workspace on first load', () async { + fakeSecure['sprout_relay_url'] = 'https://legacy.example.com'; + fakeSecure['sprout_token'] = 'legacy_token'; + fakeSecure['sprout_pubkey'] = 'legacy_pub'; + fakeSecure['sprout_nsec'] = 'legacy_nsec'; + + final loaded = await storage.loadAll(); + + expect(loaded, hasLength(1)); + expect(loaded.first.relayUrl, 'https://legacy.example.com'); + expect(loaded.first.token, 'legacy_token'); + expect(loaded.first.pubkey, 'legacy_pub'); + expect(loaded.first.nsec, 'legacy_nsec'); + expect(loaded.first.name, isNotEmpty); + + // Legacy keys should be deleted. + expect(fakeSecure['sprout_relay_url'], isNull); + expect(fakeSecure['sprout_token'], isNull); + expect(fakeSecure['sprout_pubkey'], isNull); + expect(fakeSecure['sprout_nsec'], isNull); + + // Active ID should be set. + final activeId = await storage.loadActiveId(); + expect(activeId, loaded.first.id); + }); + + test('does not migrate when no legacy keys exist', () async { + final loaded = await storage.loadAll(); + expect(loaded, isEmpty); + }); + + test('does not re-migrate after first load', () async { + fakeSecure['sprout_relay_url'] = 'https://legacy.example.com'; + fakeSecure['sprout_token'] = 'legacy_token'; + + final first = await storage.loadAll(); + expect(first, hasLength(1)); + + final second = await storage.loadAll(); + expect(second, hasLength(1)); + expect(second.first.id, first.first.id); + }); + + test('migration generates name from localhost URL', () async { + fakeSecure['sprout_relay_url'] = 'http://localhost:3000'; + fakeSecure['sprout_token'] = 'tok'; + + final loaded = await storage.loadAll(); + expect(loaded.first.name, 'Local Dev'); + }); + }); + }); +} From 5a934038584e318e6937beff7071780f18e3b5cf Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 27 Apr 2026 14:57:46 -0600 Subject: [PATCH 014/120] feat(pulse): rich text editor with @mentions, media uploads, and formatting (#407) Co-authored-by: Claude Opus 4.6 --- desktop/src-tauri/src/commands/social.rs | 8 +- desktop/src-tauri/src/events.rs | 5 + .../src/features/forum/ui/ForumComposer.tsx | 25 ++++- .../features/forum/ui/ForumThreadPanel.tsx | 2 +- desktop/src/features/forum/ui/ForumView.tsx | 24 +++-- .../src/features/messages/lib/useMentions.ts | 8 +- .../messages/lib/useRichTextEditor.ts | 3 + .../messages/ui/FormattingToolbar.tsx | 91 +++++++++++++------ .../messages/ui/MessageComposerToolbar.tsx | 42 ++++++--- desktop/src/features/pulse/hooks.ts | 13 ++- desktop/src/features/pulse/ui/ComposeNote.tsx | 78 ---------------- desktop/src/features/pulse/ui/PulseView.tsx | 60 ++++++++---- desktop/src/shared/api/social.ts | 4 + desktop/src/shared/ui/toggle.tsx | 2 +- 14 files changed, 208 insertions(+), 157 deletions(-) delete mode 100644 desktop/src/features/pulse/ui/ComposeNote.tsx diff --git a/desktop/src-tauri/src/commands/social.rs b/desktop/src-tauri/src/commands/social.rs index 1ab49fb3fc..5666978a4e 100644 --- a/desktop/src-tauri/src/commands/social.rs +++ b/desktop/src-tauri/src/commands/social.rs @@ -14,13 +14,17 @@ use crate::{ pub async fn publish_note( content: String, reply_to: Option, + mention_pubkeys: Option>, + media_tags: Option>>, state: State<'_, AppState>, ) -> Result { let reply_id = reply_to .map(|hex| EventId::from_hex(&hex).map_err(|e| format!("invalid reply_to event id: {e}"))) .transpose()?; - - let builder = events::build_note(&content, reply_id)?; + let mentions = mention_pubkeys.unwrap_or_default(); + let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect(); + let media = media_tags.unwrap_or_default(); + let builder = events::build_note(&content, reply_id, &mention_refs, &media)?; submit_event(builder, &state).await } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index efcddb1e52..ffdf662fb1 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -66,6 +66,7 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { let mut seen = std::collections::HashSet::new(); let mut tags = Vec::new(); for &hex in mentions { + check_pubkey(hex)?; let lower = hex.to_ascii_lowercase(); if seen.insert(lower.clone()) { tags.push(tag(vec!["p", &lower])?); @@ -428,12 +429,16 @@ pub fn build_huddle_guidelines( pub fn build_note( content: &str, reply_to_event_id: Option, + mentions: &[&str], + media_tags: &[Vec], ) -> Result { check_content(content)?; let mut tags = Vec::new(); if let Some(parent) = reply_to_event_id { tags.push(tag(vec!["e", &parent.to_hex(), "", "reply"])?); } + tags.extend(mention_tags(mentions)?); + imeta_tags(media_tags, &mut tags)?; Ok(EventBuilder::new(Kind::TextNote, content).tags(tags)) } diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 8812b1b6e3..0dc540ba2e 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -20,10 +20,13 @@ import { type MentionSuggestion, } from "@/features/messages/ui/MentionAutocomplete"; import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar"; +import type { ChannelMember } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; type ForumComposerProps = { channelId?: string | null; + /** Override mention source when no channel is available (e.g. Pulse). */ + members?: ChannelMember[]; placeholder: string; disabled?: boolean; isSending?: boolean; @@ -32,13 +35,14 @@ type ForumComposerProps = { content: string, mentionPubkeys: string[], mediaTags?: string[][], - ) => void; + ) => void | Promise; /** When true, autocomplete renders below the input (for top-of-view composers). */ autocompleteBelow?: boolean; }; export function ForumComposer({ channelId = null, + members, placeholder, disabled, isSending, @@ -58,7 +62,7 @@ export function ForumComposer({ setIsFormattingOpen(pressed); }, []); - const mentions = useMentions(channelId); + const mentions = useMentions(channelId, members); const channelLinks = useChannelLinks(); const media = useMediaUpload(); @@ -200,6 +204,10 @@ export function ForumComposer({ finalContent += isVideo ? `\n![video](${d.url})` : `\n![image](${d.url})`; } + // Save draft state so we can restore on failure. + const savedContent = contentRef.current; + const savedImeta = [...currentPendingImeta]; + setContent(""); contentRef.current = ""; richText.clearContent(); @@ -208,7 +216,17 @@ export function ForumComposer({ channelLinks.clearChannels(); setIsEmojiPickerOpen(false); - onSubmitRef.current(finalContent, pubkeys, mediaTags); + const result = onSubmitRef.current(finalContent, pubkeys, mediaTags); + + // If onSubmit returns a promise, restore draft on failure. + if (result && typeof result.then === "function") { + result.catch(() => { + setContent(savedContent); + contentRef.current = savedContent; + richText.setContent(savedContent); + media.setPendingImeta(savedImeta); + }); + } }, [ media.pendingImetaRef, media.setPendingImeta, @@ -216,6 +234,7 @@ export function ForumComposer({ mentions.clearMentions, channelLinks.clearChannels, richText.clearContent, + richText.setContent, ]); submitMessageRef.current = submitMessage; diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 1706f3d45e..9985b09d35 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -30,7 +30,7 @@ type ForumThreadPanelProps = { content: string, mentionPubkeys: string[], mediaTags?: string[][], - ) => void; + ) => void | Promise; onDeletePost?: (eventId: string) => void; onDeleteReply?: (eventId: string) => void; onTargetReached?: (eventId: string) => void; diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index c0c091f30b..e9aa44417a 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -126,14 +126,14 @@ export function ForumView({ deleteReplyMutation.mutate({ eventId }); }} channelId={channel.id} - onReply={(content, mentionPubkeys, mediaTags) => { - createReplyMutation.mutate({ + onReply={(content, mentionPubkeys, mediaTags) => + createReplyMutation.mutateAsync({ content, parentEventId: selectedPostId, mentionPubkeys, mediaTags, - }); - }} + }) + } onTargetReached={onTargetReached} profiles={profiles} targetEventId={targetReplyId} @@ -152,15 +152,13 @@ export function ForumView({ channelId={channel.id} isSending={createPostMutation.isPending} onCancel={() => setIsComposerOpen(false)} - onSubmit={(content, mentionPubkeys, mediaTags) => { - createPostMutation.mutate( - { content, mentionPubkeys, mediaTags }, - { - onSuccess: () => { - setIsComposerOpen(false); - }, - }, - ); + onSubmit={async (content, mentionPubkeys, mediaTags) => { + await createPostMutation.mutateAsync({ + content, + mentionPubkeys, + mediaTags, + }); + setIsComposerOpen(false); }} placeholder="Write your post..." /> diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index b486cf341a..75da235df1 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -6,20 +6,24 @@ import { } from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; +import type { ChannelMember } from "@/shared/api/types"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { hasMention } from "./hasMention"; const MENTION_DEBOUNCE_MS = 120; -export function useMentions(channelId: string | null) { +export function useMentions( + channelId: string | null, + externalMembers?: ChannelMember[], +) { const [mentionQuery, setMentionQuery] = React.useState(null); const [mentionStartIndex, setMentionStartIndex] = React.useState(0); const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0); const mentionMapRef = React.useRef>(new Map()); const membersQuery = useChannelMembersQuery(channelId); - const members = membersQuery.data; + const members = externalMembers ?? membersQuery.data; const managedAgentsQuery = useManagedAgentsQuery(); const personasQuery = usePersonasQuery(); const managedAgentNamesByPubkey = React.useMemo( diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 4ada20d364..0f0624b72d 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -69,6 +69,9 @@ export function useRichTextEditor({ // after block nodes (lists, blockquotes, code blocks) which creates // a phantom empty line in the compact message composer. trailingNode: false, + // Disable StarterKit's built-in Link — we configure it separately + // below with custom options (autolink, openOnClick, etc.). + link: false, }), // Shift+Enter inside lists/blockquotes: split the node instead of // inserting a hard break so continuation lines keep their formatting. diff --git a/desktop/src/features/messages/ui/FormattingToolbar.tsx b/desktop/src/features/messages/ui/FormattingToolbar.tsx index 4862722b63..292adb59d1 100644 --- a/desktop/src/features/messages/ui/FormattingToolbar.tsx +++ b/desktop/src/features/messages/ui/FormattingToolbar.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { useEditorState, type Editor } from "@tiptap/react"; +import type { Editor } from "@tiptap/react"; import { Bold, Code, @@ -11,7 +11,7 @@ import { Strikethrough, } from "lucide-react"; -import { Toggle } from "@/shared/ui/toggle"; +import { cn } from "@/shared/lib/cn"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; type FormattingToolbarProps = { @@ -19,14 +19,59 @@ type FormattingToolbarProps = { disabled?: boolean; }; +type ActiveStates = { + bold: boolean; + italic: boolean; + strike: boolean; + code: boolean; + link: boolean; + bulletList: boolean; + orderedList: boolean; + blockquote: boolean; +}; + +function getActiveStates(editor: Editor): ActiveStates { + return { + bold: editor.isActive("bold"), + italic: editor.isActive("italic"), + strike: editor.isActive("strike"), + code: editor.isActive("code"), + link: editor.isActive("link"), + bulletList: editor.isActive("bulletList"), + orderedList: editor.isActive("orderedList"), + blockquote: editor.isActive("blockquote"), + }; +} + /** * Formatting bar shown above the editor when the format toggle is active. - * Renders all formatting buttons in a single row with bg-muted styling. + * Uses plain buttons with explicit active-class toggling instead of + * Radix Toggle to avoid data-state / re-render issues. */ export const FormattingToolbar = React.memo(function FormattingToolbar({ editor, disabled = false, }: FormattingToolbarProps) { + const [activeStates, setActiveStates] = React.useState( + () => (editor ? getActiveStates(editor) : null), + ); + + React.useEffect(() => { + if (!editor) { + setActiveStates(null); + return; + } + setActiveStates(getActiveStates(editor)); + + const onTransaction = () => { + setActiveStates(getActiveStates(editor)); + }; + editor.on("transaction", onTransaction); + return () => { + editor.off("transaction", onTransaction); + }; + }, [editor]); + const toggleBold = React.useCallback(() => { editor?.chain().focus().toggleBold().run(); }, [editor]); @@ -80,26 +125,6 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ editor?.chain().focus().toggleBlockquote().run(); }, [editor]); - // Subscribe to editor state changes so active marks/nodes update on - // selection change — useEditorState triggers re-renders when the - // selector result changes. - const activeStates = useEditorState({ - editor, - selector: ({ editor: ed }) => - ed - ? { - bold: ed.isActive("bold"), - italic: ed.isActive("italic"), - strike: ed.isActive("strike"), - code: ed.isActive("code"), - link: ed.isActive("link"), - bulletList: ed.isActive("bulletList"), - orderedList: ed.isActive("orderedList"), - blockquote: ed.isActive("blockquote"), - } - : null, - }); - if (!editor || !activeStates) return null; const items = [ @@ -163,15 +188,25 @@ export const FormattingToolbar = React.memo(function FormattingToolbar({ {items.map((item) => ( - item.action()} - className="h-7 w-7 min-w-7 [&_svg]:size-3.5" + onClick={() => item.action()} + className={cn( + "inline-flex h-7 w-7 min-w-7 items-center justify-center rounded-md text-sm font-medium transition-colors", + "hover:bg-muted hover:text-foreground", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "disabled:pointer-events-none disabled:opacity-50", + "[&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0", + item.active + ? "bg-primary text-primary-foreground" + : "bg-transparent text-muted-foreground", + )} > - + {"shortcut" in item diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 744fce9107..76e5986c6a 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -9,9 +9,9 @@ import { X, } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; -import { Toggle } from "@/shared/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ComposerEmojiPicker } from "./ComposerEmojiPicker"; import { FormattingToolbar } from "./FormattingToolbar"; @@ -90,15 +90,25 @@ export const MessageComposerToolbar = React.memo( > - onFormattingToggle(!isFormattingOpen)} + className={cn( + "inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors", + "hover:bg-muted hover:text-foreground", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "disabled:pointer-events-none disabled:opacity-50", + "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + isFormattingOpen + ? "bg-primary text-primary-foreground" + : "bg-transparent text-muted-foreground", + )} > - + Formatting @@ -204,15 +214,25 @@ export const MessageComposerToolbar = React.memo( > - onFormattingToggle(!isFormattingOpen)} + className={cn( + "inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors", + "hover:bg-muted hover:text-foreground", + "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "disabled:pointer-events-none disabled:opacity-50", + "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + isFormattingOpen + ? "bg-primary text-primary-foreground" + : "bg-transparent text-muted-foreground", + )} > - + Formatting diff --git a/desktop/src/features/pulse/hooks.ts b/desktop/src/features/pulse/hooks.ts index 64454b3eeb..8bfc2af2c0 100644 --- a/desktop/src/features/pulse/hooks.ts +++ b/desktop/src/features/pulse/hooks.ts @@ -69,8 +69,17 @@ export function usePublishNoteMutation(currentPubkey?: string) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ content, replyTo }: { content: string; replyTo?: string }) => - publishNote(content, replyTo), + mutationFn: ({ + content, + replyTo, + mentionPubkeys, + mediaTags, + }: { + content: string; + replyTo?: string; + mentionPubkeys?: string[]; + mediaTags?: string[][]; + }) => publishNote(content, replyTo, mentionPubkeys, mediaTags), onSuccess: () => { if (currentPubkey) { void queryClient.invalidateQueries({ diff --git a/desktop/src/features/pulse/ui/ComposeNote.tsx b/desktop/src/features/pulse/ui/ComposeNote.tsx deleted file mode 100644 index 168c5522e0..0000000000 --- a/desktop/src/features/pulse/ui/ComposeNote.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Send } from "lucide-react"; -import * as React from "react"; - -import { Button } from "@/shared/ui/button"; - -type ComposeNoteProps = { - isPending: boolean; - errorMessage?: string; - onPublish: (content: string) => Promise; -}; - -export function ComposeNote({ - isPending, - errorMessage, - onPublish, -}: ComposeNoteProps) { - const [draft, setDraft] = React.useState(""); - const textareaRef = React.useRef(null); - - const canSubmit = draft.trim().length > 0 && !isPending; - - async function handleSubmit() { - if (!canSubmit) return; - try { - await onPublish(draft.trim()); - setDraft(""); - textareaRef.current?.focus(); - } catch { - // Draft preserved — error shown via errorMessage prop. - } - } - - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && canSubmit) { - e.preventDefault(); - void handleSubmit(); - } - } - - return ( -
-
-
-