From d932984047bc016777f3ce8d51dbef9d64624215 Mon Sep 17 00:00:00 2001
From: Wes
Date: Tue, 10 Mar 2026 17:11:49 -0700
Subject: [PATCH] Add channel management e2e coverage (#24)
---
desktop/src-tauri/src/lib.rs | 346 +++++--
desktop/src/app/AppShell.tsx | 61 +-
desktop/src/features/channels/hooks.ts | 289 +++++-
.../channels/ui/ChannelManagementSheet.tsx | 738 +++++++++++++++
desktop/src/features/chat/ui/ChatHeader.tsx | 5 +
desktop/src/shared/api/tauri.ts | 150 +++
desktop/src/shared/api/types.ts | 57 ++
desktop/src/testing/e2eBridge.ts | 871 ++++++++++++++++--
desktop/tests/e2e/channels.spec.ts | 171 +++-
desktop/tests/e2e/integration.spec.ts | 136 ++-
10 files changed, 2680 insertions(+), 144 deletions(-)
create mode 100644 desktop/src/features/channels/ui/ChannelManagementSheet.tsx
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 0f0b442ef..e709babbe 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -1,7 +1,8 @@
use std::sync::Mutex;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32};
-use serde::{Deserialize, Serialize};
+use reqwest::Method;
+use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tauri_plugin_window_state::StateFlags;
pub struct AppState {
@@ -20,11 +21,60 @@ pub struct ChannelInfo {
pub id: String,
pub name: String,
pub channel_type: String,
+ pub visibility: String,
pub description: String,
+ pub topic: Option,
+ pub purpose: Option,
+ pub member_count: i64,
+ pub last_message_at: Option,
+ pub archived_at: Option,
pub participants: Vec,
pub participant_pubkeys: Vec,
}
+#[derive(Serialize, Deserialize)]
+pub struct ChannelDetailInfo {
+ pub id: String,
+ pub name: String,
+ pub channel_type: String,
+ pub visibility: String,
+ pub description: String,
+ pub topic: Option,
+ pub topic_set_by: Option,
+ pub topic_set_at: Option,
+ pub purpose: Option,
+ pub purpose_set_by: Option,
+ pub purpose_set_at: Option,
+ pub created_by: String,
+ pub created_at: String,
+ pub updated_at: String,
+ pub archived_at: Option,
+ pub member_count: i64,
+ pub topic_required: bool,
+ pub max_members: Option,
+ pub nip29_group_id: Option,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct ChannelMemberInfo {
+ pub pubkey: String,
+ pub role: String,
+ pub joined_at: String,
+ pub display_name: Option,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct ChannelMembersResponse {
+ pub members: Vec,
+ pub next_cursor: Option,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct AddMembersResponse {
+ pub added: Vec,
+ pub errors: Vec,
+}
+
#[derive(Serialize)]
struct CreateChannelBody<'a> {
name: &'a str,
@@ -33,6 +83,31 @@ struct CreateChannelBody<'a> {
description: Option<&'a str>,
}
+#[derive(Serialize)]
+struct UpdateChannelBody<'a> {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ name: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ description: Option<&'a str>,
+}
+
+#[derive(Serialize)]
+struct SetTopicBody<'a> {
+ topic: &'a str,
+}
+
+#[derive(Serialize)]
+struct SetPurposeBody<'a> {
+ purpose: &'a str,
+}
+
+#[derive(Serialize)]
+struct AddMembersBody<'a> {
+ pubkeys: &'a [String],
+ #[serde(skip_serializing_if = "Option::is_none")]
+ role: Option<&'a str>,
+}
+
#[derive(Serialize)]
struct GetFeedQuery<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -116,15 +191,16 @@ fn relay_api_base_url() -> String {
.replace("ws://", "http://")
}
-async fn build_authed_request(
+fn build_authed_request(
client: &reqwest::Client,
+ method: Method,
path: &str,
state: &AppState,
) -> Result {
let pubkey_hex = auth_pubkey_header(state)?;
let url = format!("{}{}", relay_api_base_url(), path);
- Ok(client.get(url).header("X-Pubkey", pubkey_hex))
+ Ok(client.request(method, url).header("X-Pubkey", pubkey_hex))
}
fn auth_pubkey_header(state: &AppState) -> Result {
@@ -145,6 +221,38 @@ async fn relay_error_message(response: reqwest::Response) -> String {
format!("relay returned {status}: {body}")
}
+async fn send_json_request(request: reqwest::RequestBuilder) -> Result
+where
+ T: DeserializeOwned,
+{
+ let response = request
+ .send()
+ .await
+ .map_err(|e| format!("request failed: {e}"))?;
+
+ if !response.status().is_success() {
+ return Err(relay_error_message(response).await);
+ }
+
+ response
+ .json::()
+ .await
+ .map_err(|e| format!("parse failed: {e}"))
+}
+
+async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(), String> {
+ let response = request
+ .send()
+ .await
+ .map_err(|e| format!("request failed: {e}"))?;
+
+ if !response.status().is_success() {
+ return Err(relay_error_message(response).await);
+ }
+
+ Ok(())
+}
+
#[tauri::command]
fn get_identity(state: tauri::State<'_, AppState>) -> Result {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
@@ -216,20 +324,8 @@ fn create_auth_event(
#[tauri::command]
async fn get_channels(state: tauri::State<'_, AppState>) -> Result, String> {
- let request = build_authed_request(&state.http_client, "/api/channels", &state).await?;
- let response = request
- .send()
- .await
- .map_err(|e| format!("request failed: {e}"))?;
-
- if !response.status().is_success() {
- return Err(relay_error_message(response).await);
- }
-
- response
- .json::>()
- .await
- .map_err(|e| format!("parse failed: {e}"))
+ let request = build_authed_request(&state.http_client, Method::GET, "/api/channels", &state)?;
+ send_json_request(request).await
}
#[tauri::command]
@@ -240,30 +336,154 @@ async fn create_channel(
description: Option,
state: tauri::State<'_, AppState>,
) -> Result {
- let pubkey_hex = auth_pubkey_header(&state)?;
- let url = format!("{}{}", relay_api_base_url(), "/api/channels");
- let response = state
- .http_client
- .post(url)
- .header("X-Pubkey", pubkey_hex)
+ let request = build_authed_request(&state.http_client, Method::POST, "/api/channels", &state)?
.json(&CreateChannelBody {
name: &name,
channel_type: &channel_type,
visibility: &visibility,
description: description.as_deref(),
- })
- .send()
- .await
- .map_err(|e| format!("request failed: {e}"))?;
+ });
- if !response.status().is_success() {
- return Err(relay_error_message(response).await);
- }
+ send_json_request(request).await
+}
- response
- .json::()
- .await
- .map_err(|e| format!("parse failed: {e}"))
+#[tauri::command]
+async fn get_channel_details(
+ channel_id: String,
+ state: tauri::State<'_, AppState>,
+) -> Result {
+ let path = format!("/api/channels/{channel_id}");
+ let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
+ send_json_request(request).await
+}
+
+#[tauri::command]
+async fn get_channel_members(
+ channel_id: String,
+ state: tauri::State<'_, AppState>,
+) -> Result {
+ let path = format!("/api/channels/{channel_id}/members");
+ let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
+ send_json_request(request).await
+}
+
+#[tauri::command]
+async fn update_channel(
+ channel_id: String,
+ name: Option,
+ description: Option,
+ state: tauri::State<'_, AppState>,
+) -> Result {
+ let path = format!("/api/channels/{channel_id}");
+ let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)?
+ .json(&UpdateChannelBody {
+ name: name.as_deref(),
+ description: description.as_deref(),
+ });
+
+ send_json_request(request).await
+}
+
+#[tauri::command]
+async fn set_channel_topic(
+ channel_id: String,
+ topic: String,
+ state: tauri::State<'_, AppState>,
+) -> Result<(), String> {
+ let path = format!("/api/channels/{channel_id}/topic");
+ let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)?
+ .json(&SetTopicBody { topic: &topic });
+ send_empty_request(request).await
+}
+
+#[tauri::command]
+async fn set_channel_purpose(
+ channel_id: String,
+ purpose: String,
+ state: tauri::State<'_, AppState>,
+) -> Result<(), String> {
+ let path = format!("/api/channels/{channel_id}/purpose");
+ let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)?
+ .json(&SetPurposeBody { purpose: &purpose });
+ send_empty_request(request).await
+}
+
+#[tauri::command]
+async fn archive_channel(
+ channel_id: String,
+ state: tauri::State<'_, AppState>,
+) -> Result<(), String> {
+ let path = format!("/api/channels/{channel_id}/archive");
+ let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
+ send_empty_request(request).await
+}
+
+#[tauri::command]
+async fn unarchive_channel(
+ channel_id: String,
+ state: tauri::State<'_, AppState>,
+) -> Result<(), String> {
+ let path = format!("/api/channels/{channel_id}/unarchive");
+ let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
+ send_empty_request(request).await
+}
+
+#[tauri::command]
+async fn delete_channel(
+ channel_id: String,
+ state: tauri::State<'_, AppState>,
+) -> Result<(), String> {
+ let path = format!("/api/channels/{channel_id}");
+ let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?;
+ send_empty_request(request).await
+}
+
+#[tauri::command]
+async fn add_channel_members(
+ channel_id: String,
+ pubkeys: Vec,
+ role: Option,
+ state: tauri::State<'_, AppState>,
+) -> Result {
+ let path = format!("/api/channels/{channel_id}/members");
+ let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?
+ .json(&AddMembersBody {
+ pubkeys: &pubkeys,
+ role: role.as_deref(),
+ });
+
+ send_json_request(request).await
+}
+
+#[tauri::command]
+async fn remove_channel_member(
+ channel_id: String,
+ pubkey: String,
+ state: tauri::State<'_, AppState>,
+) -> Result<(), String> {
+ let path = format!("/api/channels/{channel_id}/members/{pubkey}");
+ let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?;
+ send_empty_request(request).await
+}
+
+#[tauri::command]
+async fn join_channel(
+ channel_id: String,
+ state: tauri::State<'_, AppState>,
+) -> Result<(), String> {
+ let path = format!("/api/channels/{channel_id}/join");
+ let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
+ send_empty_request(request).await
+}
+
+#[tauri::command]
+async fn leave_channel(
+ channel_id: String,
+ state: tauri::State<'_, AppState>,
+) -> Result<(), String> {
+ let path = format!("/api/channels/{channel_id}/leave");
+ let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
+ send_empty_request(request).await
}
#[tauri::command]
@@ -273,29 +493,14 @@ async fn get_feed(
types: Option,
state: tauri::State<'_, AppState>,
) -> Result {
- let pubkey_hex = auth_pubkey_header(&state)?;
- let url = format!("{}{}", relay_api_base_url(), "/api/feed");
- let response = state
- .http_client
- .get(url)
- .header("X-Pubkey", pubkey_hex)
+ let request = build_authed_request(&state.http_client, Method::GET, "/api/feed", &state)?
.query(&GetFeedQuery {
since,
limit,
types: types.as_deref(),
- })
- .send()
- .await
- .map_err(|e| format!("request failed: {e}"))?;
+ });
- if !response.status().is_success() {
- return Err(relay_error_message(response).await);
- }
-
- response
- .json::()
- .await
- .map_err(|e| format!("parse failed: {e}"))
+ send_json_request(request).await
}
#[tauri::command]
@@ -304,38 +509,23 @@ async fn search_messages(
limit: Option,
state: tauri::State<'_, AppState>,
) -> Result {
- let pubkey_hex = auth_pubkey_header(&state)?;
- let url = format!("{}{}", relay_api_base_url(), "/api/search");
- let response = state
- .http_client
- .get(url)
- .header("X-Pubkey", pubkey_hex)
+ let request = build_authed_request(&state.http_client, Method::GET, "/api/search", &state)?
.query(&SearchQueryParams {
q: q.trim(),
limit,
- })
- .send()
- .await
- .map_err(|e| format!("request failed: {e}"))?;
+ });
- if !response.status().is_success() {
- return Err(relay_error_message(response).await);
- }
-
- response
- .json::()
- .await
- .map_err(|e| format!("parse failed: {e}"))
+ send_json_request(request).await
}
#[tauri::command]
async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Result {
let request = build_authed_request(
&state.http_client,
+ Method::GET,
&format!("/api/events/{event_id}"),
&state,
- )
- .await?;
+ )?;
let response = request
.send()
.await
@@ -373,6 +563,18 @@ pub fn run() {
create_auth_event,
get_channels,
create_channel,
+ get_channel_details,
+ get_channel_members,
+ update_channel,
+ set_channel_topic,
+ set_channel_purpose,
+ archive_channel,
+ unarchive_channel,
+ delete_channel,
+ add_channel_members,
+ remove_channel_member,
+ join_channel,
+ leave_channel,
get_feed,
search_messages,
get_event,
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index d452c86ac..17e3a4426 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { Settings2 } from "lucide-react";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import {
@@ -6,6 +7,7 @@ import {
useChannelsQuery,
useSelectedChannel,
} from "@/features/channels/hooks";
+import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
import { useHomeFeedQuery } from "@/features/home/hooks";
import { HomeView } from "@/features/home/ui/HomeView";
import {
@@ -22,6 +24,7 @@ import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { getEventById } from "@/shared/api/tauri";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { RelayEvent, SearchHit } from "@/shared/api/types";
+import { Button } from "@/shared/ui/button";
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
type AppView = "home" | "channel";
@@ -40,6 +43,8 @@ function createSearchAnchorEvent(hit: SearchHit): RelayEvent {
export function AppShell() {
const [selectedView, setSelectedView] = React.useState("home");
+ const [isChannelManagementOpen, setIsChannelManagementOpen] =
+ React.useState(false);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [searchAnchor, setSearchAnchor] = React.useState(
null,
@@ -104,9 +109,17 @@ export function AppShell() {
);
const channelDescription = activeChannel
- ? activeChannel.channelType === "forum"
- ? `${activeChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.`
- : activeChannel.description
+ ? [
+ activeChannel.archivedAt ? "Archived." : null,
+ activeChannel.topic,
+ activeChannel.description,
+ activeChannel.purpose,
+ activeChannel.channelType === "forum"
+ ? "Forum channels are listed, but this first pass only wires message streams and DMs."
+ : null,
+ ]
+ .filter((value) => value && value.trim().length > 0)
+ .join(" ") || "Channel details and activity."
: "Connect to the relay to browse channels and read messages.";
const contentPaneKey =
selectedView === "home" ? "home" : `channel:${activeChannel?.id ?? "none"}`;
@@ -203,6 +216,22 @@ export function AppShell() {
/>
) : (
{
+ setIsChannelManagementOpen(true);
+ }}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+
+ Manage
+
+ ) : null
+ }
channelType={activeChannel?.channelType}
description={channelDescription}
title={activeChannel?.name ?? "Channels"}
@@ -260,6 +289,7 @@ export function AppShell() {
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
+ activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
sendMessageMutation.isPending
}
@@ -269,11 +299,13 @@ export function AppShell() {
await sendMessageMutation.mutateAsync(content);
}}
placeholder={
- activeChannel?.channelType === "forum"
- ? "Forum posting is not wired in this pass."
- : activeChannel
- ? `Message #${activeChannel.name}`
- : "Select a channel"
+ activeChannel?.archivedAt
+ ? "Archived channels are read-only."
+ : activeChannel?.channelType === "forum"
+ ? "Forum posting is not wired in this pass."
+ : activeChannel
+ ? `Message #${activeChannel.name}`
+ : "Select a channel"
}
/>
>
@@ -286,6 +318,19 @@ export function AppShell() {
onOpenChange={setIsSearchOpen}
open={isSearchOpen}
/>
+
+ {
+ React.startTransition(() => {
+ setIsChannelManagementOpen(false);
+ setSelectedView("home");
+ });
+ }}
+ onOpenChange={setIsChannelManagementOpen}
+ open={isChannelManagementOpen && activeChannel !== null}
+ />
);
diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts
index e69774928..0ba7d5d8f 100644
--- a/desktop/src/features/channels/hooks.ts
+++ b/desktop/src/features/channels/hooks.ts
@@ -1,10 +1,37 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { createChannel, getChannels } from "@/shared/api/tauri";
-import type { Channel, CreateChannelInput } from "@/shared/api/types";
+import {
+ addChannelMembers,
+ archiveChannel,
+ createChannel,
+ deleteChannel,
+ getChannelDetails,
+ getChannelMembers,
+ getChannels,
+ joinChannel,
+ leaveChannel,
+ removeChannelMember,
+ setChannelPurpose,
+ setChannelTopic,
+ unarchiveChannel,
+ updateChannel,
+} from "@/shared/api/tauri";
+import type {
+ AddChannelMembersInput,
+ Channel,
+ ChannelDetail,
+ CreateChannelInput,
+ SetChannelPurposeInput,
+ SetChannelTopicInput,
+ UpdateChannelInput,
+} from "@/shared/api/types";
const channelsQueryKey = ["channels"] as const;
+const channelDetailQueryKey = (channelId: string) =>
+ ["channels", channelId, "detail"] as const;
+const channelMembersQueryKey = (channelId: string) =>
+ ["channels", channelId, "members"] as const;
const channelTypeOrder = {
stream: 0,
forum: 1,
@@ -24,6 +51,26 @@ function sortChannels(channels: Channel[]) {
});
}
+async function invalidateChannelState(
+ queryClient: ReturnType,
+ channelId: string | null | undefined,
+) {
+ await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
+
+ if (!channelId) {
+ return;
+ }
+
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: channelDetailQueryKey(channelId),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: channelMembersQueryKey(channelId),
+ }),
+ ]);
+}
+
export function useChannelsQuery() {
return useQuery({
queryKey: channelsQueryKey,
@@ -51,6 +98,244 @@ export function useCreateChannelMutation() {
});
}
+export function useChannelDetailsQuery(
+ channelId: string | null,
+ enabled = true,
+) {
+ return useQuery({
+ enabled: enabled && channelId !== null,
+ queryKey: ["channels", channelId ?? "none", "detail"],
+ queryFn: async () => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ return getChannelDetails(channelId);
+ },
+ staleTime: 30_000,
+ });
+}
+
+export function useChannelMembersQuery(
+ channelId: string | null,
+ enabled = true,
+) {
+ return useQuery({
+ enabled: enabled && channelId !== null,
+ queryKey: ["channels", channelId ?? "none", "members"],
+ queryFn: async () => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ return getChannelMembers(channelId);
+ },
+ staleTime: 30_000,
+ });
+}
+
+export function useUpdateChannelMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (input: Omit) => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ return updateChannel({ ...input, channelId });
+ },
+ onSuccess: (updatedChannel) => {
+ if (!channelId) {
+ return;
+ }
+
+ queryClient.setQueryData(
+ channelDetailQueryKey(channelId),
+ updatedChannel,
+ );
+ queryClient.setQueryData(channelsQueryKey, (current = []) =>
+ sortChannels(
+ current.map((channel) =>
+ channel.id === updatedChannel.id ? updatedChannel : channel,
+ ),
+ ),
+ );
+ },
+ onSettled: async () => {
+ await invalidateChannelState(queryClient, channelId);
+ },
+ });
+}
+
+export function useSetChannelTopicMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (input: Omit) => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ return setChannelTopic({ ...input, channelId });
+ },
+ onSettled: async () => {
+ await invalidateChannelState(queryClient, channelId);
+ },
+ });
+}
+
+export function useSetChannelPurposeMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (input: Omit) => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ return setChannelPurpose({ ...input, channelId });
+ },
+ onSettled: async () => {
+ await invalidateChannelState(queryClient, channelId);
+ },
+ });
+}
+
+export function useArchiveChannelMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ await archiveChannel(channelId);
+ },
+ onSettled: async () => {
+ await invalidateChannelState(queryClient, channelId);
+ },
+ });
+}
+
+export function useUnarchiveChannelMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ await unarchiveChannel(channelId);
+ },
+ onSettled: async () => {
+ await invalidateChannelState(queryClient, channelId);
+ },
+ });
+}
+
+export function useDeleteChannelMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ await deleteChannel(channelId);
+ },
+ onSuccess: () => {
+ if (!channelId) {
+ return;
+ }
+
+ queryClient.setQueryData(channelsQueryKey, (current = []) =>
+ current.filter((channel) => channel.id !== channelId),
+ );
+ queryClient.removeQueries({
+ queryKey: channelDetailQueryKey(channelId),
+ });
+ queryClient.removeQueries({
+ queryKey: channelMembersQueryKey(channelId),
+ });
+ },
+ onSettled: async () => {
+ await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
+ },
+ });
+}
+
+export function useAddChannelMembersMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (input: Omit) => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ return addChannelMembers({ ...input, channelId });
+ },
+ onSettled: async () => {
+ await invalidateChannelState(queryClient, channelId);
+ },
+ });
+}
+
+export function useRemoveChannelMemberMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (pubkey: string) => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ await removeChannelMember(channelId, pubkey);
+ },
+ onSettled: async () => {
+ await invalidateChannelState(queryClient, channelId);
+ },
+ });
+}
+
+export function useJoinChannelMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ await joinChannel(channelId);
+ },
+ onSettled: async () => {
+ await invalidateChannelState(queryClient, channelId);
+ },
+ });
+}
+
+export function useLeaveChannelMutation(channelId: string | null) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ if (!channelId) {
+ throw new Error("No channel selected.");
+ }
+
+ await leaveChannel(channelId);
+ },
+ onSettled: async () => {
+ await invalidateChannelState(queryClient, channelId);
+ },
+ });
+}
+
export function useSelectedChannel(
channels: Channel[],
preferredChannelId: string | null,
diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
new file mode 100644
index 000000000..5cb77c1fd
--- /dev/null
+++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx
@@ -0,0 +1,738 @@
+import {
+ Archive,
+ ArchiveRestore,
+ Crown,
+ DoorClosed,
+ DoorOpen,
+ FileText,
+ Hash,
+ Lock,
+ MessageSquare,
+ Shield,
+ User,
+ UserPlus,
+ Users,
+} from "lucide-react";
+import * as React from "react";
+
+import {
+ useAddChannelMembersMutation,
+ useArchiveChannelMutation,
+ useChannelDetailsQuery,
+ useChannelMembersQuery,
+ useDeleteChannelMutation,
+ useJoinChannelMutation,
+ useLeaveChannelMutation,
+ useRemoveChannelMemberMutation,
+ useSetChannelPurposeMutation,
+ useSetChannelTopicMutation,
+ useUnarchiveChannelMutation,
+ useUpdateChannelMutation,
+} from "@/features/channels/hooks";
+import type { Channel, ChannelMember } from "@/shared/api/types";
+import { Button } from "@/shared/ui/button";
+import { Input } from "@/shared/ui/input";
+import { Separator } from "@/shared/ui/separator";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/shared/ui/sheet";
+import { Textarea } from "@/shared/ui/textarea";
+
+type ChannelManagementSheetProps = {
+ channel: Channel | null;
+ currentPubkey?: string;
+ onDeleted?: () => void;
+ onOpenChange: (open: boolean) => void;
+ open: boolean;
+};
+
+const roleOptions: Array> = [
+ "member",
+ "admin",
+ "guest",
+ "bot",
+];
+
+const roleOrder: Record = {
+ owner: 0,
+ admin: 1,
+ member: 2,
+ guest: 3,
+ bot: 4,
+};
+
+function formatPubkey(pubkey: string) {
+ return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`;
+}
+
+function formatMemberName(member: ChannelMember, currentPubkey?: string) {
+ if (currentPubkey && member.pubkey === currentPubkey) {
+ return "You";
+ }
+
+ return member.displayName ?? formatPubkey(member.pubkey);
+}
+
+function Section({
+ title,
+ description,
+ children,
+}: React.PropsWithChildren<{
+ title: string;
+ description?: string;
+}>) {
+ return (
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {children}
+
+ );
+}
+
+function MetadataPill({
+ icon: Icon,
+ label,
+}: {
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+}) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function roleIcon(role: ChannelMember["role"]) {
+ switch (role) {
+ case "owner":
+ return Crown;
+ case "admin":
+ return Shield;
+ default:
+ return User;
+ }
+}
+
+export function ChannelManagementSheet({
+ channel,
+ currentPubkey,
+ onDeleted,
+ onOpenChange,
+ open,
+}: ChannelManagementSheetProps) {
+ const channelId = channel?.id ?? null;
+ const detailsQuery = useChannelDetailsQuery(channelId, open);
+ const membersQuery = useChannelMembersQuery(channelId, open);
+ const updateChannelMutation = useUpdateChannelMutation(channelId);
+ const setTopicMutation = useSetChannelTopicMutation(channelId);
+ const setPurposeMutation = useSetChannelPurposeMutation(channelId);
+ const archiveChannelMutation = useArchiveChannelMutation(channelId);
+ const unarchiveChannelMutation = useUnarchiveChannelMutation(channelId);
+ const deleteChannelMutation = useDeleteChannelMutation(channelId);
+ const addMembersMutation = useAddChannelMembersMutation(channelId);
+ const removeMemberMutation = useRemoveChannelMemberMutation(channelId);
+ const joinChannelMutation = useJoinChannelMutation(channelId);
+ const leaveChannelMutation = useLeaveChannelMutation(channelId);
+
+ const detail = detailsQuery.data ?? channel;
+ const members = React.useMemo(() => {
+ const currentMembers = membersQuery.data ?? [];
+ return [...currentMembers].sort((left, right) => {
+ if (currentPubkey && left.pubkey === currentPubkey) {
+ return -1;
+ }
+
+ if (currentPubkey && right.pubkey === currentPubkey) {
+ return 1;
+ }
+
+ const roleDelta = roleOrder[left.role] - roleOrder[right.role];
+ if (roleDelta !== 0) {
+ return roleDelta;
+ }
+
+ return formatMemberName(left).localeCompare(formatMemberName(right));
+ });
+ }, [currentPubkey, membersQuery.data]);
+
+ const selfMember =
+ members.find((member) => member.pubkey === currentPubkey) ?? null;
+ const hasResolvedMembership = membersQuery.data !== undefined;
+ const isOwner = selfMember?.role === "owner";
+ const canManageChannel =
+ selfMember?.role === "owner" || selfMember?.role === "admin";
+ const canEditNarrative = selfMember !== null && detail?.channelType !== "dm";
+ const isArchived =
+ detail?.archivedAt !== null && detail?.archivedAt !== undefined;
+ const canJoin =
+ hasResolvedMembership &&
+ detail?.channelType !== "dm" &&
+ detail?.visibility === "open" &&
+ !isArchived &&
+ selfMember === null;
+ const canLeave =
+ hasResolvedMembership &&
+ detail?.channelType !== "dm" &&
+ !isArchived &&
+ selfMember !== null;
+
+ const [nameDraft, setNameDraft] = React.useState("");
+ const [descriptionDraft, setDescriptionDraft] = React.useState("");
+ const [topicDraft, setTopicDraft] = React.useState("");
+ const [purposeDraft, setPurposeDraft] = React.useState("");
+ const [invitePubkeys, setInvitePubkeys] = React.useState("");
+ const [inviteRole, setInviteRole] =
+ React.useState>("member");
+
+ React.useEffect(() => {
+ if (!detail) {
+ return;
+ }
+
+ setNameDraft(detail.name);
+ setDescriptionDraft(detail.description);
+ setTopicDraft(detail.topic ?? "");
+ setPurposeDraft(detail.purpose ?? "");
+ }, [detail]);
+
+ if (!channel) {
+ return null;
+ }
+
+ const resolvedChannel = detail ?? channel;
+
+ const parsedInvitePubkeys = invitePubkeys
+ .split(/[\s,]+/)
+ .map((value) => value.trim())
+ .filter((value) => value.length > 0);
+
+ return (
+
+
+
+
+ {channel.name}
+
+ Manage channel settings, membership, and access.
+
+
+
+
+
+
+ {isArchived ? (
+
+ ) : null}
+
+
+
+
+ {detailsQuery.error instanceof Error ? (
+
+ {detailsQuery.error.message}
+
+ ) : null}
+
+ {membersQuery.error instanceof Error ? (
+
+ {membersQuery.error.message}
+
+ ) : null}
+
+
+
+ {canJoin ? (
+
+ ) : null}
+
+ {canLeave ? (
+
+ ) : null}
+
+ {joinChannelMutation.error instanceof Error ? (
+
+ {joinChannelMutation.error.message}
+
+ ) : null}
+ {leaveChannelMutation.error instanceof Error ? (
+
+ {leaveChannelMutation.error.message}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {canManageChannel && resolvedChannel.channelType !== "dm" ? (
+
+ ) : null}
+
+
+ {members.length > 0 ? (
+ members.map((member) => {
+ const Icon = roleIcon(member.role);
+
+ return (
+
+
+
+
+
+ {formatMemberName(member, currentPubkey)}
+
+
+ {member.role}
+
+
+
+ {member.pubkey}
+
+
+ {canManageChannel ||
+ (currentPubkey && member.pubkey === currentPubkey) ? (
+
+ ) : null}
+
+ );
+ })
+ ) : (
+
+ {membersQuery.isLoading
+ ? "Loading members..."
+ : "No active members found."}
+
+ )}
+
+
+ {removeMemberMutation.error instanceof Error ? (
+
+ {removeMemberMutation.error.message}
+
+ ) : null}
+
+
+ {resolvedChannel.channelType !== "dm" ? (
+ <>
+
+
+
+
+ {isArchived ? (
+
+ ) : (
+
+ )}
+
+ {archiveChannelMutation.error instanceof Error ? (
+
+ {archiveChannelMutation.error.message}
+
+ ) : null}
+ {unarchiveChannelMutation.error instanceof Error ? (
+
+ {unarchiveChannelMutation.error.message}
+
+ ) : null}
+
+ >
+ ) : null}
+
+ {isOwner && resolvedChannel.channelType !== "dm" ? (
+ <>
+
+
+
+
+ {deleteChannelMutation.error instanceof Error ? (
+
+ {deleteChannelMutation.error.message}
+
+ ) : null}
+
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx
index 7318be39a..ef079d75b 100644
--- a/desktop/src/features/chat/ui/ChatHeader.tsx
+++ b/desktop/src/features/chat/ui/ChatHeader.tsx
@@ -1,9 +1,11 @@
import { CircleDot, FileText, Hash, Home } from "lucide-react";
+import type * as React from "react";
import type { ChannelType } from "@/shared/api/types";
import { SidebarTrigger } from "@/shared/ui/sidebar";
type ChatHeaderProps = {
+ actions?: React.ReactNode;
title: string;
description: string;
channelType?: ChannelType;
@@ -33,6 +35,7 @@ function ChannelIcon({
}
export function ChatHeader({
+ actions,
title,
description,
channelType,
@@ -62,6 +65,8 @@ export function ChatHeader({
{description}
+
+ {actions ? {actions}
: null}
);
}
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 76b8ac06f..a9be5838e 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -1,7 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
import type {
+ AddChannelMembersInput,
+ AddChannelMembersResult,
Channel,
+ ChannelDetail,
+ ChannelMember,
ChannelType,
CreateChannelInput,
GetHomeFeedInput,
@@ -10,6 +14,9 @@ import type {
RelayEvent,
SearchMessagesInput,
SearchMessagesResponse,
+ SetChannelPurposeInput,
+ SetChannelTopicInput,
+ UpdateChannelInput,
} from "@/shared/api/types";
type RawIdentity = {
@@ -21,11 +28,50 @@ type RawChannel = {
id: string;
name: string;
channel_type: ChannelType;
+ visibility: "open" | "private";
description: string;
+ topic: string | null;
+ purpose: string | null;
+ member_count: number;
+ last_message_at: string | null;
+ archived_at: string | null;
participants: string[];
participant_pubkeys: string[];
};
+type RawChannelDetail = RawChannel & {
+ created_by: string;
+ created_at: string;
+ updated_at: string;
+ topic_set_by: string | null;
+ topic_set_at: string | null;
+ purpose_set_by: string | null;
+ purpose_set_at: string | null;
+ topic_required: boolean;
+ max_members: number | null;
+ nip29_group_id: string | null;
+};
+
+type RawChannelMember = {
+ pubkey: string;
+ role: ChannelMember["role"];
+ joined_at: string;
+ display_name: string | null;
+};
+
+type RawChannelMembersResponse = {
+ members: RawChannelMember[];
+ next_cursor: string | null;
+};
+
+type RawAddChannelMembersResult = {
+ added: string[];
+ errors: Array<{
+ pubkey: string;
+ error: string;
+ }>;
+};
+
type RawFeedItem = {
id: string;
kind: number;
@@ -73,12 +119,43 @@ function fromRawChannel(channel: RawChannel): Channel {
id: channel.id,
name: channel.name,
channelType: channel.channel_type,
+ visibility: channel.visibility,
description: channel.description,
+ topic: channel.topic,
+ purpose: channel.purpose,
+ memberCount: channel.member_count,
+ lastMessageAt: channel.last_message_at,
+ archivedAt: channel.archived_at,
participants: channel.participants,
participantPubkeys: channel.participant_pubkeys,
};
}
+function fromRawChannelDetail(channel: RawChannelDetail): ChannelDetail {
+ return {
+ ...fromRawChannel(channel),
+ createdBy: channel.created_by,
+ createdAt: channel.created_at,
+ updatedAt: channel.updated_at,
+ topicSetBy: channel.topic_set_by,
+ topicSetAt: channel.topic_set_at,
+ purposeSetBy: channel.purpose_set_by,
+ purposeSetAt: channel.purpose_set_at,
+ topicRequired: channel.topic_required,
+ maxMembers: channel.max_members,
+ nip29GroupId: channel.nip29_group_id,
+ };
+}
+
+function fromRawChannelMember(member: RawChannelMember): ChannelMember {
+ return {
+ pubkey: member.pubkey,
+ role: member.role,
+ joinedAt: member.joined_at,
+ displayName: member.display_name,
+ };
+}
+
function fromRawFeedItem(item: RawFeedItem) {
return {
id: item.id,
@@ -131,6 +208,79 @@ export async function createChannel(
return fromRawChannel(channel);
}
+export async function getChannelDetails(
+ channelId: string,
+): Promise {
+ const channel = await invoke("get_channel_details", {
+ channelId,
+ });
+ return fromRawChannelDetail(channel);
+}
+
+export async function getChannelMembers(
+ channelId: string,
+): Promise {
+ const response = await invoke(
+ "get_channel_members",
+ {
+ channelId,
+ },
+ );
+ return response.members.map(fromRawChannelMember);
+}
+
+export async function updateChannel(
+ input: UpdateChannelInput,
+): Promise {
+ const channel = await invoke("update_channel", input);
+ return fromRawChannelDetail(channel);
+}
+
+export async function setChannelTopic(
+ input: SetChannelTopicInput,
+): Promise {
+ await invoke("set_channel_topic", input);
+}
+
+export async function setChannelPurpose(
+ input: SetChannelPurposeInput,
+): Promise {
+ await invoke("set_channel_purpose", input);
+}
+
+export async function archiveChannel(channelId: string): Promise {
+ await invoke("archive_channel", { channelId });
+}
+
+export async function unarchiveChannel(channelId: string): Promise {
+ await invoke("unarchive_channel", { channelId });
+}
+
+export async function deleteChannel(channelId: string): Promise {
+ await invoke("delete_channel", { channelId });
+}
+
+export async function addChannelMembers(
+ input: AddChannelMembersInput,
+): Promise {
+ return invoke("add_channel_members", input);
+}
+
+export async function removeChannelMember(
+ channelId: string,
+ pubkey: string,
+): Promise {
+ await invoke("remove_channel_member", { channelId, pubkey });
+}
+
+export async function joinChannel(channelId: string): Promise {
+ await invoke("join_channel", { channelId });
+}
+
+export async function leaveChannel(channelId: string): Promise {
+ await invoke("leave_channel", { channelId });
+}
+
export async function getHomeFeed(
input: GetHomeFeedInput = {},
): Promise {
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 2d6a6d6f4..3835ff7b5 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -1,15 +1,42 @@
export type ChannelType = "stream" | "forum" | "dm";
export type ChannelVisibility = "open" | "private";
+export type ChannelRole = "owner" | "admin" | "member" | "guest" | "bot";
export type Channel = {
id: string;
name: string;
channelType: ChannelType;
+ visibility: ChannelVisibility;
description: string;
+ topic: string | null;
+ purpose: string | null;
+ memberCount: number;
+ lastMessageAt: string | null;
+ archivedAt: string | null;
participants: string[];
participantPubkeys: string[];
};
+export type ChannelDetail = Channel & {
+ createdBy: string;
+ createdAt: string;
+ updatedAt: string;
+ topicSetBy: string | null;
+ topicSetAt: string | null;
+ purposeSetBy: string | null;
+ purposeSetAt: string | null;
+ topicRequired: boolean;
+ maxMembers: number | null;
+ nip29GroupId: string | null;
+};
+
+export type ChannelMember = {
+ pubkey: string;
+ role: ChannelRole;
+ joinedAt: string;
+ displayName: string | null;
+};
+
export type CreateChannelInput = {
name: string;
channelType: Exclude;
@@ -17,6 +44,36 @@ export type CreateChannelInput = {
description?: string;
};
+export type UpdateChannelInput = {
+ channelId: string;
+ name?: string;
+ description?: string;
+};
+
+export type SetChannelTopicInput = {
+ channelId: string;
+ topic: string;
+};
+
+export type SetChannelPurposeInput = {
+ channelId: string;
+ purpose: string;
+};
+
+export type AddChannelMembersInput = {
+ channelId: string;
+ pubkeys: string[];
+ role?: Exclude;
+};
+
+export type AddChannelMembersResult = {
+ added: string[];
+ errors: Array<{
+ pubkey: string;
+ error: string;
+ }>;
+};
+
export type Identity = {
pubkey: string;
displayName: string;
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 70303ee37..6282e946d 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -21,11 +21,54 @@ type RawChannel = {
id: string;
name: string;
channel_type: "stream" | "forum" | "dm";
+ visibility: "open" | "private";
description: string;
+ topic: string | null;
+ purpose: string | null;
+ member_count: number;
+ last_message_at: string | null;
+ archived_at: string | null;
participants: string[];
participant_pubkeys: string[];
};
+type RawChannelDetail = RawChannel & {
+ created_by: string;
+ created_at: string;
+ updated_at: string;
+ topic_set_by: string | null;
+ topic_set_at: string | null;
+ purpose_set_by: string | null;
+ purpose_set_at: string | null;
+ topic_required: boolean;
+ max_members: number | null;
+ nip29_group_id: string | null;
+};
+
+type RawChannelMember = {
+ pubkey: string;
+ role: "owner" | "admin" | "member" | "guest" | "bot";
+ joined_at: string;
+ display_name: string | null;
+};
+
+type RawChannelMembersResponse = {
+ members: RawChannelMember[];
+ next_cursor: string | null;
+};
+
+type RawAddChannelMembersResponse = {
+ added: string[];
+ errors: Array<{
+ pubkey: string;
+ error: string;
+ }>;
+};
+
+type MockChannel = RawChannelDetail & {
+ members: RawChannelMember[];
+};
+
type RawFeedItem = {
id: string;
kind: number;
@@ -95,77 +138,373 @@ const DEFAULT_REAL_IDENTITY = {
username: "tyler",
} satisfies TestIdentity;
-const mockChannels: RawChannel[] = [
- {
+const ALICE_PUBKEY =
+ "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
+const BOB_PUBKEY =
+ "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260";
+const CHARLIE_PUBKEY =
+ "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea";
+const OUTSIDER_PUBKEY =
+ "df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e";
+const MOCK_IDENTITY_PUBKEY = DEFAULT_MOCK_IDENTITY.pubkey;
+
+const mockDisplayNames = new Map([
+ [MOCK_IDENTITY_PUBKEY, DEFAULT_MOCK_IDENTITY.display_name],
+ [ALICE_PUBKEY, "alice"],
+ [BOB_PUBKEY, "bob"],
+ [CHARLIE_PUBKEY, "charlie"],
+ [OUTSIDER_PUBKEY, "outsider"],
+ [DEFAULT_REAL_IDENTITY.pubkey, DEFAULT_REAL_IDENTITY.username],
+]);
+
+function isoMinutesAgo(minutesAgo: number): string {
+ return new Date(Date.now() - minutesAgo * 60_000).toISOString();
+}
+
+function cloneMembers(members: RawChannelMember[]): RawChannelMember[] {
+ return members.map((member) => ({ ...member }));
+}
+
+function toRawChannel(channel: MockChannel): RawChannel {
+ return {
+ id: channel.id,
+ name: channel.name,
+ channel_type: channel.channel_type,
+ visibility: channel.visibility,
+ description: channel.description,
+ topic: channel.topic,
+ purpose: channel.purpose,
+ member_count: channel.member_count,
+ last_message_at: channel.last_message_at,
+ archived_at: channel.archived_at,
+ participants: [...channel.participants],
+ participant_pubkeys: [...channel.participant_pubkeys],
+ };
+}
+
+function toRawChannelDetail(channel: MockChannel): RawChannelDetail {
+ return {
+ ...toRawChannel(channel),
+ created_by: channel.created_by,
+ created_at: channel.created_at,
+ updated_at: channel.updated_at,
+ topic_set_by: channel.topic_set_by,
+ topic_set_at: channel.topic_set_at,
+ purpose_set_by: channel.purpose_set_by,
+ purpose_set_at: channel.purpose_set_at,
+ topic_required: channel.topic_required,
+ max_members: channel.max_members,
+ nip29_group_id: channel.nip29_group_id,
+ };
+}
+
+function createMockMember(
+ pubkey: string,
+ role: RawChannelMember["role"],
+ joinedMinutesAgo: number,
+): RawChannelMember {
+ return {
+ pubkey,
+ role,
+ joined_at: isoMinutesAgo(joinedMinutesAgo),
+ display_name: mockDisplayNames.get(pubkey) ?? null,
+ };
+}
+
+function createMockChannel(
+ seed: Omit<
+ MockChannel,
+ | "created_at"
+ | "member_count"
+ | "members"
+ | "updated_at"
+ | "participant_pubkeys"
+ | "participants"
+ > & {
+ created_minutes_ago: number;
+ members: RawChannelMember[];
+ participant_pubkeys?: string[];
+ participants?: string[];
+ updated_minutes_ago?: number;
+ },
+): MockChannel {
+ return {
+ ...seed,
+ created_at: isoMinutesAgo(seed.created_minutes_ago),
+ member_count: seed.members.length,
+ members: cloneMembers(seed.members),
+ participant_pubkeys: [...(seed.participant_pubkeys ?? [])],
+ participants: [...(seed.participants ?? [])],
+ updated_at: isoMinutesAgo(
+ seed.updated_minutes_ago ?? seed.created_minutes_ago,
+ ),
+ };
+}
+
+function syncMockChannel(channel: MockChannel) {
+ channel.member_count = channel.members.length;
+
+ if (channel.channel_type !== "dm") {
+ return;
+ }
+
+ channel.participant_pubkeys = channel.members.map((member) => member.pubkey);
+ channel.participants = channel.members.map(
+ (member) => member.display_name ?? member.pubkey.slice(0, 8),
+ );
+}
+
+function touchMockChannel(channel: MockChannel) {
+ channel.updated_at = new Date().toISOString();
+}
+
+function getMockIdentity() {
+ return {
+ pubkey: MOCK_IDENTITY_PUBKEY,
+ displayName: DEFAULT_MOCK_IDENTITY.display_name,
+ };
+}
+
+function listMockChannels(): RawChannel[] {
+ return mockChannels.map(toRawChannel);
+}
+
+function getMockChannel(channelId: string): MockChannel {
+ const channel = mockChannels.find((candidate) => candidate.id === channelId);
+ if (!channel) {
+ throw new Error(`Channel ${channelId} not found.`);
+ }
+
+ return channel;
+}
+
+function getMockMemberPubkey(config: E2eConfig | undefined): string {
+ return getIdentity(config)?.pubkey ?? getMockIdentity().pubkey;
+}
+
+function getMockMemberDisplayName(config: E2eConfig | undefined): string {
+ return getIdentity(config)?.username ?? getMockIdentity().displayName;
+}
+
+function createCurrentMember(
+ config: E2eConfig | undefined,
+ role: RawChannelMember["role"],
+): RawChannelMember {
+ return {
+ pubkey: getMockMemberPubkey(config),
+ role,
+ joined_at: new Date().toISOString(),
+ display_name: getMockMemberDisplayName(config),
+ };
+}
+
+const mockChannels: MockChannel[] = [
+ createMockChannel({
id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
name: "general",
channel_type: "stream",
+ visibility: "open",
description: "General discussion for everyone",
- participants: [],
- participant_pubkeys: [],
- },
- {
+ topic: "Company-wide updates",
+ purpose: "Coordinate day-to-day work and unblock the team.",
+ last_message_at: isoMinutesAgo(5),
+ archived_at: null,
+ created_by: MOCK_IDENTITY_PUBKEY,
+ topic_set_by: MOCK_IDENTITY_PUBKEY,
+ topic_set_at: isoMinutesAgo(90),
+ purpose_set_by: MOCK_IDENTITY_PUBKEY,
+ purpose_set_at: isoMinutesAgo(80),
+ topic_required: false,
+ max_members: null,
+ nip29_group_id: null,
+ created_minutes_ago: 1440,
+ updated_minutes_ago: 5,
+ members: [
+ createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1440),
+ createMockMember(ALICE_PUBKEY, "admin", 1200),
+ createMockMember(BOB_PUBKEY, "member", 960),
+ ],
+ }),
+ createMockChannel({
id: "9dae0116-799b-5071-a0a8-fdd30a91a35d",
name: "random",
channel_type: "stream",
+ visibility: "open",
description: "Off-topic, fun stuff",
- participants: [],
- participant_pubkeys: [],
- },
- {
+ topic: null,
+ purpose: null,
+ last_message_at: null,
+ archived_at: null,
+ created_by: ALICE_PUBKEY,
+ topic_set_by: null,
+ topic_set_at: null,
+ purpose_set_by: null,
+ purpose_set_at: null,
+ topic_required: false,
+ max_members: null,
+ nip29_group_id: null,
+ created_minutes_ago: 1400,
+ updated_minutes_ago: 1400,
+ members: [
+ createMockMember(ALICE_PUBKEY, "owner", 1400),
+ createMockMember(BOB_PUBKEY, "member", 1000),
+ ],
+ }),
+ createMockChannel({
id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
name: "engineering",
channel_type: "stream",
+ visibility: "open",
description: "Engineering discussions",
- participants: [],
- participant_pubkeys: [],
- },
- {
+ topic: "Desktop release train",
+ purpose: "Track implementation details and release readiness.",
+ last_message_at: isoMinutesAgo(42),
+ archived_at: null,
+ created_by: ALICE_PUBKEY,
+ topic_set_by: ALICE_PUBKEY,
+ topic_set_at: isoMinutesAgo(120),
+ purpose_set_by: ALICE_PUBKEY,
+ purpose_set_at: isoMinutesAgo(130),
+ topic_required: false,
+ max_members: null,
+ nip29_group_id: null,
+ created_minutes_ago: 1320,
+ updated_minutes_ago: 42,
+ members: [
+ createMockMember(ALICE_PUBKEY, "owner", 1320),
+ createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1180),
+ createMockMember(BOB_PUBKEY, "member", 900),
+ ],
+ }),
+ createMockChannel({
id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
name: "agents",
channel_type: "stream",
+ visibility: "open",
description: "AI agent testing and collaboration",
- participants: [],
- participant_pubkeys: [],
- },
- {
+ topic: "Coordination board",
+ purpose: "Track agent work and relay activity.",
+ last_message_at: isoMinutesAgo(15),
+ archived_at: null,
+ created_by: MOCK_IDENTITY_PUBKEY,
+ topic_set_by: MOCK_IDENTITY_PUBKEY,
+ topic_set_at: isoMinutesAgo(60),
+ purpose_set_by: MOCK_IDENTITY_PUBKEY,
+ purpose_set_at: isoMinutesAgo(65),
+ topic_required: false,
+ max_members: null,
+ nip29_group_id: null,
+ created_minutes_ago: 1000,
+ updated_minutes_ago: 15,
+ members: [
+ createMockMember(MOCK_IDENTITY_PUBKEY, "owner", 1000),
+ createMockMember(CHARLIE_PUBKEY, "bot", 800),
+ ],
+ }),
+ createMockChannel({
id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
name: "watercooler",
channel_type: "forum",
+ visibility: "open",
description: "Casual forum for async discussions",
- participants: [],
- participant_pubkeys: [],
- },
- {
+ topic: null,
+ purpose: null,
+ last_message_at: null,
+ archived_at: null,
+ created_by: ALICE_PUBKEY,
+ topic_set_by: null,
+ topic_set_at: null,
+ purpose_set_by: null,
+ purpose_set_at: null,
+ topic_required: false,
+ max_members: null,
+ nip29_group_id: null,
+ created_minutes_ago: 900,
+ updated_minutes_ago: 900,
+ members: [
+ createMockMember(ALICE_PUBKEY, "owner", 900),
+ createMockMember(MOCK_IDENTITY_PUBKEY, "member", 750),
+ ],
+ }),
+ createMockChannel({
id: "1be1dcdb-4c31-5a8c-81de-ac102552ca10",
name: "announcements",
channel_type: "forum",
+ visibility: "private",
description: "Company announcements",
- participants: [],
- participant_pubkeys: [],
- },
- {
+ topic: "Leadership updates",
+ purpose: "Read-only announcements for the workspace.",
+ last_message_at: null,
+ archived_at: null,
+ created_by: ALICE_PUBKEY,
+ topic_set_by: ALICE_PUBKEY,
+ topic_set_at: isoMinutesAgo(200),
+ purpose_set_by: ALICE_PUBKEY,
+ purpose_set_at: isoMinutesAgo(210),
+ topic_required: false,
+ max_members: null,
+ nip29_group_id: null,
+ created_minutes_ago: 880,
+ updated_minutes_ago: 200,
+ members: [
+ createMockMember(ALICE_PUBKEY, "owner", 880),
+ createMockMember(MOCK_IDENTITY_PUBKEY, "guest", 700),
+ ],
+ }),
+ createMockChannel({
id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
name: "alice-tyler",
channel_type: "dm",
+ visibility: "private",
description: "DM between alice and tyler",
+ topic: null,
+ purpose: null,
+ last_message_at: null,
+ archived_at: null,
+ created_by: ALICE_PUBKEY,
+ topic_set_by: null,
+ topic_set_at: null,
+ purpose_set_by: null,
+ purpose_set_at: null,
+ topic_required: false,
+ max_members: 2,
+ nip29_group_id: null,
+ created_minutes_ago: 720,
+ updated_minutes_ago: 720,
participants: ["alice", "tyler"],
- participant_pubkeys: [
- "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
- "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
+ participant_pubkeys: [ALICE_PUBKEY, DEFAULT_REAL_IDENTITY.pubkey],
+ members: [
+ createMockMember(ALICE_PUBKEY, "member", 720),
+ createMockMember(DEFAULT_REAL_IDENTITY.pubkey, "member", 720),
],
- },
- {
+ }),
+ createMockChannel({
id: "7eb9f239-9393-50b0-bd76-d85eef0511c7",
name: "bob-tyler",
channel_type: "dm",
+ visibility: "private",
description: "DM between bob and tyler",
+ topic: null,
+ purpose: null,
+ last_message_at: null,
+ archived_at: null,
+ created_by: BOB_PUBKEY,
+ topic_set_by: null,
+ topic_set_at: null,
+ purpose_set_by: null,
+ purpose_set_at: null,
+ topic_required: false,
+ max_members: 2,
+ nip29_group_id: null,
+ created_minutes_ago: 700,
+ updated_minutes_ago: 700,
participants: ["bob", "tyler"],
- participant_pubkeys: [
- "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
- "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
+ participant_pubkeys: [BOB_PUBKEY, DEFAULT_REAL_IDENTITY.pubkey],
+ members: [
+ createMockMember(BOB_PUBKEY, "member", 700),
+ createMockMember(DEFAULT_REAL_IDENTITY.pubkey, "member", 700),
],
- },
+ }),
];
const mockMessages = new Map();
@@ -322,19 +661,63 @@ async function assertOk(response: Response) {
throw new Error(body || `Request failed with ${response.status}`);
}
+function getRelayIdentity(config: E2eConfig | undefined): TestIdentity {
+ const identity = getIdentity(config);
+ if (!identity) {
+ throw new Error("Relay identity required.");
+ }
+
+ return identity;
+}
+
+async function relayJsonRequest(
+ config: E2eConfig | undefined,
+ path: string,
+ init: RequestInit = {},
+): Promise {
+ const identity = getRelayIdentity(config);
+ const headers = new Headers(init.headers);
+
+ headers.set("X-Pubkey", identity.pubkey);
+ if (init.body && !headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ const response = await fetch(`${getRelayHttpUrl(config)}${path}`, {
+ ...init,
+ headers,
+ });
+ await assertOk(response);
+ return response.json() as Promise;
+}
+
+async function relayEmptyRequest(
+ config: E2eConfig | undefined,
+ path: string,
+ init: RequestInit = {},
+) {
+ const identity = getRelayIdentity(config);
+ const headers = new Headers(init.headers);
+
+ headers.set("X-Pubkey", identity.pubkey);
+ if (init.body && !headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ const response = await fetch(`${getRelayHttpUrl(config)}${path}`, {
+ ...init,
+ headers,
+ });
+ await assertOk(response);
+}
+
async function handleGetChannels(config: E2eConfig | undefined) {
const identity = getIdentity(config);
if (!identity) {
- return mockChannels;
+ return listMockChannels();
}
- const response = await fetch(`${getRelayHttpUrl(config)}/api/channels`, {
- headers: {
- "X-Pubkey": identity.pubkey,
- },
- });
- await assertOk(response);
- return response.json();
+ return relayJsonRequest(config, "/api/channels");
}
async function handleCreateChannel(
@@ -348,24 +731,35 @@ async function handleCreateChannel(
) {
const identity = getIdentity(config);
if (!identity) {
- const channel: RawChannel = {
+ const owner = createCurrentMember(config, "owner");
+ const channel = createMockChannel({
id: crypto.randomUUID(),
name: args.name,
channel_type: args.channelType,
+ visibility: args.visibility,
description: args.description ?? "",
- participants: [],
- participant_pubkeys: [],
- };
+ topic: null,
+ purpose: null,
+ last_message_at: null,
+ archived_at: null,
+ created_by: owner.pubkey,
+ topic_set_by: null,
+ topic_set_at: null,
+ purpose_set_by: null,
+ purpose_set_at: null,
+ topic_required: false,
+ max_members: null,
+ nip29_group_id: null,
+ created_minutes_ago: 0,
+ updated_minutes_ago: 0,
+ members: [owner],
+ });
mockChannels.push(channel);
- return channel;
+ return toRawChannel(channel);
}
- const response = await fetch(`${getRelayHttpUrl(config)}/api/channels`, {
+ return relayJsonRequest(config, "/api/channels", {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-Pubkey": identity.pubkey,
- },
body: JSON.stringify({
name: args.name,
channel_type: args.channelType,
@@ -373,8 +767,315 @@ async function handleCreateChannel(
description: args.description,
}),
});
- await assertOk(response);
- return response.json();
+}
+
+async function handleGetChannelDetails(
+ args: { channelId: string },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ return toRawChannelDetail(getMockChannel(args.channelId));
+ }
+
+ return relayJsonRequest(
+ config,
+ `/api/channels/${args.channelId}`,
+ );
+}
+
+async function handleGetChannelMembers(
+ args: { channelId: string },
+ config: E2eConfig | undefined,
+): Promise {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ return {
+ members: cloneMembers(channel.members),
+ next_cursor: null,
+ };
+ }
+
+ return relayJsonRequest(
+ config,
+ `/api/channels/${args.channelId}/members`,
+ );
+}
+
+async function handleUpdateChannel(
+ args: {
+ channelId: string;
+ name?: string;
+ description?: string;
+ },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ if (args.name !== undefined) {
+ channel.name = args.name;
+ }
+ if (args.description !== undefined) {
+ channel.description = args.description;
+ }
+ touchMockChannel(channel);
+ return toRawChannelDetail(channel);
+ }
+
+ return relayJsonRequest(
+ config,
+ `/api/channels/${args.channelId}`,
+ {
+ method: "PUT",
+ body: JSON.stringify({
+ name: args.name,
+ description: args.description,
+ }),
+ },
+ );
+}
+
+async function handleSetChannelTopic(
+ args: {
+ channelId: string;
+ topic: string;
+ },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ const nextTopic = args.topic.trim();
+
+ channel.topic = nextTopic.length > 0 ? nextTopic : null;
+ channel.topic_set_by = getMockMemberPubkey(config);
+ channel.topic_set_at = new Date().toISOString();
+ touchMockChannel(channel);
+ return;
+ }
+
+ await relayEmptyRequest(config, `/api/channels/${args.channelId}/topic`, {
+ method: "PUT",
+ body: JSON.stringify({
+ topic: args.topic,
+ }),
+ });
+}
+
+async function handleSetChannelPurpose(
+ args: {
+ channelId: string;
+ purpose: string;
+ },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ const nextPurpose = args.purpose.trim();
+
+ channel.purpose = nextPurpose.length > 0 ? nextPurpose : null;
+ channel.purpose_set_by = getMockMemberPubkey(config);
+ channel.purpose_set_at = new Date().toISOString();
+ touchMockChannel(channel);
+ return;
+ }
+
+ await relayEmptyRequest(config, `/api/channels/${args.channelId}/purpose`, {
+ method: "PUT",
+ body: JSON.stringify({
+ purpose: args.purpose,
+ }),
+ });
+}
+
+async function handleArchiveChannel(
+ args: { channelId: string },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ channel.archived_at = new Date().toISOString();
+ touchMockChannel(channel);
+ return;
+ }
+
+ await relayEmptyRequest(config, `/api/channels/${args.channelId}/archive`, {
+ method: "POST",
+ });
+}
+
+async function handleUnarchiveChannel(
+ args: { channelId: string },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ channel.archived_at = null;
+ touchMockChannel(channel);
+ return;
+ }
+
+ await relayEmptyRequest(config, `/api/channels/${args.channelId}/unarchive`, {
+ method: "POST",
+ });
+}
+
+async function handleDeleteChannel(
+ args: { channelId: string },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const index = mockChannels.findIndex(
+ (channel) => channel.id === args.channelId,
+ );
+ if (index === -1) {
+ throw new Error(`Channel ${args.channelId} not found.`);
+ }
+
+ mockChannels.splice(index, 1);
+ mockMessages.delete(args.channelId);
+ return;
+ }
+
+ await relayEmptyRequest(config, `/api/channels/${args.channelId}`, {
+ method: "DELETE",
+ });
+}
+
+async function handleAddChannelMembers(
+ args: {
+ channelId: string;
+ pubkeys: string[];
+ role?: RawChannelMember["role"];
+ },
+ config: E2eConfig | undefined,
+): Promise {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ const added: string[] = [];
+ const errors: RawAddChannelMembersResponse["errors"] = [];
+
+ for (const pubkey of args.pubkeys) {
+ if (channel.members.some((member) => member.pubkey === pubkey)) {
+ errors.push({
+ pubkey,
+ error: "Already a member.",
+ });
+ continue;
+ }
+
+ channel.members.push({
+ pubkey,
+ role: args.role ?? "member",
+ joined_at: new Date().toISOString(),
+ display_name: mockDisplayNames.get(pubkey) ?? null,
+ });
+ added.push(pubkey);
+ }
+
+ syncMockChannel(channel);
+ touchMockChannel(channel);
+ return {
+ added,
+ errors,
+ };
+ }
+
+ return relayJsonRequest(
+ config,
+ `/api/channels/${args.channelId}/members`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ pubkeys: args.pubkeys,
+ role: args.role,
+ }),
+ },
+ );
+}
+
+async function handleRemoveChannelMember(
+ args: {
+ channelId: string;
+ pubkey: string;
+ },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ channel.members = channel.members.filter(
+ (member) => member.pubkey !== args.pubkey,
+ );
+ syncMockChannel(channel);
+ touchMockChannel(channel);
+ return;
+ }
+
+ await relayEmptyRequest(
+ config,
+ `/api/channels/${args.channelId}/members/${args.pubkey}`,
+ {
+ method: "DELETE",
+ },
+ );
+}
+
+async function handleJoinChannel(
+ args: {
+ channelId: string;
+ },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ const currentPubkey = getMockMemberPubkey(config);
+
+ if (channel.members.some((member) => member.pubkey === currentPubkey)) {
+ return;
+ }
+
+ channel.members.push(createCurrentMember(config, "member"));
+ syncMockChannel(channel);
+ touchMockChannel(channel);
+ return;
+ }
+
+ await relayEmptyRequest(config, `/api/channels/${args.channelId}/join`, {
+ method: "POST",
+ });
+}
+
+async function handleLeaveChannel(
+ args: {
+ channelId: string;
+ },
+ config: E2eConfig | undefined,
+) {
+ const identity = getIdentity(config);
+ if (!identity) {
+ const channel = getMockChannel(args.channelId);
+ const currentPubkey = getMockMemberPubkey(config);
+
+ channel.members = channel.members.filter(
+ (member) => member.pubkey !== currentPubkey,
+ );
+ syncMockChannel(channel);
+ touchMockChannel(channel);
+ return;
+ }
+
+ await relayEmptyRequest(config, `/api/channels/${args.channelId}/leave`, {
+ method: "POST",
+ });
}
async function handleGetFeed(
@@ -837,6 +1538,66 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters[0],
activeConfig,
);
+ case "get_channel_details":
+ return handleGetChannelDetails(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "get_channel_members":
+ return handleGetChannelMembers(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "update_channel":
+ return handleUpdateChannel(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "set_channel_topic":
+ return handleSetChannelTopic(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "set_channel_purpose":
+ return handleSetChannelPurpose(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "archive_channel":
+ return handleArchiveChannel(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "unarchive_channel":
+ return handleUnarchiveChannel(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "delete_channel":
+ return handleDeleteChannel(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "add_channel_members":
+ return handleAddChannelMembers(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "remove_channel_member":
+ return handleRemoveChannelMember(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "join_channel":
+ return handleJoinChannel(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "leave_channel":
+ return handleLeaveChannel(
+ payload as Parameters[0],
+ activeConfig,
+ );
case "search_messages":
return handleSearchMessages(
payload as Parameters[0],
diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts
index bf1a33acb..1a4d15e7b 100644
--- a/desktop/tests/e2e/channels.spec.ts
+++ b/desktop/tests/e2e/channels.spec.ts
@@ -1,6 +1,23 @@
import { expect, test } from "@playwright/test";
-import { installMockBridge } from "../helpers/bridge";
+import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
+
+const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
+
+async function openChannelManagement(
+ page: import("@playwright/test").Page,
+ channelName: string,
+) {
+ await page.getByTestId(`channel-${channelName}`).click();
+ await expect(page.getByTestId("chat-title")).toHaveText(channelName);
+ await page.getByTestId("channel-management-trigger").click();
+ await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
+}
+
+async function closeChannelManagement(page: import("@playwright/test").Page) {
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
+}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
@@ -123,3 +140,155 @@ test("sidebar persists after channel switch", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
await expect(page.getByTestId("app-sidebar")).toBeVisible();
});
+
+test("manage channel updates details and context", async ({ page }) => {
+ const stamp = Date.now();
+ const newName = `release-hub-${stamp}`;
+ const newDescription = `Release coordination ${stamp}`;
+ const newTopic = `Launch plan ${stamp}`;
+ const newPurpose = `Track blockers and owners ${stamp}`;
+
+ await page.goto("/");
+ await openChannelManagement(page, "general");
+
+ await page.getByTestId("channel-management-name").fill(newName);
+ await page.getByTestId("channel-management-description").fill(newDescription);
+ await page.getByTestId("channel-management-save-details").click();
+
+ await expect(page.getByTestId("chat-title")).toHaveText(newName);
+ await expect(page.getByTestId("stream-list")).toContainText(newName);
+
+ const saveTopicButton = page.getByTestId("channel-management-save-topic");
+ const savePurposeButton = page.getByTestId("channel-management-save-purpose");
+
+ await page.getByTestId("channel-management-topic").fill(newTopic);
+ await saveTopicButton.click();
+ await expect(saveTopicButton).toHaveText("Save topic");
+ await expect(page.getByTestId("channel-management-topic")).toHaveValue(
+ newTopic,
+ );
+
+ await page.getByTestId("channel-management-purpose").fill(newPurpose);
+ await savePurposeButton.click();
+ await expect(savePurposeButton).toHaveText("Save purpose");
+ await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
+ newPurpose,
+ );
+
+ await closeChannelManagement(page);
+
+ await page.getByTestId("channel-random").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+
+ await page.getByTestId("stream-list").getByText(newName).click();
+ await expect(page.getByTestId("chat-title")).toHaveText(newName);
+ await page.getByTestId("channel-management-trigger").click();
+ await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
+
+ await expect(page.getByTestId("channel-management-name")).toHaveValue(
+ newName,
+ );
+ await expect(page.getByTestId("channel-management-description")).toHaveValue(
+ newDescription,
+ );
+ await expect(page.getByTestId("channel-management-topic")).toHaveValue(
+ newTopic,
+ );
+ await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
+ newPurpose,
+ );
+});
+
+test("manage channel can invite and remove members", async ({ page }) => {
+ await page.goto("/");
+ await openChannelManagement(page, "general");
+
+ await page
+ .getByTestId("channel-management-add-pubkeys")
+ .fill(TEST_IDENTITIES.charlie.pubkey);
+ await page.getByTestId("channel-management-add-role").selectOption("admin");
+ await page.getByTestId("channel-management-add-members").click();
+
+ await expect(page.getByTestId("channel-management-add-pubkeys")).toHaveValue(
+ "",
+ );
+ await expect(
+ page.getByTestId(`channel-member-${TEST_IDENTITIES.charlie.pubkey}`),
+ ).toContainText("charlie");
+ await expect(
+ page.getByTestId(`channel-member-${TEST_IDENTITIES.charlie.pubkey}`),
+ ).toContainText("admin");
+
+ await page
+ .getByTestId(`remove-member-${TEST_IDENTITIES.charlie.pubkey}`)
+ .click();
+
+ await expect(
+ page.getByTestId(`channel-member-${TEST_IDENTITIES.charlie.pubkey}`),
+ ).toHaveCount(0);
+});
+
+test("open channel management supports join and leave", async ({ page }) => {
+ await page.goto("/");
+ await openChannelManagement(page, "random");
+
+ await expect(page.getByTestId("channel-management-join")).toBeVisible();
+ await expect(page.getByTestId("channel-management-leave")).toHaveCount(0);
+
+ await page.getByTestId("channel-management-join").click();
+
+ await expect(page.getByTestId("channel-management-join")).toHaveCount(0);
+ await expect(page.getByTestId("channel-management-leave")).toBeVisible();
+ await expect(
+ page.getByTestId(`channel-member-${MOCK_IDENTITY_PUBKEY}`),
+ ).toContainText("You");
+
+ await page.getByTestId("channel-management-leave").click();
+ await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
+
+ await page.getByTestId("channel-management-trigger").click();
+ await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
+ await expect(page.getByTestId("channel-management-join")).toBeVisible();
+ await expect(
+ page.getByTestId(`channel-member-${MOCK_IDENTITY_PUBKEY}`),
+ ).toHaveCount(0);
+});
+
+test("manage channel can archive and unarchive a stream", async ({ page }) => {
+ await page.goto("/");
+ await openChannelManagement(page, "general");
+
+ await page.getByTestId("channel-management-archive").click();
+ await expect(page.getByTestId("channel-management-unarchive")).toBeVisible();
+
+ await closeChannelManagement(page);
+ await expect(page.getByTestId("message-input")).toBeDisabled();
+ await expect(page.getByTestId("send-message")).toBeDisabled();
+
+ await page.getByTestId("channel-management-trigger").click();
+ await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
+ await page.getByTestId("channel-management-unarchive").click();
+ await expect(page.getByTestId("channel-management-archive")).toBeVisible();
+
+ await closeChannelManagement(page);
+ await expect(page.getByTestId("message-input")).toBeEnabled();
+});
+
+test("manage channel can delete an owned stream", async ({ page }) => {
+ const channelName = `delete-me-${Date.now()}`;
+
+ await page.goto("/");
+ await page.getByRole("button", { name: "Create a stream" }).click();
+ await page.getByTestId("create-stream-name").fill(channelName);
+ await page.getByRole("button", { name: "Create" }).click();
+ await expect(page.getByTestId("chat-title")).toHaveText(channelName);
+
+ page.once("dialog", (dialog) => dialog.accept());
+
+ await page.getByTestId("channel-management-trigger").click();
+ await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
+ await page.getByTestId("channel-management-delete").click();
+
+ await expect(page.getByTestId("chat-title")).toHaveText("Home");
+ await expect(page.getByTestId("stream-list")).not.toContainText(channelName);
+});
diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts
index 578e74e4f..7899f632e 100644
--- a/desktop/tests/e2e/integration.spec.ts
+++ b/desktop/tests/e2e/integration.spec.ts
@@ -3,6 +3,32 @@ import { expect, test, type Browser } from "@playwright/test";
import { installRelayBridge } from "../helpers/bridge";
import { assertRelaySeeded } from "../helpers/seed";
+async function createStream(
+ page: import("@playwright/test").Page,
+ channelName: string,
+ description?: string,
+) {
+ await page.getByRole("button", { name: "Create a stream" }).click();
+ await page.getByTestId("create-stream-name").fill(channelName);
+ if (description !== undefined) {
+ await page.getByTestId("create-stream-description").fill(description);
+ }
+ await page.getByRole("button", { name: "Create" }).click();
+
+ await expect(page.getByTestId("stream-list")).toContainText(channelName);
+ await expect(page.getByTestId("chat-title")).toHaveText(channelName);
+}
+
+async function openChannelManagement(page: import("@playwright/test").Page) {
+ await page.getByTestId("channel-management-trigger").click();
+ await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
+}
+
+async function closeChannelManagement(page: import("@playwright/test").Page) {
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
+}
+
test.beforeAll(async () => {
await assertRelaySeeded();
});
@@ -118,13 +144,8 @@ test("create channel with description", async ({ page }) => {
await installRelayBridge(page, "tyler");
await page.goto("/");
- await page.getByRole("button", { name: "Create a stream" }).click();
- await page.getByTestId("create-stream-name").fill(channelName);
- await page.getByTestId("create-stream-description").fill(description);
- await page.getByRole("button", { name: "Create" }).click();
+ await createStream(page, channelName, description);
- await expect(page.getByTestId("stream-list")).toContainText(channelName);
- await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await expect(page.getByTestId("chat-description")).toContainText(description);
});
@@ -162,3 +183,106 @@ test("multiple channels independent", async ({ page }) => {
messageA,
);
});
+
+test("manage sheet updates channel details and context through the relay", async ({
+ page,
+}) => {
+ const stamp = Date.now();
+ const initialName = `manage-integration-${stamp}`;
+ const renamedChannel = `manage-renamed-${stamp}`;
+ const initialDescription = `Initial description ${stamp}`;
+ const updatedDescription = `Updated description ${stamp}`;
+ const updatedTopic = `Updated topic ${stamp}`;
+ const updatedPurpose = `Updated purpose ${stamp}`;
+
+ await installRelayBridge(page, "tyler");
+ await page.goto("/");
+ await createStream(page, initialName, initialDescription);
+
+ await openChannelManagement(page);
+ await page.getByTestId("channel-management-name").fill(renamedChannel);
+ await page
+ .getByTestId("channel-management-description")
+ .fill(updatedDescription);
+ await page.getByTestId("channel-management-save-details").click();
+
+ await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
+ await expect(page.getByTestId("stream-list")).toContainText(renamedChannel);
+
+ const saveTopicButton = page.getByTestId("channel-management-save-topic");
+ const savePurposeButton = page.getByTestId("channel-management-save-purpose");
+
+ await page.getByTestId("channel-management-topic").fill(updatedTopic);
+ await saveTopicButton.click();
+ await expect(saveTopicButton).toHaveText("Save topic");
+ await expect(page.getByTestId("channel-management-topic")).toHaveValue(
+ updatedTopic,
+ );
+
+ await page.getByTestId("channel-management-purpose").fill(updatedPurpose);
+ await savePurposeButton.click();
+ await expect(savePurposeButton).toHaveText("Save purpose");
+ await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
+ updatedPurpose,
+ );
+
+ await closeChannelManagement(page);
+ await page.reload();
+
+ await page.getByTestId(`channel-${renamedChannel}`).click();
+ await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
+ await expect(page.getByTestId("chat-description")).toContainText(
+ updatedTopic,
+ );
+ await expect(page.getByTestId("chat-description")).toContainText(
+ updatedDescription,
+ );
+ await expect(page.getByTestId("chat-description")).toContainText(
+ updatedPurpose,
+ );
+
+ await openChannelManagement(page);
+ await expect(page.getByTestId("channel-management-name")).toHaveValue(
+ renamedChannel,
+ );
+ await expect(page.getByTestId("channel-management-description")).toHaveValue(
+ updatedDescription,
+ );
+ await expect(page.getByTestId("channel-management-topic")).toHaveValue(
+ updatedTopic,
+ );
+ await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
+ updatedPurpose,
+ );
+});
+
+test("manage sheet archive and unarchive survives a reload through the relay", async ({
+ page,
+}) => {
+ const channelName = `archive-integration-${Date.now()}`;
+
+ await installRelayBridge(page, "tyler");
+ await page.goto("/");
+ await createStream(page, channelName, "Archive integration channel");
+
+ await openChannelManagement(page);
+ await page.getByTestId("channel-management-archive").click();
+ await expect(page.getByTestId("channel-management-unarchive")).toBeVisible();
+ await closeChannelManagement(page);
+
+ await expect(page.getByTestId("message-input")).toBeDisabled();
+ await expect(page.getByTestId("send-message")).toBeDisabled();
+
+ await page.reload();
+
+ await page.getByTestId(`channel-${channelName}`).click();
+ await expect(page.getByTestId("chat-title")).toHaveText(channelName);
+ await expect(page.getByTestId("message-input")).toBeDisabled();
+
+ await openChannelManagement(page);
+ await page.getByTestId("channel-management-unarchive").click();
+ await expect(page.getByTestId("channel-management-archive")).toBeVisible();
+ await closeChannelManagement(page);
+
+ await expect(page.getByTestId("message-input")).toBeEnabled();
+});