1456 lines
47 KiB
TypeScript
1456 lines
47 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 {Trans, useLingui} from '@lingui/react/macro';
|
|
|
|
import {
|
|
BellIcon,
|
|
BugIcon,
|
|
CaretDownIcon,
|
|
CaretRightIcon,
|
|
CaretUpIcon,
|
|
ChatCircleIcon,
|
|
CheckIcon,
|
|
CrownIcon,
|
|
DotsThreeVerticalIcon,
|
|
GearIcon,
|
|
MagnifyingGlassIcon,
|
|
PencilIcon,
|
|
PushPinIcon,
|
|
SignOutIcon,
|
|
StarIcon,
|
|
TicketIcon,
|
|
UserPlusIcon,
|
|
UsersIcon,
|
|
XIcon,
|
|
} from '@phosphor-icons/react';
|
|
|
|
import clsx from 'clsx';
|
|
import {observer} from 'mobx-react-lite';
|
|
import React from 'react';
|
|
|
|
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
|
|
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
|
import {modal} from '~/actions/ModalActionCreators';
|
|
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
|
|
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
|
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
|
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
|
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
|
|
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
|
|
|
|
import {ChannelTypes, isOfflineStatus, ME, MessageNotifications, Permissions} from '~/Constants';
|
|
|
|
import {DMCloseFailedModal} from '~/components/alerts/DMCloseFailedModal';
|
|
import {ChannelSearchBottomSheet} from '~/components/bottomsheets/ChannelSearchBottomSheet';
|
|
import {createMuteConfig, getMuteDurationOptions} from '~/components/channel/muteOptions';
|
|
import {PreloadableUserPopout} from '~/components/channel/PreloadableUserPopout';
|
|
import {UserTag} from '~/components/channel/UserTag';
|
|
import {CustomStatusDisplay} from '~/components/common/CustomStatusDisplay/CustomStatusDisplay';
|
|
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
|
|
import {ChannelDebugModal} from '~/components/debug/ChannelDebugModal';
|
|
import {UserDebugModal} from '~/components/debug/UserDebugModal';
|
|
import {LongPressable} from '~/components/LongPressable';
|
|
import {AddFriendsToGroupModal} from '~/components/modals/AddFriendsToGroupModal';
|
|
import {ChannelSettingsModal} from '~/components/modals/ChannelSettingsModal';
|
|
import {ChannelTopicModal} from '~/components/modals/ChannelTopicModal';
|
|
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
|
import {CreateDMModal} from '~/components/modals/CreateDMModal';
|
|
import {EditGroupModal} from '~/components/modals/EditGroupModal';
|
|
import {GroupInvitesModal} from '~/components/modals/GroupInvitesModal';
|
|
import {GuildNotificationSettingsModal} from '~/components/modals/GuildNotificationSettingsModal';
|
|
import {GuildMemberActionsSheet} from '~/components/modals/guildTabs/GuildMemberActionsSheet';
|
|
import {InviteModal} from '~/components/modals/InviteModal';
|
|
import {ChannelPinsContent} from '~/components/shared/ChannelPinsContent';
|
|
import {
|
|
CopyIdIcon,
|
|
CopyLinkIcon,
|
|
DeleteIcon,
|
|
EditIcon,
|
|
InviteIcon,
|
|
MarkAsReadIcon,
|
|
} from '~/components/uikit/ContextMenu/ContextMenuIcons';
|
|
import {
|
|
MenuBottomSheet,
|
|
type MenuGroupType,
|
|
type MenuItemType,
|
|
type MenuRadioType,
|
|
} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
|
|
import {Scroller} from '~/components/uikit/Scroller';
|
|
import * as Sheet from '~/components/uikit/Sheet/Sheet';
|
|
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
|
|
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
|
|
|
import {useLeaveGroup} from '~/hooks/useLeaveGroup';
|
|
import {useMemberListSubscription} from '~/hooks/useMemberListSubscription';
|
|
import {usePressable} from '~/hooks/usePressable';
|
|
|
|
import {SafeMarkdown} from '~/lib/markdown';
|
|
import {MarkdownContext} from '~/lib/markdown/renderers';
|
|
|
|
import {Routes} from '~/Routes';
|
|
|
|
import type {ChannelRecord} from '~/records/ChannelRecord';
|
|
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
|
|
import type {GuildRecord} from '~/records/GuildRecord';
|
|
import type {UserRecord} from '~/records/UserRecord';
|
|
|
|
import AccessibilityStore from '~/stores/AccessibilityStore';
|
|
import AuthenticationStore from '~/stores/AuthenticationStore';
|
|
import FavoritesStore from '~/stores/FavoritesStore';
|
|
import GuildStore from '~/stores/GuildStore';
|
|
import MemberSidebarStore from '~/stores/MemberSidebarStore';
|
|
import PermissionStore from '~/stores/PermissionStore';
|
|
import PresenceStore from '~/stores/PresenceStore';
|
|
import ReadStateStore from '~/stores/ReadStateStore';
|
|
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
|
import TypingStore from '~/stores/TypingStore';
|
|
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
|
import UserSettingsStore from '~/stores/UserSettingsStore';
|
|
import UserStore from '~/stores/UserStore';
|
|
|
|
import markupStyles from '~/styles/Markup.module.css';
|
|
|
|
import * as ChannelUtils from '~/utils/ChannelUtils';
|
|
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
|
|
import {MAX_GROUP_DM_RECIPIENTS} from '~/utils/groupDmUtils';
|
|
import * as InviteUtils from '~/utils/InviteUtils';
|
|
import * as MemberListUtils from '~/utils/MemberListUtils';
|
|
import {buildChannelLink} from '~/utils/messageLinkUtils';
|
|
import * as NicknameUtils from '~/utils/NicknameUtils';
|
|
import * as RouterUtils from '~/utils/RouterUtils';
|
|
|
|
import styles from './ChannelDetailsBottomSheet.module.css';
|
|
|
|
const MEMBER_ITEM_HEIGHT = 56;
|
|
const INITIAL_MEMBER_RANGE: [number, number] = [0, 99];
|
|
const SCROLL_BUFFER = 50;
|
|
|
|
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 = PresenceStore.getStatus(member.user.id);
|
|
|
|
const handleLongPress = React.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}
|
|
/>
|
|
<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`}>
|
|
<CrownIcon className={styles.crownIcon} />
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
{member.user.bot && <UserTag className={styles.memberTag} system={member.user.system} />}
|
|
</div>
|
|
{!member.user.bot && (
|
|
<CustomStatusDisplay
|
|
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 [subscribedRange, setSubscribedRange] = React.useState<[number, number]>(INITIAL_MEMBER_RANGE);
|
|
|
|
const {subscribe} = useMemberListSubscription({
|
|
guildId: guild.id,
|
|
channelId: channel.id,
|
|
enabled,
|
|
allowInitialUnfocusedLoad: true,
|
|
});
|
|
|
|
const memberListState = MemberSidebarStore.getList(guild.id, channel.id);
|
|
const isLoading = !memberListState || memberListState.items.size === 0;
|
|
|
|
const handleScroll = React.useCallback(
|
|
(event: React.UIEvent<HTMLDivElement>) => {
|
|
const target = event.currentTarget;
|
|
const scrollTop = target.scrollTop;
|
|
const clientHeight = target.clientHeight;
|
|
|
|
const startIndex = Math.max(0, Math.floor(scrollTop / MEMBER_ITEM_HEIGHT) - SCROLL_BUFFER);
|
|
const endIndex = Math.ceil((scrollTop + clientHeight) / MEMBER_ITEM_HEIGHT) + SCROLL_BUFFER;
|
|
|
|
if (startIndex !== subscribedRange[0] || endIndex !== subscribedRange[1]) {
|
|
const newRange: [number, number] = [startIndex, endIndex];
|
|
setSubscribedRange(newRange);
|
|
subscribe([newRange]);
|
|
}
|
|
},
|
|
[subscribedRange, subscribe],
|
|
);
|
|
|
|
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 groups = memberListState.groups;
|
|
const seenMemberIds = new Set<string>();
|
|
|
|
for (const group of groups) {
|
|
groupedItems.set(group.id, []);
|
|
}
|
|
|
|
let currentGroup: string | null = null;
|
|
const sortedItems = Array.from(memberListState.items.entries()).sort(([a], [b]) => a - b);
|
|
for (const [, item] of sortedItems) {
|
|
if (item.type === 'group') {
|
|
currentGroup = (item.data as {id: string}).id;
|
|
} else if (item.type === 'member' && currentGroup) {
|
|
const member = item.data as GuildMemberRecord;
|
|
if (!seenMemberIds.has(member.user.id)) {
|
|
seenMemberIds.add(member.user.id);
|
|
groupedItems.get(currentGroup)?.push(member);
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={styles.memberListContent} onScroll={handleScroll}>
|
|
{groups.map((group) => {
|
|
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] = React.useState<ChannelDetailsTab>(initialTab);
|
|
const [muteSheetOpen, setMuteSheetOpen] = React.useState(false);
|
|
const [searchSheetOpen, setSearchSheetOpen] = React.useState(false);
|
|
const [isTopicExpanded, setIsTopicExpanded] = React.useState(false);
|
|
const [moreOptionsSheetOpen, setMoreOptionsSheetOpen] = React.useState(false);
|
|
const [notificationSheetOpen, setNotificationSheetOpen] = React.useState(false);
|
|
const [activeMemberSheet, setActiveMemberSheet] = React.useState<{
|
|
member: GuildMemberRecord;
|
|
user: UserRecord;
|
|
} | null>(null);
|
|
|
|
const leaveGroup = useLeaveGroup();
|
|
|
|
React.useEffect(() => {
|
|
setActiveTab(initialTab);
|
|
}, [initialTab]);
|
|
|
|
React.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 = React.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 handleSearchClick = () => {
|
|
setSearchSheetOpen(true);
|
|
};
|
|
|
|
const handleBellClick = () => {
|
|
setMuteSheetOpen(true);
|
|
};
|
|
|
|
const handleCogClick = () => {
|
|
setMoreOptionsSheetOpen(true);
|
|
};
|
|
|
|
const handleMoreOptionsClose = () => {
|
|
setMoreOptionsSheetOpen(false);
|
|
};
|
|
|
|
const handleNotificationClose = () => {
|
|
setNotificationSheetOpen(false);
|
|
};
|
|
|
|
const handleMarkAsRead = React.useCallback(() => {
|
|
ReadStateActionCreators.ack(channel.id, true, true);
|
|
ToastActionCreators.createToast({type: 'success', children: t`Marked as read`});
|
|
}, [channel.id]);
|
|
|
|
const handleInvite = React.useCallback(() => {
|
|
ModalActionCreators.push(modal(() => <InviteModal channelId={channel.id} />));
|
|
onClose();
|
|
}, [channel.id, onClose]);
|
|
|
|
const handleCopyLink = React.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 = React.useCallback(() => {
|
|
TextCopyActionCreators.copy(i18n, channel.id);
|
|
ToastActionCreators.createToast({type: 'success', children: t`Channel ID copied to clipboard`});
|
|
}, [channel.id, i18n]);
|
|
|
|
const handleToggleFavorite = React.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 = React.useCallback(() => {
|
|
const channelName = channel.name ?? t`Channel`;
|
|
ModalActionCreators.push(modal(() => <ChannelDebugModal title={channelName} channel={channel} />));
|
|
onClose();
|
|
}, [channel, onClose]);
|
|
|
|
const handleDebugUser = React.useCallback(() => {
|
|
if (!recipient) return;
|
|
ModalActionCreators.push(modal(() => <UserDebugModal title={recipient.username} user={recipient} />));
|
|
onClose();
|
|
}, [recipient, onClose]);
|
|
|
|
const handlePinDM = React.useCallback(async () => {
|
|
handleMoreOptionsClose();
|
|
try {
|
|
await PrivateChannelActionCreators.pinDmChannel(channel.id);
|
|
ToastActionCreators.createToast({
|
|
type: 'success',
|
|
children: isGroupDM ? t`Pinned group` : t`Pinned DM`,
|
|
});
|
|
} catch (error) {
|
|
console.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 = React.useCallback(async () => {
|
|
handleMoreOptionsClose();
|
|
try {
|
|
await PrivateChannelActionCreators.unpinDmChannel(channel.id);
|
|
ToastActionCreators.createToast({
|
|
type: 'success',
|
|
children: isGroupDM ? t`Unpinned group` : t`Unpinned DM`,
|
|
});
|
|
} catch (error) {
|
|
console.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 = React.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) {
|
|
console.error('Failed to close DM:', error);
|
|
ModalActionCreators.push(modal(() => <DMCloseFailedModal />));
|
|
}
|
|
}}
|
|
/>
|
|
)),
|
|
);
|
|
}, [channel.id, recipient, onClose]);
|
|
|
|
const handleLeaveGroup = React.useCallback(() => {
|
|
handleMoreOptionsClose();
|
|
onClose();
|
|
leaveGroup(channel.id);
|
|
}, [channel.id, onClose, leaveGroup]);
|
|
|
|
const handleEditGroup = React.useCallback(() => {
|
|
handleMoreOptionsClose();
|
|
onClose();
|
|
ModalActionCreators.push(modal(() => <EditGroupModal channelId={channel.id} />));
|
|
}, [channel.id, onClose]);
|
|
|
|
const handleShowInvites = React.useCallback(() => {
|
|
handleMoreOptionsClose();
|
|
onClose();
|
|
ModalActionCreators.push(modal(() => <GroupInvitesModal channelId={channel.id} />));
|
|
}, [channel.id, onClose]);
|
|
|
|
const handleOpenAddFriendsToGroup = React.useCallback(() => {
|
|
handleMoreOptionsClose();
|
|
onClose();
|
|
ModalActionCreators.push(modal(() => <AddFriendsToGroupModal channelId={channel.id} />));
|
|
}, [channel.id, onClose]);
|
|
|
|
const handleCopyUserId = React.useCallback(() => {
|
|
if (!recipient) return;
|
|
TextCopyActionCreators.copy(i18n, recipient.id);
|
|
ToastActionCreators.createToast({type: 'success', children: t`User ID copied to clipboard`});
|
|
}, [recipient, i18n]);
|
|
|
|
const handleEditChannel = React.useCallback(() => {
|
|
ModalActionCreators.push(modal(() => <ChannelSettingsModal channelId={channel.id} />));
|
|
onClose();
|
|
}, [channel.id, onClose]);
|
|
|
|
const handleDeleteChannel = React.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) {
|
|
console.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 = React.useCallback(() => {
|
|
if (!guildId) return;
|
|
ModalActionCreators.push(modal(() => <GuildNotificationSettingsModal guildId={guildId} />));
|
|
}, [guildId]);
|
|
|
|
const handleOpenCreateGroupModal = React.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 = React.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 handleMemberLongPress = React.useCallback((member: GuildMemberRecord) => {
|
|
setActiveMemberSheet({member, user: member.user});
|
|
}, []);
|
|
|
|
const handleCloseMemberSheet = React.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 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}>
|
|
{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 ? (
|
|
<CaretUpIcon className={styles.iconSmall} weight="bold" />
|
|
) : (
|
|
<CaretDownIcon className={styles.iconSmall} weight="bold" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.quickActionsRow}>
|
|
<div className={styles.quickActionsScroll}>
|
|
<QuickActionButton
|
|
icon={<BellIcon weight={isMuted ? 'regular' : 'fill'} size={20} />}
|
|
label={isMuted ? t`Unmute` : t`Mute`}
|
|
onClick={handleBellClick}
|
|
isActive={isMuted}
|
|
/>
|
|
|
|
<QuickActionButton
|
|
icon={<MagnifyingGlassIcon weight="bold" size={20} />}
|
|
label={t`Search`}
|
|
onClick={handleSearchClick}
|
|
/>
|
|
|
|
<QuickActionButton
|
|
icon={<DotsThreeVerticalIcon weight="bold" 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}
|
|
>
|
|
<UsersIcon 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}
|
|
>
|
|
<PushPinIcon 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}>
|
|
<ChatCircleIcon 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>
|
|
<CaretRightIcon className={styles.iconMedium} weight="bold" />
|
|
</button>
|
|
)}
|
|
|
|
<div className={styles.membersHeader}>
|
|
<Trans>Members</Trans> — {dmMemberGroups.reduce((total, group) => total + group.count, 0)}
|
|
</div>
|
|
<div className={styles.membersListContainer}>
|
|
{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`}>
|
|
<CrownIcon className={styles.ownerCrown} weight="fill" />
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
{index < group.users.length - 1 && <div className={styles.memberItemDivider} />}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</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>
|
|
|
|
<Sheet.Root isOpen={muteSheetOpen} onClose={() => setMuteSheetOpen(false)} snapPoints={[0, 1]} initialSnap={1}>
|
|
<Sheet.Handle />
|
|
<Sheet.Header trailing={<Sheet.CloseButton onClick={() => setMuteSheetOpen(false)} />}>
|
|
<Sheet.Title>
|
|
{(() => {
|
|
if (isMuted) {
|
|
if (isGuildChannel) {
|
|
return t`Unmute Channel`;
|
|
}
|
|
return t`Unmute Conversation`;
|
|
}
|
|
if (isGuildChannel) {
|
|
return t`Mute Channel`;
|
|
}
|
|
return t`Mute Conversation`;
|
|
})()}
|
|
</Sheet.Title>
|
|
</Sheet.Header>
|
|
<Sheet.Content padding="none">
|
|
<div className={styles.muteSheetContainer}>
|
|
<div className={styles.muteSheetContent}>
|
|
{isMuted && mutedText ? (
|
|
<>
|
|
<div className={styles.muteStatusBanner}>
|
|
<p className={styles.muteStatusText}>
|
|
<Trans>Currently: {mutedText}</Trans>
|
|
</p>
|
|
</div>
|
|
<div className={styles.muteOptionsContainer}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
UserGuildSettingsActionCreators.updateChannelOverride(
|
|
settingsGuildId,
|
|
channel.id,
|
|
{
|
|
muted: false,
|
|
mute_config: null,
|
|
},
|
|
{persistImmediately: true},
|
|
);
|
|
setMuteSheetOpen(false);
|
|
}}
|
|
className={styles.muteOptionButton}
|
|
>
|
|
<span className={styles.muteOptionLabel}>
|
|
<Trans>Unmute</Trans>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className={styles.muteOptionsContainer}>
|
|
{getMuteDurationOptions(t).map((option, index, array) => {
|
|
const isSelected =
|
|
isMuted &&
|
|
((option.value === null && !muteConfig?.end_time) ||
|
|
(option.value !== null && muteConfig?.selected_time_window === option.value));
|
|
|
|
return (
|
|
<React.Fragment key={option.label}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
UserGuildSettingsActionCreators.updateChannelOverride(
|
|
settingsGuildId,
|
|
channel.id,
|
|
{
|
|
muted: true,
|
|
mute_config: createMuteConfig(option.value),
|
|
},
|
|
{persistImmediately: true},
|
|
);
|
|
setMuteSheetOpen(false);
|
|
}}
|
|
className={styles.muteOptionButton}
|
|
>
|
|
<span className={styles.muteOptionLabel}>{option.label}</span>
|
|
{isSelected && <CheckIcon className={styles.iconMedium} weight="bold" />}
|
|
</button>
|
|
{index < array.length - 1 && <div className={styles.muteOptionDivider} />}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Sheet.Content>
|
|
</Sheet.Root>
|
|
|
|
<MenuBottomSheet
|
|
isOpen={moreOptionsSheetOpen}
|
|
onClose={handleMoreOptionsClose}
|
|
title={isGroupDM ? t`Group Settings` : isDM ? t`DM Settings` : t`Channel Settings`}
|
|
groups={React.useMemo(() => {
|
|
const groups: Array<MenuGroupType> = [];
|
|
const hasUnread = ReadStateStore.hasUnread(channel.id);
|
|
|
|
const commonItems: Array<MenuItemType> = [];
|
|
|
|
if (showFavorites && !isPersonalNotes) {
|
|
commonItems.push({
|
|
id: 'favorite',
|
|
icon: <StarIcon weight={isFavorited ? 'fill' : 'regular'} 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: <PushPinIcon weight="fill" size={20} />,
|
|
label: isGroupDM ? t`Unpin Group DM` : t`Unpin DM`,
|
|
onClick: handleUnpinDM,
|
|
}
|
|
: {
|
|
id: 'pin',
|
|
icon: <PushPinIcon weight="fill" 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: <BellIcon weight="fill" 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: <PencilIcon weight="fill" size={20} />,
|
|
label: t`Edit Group`,
|
|
onClick: handleEditGroup,
|
|
},
|
|
];
|
|
|
|
if (channel.recipientIds.length + 1 < MAX_GROUP_DM_RECIPIENTS) {
|
|
groupItems.push({
|
|
id: 'add-friends',
|
|
icon: <UserPlusIcon weight="fill" size={20} />,
|
|
label: t`Add Friends to Group`,
|
|
onClick: () => {
|
|
handleMoreOptionsClose();
|
|
ModalActionCreators.push(modal(() => <AddFriendsToGroupModal channelId={channel.id} />));
|
|
},
|
|
});
|
|
}
|
|
|
|
if (isGroupDMOwner) {
|
|
groupItems.push({
|
|
id: 'invites',
|
|
icon: <TicketIcon weight="fill" 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: <XIcon weight="bold" size={20} />,
|
|
label: t`Close DM`,
|
|
onClick: handleCloseDM,
|
|
danger: true,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
if (isGroupDM) {
|
|
groups.push({
|
|
items: [
|
|
{
|
|
id: 'leave-group',
|
|
icon: <SignOutIcon weight="fill" size={20} />,
|
|
label: t`Leave Group`,
|
|
onClick: handleLeaveGroup,
|
|
danger: true,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
const miscItems: Array<MenuItemType> = [];
|
|
|
|
if (developerMode) {
|
|
miscItems.push({
|
|
id: 'debug-channel',
|
|
icon: <BugIcon weight="fill" size={20} />,
|
|
label: t`Debug Channel`,
|
|
onClick: handleDebugChannel,
|
|
});
|
|
|
|
if (isDM && recipient) {
|
|
miscItems.push({
|
|
id: 'debug-user',
|
|
icon: <BugIcon weight="fill" 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={React.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: <GearIcon weight="bold" 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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
);
|