diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 779ec84d0..099f99271 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -18,6 +18,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -550,6 +561,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -581,8 +598,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -1392,9 +1411,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1733,6 +1754,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.6", ] [[package]] @@ -2201,6 +2223,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2372,6 +2400,38 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nostr" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14ad56c1d9a59f4edc46b17bc64a217b38b99baefddc0080f85ad98a0855336d" +dependencies = [ + "aes", + "async-trait", + "base64 0.22.1", + "bech32", + "bip39", + "bitcoin", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "instant", + "js-sys", + "negentropy 0.3.1", + "negentropy 0.4.3", + "once_cell", + "reqwest 0.12.28", + "scrypt", + "serde", + "serde_json", + "unicode-normalization", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "nostr" version = "0.37.0" @@ -3060,6 +3120,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -3290,6 +3405,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3297,6 +3414,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -3304,6 +3422,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.6", ] [[package]] @@ -3354,6 +3473,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3396,6 +3521,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -3891,10 +4017,11 @@ dependencies = [ name = "sprout" version = "0.1.0" dependencies = [ - "nostr", + "nostr 0.37.0", "reqwest 0.12.28", "serde", "serde_json", + "sprout-core", "tauri", "tauri-build", "tauri-plugin-opener", @@ -3902,6 +4029,19 @@ dependencies = [ "tauri-plugin-window-state", ] +[[package]] +name = "sprout-core" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "nostr 0.36.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5145,6 +5285,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webkit2gtk" version = "2.0.2" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6c089a6a7..e428ccb7f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -26,3 +26,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" nostr = "0.37" reqwest = { version = "0.12", features = ["json"] } +sprout-core = { path = "../../crates/sprout-core" } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index dbb587484..268639839 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, sync::Mutex}; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32}; use reqwest::Method; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sprout_core::PresenceStatus; use tauri_plugin_window_state::StateFlags; pub struct AppState { @@ -37,6 +38,12 @@ pub struct UsersBatchResponse { pub missing: Vec, } +#[derive(Serialize, Deserialize)] +pub struct SetPresenceResponse { + pub status: PresenceStatus, + pub ttl_seconds: u64, +} + #[derive(Serialize, Deserialize)] pub struct ChannelInfo { pub id: String, @@ -141,6 +148,11 @@ struct UpdateProfileBody<'a> { nip05_handle: Option<&'a str>, } +#[derive(Serialize)] +struct SetPresenceBody { + status: PresenceStatus, +} + #[derive(Serialize)] struct GetFeedQuery<'a> { #[serde(skip_serializing_if = "Option::is_none")] @@ -388,6 +400,30 @@ async fn get_users_batch( send_json_request(request).await } +#[tauri::command] +async fn get_presence( + pubkeys: Vec, + state: tauri::State<'_, AppState>, +) -> Result, String> { + if pubkeys.is_empty() { + return Ok(HashMap::new()); + } + + let request = build_authed_request(&state.http_client, Method::GET, "/api/presence", &state)? + .query(&[("pubkeys", pubkeys.join(","))]); + send_json_request(request).await +} + +#[tauri::command] +async fn set_presence( + status: PresenceStatus, + state: tauri::State<'_, AppState>, +) -> Result { + let request = build_authed_request(&state.http_client, Method::PUT, "/api/presence", &state)? + .json(&SetPresenceBody { status }); + send_json_request(request).await +} + #[tauri::command] fn sign_event( kind: u16, @@ -672,6 +708,8 @@ pub fn run() { update_profile, get_user_profile, get_users_batch, + get_presence, + set_presence, get_relay_ws_url, sign_event, create_auth_event, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b2f3c3119..a38097d5a 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,4 +1,3 @@ -import { getCurrentWindow } from "@tauri-apps/api/window"; import * as React from "react"; import { Settings2 } from "lucide-react"; @@ -22,6 +21,11 @@ import { collectMessageAuthorPubkeys, formatTimelineMessages, } from "@/features/messages/lib/formatTimelineMessages"; +import { + usePresenceQuery, + usePresenceSession, +} from "@/features/presence/hooks"; +import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; @@ -67,6 +71,7 @@ export function AppShell() { React.useState(null); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); + const presenceSession = usePresenceSession(identityQuery.data?.pubkey); const homeFeedQuery = useHomeFeedQuery(); const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; @@ -77,6 +82,26 @@ export function AppShell() { const createChannelMutation = useCreateChannelMutation(); const activeChannel = selectedView === "channel" ? selectedChannel : null; const { unreadChannelIds } = useUnreadChannels(channels, activeChannel); + const activeDmParticipantPubkeys = React.useMemo(() => { + if (!activeChannel || activeChannel.channelType !== "dm") { + return []; + } + + const currentPubkey = identityQuery.data?.pubkey?.toLowerCase(); + + return activeChannel.participantPubkeys.filter( + (pubkey) => pubkey.toLowerCase() !== currentPubkey, + ); + }, [activeChannel, identityQuery.data?.pubkey]); + const activeDmPresenceQuery = usePresenceQuery(activeDmParticipantPubkeys, { + enabled: activeDmParticipantPubkeys.length > 0, + }); + const activeDmPresenceStatus = + activeDmParticipantPubkeys.length > 0 + ? activeDmPresenceQuery.data?.[ + activeDmParticipantPubkeys[0]?.toLowerCase() + ] + : null; const messagesQuery = useChannelMessagesQuery(activeChannel); useChannelSubscription(activeChannel); @@ -232,14 +257,17 @@ export function AppShell() { { const createdChannel = await createChannelMutation.mutateAsync({ name, @@ -265,26 +293,16 @@ export function AppShell() { }} onSelectChannel={handleOpenChannel} onSelectSettings={handleOpenSettings} + profile={profileQuery.data} selectedChannelId={selectedChannel?.id ?? null} selectedView={selectedView} unreadChannelIds={unreadChannelIds} /> - {/* Drag strip covering the traffic-light inset area */} -
{ - if (e.button !== 0) return; - const target = e.target as HTMLElement; - if (target.closest('button, a, input, [role="button"]')) return; - e.preventDefault(); - getCurrentWindow().startDragging(); - }} - /> {selectedView === "home" ? ( { setIsChannelManagementOpen(true); }} - size="sm" + size="icon" type="button" variant="outline" > - Manage ) : null } channelType={activeChannel?.channelType} description={channelDescription} + statusBadge={ + activeChannel?.channelType === "dm" && activeDmPresenceStatus ? ( + + ) : null + } title={activeChannel?.name ?? "Channels"} /> )} @@ -343,6 +369,11 @@ export function AppShell() { ) : ( <> diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 5cb77c1fd..52ac636cf 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -29,6 +29,8 @@ import { useUnarchiveChannelMutation, useUpdateChannelMutation, } from "@/features/channels/hooks"; +import { usePresenceQuery } from "@/features/presence/hooks"; +import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import type { Channel, ChannelMember } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; @@ -165,6 +167,10 @@ export function ChannelManagementSheet({ return formatMemberName(left).localeCompare(formatMemberName(right)); }); }, [currentPubkey, membersQuery.data]); + const memberPresenceQuery = usePresenceQuery( + members.map((member) => member.pubkey), + { enabled: open && members.length > 0 }, + ); const selfMember = members.find((member) => member.pubkey === currentPubkey) ?? null; @@ -584,6 +590,17 @@ export function ChannelManagementSheet({

{formatMemberName(member, currentPubkey)}

+ {memberPresenceQuery.data ? ( + + ) : null} {member.role} diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index b27800a4e..6a698c241 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -10,6 +10,7 @@ type ChatHeaderProps = { description: string; channelType?: ChannelType; mode?: "home" | "channel" | "settings"; + statusBadge?: React.ReactNode; }; function ChannelIcon({ @@ -52,6 +53,7 @@ export function ChatHeader({ description, channelType, mode = "channel", + statusBadge, }: ChatHeaderProps) { return (
{title} + {statusBadge ?
{statusBadge}
: null}

pubkey.trim().toLowerCase()))] + .filter((pubkey) => pubkey.length > 0) + .sort(); +} + +function presenceQueryKey(pubkeys: string[]) { + return ["presence", ...normalizePubkeys(pubkeys)] as const; +} + +export function usePresenceQuery( + pubkeys: string[], + options?: { + enabled?: boolean; + }, +) { + const normalizedPubkeys = normalizePubkeys(pubkeys); + const enabled = (options?.enabled ?? true) && normalizedPubkeys.length > 0; + + return useQuery({ + enabled, + queryKey: presenceQueryKey(normalizedPubkeys), + queryFn: () => getPresence(normalizedPubkeys), + staleTime: 15_000, + refetchInterval: 30_000, + }); +} + +export function useSetPresenceMutation(pubkey?: string) { + const queryClient = useQueryClient(); + const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; + + return useMutation({ + mutationFn: async (status: PresenceStatus) => { + try { + return await setPresence(status); + } catch (error) { + if ( + !(error instanceof Error) || + (!error.message.includes("relay returned 404") && + !error.message.includes("relay returned 405")) + ) { + throw error; + } + + await relayClient.sendPresence(status); + + return { + status, + ttlSeconds: status === "offline" ? 0 : PRESENCE_TTL_SECONDS, + }; + } + }, + onSuccess: ({ status }) => { + if (normalizedPubkey.length === 0) { + return; + } + + queryClient.setQueryData( + presenceQueryKey([normalizedPubkey]), + (current = {}) => ({ + ...current, + [normalizedPubkey]: status, + }), + ); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ["presence"] }); + }, + }); +} + +export function usePresenceSession(pubkey?: string) { + const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; + const presenceQuery = usePresenceQuery( + normalizedPubkey.length > 0 ? [normalizedPubkey] : [], + { enabled: normalizedPubkey.length > 0 }, + ); + const setPresenceMutation = useSetPresenceMutation(normalizedPubkey); + const [desiredStatus, setDesiredStatus] = + React.useState(null); + const previousPubkeyRef = React.useRef(normalizedPubkey); + + React.useEffect(() => { + if (previousPubkeyRef.current === normalizedPubkey) { + return; + } + + previousPubkeyRef.current = normalizedPubkey; + setDesiredStatus(null); + }); + + const relayStatus = + normalizedPubkey.length > 0 + ? (presenceQuery.data?.[normalizedPubkey] ?? "offline") + : "offline"; + const currentStatus = desiredStatus ?? relayStatus; + + const updatePresence = React.useCallback( + async (status: PresenceStatus) => { + const previousStatus = currentStatus; + setDesiredStatus(status); + + try { + await setPresenceMutation.mutateAsync(status); + } catch (error) { + setDesiredStatus( + previousStatus === relayStatus ? null : previousStatus, + ); + throw error; + } + }, + [currentStatus, relayStatus, setPresenceMutation], + ); + + const sendHeartbeat = React.useEffectEvent((status: PresenceStatus) => { + void setPresenceMutation.mutateAsync(status).catch(() => { + return; + }); + }); + + React.useEffect(() => { + if ( + normalizedPubkey.length === 0 || + desiredStatus === null || + desiredStatus === "offline" + ) { + return; + } + + const intervalId = window.setInterval(() => { + sendHeartbeat(desiredStatus); + }, PRESENCE_HEARTBEAT_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [desiredStatus, normalizedPubkey]); + + return { + currentStatus, + isLoading: presenceQuery.isLoading, + isPending: setPresenceMutation.isPending, + error: + setPresenceMutation.error instanceof Error + ? setPresenceMutation.error + : presenceQuery.error instanceof Error + ? presenceQuery.error + : null, + setStatus: updatePresence, + }; +} diff --git a/desktop/src/features/presence/lib/presence.ts b/desktop/src/features/presence/lib/presence.ts new file mode 100644 index 000000000..a1897ecb3 --- /dev/null +++ b/desktop/src/features/presence/lib/presence.ts @@ -0,0 +1,23 @@ +import type { PresenceStatus } from "@/shared/api/types"; + +export function getPresenceLabel(status: PresenceStatus) { + switch (status) { + case "online": + return "Online"; + case "away": + return "Away"; + case "offline": + return "Offline"; + } +} + +export function getPresenceDotClassName(status: PresenceStatus) { + switch (status) { + case "online": + return "bg-emerald-500"; + case "away": + return "bg-amber-500"; + case "offline": + return "bg-muted-foreground/35"; + } +} diff --git a/desktop/src/features/presence/ui/PresenceBadge.tsx b/desktop/src/features/presence/ui/PresenceBadge.tsx new file mode 100644 index 000000000..44f0380f6 --- /dev/null +++ b/desktop/src/features/presence/ui/PresenceBadge.tsx @@ -0,0 +1,56 @@ +import type * as React from "react"; + +import type { PresenceStatus } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +import { + getPresenceDotClassName, + getPresenceLabel, +} from "@/features/presence/lib/presence"; + +type PresenceBadgeProps = { + status: PresenceStatus; + className?: string; + label?: string; +} & React.HTMLAttributes; + +export function PresenceDot({ + status, + className, + ...props +}: { + status: PresenceStatus; + className?: string; +} & React.HTMLAttributes) { + return ( +

+ {initials.length > 0 ? initials : } +
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 77fb3be2d..6cf8c23e9 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -15,6 +15,12 @@ import { useProfileQuery, useUpdateProfileMutation, } from "@/features/profile/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + PresenceBadge, + PresenceDot, +} from "@/features/presence/ui/PresenceBadge"; +import type { PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { Button } from "@/shared/ui/button"; @@ -25,6 +31,11 @@ import { Textarea } from "@/shared/ui/textarea"; type SettingsViewProps = { currentPubkey?: string; fallbackDisplayName?: string; + isPresenceLoading: boolean; + isUpdatingPresence: boolean; + onSetPresence: (status: PresenceStatus) => Promise; + presenceError: Error | null; + presenceStatus: PresenceStatus; }; type ThemeOption = { @@ -51,6 +62,28 @@ const themeOptions: ThemeOption[] = [ }, ]; +const presenceOptions: Array<{ + value: PresenceStatus; + label: string; + description: string; +}> = [ + { + value: "online", + label: "Online", + description: "Visible as active and refreshed while the app is open.", + }, + { + value: "away", + label: "Away", + description: "Visible as idle and refreshed while the app is open.", + }, + { + value: "offline", + label: "Offline", + description: "Clears your relay presence entry immediately.", + }, +]; + function Section({ title, description, @@ -94,44 +127,6 @@ function ReadOnlyField({ ); } -function AvatarPreview({ - avatarUrl, - label, -}: { - avatarUrl: string | null; - label: string; -}) { - const [hasError, setHasError] = React.useState(false); - - const initials = label - .trim() - .split(/\s+/) - .map((part) => part[0] ?? "") - .join("") - .slice(0, 2) - .toUpperCase(); - - if (avatarUrl && !hasError) { - return ( - {`${label} { - setHasError(true); - }} - referrerPolicy="no-referrer" - src={avatarUrl} - /> - ); - } - - return ( -
- {initials.length > 0 ? initials : } -
- ); -} - function ThemeSettingsCard() { const { setTheme, theme } = useTheme(); @@ -179,10 +174,96 @@ function ThemeSettingsCard() { ); } +function PresenceStatusBadge({ status }: { status: PresenceStatus }) { + return ( + + ); +} + +function PresenceSettingsCard({ + isLoading, + isUpdating, + onSetPresence, + presenceError, + presenceStatus, +}: { + isLoading: boolean; + isUpdating: boolean; + onSetPresence: (status: PresenceStatus) => Promise; + presenceError: Error | null; + presenceStatus: PresenceStatus; +}) { + return ( +
+
+
+

Presence

+

+ Choose how this desktop session appears on the relay. +

+
+ +
+ +
+ {presenceOptions.map((option) => { + const isActive = presenceStatus === option.value; + + return ( + + ); + })} +
+ + {presenceError ? ( +

+ {presenceError.message} +

+ ) : null} + +

+ Online and away are refreshed automatically every minute while Sprout is + running. The relay expires presence after 90 seconds. +

+
+ ); +} + function ProfileSettingsCard({ currentPubkey, fallbackDisplayName, -}: SettingsViewProps) { +}: Pick) { const profileQuery = useProfileQuery(); const updateProfileMutation = useUpdateProfileMutation(); const profile = profileQuery.data; @@ -252,8 +333,10 @@ function ProfileSettingsCard({ data-testid="settings-profile" >
- @@ -435,6 +518,11 @@ function ProfileSettingsCard({ export function SettingsView({ currentPubkey, fallbackDisplayName, + isPresenceLoading, + isUpdatingPresence, + onSetPresence, + presenceError, + presenceStatus, }: SettingsViewProps) { return (
+ void; }) { return ( @@ -87,13 +89,22 @@ function ChannelMenuButton({ > {channel.name} - {hasUnread && !isActive ? ( -