/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import * as ChannelActionCreators from '@app/actions/ChannelActionCreators'; import * as ModalActionCreators from '@app/actions/ModalActionCreators'; import {modal} from '@app/actions/ModalActionCreators'; import * as PrivateChannelActionCreators from '@app/actions/PrivateChannelActionCreators'; import * as ReadStateActionCreators from '@app/actions/ReadStateActionCreators'; import * as TextCopyActionCreators from '@app/actions/TextCopyActionCreators'; import * as ToastActionCreators from '@app/actions/ToastActionCreators'; import * as UserGuildSettingsActionCreators from '@app/actions/UserGuildSettingsActionCreators'; import * as UserProfileActionCreators from '@app/actions/UserProfileActionCreators'; import {DMCloseFailedModal} from '@app/components/alerts/DMCloseFailedModal'; import styles from '@app/components/bottomsheets/ChannelDetailsBottomSheet.module.css'; import {ChannelSearchBottomSheet} from '@app/components/bottomsheets/ChannelSearchBottomSheet'; import {MuteDurationSheet} from '@app/components/bottomsheets/MuteDurationSheet'; import {createMuteConfig} from '@app/components/channel/MuteOptions'; import {PreloadableUserPopout} from '@app/components/channel/PreloadableUserPopout'; import {MemberListUnavailableFallback} from '@app/components/channel/shared/MemberListUnavailableFallback'; import {UserTag} from '@app/components/channel/UserTag'; import {CustomStatusDisplay} from '@app/components/common/custom_status_display/CustomStatusDisplay'; import {GroupDMAvatar} from '@app/components/common/GroupDMAvatar'; import {ChannelDebugModal} from '@app/components/debug/ChannelDebugModal'; import {UserDebugModal} from '@app/components/debug/UserDebugModal'; import {LongPressable} from '@app/components/LongPressable'; import {AddFriendsToGroupModal} from '@app/components/modals/AddFriendsToGroupModal'; import {ChannelSettingsModal} from '@app/components/modals/ChannelSettingsModal'; import {ChannelTopicModal} from '@app/components/modals/ChannelTopicModal'; import {ConfirmModal} from '@app/components/modals/ConfirmModal'; import {CreateDMModal} from '@app/components/modals/CreateDMModal'; import {EditGroupModal} from '@app/components/modals/EditGroupModal'; import {GroupInvitesModal} from '@app/components/modals/GroupInvitesModal'; import {GuildNotificationSettingsModal} from '@app/components/modals/GuildNotificationSettingsModal'; import {GuildMemberActionsSheet} from '@app/components/modals/guild_tabs/GuildMemberActionsSheet'; import {InviteModal} from '@app/components/modals/InviteModal'; import {ChannelPinsContent} from '@app/components/shared/ChannelPinsContent'; import { ChevronRightIcon, CloseDMIcon, CollapseChevronIcon, CopyIdIcon, CopyLinkIcon, DebugMessageIcon, DeleteIcon, EditIcon, ExpandChevronIcon, FavoriteIcon, InviteIcon, InvitesIcon, LeaveIcon, MarkAsReadIcon, MembersIcon, MoreOptionsVerticalIcon, MuteIcon, NewGroupIcon, NotificationSettingsIcon, OwnerCrownIcon, PinIcon, SearchIcon, SettingsIcon, } from '@app/components/uikit/context_menu/ContextMenuIcons'; import type {MenuGroupType, MenuItemType, MenuRadioType} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet'; import {MenuBottomSheet} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet'; import {Scroller} from '@app/components/uikit/Scroller'; import {StatusAwareAvatar} from '@app/components/uikit/StatusAwareAvatar'; import * as Sheet from '@app/components/uikit/sheet/Sheet'; import {Tooltip} from '@app/components/uikit/tooltip/Tooltip'; import {useLeaveGroup} from '@app/hooks/useLeaveGroup'; import {useMemberListCustomStatus} from '@app/hooks/useMemberListCustomStatus'; import {useMemberListPresence} from '@app/hooks/useMemberListPresence'; import {useMemberListSubscription} from '@app/hooks/useMemberListSubscription'; import {usePressable} from '@app/hooks/usePressable'; import {Logger} from '@app/lib/Logger'; import {SafeMarkdown} from '@app/lib/markdown'; import {MarkdownContext} from '@app/lib/markdown/renderers/RendererTypes'; import {Routes} from '@app/Routes'; import type {ChannelRecord} from '@app/records/ChannelRecord'; import type {GuildMemberRecord} from '@app/records/GuildMemberRecord'; import type {GuildRecord} from '@app/records/GuildRecord'; import type {UserRecord} from '@app/records/UserRecord'; import AccessibilityStore from '@app/stores/AccessibilityStore'; import AuthenticationStore from '@app/stores/AuthenticationStore'; import FavoritesStore from '@app/stores/FavoritesStore'; import GuildStore from '@app/stores/GuildStore'; import MemberSidebarStore from '@app/stores/MemberSidebarStore'; import PermissionStore from '@app/stores/PermissionStore'; import ReadStateStore from '@app/stores/ReadStateStore'; import SelectedChannelStore from '@app/stores/SelectedChannelStore'; import TypingStore from '@app/stores/TypingStore'; import UserGuildSettingsStore from '@app/stores/UserGuildSettingsStore'; import UserSettingsStore from '@app/stores/UserSettingsStore'; import UserStore from '@app/stores/UserStore'; import markupStyles from '@app/styles/Markup.module.css'; import * as ChannelUtils from '@app/utils/ChannelUtils'; import {getMutedText, getNotificationSettingsLabel} from '@app/utils/ContextMenuUtils'; import {isGroupDmFull} from '@app/utils/GroupDmUtils'; import * as InviteUtils from '@app/utils/InviteUtils'; import { buildMemberListLayout, getRowIndexRangeForMemberIndexRange, getTotalMemberCount, } from '@app/utils/MemberListLayout'; import * as MemberListUtils from '@app/utils/MemberListUtils'; import {buildChannelLink} from '@app/utils/MessageLinkUtils'; import * as NicknameUtils from '@app/utils/NicknameUtils'; import * as RouterUtils from '@app/utils/RouterUtils'; import {ME} from '@fluxer/constants/src/AppConstants'; import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildOperations} from '@fluxer/constants/src/GuildConstants'; import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants'; import {isOfflineStatus} from '@fluxer/constants/src/StatusConstants'; import {Trans, useLingui} from '@lingui/react/macro'; import clsx from 'clsx'; import {observer} from 'mobx-react-lite'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; const logger = new Logger('ChannelDetailsBottomSheet'); const MEMBER_ITEM_HEIGHT = 56; const INITIAL_MEMBER_RANGE: [number, number] = [0, 99]; const SCROLL_BUFFER = 50; function isScrollableOverflow(value: string): boolean { return value === 'auto' || value === 'scroll' || value === 'overlay'; } function findScrollableParent(node: HTMLElement | null): HTMLElement | null { let currentNode = node?.parentElement ?? null; while (currentNode) { const computedStyle = window.getComputedStyle(currentNode); if (isScrollableOverflow(computedStyle.overflowY) || isScrollableOverflow(computedStyle.overflow)) { return currentNode; } currentNode = currentNode.parentElement; } return null; } const SkeletonMemberItem = () => (
); type ChannelDetailsTab = 'members' | 'pins'; interface ChannelDetailsBottomSheetProps { isOpen: boolean; onClose: () => void; channel: ChannelRecord; initialTab?: ChannelDetailsTab; openSearchImmediately?: boolean; } interface QuickActionButtonProps { icon: React.ReactNode; label: string; onClick: () => void; isActive?: boolean; danger?: boolean; disabled?: boolean; } const QuickActionButton: React.FC = ({icon, label, onClick, isActive, danger, disabled}) => { const {isPressed, pressableProps} = usePressable(disabled); return ( ); }; const MobileMemberListItem = observer( ({ guild, channelId, member, onLongPress, }: { guild: GuildRecord; channelId: string; member: GuildMemberRecord; onLongPress?: (member: GuildMemberRecord) => void; }) => { const {t} = useLingui(); const isTyping = TypingStore.isTyping(channelId, member.user.id); const status = useMemberListPresence({ guildId: guild.id, channelId, userId: member.user.id, enabled: true, }); const memberListCustomStatus = useMemberListCustomStatus({ guildId: guild.id, channelId, userId: member.user.id, enabled: true, }); const handleLongPress = useCallback(() => { onLongPress?.(member); }, [member, onLongPress]); const content = (
{NicknameUtils.getNickname(member.user, guild.id)} {guild.isOwner(member.user.id) && (
)} {member.user.bot && }
{!member.user.bot && ( )}
); if (onLongPress) { return ( {content} ); } return content; }, ); interface LazyMemberListGroupProps { guild: GuildRecord; group: {id: string; count: number}; channelId: string; members: Array; onMemberLongPress?: (member: GuildMemberRecord) => void; } const LazyMemberListGroup = observer( ({guild, group, channelId, members, onMemberLongPress}: LazyMemberListGroupProps) => { const {t} = useLingui(); const groupName = (() => { switch (group.id) { case 'online': return t`Online`; case 'offline': return t`Offline`; default: { const role = guild.getRole(group.id); return role?.name ?? group.id; } } })(); return (
{groupName} — {group.count}
{members.map((member, index) => ( {index < members.length - 1 &&
} ))}
); }, ); const LazyGuildMemberList = observer( ({ guild, channel, onMemberLongPress, enabled = true, }: { guild: GuildRecord; channel: ChannelRecord; onMemberLongPress?: (member: GuildMemberRecord) => void; enabled?: boolean; }) => { const subscribedRangeRef = useRef<[number, number]>(INITIAL_MEMBER_RANGE); const listContainerRef = useRef(null); const scrollAnimationFrameRef = useRef(null); const memberListUpdatesDisabled = (guild.disabledOperations & GuildOperations.MEMBER_LIST_UPDATES) !== 0; const {subscribe} = useMemberListSubscription({ guildId: guild.id, channelId: channel.id, enabled: enabled && !memberListUpdatesDisabled, allowInitialUnfocusedLoad: true, }); const memberListState = MemberSidebarStore.getList(guild.id, channel.id); const isLoading = !memberListState || memberListState.items.size === 0; const memberCount = memberListState?.memberCount ?? 0; const groups = memberListState?.groups ?? []; const layouts = useMemo(() => buildMemberListLayout(groups), [groups]); const totalMembers = useMemo(() => Math.max(memberCount, getTotalMemberCount(groups)), [groups, memberCount]); const updateSubscribedRange = useCallback( (scrollTop: number, clientHeight: number) => { const startMemberIndex = Math.max(0, Math.floor(scrollTop / MEMBER_ITEM_HEIGHT) - SCROLL_BUFFER); const endMemberIndex = Math.max( INITIAL_MEMBER_RANGE[1], Math.ceil((scrollTop + clientHeight) / MEMBER_ITEM_HEIGHT) + SCROLL_BUFFER, ); let nextRange: [number, number] = [startMemberIndex, endMemberIndex]; if (totalMembers > 0) { const maxMemberIndex = totalMembers - 1; const clampedStart = Math.min(startMemberIndex, maxMemberIndex); const clampedEnd = Math.min(Math.max(clampedStart, endMemberIndex), maxMemberIndex); const rowRange = getRowIndexRangeForMemberIndexRange(layouts, clampedStart, clampedEnd); nextRange = rowRange ?? [clampedStart, clampedEnd]; } const [previousStart, previousEnd] = subscribedRangeRef.current; if (nextRange[0] === previousStart && nextRange[1] === previousEnd) { return; } subscribedRangeRef.current = nextRange; subscribe([nextRange]); }, [layouts, subscribe, totalMembers], ); useEffect(() => { if (!enabled || memberListUpdatesDisabled) { return; } const listContainer = listContainerRef.current; if (!listContainer) { return; } const scrollParent = findScrollableParent(listContainer); if (!scrollParent) { return; } const processScroll = () => { scrollAnimationFrameRef.current = null; updateSubscribedRange(scrollParent.scrollTop, scrollParent.clientHeight); }; const handleScroll = () => { if (scrollAnimationFrameRef.current !== null) { return; } scrollAnimationFrameRef.current = window.requestAnimationFrame(processScroll); }; handleScroll(); scrollParent.addEventListener('scroll', handleScroll, {passive: true}); window.addEventListener('resize', handleScroll); return () => { scrollParent.removeEventListener('scroll', handleScroll); window.removeEventListener('resize', handleScroll); if (scrollAnimationFrameRef.current !== null) { window.cancelAnimationFrame(scrollAnimationFrameRef.current); scrollAnimationFrameRef.current = null; } }; }, [enabled, memberListUpdatesDisabled, updateSubscribedRange]); if (memberListUpdatesDisabled) { return (
); } if (isLoading) { return (
{Array.from({length: 10}).map((_, i) => ( {i < 9 &&
} ))}
); } const groupedItems: Map> = new Map(); const seenMemberIds = new Set(); const groupById = new Map(groups.map((group) => [group.id, group])); for (const layout of layouts) { const members: Array = []; for (let index = layout.memberStartIndex; index <= layout.memberEndIndex; index++) { const item = memberListState.items.get(index); if (!item) { continue; } const member = item.data as GuildMemberRecord; if (!seenMemberIds.has(member.user.id)) { seenMemberIds.add(member.user.id); members.push(member); } } const group = groupById.get(layout.id); if (group) { groupedItems.set(group.id, members); } } return (
{layouts.map((layout) => { const group = groupById.get(layout.id); if (!group) { return null; } const members = groupedItems.get(group.id) ?? []; if (members.length === 0) { return null; } return ( ); })}
); }, ); const GuildMemberList = observer( ({ guild, channel, onMemberLongPress, enabled = true, }: { guild: GuildRecord; channel: ChannelRecord; onMemberLongPress?: (member: GuildMemberRecord) => void; enabled?: boolean; }) => { return ( ); }, ); export const ChannelDetailsBottomSheet: React.FC = observer( ({isOpen, onClose, channel, initialTab = 'members', openSearchImmediately = false}) => { const {t, i18n} = useLingui(); const [activeTab, setActiveTab] = useState(initialTab); const [muteSheetOpen, setMuteSheetOpen] = useState(false); const [searchSheetOpen, setSearchSheetOpen] = useState(false); const [isTopicExpanded, setIsTopicExpanded] = useState(false); const [moreOptionsSheetOpen, setMoreOptionsSheetOpen] = useState(false); const [notificationSheetOpen, setNotificationSheetOpen] = useState(false); const [activeMemberSheet, setActiveMemberSheet] = useState<{ member: GuildMemberRecord; user: UserRecord; } | null>(null); const leaveGroup = useLeaveGroup(); useEffect(() => { setActiveTab(initialTab); }, [initialTab]); useEffect(() => { if (openSearchImmediately && isOpen) { setSearchSheetOpen(true); } }, [openSearchImmediately, isOpen]); const isDM = channel.type === ChannelTypes.DM; const isPersonalNotes = channel.type === ChannelTypes.DM_PERSONAL_NOTES; const isGuildChannel = channel.guildId != null; const guild = isGuildChannel ? GuildStore.getGuild(channel.guildId) : null; const recipient = isDM && channel.recipientIds.length > 0 ? UserStore.getUser(channel.recipientIds[0]) : null; const currentUser = UserStore.currentUser; const currentUserId = AuthenticationStore.currentUserId; const guildId = channel.guildId ?? null; const settingsGuildId = isGuildChannel ? channel.guildId : null; const channelOverride = UserGuildSettingsStore.getChannelOverride(settingsGuildId, channel.id); const isMuted = channelOverride?.muted ?? false; const muteConfig = channelOverride?.mute_config; const mutedText = getMutedText(isMuted, muteConfig); const isGroupDMOwner = channel.type === ChannelTypes.GROUP_DM && channel.ownerId === currentUserId; const channelTypeLabel = useMemo(() => { switch (channel.type) { case ChannelTypes.GUILD_TEXT: return t`Text Channel`; case ChannelTypes.GUILD_VOICE: return t`Voice Channel`; case ChannelTypes.DM: return t`Direct Message`; case ChannelTypes.DM_PERSONAL_NOTES: return t`Personal Notes`; case ChannelTypes.GROUP_DM: return t`Group Direct Message`; default: return t`Channel`; } }, [channel.type]); const isFavorited = !!FavoritesStore.getChannel(channel.id); const showFavorites = AccessibilityStore.showFavorites; const isGroupDM = channel.type === ChannelTypes.GROUP_DM; const developerMode = UserSettingsStore.developerMode; const moreOptionsTitle = (() => { if (isGroupDM) return t`Group Settings`; if (isDM) return t`DM Settings`; return t`Channel Settings`; })(); const handleSearchClick = () => { setSearchSheetOpen(true); }; const handleBellClick = () => { setMuteSheetOpen(true); }; const handleCogClick = () => { setMoreOptionsSheetOpen(true); }; const handleMoreOptionsClose = () => { setMoreOptionsSheetOpen(false); }; const handleNotificationClose = () => { setNotificationSheetOpen(false); }; const handleMarkAsRead = useCallback(() => { ReadStateActionCreators.ack(channel.id, true, true); ToastActionCreators.createToast({type: 'success', children: t`Marked as read`}); }, [channel.id]); const handleInvite = useCallback(() => { ModalActionCreators.push(modal(() => )); onClose(); }, [channel.id, onClose]); const handleCopyLink = useCallback(() => { const channelLink = buildChannelLink({ guildId: channel.guildId, channelId: channel.id, }); TextCopyActionCreators.copy(i18n, channelLink); ToastActionCreators.createToast({type: 'success', children: t`Link copied to clipboard`}); }, [channel.id, channel.guildId, i18n]); const handleCopyId = useCallback(() => { TextCopyActionCreators.copy(i18n, channel.id); ToastActionCreators.createToast({type: 'success', children: t`Channel ID copied to clipboard`}); }, [channel.id, i18n]); const handleToggleFavorite = useCallback(() => { if (isFavorited) { FavoritesStore.removeChannel(channel.id); ToastActionCreators.createToast({type: 'success', children: t`Removed from favorites`}); } else { FavoritesStore.addChannel(channel.id, channel.guildId ?? ME, null); ToastActionCreators.createToast({type: 'success', children: t`Added to favorites`}); } }, [channel.id, channel.guildId, isFavorited]); const handleDebugChannel = useCallback(() => { const channelName = channel.name ?? t`Channel`; ModalActionCreators.push(modal(() => )); onClose(); }, [channel, onClose]); const handleDebugUser = useCallback(() => { if (!recipient) return; ModalActionCreators.push(modal(() => )); onClose(); }, [recipient, onClose]); const handlePinDM = useCallback(async () => { handleMoreOptionsClose(); try { await PrivateChannelActionCreators.pinDmChannel(channel.id); ToastActionCreators.createToast({ type: 'success', children: isGroupDM ? t`Pinned group` : t`Pinned DM`, }); } catch (error) { logger.error('Failed to pin:', error); ToastActionCreators.createToast({ type: 'error', children: isGroupDM ? t`Failed to pin group` : t`Failed to pin DM`, }); } }, [channel.id, isGroupDM]); const handleUnpinDM = useCallback(async () => { handleMoreOptionsClose(); try { await PrivateChannelActionCreators.unpinDmChannel(channel.id); ToastActionCreators.createToast({ type: 'success', children: isGroupDM ? t`Unpinned group` : t`Unpinned DM`, }); } catch (error) { logger.error('Failed to unpin:', error); ToastActionCreators.createToast({ type: 'error', children: isGroupDM ? t`Failed to unpin group` : t`Failed to unpin DM`, }); } }, [channel.id, isGroupDM]); const handleCloseDM = useCallback(() => { handleMoreOptionsClose(); onClose(); ModalActionCreators.push( modal(() => ( { try { await ChannelActionCreators.remove(channel.id); const selectedChannel = SelectedChannelStore.selectedChannelIds.get(ME); if (selectedChannel === channel.id) { RouterUtils.transitionTo(Routes.ME); } ToastActionCreators.createToast({ type: 'success', children: t`DM closed`, }); } catch (error) { logger.error('Failed to close DM:', error); window.setTimeout(() => { ModalActionCreators.push(modal(() => )); }, 0); } }} /> )), ); }, [channel.id, recipient, onClose]); const handleLeaveGroup = useCallback(() => { handleMoreOptionsClose(); onClose(); leaveGroup(channel.id); }, [channel.id, onClose, leaveGroup]); const handleEditGroup = useCallback(() => { handleMoreOptionsClose(); onClose(); ModalActionCreators.push(modal(() => )); }, [channel.id, onClose]); const handleShowInvites = useCallback(() => { handleMoreOptionsClose(); onClose(); ModalActionCreators.push(modal(() => )); }, [channel.id, onClose]); const handleOpenAddFriendsToGroup = useCallback(() => { handleMoreOptionsClose(); onClose(); ModalActionCreators.push(modal(() => )); }, [channel.id, onClose]); const handleCopyUserId = useCallback(() => { if (!recipient) return; TextCopyActionCreators.copy(i18n, recipient.id); ToastActionCreators.createToast({type: 'success', children: t`User ID copied to clipboard`}); }, [recipient, i18n]); const handleEditChannel = useCallback(() => { ModalActionCreators.push(modal(() => )); onClose(); }, [channel.id, onClose]); const handleDeleteChannel = useCallback(() => { onClose(); const channelType = channel.type === ChannelTypes.GUILD_VOICE ? t`Voice Channel` : t`Text Channel`; ModalActionCreators.push( modal(() => ( { try { await ChannelActionCreators.remove(channel.id); ToastActionCreators.createToast({ type: 'success', children: t`Channel deleted`, }); } catch (error) { logger.error('Failed to delete channel:', error); ToastActionCreators.createToast({ type: 'error', children: t`Failed to delete channel`, }); } }} /> )), ); }, [channel.id, channel.name, channel.type, onClose]); const handleOpenGuildNotificationSettings = useCallback(() => { if (!guildId) return; ModalActionCreators.push(modal(() => )); }, [guildId]); const handleOpenCreateGroupModal = useCallback(() => { const duplicateExcludeChannelId = channel.type === ChannelTypes.GROUP_DM ? channel.id : undefined; ModalActionCreators.push( modal(() => ( )), ); }, [channel.id, channel.recipientIds, channel.type]); const handleNotificationLevelChange = useCallback( (level: number) => { if (!guildId) return; if (level === MessageNotifications.INHERIT) { UserGuildSettingsActionCreators.updateChannelOverride( guildId, channel.id, { message_notifications: MessageNotifications.INHERIT, }, {persistImmediately: true}, ); } else { UserGuildSettingsActionCreators.updateMessageNotifications(guildId, level, channel.id, { persistImmediately: true, }); } }, [guildId, channel.id], ); const handleMute = useCallback( (duration: number | null) => { UserGuildSettingsActionCreators.updateChannelOverride( settingsGuildId, channel.id, { muted: true, mute_config: createMuteConfig(duration), }, {persistImmediately: true}, ); setMuteSheetOpen(false); }, [settingsGuildId, channel.id], ); const handleUnmute = useCallback(() => { UserGuildSettingsActionCreators.updateChannelOverride( settingsGuildId, channel.id, { muted: false, mute_config: null, }, {persistImmediately: true}, ); setMuteSheetOpen(false); }, [settingsGuildId, channel.id]); const handleMemberLongPress = useCallback((member: GuildMemberRecord) => { setActiveMemberSheet({member, user: member.user}); }, []); const handleCloseMemberSheet = useCallback(() => { setActiveMemberSheet(null); }, []); const isMemberTabVisible = isOpen && activeTab === 'members'; const dmMemberGroups = (() => { if (!(isDM || isGroupDM || isPersonalNotes)) return []; const currentUserId = AuthenticationStore.currentUserId; let memberIds: Array = []; if (isPersonalNotes) { memberIds = currentUser ? [currentUser.id] : []; } else { memberIds = [...channel.recipientIds]; if (currentUserId && !memberIds.includes(currentUserId)) { memberIds.push(currentUserId); } } const users = memberIds.map((id) => UserStore.getUser(id)).filter((u): u is UserRecord => u != null); return MemberListUtils.getGroupDMMemberGroups(users); })(); return ( <>
{isDM && recipient ? ( ) : isGroupDM ? ( ) : isPersonalNotes && currentUser ? ( ) : (
{ChannelUtils.getIcon(channel, {className: styles.iconLarge})}
)}
{isDM && recipient ? ( <>
{recipient.username} #{recipient.discriminator}
{recipient.bot && } ) : isGroupDM ? ( <>

{ChannelUtils.getDMDisplayName(channel)}

{channel.recipientIds.length + 1 === 1 ? t`Group DM · 1 member` : t`Group DM · ${channel.recipientIds.length + 1} members`}

) : isPersonalNotes ? ( <>

Personal Notes

Your private space

) : ( <>

{ChannelUtils.getIcon(channel, {className: styles.channelNameIcon})} {channel.name}

{channelTypeLabel}

)}
{channel.topic && !isDM && !isPersonalNotes && (
ModalActionCreators.push(modal(() => )) } onKeyDown={(e) => e.key === 'Enter' && ModalActionCreators.push(modal(() => )) } tabIndex={0} >
)}
} label={isMuted ? t`Unmute` : t`Mute`} onClick={handleBellClick} isActive={isMuted} /> } label={t`Search`} onClick={handleSearchClick} /> } label={t`More`} onClick={handleCogClick} />
{activeTab === 'members' && (
{(isDM || isGroupDM || isPersonalNotes) && (
{isDM && recipient && ( )} {dmMemberGroups.map((group) => (
{group.displayName} — {group.count}
{group.users.map((user, index) => { const isCurrentUser = user.id === currentUser?.id; const isOwner = isGroupDM && channel.ownerId === user.id; const handleUserClick = () => { UserProfileActionCreators.openUserProfile(user.id); }; return ( {index < group.users.length - 1 &&
} ); })}
))}
)} {isGuildChannel && guild && ( )}
)} {activeTab === 'pins' && (
)}
setMuteSheetOpen(false)} isMuted={isMuted} mutedText={mutedText} muteConfig={muteConfig} muteTitle={isGuildChannel ? t`Mute Channel` : t`Mute Conversation`} unmuteTitle={isGuildChannel ? t`Unmute Channel` : t`Unmute Conversation`} onMute={handleMute} onUnmute={handleUnmute} /> { const groups: Array = []; const hasUnread = ReadStateStore.hasUnread(channel.id); const commonItems: Array = []; if (showFavorites && !isPersonalNotes) { commonItems.push({ id: 'favorite', icon: , label: isFavorited ? t`Remove from Favorites` : t`Add to Favorites`, onClick: () => { handleToggleFavorite(); handleMoreOptionsClose(); }, }); } if (hasUnread) { commonItems.push({ id: 'mark-as-read', icon: , label: t`Mark as Read`, onClick: handleMarkAsRead, }); } if (isDM || isGroupDM) { commonItems.push( channel.isPinned ? { id: 'unpin', icon: , label: isGroupDM ? t`Unpin Group DM` : t`Unpin DM`, onClick: handleUnpinDM, } : { id: 'pin', icon: , label: isGroupDM ? t`Pin Group DM` : t`Pin DM`, onClick: handlePinDM, }, ); } const canInvite = isGuildChannel ? InviteUtils.canInviteToChannel(channel.id, channel.guildId) : false; if (canInvite) { commonItems.push({ id: 'invite', icon: , label: t`Invite People`, onClick: handleInvite, }); } if (isGuildChannel) { commonItems.push( { id: 'copy-link', icon: , label: t`Copy Link`, onClick: handleCopyLink, }, { id: 'notification-settings', icon: , label: t`Notification Settings`, onClick: () => { handleMoreOptionsClose(); setNotificationSheetOpen(true); }, }, ); } if (commonItems.length > 0) { groups.push({items: commonItems}); } if (isGroupDM) { const groupItems: Array = [ { id: 'edit-group', icon: , label: t`Edit Group`, onClick: handleEditGroup, }, ]; if (!isGroupDmFull(channel)) { groupItems.push({ id: 'add-friends', icon: , label: t`Add Friends to Group`, onClick: () => { handleMoreOptionsClose(); ModalActionCreators.push(modal(() => )); }, }); } if (isGroupDMOwner) { groupItems.push({ id: 'invites', icon: , label: t`Invites`, onClick: handleShowInvites, }); } groups.push({items: groupItems}); } if (isGuildChannel) { const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, { channelId: channel.id, guildId: channel.guildId, }); if (canManageChannels) { const managerItems: Array = [ { id: 'edit-channel', icon: , label: t`Edit Channel`, onClick: handleEditChannel, }, { id: 'delete-channel', icon: , label: t`Delete Channel`, onClick: handleDeleteChannel, danger: true, }, ]; groups.push({items: managerItems}); } } if (isDM) { groups.push({ items: [ { id: 'close-dm', icon: , label: t`Close DM`, onClick: handleCloseDM, danger: true, }, ], }); } if (isGroupDM) { groups.push({ items: [ { id: 'leave-group', icon: , label: t`Leave Group`, onClick: handleLeaveGroup, danger: true, }, ], }); } const miscItems: Array = []; if (developerMode) { miscItems.push({ id: 'debug-channel', icon: , label: t`Debug Channel`, onClick: handleDebugChannel, }); if (isDM && recipient) { miscItems.push({ id: 'debug-user', icon: , label: t`Debug User`, onClick: handleDebugUser, }); } } if (isDM && recipient) { miscItems.push({ id: 'copy-user-id', icon: , label: t`Copy User ID`, onClick: handleCopyUserId, }); } miscItems.push({ id: 'copy-channel-id', icon: , label: t`Copy Channel ID`, onClick: handleCopyId, }); if (miscItems.length > 0) { groups.push({items: miscItems}); } return groups; }, [ channel.id, channel.guildId, channel.name, channel.type, channel.isPinned, channel.recipientIds, isDM, isGroupDM, isGuildChannel, isGroupDMOwner, isPersonalNotes, showFavorites, isFavorited, recipient, developerMode, handleMarkAsRead, handleInvite, handleCopyLink, handlePinDM, handleUnpinDM, handleEditChannel, handleDeleteChannel, handleEditGroup, handleShowInvites, handleOpenAddFriendsToGroup, handleCloseDM, handleLeaveGroup, handleToggleFavorite, handleDebugChannel, handleDebugUser, handleCopyUserId, handleCopyId, ])} /> => { const categoryId = channel.parentId; const hasCategory = categoryId != null; const channelNotifications = UserGuildSettingsStore.getChannelOverride( guildId, channel.id, )?.message_notifications; const currentNotificationLevel = channelNotifications ?? MessageNotifications.INHERIT; const guildNotificationLevel = UserGuildSettingsStore.getGuildMessageNotifications(guildId); const categoryOverride = UserGuildSettingsStore.getChannelOverride(guildId, categoryId ?? ''); const categoryNotifications = categoryId ? categoryOverride?.message_notifications : undefined; const resolveEffectiveLevel = (level: number | undefined, fallback: number): number => { if (level === undefined || level === MessageNotifications.INHERIT) { return fallback; } return level; }; const categoryDefaultLevel = resolveEffectiveLevel(categoryNotifications, guildNotificationLevel); const defaultSubtext = getNotificationSettingsLabel(categoryDefaultLevel) ?? undefined; return [ { items: [ { label: hasCategory ? t`Category Default` : t`Community Default`, subtext: defaultSubtext, selected: currentNotificationLevel === MessageNotifications.INHERIT, onSelect: () => handleNotificationLevelChange(MessageNotifications.INHERIT), }, { label: t`All Messages`, selected: currentNotificationLevel === MessageNotifications.ALL_MESSAGES, onSelect: () => handleNotificationLevelChange(MessageNotifications.ALL_MESSAGES), }, { label: t`Only @mentions`, selected: currentNotificationLevel === MessageNotifications.ONLY_MENTIONS, onSelect: () => handleNotificationLevelChange(MessageNotifications.ONLY_MENTIONS), }, { label: t`Nothing`, selected: currentNotificationLevel === MessageNotifications.NO_MESSAGES, onSelect: () => handleNotificationLevelChange(MessageNotifications.NO_MESSAGES), }, ] as Array, }, { items: [ { id: 'open-guild-settings', icon: , label: t`Open Community Notification Settings`, onClick: handleOpenGuildNotificationSettings, }, ] as Array, }, ]; }, [ guildId, channel.id, channel.parentId, handleNotificationLevelChange, handleOpenGuildNotificationSettings, ])} /> setSearchSheetOpen(false)} channel={channel} /> {activeMemberSheet && guildId && ( )} ); }, );