/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import 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 = 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 ( } onClick={handleMarkAsRead} disabled={!hasUnread}> {t(msg`Mark as Read`)} ); }); export const InvitePeopleMenuItem: React.FC = 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(() => )); onClose(); }, [channelId, onClose]); if (!canInvite) return null; return ( } onClick={handleInvite}> {t(msg`Invite People`)} ); }); interface MuteDuration { label: string; value: number | null; } const getMuteDurations = (t: (message: MessageDescriptor) => string): Array => { 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 = 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 ( } onClick={handleUnmute} hint={mutedText ?? undefined}> {t(msg`Unmute Community`)} ); } return ( } onTriggerSelect={() => handleMute(null)} render={() => ( {MUTE_DURATIONS.map((duration) => ( handleMute(duration.value)}> {duration.label} ))} )} /> ); }); export const NotificationSettingsMenuItem: React.FC = 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(() => )); onClose(); }, [guild.id, onClose]); return ( } hint={currentStateText} onTriggerSelect={handleOpenModal} render={() => ( <> handleNotificationLevelChange(MessageNotifications.ALL_MESSAGES)} > {t(msg`All Messages`)} handleNotificationLevelChange(MessageNotifications.ONLY_MENTIONS)} > {t(msg`Only @mentions`)} handleNotificationLevelChange(MessageNotifications.NO_MESSAGES)} > {t(msg`Nothing`)} {t(msg`Suppress @everyone and @here`)} {t(msg`Suppress All Role @mentions`)} {t(msg`Mobile Push Notifications`)} )} /> ); }); export const HideMutedChannelsMenuItem: React.FC = 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 ( {t(msg`Hide Muted Channels`)} ); }); export const CommunitySettingsMenuItem: React.FC = 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(() => )); onClose(); }, [guild.id, onClose], ); const handleOpenDefaultTab = React.useCallback(() => { if (!defaultTab) return; handleOpenSettings(defaultTab); }, [defaultTab, handleOpenSettings]); if (accessibleTabs.length === 0) return null; return ( } onTriggerSelect={handleOpenDefaultTab} render={() => ( <> {accessibleTabs.map((tab) => { const IconComponent = tab.icon; return ( } onClick={() => handleOpenSettings(tab)} > {tab.label} ); })} )} /> ); }); export const PrivacySettingsMenuItem: React.FC = observer(({guild, onClose}) => { const {t} = useLingui(); const handleOpenPrivacySettings = React.useCallback(() => { ModalActionCreators.push(modal(() => )); onClose(); }, [guild.id, onClose]); return ( } onClick={handleOpenPrivacySettings}> {t(msg`Privacy Settings`)} ); }); export const EditCommunityProfileMenuItem: React.FC = observer(({guild, onClose}) => { const {t} = useLingui(); const handleEditProfile = React.useCallback(() => { ModalActionCreators.push(modal(() => )); onClose(); }, [guild.id, onClose]); return ( } onClick={handleEditProfile}> {t(msg`Edit Community Profile`)} ); }); export const CreateChannelMenuItem: React.FC = observer(({guild, onClose}) => { const {t} = useLingui(); const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {guildId: guild.id}); const handleCreateChannel = React.useCallback(() => { ModalActionCreators.push(modal(() => )); onClose(); }, [guild.id, onClose]); if (!canManageChannels) return null; return ( } onClick={handleCreateChannel}> {t(msg`Create Channel`)} ); }); export const CreateCategoryMenuItem: React.FC = observer(({guild, onClose}) => { const {t} = useLingui(); const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {guildId: guild.id}); const handleCreateCategory = React.useCallback(() => { ModalActionCreators.push(modal(() => )); onClose(); }, [guild.id, onClose]); if (!canManageChannels) return null; return ( } onClick={handleCreateCategory}> {t(msg`Create Category`)} ); }); export const LeaveCommunityMenuItem: React.FC = 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 ( } onClick={handleLeave} danger> {t(msg`Leave Community`)} ); }); export const CopyGuildIdMenuItem: React.FC = observer(({guild, onClose}) => { const {t, i18n} = useLingui(); const handleCopyId = React.useCallback(() => { TextCopyActionCreators.copy(i18n, guild.id); onClose(); }, [guild.id, onClose, i18n]); return ( } onClick={handleCopyId}> {t(msg`Copy Guild ID`)} ); });