initial commit
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {PhoneIcon, PhoneXIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import type {PressEvent} from 'react-aria-components';
|
||||
import * as CallActionCreators from '~/actions/CallActionCreators';
|
||||
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import CallStateStore from '~/stores/CallStateStore';
|
||||
import * as CallUtils from '~/utils/CallUtils';
|
||||
import {VideoCallIcon, VoiceCallIcon} from '../ContextMenuIcons';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
import styles from './MenuItems.module.css';
|
||||
|
||||
interface StartVoiceCallMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const StartVoiceCallMenuItem: React.FC<StartVoiceCallMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleStartVoiceCall = React.useCallback(
|
||||
async (event: PressEvent) => {
|
||||
onClose();
|
||||
try {
|
||||
const channelId = await PrivateChannelActionCreators.ensureDMChannel(user.id);
|
||||
await CallUtils.checkAndStartCall(channelId, event.shiftKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to start voice call:', error);
|
||||
}
|
||||
},
|
||||
[user.id, onClose],
|
||||
);
|
||||
|
||||
if (user.bot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem icon={<VoiceCallIcon />} onClick={handleStartVoiceCall}>
|
||||
{t`Start Voice Call`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface StartVideoCallMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const StartVideoCallMenuItem: React.FC<StartVideoCallMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleStartVideoCall = React.useCallback(
|
||||
async (event: PressEvent) => {
|
||||
onClose();
|
||||
try {
|
||||
const channelId = await PrivateChannelActionCreators.ensureDMChannel(user.id);
|
||||
await CallUtils.checkAndStartCall(channelId, event.shiftKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to start video call:', error);
|
||||
}
|
||||
},
|
||||
[user.id, onClose],
|
||||
);
|
||||
|
||||
if (user.bot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem icon={<VideoCallIcon />} onClick={handleStartVideoCall}>
|
||||
{t`Start Video Call`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface RingUserMenuItemProps {
|
||||
userId: string;
|
||||
channelId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const RingUserMenuItem: React.FC<RingUserMenuItemProps> = observer(({userId, channelId, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const call = CallStateStore.getCall(channelId);
|
||||
const participants = call ? CallStateStore.getParticipants(channelId) : [];
|
||||
const isInCall = participants.includes(userId);
|
||||
const isRinging = call?.ringing.includes(userId) ?? false;
|
||||
|
||||
const handleRing = React.useCallback(async () => {
|
||||
onClose();
|
||||
try {
|
||||
await CallActionCreators.ringParticipants(channelId, [userId]);
|
||||
} catch (error) {
|
||||
console.error('Failed to ring user:', error);
|
||||
}
|
||||
}, [channelId, userId, onClose]);
|
||||
|
||||
const handleStopRinging = React.useCallback(async () => {
|
||||
onClose();
|
||||
try {
|
||||
await CallActionCreators.stopRingingParticipants(channelId, [userId]);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop ringing user:', error);
|
||||
}
|
||||
}, [channelId, userId, onClose]);
|
||||
|
||||
if (!call || isInCall) return null;
|
||||
|
||||
if (isRinging) {
|
||||
return (
|
||||
<MenuItem icon={<PhoneXIcon weight="fill" className={styles.icon} />} onClick={handleStopRinging}>
|
||||
{t`Stop Ringing`}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem icon={<PhoneIcon weight="fill" className={styles.icon} />} onClick={handleRing}>
|
||||
{t`Ring`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
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 ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
|
||||
import {ChannelTypes, MessageNotifications, Permissions} from '~/Constants';
|
||||
import {ChannelSettingsModal} from '~/components/modals/ChannelSettingsModal';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
|
||||
import {
|
||||
CopyIdIcon,
|
||||
DeleteIcon,
|
||||
MarkAsReadIcon,
|
||||
MuteIcon,
|
||||
NotificationSettingsIcon,
|
||||
SettingsIcon,
|
||||
} from '../ContextMenuIcons';
|
||||
import {MenuGroup} from '../MenuGroup';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
import menuItemStyles from '../MenuItem.module.css';
|
||||
import {MenuItemCheckbox} from '../MenuItemCheckbox';
|
||||
import {MenuItemRadio} from '../MenuItemRadio';
|
||||
import {MenuItemSubmenu} from '../MenuItemSubmenu';
|
||||
import itemStyles from './MenuItems.module.css';
|
||||
|
||||
interface CategoryMenuItemProps {
|
||||
category: ChannelRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MarkCategoryAsReadMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const guildId = category.guildId!;
|
||||
const channels = ChannelStore.getGuildChannels(guildId);
|
||||
|
||||
const channelsInCategory = React.useMemo(
|
||||
() => channels.filter((ch) => ch.parentId === category.id && ch.type !== ChannelTypes.GUILD_CATEGORY),
|
||||
[channels, category.id],
|
||||
);
|
||||
|
||||
const hasUnread = channelsInCategory.some((ch) => ReadStateStore.hasUnread(ch.id));
|
||||
|
||||
const handleMarkAsRead = React.useCallback(() => {
|
||||
for (const channel of channelsInCategory) {
|
||||
ReadStateActionCreators.ack(channel.id, true, true);
|
||||
}
|
||||
onClose();
|
||||
}, [channelsInCategory, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<MarkAsReadIcon />} onClick={handleMarkAsRead} disabled={!hasUnread}>
|
||||
{t`Mark as Read`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const CollapseCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const guildId = category.guildId!;
|
||||
const isCollapsed = UserGuildSettingsStore.isChannelCollapsed(guildId, category.id);
|
||||
|
||||
const handleToggleCollapse = React.useCallback(() => {
|
||||
UserGuildSettingsActionCreators.toggleChannelCollapsed(guildId, category.id);
|
||||
onClose();
|
||||
}, [guildId, category.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItemCheckbox checked={isCollapsed} onChange={handleToggleCollapse}>{t`Collapse Category`}</MenuItemCheckbox>
|
||||
);
|
||||
});
|
||||
|
||||
export const CollapseAllCategoriesMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const guildId = category.guildId!;
|
||||
const channels = ChannelStore.getGuildChannels(guildId);
|
||||
|
||||
const categoryIds = React.useMemo(
|
||||
() => channels.filter((ch) => ch.type === ChannelTypes.GUILD_CATEGORY).map((ch) => ch.id),
|
||||
[channels],
|
||||
);
|
||||
|
||||
const allCategoriesCollapsed = React.useMemo(() => {
|
||||
if (categoryIds.length === 0) return false;
|
||||
return categoryIds.every((categoryId) => UserGuildSettingsStore.isChannelCollapsed(guildId, categoryId));
|
||||
}, [guildId, categoryIds]);
|
||||
|
||||
const handleToggleCollapseAll = React.useCallback(() => {
|
||||
UserGuildSettingsActionCreators.toggleAllCategoriesCollapsed(guildId, categoryIds);
|
||||
onClose();
|
||||
}, [guildId, categoryIds, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItemCheckbox checked={allCategoriesCollapsed} onChange={handleToggleCollapseAll}>
|
||||
{t`Collapse All Categories`}
|
||||
</MenuItemCheckbox>
|
||||
);
|
||||
});
|
||||
|
||||
interface MuteDuration {
|
||||
label: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export const MuteCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const guildId = category.guildId!;
|
||||
const categoryOverride = UserGuildSettingsStore.getChannelOverride(guildId, category.id);
|
||||
const isMuted = categoryOverride?.muted ?? false;
|
||||
const muteConfig = categoryOverride?.mute_config;
|
||||
|
||||
const getMuteDurations = React.useCallback(
|
||||
(): Array<MuteDuration> => [
|
||||
{label: t`For 15 Minutes`, value: 15 * 60 * 1000},
|
||||
{label: t`For 1 Hour`, value: 60 * 60 * 1000},
|
||||
{label: t`For 3 Hours`, value: 3 * 60 * 60 * 1000},
|
||||
{label: t`For 8 Hours`, value: 8 * 60 * 60 * 1000},
|
||||
{label: t`For 24 Hours`, value: 24 * 60 * 60 * 1000},
|
||||
{label: t`Until I turn it back on`, value: null},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const mutedText = getMutedText(isMuted, muteConfig);
|
||||
|
||||
const handleMute = React.useCallback(
|
||||
(duration: number | null) => {
|
||||
const nextMuteConfig = duration
|
||||
? {
|
||||
selected_time_window: duration,
|
||||
end_time: new Date(Date.now() + duration).toISOString(),
|
||||
}
|
||||
: null;
|
||||
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(guildId, category.id, {
|
||||
muted: true,
|
||||
mute_config: nextMuteConfig,
|
||||
collapsed: true,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
[guildId, category.id, onClose],
|
||||
);
|
||||
|
||||
const handleUnmute = React.useCallback(() => {
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(guildId, category.id, {
|
||||
muted: false,
|
||||
mute_config: null,
|
||||
});
|
||||
onClose();
|
||||
}, [guildId, category.id, onClose]);
|
||||
|
||||
if (isMuted && mutedText) {
|
||||
return (
|
||||
<MenuItem icon={<MuteIcon />} onClick={handleUnmute} hint={mutedText}>
|
||||
{t`Unmute Category`}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={t`Mute Category`}
|
||||
icon={<MuteIcon />}
|
||||
onTriggerSelect={() => handleMute(null)}
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
{getMuteDurations().map((duration) => (
|
||||
<MenuItem key={duration.label} onClick={() => handleMute(duration.value)}>
|
||||
{duration.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const CategoryNotificationSettingsMenuItem: React.FC<CategoryMenuItemProps> = observer(({category}) => {
|
||||
const {t} = useLingui();
|
||||
const guildId = category.guildId!;
|
||||
const categoryNotifications = UserGuildSettingsStore.getChannelOverride(guildId, category.id)?.message_notifications;
|
||||
const currentNotificationLevel = categoryNotifications ?? MessageNotifications.INHERIT;
|
||||
|
||||
const guildNotificationLevel = UserGuildSettingsStore.getGuildMessageNotifications(guildId);
|
||||
|
||||
const effectiveCurrentNotificationLevel =
|
||||
currentNotificationLevel === MessageNotifications.INHERIT ? guildNotificationLevel : currentNotificationLevel;
|
||||
|
||||
const currentStateText = getNotificationSettingsLabel(effectiveCurrentNotificationLevel);
|
||||
|
||||
const defaultLabelParts = {
|
||||
main: t`Community Default`,
|
||||
sub: getNotificationSettingsLabel(guildNotificationLevel) ?? null,
|
||||
};
|
||||
|
||||
const handleNotificationLevelChange = React.useCallback(
|
||||
(level: number) => {
|
||||
if (level === MessageNotifications.INHERIT) {
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(guildId, category.id, {
|
||||
message_notifications: MessageNotifications.INHERIT,
|
||||
});
|
||||
} else {
|
||||
UserGuildSettingsActionCreators.updateMessageNotifications(guildId, level, category.id);
|
||||
}
|
||||
},
|
||||
[guildId, category.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={t`Notification Settings`}
|
||||
icon={<NotificationSettingsIcon />}
|
||||
hint={currentStateText}
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
<MenuItemRadio
|
||||
selected={currentNotificationLevel === MessageNotifications.INHERIT}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.INHERIT)}
|
||||
>
|
||||
<div className={itemStyles.flexColumn}>
|
||||
<span>{defaultLabelParts.main}</span>
|
||||
{defaultLabelParts.sub && <div className={menuItemStyles.subtext}>{defaultLabelParts.sub}</div>}
|
||||
</div>
|
||||
</MenuItemRadio>
|
||||
|
||||
<MenuItemRadio
|
||||
selected={currentNotificationLevel === MessageNotifications.ALL_MESSAGES}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.ALL_MESSAGES)}
|
||||
>
|
||||
{t`All Messages`}
|
||||
</MenuItemRadio>
|
||||
|
||||
<MenuItemRadio
|
||||
selected={currentNotificationLevel === MessageNotifications.ONLY_MENTIONS}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.ONLY_MENTIONS)}
|
||||
>
|
||||
{t`Only @mentions`}
|
||||
</MenuItemRadio>
|
||||
|
||||
<MenuItemRadio
|
||||
selected={currentNotificationLevel === MessageNotifications.NO_MESSAGES}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.NO_MESSAGES)}
|
||||
>
|
||||
{t`Nothing`}
|
||||
</MenuItemRadio>
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const EditCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const guildId = category.guildId!;
|
||||
|
||||
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
|
||||
channelId: category.id,
|
||||
guildId,
|
||||
});
|
||||
|
||||
const handleEditCategory = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <ChannelSettingsModal channelId={category.id} />));
|
||||
onClose();
|
||||
}, [category.id, onClose]);
|
||||
|
||||
if (!canManageChannels) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<SettingsIcon />} onClick={handleEditCategory}>
|
||||
{t`Edit Category`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const DeleteCategoryMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const guildId = category.guildId!;
|
||||
|
||||
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
|
||||
channelId: category.id,
|
||||
guildId,
|
||||
});
|
||||
|
||||
const channels = ChannelStore.getGuildChannels(guildId);
|
||||
const channelsInCategory = React.useMemo(
|
||||
() => channels.filter((ch) => ch.parentId === category.id && ch.type !== ChannelTypes.GUILD_CATEGORY),
|
||||
[channels, category.id],
|
||||
);
|
||||
|
||||
const handleDeleteCategory = React.useCallback(() => {
|
||||
onClose();
|
||||
|
||||
const categoryName = category.name ?? '';
|
||||
const channelCount = channelsInCategory.length;
|
||||
const hasChannels = channelCount > 0;
|
||||
|
||||
const description = hasChannels
|
||||
? channelCount === 1
|
||||
? t`Are you sure you want to delete the category "${categoryName}"? All ${channelCount} channel inside will be moved to the top of the channel list.`
|
||||
: t`Are you sure you want to delete the category "${categoryName}"? All ${channelCount} channels inside will be moved to the top of the channel list.`
|
||||
: t`Are you sure you want to delete the category "${categoryName}"? This cannot be undone.`;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Delete Category`}
|
||||
description={description}
|
||||
primaryText={t`Delete Category`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
await ChannelActionCreators.remove(category.id);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`Category deleted`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete category:', error);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: t`Failed to delete category`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}, [category.id, category.name, channelsInCategory.length, onClose, t]);
|
||||
|
||||
if (!canManageChannels) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<DeleteIcon />} onClick={handleDeleteCategory} danger>
|
||||
{t`Delete Category`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const CopyCategoryIdMenuItem: React.FC<CategoryMenuItemProps> = observer(({category, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
|
||||
const handleCopyId = React.useCallback(() => {
|
||||
TextCopyActionCreators.copy(i18n, category.id);
|
||||
onClose();
|
||||
}, [category.id, onClose, i18n]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CopyIdIcon />} onClick={handleCopyId}>
|
||||
{t`Copy Category ID`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {StarIcon} from '@phosphor-icons/react';
|
||||
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 ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
|
||||
import {ChannelTypes, ME, MessageNotifications, Permissions} from '~/Constants';
|
||||
import {createMuteConfig, getMuteDurationOptions} from '~/components/channel/muteOptions';
|
||||
import {ChannelSettingsModal} from '~/components/modals/ChannelSettingsModal';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {GuildNotificationSettingsModal} from '~/components/modals/GuildNotificationSettingsModal';
|
||||
import {InviteModal} from '~/components/modals/InviteModal';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import FavoritesStore from '~/stores/FavoritesStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
import {buildChannelLink} from '~/utils/messageLinkUtils';
|
||||
import {
|
||||
CopyIdIcon,
|
||||
CopyLinkIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
InviteIcon,
|
||||
MarkAsReadIcon,
|
||||
MuteIcon,
|
||||
NotificationSettingsIcon,
|
||||
} from '../ContextMenuIcons';
|
||||
import {MenuGroup} from '../MenuGroup';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
import menuItemStyles from '../MenuItem.module.css';
|
||||
import {MenuItemRadio} from '../MenuItemRadio';
|
||||
import {MenuItemSubmenu} from '../MenuItemSubmenu';
|
||||
import itemStyles from './MenuItems.module.css';
|
||||
|
||||
interface ChannelMenuItemProps {
|
||||
channel: ChannelRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MarkChannelAsReadMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const hasUnread = ReadStateStore.hasUnread(channel.id);
|
||||
|
||||
const handleMarkAsRead = React.useCallback(() => {
|
||||
ReadStateActionCreators.ack(channel.id, true, true);
|
||||
onClose();
|
||||
}, [channel.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<MarkAsReadIcon />} onClick={handleMarkAsRead} disabled={!hasUnread}>
|
||||
{t`Mark as Read`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const InvitePeopleToChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
|
||||
|
||||
const handleInvite = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <InviteModal channelId={channel.id} />));
|
||||
onClose();
|
||||
}, [channel.id, onClose]);
|
||||
|
||||
if (!canInvite) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<InviteIcon />} onClick={handleInvite}>
|
||||
{t`Invite People`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const CopyChannelLinkMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleCopyLink = React.useCallback(() => {
|
||||
const channelLink = buildChannelLink({
|
||||
guildId: channel.guildId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
TextCopyActionCreators.copy(i18n, channelLink);
|
||||
onClose();
|
||||
}, [channel.id, channel.guildId, onClose, i18n]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CopyLinkIcon />} onClick={handleCopyLink}>
|
||||
{t`Copy Link`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const MuteChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const guildId = channel.guildId!;
|
||||
const channelOverride = UserGuildSettingsStore.getChannelOverride(guildId, channel.id);
|
||||
const isMuted = channelOverride?.muted ?? false;
|
||||
const muteConfig = channelOverride?.mute_config;
|
||||
|
||||
const mutedText = getMutedText(isMuted, muteConfig);
|
||||
|
||||
const handleMute = React.useCallback(
|
||||
(duration: number | null) => {
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(
|
||||
channel.guildId!,
|
||||
channel.id,
|
||||
{
|
||||
muted: true,
|
||||
mute_config: createMuteConfig(duration),
|
||||
},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
[channel.guildId, channel.id, onClose],
|
||||
);
|
||||
|
||||
const handleUnmute = React.useCallback(() => {
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(
|
||||
channel.guildId!,
|
||||
channel.id,
|
||||
{
|
||||
muted: false,
|
||||
mute_config: null,
|
||||
},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
onClose();
|
||||
}, [channel.guildId, channel.id, onClose]);
|
||||
|
||||
if (isMuted) {
|
||||
return (
|
||||
<MenuItem icon={<MuteIcon />} onClick={handleUnmute} hint={mutedText ?? undefined}>
|
||||
{t`Unmute Channel`}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={t`Mute Channel`}
|
||||
icon={<MuteIcon />}
|
||||
onTriggerSelect={() => handleMute(null)}
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
{getMuteDurationOptions(t).map((option) => (
|
||||
<MenuItem key={option.label} onClick={() => handleMute(option.value)}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const ChannelNotificationSettingsMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const guildId = channel.guildId;
|
||||
|
||||
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 handleOpenGuildNotificationSettings = React.useCallback(() => {
|
||||
if (!guildId) return;
|
||||
ModalActionCreators.push(modal(() => <GuildNotificationSettingsModal guildId={guildId} />));
|
||||
onClose();
|
||||
}, [guildId, onClose]);
|
||||
|
||||
if (!guildId) return null;
|
||||
const channelNotifications = UserGuildSettingsStore.getChannelOverride(guildId, channel.id)?.message_notifications;
|
||||
const currentNotificationLevel = channelNotifications ?? MessageNotifications.INHERIT;
|
||||
|
||||
const guildNotificationLevel = UserGuildSettingsStore.getGuildMessageNotifications(guildId);
|
||||
|
||||
const categoryId = channel.parentId;
|
||||
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 effectiveCurrentNotificationLevel =
|
||||
currentNotificationLevel === MessageNotifications.INHERIT ? categoryDefaultLevel : currentNotificationLevel;
|
||||
const hasCategory = categoryId != null;
|
||||
|
||||
const currentStateText = getNotificationSettingsLabel(effectiveCurrentNotificationLevel);
|
||||
const defaultLabelParts = {
|
||||
main: hasCategory ? t`Category Default` : t`Community Default`,
|
||||
sub: getNotificationSettingsLabel(categoryDefaultLevel) ?? null,
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={t`Notification Settings`}
|
||||
icon={<NotificationSettingsIcon />}
|
||||
hint={currentStateText}
|
||||
onTriggerSelect={handleOpenGuildNotificationSettings}
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
<MenuItemRadio
|
||||
selected={currentNotificationLevel === MessageNotifications.INHERIT}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.INHERIT)}
|
||||
>
|
||||
<div className={itemStyles.flexColumn}>
|
||||
<span>{defaultLabelParts.main}</span>
|
||||
{defaultLabelParts.sub && <div className={menuItemStyles.subtext}>{defaultLabelParts.sub}</div>}
|
||||
</div>
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
selected={currentNotificationLevel === MessageNotifications.ALL_MESSAGES}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.ALL_MESSAGES)}
|
||||
>
|
||||
{t`All Messages`}
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
selected={currentNotificationLevel === MessageNotifications.ONLY_MENTIONS}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.ONLY_MENTIONS)}
|
||||
>
|
||||
{t`Only @mentions`}
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
selected={currentNotificationLevel === MessageNotifications.NO_MESSAGES}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.NO_MESSAGES)}
|
||||
>
|
||||
{t`Nothing`}
|
||||
</MenuItemRadio>
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const EditChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
|
||||
channelId: channel.id,
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
|
||||
const handleEditChannel = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <ChannelSettingsModal channelId={channel.id} />));
|
||||
onClose();
|
||||
}, [channel.id, onClose]);
|
||||
|
||||
if (!canManageChannels) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<EditIcon />} onClick={handleEditChannel}>
|
||||
{t`Edit Channel`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const DeleteChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
|
||||
channelId: channel.id,
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
|
||||
const handleDeleteChannel = React.useCallback(() => {
|
||||
onClose();
|
||||
const channelType = channel.type === ChannelTypes.GUILD_VOICE ? t`Voice Channel` : t`Text Channel`;
|
||||
const channelName = channel.name ?? 'this channel';
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Delete ${channelType}`}
|
||||
description={t`Are you sure you want to delete #${channelName}? 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]);
|
||||
|
||||
if (!canManageChannels) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<DeleteIcon />} onClick={handleDeleteChannel} danger>
|
||||
{t`Delete Channel`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const CopyChannelIdMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleCopyId = React.useCallback(() => {
|
||||
TextCopyActionCreators.copy(i18n, channel.id);
|
||||
onClose();
|
||||
}, [channel.id, onClose, i18n]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CopyIdIcon />} onClick={handleCopyId}>
|
||||
{t`Copy Channel ID`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const FavoriteChannelMenuItem: React.FC<ChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const categories = FavoritesStore.sortedCategories;
|
||||
const isAlreadyFavorite = !!FavoritesStore.getChannel(channel.id);
|
||||
const favoriteLabel = React.useMemo(() => {
|
||||
if (channel.isDM()) {
|
||||
return t`Favorite DM`;
|
||||
}
|
||||
if (channel.isGroupDM()) {
|
||||
return t`Favorite Group DM`;
|
||||
}
|
||||
return t`Favorite Channel`;
|
||||
}, [channel]);
|
||||
const unfavoriteLabel = React.useMemo(() => {
|
||||
if (channel.isDM()) {
|
||||
return t`Unfavorite DM`;
|
||||
}
|
||||
if (channel.isGroupDM()) {
|
||||
return t`Unfavorite Group DM`;
|
||||
}
|
||||
return t`Unfavorite Channel`;
|
||||
}, [channel]);
|
||||
|
||||
const handleAddToCategory = React.useCallback(
|
||||
(categoryId: string | null) => {
|
||||
const guildId = channel.guildId ?? ME;
|
||||
FavoritesStore.addChannel(channel.id, guildId, categoryId);
|
||||
ToastActionCreators.createToast({type: 'success', children: t`Channel added to favorites`});
|
||||
onClose();
|
||||
},
|
||||
[channel.id, channel.guildId, onClose],
|
||||
);
|
||||
|
||||
const handleRemoveFromFavorites = React.useCallback(() => {
|
||||
FavoritesStore.removeChannel(channel.id);
|
||||
ToastActionCreators.createToast({type: 'success', children: t`Channel removed from favorites`});
|
||||
onClose();
|
||||
}, [channel.id, onClose]);
|
||||
|
||||
if (!AccessibilityStore.showFavorites) return null;
|
||||
|
||||
if (isAlreadyFavorite) {
|
||||
return (
|
||||
<MenuItem icon={<StarIcon weight="fill" />} onClick={handleRemoveFromFavorites}>
|
||||
{unfavoriteLabel}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<MenuItem icon={<StarIcon weight="regular" />} onClick={() => handleAddToCategory(null)}>
|
||||
{favoriteLabel}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={favoriteLabel}
|
||||
icon={<StarIcon weight="regular" />}
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
<MenuItem onClick={() => handleAddToCategory(null)}>{t`Uncategorized`}</MenuItem>
|
||||
{categories.map((category) => (
|
||||
<MenuItem key={category.id} onClick={() => handleAddToCategory(category.id)}>
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import {CopyUserIdIcon} from '../ContextMenuIcons';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface CopyUserIdMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CopyUserIdMenuItem: React.FC<CopyUserIdMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleCopyUserId = React.useCallback(() => {
|
||||
onClose();
|
||||
TextCopyActionCreators.copy(i18n, user.id, true);
|
||||
}, [user.id, onClose, i18n]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CopyUserIdIcon />} onClick={handleCopyUserId}>
|
||||
{t`Copy User ID`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* 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 type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
||||
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import * as ChannelUtils from '~/utils/ChannelUtils';
|
||||
import {getMutedText} from '~/utils/ContextMenuUtils';
|
||||
import {MarkAsReadIcon, MuteIcon} from '../ContextMenuIcons';
|
||||
import {MenuGroup} from '../MenuGroup';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
import {MenuItemSubmenu} from '../MenuItemSubmenu';
|
||||
|
||||
interface DMMenuItemProps {
|
||||
channel: ChannelRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MarkDMAsReadMenuItem: React.FC<DMMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const hasUnread = ReadStateStore.hasUnread(channel.id);
|
||||
|
||||
const handleMarkAsRead = React.useCallback(() => {
|
||||
ReadStateActionCreators.ack(channel.id, true, true);
|
||||
onClose();
|
||||
}, [channel.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<MarkAsReadIcon />} onClick={handleMarkAsRead} disabled={!hasUnread}>
|
||||
{t`Mark as Read`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface MuteDuration {
|
||||
label: MessageDescriptor;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
const MUTE_DURATIONS: Array<MuteDuration> = [
|
||||
{label: msg`For 15 Minutes`, value: 15 * 60 * 1000},
|
||||
{label: msg`For 1 Hour`, value: 60 * 60 * 1000},
|
||||
{label: msg`For 3 Hours`, value: 3 * 60 * 60 * 1000},
|
||||
{label: msg`For 8 Hours`, value: 8 * 60 * 60 * 1000},
|
||||
{label: msg`For 24 Hours`, value: 24 * 60 * 60 * 1000},
|
||||
{label: msg`Until I turn it back on`, value: null},
|
||||
];
|
||||
|
||||
export const MuteDMMenuItem: React.FC<DMMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const channelOverride = UserGuildSettingsStore.getChannelOverride(null, channel.id);
|
||||
const isMuted = channelOverride?.muted ?? false;
|
||||
const muteConfig = channelOverride?.mute_config;
|
||||
|
||||
const mutedText = getMutedText(isMuted, muteConfig);
|
||||
const dmDisplayName = ChannelUtils.getDMDisplayName(channel);
|
||||
const displayLabel = channel.isDM() ? `@${dmDisplayName}` : dmDisplayName;
|
||||
const muteLabel = t`Mute ${displayLabel}`;
|
||||
const unmuteLabel = t`Unmute ${displayLabel}`;
|
||||
|
||||
const handleMute = React.useCallback(
|
||||
(duration: number | null) => {
|
||||
const muteConfig = duration
|
||||
? {
|
||||
selected_time_window: duration,
|
||||
end_time: new Date(Date.now() + duration).toISOString(),
|
||||
}
|
||||
: null;
|
||||
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(
|
||||
null,
|
||||
channel.id,
|
||||
{
|
||||
muted: true,
|
||||
mute_config: muteConfig,
|
||||
},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
[channel.id, onClose],
|
||||
);
|
||||
|
||||
const handleUnmute = React.useCallback(() => {
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(
|
||||
null,
|
||||
channel.id,
|
||||
{
|
||||
muted: false,
|
||||
mute_config: null,
|
||||
},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
onClose();
|
||||
}, [channel.id, onClose]);
|
||||
|
||||
if (isMuted) {
|
||||
return (
|
||||
<MenuItem icon={<MuteIcon />} onClick={handleUnmute} hint={mutedText ?? undefined}>
|
||||
{unmuteLabel}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={muteLabel}
|
||||
icon={<MuteIcon />}
|
||||
onTriggerSelect={() => handleMute(null)}
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
{MUTE_DURATIONS.map((duration) => (
|
||||
<MenuItem key={duration.value ?? 'until'} onClick={() => handleMute(duration.value)}>
|
||||
{t(duration.label)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ChannelDebugModal} from '~/components/debug/ChannelDebugModal';
|
||||
import {GuildDebugModal} from '~/components/debug/GuildDebugModal';
|
||||
import {GuildMemberDebugModal} from '~/components/debug/GuildMemberDebugModal';
|
||||
import {UserDebugModal} from '~/components/debug/UserDebugModal';
|
||||
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 {DebugIcon} from '../ContextMenuIcons';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface BaseDebugMenuItemProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DebugUserMenuItemProps = BaseDebugMenuItemProps & {
|
||||
user: UserRecord;
|
||||
};
|
||||
|
||||
export const DebugUserMenuItem: React.FC<DebugUserMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleDebug = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <UserDebugModal title={t`User Debug`} user={user} />));
|
||||
onClose();
|
||||
}, [user, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
|
||||
{t`Debug User`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type DebugChannelMenuItemProps = BaseDebugMenuItemProps & {
|
||||
channel: ChannelRecord;
|
||||
};
|
||||
|
||||
export const DebugChannelMenuItem: React.FC<DebugChannelMenuItemProps> = observer(({channel, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleDebug = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <ChannelDebugModal title={t`Channel Debug`} channel={channel} />));
|
||||
onClose();
|
||||
}, [channel, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
|
||||
{t`Debug Channel`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type DebugGuildMenuItemProps = BaseDebugMenuItemProps & {
|
||||
guild: GuildRecord;
|
||||
};
|
||||
|
||||
export const DebugGuildMenuItem: React.FC<DebugGuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleDebug = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <GuildDebugModal title={t`Community Debug`} guild={guild} />));
|
||||
onClose();
|
||||
}, [guild, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
|
||||
{t`Debug Guild`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type DebugGuildMemberMenuItemProps = BaseDebugMenuItemProps & {
|
||||
member: GuildMemberRecord;
|
||||
};
|
||||
|
||||
export const DebugGuildMemberMenuItem: React.FC<DebugGuildMemberMenuItemProps> = observer(({member, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleDebug = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <GuildMemberDebugModal title={t`Community Member Debug`} member={member} />));
|
||||
onClose();
|
||||
}, [member, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
|
||||
{t`Debug Member`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {ClockIcon, CrownIcon, PencilIcon, UserListIcon, UsersIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {Permissions} from '~/Constants';
|
||||
import {BanMemberModal} from '~/components/modals/BanMemberModal';
|
||||
import {ChangeNicknameModal} from '~/components/modals/ChangeNicknameModal';
|
||||
import {KickMemberModal} from '~/components/modals/KickMemberModal';
|
||||
import {RemoveTimeoutModal} from '~/components/modals/RemoveTimeoutModal';
|
||||
import {TimeoutMemberModal} from '~/components/modals/TimeoutMemberModal';
|
||||
import {TransferOwnershipModal} from '~/components/modals/TransferOwnershipModal';
|
||||
import {useRoleHierarchy} from '~/hooks/useRoleHierarchy';
|
||||
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import GuildMemberStore from '~/stores/GuildMemberStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import * as ColorUtils from '~/utils/ColorUtils';
|
||||
import * as PermissionUtils from '~/utils/PermissionUtils';
|
||||
import {MenuGroup} from '../MenuGroup';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
import {MenuItemCheckbox} from '../MenuItemCheckbox';
|
||||
import {MenuItemSubmenu} from '../MenuItemSubmenu';
|
||||
import itemStyles from './MenuItems.module.css';
|
||||
|
||||
interface TransferOwnershipMenuItemProps {
|
||||
guildId: string;
|
||||
user: UserRecord;
|
||||
member: GuildMemberRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const TransferOwnershipMenuItem: React.FC<TransferOwnershipMenuItemProps> = observer(
|
||||
function TransferOwnershipMenuItem({guildId, user, member, onClose}) {
|
||||
const {t} = useLingui();
|
||||
const handleTransferOwnership = React.useCallback(() => {
|
||||
onClose();
|
||||
ModalActionCreators.push(
|
||||
modal(() => <TransferOwnershipModal guildId={guildId} targetUser={user} targetMember={member} />),
|
||||
);
|
||||
}, [guildId, user, member, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CrownIcon size={16} />} onClick={handleTransferOwnership}>
|
||||
{t`Transfer Ownership`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface KickMemberMenuItemProps {
|
||||
guildId: string;
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const KickMemberMenuItem: React.FC<KickMemberMenuItemProps> = observer(function KickMemberMenuItem({
|
||||
guildId,
|
||||
user,
|
||||
onClose,
|
||||
}) {
|
||||
const {t} = useLingui();
|
||||
const handleKickMember = React.useCallback(() => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <KickMemberModal guildId={guildId} targetUser={user} />));
|
||||
}, [guildId, user, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<UsersIcon size={16} />} onClick={handleKickMember} danger>
|
||||
{t`Kick Member`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface BanMemberMenuItemProps {
|
||||
guildId: string;
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BanMemberMenuItem: React.FC<BanMemberMenuItemProps> = observer(function BanMemberMenuItem({
|
||||
guildId,
|
||||
user,
|
||||
onClose,
|
||||
}) {
|
||||
const {t} = useLingui();
|
||||
const handleBanMember = React.useCallback(() => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <BanMemberModal guildId={guildId} targetUser={user} />));
|
||||
}, [guildId, user, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<UsersIcon size={16} />} onClick={handleBanMember} danger>
|
||||
{t`Ban Member`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface ManageRolesMenuItemProps {
|
||||
guildId: string;
|
||||
member: GuildMemberRecord;
|
||||
}
|
||||
|
||||
export const ManageRolesMenuItem: React.FC<ManageRolesMenuItemProps> = observer(function ManageRolesMenuItem({
|
||||
guildId,
|
||||
member,
|
||||
}) {
|
||||
const {t} = useLingui();
|
||||
const guild = GuildStore.getGuild(guildId);
|
||||
const currentMember = GuildMemberStore.getMember(guildId, member.user.id);
|
||||
const {canManageRole} = useRoleHierarchy(guild);
|
||||
|
||||
const canManageRoles = PermissionStore.can(Permissions.MANAGE_ROLES, {guildId});
|
||||
|
||||
const allRoles = React.useMemo(() => {
|
||||
if (!guild) return [];
|
||||
|
||||
return Object.values(guild.roles)
|
||||
.filter((role) => !role.isEveryone)
|
||||
.sort((a, b) => b.position - a.position)
|
||||
.map((role) => ({
|
||||
role,
|
||||
canManage: canManageRole({id: role.id, position: role.position, permissions: role.permissions}),
|
||||
}));
|
||||
}, [guild, canManageRole]);
|
||||
|
||||
const handleToggleRole = React.useCallback(
|
||||
async (roleId: string, hasRole: boolean, canToggle: boolean) => {
|
||||
if (!canToggle) return;
|
||||
if (hasRole) {
|
||||
await GuildMemberActionCreators.removeRole(guildId, member.user.id, roleId);
|
||||
} else {
|
||||
await GuildMemberActionCreators.addRole(guildId, member.user.id, roleId);
|
||||
}
|
||||
},
|
||||
[guildId, member.user.id],
|
||||
);
|
||||
|
||||
if (allRoles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={t`Roles`}
|
||||
icon={<UserListIcon className={itemStyles.icon} />}
|
||||
selectionMode="multiple"
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
{allRoles.map(({role, canManage}) => {
|
||||
const hasRole = currentMember?.roles.has(role.id) ?? false;
|
||||
const canToggle = canManageRoles && canManage;
|
||||
return (
|
||||
<MenuItemCheckbox
|
||||
key={role.id}
|
||||
checked={hasRole}
|
||||
disabled={!canToggle}
|
||||
onChange={() => handleToggleRole(role.id, hasRole, canToggle)}
|
||||
closeOnChange={false}
|
||||
>
|
||||
<div className={itemStyles.roleContainer}>
|
||||
<div className={itemStyles.roleIcon} style={{backgroundColor: ColorUtils.int2rgb(role.color)}} />
|
||||
<span className={!canToggle ? itemStyles.roleDisabled : undefined}>{role.name}</span>
|
||||
</div>
|
||||
</MenuItemCheckbox>
|
||||
);
|
||||
})}
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface ChangeNicknameMenuItemProps {
|
||||
guildId: string;
|
||||
user: UserRecord;
|
||||
member: GuildMemberRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ChangeNicknameMenuItem: React.FC<ChangeNicknameMenuItemProps> = observer(function ChangeNicknameMenuItem({
|
||||
guildId,
|
||||
user,
|
||||
member,
|
||||
onClose,
|
||||
}) {
|
||||
const {t} = useLingui();
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
const isCurrentUser = user.id === currentUserId;
|
||||
const guild = GuildStore.getGuild(guildId);
|
||||
const {canManageTarget} = useRoleHierarchy(guild);
|
||||
|
||||
const hasChangeNicknamePermission = PermissionStore.can(Permissions.CHANGE_NICKNAME, {guildId});
|
||||
const hasManageNicknamesPermission = PermissionStore.can(Permissions.MANAGE_NICKNAMES, {guildId});
|
||||
|
||||
const canManageNicknames =
|
||||
(isCurrentUser && hasChangeNicknamePermission) || (hasManageNicknamesPermission && canManageTarget(user.id));
|
||||
|
||||
const handleChangeNickname = React.useCallback(() => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <ChangeNicknameModal guildId={guildId} user={user} member={member} />));
|
||||
}, [guildId, user, member, onClose]);
|
||||
|
||||
if (!canManageNicknames) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<PencilIcon size={16} />} onClick={handleChangeNickname}>
|
||||
{isCurrentUser ? t`Change Nickname` : t`Change Nickname`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface TimeoutMemberMenuItemProps {
|
||||
guildId: string;
|
||||
user: UserRecord;
|
||||
member: GuildMemberRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const TimeoutMemberMenuItem: React.FC<TimeoutMemberMenuItemProps> = observer(function TimeoutMemberMenuItem({
|
||||
guildId,
|
||||
user,
|
||||
member,
|
||||
onClose,
|
||||
}) {
|
||||
const {t} = useLingui();
|
||||
const guild = GuildStore.getGuild(guildId);
|
||||
const guildSnapshot = guild?.toJSON();
|
||||
const targetHasModerateMembersPermission =
|
||||
guildSnapshot !== undefined && PermissionUtils.can(Permissions.MODERATE_MEMBERS, user.id, guildSnapshot);
|
||||
|
||||
const handleTimeoutMember = React.useCallback(() => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <TimeoutMemberModal guildId={guildId} targetUser={user} />));
|
||||
}, [guildId, user, onClose]);
|
||||
|
||||
const handleRemoveTimeout = React.useCallback(() => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <RemoveTimeoutModal guildId={guildId} targetUser={user} />));
|
||||
}, [guildId, user, onClose]);
|
||||
|
||||
if (targetHasModerateMembersPermission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isTimedOut = member.isTimedOut();
|
||||
const handleClick = isTimedOut ? handleRemoveTimeout : handleTimeoutMember;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<ClockIcon size={16} />} onClick={handleClick} danger={!isTimedOut}>
|
||||
{isTimedOut ? t`Remove Timeout` : t`Timeout`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,478 @@
|
||||
/*
|
||||
* 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 type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
|
||||
import {MessageNotifications, Permissions} from '~/Constants';
|
||||
import {CategoryCreateModal} from '~/components/modals/CategoryCreateModal';
|
||||
import {ChannelCreateModal} from '~/components/modals/ChannelCreateModal';
|
||||
import {GuildNotificationSettingsModal} from '~/components/modals/GuildNotificationSettingsModal';
|
||||
import {GuildPrivacySettingsModal} from '~/components/modals/GuildPrivacySettingsModal';
|
||||
import {GuildSettingsModal} from '~/components/modals/GuildSettingsModal';
|
||||
import {InviteModal} from '~/components/modals/InviteModal';
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import {type GuildSettingsTab, getGuildSettingsTabs} from '~/components/modals/utils/guildSettingsConstants';
|
||||
import {useLeaveGuild} from '~/hooks/useLeaveGuild';
|
||||
import type {GuildRecord} from '~/records/GuildRecord';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
import {
|
||||
CopyIdIcon,
|
||||
CreateCategoryIcon,
|
||||
CreateChannelIcon,
|
||||
EditProfileIcon,
|
||||
InviteIcon,
|
||||
LeaveIcon,
|
||||
MarkAsReadIcon,
|
||||
MuteIcon,
|
||||
NotificationSettingsIcon,
|
||||
PrivacySettingsIcon,
|
||||
SettingsIcon,
|
||||
} from '../ContextMenuIcons';
|
||||
import {MenuGroup} from '../MenuGroup';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
import {MenuItemCheckbox} from '../MenuItemCheckbox';
|
||||
import {MenuItemRadio} from '../MenuItemRadio';
|
||||
import {MenuItemSubmenu} from '../MenuItemSubmenu';
|
||||
|
||||
interface GuildMenuItemProps {
|
||||
guild: GuildRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MarkAsReadMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const channels = ChannelStore.getGuildChannels(guild.id);
|
||||
|
||||
const hasUnread = React.useMemo(() => {
|
||||
return channels.some((channel) => ReadStateStore.hasUnread(channel.id));
|
||||
}, [channels]);
|
||||
|
||||
const handleMarkAsRead = React.useCallback(() => {
|
||||
const channelIds = channels
|
||||
.filter((channel) => ReadStateStore.getUnreadCount(channel.id) > 0)
|
||||
.map((channel) => channel.id);
|
||||
|
||||
if (channelIds.length > 0) {
|
||||
void ReadStateActionCreators.bulkAckChannels(channelIds);
|
||||
}
|
||||
onClose();
|
||||
}, [channels, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<MarkAsReadIcon />} onClick={handleMarkAsRead} disabled={!hasUnread}>
|
||||
{t(msg`Mark as Read`)}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const InvitePeopleMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const channelId = InviteUtils.getInvitableChannelId(guild.id);
|
||||
const canInvite = InviteUtils.canInviteToChannel(channelId, guild.id);
|
||||
|
||||
const handleInvite = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <InviteModal channelId={channelId ?? ''} />));
|
||||
onClose();
|
||||
}, [channelId, onClose]);
|
||||
|
||||
if (!canInvite) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<InviteIcon />} onClick={handleInvite}>
|
||||
{t(msg`Invite People`)}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface MuteDuration {
|
||||
label: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
const getMuteDurations = (t: (message: MessageDescriptor) => string): Array<MuteDuration> => {
|
||||
return [
|
||||
{label: t(msg`For 15 Minutes`), value: 15 * 60 * 1000},
|
||||
{label: t(msg`For 1 Hour`), value: 60 * 60 * 1000},
|
||||
{label: t(msg`For 3 Hours`), value: 3 * 60 * 60 * 1000},
|
||||
{label: t(msg`For 8 Hours`), value: 8 * 60 * 60 * 1000},
|
||||
{label: t(msg`For 24 Hours`), value: 24 * 60 * 60 * 1000},
|
||||
{label: t(msg`Until I turn it back on`), value: null},
|
||||
];
|
||||
};
|
||||
|
||||
export const MuteCommunityMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const settings = UserGuildSettingsStore.getSettings(guild.id);
|
||||
const isMuted = settings?.muted ?? false;
|
||||
const muteConfig = settings?.mute_config;
|
||||
|
||||
const mutedText = getMutedText(isMuted, muteConfig);
|
||||
const MUTE_DURATIONS = React.useMemo(() => getMuteDurations(t), [t]);
|
||||
|
||||
const handleMute = React.useCallback(
|
||||
(duration: number | null) => {
|
||||
const computedMuteConfig = duration
|
||||
? {
|
||||
selected_time_window: duration,
|
||||
end_time: new Date(Date.now() + duration).toISOString(),
|
||||
}
|
||||
: null;
|
||||
|
||||
UserGuildSettingsActionCreators.updateGuildSettings(
|
||||
guild.id,
|
||||
{
|
||||
muted: true,
|
||||
mute_config: computedMuteConfig,
|
||||
},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
[guild.id, onClose],
|
||||
);
|
||||
|
||||
const handleUnmute = React.useCallback(() => {
|
||||
UserGuildSettingsActionCreators.updateGuildSettings(
|
||||
guild.id,
|
||||
{
|
||||
muted: false,
|
||||
mute_config: null,
|
||||
},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
onClose();
|
||||
}, [guild.id, onClose]);
|
||||
|
||||
if (isMuted) {
|
||||
return (
|
||||
<MenuItem icon={<MuteIcon />} onClick={handleUnmute} hint={mutedText ?? undefined}>
|
||||
{t(msg`Unmute Community`)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={t(msg`Mute Community`)}
|
||||
icon={<MuteIcon />}
|
||||
onTriggerSelect={() => handleMute(null)}
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
{MUTE_DURATIONS.map((duration) => (
|
||||
<MenuItem key={duration.label} onClick={() => handleMute(duration.value)}>
|
||||
{duration.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const NotificationSettingsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const settings = UserGuildSettingsStore.getSettings(guild.id);
|
||||
const suppressEveryone = settings?.suppress_everyone ?? false;
|
||||
const suppressRoles = settings?.suppress_roles ?? false;
|
||||
const mobilePush = settings?.mobile_push ?? true;
|
||||
|
||||
const effectiveNotificationLevel = UserGuildSettingsStore.getGuildMessageNotifications(guild.id);
|
||||
const currentStateText = getNotificationSettingsLabel(effectiveNotificationLevel);
|
||||
|
||||
const handleNotificationLevelChange = React.useCallback(
|
||||
(level: number) => {
|
||||
UserGuildSettingsActionCreators.updateMessageNotifications(guild.id, level, undefined, {
|
||||
persistImmediately: true,
|
||||
});
|
||||
},
|
||||
[guild.id],
|
||||
);
|
||||
|
||||
const handleToggleSuppressEveryone = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
UserGuildSettingsActionCreators.updateGuildSettings(
|
||||
guild.id,
|
||||
{suppress_everyone: checked},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
},
|
||||
[guild.id],
|
||||
);
|
||||
|
||||
const handleToggleSuppressRoles = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
UserGuildSettingsActionCreators.updateGuildSettings(
|
||||
guild.id,
|
||||
{suppress_roles: checked},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
},
|
||||
[guild.id],
|
||||
);
|
||||
|
||||
const handleToggleMobilePush = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
UserGuildSettingsActionCreators.updateGuildSettings(guild.id, {mobile_push: checked}, {persistImmediately: true});
|
||||
},
|
||||
[guild.id],
|
||||
);
|
||||
|
||||
const handleOpenModal = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <GuildNotificationSettingsModal guildId={guild.id} />));
|
||||
onClose();
|
||||
}, [guild.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={t(msg`Notification Settings`)}
|
||||
icon={<NotificationSettingsIcon />}
|
||||
hint={currentStateText}
|
||||
onTriggerSelect={handleOpenModal}
|
||||
render={() => (
|
||||
<>
|
||||
<MenuGroup>
|
||||
<MenuItemRadio
|
||||
selected={effectiveNotificationLevel === MessageNotifications.ALL_MESSAGES}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.ALL_MESSAGES)}
|
||||
>
|
||||
{t(msg`All Messages`)}
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
selected={effectiveNotificationLevel === MessageNotifications.ONLY_MENTIONS}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.ONLY_MENTIONS)}
|
||||
>
|
||||
{t(msg`Only @mentions`)}
|
||||
</MenuItemRadio>
|
||||
<MenuItemRadio
|
||||
selected={effectiveNotificationLevel === MessageNotifications.NO_MESSAGES}
|
||||
onSelect={() => handleNotificationLevelChange(MessageNotifications.NO_MESSAGES)}
|
||||
>
|
||||
{t(msg`Nothing`)}
|
||||
</MenuItemRadio>
|
||||
</MenuGroup>
|
||||
|
||||
<MenuGroup>
|
||||
<MenuItemCheckbox checked={suppressEveryone} onChange={handleToggleSuppressEveryone}>
|
||||
{t(msg`Suppress @everyone and @here`)}
|
||||
</MenuItemCheckbox>
|
||||
<MenuItemCheckbox checked={suppressRoles} onChange={handleToggleSuppressRoles}>
|
||||
{t(msg`Suppress All Role @mentions`)}
|
||||
</MenuItemCheckbox>
|
||||
<MenuItemCheckbox checked={mobilePush} onChange={handleToggleMobilePush}>
|
||||
{t(msg`Mobile Push Notifications`)}
|
||||
</MenuItemCheckbox>
|
||||
</MenuGroup>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const HideMutedChannelsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild}) => {
|
||||
const {t} = useLingui();
|
||||
const settings = UserGuildSettingsStore.getSettings(guild.id);
|
||||
const hideMutedChannels = settings?.hide_muted_channels ?? false;
|
||||
|
||||
const handleToggle = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
const currentSettings = UserGuildSettingsStore.getSettings(guild.id);
|
||||
const currentValue = currentSettings?.hide_muted_channels ?? false;
|
||||
if (checked === currentValue) return;
|
||||
UserGuildSettingsActionCreators.toggleHideMutedChannels(guild.id);
|
||||
},
|
||||
[guild.id],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItemCheckbox checked={hideMutedChannels} onChange={handleToggle}>
|
||||
{t(msg`Hide Muted Channels`)}
|
||||
</MenuItemCheckbox>
|
||||
);
|
||||
});
|
||||
|
||||
export const CommunitySettingsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const accessibleTabs = React.useMemo(() => {
|
||||
const guildTabs = getGuildSettingsTabs(t);
|
||||
return guildTabs.filter((tab) => {
|
||||
if (tab.permission && !PermissionStore.can(tab.permission, {guildId: guild.id})) {
|
||||
return false;
|
||||
}
|
||||
if (tab.requireFeature && !guild.features.has(tab.requireFeature)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [guild, t]);
|
||||
|
||||
const defaultTab = React.useMemo(() => {
|
||||
const overviewTab = accessibleTabs.find((tab) => tab.type === 'overview');
|
||||
return overviewTab ?? accessibleTabs[0] ?? null;
|
||||
}, [accessibleTabs]);
|
||||
|
||||
const handleOpenSettings = React.useCallback(
|
||||
(tab: GuildSettingsTab) => {
|
||||
ModalActionCreators.push(modal(() => <GuildSettingsModal guildId={guild.id} initialTab={tab.type} />));
|
||||
onClose();
|
||||
},
|
||||
[guild.id, onClose],
|
||||
);
|
||||
|
||||
const handleOpenDefaultTab = React.useCallback(() => {
|
||||
if (!defaultTab) return;
|
||||
handleOpenSettings(defaultTab);
|
||||
}, [defaultTab, handleOpenSettings]);
|
||||
|
||||
if (accessibleTabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={t(msg`Community Settings`)}
|
||||
icon={<SettingsIcon />}
|
||||
onTriggerSelect={handleOpenDefaultTab}
|
||||
render={() => (
|
||||
<>
|
||||
{accessibleTabs.map((tab) => {
|
||||
const IconComponent = tab.icon;
|
||||
return (
|
||||
<MenuItem
|
||||
key={tab.type}
|
||||
icon={<IconComponent size={16} weight={tab.iconWeight ?? 'fill'} />}
|
||||
onClick={() => handleOpenSettings(tab)}
|
||||
>
|
||||
{tab.label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const PrivacySettingsMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleOpenPrivacySettings = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <GuildPrivacySettingsModal guildId={guild.id} />));
|
||||
onClose();
|
||||
}, [guild.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<PrivacySettingsIcon />} onClick={handleOpenPrivacySettings}>
|
||||
{t(msg`Privacy Settings`)}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const EditCommunityProfileMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleEditProfile = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <UserSettingsModal initialGuildId={guild.id} initialTab="my_profile" />));
|
||||
onClose();
|
||||
}, [guild.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<EditProfileIcon />} onClick={handleEditProfile}>
|
||||
{t(msg`Edit Community Profile`)}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreateChannelMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {guildId: guild.id});
|
||||
|
||||
const handleCreateChannel = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <ChannelCreateModal guildId={guild.id} />));
|
||||
onClose();
|
||||
}, [guild.id, onClose]);
|
||||
|
||||
if (!canManageChannels) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CreateChannelIcon />} onClick={handleCreateChannel}>
|
||||
{t(msg`Create Channel`)}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreateCategoryMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {guildId: guild.id});
|
||||
|
||||
const handleCreateCategory = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <CategoryCreateModal guildId={guild.id} />));
|
||||
onClose();
|
||||
}, [guild.id, onClose]);
|
||||
|
||||
if (!canManageChannels) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CreateCategoryIcon />} onClick={handleCreateCategory}>
|
||||
{t(msg`Create Category`)}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const LeaveCommunityMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const isOwner = guild.isOwner(AuthenticationStore.currentUserId);
|
||||
const leaveGuild = useLeaveGuild();
|
||||
|
||||
const handleLeave = React.useCallback(() => {
|
||||
leaveGuild(guild.id);
|
||||
onClose();
|
||||
}, [guild.id, onClose, leaveGuild]);
|
||||
|
||||
if (isOwner) return null;
|
||||
|
||||
return (
|
||||
<MenuItem icon={<LeaveIcon />} onClick={handleLeave} danger>
|
||||
{t(msg`Leave Community`)}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const CopyGuildIdMenuItem: React.FC<GuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleCopyId = React.useCallback(() => {
|
||||
TextCopyActionCreators.copy(i18n, guild.id);
|
||||
onClose();
|
||||
}, [guild.id, onClose, i18n]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CopyIdIcon />} onClick={handleCopyId}>
|
||||
{t(msg`Copy Guild ID`)}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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 {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as InviteActionCreators from '~/actions/InviteActionCreators';
|
||||
import * as MessageActionCreators from '~/actions/MessageActionCreators';
|
||||
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import type {GuildRecord} from '~/records/GuildRecord';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import GuildMemberStore from '~/stores/GuildMemberStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
||||
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
import {fromTimestamp} from '~/utils/SnowflakeUtils';
|
||||
import {InviteIcon} from '../ContextMenuIcons';
|
||||
import {MenuGroup} from '../MenuGroup';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
import {MenuItemSubmenu} from '../MenuItemSubmenu';
|
||||
|
||||
interface InviteCandidate {
|
||||
guild: GuildRecord;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
const canInviteInChannel = (channel: ChannelRecord | undefined): channel is ChannelRecord => {
|
||||
if (!channel || !channel.guildId) {
|
||||
return false;
|
||||
}
|
||||
return InviteUtils.canInviteToChannel(channel.id, channel.guildId);
|
||||
};
|
||||
|
||||
const getDefaultInviteChannelId = (guildId: string): string | null => {
|
||||
const selectedChannelId = SelectedChannelStore.selectedChannelIds.get(guildId);
|
||||
if (selectedChannelId) {
|
||||
const selectedChannel = ChannelStore.getChannel(selectedChannelId);
|
||||
if (canInviteInChannel(selectedChannel)) {
|
||||
return selectedChannel!.id;
|
||||
}
|
||||
}
|
||||
|
||||
const guildChannels = ChannelStore.getGuildChannels(guildId);
|
||||
for (const channel of guildChannels) {
|
||||
if (channel.type === ChannelTypes.GUILD_TEXT && canInviteInChannel(channel)) {
|
||||
return channel.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface InviteToCommunityMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const InviteToCommunityMenuItem: React.FC<InviteToCommunityMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const candidates = React.useMemo(() => {
|
||||
return GuildStore.getGuilds()
|
||||
.filter((guild) => !GuildMemberStore.getMember(guild.id, user.id))
|
||||
.map((guild): InviteCandidate | null => {
|
||||
const channelId = getDefaultInviteChannelId(guild.id);
|
||||
return channelId ? {guild, channelId} : null;
|
||||
})
|
||||
.filter((candidate): candidate is InviteCandidate => candidate !== null)
|
||||
.sort((a, b) => a.guild.name.localeCompare(b.guild.name));
|
||||
}, [user.id]);
|
||||
|
||||
const handleSendInvite = React.useCallback(
|
||||
async (candidate: InviteCandidate) => {
|
||||
onClose();
|
||||
try {
|
||||
const invite = await InviteActionCreators.create(candidate.channelId);
|
||||
const inviteUrl = `${RuntimeConfigStore.inviteEndpoint}/${invite.code}`;
|
||||
const dmChannelId = await PrivateChannelActionCreators.ensureDMChannel(user.id);
|
||||
await MessageActionCreators.send(dmChannelId, {
|
||||
content: inviteUrl,
|
||||
nonce: fromTimestamp(Date.now()),
|
||||
});
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: <Trans>Invite sent for {candidate.guild.name}</Trans>,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send invite from context menu:', error);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: <Trans>Failed to send invite</Trans>,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onClose, user.id],
|
||||
);
|
||||
|
||||
if (user.bot || candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
label={t`Invite to Community`}
|
||||
icon={<InviteIcon />}
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
{candidates.map((candidate) => (
|
||||
<MenuItem key={candidate.guild.id} onClick={() => handleSendInvite(candidate)}>
|
||||
{candidate.guild.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {AtIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface MentionUserMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MentionUserMenuItem: React.FC<MentionUserMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleMentionUser = React.useCallback(() => {
|
||||
onClose();
|
||||
ComponentDispatch.dispatch('INSERT_MENTION', {userId: user.id});
|
||||
}, [user.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<AtIcon size={16} />} onClick={handleMentionUser}>
|
||||
{t`Mention`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.roleIcon {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.roleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roleName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
max-height: 1.2em;
|
||||
}
|
||||
|
||||
.roleDisabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.flexContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.flexColumn {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.submenuContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.submenuIcon {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.submenuPopup {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 100%;
|
||||
z-index: 50;
|
||||
margin-left: 4px;
|
||||
min-width: max-content;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--background-secondary);
|
||||
background-color: var(--background-primary);
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.submenuItem {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.submenuItem:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {isEmbedsSuppressed, triggerAddReaction} from '~/components/channel/messageActionUtils';
|
||||
import {MessageDebugModal} from '~/components/debug/MessageDebugModal';
|
||||
import type {MessageRecord} from '~/records/MessageRecord';
|
||||
import SavedMessagesStore from '~/stores/SavedMessagesStore';
|
||||
import * as TtsUtils from '~/utils/TtsUtils';
|
||||
import {
|
||||
AddReactionIcon,
|
||||
BookmarkIcon,
|
||||
CopyIdIcon,
|
||||
CopyLinkIcon,
|
||||
CopyTextIcon,
|
||||
DebugIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
ForwardIcon,
|
||||
MarkAsUnreadIcon,
|
||||
PinIcon,
|
||||
RemoveAllReactionsIcon,
|
||||
ReplyIcon,
|
||||
SpeakIcon,
|
||||
SuppressEmbedsIcon,
|
||||
} from '../ContextMenuIcons';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface MessageMenuItemProps {
|
||||
message: MessageRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type AddReactionMenuItemProps = MessageMenuItemProps;
|
||||
|
||||
export const AddReactionMenuItem: React.FC<AddReactionMenuItemProps> = observer(({message, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleAddReaction = React.useCallback(() => {
|
||||
triggerAddReaction(message.id);
|
||||
onClose();
|
||||
}, [message.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<AddReactionIcon />} onClick={handleAddReaction} shortcut="+">
|
||||
{t`Add Reaction`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type EditMessageMenuItemProps = MessageMenuItemProps & {
|
||||
onEdit: () => void;
|
||||
};
|
||||
|
||||
export const EditMessageMenuItem: React.FC<EditMessageMenuItemProps> = observer(({onEdit, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleEdit = React.useCallback(() => {
|
||||
onEdit();
|
||||
onClose();
|
||||
}, [onEdit, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<EditIcon />} onClick={handleEdit} shortcut="e">
|
||||
{t`Edit Message`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type ReplyMessageMenuItemProps = MessageMenuItemProps & {
|
||||
onReply: () => void;
|
||||
};
|
||||
|
||||
export const ReplyMessageMenuItem: React.FC<ReplyMessageMenuItemProps> = observer(({onReply, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleReply = React.useCallback(() => {
|
||||
onReply();
|
||||
onClose();
|
||||
}, [onReply, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<ReplyIcon />} onClick={handleReply} shortcut="r">
|
||||
{t`Reply`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type ForwardMessageMenuItemProps = MessageMenuItemProps & {
|
||||
onForward: () => void;
|
||||
};
|
||||
|
||||
export const ForwardMessageMenuItem: React.FC<ForwardMessageMenuItemProps> = observer(({onForward, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleForward = React.useCallback(() => {
|
||||
onForward();
|
||||
onClose();
|
||||
}, [onForward, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<ForwardIcon />} onClick={handleForward} shortcut="f">
|
||||
{t`Forward`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type BookmarkMessageMenuItemProps = MessageMenuItemProps & {
|
||||
onSave: (isSaved: boolean) => () => void;
|
||||
};
|
||||
|
||||
export const BookmarkMessageMenuItem: React.FC<BookmarkMessageMenuItemProps> = observer(
|
||||
({message, onSave, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const isSaved = SavedMessagesStore.isSaved(message.id);
|
||||
|
||||
const handleSave = React.useCallback(() => {
|
||||
onSave(isSaved)();
|
||||
onClose();
|
||||
}, [isSaved, onSave, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<BookmarkIcon filled={isSaved} />} onClick={handleSave} shortcut="b">
|
||||
{isSaved ? t`Remove Bookmark` : t`Bookmark Message`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type PinMessageMenuItemProps = MessageMenuItemProps & {
|
||||
onPin: () => void;
|
||||
};
|
||||
|
||||
export const PinMessageMenuItem: React.FC<PinMessageMenuItemProps> = observer(({message, onPin, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handlePin = React.useCallback(() => {
|
||||
onPin();
|
||||
onClose();
|
||||
}, [onPin, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<PinIcon />} onClick={handlePin} shortcut="p">
|
||||
{message.pinned ? t`Unpin Message` : t`Pin Message`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type SuppressEmbedsMenuItemProps = MessageMenuItemProps & {
|
||||
onToggleSuppressEmbeds: () => void;
|
||||
};
|
||||
|
||||
export const SuppressEmbedsMenuItem: React.FC<SuppressEmbedsMenuItemProps> = observer(
|
||||
({message, onToggleSuppressEmbeds, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleToggle = React.useCallback(() => {
|
||||
onToggleSuppressEmbeds();
|
||||
onClose();
|
||||
}, [onToggleSuppressEmbeds, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<SuppressEmbedsIcon />} onClick={handleToggle} shortcut="s">
|
||||
{isEmbedsSuppressed(message) ? t`Unsuppress Embeds` : t`Suppress Embeds`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type CopyMessageTextMenuItemProps = MessageMenuItemProps & {
|
||||
onCopyMessage: () => void;
|
||||
};
|
||||
|
||||
export const CopyMessageTextMenuItem: React.FC<CopyMessageTextMenuItemProps> = observer(({onCopyMessage, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleCopy = React.useCallback(() => {
|
||||
onCopyMessage();
|
||||
onClose();
|
||||
}, [onCopyMessage, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CopyTextIcon />} onClick={handleCopy} shortcut="c">
|
||||
{t`Copy Text`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type CopyMessageLinkMenuItemProps = MessageMenuItemProps & {
|
||||
onCopyMessageLink: () => void;
|
||||
};
|
||||
|
||||
export const CopyMessageLinkMenuItem: React.FC<CopyMessageLinkMenuItemProps> = observer(
|
||||
({onCopyMessageLink, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleCopyLink = React.useCallback(() => {
|
||||
onCopyMessageLink();
|
||||
onClose();
|
||||
}, [onCopyMessageLink, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CopyLinkIcon />} onClick={handleCopyLink} shortcut="l">
|
||||
{t`Copy Message Link`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type CopyMessageIdMenuItemProps = MessageMenuItemProps & {
|
||||
onCopyMessageId: () => void;
|
||||
};
|
||||
|
||||
export const CopyMessageIdMenuItem: React.FC<CopyMessageIdMenuItemProps> = observer(({onCopyMessageId, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleCopyId = React.useCallback(() => {
|
||||
onCopyMessageId();
|
||||
onClose();
|
||||
}, [onCopyMessageId, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CopyIdIcon />} onClick={handleCopyId}>
|
||||
{t`Copy Message ID`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type DebugMessageMenuItemProps = MessageMenuItemProps;
|
||||
|
||||
export const DebugMessageMenuItem: React.FC<DebugMessageMenuItemProps> = observer(({message, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleDebug = React.useCallback(() => {
|
||||
ModalActionCreators.push(modal(() => <MessageDebugModal title={t`Message Debug`} message={message} />));
|
||||
onClose();
|
||||
}, [message, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<DebugIcon />} onClick={handleDebug}>
|
||||
{t`Debug Message`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type DeleteMessageMenuItemProps = MessageMenuItemProps & {
|
||||
onDelete: (bypassConfirm?: boolean) => void;
|
||||
};
|
||||
|
||||
export const DeleteMessageMenuItem: React.FC<DeleteMessageMenuItemProps> = observer(({onDelete, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleDelete = React.useCallback(
|
||||
(event?: unknown) => {
|
||||
const shiftKey = Boolean((event as {shiftKey?: boolean} | undefined)?.shiftKey);
|
||||
onDelete(shiftKey);
|
||||
onClose();
|
||||
},
|
||||
[onDelete, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<DeleteIcon />} onClick={handleDelete} danger shortcut="d">
|
||||
{t`Delete Message`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type RemoveAllReactionsMenuItemProps = MessageMenuItemProps & {
|
||||
onRemoveAllReactions: () => void;
|
||||
};
|
||||
|
||||
export const RemoveAllReactionsMenuItem: React.FC<RemoveAllReactionsMenuItemProps> = observer(
|
||||
({onRemoveAllReactions, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleRemoveAll = React.useCallback(() => {
|
||||
onRemoveAllReactions();
|
||||
onClose();
|
||||
}, [onRemoveAllReactions, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<RemoveAllReactionsIcon />} onClick={handleRemoveAll} danger>
|
||||
{t`Remove All Reactions`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type MarkAsUnreadMenuItemProps = MessageMenuItemProps & {
|
||||
onMarkAsUnread: () => void;
|
||||
};
|
||||
|
||||
export const MarkAsUnreadMenuItem: React.FC<MarkAsUnreadMenuItemProps> = observer(({onMarkAsUnread, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleMarkAsUnread = React.useCallback(() => {
|
||||
onMarkAsUnread();
|
||||
onClose();
|
||||
}, [onMarkAsUnread, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<MarkAsUnreadIcon />} onClick={handleMarkAsUnread} shortcut="u">
|
||||
{t`Mark as Unread`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
type SpeakMessageMenuItemProps = MessageMenuItemProps;
|
||||
|
||||
export const SpeakMessageMenuItem: React.FC<SpeakMessageMenuItemProps> = observer(({message, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleSpeak = React.useCallback(() => {
|
||||
TtsUtils.speakMessage(message.content);
|
||||
onClose();
|
||||
}, [message.content, onClose]);
|
||||
|
||||
if (!TtsUtils.isSupported()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!message.content.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem icon={<SpeakIcon />} onClick={handleSpeak}>
|
||||
{TtsUtils.isSpeaking() ? t`Stop Speaking` : t`Speak Message`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {ChatCircleIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface MessageUserMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MessageUserMenuItem: React.FC<MessageUserMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleMessageUser = React.useCallback(async () => {
|
||||
onClose();
|
||||
|
||||
try {
|
||||
await PrivateChannelActionCreators.openDMChannel(user.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to open DM channel:', error);
|
||||
}
|
||||
}, [user.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<ChatCircleIcon size={16} />} onClick={handleMessageUser}>
|
||||
{t`Message`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowsLeftRightIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
|
||||
import * as VoiceStateActionCreators from '~/actions/VoiceStateActionCreators';
|
||||
import {ChannelTypes, Permissions} from '~/Constants';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
import {MenuGroup} from '../MenuGroup';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
import {MenuItemSubmenu} from '../MenuItemSubmenu';
|
||||
|
||||
interface MoveToChannelSubmenuProps {
|
||||
userId: string;
|
||||
guildId: string;
|
||||
connectionId?: string;
|
||||
connectionIds?: Array<string>;
|
||||
onClose: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const MoveToChannelSubmenu: React.FC<MoveToChannelSubmenuProps> = observer(
|
||||
({userId, guildId, connectionId, connectionIds, onClose, label}) => {
|
||||
const {t} = useLingui();
|
||||
const channels = ChannelStore.getGuildChannels(guildId);
|
||||
const userVoiceState = MediaEngineStore.getVoiceState(guildId, userId);
|
||||
const currentUser = UserStore.currentUser;
|
||||
const isSelf = currentUser?.id === userId;
|
||||
|
||||
const voiceChannels = React.useMemo(() => {
|
||||
return channels.filter((channel) => {
|
||||
if (channel.type !== ChannelTypes.GUILD_VOICE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (userVoiceState?.channel_id === channel.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const canConnect = PermissionStore.can(Permissions.CONNECT, {
|
||||
guildId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
|
||||
return canConnect;
|
||||
});
|
||||
}, [channels, guildId, userVoiceState]);
|
||||
|
||||
const handleMoveToChannel = React.useCallback(
|
||||
async (channelId: string) => {
|
||||
onClose();
|
||||
|
||||
if (connectionIds && connectionIds.length > 0) {
|
||||
try {
|
||||
await VoiceStateActionCreators.bulkMoveConnections(connectionIds, channelId);
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk move connections:', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelf) {
|
||||
const socket = ConnectionStore.socket;
|
||||
if (socket) {
|
||||
socket.updateVoiceState({
|
||||
guild_id: guildId,
|
||||
channel_id: channelId,
|
||||
self_mute: true,
|
||||
self_deaf: true,
|
||||
self_video: false,
|
||||
self_stream: false,
|
||||
connection_id: MediaEngineStore.connectionId ?? null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await GuildMemberActionCreators.update(guildId, userId, {
|
||||
channel_id: channelId,
|
||||
connection_id: connectionId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to move member to channel:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[guildId, userId, connectionId, connectionIds, onClose, isSelf],
|
||||
);
|
||||
|
||||
if (voiceChannels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItemSubmenu
|
||||
icon={<ArrowsLeftRightIcon weight="fill" style={{width: 16, height: 16}} />}
|
||||
label={label ?? t`Move To...`}
|
||||
render={() => (
|
||||
<MenuGroup>
|
||||
{voiceChannels.map((channel) => (
|
||||
<MenuItem key={channel.id} onClick={() => handleMoveToChannel(channel.id)}>
|
||||
{channel.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuGroup>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {RelationshipTypes} from '~/Constants';
|
||||
import {ChangeFriendNicknameModal} from '~/components/modals/ChangeFriendNicknameModal';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import RelationshipStore from '~/stores/RelationshipStore';
|
||||
import * as RelationshipActionUtils from '~/utils/RelationshipActionUtils';
|
||||
import {
|
||||
AcceptFriendRequestIcon,
|
||||
BlockUserIcon,
|
||||
CancelFriendRequestIcon,
|
||||
EditIcon,
|
||||
IgnoreFriendRequestIcon,
|
||||
RemoveFriendIcon,
|
||||
SendFriendRequestIcon,
|
||||
} from '../ContextMenuIcons';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface SendFriendRequestMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SendFriendRequestMenuItem: React.FC<SendFriendRequestMenuItemProps> = observer(
|
||||
({user, onClose: _onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const relationshipType = RelationshipStore.getRelationship(user.id)?.type;
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
const showFriendRequestSent = relationshipType === RelationshipTypes.OUTGOING_REQUEST;
|
||||
|
||||
const handleSendFriendRequest = React.useCallback(async () => {
|
||||
if (submitting || showFriendRequestSent) return;
|
||||
setSubmitting(true);
|
||||
await RelationshipActionUtils.sendFriendRequest(i18n, user.id);
|
||||
setSubmitting(false);
|
||||
}, [i18n, showFriendRequestSent, submitting, user.id]);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
icon={<SendFriendRequestIcon />}
|
||||
onClick={handleSendFriendRequest}
|
||||
disabled={submitting || showFriendRequestSent}
|
||||
closeOnSelect={false}
|
||||
>
|
||||
{showFriendRequestSent ? t`Friend Request Sent` : t`Add Friend`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface AcceptFriendRequestMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AcceptFriendRequestMenuItem: React.FC<AcceptFriendRequestMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleAcceptFriendRequest = React.useCallback(() => {
|
||||
onClose();
|
||||
RelationshipActionUtils.acceptFriendRequest(i18n, user.id);
|
||||
}, [i18n, user.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<AcceptFriendRequestIcon />} onClick={handleAcceptFriendRequest}>
|
||||
{t`Accept Friend Request`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface RemoveFriendMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export const RemoveFriendMenuItem: React.FC<RemoveFriendMenuItemProps> = observer(({user, onClose, danger = true}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleRemoveFriend = React.useCallback(() => {
|
||||
onClose();
|
||||
RelationshipActionUtils.showRemoveFriendConfirmation(i18n, user);
|
||||
}, [i18n, user, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<RemoveFriendIcon />} onClick={handleRemoveFriend} danger={danger}>
|
||||
{t`Remove Friend`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface ChangeFriendNicknameMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ChangeFriendNicknameMenuItem: React.FC<ChangeFriendNicknameMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const relationship = RelationshipStore.getRelationship(user.id);
|
||||
|
||||
const handleChangeNickname = React.useCallback(() => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <ChangeFriendNicknameModal user={user} />));
|
||||
}, [onClose, user]);
|
||||
|
||||
if (relationship?.type !== RelationshipTypes.FRIEND) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem icon={<EditIcon />} onClick={handleChangeNickname}>
|
||||
{t`Change Friend Nickname`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface IgnoreFriendRequestMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const IgnoreFriendRequestMenuItem: React.FC<IgnoreFriendRequestMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleIgnoreFriendRequest = React.useCallback(() => {
|
||||
onClose();
|
||||
RelationshipActionUtils.ignoreFriendRequest(i18n, user.id);
|
||||
}, [i18n, user.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<IgnoreFriendRequestIcon />} onClick={handleIgnoreFriendRequest}>
|
||||
{t`Ignore Friend Request`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface CancelFriendRequestMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CancelFriendRequestMenuItem: React.FC<CancelFriendRequestMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleCancelFriendRequest = React.useCallback(() => {
|
||||
onClose();
|
||||
RelationshipActionUtils.cancelFriendRequest(i18n, user.id);
|
||||
}, [i18n, user.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<CancelFriendRequestIcon />} onClick={handleCancelFriendRequest}>
|
||||
{t`Cancel Friend Request`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface BlockUserMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BlockUserMenuItem: React.FC<BlockUserMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleBlockUser = React.useCallback(() => {
|
||||
onClose();
|
||||
RelationshipActionUtils.showBlockUserConfirmation(i18n, user);
|
||||
}, [i18n, user, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<BlockUserIcon />} onClick={handleBlockUser} danger>
|
||||
{t`Block`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface UnblockUserMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UnblockUserMenuItem: React.FC<UnblockUserMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleUnblockUser = React.useCallback(() => {
|
||||
onClose();
|
||||
RelationshipActionUtils.unblockUser(i18n, user.id);
|
||||
}, [i18n, user.id, onClose]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<BlockUserIcon />} onClick={handleUnblockUser}>
|
||||
{t`Unblock`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface RelationshipActionMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const RelationshipActionMenuItem: React.FC<RelationshipActionMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const relationship = RelationshipStore.getRelationship(user.id);
|
||||
const relationshipType = relationship?.type;
|
||||
|
||||
if (user.bot) {
|
||||
if (relationshipType === RelationshipTypes.FRIEND) {
|
||||
return <RemoveFriendMenuItem user={user} onClose={onClose} danger={false} />;
|
||||
}
|
||||
if (relationshipType === RelationshipTypes.INCOMING_REQUEST) {
|
||||
return <IgnoreFriendRequestMenuItem user={user} onClose={onClose} />;
|
||||
}
|
||||
if (relationshipType === RelationshipTypes.OUTGOING_REQUEST) {
|
||||
return <CancelFriendRequestMenuItem user={user} onClose={onClose} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (relationshipType) {
|
||||
case RelationshipTypes.FRIEND:
|
||||
return <RemoveFriendMenuItem user={user} onClose={onClose} danger={false} />;
|
||||
case RelationshipTypes.INCOMING_REQUEST:
|
||||
return (
|
||||
<>
|
||||
<AcceptFriendRequestMenuItem user={user} onClose={onClose} />
|
||||
<IgnoreFriendRequestMenuItem user={user} onClose={onClose} />
|
||||
</>
|
||||
);
|
||||
case RelationshipTypes.OUTGOING_REQUEST:
|
||||
return (
|
||||
<MenuItem icon={<SendFriendRequestIcon />} disabled closeOnSelect={false}>
|
||||
{t`Friend Request Sent`}
|
||||
</MenuItem>
|
||||
);
|
||||
case RelationshipTypes.BLOCKED:
|
||||
return null;
|
||||
default:
|
||||
return <SendFriendRequestMenuItem user={user} onClose={onClose} />;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {FlagIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import type {IARContext} from '~/components/modals/IARModal';
|
||||
import {IARModal} from '~/components/modals/IARModal';
|
||||
import type {GuildRecord} from '~/records/GuildRecord';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface ReportGuildMenuItemProps {
|
||||
guild: GuildRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ReportGuildMenuItem: React.FC<ReportGuildMenuItemProps> = observer(({guild, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const isOwner = guild.ownerId === AuthenticationStore.currentUserId;
|
||||
|
||||
const handleReportGuild = React.useCallback(() => {
|
||||
onClose();
|
||||
const context: IARContext = {
|
||||
type: 'guild',
|
||||
guild,
|
||||
};
|
||||
ModalActionCreators.push(modal(() => <IARModal context={context} />));
|
||||
}, [guild, onClose]);
|
||||
|
||||
if (isOwner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem icon={<FlagIcon size={16} />} onClick={handleReportGuild} danger>
|
||||
{t`Report Community`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {FlagIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import type {IARContext} from '~/components/modals/IARModal';
|
||||
import {IARModal} from '~/components/modals/IARModal';
|
||||
import type {MessageRecord} from '~/records/MessageRecord';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface ReportMessageMenuItemProps {
|
||||
message: MessageRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ReportMessageMenuItem: React.FC<ReportMessageMenuItemProps> = observer(({message, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleReportMessage = React.useCallback(() => {
|
||||
onClose();
|
||||
const context: IARContext = {
|
||||
type: 'message',
|
||||
message,
|
||||
};
|
||||
ModalActionCreators.push(modal(() => <IARModal context={context} />));
|
||||
}, [message, onClose]);
|
||||
|
||||
if (message.isCurrentUserAuthor()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem icon={<FlagIcon size={16} />} onClick={handleReportMessage} danger>
|
||||
{t`Report Message`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import {AddNoteIcon} from '../ContextMenuIcons';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface AddNoteMenuItemProps {
|
||||
user: UserRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AddNoteMenuItem: React.FC<AddNoteMenuItemProps> = observer(({user, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleAddNote = React.useCallback(() => {
|
||||
UserProfileActionCreators.openUserProfile(user.id, undefined, true);
|
||||
onClose();
|
||||
}, [onClose, user.id]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<AddNoteIcon />} onClick={handleAddNote}>
|
||||
{t`Add Note`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {UserIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
|
||||
interface UserProfileMenuItemProps {
|
||||
user: UserRecord;
|
||||
guildId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const UserProfileMenuItem: React.FC<UserProfileMenuItemProps> = observer(({user, guildId, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleViewProfile = React.useCallback(() => {
|
||||
onClose();
|
||||
UserProfileActionCreators.openUserProfile(user.id, guildId);
|
||||
}, [onClose, user.id, guildId]);
|
||||
|
||||
return (
|
||||
<MenuItem icon={<UserIcon size={16} />} onClick={handleViewProfile}>
|
||||
{t`View Profile`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
GearIcon,
|
||||
MicrophoneSlashIcon,
|
||||
PhoneXIcon,
|
||||
ProjectorScreenIcon,
|
||||
SpeakerSlashIcon,
|
||||
VideoCameraSlashIcon,
|
||||
VideoIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as SoundActionCreators from '~/actions/SoundActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import * as VoiceCallLayoutActionCreators from '~/actions/VoiceCallLayoutActionCreators';
|
||||
import * as VoiceStateActionCreators from '~/actions/VoiceStateActionCreators';
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import CallMediaPrefsStore from '~/stores/CallMediaPrefsStore';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import GuildMemberStore from '~/stores/GuildMemberStore';
|
||||
import ParticipantVolumeStore from '~/stores/ParticipantVolumeStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import VoiceCallLayoutStore from '~/stores/VoiceCallLayoutStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
import type {VoiceState} from '~/stores/voice/VoiceStateManager';
|
||||
import {SoundType} from '~/utils/SoundUtils';
|
||||
import {MenuItem} from '../MenuItem';
|
||||
import {MenuItemCheckbox} from '../MenuItemCheckbox';
|
||||
import {MenuItemSlider} from '../MenuItemSlider';
|
||||
import styles from './MenuItems.module.css';
|
||||
|
||||
interface SelfMuteMenuItemProps {
|
||||
onClose: () => void;
|
||||
connectionId?: string;
|
||||
isDeviceSpecific?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const SelfMuteMenuItem: React.FC<SelfMuteMenuItemProps> = observer(
|
||||
({connectionId, isDeviceSpecific = false, label}) => {
|
||||
const {t} = useLingui();
|
||||
const voiceState = connectionId
|
||||
? MediaEngineStore.getVoiceStateByConnectionId(connectionId)
|
||||
: MediaEngineStore.getCurrentUserVoiceState();
|
||||
const isSelfMuted = voiceState?.self_mute ?? false;
|
||||
const handleToggle = React.useCallback(() => {
|
||||
if (isDeviceSpecific && connectionId) {
|
||||
VoiceStateActionCreators.toggleSelfMuteForConnection(connectionId);
|
||||
} else {
|
||||
VoiceStateActionCreators.toggleSelfMute(null);
|
||||
}
|
||||
}, [connectionId, isDeviceSpecific]);
|
||||
return (
|
||||
<MenuItemCheckbox
|
||||
icon={<MicrophoneSlashIcon weight="fill" className={styles.icon} />}
|
||||
checked={isSelfMuted}
|
||||
onChange={handleToggle}
|
||||
>
|
||||
{label ?? t`Mute`}
|
||||
</MenuItemCheckbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface SelfDeafenMenuItemProps {
|
||||
onClose: () => void;
|
||||
connectionId?: string;
|
||||
isDeviceSpecific?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const SelfDeafenMenuItem: React.FC<SelfDeafenMenuItemProps> = observer(
|
||||
({connectionId, isDeviceSpecific = false, label}) => {
|
||||
const {t} = useLingui();
|
||||
const voiceState = connectionId
|
||||
? MediaEngineStore.getVoiceStateByConnectionId(connectionId)
|
||||
: MediaEngineStore.getCurrentUserVoiceState();
|
||||
const isSelfDeafened = voiceState?.self_deaf ?? false;
|
||||
const handleToggle = React.useCallback(() => {
|
||||
if (isDeviceSpecific && connectionId) {
|
||||
VoiceStateActionCreators.toggleSelfDeafenForConnection(connectionId);
|
||||
} else {
|
||||
VoiceStateActionCreators.toggleSelfDeaf(null);
|
||||
}
|
||||
}, [connectionId, isDeviceSpecific]);
|
||||
return (
|
||||
<MenuItemCheckbox
|
||||
icon={<SpeakerSlashIcon weight="fill" className={styles.icon} />}
|
||||
checked={isSelfDeafened}
|
||||
onChange={handleToggle}
|
||||
>
|
||||
{label ?? t`Deafen`}
|
||||
</MenuItemCheckbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface VoiceVideoSettingsMenuItemProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const VoiceVideoSettingsMenuItem: React.FC<VoiceVideoSettingsMenuItemProps> = observer(({onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleClick = React.useCallback(() => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="voice_video" />));
|
||||
}, [onClose]);
|
||||
return (
|
||||
<MenuItem icon={<GearIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
|
||||
{t`Voice & Video Settings`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface SelfTurnOffCameraMenuItemProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
export const SelfTurnOffCameraMenuItem: React.FC<SelfTurnOffCameraMenuItemProps> = observer(({onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const connectionId = MediaEngineStore.connectionId;
|
||||
const voiceState = connectionId ? MediaEngineStore.getVoiceStateByConnectionId(connectionId) : null;
|
||||
const isCameraOn = voiceState?.self_video ?? false;
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (connectionId) VoiceStateActionCreators.turnOffCameraForConnection(connectionId);
|
||||
onClose();
|
||||
}, [connectionId, onClose]);
|
||||
if (!isCameraOn) return null;
|
||||
return (
|
||||
<MenuItem icon={<VideoCameraSlashIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
|
||||
{t`Turn Off Camera`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface SelfTurnOffStreamMenuItemProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
export const SelfTurnOffStreamMenuItem: React.FC<SelfTurnOffStreamMenuItemProps> = observer(({onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const connectionId = MediaEngineStore.connectionId;
|
||||
const voiceState = connectionId ? MediaEngineStore.getVoiceStateByConnectionId(connectionId) : null;
|
||||
const isStreaming = voiceState?.self_stream ?? false;
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (connectionId) VoiceStateActionCreators.turnOffStreamForConnection(connectionId);
|
||||
onClose();
|
||||
}, [connectionId, onClose]);
|
||||
if (!isStreaming) return null;
|
||||
return (
|
||||
<MenuItem icon={<ProjectorScreenIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
|
||||
{t`Turn Off Stream`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface ParticipantVolumeSliderProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const ParticipantVolumeSlider: React.FC<ParticipantVolumeSliderProps> = observer(({userId}) => {
|
||||
const {t} = useLingui();
|
||||
const participantVolume = ParticipantVolumeStore.getVolume(userId);
|
||||
const handleChange = React.useCallback(
|
||||
(value: number) => {
|
||||
ParticipantVolumeStore.setVolume(userId, value);
|
||||
MediaEngineStore.applyLocalAudioPreferencesForUser(userId);
|
||||
},
|
||||
[userId],
|
||||
);
|
||||
return (
|
||||
<MenuItemSlider
|
||||
label={t`User Volume`}
|
||||
value={participantVolume}
|
||||
minValue={0}
|
||||
maxValue={200}
|
||||
onChange={handleChange}
|
||||
onFormat={(value) => `${Math.round(value)}%`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
interface LocalMuteParticipantMenuItemProps {
|
||||
userId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const LocalMuteParticipantMenuItem: React.FC<LocalMuteParticipantMenuItemProps> = observer(({userId}) => {
|
||||
const {t} = useLingui();
|
||||
const isLocalMuted = ParticipantVolumeStore.isLocalMuted(userId);
|
||||
const handleToggle = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
ParticipantVolumeStore.setLocalMute(userId, checked);
|
||||
MediaEngineStore.applyLocalAudioPreferencesForUser(userId);
|
||||
},
|
||||
[userId],
|
||||
);
|
||||
return (
|
||||
<MenuItemCheckbox
|
||||
icon={<SpeakerSlashIcon weight="fill" className={styles.icon} />}
|
||||
checked={isLocalMuted}
|
||||
onChange={handleToggle}
|
||||
>
|
||||
{t`Mute`}
|
||||
</MenuItemCheckbox>
|
||||
);
|
||||
});
|
||||
|
||||
interface LocalDisableVideoMenuItemProps {
|
||||
userId: string;
|
||||
connectionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const LocalDisableVideoMenuItem: React.FC<LocalDisableVideoMenuItemProps> = observer(
|
||||
({userId, connectionId, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const callId = MediaEngineStore.connectionId ?? '';
|
||||
const identity = `user_${userId}_${connectionId}`;
|
||||
const disabled = callId ? CallMediaPrefsStore.isVideoDisabled(callId, identity) : false;
|
||||
const handleToggle = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
const id = MediaEngineStore.connectionId ?? '';
|
||||
if (!id) return;
|
||||
MediaEngineStore.setLocalVideoDisabled(identity, checked);
|
||||
onClose();
|
||||
},
|
||||
[identity, onClose],
|
||||
);
|
||||
return (
|
||||
<MenuItemCheckbox
|
||||
icon={<VideoCameraSlashIcon weight="fill" className={styles.icon} />}
|
||||
checked={disabled}
|
||||
onChange={handleToggle}
|
||||
>
|
||||
{t`Disable Video (Local)`}
|
||||
</MenuItemCheckbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface GuildMuteMenuItemProps {
|
||||
userId: string;
|
||||
guildId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const GuildMuteMenuItem: React.FC<GuildMuteMenuItemProps> = observer(function GuildMuteMenuItem({
|
||||
userId,
|
||||
guildId,
|
||||
}) {
|
||||
const {t} = useLingui();
|
||||
const member = GuildMemberStore.getMember(guildId, userId);
|
||||
const isGuildMuted = member?.mute ?? false;
|
||||
const isTimedOut = member?.isTimedOut() ?? false;
|
||||
const handleToggle = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await GuildMemberActionCreators.update(guildId, userId, {mute: checked});
|
||||
if (checked) SoundActionCreators.playSound(SoundType.Mute);
|
||||
else SoundActionCreators.playSound(SoundType.Unmute);
|
||||
} catch {}
|
||||
},
|
||||
[guildId, userId],
|
||||
);
|
||||
return (
|
||||
<MenuItemCheckbox
|
||||
icon={<MicrophoneSlashIcon weight="fill" className={styles.icon} />}
|
||||
checked={!!isGuildMuted}
|
||||
onChange={handleToggle}
|
||||
danger
|
||||
disabled={isTimedOut}
|
||||
description={isTimedOut ? t`Disabled while the member is timed out.` : undefined}
|
||||
>
|
||||
{t`Community Mute`}
|
||||
</MenuItemCheckbox>
|
||||
);
|
||||
});
|
||||
|
||||
interface GuildDeafenMenuItemProps {
|
||||
userId: string;
|
||||
guildId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const GuildDeafenMenuItem: React.FC<GuildDeafenMenuItemProps> = observer(function GuildDeafenMenuItem({
|
||||
userId,
|
||||
guildId,
|
||||
}) {
|
||||
const {t} = useLingui();
|
||||
const member = GuildMemberStore.getMember(guildId, userId);
|
||||
const isGuildDeafened = member?.deaf ?? false;
|
||||
const handleToggle = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await GuildMemberActionCreators.update(guildId, userId, {deaf: checked});
|
||||
if (checked) SoundActionCreators.playSound(SoundType.Deaf);
|
||||
else SoundActionCreators.playSound(SoundType.Undeaf);
|
||||
} catch {}
|
||||
},
|
||||
[guildId, userId],
|
||||
);
|
||||
return (
|
||||
<MenuItemCheckbox
|
||||
icon={<SpeakerSlashIcon weight="fill" className={styles.icon} />}
|
||||
checked={!!isGuildDeafened}
|
||||
onChange={handleToggle}
|
||||
danger
|
||||
>
|
||||
{t`Community Deafen`}
|
||||
</MenuItemCheckbox>
|
||||
);
|
||||
});
|
||||
|
||||
interface DisconnectParticipantMenuItemProps {
|
||||
userId: string;
|
||||
guildId: string;
|
||||
participantName: string;
|
||||
connectionId?: string;
|
||||
onClose: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const DisconnectParticipantMenuItem: React.FC<DisconnectParticipantMenuItemProps> = observer(
|
||||
function DisconnectParticipantMenuItem({userId, guildId, connectionId, onClose, label}) {
|
||||
const {t} = useLingui();
|
||||
const currentUser = UserStore.currentUser;
|
||||
const isSelf = currentUser?.id === userId;
|
||||
const handleClick = React.useCallback(async () => {
|
||||
onClose();
|
||||
if (isSelf) {
|
||||
const socket = ConnectionStore.socket;
|
||||
const cid = connectionId ?? MediaEngineStore.connectionId ?? null;
|
||||
if (socket && cid) {
|
||||
socket.updateVoiceState({
|
||||
guild_id: guildId,
|
||||
channel_id: null,
|
||||
self_mute: true,
|
||||
self_deaf: true,
|
||||
self_video: false,
|
||||
self_stream: false,
|
||||
connection_id: cid,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await GuildMemberActionCreators.update(guildId, userId, {channel_id: null, connection_id: connectionId});
|
||||
} catch {}
|
||||
}
|
||||
}, [guildId, userId, connectionId, onClose, isSelf]);
|
||||
const defaultLabel = connectionId ? t`Disconnect Device` : t`Disconnect`;
|
||||
return (
|
||||
<MenuItem icon={<PhoneXIcon weight="fill" className={styles.icon} />} onClick={handleClick} danger>
|
||||
{label ?? defaultLabel}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface TurnOffDeviceCameraMenuItemProps {
|
||||
onClose: () => void;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export const TurnOffDeviceCameraMenuItem: React.FC<TurnOffDeviceCameraMenuItemProps> = observer(
|
||||
({connectionId, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
|
||||
const isCameraOn = voiceState?.self_video ?? false;
|
||||
const handleClick = React.useCallback(() => {
|
||||
VoiceStateActionCreators.turnOffCameraForConnection(connectionId);
|
||||
onClose();
|
||||
}, [connectionId, onClose]);
|
||||
if (!isCameraOn) return null;
|
||||
return (
|
||||
<MenuItem icon={<VideoCameraSlashIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
|
||||
{t`Turn Off Device Camera`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface TurnOffDeviceStreamMenuItemProps {
|
||||
onClose: () => void;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export const TurnOffDeviceStreamMenuItem: React.FC<TurnOffDeviceStreamMenuItemProps> = observer(
|
||||
({connectionId, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
|
||||
const isStreaming = voiceState?.self_stream ?? false;
|
||||
const handleClick = React.useCallback(() => {
|
||||
VoiceStateActionCreators.turnOffStreamForConnection(connectionId);
|
||||
onClose();
|
||||
}, [connectionId, onClose]);
|
||||
if (!isStreaming) return null;
|
||||
return (
|
||||
<MenuItem icon={<ProjectorScreenIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
|
||||
{t`Turn Off Device Stream`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface CopyDeviceIdMenuItemProps {
|
||||
onClose: () => void;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export const CopyDeviceIdMenuItem: React.FC<CopyDeviceIdMenuItemProps> = observer(({connectionId, onClose}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const handleClick = React.useCallback(() => {
|
||||
TextCopyActionCreators.copy(i18n, connectionId, true).catch(() => {});
|
||||
onClose();
|
||||
}, [connectionId, onClose, i18n]);
|
||||
return (
|
||||
<MenuItem icon={<CopyIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
|
||||
{t`Copy Device ID`}
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
interface BulkMuteDevicesMenuItemProps {
|
||||
userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BulkMuteDevicesMenuItem: React.FC<BulkMuteDevicesMenuItemProps> = observer(
|
||||
({userVoiceStates, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const allMuted = React.useMemo(
|
||||
() => userVoiceStates.every(({voiceState}) => voiceState.self_mute),
|
||||
[userVoiceStates],
|
||||
);
|
||||
const handleClick = React.useCallback(() => {
|
||||
const connectionIds = userVoiceStates.map(({connectionId}) => connectionId);
|
||||
const targetMute = !allMuted;
|
||||
VoiceStateActionCreators.bulkMuteConnections(connectionIds, targetMute);
|
||||
if (targetMute) SoundActionCreators.playSound(SoundType.Mute);
|
||||
else SoundActionCreators.playSound(SoundType.Unmute);
|
||||
onClose();
|
||||
}, [userVoiceStates, allMuted, onClose]);
|
||||
return (
|
||||
<MenuItem icon={<MicrophoneSlashIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
|
||||
{allMuted ? t`Unmute All Devices` : t`Mute All Devices`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface BulkDeafenDevicesMenuItemProps {
|
||||
userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BulkDeafenDevicesMenuItem: React.FC<BulkDeafenDevicesMenuItemProps> = observer(
|
||||
({userVoiceStates, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const allDeafened = React.useMemo(
|
||||
() => userVoiceStates.every(({voiceState}) => voiceState.self_deaf),
|
||||
[userVoiceStates],
|
||||
);
|
||||
const handleClick = React.useCallback(() => {
|
||||
const connectionIds = userVoiceStates.map(({connectionId}) => connectionId);
|
||||
const targetDeafen = !allDeafened;
|
||||
VoiceStateActionCreators.bulkDeafenConnections(connectionIds, targetDeafen);
|
||||
if (targetDeafen) SoundActionCreators.playSound(SoundType.Deaf);
|
||||
else SoundActionCreators.playSound(SoundType.Undeaf);
|
||||
onClose();
|
||||
}, [userVoiceStates, allDeafened, onClose]);
|
||||
return (
|
||||
<MenuItem icon={<SpeakerSlashIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
|
||||
{allDeafened ? t`Undeafen All Devices` : t`Deafen All Devices`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface BulkCameraDevicesMenuItemProps {
|
||||
userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BulkCameraDevicesMenuItem: React.FC<BulkCameraDevicesMenuItemProps> = observer(
|
||||
({userVoiceStates, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleClick = React.useCallback(() => {
|
||||
const connectionIds = userVoiceStates.map(({connectionId}) => connectionId);
|
||||
VoiceStateActionCreators.bulkTurnOffCameras(connectionIds);
|
||||
onClose();
|
||||
}, [userVoiceStates, onClose]);
|
||||
return (
|
||||
<MenuItem icon={<VideoIcon weight="fill" className={styles.icon} />} onClick={handleClick}>
|
||||
{t`Turn Off All Device Cameras`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface BulkDisconnectDevicesMenuItemProps {
|
||||
userVoiceStates: Array<{connectionId: string; voiceState: VoiceState}>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const BulkDisconnectDevicesMenuItem: React.FC<BulkDisconnectDevicesMenuItemProps> = observer(
|
||||
({userVoiceStates, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const handleClick = React.useCallback(async () => {
|
||||
await VoiceStateActionCreators.bulkDisconnect(userVoiceStates.map(({connectionId}) => connectionId));
|
||||
onClose();
|
||||
}, [userVoiceStates, onClose]);
|
||||
return (
|
||||
<MenuItem icon={<PhoneXIcon weight="fill" className={styles.icon} />} onClick={handleClick} danger>
|
||||
{t`Disconnect All Devices`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface FocusParticipantMenuItemProps {
|
||||
userId: string;
|
||||
connectionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const FocusParticipantMenuItem: React.FC<FocusParticipantMenuItemProps> = observer(
|
||||
({userId, connectionId, onClose}) => {
|
||||
const {t} = useLingui();
|
||||
const identity = `user_${userId}_${connectionId}`;
|
||||
const isFocused = VoiceCallLayoutStore.pinnedParticipantIdentity === identity;
|
||||
const hasMultipleConnections = React.useMemo(() => {
|
||||
const allStates = MediaEngineStore.getAllVoiceStates();
|
||||
let count = 0;
|
||||
Object.values(allStates).forEach((guildData: any) => {
|
||||
Object.values(guildData).forEach((channelData: any) => {
|
||||
Object.values(channelData).forEach((vs: any) => {
|
||||
if ((vs as any)?.user_id === userId) count++;
|
||||
});
|
||||
});
|
||||
});
|
||||
return count > 1;
|
||||
}, [userId]);
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (isFocused) {
|
||||
VoiceCallLayoutActionCreators.setPinnedParticipant(null);
|
||||
VoiceCallLayoutActionCreators.setLayoutMode('grid');
|
||||
VoiceCallLayoutActionCreators.markUserOverride();
|
||||
} else {
|
||||
VoiceCallLayoutActionCreators.setLayoutMode('focus');
|
||||
VoiceCallLayoutActionCreators.setPinnedParticipant(identity);
|
||||
VoiceCallLayoutActionCreators.markUserOverride();
|
||||
}
|
||||
onClose();
|
||||
}, [identity, onClose, isFocused]);
|
||||
return (
|
||||
<MenuItem
|
||||
icon={
|
||||
isFocused ? (
|
||||
<EyeSlashIcon weight="fill" className={styles.icon} />
|
||||
) : (
|
||||
<EyeIcon weight="fill" className={styles.icon} />
|
||||
)
|
||||
}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isFocused ? t`Unfocus` : hasMultipleConnections ? t`Focus This Device` : t`Focus This Person`}
|
||||
</MenuItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user