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:
Wes
2026-03-12 12:53:59 -07:00
committed by GitHub
parent 80356e8cb5
commit 1feab79543
19 changed files with 977 additions and 74 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ export default defineConfig({
testMatch: [
"**/smoke.spec.ts",
"**/channels.spec.ts",
"**/channel-browser.spec.ts",
"**/messaging.spec.ts",
],
use: {

View File

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

View File

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

View 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">
&#x21E7;&#x2318;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>
);
}

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ export type Channel = {
archivedAt: string | null;
participants: string[];
participantPubkeys: string[];
isMember: boolean;
};
export type ChannelDetail = Channel & {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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