/*
* 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 (
{icon}
{label}
);
};
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}
>
setIsTopicExpanded(!isTopicExpanded)}
className={styles.topicExpandButton}
>
{isTopicExpanded ? (
) : (
)}
)}
}
label={isMuted ? t`Unmute` : t`Mute`}
onClick={handleBellClick}
isActive={isMuted}
/>
} label={t`Search`} onClick={handleSearchClick} />
}
label={t`More`}
onClick={handleCogClick}
/>
setActiveTab('members')}
className={`${styles.tabButton} ${activeTab === 'members' ? styles.tabButtonActive : styles.tabButtonInactive}`}
style={activeTab === 'members' ? {borderBottomColor: 'var(--brand-primary-light)'} : undefined}
>
Members
setActiveTab('pins')}
className={`${styles.tabButton} ${activeTab === 'pins' ? styles.tabButtonActive : styles.tabButtonInactive}`}
style={activeTab === 'pins' ? {borderBottomColor: 'var(--brand-primary-light)'} : undefined}
>
Pins
{activeTab === 'members' && (
{(isDM || isGroupDM || isPersonalNotes) && (
{isDM && recipient && (
New Group
Create a new group with {recipient.username}
)}
{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 (
{user.username}
{isCurrentUser && (
{' '}
(you)
)}
{(user.bot || isOwner) && (
{user.bot && }
{isOwner && (
)}
)}
{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 && (
)}
>
);
},
);