initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
)}
/>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
)}
/>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
)}
/>
);
});

View File

@@ -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>
);
});

View File

@@ -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);
}

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
)}
/>
);
},
);

View File

@@ -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} />;
}
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
});

View File

@@ -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>
);
},
);