diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 40dd5dd1b1..5e8906279e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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", diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs new file mode 100644 index 0000000000..6f0f4a4a05 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -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"], + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index a30ee24114..6aef35b670 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -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"; @@ -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, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 92fa172ff5..c3fcd81dff 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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"; @@ -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( diff --git a/desktop/src/features/messages/lib/showJoinLeaveMessages.ts b/desktop/src/features/messages/lib/showJoinLeaveMessages.ts new file mode 100644 index 0000000000..d51a307839 --- /dev/null +++ b/desktop/src/features/messages/lib/showJoinLeaveMessages.ts @@ -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); +} diff --git a/desktop/src/features/settings/ui/MessageDisplaySettingsCard.tsx b/desktop/src/features/settings/ui/MessageDisplaySettingsCard.tsx new file mode 100644 index 0000000000..b300ede5d7 --- /dev/null +++ b/desktop/src/features/settings/ui/MessageDisplaySettingsCard.tsx @@ -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 ( +
+ + + + +
+ +

+ Show "joined", "added", "left", and "removed" messages in channel + timelines. Member lists stay up to date either way. +

+
+ +
+
+
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 156be00b72..2c0744625c 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -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"; @@ -824,7 +825,12 @@ export function renderSettingsSection( case "compute": return ; case "appearance": - return ; + return ( +
+ + +
+ ); case "shortcuts": return ; case "hosted-communities": diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index aa345570f9..e9a2618f2d 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -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. // @@ -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(); diff --git a/desktop/tests/e2e/join-leave-visibility-screenshots.spec.ts b/desktop/tests/e2e/join-leave-visibility-screenshots.spec.ts new file mode 100644 index 0000000000..6b810a3b5b --- /dev/null +++ b/desktop/tests/e2e/join-leave-visibility-screenshots.spec.ts @@ -0,0 +1,182 @@ +/** + * Screenshots documenting the device-local "Show join and leave messages" + * setting: the Settings → Appearance toggle, the default timeline (rows + * hidden), and the timeline with the setting enabled (rows shown). + * + * Run: pnpm build:e2e && pnpm exec playwright test --project=smoke \ + * tests/e2e/join-leave-visibility-screenshots.spec.ts + * Output: test-results/join-leave-visibility/ + */ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { enableJoinLeaveMessages } from "../helpers/joinLeaveMessages"; +import { openSettings } from "../helpers/settings"; + +const SHOTS = "test-results/join-leave-visibility"; + +const KIND_SYSTEM_MESSAGE = 40099; + +// Skip the 256px sidebar so the timeline fills the shot. +const TIMELINE_CLIP = { x: 256, y: 0, width: 1024, height: 720 }; + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(async () => { + return page.evaluate( + ({ ch }) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? + false, + { ch: channelName }, + ); + }) + .toBe(true); +} + +/** + * Seed a timeline with normal chat messages surrounding membership system + * rows: alice joins (self-join), bob is added by tyler, then bob leaves. + */ +async function seedTimelineWithMembershipRows( + page: import("@playwright/test").Page, +) { + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "random"); + + const base = Math.floor(Date.now() / 1000) - 600; + await page.evaluate( + ({ alice, bob, tyler, kindSystem, baseTime }) => { + const emit = ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + pubkey?: string; + kind?: number; + createdAt?: number; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("mock emit unavailable"); + + emit({ + channelName: "random", + content: "Morning! Kicking off the release checklist today.", + pubkey: tyler, + createdAt: baseTime, + }); + emit({ + channelName: "random", + content: JSON.stringify({ + type: "member_joined", + actor: alice, + target: alice, + }), + pubkey: alice, + kind: kindSystem, + createdAt: baseTime + 60, + }); + emit({ + channelName: "random", + content: JSON.stringify({ + type: "member_joined", + actor: tyler, + target: bob, + }), + pubkey: tyler, + kind: kindSystem, + createdAt: baseTime + 120, + }); + emit({ + channelName: "random", + content: JSON.stringify({ + type: "member_left", + actor: bob, + target: bob, + }), + pubkey: bob, + kind: kindSystem, + createdAt: baseTime + 180, + }); + emit({ + channelName: "random", + content: "Checklist looks good — shipping after lunch.", + pubkey: alice, + createdAt: baseTime + 240, + }); + }, + { + alice: TEST_IDENTITIES.alice.pubkey, + bob: TEST_IDENTITIES.bob.pubkey, + tyler: TEST_IDENTITIES.tyler.pubkey, + kindSystem: KIND_SYSTEM_MESSAGE, + baseTime: base, + }, + ); + + await expect( + page.getByText("Checklist looks good — shipping after lunch."), + ).toBeVisible(); +} + +test("capture: settings toggle under Appearance", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await openSettings(page, "appearance"); + + const card = page.getByTestId("settings-message-display"); + await card.scrollIntoViewIfNeeded(); + await expect( + page.getByTestId("show-join-leave-messages-toggle"), + ).toBeVisible(); + await expect( + page.getByTestId("show-join-leave-messages-toggle"), + ).not.toBeChecked(); + + await waitForAnimations(page); + await card.screenshot({ path: `${SHOTS}/01-settings-toggle.png` }); +}); + +test("capture: timeline hides join/leave rows by default", async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await seedTimelineWithMembershipRows(page); + + await expect(page.getByText("joined the channel")).toHaveCount(0); + await expect(page.getByText("left the channel")).toHaveCount(0); + + await waitForAnimations(page); + await page.screenshot({ + path: `${SHOTS}/02-timeline-default-hidden.png`, + clip: TIMELINE_CLIP, + }); +}); + +test("capture: timeline shows join/leave rows when enabled", async ({ + page, +}) => { + await enableJoinLeaveMessages(page); + await installMockBridge(page); + await page.goto("/"); + await seedTimelineWithMembershipRows(page); + + await expect(page.getByText("joined the channel")).toBeVisible(); + await expect(page.getByText("left the channel")).toBeVisible(); + + await waitForAnimations(page); + await page.screenshot({ + path: `${SHOTS}/03-timeline-enabled-shown.png`, + clip: TIMELINE_CLIP, + }); +}); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 694b5abef5..4e27e6e764 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -5,6 +5,7 @@ import { openChannelBrowser, TEST_IDENTITIES, } from "../helpers/bridge"; +import { enableJoinLeaveMessages } from "../helpers/joinLeaveMessages"; const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8); @@ -1064,6 +1065,7 @@ test("mentioning a non-member provider managed agent deploys it before sending", test("system add rows use plain names while remove rows retain agent mention styling", async ({ page, }) => { + await enableJoinLeaveMessages(page); await installMockBridge(page, { managedAgents: [ { @@ -1144,6 +1146,7 @@ test("groups member additions and joins with hidden names in the standard toolti { pubkey: "15".repeat(32), displayName: "Olivia Park" }, { pubkey: "16".repeat(32), displayName: "Sam Rivera" }, ]; + await enableJoinLeaveMessages(page); await installMockBridge(page, { searchProfiles: [actor, ...targets], }); @@ -1249,6 +1252,7 @@ test("groups member additions and joins with hidden names in the standard toolti }); test("system agent profile only exposes message action", async ({ page }) => { + await enableJoinLeaveMessages(page); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -1294,6 +1298,7 @@ test("system agent profile only exposes message action", async ({ page }) => { }); test("system agent avatar only exposes message action", async ({ page }) => { + await enableJoinLeaveMessages(page); await page.goto("/"); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -1375,6 +1380,7 @@ test("profile-only agent author hides actions without agent access", async ({ test("system member-joined rows render the joined person as a plain profile name", async ({ page, }) => { + await enableJoinLeaveMessages(page); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 2487dbbf9c..dbb610bd84 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -2,6 +2,7 @@ import { expect, test, type Locator } from "@playwright/test"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; +import { enableJoinLeaveMessages } from "../helpers/joinLeaveMessages"; import { openSettings } from "../helpers/settings"; async function expectThreadReplyUnobscured(row: Locator) { @@ -113,6 +114,7 @@ test.beforeEach(async ({ page }, testInfo) => { }); test("agent owner label identifies the agent and owner", async ({ page }) => { + await enableJoinLeaveMessages(page); await page.goto("/"); await page.getByTestId("channel-general").click(); diff --git a/desktop/tests/helpers/joinLeaveMessages.ts b/desktop/tests/helpers/joinLeaveMessages.ts new file mode 100644 index 0000000000..ed44a307e1 --- /dev/null +++ b/desktop/tests/helpers/joinLeaveMessages.ts @@ -0,0 +1,15 @@ +import type { Page } from "@playwright/test"; + +export const SHOW_JOIN_LEAVE_STORAGE_KEY = "buzz:show-join-leave-messages"; + +/** + * Enable the device-local "Show join and leave messages" preference before + * the app boots. Production hides membership system rows by default, so any + * test asserting on joined/added/left/removed timeline rows must opt in. + * Call before `page.goto` — React reads the preference on mount. + */ +export async function enableJoinLeaveMessages(page: Page) { + await page.addInitScript((key) => { + window.localStorage.setItem(key, "1"); + }, SHOW_JOIN_LEAVE_STORAGE_KEY); +} diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index f94226ac80..eae0c131ae 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../shared/preferences/show_join_leave_messages_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -345,10 +346,24 @@ class ChannelDetailPage extends HookConsumerWidget { final summaries = ref .read(channelMessagesProvider(channel.id).notifier) .threadSummaries; - final entries = buildMainTimelineEntries( - messages, - relaySummaries: summaries, + final showJoinLeave = ref.watch( + showJoinLeaveMessagesProvider, ); + const joinLeaveTypes = { + SystemEventType.memberJoined, + SystemEventType.memberLeft, + SystemEventType.memberRemoved, + }; + final entries = + buildMainTimelineEntries( + messages, + relaySummaries: summaries, + ).where((entry) { + final type = entry.message.systemEvent?.type; + return showJoinLeave || + type == null || + !joinLeaveTypes.contains(type); + }).toList(); return _MessageList( entries: entries, allMessages: messages, diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index f089f63915..38204509a4 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/preferences/show_join_leave_messages_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; @@ -18,6 +19,7 @@ import 'theme_picker_page.dart'; part 'settings_page/appearance_section.dart'; part 'settings_page/connection_section.dart'; +part 'settings_page/messages_section.dart'; class SettingsPage extends HookConsumerWidget { const SettingsPage({super.key, required this.profileHeader}); @@ -42,6 +44,7 @@ class SettingsPage extends HookConsumerWidget { children: [ profileHeader, const _AppearanceSection(), + const _MessagesSection(), const _ConnectionSection(), const _RemoveCommunitySection(), ], diff --git a/mobile/lib/features/settings/settings_page/messages_section.dart b/mobile/lib/features/settings/settings_page/messages_section.dart new file mode 100644 index 0000000000..a17715cdc1 --- /dev/null +++ b/mobile/lib/features/settings/settings_page/messages_section.dart @@ -0,0 +1,30 @@ +part of '../settings_page.dart'; + +/// Device-local message display preferences. +class _MessagesSection extends ConsumerWidget { + const _MessagesSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final showJoinLeave = ref.watch(showJoinLeaveMessagesProvider); + + return AppListCard( + label: 'Messages', + children: [ + AppListRow( + icon: LucideIcons.userPlus, + title: 'Show join and leave messages', + trailing: Switch( + value: showJoinLeave, + onChanged: (enabled) => ref + .read(showJoinLeaveMessagesProvider.notifier) + .setEnabled(enabled), + ), + onTap: () => ref + .read(showJoinLeaveMessagesProvider.notifier) + .setEnabled(!showJoinLeave), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/preferences/show_join_leave_messages_provider.dart b/mobile/lib/shared/preferences/show_join_leave_messages_provider.dart new file mode 100644 index 0000000000..41583d0ef5 --- /dev/null +++ b/mobile/lib/shared/preferences/show_join_leave_messages_provider.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../theme/theme_provider.dart' show savedPrefsProvider; + +const _showJoinLeaveMessagesKey = 'buzz_show_join_leave_messages'; + +/// 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 membership events (member lists depend on them); this +/// setting only controls whether they render in the timeline. +class ShowJoinLeaveMessagesNotifier extends Notifier { + @override + bool build() { + return ref.read(savedPrefsProvider).getBool(_showJoinLeaveMessagesKey) ?? + false; + } + + void setEnabled(bool enabled) { + state = enabled; + ref.read(savedPrefsProvider).setBool(_showJoinLeaveMessagesKey, enabled); + } +} + +final showJoinLeaveMessagesProvider = + NotifierProvider( + ShowJoinLeaveMessagesNotifier.new, + ); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index af39eb103a..232d8ad3b9 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -23,6 +23,7 @@ import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/preferences/show_join_leave_messages_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; @@ -153,6 +154,10 @@ Widget _buildTestable({ String? initialThreadRootId, Map> threadReplies = const {}, TextScaler textScaler = TextScaler.noScaling, + // On by default so system-row rendering tests exercise the join/leave + // rows; the dedicated filtering test passes false to cover the app + // default (hidden). + bool showJoinLeaveMessages = true, RelaySessionNotifier? relaySessionNotifier, }) { final resolvedChannel = channel ?? _testChannel; @@ -170,6 +175,9 @@ Widget _buildTestable({ ).overrideWith(() => _FakeTypingNotifier(typing)), userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), profileProvider.overrideWith(() => _FakeProfileNotifier()), + showJoinLeaveMessagesProvider.overrideWith( + () => _FakeShowJoinLeaveMessagesNotifier(showJoinLeaveMessages), + ), channelsProvider.overrideWith(() => fakeChannelsNotifier), channelDetailsProvider(_channelId).overrideWith( (ref) async => ChannelDetails.fromChannel(resolvedChannel), @@ -904,6 +912,45 @@ void main() { }); group('System messages', () { + testWidgets('hides join/leave rows when the setting is off (the default)', ( + tester, + ) async { + final messages = [ + _systemMsg( + id: 'sys1', + payload: {'type': 'member_joined', 'actor': 'bob', 'target': 'bob'}, + ), + _systemMsg( + id: 'sys2', + payload: {'type': 'member_left', 'actor': 'bob'}, + ), + _systemMsg( + id: 'sys3', + payload: { + 'type': 'member_removed', + 'actor': 'alice', + 'target': 'bob', + }, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + showJoinLeaveMessages: false, + users: { + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(findRichText('joined the channel'), findsNothing); + expect(find.text('Bob left the channel'), findsNothing); + expect(find.text('Alice removed Bob from the channel'), findsNothing); + }); + testWidgets('renders channel_created system event', (tester) async { final messages = [ _systemMsg( @@ -1946,6 +1993,14 @@ class _SynchronousReadStateNotifier extends ReadStateNotifier { } } +class _FakeShowJoinLeaveMessagesNotifier extends ShowJoinLeaveMessagesNotifier { + _FakeShowJoinLeaveMessagesNotifier(this._value); + final bool _value; + + @override + bool build() => _value; +} + class _FakeProfileNotifier extends ProfileNotifier { @override Future build() async =>