Add channel management e2e coverage (#24)

This commit is contained in:
Wes
2026-03-10 17:11:49 -07:00
committed by GitHub
parent 9793c997a6
commit d932984047
10 changed files with 2680 additions and 144 deletions
+274 -72
View File
@@ -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<String>,
pub purpose: Option<String>,
pub member_count: i64,
pub last_message_at: Option<String>,
pub archived_at: Option<String>,
pub participants: Vec<String>,
pub participant_pubkeys: Vec<String>,
}
#[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<String>,
pub topic_set_by: Option<String>,
pub topic_set_at: Option<String>,
pub purpose: Option<String>,
pub purpose_set_by: Option<String>,
pub purpose_set_at: Option<String>,
pub created_by: String,
pub created_at: String,
pub updated_at: String,
pub archived_at: Option<String>,
pub member_count: i64,
pub topic_required: bool,
pub max_members: Option<i32>,
pub nip29_group_id: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelMemberInfo {
pub pubkey: String,
pub role: String,
pub joined_at: String,
pub display_name: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelMembersResponse {
pub members: Vec<ChannelMemberInfo>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct AddMembersResponse {
pub added: Vec<String>,
pub errors: Vec<serde_json::Value>,
}
#[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<reqwest::RequestBuilder, String> {
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<String, String> {
@@ -145,6 +221,38 @@ async fn relay_error_message(response: reqwest::Response) -> String {
format!("relay returned {status}: {body}")
}
async fn send_json_request<T>(request: reqwest::RequestBuilder) -> Result<T, String>
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::<T>()
.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<IdentityInfo, String> {
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<Vec<ChannelInfo>, 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::<Vec<ChannelInfo>>()
.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<String>,
state: tauri::State<'_, AppState>,
) -> Result<ChannelInfo, String> {
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::<ChannelInfo>()
.await
.map_err(|e| format!("parse failed: {e}"))
#[tauri::command]
async fn get_channel_details(
channel_id: String,
state: tauri::State<'_, AppState>,
) -> Result<ChannelDetailInfo, String> {
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<ChannelMembersResponse, String> {
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<String>,
description: Option<String>,
state: tauri::State<'_, AppState>,
) -> Result<ChannelDetailInfo, String> {
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<String>,
role: Option<String>,
state: tauri::State<'_, AppState>,
) -> Result<AddMembersResponse, String> {
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<String>,
state: tauri::State<'_, AppState>,
) -> Result<FeedResponse, String> {
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::<FeedResponse>()
.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<u32>,
state: tauri::State<'_, AppState>,
) -> Result<SearchResponse, String> {
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::<SearchResponse>()
.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<String, String> {
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,
+53 -8
View File
@@ -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<AppView>("home");
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
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() {
/>
) : (
<ChatHeader
actions={
activeChannel ? (
<Button
data-testid="channel-management-trigger"
onClick={() => {
setIsChannelManagementOpen(true);
}}
size="sm"
type="button"
variant="outline"
>
<Settings2 className="h-4 w-4" />
Manage
</Button>
) : 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}
/>
<ChannelManagementSheet
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onDeleted={() => {
React.startTransition(() => {
setIsChannelManagementOpen(false);
setSelectedView("home");
});
}}
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen && activeChannel !== null}
/>
</SidebarInset>
</SidebarProvider>
);
+287 -2
View File
@@ -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<typeof useQueryClient>,
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<UpdateChannelInput, "channelId">) => {
if (!channelId) {
throw new Error("No channel selected.");
}
return updateChannel({ ...input, channelId });
},
onSuccess: (updatedChannel) => {
if (!channelId) {
return;
}
queryClient.setQueryData<ChannelDetail>(
channelDetailQueryKey(channelId),
updatedChannel,
);
queryClient.setQueryData<Channel[]>(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<SetChannelTopicInput, "channelId">) => {
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<SetChannelPurposeInput, "channelId">) => {
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<Channel[]>(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<AddChannelMembersInput, "channelId">) => {
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,
@@ -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<Exclude<ChannelMember["role"], "owner">> = [
"member",
"admin",
"guest",
"bot",
];
const roleOrder: Record<ChannelMember["role"], number> = {
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 (
<section className="space-y-3">
<div className="space-y-1">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{children}
</section>
);
}
function MetadataPill({
icon: Icon,
label,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
}) {
return (
<div className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground">
<Icon className="h-3.5 w-3.5" />
<span>{label}</span>
</div>
);
}
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<Exclude<ChannelMember["role"], "owner">>("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 (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-xl"
data-testid="channel-management-sheet"
side="right"
>
<SheetHeader className="space-y-4 border-b border-border/80 bg-muted/20 px-6 py-6 text-left">
<div className="space-y-2">
<SheetTitle className="pr-8">{channel.name}</SheetTitle>
<SheetDescription>
Manage channel settings, membership, and access.
</SheetDescription>
</div>
<div className="flex flex-wrap gap-2">
<MetadataPill
icon={
channel.channelType === "forum"
? FileText
: channel.channelType === "dm"
? MessageSquare
: Hash
}
label={channel.channelType}
/>
<MetadataPill
icon={channel.visibility === "private" ? Lock : DoorOpen}
label={channel.visibility}
/>
<MetadataPill
icon={Users}
label={`${resolvedChannel.memberCount} members`}
/>
{isArchived ? (
<MetadataPill icon={Archive} label="archived" />
) : null}
</div>
</SheetHeader>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{detailsQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{detailsQuery.error.message}
</p>
) : null}
{membersQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{membersQuery.error.message}
</p>
) : null}
<Section
description="Open channels stay visible to everyone. Private channels require an invite."
title="Access"
>
<div className="flex flex-wrap gap-2">
{canJoin ? (
<Button
data-testid="channel-management-join"
disabled={joinChannelMutation.isPending}
onClick={() => {
void joinChannelMutation.mutateAsync();
}}
size="sm"
type="button"
>
<DoorOpen className="h-4 w-4" />
{joinChannelMutation.isPending
? "Joining..."
: "Join channel"}
</Button>
) : null}
{canLeave ? (
<Button
data-testid="channel-management-leave"
disabled={leaveChannelMutation.isPending}
onClick={() => {
void leaveChannelMutation.mutateAsync().then(() => {
onOpenChange(false);
});
}}
size="sm"
type="button"
variant="outline"
>
<DoorClosed className="h-4 w-4" />
{leaveChannelMutation.isPending
? "Leaving..."
: "Leave channel"}
</Button>
) : null}
</div>
{joinChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{joinChannelMutation.error.message}
</p>
) : null}
{leaveChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{leaveChannelMutation.error.message}
</p>
) : null}
</Section>
<Separator />
<Section
description="Name and description are owner/admin actions."
title="Details"
>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void updateChannelMutation.mutateAsync({
description: descriptionDraft.trim() || undefined,
name: nameDraft.trim() || undefined,
});
}}
>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="channel-name">
Name
</label>
<Input
data-testid="channel-management-name"
disabled={
!canManageChannel || updateChannelMutation.isPending
}
id="channel-name"
onChange={(event) => setNameDraft(event.target.value)}
value={nameDraft}
/>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-description"
>
Description
</label>
<Textarea
className="min-h-24"
data-testid="channel-management-description"
disabled={
!canManageChannel || updateChannelMutation.isPending
}
id="channel-description"
onChange={(event) => setDescriptionDraft(event.target.value)}
value={descriptionDraft}
/>
</div>
<Button
data-testid="channel-management-save-details"
disabled={!canManageChannel || updateChannelMutation.isPending}
size="sm"
type="submit"
>
{updateChannelMutation.isPending ? "Saving..." : "Save details"}
</Button>
{updateChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{updateChannelMutation.error.message}
</p>
) : null}
</form>
</Section>
<Separator />
<Section
description="Topic and purpose show the current context for the channel."
title="Context"
>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void setTopicMutation.mutateAsync({
topic: topicDraft.trim(),
});
}}
>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="channel-topic">
Topic
</label>
<Input
data-testid="channel-management-topic"
disabled={!canEditNarrative || setTopicMutation.isPending}
id="channel-topic"
onChange={(event) => setTopicDraft(event.target.value)}
value={topicDraft}
/>
</div>
<Button
data-testid="channel-management-save-topic"
disabled={!canEditNarrative || setTopicMutation.isPending}
size="sm"
type="submit"
variant="outline"
>
{setTopicMutation.isPending ? "Saving..." : "Save topic"}
</Button>
{setTopicMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{setTopicMutation.error.message}
</p>
) : null}
</form>
<form
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void setPurposeMutation.mutateAsync({
purpose: purposeDraft.trim(),
});
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-purpose"
>
Purpose
</label>
<Textarea
className="min-h-24"
data-testid="channel-management-purpose"
disabled={!canEditNarrative || setPurposeMutation.isPending}
id="channel-purpose"
onChange={(event) => setPurposeDraft(event.target.value)}
value={purposeDraft}
/>
</div>
<Button
data-testid="channel-management-save-purpose"
disabled={!canEditNarrative || setPurposeMutation.isPending}
size="sm"
type="submit"
variant="outline"
>
{setPurposeMutation.isPending ? "Saving..." : "Save purpose"}
</Button>
{setPurposeMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{setPurposeMutation.error.message}
</p>
) : null}
</form>
</Section>
<Separator />
<Section
description="Owners and admins can invite members or remove them."
title="Members"
>
{canManageChannel && resolvedChannel.channelType !== "dm" ? (
<form
className="space-y-3 rounded-2xl border border-border/80 bg-muted/20 p-4"
onSubmit={(event) => {
event.preventDefault();
void addMembersMutation
.mutateAsync({
pubkeys: parsedInvitePubkeys,
role: inviteRole,
})
.then((result) => {
if (result.errors.length === 0) {
setInvitePubkeys("");
}
});
}}
>
<div className="flex items-center gap-2 text-sm font-medium">
<UserPlus className="h-4 w-4" />
Invite members
</div>
<Textarea
className="min-h-24"
data-testid="channel-management-add-pubkeys"
disabled={addMembersMutation.isPending}
onChange={(event) => setInvitePubkeys(event.target.value)}
placeholder="Paste one or more pubkeys, separated by spaces, commas, or new lines."
value={invitePubkeys}
/>
<div className="flex flex-wrap items-center gap-3">
<label
className="flex items-center gap-2 text-sm text-muted-foreground"
htmlFor="channel-member-role"
>
Role
</label>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
data-testid="channel-management-add-role"
disabled={addMembersMutation.isPending}
id="channel-member-role"
onChange={(event) =>
setInviteRole(
event.target.value as Exclude<
ChannelMember["role"],
"owner"
>,
)
}
value={inviteRole}
>
{roleOptions.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
<Button
data-testid="channel-management-add-members"
disabled={
addMembersMutation.isPending ||
parsedInvitePubkeys.length === 0
}
size="sm"
type="submit"
>
{addMembersMutation.isPending
? "Inviting..."
: "Add members"}
</Button>
</div>
{addMembersMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{addMembersMutation.error.message}
</p>
) : null}
{addMembersMutation.data &&
addMembersMutation.data.errors.length > 0 ? (
<div className="space-y-1 text-sm text-destructive">
{addMembersMutation.data.errors.map((error) => (
<p key={`${error.pubkey}-${error.error}`}>
{formatPubkey(error.pubkey)}: {error.error}
</p>
))}
</div>
) : null}
</form>
) : null}
<div className="space-y-2" data-testid="channel-members-list">
{members.length > 0 ? (
members.map((member) => {
const Icon = roleIcon(member.role);
return (
<div
className="flex items-start justify-between gap-3 rounded-2xl border border-border/80 bg-background px-4 py-3"
data-testid={`channel-member-${member.pubkey}`}
key={member.pubkey}
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<p className="truncate text-sm font-medium">
{formatMemberName(member, currentPubkey)}
</p>
<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>
</div>
<p className="text-xs text-muted-foreground">
{member.pubkey}
</p>
</div>
{canManageChannel ||
(currentPubkey && member.pubkey === currentPubkey) ? (
<Button
data-testid={`remove-member-${member.pubkey}`}
disabled={
removeMemberMutation.isPending || isArchived
}
onClick={() => {
void removeMemberMutation
.mutateAsync(member.pubkey)
.then(() => {
if (member.pubkey === currentPubkey) {
onOpenChange(false);
}
});
}}
size="sm"
type="button"
variant="ghost"
>
Remove
</Button>
) : null}
</div>
);
})
) : (
<p className="text-sm text-muted-foreground">
{membersQuery.isLoading
? "Loading members..."
: "No active members found."}
</p>
)}
</div>
{removeMemberMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{removeMemberMutation.error.message}
</p>
) : null}
</Section>
{resolvedChannel.channelType !== "dm" ? (
<>
<Separator />
<Section
description="Archiving keeps history but blocks new changes."
title="Channel state"
>
<div className="flex flex-wrap gap-2">
{isArchived ? (
<Button
data-testid="channel-management-unarchive"
disabled={
!canManageChannel || unarchiveChannelMutation.isPending
}
onClick={() => {
void unarchiveChannelMutation.mutateAsync();
}}
size="sm"
type="button"
>
<ArchiveRestore className="h-4 w-4" />
{unarchiveChannelMutation.isPending
? "Restoring..."
: "Unarchive channel"}
</Button>
) : (
<Button
data-testid="channel-management-archive"
disabled={
!canManageChannel || archiveChannelMutation.isPending
}
onClick={() => {
void archiveChannelMutation.mutateAsync();
}}
size="sm"
type="button"
variant="outline"
>
<Archive className="h-4 w-4" />
{archiveChannelMutation.isPending
? "Archiving..."
: "Archive channel"}
</Button>
)}
</div>
{archiveChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{archiveChannelMutation.error.message}
</p>
) : null}
{unarchiveChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{unarchiveChannelMutation.error.message}
</p>
) : null}
</Section>
</>
) : null}
{isOwner && resolvedChannel.channelType !== "dm" ? (
<>
<Separator />
<Section
description="Deleting removes the channel from the workspace list."
title="Danger zone"
>
<Button
data-testid="channel-management-delete"
disabled={deleteChannelMutation.isPending}
onClick={() => {
if (!window.confirm(`Delete ${resolvedChannel.name}?`)) {
return;
}
void deleteChannelMutation.mutateAsync().then(() => {
onOpenChange(false);
onDeleted?.();
});
}}
size="sm"
type="button"
variant="destructive"
>
{deleteChannelMutation.isPending
? "Deleting..."
: "Delete channel"}
</Button>
{deleteChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{deleteChannelMutation.error.message}
</p>
) : null}
</Section>
</>
) : null}
</div>
</SheetContent>
</Sheet>
);
}
@@ -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}
</p>
</div>
{actions ? <div className="shrink-0">{actions}</div> : null}
</header>
);
}
+150
View File
@@ -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<ChannelDetail> {
const channel = await invoke<RawChannelDetail>("get_channel_details", {
channelId,
});
return fromRawChannelDetail(channel);
}
export async function getChannelMembers(
channelId: string,
): Promise<ChannelMember[]> {
const response = await invoke<RawChannelMembersResponse>(
"get_channel_members",
{
channelId,
},
);
return response.members.map(fromRawChannelMember);
}
export async function updateChannel(
input: UpdateChannelInput,
): Promise<ChannelDetail> {
const channel = await invoke<RawChannelDetail>("update_channel", input);
return fromRawChannelDetail(channel);
}
export async function setChannelTopic(
input: SetChannelTopicInput,
): Promise<void> {
await invoke("set_channel_topic", input);
}
export async function setChannelPurpose(
input: SetChannelPurposeInput,
): Promise<void> {
await invoke("set_channel_purpose", input);
}
export async function archiveChannel(channelId: string): Promise<void> {
await invoke("archive_channel", { channelId });
}
export async function unarchiveChannel(channelId: string): Promise<void> {
await invoke("unarchive_channel", { channelId });
}
export async function deleteChannel(channelId: string): Promise<void> {
await invoke("delete_channel", { channelId });
}
export async function addChannelMembers(
input: AddChannelMembersInput,
): Promise<AddChannelMembersResult> {
return invoke<RawAddChannelMembersResult>("add_channel_members", input);
}
export async function removeChannelMember(
channelId: string,
pubkey: string,
): Promise<void> {
await invoke("remove_channel_member", { channelId, pubkey });
}
export async function joinChannel(channelId: string): Promise<void> {
await invoke("join_channel", { channelId });
}
export async function leaveChannel(channelId: string): Promise<void> {
await invoke("leave_channel", { channelId });
}
export async function getHomeFeed(
input: GetHomeFeedInput = {},
): Promise<HomeFeedResponse> {
+57
View File
@@ -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<ChannelType, "dm">;
@@ -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<ChannelRole, "owner">;
};
export type AddChannelMembersResult = {
added: string[];
errors: Array<{
pubkey: string;
error: string;
}>;
};
export type Identity = {
pubkey: string;
displayName: string;
+816 -55
View File
@@ -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<string, string>([
[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<string, RelayEvent[]>();
@@ -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<T>(
config: E2eConfig | undefined,
path: string,
init: RequestInit = {},
): Promise<T> {
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<T>;
}
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<RawChannel[]>(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<RawChannel>(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<RawChannelDetail>(
config,
`/api/channels/${args.channelId}`,
);
}
async function handleGetChannelMembers(
args: { channelId: string },
config: E2eConfig | undefined,
): Promise<RawChannelMembersResponse> {
const identity = getIdentity(config);
if (!identity) {
const channel = getMockChannel(args.channelId);
return {
members: cloneMembers(channel.members),
next_cursor: null,
};
}
return relayJsonRequest<RawChannelMembersResponse>(
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<RawChannelDetail>(
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<RawAddChannelMembersResponse> {
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<RawAddChannelMembersResponse>(
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<typeof handleCreateChannel>[0],
activeConfig,
);
case "get_channel_details":
return handleGetChannelDetails(
payload as Parameters<typeof handleGetChannelDetails>[0],
activeConfig,
);
case "get_channel_members":
return handleGetChannelMembers(
payload as Parameters<typeof handleGetChannelMembers>[0],
activeConfig,
);
case "update_channel":
return handleUpdateChannel(
payload as Parameters<typeof handleUpdateChannel>[0],
activeConfig,
);
case "set_channel_topic":
return handleSetChannelTopic(
payload as Parameters<typeof handleSetChannelTopic>[0],
activeConfig,
);
case "set_channel_purpose":
return handleSetChannelPurpose(
payload as Parameters<typeof handleSetChannelPurpose>[0],
activeConfig,
);
case "archive_channel":
return handleArchiveChannel(
payload as Parameters<typeof handleArchiveChannel>[0],
activeConfig,
);
case "unarchive_channel":
return handleUnarchiveChannel(
payload as Parameters<typeof handleUnarchiveChannel>[0],
activeConfig,
);
case "delete_channel":
return handleDeleteChannel(
payload as Parameters<typeof handleDeleteChannel>[0],
activeConfig,
);
case "add_channel_members":
return handleAddChannelMembers(
payload as Parameters<typeof handleAddChannelMembers>[0],
activeConfig,
);
case "remove_channel_member":
return handleRemoveChannelMember(
payload as Parameters<typeof handleRemoveChannelMember>[0],
activeConfig,
);
case "join_channel":
return handleJoinChannel(
payload as Parameters<typeof handleJoinChannel>[0],
activeConfig,
);
case "leave_channel":
return handleLeaveChannel(
payload as Parameters<typeof handleLeaveChannel>[0],
activeConfig,
);
case "search_messages":
return handleSearchMessages(
payload as Parameters<typeof handleSearchMessages>[0],
+170 -1
View File
@@ -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);
});
+130 -6
View File
@@ -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();
});