mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: view open channels without joining (#430)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user