Add desktop presence UI (#35)

This commit is contained in:
Wes
2026-03-11 15:26:01 -07:00
committed by GitHub
parent 397564b434
commit d94e3fbf77
18 changed files with 1072 additions and 149 deletions

View File

@@ -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"

View File

@@ -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" }

View File

@@ -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,

View File

@@ -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}
/>
) : (
<>

View File

@@ -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>

View File

@@ -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"

View 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,
};
}

View 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";
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -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}

View File

@@ -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">&#x2318;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>
);

View File

@@ -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" &&

View File

@@ -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");
}

View File

@@ -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;

View File

@@ -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":

View File

@@ -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()}`;

View File

@@ -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,
}) => {