Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default defineConfig({
"**/add-community-screenshots.spec.ts",
"**/hosted-communities-settings-screenshots.spec.ts",
"**/invites-settings-screenshots.spec.ts",
"**/join-leave-visibility-screenshots.spec.ts",
"**/messaging.spec.ts",
"**/custom-emoji.spec.ts",
"**/profile-custom-emoji-status.spec.ts",
Expand Down
125 changes: 125 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
filterVisibleTimelineMessages,
isJoinLeaveSystemMessage,
} from "./ChannelPane.helpers.ts";

const KIND_SYSTEM_MESSAGE = 40099;
const KIND_CHANNEL_MESSAGE = 9;

function systemMessage(id, type) {
return {
id,
createdAt: 0,
author: "system",
body: JSON.stringify({ type }),
kind: KIND_SYSTEM_MESSAGE,
};
}

function chatMessage(id) {
return {
id,
createdAt: 0,
author: "alice",
body: "hello",
kind: KIND_CHANNEL_MESSAGE,
};
}

function channel(overrides = {}) {
return {
id: "chan-1",
name: "general",
description: "",
topic: null,
purpose: null,
visibility: "open",
channelType: "stream",
createdAt: "2025-01-01T00:00:00Z",
archivedAt: null,
memberCount: 1,
lastMessageAt: null,
participants: [],
participantPubkeys: [],
isMember: true,
ttlSeconds: null,
ttlDeadline: null,
...overrides,
};
}

test("isJoinLeaveSystemMessage matches membership-change system rows", () => {
assert.equal(
isJoinLeaveSystemMessage(systemMessage("a", "member_joined")),
true,
);
assert.equal(
isJoinLeaveSystemMessage(systemMessage("b", "member_left")),
true,
);
assert.equal(
isJoinLeaveSystemMessage(systemMessage("c", "member_removed")),
true,
);
assert.equal(
isJoinLeaveSystemMessage(systemMessage("d", "topic_changed")),
false,
);
assert.equal(isJoinLeaveSystemMessage(chatMessage("e")), false);
});

test("join/leave rows are hidden when the setting is off (the default)", () => {
const messages = [
chatMessage("m1"),
systemMessage("j1", "member_joined"),
systemMessage("l1", "member_left"),
systemMessage("r1", "member_removed"),
systemMessage("t1", "topic_changed"),
];

for (const activeChannel of [channel(), null]) {
const visible = filterVisibleTimelineMessages(
messages,
activeChannel,
false,
);
assert.deepEqual(
visible.map((m) => m.id),
["m1", "t1"],
);
}
});

test("join/leave rows are shown when the setting is on", () => {
const messages = [
chatMessage("m1"),
systemMessage("j1", "member_joined"),
systemMessage("l1", "member_left"),
systemMessage("r1", "member_removed"),
];
const visible = filterVisibleTimelineMessages(messages, channel(), true);
assert.deepEqual(
visible.map((m) => m.id),
["m1", "j1", "l1", "r1"],
);
});

test("welcome channels hide setup rows even with join/leave enabled", () => {
const messages = [
chatMessage("m1"),
systemMessage("j1", "member_joined"),
systemMessage("c1", "channel_created"),
];
const visible = filterVisibleTimelineMessages(
messages,
channel({ name: "welcome-everyone" }),
true,
);
assert.deepEqual(
visible.map((m) => m.id),
["m1"],
);
});
68 changes: 68 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.helpers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as React from "react";
import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel";
import { useShowJoinLeaveMessages } from "@/features/messages/lib/showJoinLeaveMessages";
import type { TimelineMessage } from "@/features/messages/types";
import { isWelcomeExperienceChannel } from "@/features/onboarding/welcome";
import type { Channel } from "@/shared/api/types";
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";

Expand Down Expand Up @@ -43,6 +46,71 @@ export function isWelcomeSetupSystemMessage(message: TimelineMessage) {
}
}

const JOIN_LEAVE_SYSTEM_TYPES = new Set([
"member_joined",
"member_left",
"member_removed",
]);

/**
* Membership-change system rows: "X joined" / "Y added X" (member_joined),
* "X left" (member_left), and "Y removed X" (member_removed). Hidden from
* the timeline unless the device-local "Show join and leave messages"
* setting enables them; the events still flow so member lists stay live.
*/
export function isJoinLeaveSystemMessage(message: TimelineMessage) {
if (message.kind !== KIND_SYSTEM_MESSAGE) {
return false;
}

try {
const payload = JSON.parse(message.body) as { type?: string };
return (
typeof payload.type === "string" &&
JOIN_LEAVE_SYSTEM_TYPES.has(payload.type)
);
} catch {
return false;
}
}

/**
* Timeline visibility filter: join/leave rows are hidden unless the
* device-local "Show join and leave messages" setting enables them, and
* welcome channels additionally hide their setup system rows.
*/
export function filterVisibleTimelineMessages(
messages: TimelineMessage[],
activeChannel: Channel | null,
showJoinLeave: boolean,
): TimelineMessage[] {
const hideWelcomeSetup =
activeChannel !== null && isWelcomeExperienceChannel(activeChannel);
if (showJoinLeave && !hideWelcomeSetup) {
return messages;
}
return messages.filter(
(message) =>
(showJoinLeave || !isJoinLeaveSystemMessage(message)) &&
(!hideWelcomeSetup || !isWelcomeSetupSystemMessage(message)),
);
}

