feat: view open channels without joining (#430)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-29 22:06:16 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 127829e0f2
commit 28a4658ac2
5 changed files with 113 additions and 60 deletions
@@ -187,12 +187,8 @@ export function ChannelBrowserDialog({
}
function handleSelect(channel: Channel) {
if (channel.isMember) {
onOpenChange(false);
onSelectChannel(channel.id);
} else {
void handleJoin(channel.id);
}
onOpenChange(false);
onSelectChannel(channel.id);
}
const selectedItem = allItems[selectedIndex];
@@ -280,7 +276,7 @@ export function ChannelBrowserDialog({
{notJoined.length} {entityLabel}
{notJoined.length !== 1 ? "s" : ""} to join
</span>
<span>Enter to join</span>
<span>Enter to view</span>
</div>
<div className="space-y-2">
{notJoined.map((channel) => {
@@ -1,4 +1,5 @@
import * as React from "react";
import { Hash, LogIn } from "lucide-react";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel";
@@ -7,6 +8,7 @@ import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { BotActivityBar } from "@/features/channels/ui/BotActivityBar";
import { Button } from "@/shared/ui/button";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";
@@ -61,6 +63,7 @@ type ChannelPaneProps = {
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
isFetchingOlder?: boolean;
isJoining?: boolean;
isSending: boolean;
isTimelineLoading: boolean;
messages: TimelineMessage[];
@@ -72,6 +75,7 @@ type ChannelPaneProps = {
onEdit?: (message: TimelineMessage) => void;
onEditSave?: (content: string) => Promise<void>;
onExpandThreadReplies: (message: TimelineMessage) => void;
onJoinChannel?: () => Promise<void>;
onOpenAgentSession: (pubkey: string) => void;
onOpenThread: (message: TimelineMessage) => void;
onSelectThreadReplyTarget: (message: TimelineMessage) => void;
@@ -117,6 +121,7 @@ export const ChannelPane = React.memo(function ChannelPane({
fetchOlder,
hasOlderMessages,
isFetchingOlder,
isJoining = false,
isSending,
isTimelineLoading,
messages,
@@ -128,6 +133,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onEdit,
onEditSave,
onExpandThreadReplies,
onJoinChannel,
onOpenAgentSession,
onOpenThread,
onSelectThreadReplyTarget,
@@ -205,6 +211,12 @@ export const ChannelPane = React.memo(function ChannelPane({
const canResetThreadPanelWidth =
threadPanelWidthPx !== THREAD_PANEL_DEFAULT_WIDTH_PX;
const isNonMemberView =
activeChannel !== null &&
!activeChannel.isMember &&
activeChannel.visibility === "open" &&
!activeChannel.archivedAt;
const isComposerDisabled =
!activeChannel?.isMember ||
activeChannel.archivedAt !== null ||
@@ -268,27 +280,53 @@ export const ChannelPane = React.memo(function ChannelPane({
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={isComposerDisabled}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onEditSave={onEditSave}
onSend={onSendMessage}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel && !activeChannel.isMember
? "Join this channel to message."
{isNonMemberView ? (
<div
data-testid="join-banner"
className="flex items-center gap-3 border-t border-border/80 bg-card/50 px-4 py-3"
>
<div className="flex min-w-0 flex-1 items-center gap-2 text-sm text-muted-foreground">
<Hash className="h-4 w-4 shrink-0" />
<span className="truncate">
Viewing{" "}
<span className="font-medium text-foreground">
#{activeChannel?.name}
</span>
</span>
</div>
<Button
disabled={isJoining}
onClick={() => {
void onJoinChannel?.();
}}
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
{isJoining ? "Joining..." : "Join to participate"}
</Button>
</div>
) : (
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={isComposerDisabled}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onEditSave={onEditSave}
onSend={onSendMessage}
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"
}
/>
}
/>
)}
<div className="relative bg-background">
<TypingIndicatorRow
channel={activeChannel}
@@ -2,7 +2,10 @@ import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import {
useChannelMembersQuery,
useJoinChannelMutation,
} from "@/features/channels/hooks";
import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState";
import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader";
import {
@@ -72,9 +75,9 @@ export function ChannelScreen({
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
null,
);
const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState<
Set<string>
>(new Set());
const [expandedThreadReplyIds, setExpandedThreadReplyIds] = React.useState(
() => new Set<string>(),
);
const [threadScrollTargetId, setThreadScrollTargetId] = React.useState<
string | null
>(null);
@@ -96,12 +99,12 @@ export function ChannelScreen({
: (activeChannel?.lastMessageAt ?? null);
React.useEffect(() => {
if (!activeChannelId) {
if (!activeChannelId || activeChannel?.isMember === false) {
return;
}
markChannelRead(activeChannelId, activeReadAt);
}, [activeChannelId, activeReadAt, markChannelRead]);
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
const {
activeChannelTitle,
@@ -115,14 +118,11 @@ export function ChannelScreen({
const toggleReactionMutation = useToggleReactionMutation();
const deleteMessageMutation = useDeleteMessageMutation(activeChannel);
const editMessageMutation = useEditMessageMutation(activeChannel);
const joinChannelMutation = useJoinChannelMutation(activeChannelId);
const resolvedMessages = React.useMemo(() => {
const currentMessages = messagesQuery.data ?? [];
if (!activeChannel || !targetMessageEvent) {
return currentMessages;
}
if (!activeChannel || !targetMessageEvent) return currentMessages;
return mergeMessages(currentMessages, targetMessageEvent);
}, [activeChannel, messagesQuery.data, targetMessageEvent]);
const messageAuthorPubkeys = React.useMemo(
@@ -253,17 +253,12 @@ export function ChannelScreen({
const directReplyIdsByParentId = React.useMemo(() => {
const map = new Map<string, string[]>();
for (const message of timelineMessages) {
if (!message.parentId) {
continue;
}
if (!message.parentId) continue;
const currentReplies = map.get(message.parentId) ?? [];
currentReplies.push(message.id);
map.set(message.parentId, currentReplies);
}
return map;
}, [timelineMessages]);
const getFirstReplyIdForMessage = React.useCallback(
@@ -325,10 +320,12 @@ export function ChannelScreen({
toggleReactionMutation,
});
const canReact = activeChannel !== null && activeChannel.archivedAt === null;
const effectiveToggleReaction = React.useMemo(
() => (canReact ? handleToggleReaction : undefined),
[canReact, handleToggleReaction],
() =>
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
? handleToggleReaction
: undefined,
[activeChannel, handleToggleReaction],
);
const {
channelAgentSessionAgents,
@@ -350,10 +347,9 @@ export function ChannelScreen({
timelineMessages,
});
const shouldLoadTimeline =
activeChannel !== null && activeChannel.channelType !== "forum";
const isTimelineLoading =
shouldLoadTimeline &&
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
(messagesQuery.isPending ||
(messagesQuery.isFetching && resolvedMessages.length === 0));
const resetComposerTargets = React.useCallback(
@@ -412,6 +408,8 @@ export function ChannelScreen({
activeChannelTitle={activeChannelTitle}
activeDmPresenceStatus={activeDmPresenceStatus}
currentPubkey={currentPubkey}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={openChannelManagement}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
/>
@@ -478,6 +476,8 @@ export function ChannelScreen({
threadReplyTargetId={threadReplyTargetId}
threadReplyTargetMessage={threadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
typingPubkeys={humanTypingPubkeys}
/>
</React.Suspense>
@@ -1,8 +1,11 @@
import { LogIn } from "lucide-react";
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
import { Button } from "@/shared/ui/button";
import type { Channel, PresenceStatus } from "@/shared/api/types";
type ChannelScreenHeaderProps = {
@@ -11,6 +14,8 @@ type ChannelScreenHeaderProps = {
activeChannelTitle: string;
activeDmPresenceStatus: PresenceStatus | null;
currentPubkey?: string;
isJoining?: boolean;
onJoinChannel?: () => Promise<void>;
onManageChannel: () => void;
onToggleMembers: () => void;
};
@@ -21,19 +26,40 @@ export function ChannelScreenHeader({
activeChannelTitle,
activeDmPresenceStatus,
currentPubkey,
isJoining = false,
onJoinChannel,
onManageChannel,
onToggleMembers,
}: ChannelScreenHeaderProps) {
const showJoinButton =
activeChannel !== null &&
!activeChannel.isMember &&
activeChannel.visibility === "open" &&
!activeChannel.archivedAt &&
onJoinChannel;
return (
<ChatHeader
actions={
activeChannel ? (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={currentPubkey}
onManageChannel={onManageChannel}
onToggleMembers={onToggleMembers}
/>
showJoinButton ? (
<Button
disabled={isJoining}
onClick={() => void onJoinChannel()}
size="sm"
variant="default"
>
<LogIn className="mr-1.5 h-3.5 w-3.5" />
{isJoining ? "Joining\u2026" : "Join"}
</Button>
) : (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={currentPubkey}
onManageChannel={onManageChannel}
onToggleMembers={onToggleMembers}
/>
)
) : null
}
channelType={activeChannel?.channelType}
+1 -8
View File
@@ -306,14 +306,7 @@ test("opens accessible unjoined channels from search in read-only mode", async (
await expect(page.getByTestId("message-timeline")).toContainText(
"Design critique notes for the browse flow.",
);
await expect(page.getByTestId("message-input")).toHaveAttribute(
"contenteditable",
"false",
);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-join")).toBeVisible();
await expect(page.getByTestId("join-banner")).toBeVisible();
});
test("replaces the channel pane when switching channels", async ({ page }) => {