mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: add channel browser for discovering and joining channels (#42)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -684,6 +684,15 @@ pub struct UserRecord {
|
||||
pub nip05_handle: Option<String>,
|
||||
}
|
||||
|
||||
/// A channel record paired with whether the querying user is an active member.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccessibleChannel {
|
||||
/// The channel record.
|
||||
pub channel: ChannelRecord,
|
||||
/// Whether the querying user is an active member of this channel.
|
||||
pub is_member: bool,
|
||||
}
|
||||
|
||||
/// Returns full channel records for all channels a user can access:
|
||||
/// open channels (visible to everyone) plus channels where the user is an active member.
|
||||
///
|
||||
@@ -696,13 +705,14 @@ pub async fn get_accessible_channels(
|
||||
pool: &MySqlPool,
|
||||
pubkey: &[u8],
|
||||
visibility_filter: Option<&str>,
|
||||
) -> Result<Vec<ChannelRecord>> {
|
||||
) -> Result<Vec<AccessibleChannel>> {
|
||||
let base = r#"
|
||||
SELECT DISTINCT c.id, c.name, c.channel_type, c.visibility, c.description, c.canvas,
|
||||
c.created_by, c.created_at, c.updated_at, c.archived_at, c.deleted_at,
|
||||
c.nip29_group_id, c.topic_required, c.max_members,
|
||||
c.topic, c.topic_set_by, c.topic_set_at,
|
||||
c.purpose, c.purpose_set_by, c.purpose_set_at
|
||||
c.purpose, c.purpose_set_by, c.purpose_set_at,
|
||||
(cm.channel_id IS NOT NULL) AS is_member
|
||||
FROM channels c
|
||||
LEFT JOIN channel_members cm
|
||||
ON c.id = cm.channel_id AND cm.pubkey = ? AND cm.removed_at IS NULL
|
||||
@@ -724,7 +734,13 @@ pub async fn get_accessible_channels(
|
||||
};
|
||||
|
||||
let rows = query.fetch_all(pool).await?;
|
||||
rows.into_iter().map(row_to_channel_record).collect()
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
let is_member: bool = row.try_get::<i8, _>("is_member").unwrap_or(0) != 0;
|
||||
let channel = row_to_channel_record(row)?;
|
||||
Ok(AccessibleChannel { channel, is_member })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns all bot-role members with their aggregated channel names.
|
||||
|
||||
@@ -327,7 +327,7 @@ impl Db {
|
||||
&self,
|
||||
pubkey: &[u8],
|
||||
visibility_filter: Option<&str>,
|
||||
) -> Result<Vec<channel::ChannelRecord>> {
|
||||
) -> Result<Vec<channel::AccessibleChannel>> {
|
||||
channel::get_accessible_channels(&self.pool, pubkey, visibility_filter).await
|
||||
}
|
||||
|
||||
|
||||
@@ -36,8 +36,10 @@ pub async fn agents_handler(
|
||||
tracing::error!("agents: failed to load accessible channels: {e}");
|
||||
internal_error("presence lookup failed")
|
||||
})?;
|
||||
let accessible_names: std::collections::HashSet<String> =
|
||||
accessible_channels.iter().map(|c| c.name.clone()).collect();
|
||||
let accessible_names: std::collections::HashSet<String> = accessible_channels
|
||||
.iter()
|
||||
.map(|ac| ac.channel.name.clone())
|
||||
.collect();
|
||||
|
||||
let bots = state
|
||||
.db
|
||||
|
||||
@@ -49,7 +49,7 @@ pub async fn channels_handler(
|
||||
|
||||
// Bulk-fetch member counts and last-message timestamps in two queries
|
||||
// instead of 2N queries (one per channel per metric).
|
||||
let channel_ids: Vec<uuid::Uuid> = channels.iter().map(|ch| ch.id).collect();
|
||||
let channel_ids: Vec<uuid::Uuid> = channels.iter().map(|ac| ac.channel.id).collect();
|
||||
let member_counts = state
|
||||
.db
|
||||
.get_member_counts_bulk(&channel_ids)
|
||||
@@ -63,7 +63,8 @@ pub async fn channels_handler(
|
||||
|
||||
let mut result = Vec::with_capacity(channels.len());
|
||||
|
||||
for ch in &channels {
|
||||
for ac in &channels {
|
||||
let ch = &ac.channel;
|
||||
let (participants, participant_pubkeys) = if ch.channel_type == "dm" {
|
||||
resolve_dm_participants(&state, ch.id).await
|
||||
} else {
|
||||
@@ -79,6 +80,7 @@ pub async fn channels_handler(
|
||||
participant_pubkeys,
|
||||
member_count,
|
||||
last_message_at,
|
||||
ac.is_member,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -153,7 +155,14 @@ pub async fn create_channel(
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(channel_record_to_json(&channel, vec![], vec![], 1, None)),
|
||||
Json(channel_record_to_json(
|
||||
&channel,
|
||||
vec![],
|
||||
vec![],
|
||||
1,
|
||||
None,
|
||||
true,
|
||||
)),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -163,6 +172,7 @@ fn channel_record_to_json(
|
||||
participant_pubkeys: Vec<String>,
|
||||
member_count: i64,
|
||||
last_message_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
is_member: bool,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": channel.id.to_string(),
|
||||
@@ -180,6 +190,7 @@ fn channel_record_to_json(
|
||||
"last_message_at": last_message_at.map(|t| t.to_rfc3339()),
|
||||
"participants": participants,
|
||||
"participant_pubkeys": participant_pubkeys,
|
||||
"is_member": is_member,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export default defineConfig({
|
||||
testMatch: [
|
||||
"**/smoke.spec.ts",
|
||||
"**/channels.spec.ts",
|
||||
"**/channel-browser.spec.ts",
|
||||
"**/messaging.spec.ts",
|
||||
],
|
||||
use: {
|
||||
|
||||
@@ -63,6 +63,8 @@ pub struct ChannelInfo {
|
||||
pub archived_at: Option<String>,
|
||||
pub participants: Vec<String>,
|
||||
pub participant_pubkeys: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub is_member: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -278,6 +280,10 @@ where
|
||||
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn relay_ws_url() -> String {
|
||||
std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
@@ -965,3 +971,31 @@ pub fn run() {
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::ChannelInfo;
|
||||
|
||||
#[test]
|
||||
fn channel_info_defaults_is_member_for_legacy_payloads() {
|
||||
let channel: ChannelInfo = serde_json::from_value(json!({
|
||||
"id": "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
"name": "general",
|
||||
"channel_type": "stream",
|
||||
"visibility": "open",
|
||||
"description": "General discussion",
|
||||
"topic": null,
|
||||
"purpose": null,
|
||||
"member_count": 3,
|
||||
"last_message_at": null,
|
||||
"archived_at": null,
|
||||
"participants": [],
|
||||
"participant_pubkeys": []
|
||||
}))
|
||||
.expect("legacy payload should deserialize");
|
||||
|
||||
assert!(channel.is_member);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Settings2 } from "lucide-react";
|
||||
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import {
|
||||
channelsQueryKey,
|
||||
useCreateChannelMutation,
|
||||
useChannelsQuery,
|
||||
useSelectedChannel,
|
||||
@@ -29,12 +31,13 @@ import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
|
||||
import { ChannelBrowserDialog } from "@/features/channels/ui/ChannelBrowserDialog";
|
||||
import { SearchDialog } from "@/features/search/ui/SearchDialog";
|
||||
import { SettingsView } from "@/features/settings/ui/SettingsView";
|
||||
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
||||
import { getEventById } from "@/shared/api/tauri";
|
||||
import { getEventById, joinChannel } from "@/shared/api/tauri";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { RelayEvent, SearchHit } from "@/shared/api/types";
|
||||
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
SidebarInset,
|
||||
@@ -61,6 +64,7 @@ export function AppShell() {
|
||||
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
||||
React.useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||
const [isBrowseChannelsOpen, setIsBrowseChannelsOpen] = React.useState(false);
|
||||
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
|
||||
null,
|
||||
);
|
||||
@@ -69,12 +73,18 @@ export function AppShell() {
|
||||
>(null);
|
||||
const [searchAnchorEvent, setSearchAnchorEvent] =
|
||||
React.useState<RelayEvent | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const profileQuery = useProfileQuery();
|
||||
const presenceSession = usePresenceSession(identityQuery.data?.pubkey);
|
||||
const homeFeedQuery = useHomeFeedQuery();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const { refetch: refetchChannels } = channelsQuery;
|
||||
const channels = channelsQuery.data ?? [];
|
||||
const memberChannels = React.useMemo(
|
||||
() => channels.filter((channel) => channel.isMember),
|
||||
[channels],
|
||||
);
|
||||
const { selectedChannel, setSelectedChannelId } = useSelectedChannel(
|
||||
channels,
|
||||
null,
|
||||
@@ -164,6 +174,9 @@ export function AppShell() {
|
||||
const channelDescription = activeChannel
|
||||
? [
|
||||
activeChannel.archivedAt ? "Archived." : null,
|
||||
!activeChannel.isMember
|
||||
? "Read-only until you join this open channel."
|
||||
: null,
|
||||
activeChannel.topic,
|
||||
activeChannel.description,
|
||||
activeChannel.purpose,
|
||||
@@ -183,14 +196,53 @@ export function AppShell() {
|
||||
const isTimelineLoading =
|
||||
messagesQuery.isLoading && resolvedMessages.length === 0;
|
||||
|
||||
const handleOpenChannel = React.useCallback(
|
||||
(channelId: string) => {
|
||||
React.startTransition(() => {
|
||||
setSelectedChannelId(channelId);
|
||||
setSelectedView("channel");
|
||||
});
|
||||
const resolveChannel = React.useCallback(
|
||||
async (channelId: string): Promise<Channel | null> => {
|
||||
const cachedChannels =
|
||||
queryClient.getQueryData<Channel[]>(channelsQueryKey);
|
||||
const knownChannel =
|
||||
channels.find((channel) => channel.id === channelId) ??
|
||||
cachedChannels?.find((channel) => channel.id === channelId) ??
|
||||
null;
|
||||
|
||||
if (knownChannel) {
|
||||
return knownChannel;
|
||||
}
|
||||
|
||||
const refreshed = await refetchChannels();
|
||||
return (
|
||||
refreshed.data?.find((channel) => channel.id === channelId) ?? null
|
||||
);
|
||||
},
|
||||
[setSelectedChannelId],
|
||||
[channels, queryClient, refetchChannels],
|
||||
);
|
||||
|
||||
const handleOpenChannel = React.useCallback(
|
||||
async (channelId: string) => {
|
||||
try {
|
||||
const channel = await resolveChannel(channelId);
|
||||
if (!channel) {
|
||||
console.error("Failed to resolve channel before opening", channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
React.startTransition(() => {
|
||||
setSelectedChannelId(channel.id);
|
||||
setSelectedView("channel");
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to open channel", channelId, error);
|
||||
}
|
||||
},
|
||||
[resolveChannel, setSelectedChannelId],
|
||||
);
|
||||
|
||||
const handleBrowseChannelJoin = React.useCallback(
|
||||
async (channelId: string) => {
|
||||
await joinChannel(channelId);
|
||||
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const handleOpenSettings = React.useCallback(() => {
|
||||
@@ -207,7 +259,7 @@ export function AppShell() {
|
||||
setSearchAnchor(hit);
|
||||
setSearchAnchorChannelId(hit.channelId);
|
||||
setSearchAnchorEvent(createSearchAnchorEvent(hit));
|
||||
handleOpenChannel(hit.channelId);
|
||||
void handleOpenChannel(hit.channelId);
|
||||
|
||||
void getEventById(hit.eventId)
|
||||
.then((event) => {
|
||||
@@ -256,7 +308,7 @@ export function AppShell() {
|
||||
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
|
||||
<SidebarTrigger className="fixed left-[80px] top-[9px] z-50 h-6 w-6" />
|
||||
<AppSidebar
|
||||
channels={channels}
|
||||
channels={memberChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
@@ -281,8 +333,13 @@ export function AppShell() {
|
||||
setSelectedView("channel");
|
||||
});
|
||||
}}
|
||||
onOpenBrowseChannels={() => {
|
||||
setIsBrowseChannelsOpen(true);
|
||||
void refetchChannels();
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
setIsSearchOpen(true);
|
||||
void refetchChannels();
|
||||
}}
|
||||
onSelectHome={() => {
|
||||
React.startTransition(() => {
|
||||
@@ -408,6 +465,7 @@ export function AppShell() {
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
disabled={
|
||||
!activeChannel ||
|
||||
!activeChannel.isMember ||
|
||||
activeChannel.archivedAt !== null ||
|
||||
activeChannel.channelType === "forum" ||
|
||||
sendMessageMutation.isPending
|
||||
@@ -420,17 +478,27 @@ export function AppShell() {
|
||||
placeholder={
|
||||
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"
|
||||
: activeChannel && !activeChannel.isMember
|
||||
? "Join this channel to message."
|
||||
: activeChannel?.channelType === "forum"
|
||||
? "Forum posting is not wired in this pass."
|
||||
: activeChannel
|
||||
? `Message #${activeChannel.name}`
|
||||
: "Select a channel"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChannelBrowserDialog
|
||||
channels={channels}
|
||||
onJoinChannel={handleBrowseChannelJoin}
|
||||
onOpenChange={setIsBrowseChannelsOpen}
|
||||
onSelectChannel={handleOpenChannel}
|
||||
open={isBrowseChannelsOpen}
|
||||
/>
|
||||
|
||||
<SearchDialog
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
|
||||
420
desktop/src/features/channels/ui/ChannelBrowserDialog.tsx
Normal file
420
desktop/src/features/channels/ui/ChannelBrowserDialog.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Compass,
|
||||
FileText,
|
||||
Hash,
|
||||
LogIn,
|
||||
Search,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
function formatRelativeTime(isoString: string | null) {
|
||||
if (!isoString) {
|
||||
return "No activity";
|
||||
}
|
||||
|
||||
const diff = Math.floor((Date.now() - new Date(isoString).getTime()) / 1_000);
|
||||
|
||||
if (diff < 60) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
if (diff < 60 * 60) {
|
||||
return `${Math.floor(diff / 60)}m ago`;
|
||||
}
|
||||
|
||||
if (diff < 60 * 60 * 24) {
|
||||
return `${Math.floor(diff / (60 * 60))}h ago`;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(new Date(isoString));
|
||||
}
|
||||
|
||||
function BrowseState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="mt-4 text-base font-semibold tracking-tight">{title}</p>
|
||||
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChannelBrowserDialogProps = {
|
||||
channels: Channel[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onJoinChannel: (channelId: string) => Promise<void>;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
};
|
||||
|
||||
export function ChannelBrowserDialog({
|
||||
channels,
|
||||
open,
|
||||
onOpenChange,
|
||||
onJoinChannel,
|
||||
onSelectChannel,
|
||||
}: ChannelBrowserDialogProps) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const [joiningChannelId, setJoiningChannelId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
|
||||
|
||||
const browsableChannels = React.useMemo(() => {
|
||||
const filtered = channels.filter(
|
||||
(channel) =>
|
||||
channel.channelType !== "dm" &&
|
||||
channel.visibility === "open" &&
|
||||
!channel.archivedAt,
|
||||
);
|
||||
|
||||
if (deferredQuery.length === 0) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
return filtered.filter(
|
||||
(channel) =>
|
||||
channel.name.toLowerCase().includes(deferredQuery) ||
|
||||
channel.description.toLowerCase().includes(deferredQuery),
|
||||
);
|
||||
}, [channels, deferredQuery]);
|
||||
|
||||
const notJoined = React.useMemo(
|
||||
() => browsableChannels.filter((channel) => !channel.isMember),
|
||||
[browsableChannels],
|
||||
);
|
||||
|
||||
const joined = React.useMemo(
|
||||
() => browsableChannels.filter((channel) => channel.isMember),
|
||||
[browsableChannels],
|
||||
);
|
||||
|
||||
// Flat list for keyboard navigation: not-joined first, then joined
|
||||
const allItems = React.useMemo(
|
||||
() => [...notJoined, ...joined],
|
||||
[notJoined, joined],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key.toLowerCase() !== "b" ||
|
||||
!(event.metaKey || event.ctrlKey) ||
|
||||
event.altKey ||
|
||||
!event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onOpenChange(true);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onOpenChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery("");
|
||||
setSelectedIndex(0);
|
||||
setJoiningChannelId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedIndex((current) => {
|
||||
if (allItems.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(current, allItems.length - 1);
|
||||
});
|
||||
}, [allItems]);
|
||||
|
||||
async function handleJoin(channelId: string) {
|
||||
setJoiningChannelId(channelId);
|
||||
|
||||
try {
|
||||
await onJoinChannel(channelId);
|
||||
onOpenChange(false);
|
||||
onSelectChannel(channelId);
|
||||
} catch {
|
||||
setJoiningChannelId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(channel: Channel) {
|
||||
if (channel.isMember) {
|
||||
onOpenChange(false);
|
||||
onSelectChannel(channel.id);
|
||||
} else {
|
||||
void handleJoin(channel.id);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedItem = allItems[selectedIndex];
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="gap-0 overflow-hidden p-0"
|
||||
data-testid="channel-browser-dialog"
|
||||
>
|
||||
<DialogHeader className="border-b border-border/80 px-6 py-5">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
|
||||
<Compass className="h-4 w-4" />
|
||||
</span>
|
||||
Browse Channels
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Discover and join open channels.
|
||||
</DialogDescription>
|
||||
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-input bg-card px-3 py-3 shadow-sm">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-auto border-0 bg-transparent px-0 py-0 text-base shadow-none focus-visible:ring-0"
|
||||
data-testid="channel-browser-search"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" && allItems.length > 0) {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) =>
|
||||
Math.min(current + 1, allItems.length - 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" && allItems.length > 0) {
|
||||
event.preventDefault();
|
||||
setSelectedIndex((current) => Math.max(current - 1, 0));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.nativeEvent.isComposing &&
|
||||
selectedItem
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleSelect(selectedItem);
|
||||
}
|
||||
}}
|
||||
placeholder="Search channels by name or description"
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
/>
|
||||
<div className="hidden items-center gap-1 rounded-lg border border-border bg-background px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground sm:flex">
|
||||
⇧⌘B
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{browsableChannels.length === 0 ? (
|
||||
deferredQuery.length > 0 ? (
|
||||
<BrowseState
|
||||
description="Try a different name or keyword."
|
||||
icon={Search}
|
||||
title="No channels match your search"
|
||||
/>
|
||||
) : (
|
||||
<BrowseState
|
||||
description="All open channels are available in the sidebar. Create a new channel to get started."
|
||||
icon={Compass}
|
||||
title="No channels to browse"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="p-3">
|
||||
{notJoined.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<span>
|
||||
{notJoined.length} channel
|
||||
{notJoined.length !== 1 ? "s" : ""} to join
|
||||
</span>
|
||||
<span>Enter to join</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{notJoined.map((channel) => {
|
||||
const flatIndex = allItems.indexOf(channel);
|
||||
return (
|
||||
<ChannelCard
|
||||
channel={channel}
|
||||
isJoining={joiningChannelId === channel.id}
|
||||
isSelected={flatIndex === selectedIndex}
|
||||
key={channel.id}
|
||||
onJoin={() => {
|
||||
void handleJoin(channel.id);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
||||
onSelect={() => handleSelect(channel)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{joined.length > 0 ? (
|
||||
<>
|
||||
<div className="mb-3 mt-4 flex items-center px-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
<span>Joined</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{joined.map((channel) => {
|
||||
const flatIndex = allItems.indexOf(channel);
|
||||
return (
|
||||
<ChannelCard
|
||||
channel={channel}
|
||||
isJoining={false}
|
||||
isSelected={flatIndex === selectedIndex}
|
||||
key={channel.id}
|
||||
onMouseEnter={() => setSelectedIndex(flatIndex)}
|
||||
onSelect={() => handleSelect(channel)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/80 bg-card/50 px-6 py-3 text-xs text-muted-foreground">
|
||||
Showing open channels. Private channels require an invite.
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelCard({
|
||||
channel,
|
||||
isJoining,
|
||||
isSelected,
|
||||
onJoin,
|
||||
onMouseEnter,
|
||||
onSelect,
|
||||
}: {
|
||||
channel: Channel;
|
||||
isJoining: boolean;
|
||||
isSelected: boolean;
|
||||
onJoin?: () => void;
|
||||
onMouseEnter: () => void;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={
|
||||
isSelected
|
||||
? "w-full rounded-2xl border border-primary/30 bg-primary/10 px-4 py-4 text-left shadow-sm outline-none transition-colors"
|
||||
: "w-full rounded-2xl border border-border/80 bg-card/60 px-4 py-4 text-left shadow-sm outline-none transition-colors hover:border-primary/20 hover:bg-accent"
|
||||
}
|
||||
data-testid={`browse-channel-${channel.name}`}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onMouseEnter}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground">
|
||||
{channel.channelType === "forum" ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<Hash className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
{channel.name}
|
||||
</p>
|
||||
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{channel.channelType}
|
||||
</p>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users className="h-3 w-3" />
|
||||
{channel.memberCount}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(channel.lastMessageAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{channel.description ? (
|
||||
<p className="mt-1 text-sm leading-6 text-muted-foreground line-clamp-2">
|
||||
{channel.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!channel.isMember && onJoin ? (
|
||||
<Button
|
||||
className="shrink-0"
|
||||
disabled={isJoining}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onJoin();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="default"
|
||||
>
|
||||
<LogIn className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isJoining ? "Joining..." : "Join"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { CircleDot, FileText, Hash, Home, Plus, Search } from "lucide-react";
|
||||
import {
|
||||
CircleDot,
|
||||
Compass,
|
||||
FileText,
|
||||
Hash,
|
||||
Home,
|
||||
Plus,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { getPresenceLabel } from "@/features/presence/lib/presence";
|
||||
@@ -43,6 +51,7 @@ type AppSidebarProps = {
|
||||
name: string;
|
||||
description?: string;
|
||||
}) => Promise<void>;
|
||||
onOpenBrowseChannels: () => void;
|
||||
onOpenSearch: () => void;
|
||||
onSelectHome: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
@@ -290,6 +299,7 @@ export function AppSidebar({
|
||||
selectedView,
|
||||
unreadChannelIds,
|
||||
onCreateChannel,
|
||||
onOpenBrowseChannels,
|
||||
onOpenSearch,
|
||||
onSelectHome,
|
||||
onSelectChannel,
|
||||
@@ -487,6 +497,20 @@ export function AppSidebar({
|
||||
selectedChannelId={selectedChannelId}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="text-sidebar-foreground/60 hover:text-sidebar-foreground"
|
||||
data-testid="browse-channels"
|
||||
onClick={onOpenBrowseChannels}
|
||||
tooltip="Browse channels"
|
||||
type="button"
|
||||
>
|
||||
<Compass className="h-4 w-4" />
|
||||
<span>Browse channels</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SidebarSection
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
items={forumChannels}
|
||||
|
||||
@@ -73,6 +73,7 @@ type RawChannel = {
|
||||
archived_at: string | null;
|
||||
participants: string[];
|
||||
participant_pubkeys: string[];
|
||||
is_member?: boolean;
|
||||
};
|
||||
|
||||
type RawChannelDetail = RawChannel & {
|
||||
@@ -225,6 +226,7 @@ function fromRawChannel(channel: RawChannel): Channel {
|
||||
archivedAt: channel.archived_at,
|
||||
participants: channel.participants,
|
||||
participantPubkeys: channel.participant_pubkeys,
|
||||
isMember: channel.is_member ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export type Channel = {
|
||||
archivedAt: string | null;
|
||||
participants: string[];
|
||||
participantPubkeys: string[];
|
||||
isMember: boolean;
|
||||
};
|
||||
|
||||
export type ChannelDetail = Channel & {
|
||||
|
||||
@@ -75,6 +75,10 @@ type RawChannel = {
|
||||
participant_pubkeys: string[];
|
||||
};
|
||||
|
||||
type RawChannelWithMembership = RawChannel & {
|
||||
is_member: boolean;
|
||||
};
|
||||
|
||||
type RawChannelDetail = RawChannel & {
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
@@ -240,7 +244,7 @@ function cloneMembers(members: RawChannelMember[]): RawChannelMember[] {
|
||||
return members.map((member) => ({ ...member }));
|
||||
}
|
||||
|
||||
function toRawChannel(channel: MockChannel): RawChannel {
|
||||
function toRawChannel(channel: MockChannel): RawChannelWithMembership {
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
@@ -254,6 +258,11 @@ function toRawChannel(channel: MockChannel): RawChannel {
|
||||
archived_at: channel.archived_at,
|
||||
participants: [...channel.participants],
|
||||
participant_pubkeys: [...channel.participant_pubkeys],
|
||||
is_member: channel.members.some(
|
||||
(member) =>
|
||||
member.pubkey === MOCK_IDENTITY_PUBKEY ||
|
||||
member.pubkey === DEFAULT_REAL_IDENTITY.pubkey,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,7 +402,7 @@ function getMockProfileByPubkey(pubkey: string): RawProfile | null {
|
||||
};
|
||||
}
|
||||
|
||||
function listMockChannels(): RawChannel[] {
|
||||
function listMockChannels(): RawChannelWithMembership[] {
|
||||
return mockChannels.map(toRawChannel);
|
||||
}
|
||||
|
||||
@@ -475,9 +484,60 @@ const mockChannels: MockChannel[] = [
|
||||
updated_minutes_ago: 1400,
|
||||
members: [
|
||||
createMockMember(ALICE_PUBKEY, "owner", 1400),
|
||||
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 1300),
|
||||
createMockMember(BOB_PUBKEY, "member", 1000),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
|
||||
name: "design",
|
||||
channel_type: "stream",
|
||||
visibility: "open",
|
||||
description: "Design system and UX discussions",
|
||||
topic: null,
|
||||
purpose: null,
|
||||
last_message_at: isoMinutesAgo(120),
|
||||
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: 1350,
|
||||
updated_minutes_ago: 120,
|
||||
members: [
|
||||
createMockMember(ALICE_PUBKEY, "owner", 1350),
|
||||
createMockMember(BOB_PUBKEY, "member", 1100),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "c6f3a9b2-4d55-5a23-bf78-5b9e2g3c5d6f",
|
||||
name: "sales",
|
||||
channel_type: "stream",
|
||||
visibility: "open",
|
||||
description: "Sales team coordination and pipeline updates",
|
||||
topic: "Q1 targets",
|
||||
purpose: null,
|
||||
last_message_at: isoMinutesAgo(30),
|
||||
archived_at: null,
|
||||
created_by: BOB_PUBKEY,
|
||||
topic_set_by: BOB_PUBKEY,
|
||||
topic_set_at: isoMinutesAgo(200),
|
||||
purpose_set_by: null,
|
||||
purpose_set_at: null,
|
||||
topic_required: false,
|
||||
max_members: null,
|
||||
nip29_group_id: null,
|
||||
created_minutes_ago: 1300,
|
||||
updated_minutes_ago: 30,
|
||||
members: [
|
||||
createMockMember(BOB_PUBKEY, "owner", 1300),
|
||||
createMockMember(CHARLIE_PUBKEY, "member", 900),
|
||||
],
|
||||
}),
|
||||
createMockChannel({
|
||||
id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
||||
name: "engineering",
|
||||
@@ -1753,6 +1813,17 @@ async function handleSearchMessages(
|
||||
created_at: now - 42 * 60,
|
||||
score: 7.2,
|
||||
},
|
||||
{
|
||||
event_id: "mock-design-critique",
|
||||
content: "Design critique notes for the browse flow.",
|
||||
kind: 40001,
|
||||
pubkey:
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
channel_id: "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e",
|
||||
channel_name: "design",
|
||||
created_at: now - 75 * 60,
|
||||
score: 6.6,
|
||||
},
|
||||
{
|
||||
event_id: "mock-forum-release-thread",
|
||||
content: "Release checklist: async feedback thread.",
|
||||
@@ -1820,6 +1891,16 @@ async function handleGetEvent(
|
||||
content: "Engineering shipped the desktop build.",
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
},
|
||||
{
|
||||
id: "mock-design-critique",
|
||||
pubkey:
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
created_at: Math.floor(Date.now() / 1000) - 75 * 60,
|
||||
kind: 40001,
|
||||
tags: [["h", "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"]],
|
||||
content: "Design critique notes for the browse flow.",
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
},
|
||||
{
|
||||
id: "mock-forum-release-thread",
|
||||
pubkey:
|
||||
|
||||
173
desktop/tests/e2e/channel-browser.spec.ts
Normal file
173
desktop/tests/e2e/channel-browser.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
test("browse channels button opens the channel browser dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("keyboard shortcut opens the channel browser dialog", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Meta+Shift+B");
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
});
|
||||
|
||||
test("channel browser shows channels not yet joined", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
|
||||
// "design" and "sales" are open channels the mock user is NOT a member of
|
||||
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
|
||||
await expect(page.getByTestId("browse-channel-sales")).toBeVisible();
|
||||
|
||||
// "general" is a channel the mock user IS a member of — shown in "Joined" section
|
||||
await expect(page.getByTestId("browse-channel-general")).toBeVisible();
|
||||
});
|
||||
|
||||
test("channel browser search filters by name", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
|
||||
await page.getByTestId("channel-browser-search").fill("design");
|
||||
|
||||
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
|
||||
await expect(page.getByTestId("browse-channel-sales")).toHaveCount(0);
|
||||
await expect(page.getByTestId("browse-channel-general")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("channel browser search filters by description", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await page.getByTestId("channel-browser-search").fill("pipeline");
|
||||
|
||||
// "sales" has "pipeline" in its description
|
||||
await expect(page.getByTestId("browse-channel-sales")).toBeVisible();
|
||||
await expect(page.getByTestId("browse-channel-design")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("channel browser shows no results for unmatched search", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await page.getByTestId("channel-browser-search").fill("zzz-nonexistent");
|
||||
|
||||
await expect(page.getByText("No channels match your search")).toBeVisible();
|
||||
});
|
||||
|
||||
test("joining a channel from browser adds it to the sidebar", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// Verify "design" is not in the sidebar
|
||||
const streamList = page.getByTestId("stream-list");
|
||||
await expect(streamList).not.toContainText("design");
|
||||
|
||||
// Open browser and join
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
await page
|
||||
.getByTestId("browse-channel-design")
|
||||
.getByRole("button", { name: "Join" })
|
||||
.click();
|
||||
|
||||
// Dialog should close and navigate to the joined channel
|
||||
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
||||
|
||||
// Channel should now appear in the sidebar
|
||||
await expect(streamList).toContainText("design");
|
||||
});
|
||||
|
||||
test("clicking a joined channel in browser navigates to it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
|
||||
// "general" is already joined — clicking should navigate without join
|
||||
await page.getByTestId("browse-channel-general").click();
|
||||
|
||||
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
});
|
||||
|
||||
test("channel browser does not show DM or private channels", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
|
||||
// DM channels should not appear
|
||||
await expect(page.getByTestId("browse-channel-alice-tyler")).toHaveCount(0);
|
||||
await expect(page.getByTestId("browse-channel-bob-tyler")).toHaveCount(0);
|
||||
|
||||
// Private forum should not appear
|
||||
await expect(page.getByTestId("browse-channel-announcements")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("channel browser closes on escape", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("keyboard navigation works in channel browser", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
|
||||
// Filter to unjoined channels only to get a predictable list
|
||||
await page.getByTestId("channel-browser-search").fill("design");
|
||||
await expect(page.getByTestId("browse-channel-design")).toBeVisible();
|
||||
|
||||
// Press Enter to join the selected (first) channel
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expect(page.getByTestId("channel-browser-dialog")).not.toBeVisible();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
||||
});
|
||||
|
||||
test("sidebar only shows channels the user has joined", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const streamList = page.getByTestId("stream-list");
|
||||
|
||||
// Channels the mock user IS a member of
|
||||
await expect(streamList).toContainText("general");
|
||||
await expect(streamList).toContainText("random");
|
||||
await expect(streamList).toContainText("engineering");
|
||||
await expect(streamList).toContainText("agents");
|
||||
|
||||
// Channels the mock user is NOT a member of
|
||||
await expect(streamList).not.toContainText("design");
|
||||
await expect(streamList).not.toContainText("sales");
|
||||
});
|
||||
@@ -280,28 +280,39 @@ test("manage channel can invite and remove members", async ({ page }) => {
|
||||
|
||||
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();
|
||||
// Navigate to "design" (an unjoined channel) via the channel browser
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
await page
|
||||
.getByTestId("browse-channel-design")
|
||||
.getByRole("button", { name: "Join" })
|
||||
.click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
||||
|
||||
// Open channel management — should show Leave since we just joined
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
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");
|
||||
|
||||
// Leave the channel
|
||||
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();
|
||||
// After leaving, the app navigates away — re-open browser and find design
|
||||
await page.getByTestId("browse-channels").click();
|
||||
await expect(page.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
|
||||
// "design" should be back in the unjoined section with a Join button
|
||||
await expect(
|
||||
page.getByTestId(`channel-member-${MOCK_IDENTITY_PUBKEY}`),
|
||||
).toHaveCount(0);
|
||||
page
|
||||
.getByTestId("browse-channel-design")
|
||||
.getByRole("button", { name: "Join" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("manage channel can archive and unarchive a stream", async ({ page }) => {
|
||||
|
||||
@@ -68,6 +68,12 @@ test("two users see the same channel", async ({
|
||||
await expect(pageOne.getByTestId("stream-list")).toContainText(channelName);
|
||||
|
||||
await pageTwo.goto("/");
|
||||
await pageTwo.getByTestId("browse-channels").click();
|
||||
await expect(pageTwo.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
await pageTwo
|
||||
.getByTestId(`browse-channel-${channelName}`)
|
||||
.getByRole("button", { name: "Join" })
|
||||
.click();
|
||||
await expect(pageTwo.getByTestId("stream-list")).toContainText(channelName);
|
||||
} finally {
|
||||
await contextOne.close();
|
||||
|
||||
@@ -152,6 +152,35 @@ test("search results use your resolved profile label instead of You", async ({
|
||||
await expect(results).not.toContainText("You");
|
||||
});
|
||||
|
||||
test("opens accessible unjoined channels from search in read-only mode", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.keyboard.press(
|
||||
process.platform === "darwin" ? "Meta+K" : "Control+K",
|
||||
);
|
||||
await expect(page.getByTestId("search-dialog")).toBeVisible();
|
||||
|
||||
await page.getByTestId("search-input").fill("critique");
|
||||
const results = page.getByTestId("search-results");
|
||||
|
||||
await expect(results).toContainText(
|
||||
"Design critique notes for the browse flow.",
|
||||
);
|
||||
await results.getByText("Design critique notes for the browse flow.").click();
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("design");
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
"Design critique notes for the browse flow.",
|
||||
);
|
||||
await expect(page.getByTestId("message-input")).toBeDisabled();
|
||||
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
await expect(page.getByTestId("channel-management-join")).toBeVisible();
|
||||
});
|
||||
|
||||
test("replaces the channel pane when switching channels", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
|
||||
@@ -45,6 +45,44 @@ async function ensureTimelineScrollable(
|
||||
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight + 160);
|
||||
}
|
||||
|
||||
async function createAndJoinSharedStream(
|
||||
ownerPage: Page,
|
||||
memberPage: Page,
|
||||
channelName: string,
|
||||
) {
|
||||
await ownerPage.getByRole("button", { name: "Create a stream" }).click();
|
||||
await ownerPage.getByTestId("create-stream-name").fill(channelName);
|
||||
await ownerPage.getByRole("button", { name: "Create" }).click();
|
||||
await expect(ownerPage.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(ownerPage.getByTestId("chat-title")).toHaveText(channelName);
|
||||
|
||||
await memberPage.getByTestId("browse-channels").click();
|
||||
await expect(memberPage.getByTestId("channel-browser-dialog")).toBeVisible();
|
||||
await memberPage
|
||||
.getByTestId(`browse-channel-${channelName}`)
|
||||
.getByRole("button", { name: "Join" })
|
||||
.click();
|
||||
await expect(memberPage.getByTestId("chat-title")).toHaveText(channelName);
|
||||
await expect(memberPage.getByTestId("stream-list")).toContainText(
|
||||
channelName,
|
||||
);
|
||||
}
|
||||
|
||||
async function scrollTimelineAwayFromBottom(page: Page, minDistance = 160) {
|
||||
const timeline = page.getByTestId("message-timeline");
|
||||
await timeline.hover();
|
||||
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
await page.mouse.wheel(0, -800);
|
||||
const metrics = await getTimelineMetrics(page);
|
||||
if (metrics.distanceFromBottom > minDistance) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to scroll the timeline away from the bottom.");
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await assertRelaySeeded();
|
||||
});
|
||||
@@ -107,6 +145,7 @@ test("delivers a message to a second browser context in real time", async ({
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const channelName = `realtime-shared-${Date.now()}`;
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
@@ -119,11 +158,7 @@ test("delivers a message to a second browser context in real time", async ({
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(message);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
@@ -142,6 +177,7 @@ test("stays pinned to the latest message when new messages arrive at the bottom"
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const channelName = `pinned-bottom-${Date.now()}`;
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
@@ -155,11 +191,7 @@ test("stays pinned to the latest message when new messages arrive at the bottom"
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
@@ -189,6 +221,7 @@ test("stays pinned after you send a message and a remote reply arrives right aft
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const channelName = `reply-shared-${Date.now()}`;
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
@@ -203,11 +236,7 @@ test("stays pinned after you send a message and a remote reply arrives right aft
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
@@ -243,6 +272,7 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const channelName = `composer-shared-${Date.now()}`;
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
@@ -257,11 +287,7 @@ test("keeps bottom-pinned scrolling after the composer grows", async ({
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
@@ -303,6 +329,7 @@ test("keeps scroll position when new messages arrive above the fold", async ({
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const channelName = `scroll-shared-${Date.now()}`;
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
@@ -316,25 +343,14 @@ test("keeps scroll position when new messages arrive above the fold", async ({
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
await createAndJoinSharedStream(pageOne, pageTwo, channelName);
|
||||
|
||||
await ensureTimelineScrollable(pageOne, pageTwo, prefix);
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeLessThan(8);
|
||||
|
||||
await pageTwo.getByTestId("message-timeline").evaluate((element) => {
|
||||
const timeline = element as HTMLDivElement;
|
||||
timeline.scrollTop = 0;
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => (await getTimelineMetrics(pageTwo)).distanceFromBottom)
|
||||
.toBeGreaterThan(160);
|
||||
await scrollTimelineAwayFromBottom(pageTwo);
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(incomingMessage);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
|
||||
const DESIGN_CHANNEL_ID = "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e";
|
||||
|
||||
test("creates a channel-scoped token from settings and can revoke it", async ({
|
||||
page,
|
||||
@@ -27,14 +27,14 @@ test("creates a channel-scoped token from settings and can revoke it", async ({
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"Only channels where you are a member can be added to a scoped token. 3 accessible channels hidden because you are not a member.",
|
||||
/Only channels where you are a member can be added to a scoped token\.( \d+ accessible channels? hidden because you are not a member\.)?/,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`token-channel-${GENERAL_CHANNEL_ID}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`token-channel-${RANDOM_CHANNEL_ID}`),
|
||||
page.getByTestId(`token-channel-${DESIGN_CHANNEL_ID}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId(`token-channel-${GENERAL_CHANNEL_ID}`).click();
|
||||
|
||||
@@ -79,6 +79,14 @@ run_sql "
|
||||
INSERT IGNORE INTO channel_members
|
||||
(channel_id, pubkey, role, invited_by)
|
||||
VALUES
|
||||
(UNHEX('${UUID_GENERAL}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_GENERAL}'), UNHEX('${ALICE_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_GENERAL}'), UNHEX('${BOB_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_RANDOM}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_ENGINEERING}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_AGENTS}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_WATERCOOLER}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_ANNOUNCEMENTS}'), UNHEX('${TYLER_PUBKEY}'), 'guest', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_DM_ALICE_TYLER}'), UNHEX('${ALICE_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_DM_ALICE_TYLER}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_DM_BOB_TYLER}'), UNHEX('${BOB_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
|
||||
Reference in New Issue
Block a user