mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Add desktop presence UI (#35)
This commit is contained in:
152
desktop/src-tauri/Cargo.lock
generated
152
desktop/src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<HashMap<String, PresenceStatus>, 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<SetPresenceResponse, String> {
|
||||
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,
|
||||
|
||||
@@ -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<RelayEvent | null>(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() {
|
||||
<SidebarTrigger className="fixed left-[80px] top-[9px] z-50 h-6 w-6" />
|
||||
<AppSidebar
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeUrgentCount={homeUrgentCount}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
onCreateChannel={async ({ description, name }) => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<SidebarInset
|
||||
className="relative min-h-0 min-w-0 overflow-hidden pt-7"
|
||||
className="min-h-0 min-w-0 overflow-hidden"
|
||||
key={contentPaneKey}
|
||||
>
|
||||
{/* Drag strip covering the traffic-light inset area */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 flex h-7 items-center px-2"
|
||||
onPointerDown={(e) => {
|
||||
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" ? (
|
||||
<ChatHeader
|
||||
description="Personalized feed for mentions, reminders, channel activity, and agent work."
|
||||
@@ -302,21 +320,29 @@ export function AppShell() {
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<Button
|
||||
aria-label="Manage channel"
|
||||
data-testid="channel-management-trigger"
|
||||
onClick={() => {
|
||||
setIsChannelManagementOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Manage
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
description={channelDescription}
|
||||
statusBadge={
|
||||
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
|
||||
<PresenceBadge
|
||||
data-testid="chat-presence-badge"
|
||||
status={activeDmPresenceStatus}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
title={activeChannel?.name ?? "Channels"}
|
||||
/>
|
||||
)}
|
||||
@@ -343,6 +369,11 @@ export function AppShell() {
|
||||
<SettingsView
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isPresenceLoading={presenceSession.isLoading}
|
||||
isUpdatingPresence={presenceSession.isPending}
|
||||
onSetPresence={presenceSession.setStatus}
|
||||
presenceError={presenceSession.error}
|
||||
presenceStatus={presenceSession.currentStatus}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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({
|
||||
<p className="truncate text-sm font-medium">
|
||||
{formatMemberName(member, currentPubkey)}
|
||||
</p>
|
||||
{memberPresenceQuery.data ? (
|
||||
<PresenceBadge
|
||||
className="border-border/70 bg-muted/50 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em]"
|
||||
data-testid={`member-presence-${member.pubkey}`}
|
||||
status={
|
||||
memberPresenceQuery.data[
|
||||
member.pubkey.toLowerCase()
|
||||
] ?? "offline"
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{member.role}
|
||||
</span>
|
||||
|
||||
@@ -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 (
|
||||
<header
|
||||
@@ -68,6 +70,7 @@ export function ChatHeader({
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{statusBadge ? <div className="shrink-0">{statusBadge}</div> : null}
|
||||
</div>
|
||||
<p
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
|
||||
162
desktop/src/features/presence/hooks.ts
Normal file
162
desktop/src/features/presence/hooks.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import * as React from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { getPresence, setPresence } from "@/shared/api/tauri";
|
||||
import type { PresenceLookup, PresenceStatus } from "@/shared/api/types";
|
||||
|
||||
const PRESENCE_HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
const PRESENCE_TTL_SECONDS = 90;
|
||||
|
||||
function normalizePubkeys(pubkeys: string[]) {
|
||||
return [...new Set(pubkeys.map((pubkey) => 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<PresenceLookup>({
|
||||
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<PresenceLookup>(
|
||||
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<PresenceStatus | null>(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,
|
||||
};
|
||||
}
|
||||
23
desktop/src/features/presence/lib/presence.ts
Normal file
23
desktop/src/features/presence/lib/presence.ts
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
56
desktop/src/features/presence/ui/PresenceBadge.tsx
Normal file
56
desktop/src/features/presence/ui/PresenceBadge.tsx
Normal file
@@ -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<HTMLSpanElement>;
|
||||
|
||||
export function PresenceDot({
|
||||
status,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
status: PresenceStatus;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-flex h-2.5 w-2.5 shrink-0 rounded-full",
|
||||
getPresenceDotClassName(status),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PresenceBadge({
|
||||
status,
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: PresenceBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PresenceDot status={status} />
|
||||
<span>{label ?? getPresenceLabel(status)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
57
desktop/src/features/profile/ui/ProfileAvatar.tsx
Normal file
57
desktop/src/features/profile/ui/ProfileAvatar.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { UserRound } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type ProfileAvatarProps = {
|
||||
avatarUrl: string | null;
|
||||
label: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export function ProfileAvatar({
|
||||
avatarUrl,
|
||||
label,
|
||||
className,
|
||||
iconClassName,
|
||||
testId,
|
||||
}: ProfileAvatarProps) {
|
||||
const [failedAvatarUrl, setFailedAvatarUrl] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const initials = label
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => part[0] ?? "")
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
const baseClassName = cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden border border-border/80 bg-primary/10 text-primary shadow-sm",
|
||||
className,
|
||||
);
|
||||
|
||||
if (avatarUrl && failedAvatarUrl !== avatarUrl) {
|
||||
return (
|
||||
<img
|
||||
alt={`${label} avatar`}
|
||||
className={cn(baseClassName, "object-cover")}
|
||||
data-testid={testId}
|
||||
onError={() => {
|
||||
setFailedAvatarUrl(avatarUrl);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(baseClassName, "font-semibold")} data-testid={testId}>
|
||||
{initials.length > 0 ? initials : <UserRound className={iconClassName} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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 (
|
||||
<img
|
||||
alt={`${label} avatar`}
|
||||
className="h-16 w-16 rounded-3xl border border-border/80 object-cover shadow-sm"
|
||||
onError={() => {
|
||||
setHasError(true);
|
||||
}}
|
||||
referrerPolicy="no-referrer"
|
||||
src={avatarUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-3xl border border-border/80 bg-primary/10 text-lg font-semibold text-primary shadow-sm">
|
||||
{initials.length > 0 ? initials : <UserRound className="h-6 w-6" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSettingsCard() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
@@ -179,10 +174,96 @@ function ThemeSettingsCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function PresenceStatusBadge({ status }: { status: PresenceStatus }) {
|
||||
return (
|
||||
<PresenceBadge data-testid="presence-current-status" status={status} />
|
||||
);
|
||||
}
|
||||
|
||||
function PresenceSettingsCard({
|
||||
isLoading,
|
||||
isUpdating,
|
||||
onSetPresence,
|
||||
presenceError,
|
||||
presenceStatus,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
isUpdating: boolean;
|
||||
onSetPresence: (status: PresenceStatus) => Promise<void>;
|
||||
presenceError: Error | null;
|
||||
presenceStatus: PresenceStatus;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
|
||||
data-testid="settings-presence"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Presence</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how this desktop session appears on the relay.
|
||||
</p>
|
||||
</div>
|
||||
<PresenceStatusBadge status={presenceStatus} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 md:grid-cols-3">
|
||||
{presenceOptions.map((option) => {
|
||||
const isActive = presenceStatus === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"flex min-h-24 flex-col items-start justify-between rounded-xl border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
data-testid={`presence-option-${option.value}`}
|
||||
disabled={isLoading || isUpdating}
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
void onSetPresence(option.value);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PresenceDot className="h-4 w-4" status={option.value} />
|
||||
<span className="font-medium text-foreground">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{presenceError ? (
|
||||
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{presenceError.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p
|
||||
className="mt-4 text-sm text-muted-foreground"
|
||||
data-testid="presence-help"
|
||||
>
|
||||
Online and away are refreshed automatically every minute while Sprout is
|
||||
running. The relay expires presence after 90 seconds.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileSettingsCard({
|
||||
currentPubkey,
|
||||
fallbackDisplayName,
|
||||
}: SettingsViewProps) {
|
||||
}: Pick<SettingsViewProps, "currentPubkey" | "fallbackDisplayName">) {
|
||||
const profileQuery = useProfileQuery();
|
||||
const updateProfileMutation = useUpdateProfileMutation();
|
||||
const profile = profileQuery.data;
|
||||
@@ -252,8 +333,10 @@ function ProfileSettingsCard({
|
||||
data-testid="settings-profile"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<AvatarPreview
|
||||
<ProfileAvatar
|
||||
avatarUrl={resolvedAvatarUrl}
|
||||
className="h-16 w-16 rounded-3xl text-lg"
|
||||
iconClassName="h-6 w-6"
|
||||
key={resolvedAvatarUrl ?? "profile-fallback-avatar"}
|
||||
label={resolvedName}
|
||||
/>
|
||||
@@ -435,6 +518,11 @@ function ProfileSettingsCard({
|
||||
export function SettingsView({
|
||||
currentPubkey,
|
||||
fallbackDisplayName,
|
||||
isPresenceLoading,
|
||||
isUpdatingPresence,
|
||||
onSetPresence,
|
||||
presenceError,
|
||||
presenceStatus,
|
||||
}: SettingsViewProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -443,6 +531,13 @@ export function SettingsView({
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
|
||||
<ThemeSettingsCard />
|
||||
<PresenceSettingsCard
|
||||
isLoading={isPresenceLoading}
|
||||
isUpdating={isUpdatingPresence}
|
||||
onSetPresence={onSetPresence}
|
||||
presenceError={presenceError}
|
||||
presenceStatus={presenceStatus}
|
||||
/>
|
||||
<ProfileSettingsCard
|
||||
currentPubkey={currentPubkey}
|
||||
fallbackDisplayName={fallbackDisplayName}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import {
|
||||
CircleDot,
|
||||
FileText,
|
||||
Hash,
|
||||
Home,
|
||||
Plus,
|
||||
Search,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { CircleDot, FileText, Hash, Home, Plus, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { getPresenceLabel } from "@/features/presence/lib/presence";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import type { Channel, PresenceStatus, Profile } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
@@ -32,8 +28,12 @@ import {
|
||||
|
||||
type AppSidebarProps = {
|
||||
channels: Channel[];
|
||||
currentPubkey?: string;
|
||||
fallbackDisplayName?: string;
|
||||
isLoading: boolean;
|
||||
isCreatingChannel: boolean;
|
||||
profile?: Profile;
|
||||
selfPresenceStatus: PresenceStatus;
|
||||
errorMessage?: string;
|
||||
homeUrgentCount?: number;
|
||||
selectedChannelId: string | null;
|
||||
@@ -65,11 +65,13 @@ function ChannelMenuButton({
|
||||
channel,
|
||||
isActive,
|
||||
hasUnread,
|
||||
presenceStatus,
|
||||
onSelectChannel,
|
||||
}: {
|
||||
channel: Channel;
|
||||
isActive: boolean;
|
||||
hasUnread: boolean;
|
||||
presenceStatus?: PresenceStatus;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -87,13 +89,22 @@ function ChannelMenuButton({
|
||||
>
|
||||
<SidebarChannelIcon channel={channel} />
|
||||
<span className="min-w-0 flex-1 truncate">{channel.name}</span>
|
||||
{hasUnread && !isActive ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-auto h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
|
||||
data-testid={`channel-unread-${channel.name}`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{presenceStatus ? (
|
||||
<PresenceDot
|
||||
className="h-2 w-2"
|
||||
data-testid={`channel-presence-${channel.name}`}
|
||||
status={presenceStatus}
|
||||
/>
|
||||
) : null}
|
||||
{hasUnread && !isActive ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full bg-primary"
|
||||
data-testid={`channel-unread-${channel.name}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +112,7 @@ function ChannelMenuButton({
|
||||
function SidebarSection({
|
||||
items,
|
||||
isActiveChannel,
|
||||
presenceByChannelId,
|
||||
selectedChannelId,
|
||||
title,
|
||||
testId,
|
||||
@@ -109,6 +121,7 @@ function SidebarSection({
|
||||
}: {
|
||||
items: Channel[];
|
||||
isActiveChannel: boolean;
|
||||
presenceByChannelId?: Record<string, PresenceStatus>;
|
||||
selectedChannelId: string | null;
|
||||
title: string;
|
||||
testId: string;
|
||||
@@ -130,6 +143,7 @@ function SidebarSection({
|
||||
channel={channel}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
isActive={isActiveChannel && selectedChannelId === channel.id}
|
||||
presenceStatus={presenceByChannelId?.[channel.id]}
|
||||
onSelectChannel={onSelectChannel}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
@@ -176,8 +190,8 @@ function StreamsSection({
|
||||
unreadChannelIds: Set<string>;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Streams</SidebarGroupLabel>
|
||||
<SidebarGroup className="pt-1">
|
||||
<SidebarGroupLabel>Channels</SidebarGroupLabel>
|
||||
<SidebarGroupAction
|
||||
aria-expanded={isCreateOpen}
|
||||
aria-label={isCreateOpen ? "Close new stream form" : "Create a stream"}
|
||||
@@ -264,8 +278,12 @@ function StreamsSection({
|
||||
|
||||
export function AppSidebar({
|
||||
channels,
|
||||
currentPubkey,
|
||||
fallbackDisplayName,
|
||||
isLoading,
|
||||
isCreatingChannel,
|
||||
profile,
|
||||
selfPresenceStatus,
|
||||
errorMessage,
|
||||
homeUrgentCount,
|
||||
selectedChannelId,
|
||||
@@ -294,6 +312,41 @@ export function AppSidebar({
|
||||
const directMessages = channels.filter(
|
||||
(channel) => channel.channelType === "dm",
|
||||
);
|
||||
const dmParticipantPubkeys = React.useMemo(
|
||||
() =>
|
||||
directMessages
|
||||
.flatMap((channel) => channel.participantPubkeys)
|
||||
.filter(
|
||||
(pubkey) => pubkey.toLowerCase() !== currentPubkey?.toLowerCase(),
|
||||
),
|
||||
[currentPubkey, directMessages],
|
||||
);
|
||||
const dmPresenceQuery = usePresenceQuery(dmParticipantPubkeys, {
|
||||
enabled: directMessages.length > 0,
|
||||
});
|
||||
const dmPresenceByChannelId = React.useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
directMessages.map((channel) => {
|
||||
const otherParticipantPubkey = channel.participantPubkeys.find(
|
||||
(pubkey) => pubkey.toLowerCase() !== currentPubkey?.toLowerCase(),
|
||||
);
|
||||
|
||||
return [
|
||||
channel.id,
|
||||
otherParticipantPubkey
|
||||
? (dmPresenceQuery.data?.[otherParticipantPubkey.toLowerCase()] ??
|
||||
"offline")
|
||||
: "offline",
|
||||
];
|
||||
}),
|
||||
) satisfies Record<string, PresenceStatus>,
|
||||
[currentPubkey, directMessages, dmPresenceQuery.data],
|
||||
);
|
||||
const resolvedDisplayName =
|
||||
profile?.displayName?.trim() ||
|
||||
fallbackDisplayName?.trim() ||
|
||||
"Current identity";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isCreateOpen) {
|
||||
@@ -345,20 +398,9 @@ export function AppSidebar({
|
||||
variant="sidebar"
|
||||
>
|
||||
<SidebarHeader
|
||||
className="gap-3 pt-7"
|
||||
className="gap-3 pt-12"
|
||||
onPointerDown={handleDragPointerDown}
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-xl bg-sidebar-accent/80 px-3 py-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-xl text-lg">
|
||||
<span aria-hidden="true">🌱</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">Sprout</p>
|
||||
<p className="truncate text-xs text-sidebar-foreground/65">
|
||||
Humans and agents, together
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full justify-between rounded-xl border border-sidebar-border/80 bg-sidebar-accent/60 px-3 text-sidebar-foreground/80 shadow-sm hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
data-testid="open-search"
|
||||
@@ -373,34 +415,29 @@ export function AppSidebar({
|
||||
</span>
|
||||
<span className="text-xs text-sidebar-foreground/50">⌘K</span>
|
||||
</Button>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedView === "home"}
|
||||
onClick={onSelectHome}
|
||||
tooltip="Home"
|
||||
type="button"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
{homeUrgentCount && homeUrgentCount > 0 ? (
|
||||
<span className="ml-auto rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-primary-foreground">
|
||||
{homeUrgentCount}
|
||||
</span>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarSeparator />
|
||||
<SidebarSeparator className="mx-0 w-full" />
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={selectedView === "home"}
|
||||
onClick={onSelectHome}
|
||||
tooltip="Home"
|
||||
type="button"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
{homeUrgentCount && homeUrgentCount > 0 ? (
|
||||
<span className="ml-auto rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-primary-foreground">
|
||||
{homeUrgentCount}
|
||||
</span>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{isLoading ? (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Channels</SidebarGroupLabel>
|
||||
@@ -463,6 +500,7 @@ export function AppSidebar({
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
items={directMessages}
|
||||
onSelectChannel={onSelectChannel}
|
||||
presenceByChannelId={dmPresenceByChannelId}
|
||||
selectedChannelId={selectedChannelId}
|
||||
testId="dm-list"
|
||||
title="Direct Messages"
|
||||
@@ -484,24 +522,55 @@ export function AppSidebar({
|
||||
) : null}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarSeparator className="mx-0 w-full" />
|
||||
|
||||
<SidebarFooter>
|
||||
<div className="w-full border-t border-sidebar-border/70 pt-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="rounded-xl"
|
||||
data-testid="open-settings"
|
||||
isActive={selectedView === "settings"}
|
||||
onClick={onSelectSettings}
|
||||
tooltip="Settings"
|
||||
type="button"
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
aria-pressed={selectedView === "settings"}
|
||||
className="h-auto gap-3 rounded-xl px-2 py-2"
|
||||
data-testid="open-settings"
|
||||
isActive={selectedView === "settings"}
|
||||
onClick={onSelectSettings}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
data-testid="sidebar-profile-card"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
<div className="relative shrink-0">
|
||||
<ProfileAvatar
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="h-10 w-10 rounded-2xl text-sm"
|
||||
iconClassName="h-5 w-5"
|
||||
label={resolvedDisplayName}
|
||||
testId="sidebar-profile-avatar"
|
||||
/>
|
||||
<span
|
||||
aria-label={getPresenceLabel(selfPresenceStatus)}
|
||||
className="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar"
|
||||
data-testid="self-presence-badge"
|
||||
role="img"
|
||||
>
|
||||
<PresenceDot
|
||||
className="h-2.5 w-2.5"
|
||||
status={selfPresenceStatus}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-current"
|
||||
data-testid="sidebar-profile-name"
|
||||
>
|
||||
{resolvedDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getRelayWsUrl,
|
||||
signRelayEvent,
|
||||
} from "@/shared/api/tauri";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
type RelaySubscriptionFilter = {
|
||||
kinds: number[];
|
||||
@@ -125,29 +125,27 @@ class RelayClient {
|
||||
tags: [["h", channelId]],
|
||||
});
|
||||
|
||||
return new Promise<RelayEvent>((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
this.pendingEvents.delete(event.id);
|
||||
reject(new Error("Timed out while sending the message."));
|
||||
}, 8_000);
|
||||
return this.publishEvent(
|
||||
event,
|
||||
"Timed out while sending the message.",
|
||||
"Failed to send the message.",
|
||||
);
|
||||
}
|
||||
|
||||
this.pendingEvents.set(event.id, {
|
||||
event,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
async sendPresence(status: PresenceStatus) {
|
||||
await this.ensureConnected();
|
||||
|
||||
void this.sendRaw(["EVENT", event]).catch((error) => {
|
||||
window.clearTimeout(timeout);
|
||||
this.pendingEvents.delete(event.id);
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Failed to send the message."),
|
||||
);
|
||||
});
|
||||
const event = await signRelayEvent({
|
||||
kind: 20001,
|
||||
content: status,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
return this.publishEvent(
|
||||
event,
|
||||
"Timed out while updating presence.",
|
||||
"Failed to update presence.",
|
||||
);
|
||||
}
|
||||
|
||||
async subscribeToChannel(
|
||||
@@ -260,6 +258,32 @@ class RelayClient {
|
||||
await this.sendRaw(["CLOSE", subId]);
|
||||
}
|
||||
|
||||
private publishEvent(
|
||||
event: RelayEvent,
|
||||
timeoutMessage: string,
|
||||
sendErrorMessage: string,
|
||||
) {
|
||||
return new Promise<RelayEvent>((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
this.pendingEvents.delete(event.id);
|
||||
reject(new Error(timeoutMessage));
|
||||
}, 8_000);
|
||||
|
||||
this.pendingEvents.set(event.id, {
|
||||
event,
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
void this.sendRaw(["EVENT", event]).catch((error) => {
|
||||
window.clearTimeout(timeout);
|
||||
this.pendingEvents.delete(event.id);
|
||||
reject(error instanceof Error ? error : new Error(sendErrorMessage));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleWsMessage(message: unknown) {
|
||||
if (
|
||||
typeof message === "object" &&
|
||||
|
||||
@@ -11,10 +11,13 @@ import type {
|
||||
GetHomeFeedInput,
|
||||
HomeFeedResponse,
|
||||
Identity,
|
||||
PresenceLookup,
|
||||
PresenceStatus,
|
||||
Profile,
|
||||
RelayEvent,
|
||||
SearchMessagesInput,
|
||||
SearchMessagesResponse,
|
||||
SetPresenceResult,
|
||||
SetChannelPurposeInput,
|
||||
SetChannelTopicInput,
|
||||
UpdateProfileInput,
|
||||
@@ -46,6 +49,13 @@ type RawUsersBatchResponse = {
|
||||
missing: string[];
|
||||
};
|
||||
|
||||
type RawPresenceLookup = Record<string, PresenceStatus>;
|
||||
|
||||
type RawSetPresenceResult = {
|
||||
status: PresenceStatus;
|
||||
ttl_seconds: number;
|
||||
};
|
||||
|
||||
type RawChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -268,6 +278,32 @@ export async function getUsersBatch(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPresence(pubkeys: string[]): Promise<PresenceLookup> {
|
||||
const response = await invoke<RawPresenceLookup>("get_presence", {
|
||||
pubkeys,
|
||||
});
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(response).map(([pubkey, status]) => [
|
||||
pubkey.toLowerCase(),
|
||||
status,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function setPresence(
|
||||
status: PresenceStatus,
|
||||
): Promise<SetPresenceResult> {
|
||||
const response = await invoke<RawSetPresenceResult>("set_presence", {
|
||||
status,
|
||||
});
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
ttlSeconds: response.ttl_seconds,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelayWsUrl(): Promise<string> {
|
||||
return invoke<string>("get_relay_ws_url");
|
||||
}
|
||||
|
||||
@@ -104,6 +104,15 @@ export type UpdateProfileInput = {
|
||||
nip05Handle?: string;
|
||||
};
|
||||
|
||||
export type PresenceStatus = "online" | "away" | "offline";
|
||||
|
||||
export type PresenceLookup = Record<string, PresenceStatus>;
|
||||
|
||||
export type SetPresenceResult = {
|
||||
status: PresenceStatus;
|
||||
ttlSeconds: number;
|
||||
};
|
||||
|
||||
export type RelayEvent = {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
|
||||
@@ -35,6 +35,15 @@ type RawUsersBatchResponse = {
|
||||
missing: string[];
|
||||
};
|
||||
|
||||
type PresenceStatus = "online" | "away" | "offline";
|
||||
|
||||
type RawPresenceLookup = Record<string, PresenceStatus>;
|
||||
|
||||
type RawSetPresenceResponse = {
|
||||
status: PresenceStatus;
|
||||
ttl_seconds: number;
|
||||
};
|
||||
|
||||
type RawChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -169,6 +178,7 @@ const CHARLIE_PUBKEY =
|
||||
const OUTSIDER_PUBKEY =
|
||||
"df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e";
|
||||
const MOCK_IDENTITY_PUBKEY = DEFAULT_MOCK_IDENTITY.pubkey;
|
||||
const MOCK_PRESENCE_TTL_SECONDS = 90;
|
||||
|
||||
const mockDisplayNames = new Map<string, string>([
|
||||
[MOCK_IDENTITY_PUBKEY, DEFAULT_MOCK_IDENTITY.display_name],
|
||||
@@ -568,6 +578,14 @@ const mockProfiles = new Map<string, RawProfile>([
|
||||
},
|
||||
],
|
||||
]);
|
||||
const mockPresence = new Map<string, PresenceStatus>([
|
||||
[MOCK_IDENTITY_PUBKEY, "offline"],
|
||||
[DEFAULT_REAL_IDENTITY.pubkey, "offline"],
|
||||
[ALICE_PUBKEY, "online"],
|
||||
[BOB_PUBKEY, "away"],
|
||||
[CHARLIE_PUBKEY, "online"],
|
||||
[OUTSIDER_PUBKEY, "offline"],
|
||||
]);
|
||||
|
||||
let installed = false;
|
||||
let nextSocketId = 1;
|
||||
@@ -631,6 +649,14 @@ function applyMockDisplayName(pubkey: string, displayName: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function getMockPresenceStatus(pubkey: string): PresenceStatus {
|
||||
return mockPresence.get(pubkey.toLowerCase()) ?? "offline";
|
||||
}
|
||||
|
||||
function setMockPresenceStatus(pubkey: string, status: PresenceStatus) {
|
||||
mockPresence.set(pubkey.toLowerCase(), status);
|
||||
}
|
||||
|
||||
function resolveHandler(handler: unknown): WsHandler {
|
||||
if (typeof handler === "function") {
|
||||
return handler as WsHandler;
|
||||
@@ -956,6 +982,58 @@ async function handleGetUsersBatch(
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGetPresence(
|
||||
args: {
|
||||
pubkeys: string[];
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
return Object.fromEntries(
|
||||
args.pubkeys.map((pubkey) => [
|
||||
pubkey.toLowerCase(),
|
||||
getMockPresenceStatus(pubkey),
|
||||
]),
|
||||
) satisfies RawPresenceLookup;
|
||||
}
|
||||
|
||||
if (args.pubkeys.length === 0) {
|
||||
return {} satisfies RawPresenceLookup;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("pubkeys", args.pubkeys.join(","));
|
||||
return relayJsonRequest<RawPresenceLookup>(
|
||||
config,
|
||||
`/api/presence?${searchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSetPresence(
|
||||
args: {
|
||||
status: PresenceStatus;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
setMockPresenceStatus(getMockMemberPubkey(config), args.status);
|
||||
|
||||
return {
|
||||
status: args.status,
|
||||
ttl_seconds: args.status === "offline" ? 0 : MOCK_PRESENCE_TTL_SECONDS,
|
||||
} satisfies RawSetPresenceResponse;
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawSetPresenceResponse>(config, "/api/presence", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
status: args.status,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateChannel(
|
||||
args: {
|
||||
name: string;
|
||||
@@ -1797,6 +1875,18 @@ export function maybeInstallE2eTauriMocks() {
|
||||
payload as Parameters<typeof handleGetUsersBatch>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "get_presence":
|
||||
return handleGetPresence(
|
||||
(payload as Parameters<typeof handleGetPresence>[0]) ?? {
|
||||
pubkeys: [],
|
||||
},
|
||||
activeConfig,
|
||||
);
|
||||
case "set_presence":
|
||||
return handleSetPresence(
|
||||
payload as Parameters<typeof handleSetPresence>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "get_relay_ws_url":
|
||||
return getRelayWsUrl(activeConfig);
|
||||
case "get_channels":
|
||||
|
||||
@@ -46,6 +46,32 @@ test("sidebar shows all channel types", async ({ page }) => {
|
||||
await expect(dmList).toContainText("bob-tyler");
|
||||
});
|
||||
|
||||
test("shows presence in sidebar, DM header, and member list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("sidebar-profile-card")).toBeVisible();
|
||||
await expect(page.getByTestId("self-presence-badge")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Offline",
|
||||
);
|
||||
await expect(page.getByTestId("channel-presence-alice-tyler")).toBeVisible();
|
||||
|
||||
await page.getByTestId("channel-alice-tyler").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler");
|
||||
await expect(page.getByTestId("chat-presence-badge")).toContainText("Online");
|
||||
|
||||
await openChannelManagement(page, "general");
|
||||
await expect(
|
||||
page.getByTestId(`member-presence-${TEST_IDENTITIES.alice.pubkey}`),
|
||||
).toContainText("Online");
|
||||
await expect(
|
||||
page.getByTestId(`member-presence-${TEST_IDENTITIES.bob.pubkey}`),
|
||||
).toContainText("Away");
|
||||
await closeChannelManagement(page);
|
||||
});
|
||||
|
||||
test("create stream with name and description", async ({ page }) => {
|
||||
const channelName = `my-new-stream-${Date.now()}`;
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Settings");
|
||||
await expect(page.getByTestId("open-settings")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef");
|
||||
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
|
||||
@@ -40,6 +44,10 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
await expect(page.getByTestId("open-settings")).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
);
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
@@ -54,6 +62,34 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
await expect(page.getByTestId("profile-about")).toHaveValue(about);
|
||||
});
|
||||
|
||||
test("updates presence from settings", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await expect(page.getByTestId("presence-current-status")).toContainText(
|
||||
"Offline",
|
||||
);
|
||||
|
||||
await page.getByTestId("presence-option-away").click();
|
||||
await expect(page.getByTestId("presence-current-status")).toContainText(
|
||||
"Away",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("presence-current-status")).toContainText(
|
||||
"Away",
|
||||
);
|
||||
|
||||
await page.getByTestId("presence-option-offline").click();
|
||||
await expect(page.getByTestId("presence-current-status")).toContainText(
|
||||
"Offline",
|
||||
);
|
||||
});
|
||||
|
||||
test("opens settings with the keyboard shortcut and updates theme", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user