/**
* Memoized {@link filterVisibleTimelineMessages} bound to the device-local
* "Show join and leave messages" setting.
*/
export function useVisibleTimelineMessages(
messages: TimelineMessage[],
activeChannel: Channel | null,
): TimelineMessage[] {
const showJoinLeave = useShowJoinLeaveMessages();
return React.useMemo(
() => filterVisibleTimelineMessages(messages, activeChannel, showJoinLeave),
[activeChannel, messages, showJoinLeave],
);
}

export function mentionsKnownAgent(
mentionPubkeys: string[],
knownAgentPubkeys: ReadonlySet<string>,
Expand Down
10 changes: 2 additions & 8 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ import {
type WelcomeComposerBannerState,
} from "@/features/channels/ui/WelcomeComposerBanner";
import {
isWelcomeSetupSystemMessage,
mentionsKnownAgent,
useVisibleTimelineMessages,
} from "@/features/channels/ui/ChannelPane.helpers";
import { useChannelIntro } from "@/features/channels/ui/useChannelIntro";
import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
Expand Down Expand Up @@ -449,13 +449,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onOpenMembers,
onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
});
const visibleMessages = React.useMemo(() => {
if (!isWelcomeExperience(activeChannel)) {
return messages;
}

return messages.filter((message) => !isWelcomeSetupSystemMessage(message));
}, [activeChannel, messages]);
const visibleMessages = useVisibleTimelineMessages(messages, activeChannel);
const mainTimelineEntries = React.useMemo(
() =>
buildMainTimelineEntries(
Expand Down
46 changes: 46 additions & 0 deletions desktop/src/features/messages/lib/showJoinLeaveMessages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as React from "react";

/**
* Device-local "Show join and leave messages" preference. Hidden by default:
* channel timelines omit joined/added/left/removed system rows unless the
* user enables them in Settings. Purely client-side — the relay always
* delivers the kind:40099 membership events (member lists depend on them);
* this setting only controls whether they render in the timeline.
*/
const STORAGE_KEY = "buzz:show-join-leave-messages";

const listeners = new Set<() => void>();
let enabled = readEnabled();

function readEnabled(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(STORAGE_KEY) === "1";
} catch {
return false;
}
}

export function setShowJoinLeaveMessagesEnabled(next: boolean): void {
if (enabled === next) return;
enabled = next;
try {
window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
} catch {
// Persistence is best-effort; the live session still uses in-memory state.
}
for (const listener of listeners) listener();
}

function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}

function getSnapshot(): boolean {
return enabled;
}

export function useShowJoinLeaveMessages(): boolean {
return React.useSyncExternalStore(subscribe, getSnapshot, () => false);
}
43 changes: 43 additions & 0 deletions desktop/src/features/settings/ui/MessageDisplaySettingsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
setShowJoinLeaveMessagesEnabled,
useShowJoinLeaveMessages,
} from "@/features/messages/lib/showJoinLeaveMessages";
import { Switch } from "@/shared/ui/switch";
import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup";
import { SettingsSectionHeader } from "./SettingsSectionHeader";

export function MessageDisplaySettingsCard() {
const showJoinLeaveMessages = useShowJoinLeaveMessages();

return (
<section className="min-w-0" data-testid="settings-message-display">
<SettingsSectionHeader
title="Messages"
description="Control which system messages appear in channel timelines on this device."
/>

<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<label
className="text-sm font-medium"
htmlFor="show-join-leave-messages-switch"
>
Show join and leave messages
</label>
<p className="text-sm font-normal text-muted-foreground">
Show "joined", "added", "left", and "removed" messages in channel
timelines. Member lists stay up to date either way.
</p>
</div>
<Switch
checked={showJoinLeaveMessages}
data-testid="show-join-leave-messages-toggle"
id="show-join-leave-messages-switch"
onCheckedChange={setShowJoinLeaveMessagesEnabled}
/>
</SettingsOptionRow>
</SettingsOptionGroup>
</section>
);
}
8 changes: 7 additions & 1 deletion desktop/src/features/settings/ui/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel";
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
import { MessageDisplaySettingsCard } from "./MessageDisplaySettingsCard";
import { MobilePairingCard } from "./MobilePairingCard";
import { ModerationQueueCard } from "./ModerationQueueCard";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
Expand Down Expand Up @@ -824,7 +825,12 @@ export function renderSettingsSection(
case "compute":
return <MeshComputeSettingsCard />;
case "appearance":
return <ThemeSettingsCard />;
return (
<div className="space-y-12">
<ThemeSettingsCard />
<MessageDisplaySettingsCard />
</div>
);
case "shortcuts":
return <KeyboardShortcutsCard />;
case "hosted-communities":
Expand Down
2 changes: 2 additions & 0 deletions desktop/tests/e2e/custom-emoji.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";

import { installMockBridge } from "../helpers/bridge";
import { enableJoinLeaveMessages } from "../helpers/joinLeaveMessages";

// Custom-emoji end-to-end guard.
//
Expand Down Expand Up @@ -445,6 +446,7 @@ test("adding a custom emoji while editing keeps the image after save (Bug 2)", a
// affordance. This drives the real react flow on a system row and asserts the
// pill appears — the surface the fix targeted.
test("a system message accepts a custom-emoji reaction", async ({ page }) => {
await enableJoinLeaveMessages(page);
await openGeneral(page);

const row = page.getByTestId("system-message-row").first();
Expand Down
Loading
Loading