1496 lines
48 KiB
TypeScript
1496 lines
48 KiB
TypeScript
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
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 = () => (
|
|
<div className={styles.skeletonItem}>
|
|
<div className={clsx(styles.skeletonAvatar, styles.skeleton)} />
|
|
<div className={styles.skeletonInfo}>
|
|
<div className={clsx(styles.skeletonName, styles.skeleton)} />
|
|
<div className={clsx(styles.skeletonStatus, styles.skeleton)} />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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<QuickActionButtonProps> = ({icon, label, onClick, isActive, danger, disabled}) => {
|
|
const {isPressed, pressableProps} = usePressable(disabled);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className={clsx(
|
|
styles.quickActionButton,
|
|
isPressed && styles.quickActionButtonPressed,
|
|
isActive && styles.quickActionButtonActive,
|
|
danger && styles.quickActionButtonDanger,
|
|
disabled && styles.quickActionButtonDisabled,
|
|
)}
|
|
{...pressableProps}
|
|
>
|
|
<div className={styles.quickActionIcon}>{icon}</div>
|
|
<span className={styles.quickActionLabel}>{label}</span>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
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 = (
|
|
<PreloadableUserPopout user={member.user} isWebhook={false} guildId={guild.id} position="left-start">
|
|
<div
|
|
className={`${styles.memberListItem} ${
|
|
!member.isCurrentUser() && isOfflineStatus(status) ? styles.memberListItemOffline : ''
|
|
}`}
|
|
>
|
|
<StatusAwareAvatar
|
|
user={member.user}
|
|
size={40}
|
|
isTyping={isTyping}
|
|
showOffline={member.user.id === AuthenticationStore.currentUserId || isTyping}
|
|
guildId={guild.id}
|
|
status={status}
|
|
/>
|
|
<div className={styles.memberContent}>
|
|
<div className={styles.memberNameRow}>
|
|
<span className={styles.memberName} style={{color: member.getColorString()}}>
|
|
{NicknameUtils.getNickname(member.user, guild.id)}
|
|
</span>
|
|
{guild.isOwner(member.user.id) && (
|
|
<div className={styles.crownContainer}>
|
|
<Tooltip text={t`Community Owner`}>
|
|
<OwnerCrownIcon className={styles.crownIcon} />
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
{member.user.bot && <UserTag className={styles.memberTag} system={member.user.system} />}
|
|
</div>
|
|
{!member.user.bot && (
|
|
<CustomStatusDisplay
|
|
customStatus={memberListCustomStatus}
|
|
userId={member.user.id}
|
|
className={styles.memberCustomStatus}
|
|
showText={true}
|
|
showTooltip={false}
|
|
animateOnParentHover
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PreloadableUserPopout>
|
|
);
|
|
|
|
if (onLongPress) {
|
|
return (
|
|
<LongPressable onLongPress={handleLongPress} delay={500}>
|
|
{content}
|
|
</LongPressable>
|
|
);
|
|
}
|
|
|
|
return content;
|
|
},
|
|
);
|
|
|
|
interface LazyMemberListGroupProps {
|
|
guild: GuildRecord;
|
|
group: {id: string; count: number};
|
|
channelId: string;
|
|
members: Array<GuildMemberRecord>;
|
|
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 (
|
|
<div className={styles.memberGroupContainer}>
|
|
<div className={styles.memberGroupHeader}>
|
|
{groupName} — {group.count}
|
|
</div>
|
|
<div className={styles.memberGroupList}>
|
|
{members.map((member, index) => (
|
|
<React.Fragment key={member.user.id}>
|
|
<MobileMemberListItem
|
|
guild={guild}
|
|
channelId={channelId}
|
|
member={member}
|
|
onLongPress={onMemberLongPress}
|
|
/>
|
|
{index < members.length - 1 && <div className={styles.memberDivider} />}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
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<HTMLDivElement | null>(null);
|
|
const scrollAnimationFrameRef = useRef<number | null>(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 (
|
|
<div className={styles.memberListFallbackContainer}>
|
|
<MemberListUnavailableFallback className={styles.memberListFallback} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className={styles.memberListContent}>
|
|
<div className={styles.memberGroupContainer}>
|
|
<div className={clsx(styles.memberGroupHeader, styles.skeletonHeader, styles.skeleton)} />
|
|
<div className={styles.memberGroupList}>
|
|
{Array.from({length: 10}).map((_, i) => (
|
|
<React.Fragment key={i}>
|
|
<SkeletonMemberItem />
|
|
{i < 9 && <div className={styles.memberDivider} />}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const groupedItems: Map<string, Array<GuildMemberRecord>> = new Map();
|
|
const seenMemberIds = new Set<string>();
|
|
const groupById = new Map(groups.map((group) => [group.id, group]));
|
|
|
|
for (const layout of layouts) {
|
|
const members: Array<GuildMemberRecord> = [];
|
|
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 (
|
|
<div className={styles.memberListContent} ref={listContainerRef}>
|
|
{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 (
|
|
<LazyMemberListGroup
|
|
key={group.id}
|
|
guild={guild}
|
|
group={group}
|
|
channelId={channel.id}
|
|
members={members}
|
|
onMemberLongPress={onMemberLongPress}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
const GuildMemberList = observer(
|
|
({
|
|
guild,
|
|
channel,
|
|
onMemberLongPress,
|
|
enabled = true,
|
|
}: {
|
|
guild: GuildRecord;
|
|
channel: ChannelRecord;
|
|
onMemberLongPress?: (member: GuildMemberRecord) => void;
|
|
enabled?: boolean;
|
|
}) => {
|
|
return (
|
|
<LazyGuildMemberList guild={guild} channel={channel} onMemberLongPress={onMemberLongPress} enabled={enabled} />
|
|
);
|
|
},
|
|
);
|
|
|
|
export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps> = observer(
|
|
({isOpen, onClose, channel, initialTab = 'members', openSearchImmediately = false}) => {
|
|
const {t, i18n} = useLingui();
|
|
const [activeTab, setActiveTab] = useState<ChannelDetailsTab>(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(() => <InviteModal channelId={channel.id} />));
|
|
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(() => <ChannelDebugModal title={channelName} channel={channel} />));
|
|
onClose();
|
|
}, [channel, onClose]);
|
|
|
|
const handleDebugUser = useCallback(() => {
|
|
if (!recipient) return;
|
|
ModalActionCreators.push(modal(() => <UserDebugModal title={recipient.username} user={recipient} />));
|
|
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(() => (
|
|
<ConfirmModal
|
|
title={t`Close DM`}
|
|
description={t`Are you sure you want to close your DM with ${recipient?.username ?? ''}? You can always reopen it later.`}
|
|
primaryText={t`Close DM`}
|
|
primaryVariant="danger-primary"
|
|
onPrimary={async () => {
|
|
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(() => <DMCloseFailedModal />));
|
|
}, 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(() => <EditGroupModal channelId={channel.id} />));
|
|
}, [channel.id, onClose]);
|
|
|
|
const handleShowInvites = useCallback(() => {
|
|
handleMoreOptionsClose();
|
|
onClose();
|
|
ModalActionCreators.push(modal(() => <GroupInvitesModal channelId={channel.id} />));
|
|
}, [channel.id, onClose]);
|
|
|
|
const handleOpenAddFriendsToGroup = useCallback(() => {
|
|
handleMoreOptionsClose();
|
|
onClose();
|
|
ModalActionCreators.push(modal(() => <AddFriendsToGroupModal channelId={channel.id} />));
|
|
}, [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(() => <ChannelSettingsModal channelId={channel.id} />));
|
|
onClose();
|
|
}, [channel.id, onClose]);
|
|
|
|
const handleDeleteChannel = useCallback(() => {
|
|
onClose();
|
|
const channelType = channel.type === ChannelTypes.GUILD_VOICE ? t`Voice Channel` : t`Text Channel`;
|
|
ModalActionCreators.push(
|
|
modal(() => (
|
|
<ConfirmModal
|
|
title={t`Delete ${channelType}`}
|
|
description={t`Are you sure you want to delete #${channel.name ?? 'this channel'}? This cannot be undone.`}
|
|
primaryText={t`Delete Channel`}
|
|
primaryVariant="danger-primary"
|
|
onPrimary={async () => {
|
|
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(() => <GuildNotificationSettingsModal guildId={guildId} />));
|
|
}, [guildId]);
|
|
|
|
const handleOpenCreateGroupModal = useCallback(() => {
|
|
const duplicateExcludeChannelId = channel.type === ChannelTypes.GROUP_DM ? channel.id : undefined;
|
|
ModalActionCreators.push(
|
|
modal(() => (
|
|
<CreateDMModal
|
|
initialSelectedUserIds={Array.from(channel.recipientIds)}
|
|
duplicateExcludeChannelId={duplicateExcludeChannelId}
|
|
/>
|
|
)),
|
|
);
|
|
}, [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<string> = [];
|
|
|
|
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 (
|
|
<>
|
|
<Sheet.Root isOpen={isOpen} onClose={onClose} snapPoints={[0, 1]} initialSnap={1}>
|
|
<Sheet.Handle />
|
|
<Sheet.Content padding="none">
|
|
<Scroller key="channel-details-scroller" className={styles.mainScroller}>
|
|
<div className={styles.channelInfoSection}>
|
|
<Sheet.CloseButton onClick={onClose} className={styles.closeButton} />
|
|
<div className={styles.channelInfoContainer}>
|
|
{isDM && recipient ? (
|
|
<StatusAwareAvatar user={recipient} size={48} />
|
|
) : isGroupDM ? (
|
|
<GroupDMAvatar channel={channel} size={48} />
|
|
) : isPersonalNotes && currentUser ? (
|
|
<StatusAwareAvatar user={currentUser} size={48} />
|
|
) : (
|
|
<div className={styles.channelAvatar}>
|
|
{ChannelUtils.getIcon(channel, {className: styles.iconLarge})}
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.channelInfoContent}>
|
|
{isDM && recipient ? (
|
|
<>
|
|
<div className={styles.channelInfoUserContainer}>
|
|
<span className={styles.channelInfoUsername}>{recipient.username}</span>
|
|
<span className={styles.channelInfoDiscriminator}>#{recipient.discriminator}</span>
|
|
</div>
|
|
{recipient.bot && <UserTag className={styles.channelInfoTag} system={recipient.system} />}
|
|
</>
|
|
) : isGroupDM ? (
|
|
<>
|
|
<h2 className={styles.channelInfoTitle}>{ChannelUtils.getDMDisplayName(channel)}</h2>
|
|
<p className={styles.channelInfoSubtitle}>
|
|
{channel.recipientIds.length + 1 === 1
|
|
? t`Group DM · 1 member`
|
|
: t`Group DM · ${channel.recipientIds.length + 1} members`}
|
|
</p>
|
|
</>
|
|
) : isPersonalNotes ? (
|
|
<>
|
|
<h2 className={styles.channelInfoTitle}>
|
|
<Trans>Personal Notes</Trans>
|
|
</h2>
|
|
<p className={styles.channelInfoSubtitle}>
|
|
<Trans>Your private space</Trans>
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<h2 className={styles.channelInfoTitle}>
|
|
<span className={styles.channelNameWithIcon}>
|
|
{ChannelUtils.getIcon(channel, {className: styles.channelNameIcon})}
|
|
{channel.name}
|
|
</span>
|
|
</h2>
|
|
<p className={styles.channelInfoSubtitle}>{channelTypeLabel}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{channel.topic && !isDM && !isPersonalNotes && (
|
|
<div className={styles.topicSectionContainer}>
|
|
<div className={styles.topicWrapper}>
|
|
<div
|
|
role="button"
|
|
className={`${markupStyles.markup} ${styles.topicMarkup} ${!isTopicExpanded ? styles.topicMarkupCollapsed : ''}`}
|
|
style={
|
|
isTopicExpanded
|
|
? {
|
|
wordWrap: 'break-word',
|
|
overflowWrap: 'break-word',
|
|
whiteSpace: 'break-spaces',
|
|
}
|
|
: undefined
|
|
}
|
|
onClick={() =>
|
|
ModalActionCreators.push(modal(() => <ChannelTopicModal channelId={channel.id} />))
|
|
}
|
|
onKeyDown={(e) =>
|
|
e.key === 'Enter' &&
|
|
ModalActionCreators.push(modal(() => <ChannelTopicModal channelId={channel.id} />))
|
|
}
|
|
tabIndex={0}
|
|
>
|
|
<SafeMarkdown
|
|
content={channel.topic}
|
|
options={{
|
|
context: MarkdownContext.RESTRICTED_INLINE_REPLY,
|
|
channelId: channel.id,
|
|
}}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsTopicExpanded(!isTopicExpanded)}
|
|
className={styles.topicExpandButton}
|
|
>
|
|
{isTopicExpanded ? (
|
|
<CollapseChevronIcon className={styles.iconSmall} />
|
|
) : (
|
|
<ExpandChevronIcon className={styles.iconSmall} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.quickActionsRow}>
|
|
<div className={styles.quickActionsScroll}>
|
|
<QuickActionButton
|
|
icon={<MuteIcon size={20} />}
|
|
label={isMuted ? t`Unmute` : t`Mute`}
|
|
onClick={handleBellClick}
|
|
isActive={isMuted}
|
|
/>
|
|
|
|
<QuickActionButton icon={<SearchIcon size={20} />} label={t`Search`} onClick={handleSearchClick} />
|
|
|
|
<QuickActionButton
|
|
icon={<MoreOptionsVerticalIcon size={20} />}
|
|
label={t`More`}
|
|
onClick={handleCogClick}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.tabBarContainer}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('members')}
|
|
className={`${styles.tabButton} ${activeTab === 'members' ? styles.tabButtonActive : styles.tabButtonInactive}`}
|
|
style={activeTab === 'members' ? {borderBottomColor: 'var(--brand-primary-light)'} : undefined}
|
|
>
|
|
<MembersIcon className={styles.tabIcon} />
|
|
<Trans>Members</Trans>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab('pins')}
|
|
className={`${styles.tabButton} ${activeTab === 'pins' ? styles.tabButtonActive : styles.tabButtonInactive}`}
|
|
style={activeTab === 'pins' ? {borderBottomColor: 'var(--brand-primary-light)'} : undefined}
|
|
>
|
|
<PinIcon className={styles.tabIcon} />
|
|
<Trans>Pins</Trans>
|
|
</button>
|
|
</div>
|
|
<div className={styles.contentArea}>
|
|
{activeTab === 'members' && (
|
|
<div className={styles.membersTabContent}>
|
|
{(isDM || isGroupDM || isPersonalNotes) && (
|
|
<div className={styles.dmMembersContainer}>
|
|
{isDM && recipient && (
|
|
<button type="button" className={styles.newGroupButton} onClick={handleOpenCreateGroupModal}>
|
|
<div className={styles.newGroupIconContainer}>
|
|
<NewGroupIcon className={`${styles.iconMedium} ${styles.newGroupIconWhite}`} />
|
|
</div>
|
|
<div className={styles.newGroupContent}>
|
|
<p className={styles.newGroupTitle}>
|
|
<Trans>New Group</Trans>
|
|
</p>
|
|
<p className={styles.newGroupSubtitle}>
|
|
<Trans>Create a new group with {recipient.username}</Trans>
|
|
</p>
|
|
</div>
|
|
<ChevronRightIcon className={styles.iconMedium} />
|
|
</button>
|
|
)}
|
|
|
|
{dmMemberGroups.map((group) => (
|
|
<div key={group.id} className={styles.memberGroupContainer}>
|
|
<div className={styles.memberGroupHeader}>
|
|
{group.displayName} — {group.count}
|
|
</div>
|
|
<div className={styles.memberGroupList}>
|
|
{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 (
|
|
<React.Fragment key={user.id}>
|
|
<button type="button" onClick={handleUserClick} className={styles.memberItemButton}>
|
|
<StatusAwareAvatar user={user} size={40} />
|
|
<div className={styles.memberItemContent}>
|
|
<span className={styles.memberItemName}>
|
|
{user.username}
|
|
{isCurrentUser && (
|
|
<span className={styles.memberItemYou}>
|
|
{' '}
|
|
<Trans>(you)</Trans>
|
|
</span>
|
|
)}
|
|
</span>
|
|
{(user.bot || isOwner) && (
|
|
<div className={styles.memberItemTags}>
|
|
{user.bot && <UserTag system={user.system} />}
|
|
{isOwner && (
|
|
<Tooltip text={t`Group Owner`}>
|
|
<OwnerCrownIcon className={styles.ownerCrown} />
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
{index < group.users.length - 1 && <div className={styles.memberItemDivider} />}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{isGuildChannel && guild && (
|
|
<GuildMemberList
|
|
guild={guild}
|
|
channel={channel}
|
|
onMemberLongPress={handleMemberLongPress}
|
|
enabled={isMemberTabVisible}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{activeTab === 'pins' && (
|
|
<div className={styles.pinsTabContent}>
|
|
<ChannelPinsContent channel={channel} onJump={onClose} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Scroller>
|
|
</Sheet.Content>
|
|
</Sheet.Root>
|
|
|
|
<MuteDurationSheet
|
|
isOpen={muteSheetOpen}
|
|
onClose={() => 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}
|
|
/>
|
|
|
|
<MenuBottomSheet
|
|
isOpen={moreOptionsSheetOpen}
|
|
onClose={handleMoreOptionsClose}
|
|
title={moreOptionsTitle}
|
|
groups={useMemo(() => {
|
|
const groups: Array<MenuGroupType> = [];
|
|
const hasUnread = ReadStateStore.hasUnread(channel.id);
|
|
|
|
const commonItems: Array<MenuItemType> = [];
|
|
|
|
if (showFavorites && !isPersonalNotes) {
|
|
commonItems.push({
|
|
id: 'favorite',
|
|
icon: <FavoriteIcon filled={isFavorited} size={20} />,
|
|
label: isFavorited ? t`Remove from Favorites` : t`Add to Favorites`,
|
|
onClick: () => {
|
|
handleToggleFavorite();
|
|
handleMoreOptionsClose();
|
|
},
|
|
});
|
|
}
|
|
|
|
if (hasUnread) {
|
|
commonItems.push({
|
|
id: 'mark-as-read',
|
|
icon: <MarkAsReadIcon size={20} />,
|
|
label: t`Mark as Read`,
|
|
onClick: handleMarkAsRead,
|
|
});
|
|
}
|
|
|
|
if (isDM || isGroupDM) {
|
|
commonItems.push(
|
|
channel.isPinned
|
|
? {
|
|
id: 'unpin',
|
|
icon: <PinIcon size={20} />,
|
|
label: isGroupDM ? t`Unpin Group DM` : t`Unpin DM`,
|
|
onClick: handleUnpinDM,
|
|
}
|
|
: {
|
|
id: 'pin',
|
|
icon: <PinIcon size={20} />,
|
|
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: <InviteIcon size={20} />,
|
|
label: t`Invite People`,
|
|
onClick: handleInvite,
|
|
});
|
|
}
|
|
|
|
if (isGuildChannel) {
|
|
commonItems.push(
|
|
{
|
|
id: 'copy-link',
|
|
icon: <CopyLinkIcon size={20} />,
|
|
label: t`Copy Link`,
|
|
onClick: handleCopyLink,
|
|
},
|
|
{
|
|
id: 'notification-settings',
|
|
icon: <NotificationSettingsIcon size={20} />,
|
|
label: t`Notification Settings`,
|
|
onClick: () => {
|
|
handleMoreOptionsClose();
|
|
setNotificationSheetOpen(true);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
if (commonItems.length > 0) {
|
|
groups.push({items: commonItems});
|
|
}
|
|
|
|
if (isGroupDM) {
|
|
const groupItems: Array<MenuItemType> = [
|
|
{
|
|
id: 'edit-group',
|
|
icon: <EditIcon size={20} />,
|
|
label: t`Edit Group`,
|
|
onClick: handleEditGroup,
|
|
},
|
|
];
|
|
|
|
if (!isGroupDmFull(channel)) {
|
|
groupItems.push({
|
|
id: 'add-friends',
|
|
icon: <InviteIcon size={20} />,
|
|
label: t`Add Friends to Group`,
|
|
onClick: () => {
|
|
handleMoreOptionsClose();
|
|
ModalActionCreators.push(modal(() => <AddFriendsToGroupModal channelId={channel.id} />));
|
|
},
|
|
});
|
|
}
|
|
|
|
if (isGroupDMOwner) {
|
|
groupItems.push({
|
|
id: 'invites',
|
|
icon: <InvitesIcon size={20} />,
|
|
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<MenuItemType> = [
|
|
{
|
|
id: 'edit-channel',
|
|
icon: <EditIcon size={20} />,
|
|
label: t`Edit Channel`,
|
|
onClick: handleEditChannel,
|
|
},
|
|
{
|
|
id: 'delete-channel',
|
|
icon: <DeleteIcon size={20} />,
|
|
label: t`Delete Channel`,
|
|
onClick: handleDeleteChannel,
|
|
danger: true,
|
|
},
|
|
];
|
|
groups.push({items: managerItems});
|
|
}
|
|
}
|
|
|
|
if (isDM) {
|
|
groups.push({
|
|
items: [
|
|
{
|
|
id: 'close-dm',
|
|
icon: <CloseDMIcon size={20} />,
|
|
label: t`Close DM`,
|
|
onClick: handleCloseDM,
|
|
danger: true,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
if (isGroupDM) {
|
|
groups.push({
|
|
items: [
|
|
{
|
|
id: 'leave-group',
|
|
icon: <LeaveIcon size={20} />,
|
|
label: t`Leave Group`,
|
|
onClick: handleLeaveGroup,
|
|
danger: true,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
const miscItems: Array<MenuItemType> = [];
|
|
|
|
if (developerMode) {
|
|
miscItems.push({
|
|
id: 'debug-channel',
|
|
icon: <DebugMessageIcon size={20} />,
|
|
label: t`Debug Channel`,
|
|
onClick: handleDebugChannel,
|
|
});
|
|
|
|
if (isDM && recipient) {
|
|
miscItems.push({
|
|
id: 'debug-user',
|
|
icon: <DebugMessageIcon size={20} />,
|
|
label: t`Debug User`,
|
|
onClick: handleDebugUser,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (isDM && recipient) {
|
|
miscItems.push({
|
|
id: 'copy-user-id',
|
|
icon: <CopyIdIcon size={20} />,
|
|
label: t`Copy User ID`,
|
|
onClick: handleCopyUserId,
|
|
});
|
|
}
|
|
|
|
miscItems.push({
|
|
id: 'copy-channel-id',
|
|
icon: <CopyIdIcon size={20} />,
|
|
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,
|
|
])}
|
|
/>
|
|
|
|
<MenuBottomSheet
|
|
isOpen={notificationSheetOpen}
|
|
onClose={handleNotificationClose}
|
|
title={t`Notification Settings`}
|
|
groups={useMemo((): Array<MenuGroupType> => {
|
|
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<MenuRadioType>,
|
|
},
|
|
{
|
|
items: [
|
|
{
|
|
id: 'open-guild-settings',
|
|
icon: <SettingsIcon size={20} />,
|
|
label: t`Open Community Notification Settings`,
|
|
onClick: handleOpenGuildNotificationSettings,
|
|
},
|
|
] as Array<MenuItemType>,
|
|
},
|
|
];
|
|
}, [
|
|
guildId,
|
|
channel.id,
|
|
channel.parentId,
|
|
handleNotificationLevelChange,
|
|
handleOpenGuildNotificationSettings,
|
|
])}
|
|
/>
|
|
|
|
<ChannelSearchBottomSheet
|
|
isOpen={searchSheetOpen}
|
|
onClose={() => setSearchSheetOpen(false)}
|
|
channel={channel}
|
|
/>
|
|
|
|
{activeMemberSheet && guildId && (
|
|
<GuildMemberActionsSheet
|
|
isOpen={true}
|
|
onClose={handleCloseMemberSheet}
|
|
user={activeMemberSheet.user}
|
|
member={activeMemberSheet.member}
|
|
guildId={guildId}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
);
|