initial commit
This commit is contained in:
55
fluxer_app/src/App.module.css
Normal file
55
fluxer_app/src/App.module.css
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
:global(html:not(.auth-page)) :local(.appContainer) {
|
||||
height: 100svh;
|
||||
min-height: 100svh;
|
||||
box-sizing: border-box;
|
||||
background: var(--background-primary);
|
||||
padding-top: 0;
|
||||
padding-right: env(safe-area-inset-right);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:global(html.is-standalone:not(.auth-page)) :local(.appContainer) {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
:global(html) :local(.overlayScope) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-index-overlay);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) :local(.overlayScope) {
|
||||
top: var(--native-titlebar-height);
|
||||
}
|
||||
|
||||
:global(html) :local(.overlayScope) > :not([data-overlay-pass-through]) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.quickSwitcherPortal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
516
fluxer_app/src/App.tsx
Normal file
516
fluxer_app/src/App.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
/*
|
||||
* 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 'highlight.js/styles/github-dark.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
import {i18n} from '@lingui/core';
|
||||
import {I18nProvider} from '@lingui/react';
|
||||
import {RoomAudioRenderer, RoomContext} from '@livekit/components-react';
|
||||
import {IconContext} from '@phosphor-icons/react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {type ReactNode} from 'react';
|
||||
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import * as WindowActionCreators from '~/actions/WindowActionCreators';
|
||||
|
||||
import GlobalOverlays from '~/components/layout/GlobalOverlays';
|
||||
import {NativeTitlebar} from '~/components/layout/NativeTitlebar';
|
||||
import {NativeTrafficLightsBackdrop} from '~/components/layout/NativeTrafficLightsBackdrop';
|
||||
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import '~/components/modals/SudoVerificationModal';
|
||||
|
||||
import {QUICK_SWITCHER_PORTAL_ID} from '~/components/quick-switcher/QuickSwitcherConstants';
|
||||
import FocusRingScope from '~/components/uikit/FocusRing/FocusRingScope';
|
||||
import {SVGMasks} from '~/components/uikit/SVGMasks';
|
||||
import {IncomingCallManager} from '~/components/voice/IncomingCallManager';
|
||||
|
||||
import {type LayoutVariant, LayoutVariantProvider} from '~/contexts/LayoutVariantContext';
|
||||
|
||||
import {showMyselfTypingHelper} from '~/devtools/ShowMyselfTypingHelper';
|
||||
|
||||
import {useActivityRecorder} from '~/hooks/useActivityRecorder';
|
||||
import {useElectronScreenSharePicker} from '~/hooks/useElectronScreenSharePicker';
|
||||
import {useNativePlatform} from '~/hooks/useNativePlatform';
|
||||
import {useTextInputContextMenu} from '~/hooks/useTextInputContextMenu';
|
||||
|
||||
import CaptchaInterceptorStore from '~/lib/CaptchaInterceptor';
|
||||
import FocusManager from '~/lib/FocusManager';
|
||||
import KeybindManager from '~/lib/KeybindManager';
|
||||
import {startReadStateCleanup} from '~/lib/ReadStateCleanup';
|
||||
import {Outlet, RouterProvider} from '~/lib/router';
|
||||
|
||||
import {router} from '~/router';
|
||||
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import ModalStore from '~/stores/ModalStore';
|
||||
import PopoutStore from '~/stores/PopoutStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
|
||||
import {ensureAutostartDefaultEnabled} from '~/utils/AutostartUtils';
|
||||
import {startDeepLinkHandling} from '~/utils/DeepLinkUtils';
|
||||
import {attachExternalLinkInterceptor, getElectronAPI, getNativePlatform} from '~/utils/NativeUtils';
|
||||
|
||||
import styles from './App.module.css';
|
||||
import Config from './Config';
|
||||
|
||||
interface AppWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
const saturationFactor = AccessibilityStore.saturationFactor;
|
||||
const alwaysUnderlineLinks = AccessibilityStore.alwaysUnderlineLinks;
|
||||
const enableTextSelection = AccessibilityStore.textSelectionEnabled;
|
||||
const fontSize = AccessibilityStore.fontSize;
|
||||
const messageGutter = AccessibilityStore.messageGutter;
|
||||
const messageGroupSpacing = AccessibilityStore.messageGroupSpacingValue;
|
||||
const reducedMotion = AccessibilityStore.useReducedMotion;
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
useElectronScreenSharePicker();
|
||||
const syncThemeAcrossDevices = AccessibilityStore.syncThemeAcrossDevices;
|
||||
const localThemeOverride = AccessibilityStore.localThemeOverride;
|
||||
const customThemeCss = AccessibilityStore.customThemeCss;
|
||||
const [layoutVariant, setLayoutVariant] = React.useState<LayoutVariant>('app');
|
||||
const layoutVariantContextValue = React.useMemo(
|
||||
() => ({variant: layoutVariant, setVariant: setLayoutVariant}),
|
||||
[layoutVariant],
|
||||
);
|
||||
|
||||
const popouts = PopoutStore.getPopouts();
|
||||
const topPopout = popouts.length ? popouts[popouts.length - 1] : null;
|
||||
const topPopoutRequiresBackdrop = Boolean(topPopout && !topPopout.disableBackdrop);
|
||||
|
||||
const userSettings = UserSettingsStore;
|
||||
const room = MediaEngineStore.room;
|
||||
const ringsContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const overlayScopeRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const recordActivity = useActivityRecorder();
|
||||
const handleUserActivity = React.useCallback(() => recordActivity(), [recordActivity]);
|
||||
const handleImmediateActivity = React.useCallback(() => recordActivity(true), [recordActivity]);
|
||||
const handleResize = React.useCallback(() => WindowActionCreators.resized(), []);
|
||||
useTextInputContextMenu();
|
||||
|
||||
const effectiveTheme = React.useMemo(() => {
|
||||
return UserSettingsStore.getTheme();
|
||||
}, [userSettings.theme, syncThemeAcrossDevices, localThemeOverride]);
|
||||
|
||||
const hasBlockingModal = ModalStore.hasModalOpen();
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = ringsContainerRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const shouldBlockBackground = hasBlockingModal || topPopoutRequiresBackdrop;
|
||||
|
||||
node.toggleAttribute('inert', shouldBlockBackground);
|
||||
|
||||
return () => {
|
||||
node.removeAttribute('inert');
|
||||
};
|
||||
}, [hasBlockingModal, topPopoutRequiresBackdrop]);
|
||||
|
||||
React.useEffect(() => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
startReadStateCleanup();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const postBadgeUpdate = (count: number) => {
|
||||
const controller = navigator.serviceWorker.controller;
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
controller.postMessage({type: 'APP_UPDATE_BADGE', count});
|
||||
} catch (error) {
|
||||
console.warn('[Badge] Failed to post badge update to service worker', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBadgeFromReadState = () => {
|
||||
const channelIds = ReadStateStore.getChannelIds();
|
||||
const totalMentions = channelIds.reduce((sum, channelId) => sum + ReadStateStore.getMentionCount(channelId), 0);
|
||||
postBadgeUpdate(totalMentions);
|
||||
};
|
||||
|
||||
const unsubscribe = ReadStateStore.subscribe(() => {
|
||||
updateBadgeFromReadState();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void KeybindManager.init(i18n);
|
||||
void CaptchaInterceptorStore;
|
||||
return () => {
|
||||
KeybindManager.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void AccessibilityStore.applyStoredZoom();
|
||||
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
|
||||
const unsubZoomIn = electronApi.onZoomIn?.(() => void AccessibilityStore.adjustZoom(0.1));
|
||||
const unsubZoomOut = electronApi.onZoomOut?.(() => void AccessibilityStore.adjustZoom(-0.1));
|
||||
const unsubZoomReset = electronApi.onZoomReset?.(() => AccessibilityStore.updateSettings({zoomLevel: 1.0}));
|
||||
const unsubOpenSettings = electronApi.onOpenSettings?.(() => {
|
||||
ModalActionCreators.push(ModalActionCreators.modal(() => <UserSettingsModal />));
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubZoomIn?.();
|
||||
unsubZoomOut?.();
|
||||
unsubZoomReset?.();
|
||||
unsubOpenSettings?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('reduced-motion', reducedMotion);
|
||||
return () => {
|
||||
root.classList.remove('reduced-motion');
|
||||
};
|
||||
}, [reducedMotion]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (Config.PUBLIC_BUILD_SHA && Config.PUBLIC_BUILD_TIMESTAMP) {
|
||||
const buildInfo = Config.PUBLIC_BUILD_NUMBER
|
||||
? `build ${Config.PUBLIC_BUILD_NUMBER} (${Config.PUBLIC_BUILD_SHA})`
|
||||
: Config.PUBLIC_BUILD_SHA;
|
||||
console.info(`[BUILD INFO] ${Config.PUBLIC_PROJECT_ENV} - ${buildInfo} - ${Config.PUBLIC_BUILD_TIMESTAMP}`);
|
||||
}
|
||||
|
||||
FocusManager.init();
|
||||
|
||||
const shouldRegisterWindowListeners = !isNative;
|
||||
if (shouldRegisterWindowListeners && document.hasFocus()) {
|
||||
document.documentElement.classList.add('window-focused');
|
||||
}
|
||||
|
||||
const preventScroll = (event: Event) => event.preventDefault();
|
||||
const handleBlur = () => {
|
||||
WindowActionCreators.focus(false);
|
||||
if (shouldRegisterWindowListeners) {
|
||||
document.documentElement.classList.remove('window-focused');
|
||||
}
|
||||
};
|
||||
const handleFocus = () => {
|
||||
WindowActionCreators.focus(true);
|
||||
if (shouldRegisterWindowListeners) {
|
||||
document.documentElement.classList.add('window-focused');
|
||||
}
|
||||
handleImmediateActivity();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
WindowActionCreators.visibilityChanged(!document.hidden);
|
||||
};
|
||||
|
||||
const preventPinchZoom = (event: TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
if (shouldRegisterWindowListeners) {
|
||||
document.addEventListener('scroll', preventScroll);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
window.addEventListener('focus', handleFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('mousedown', handleImmediateActivity);
|
||||
window.addEventListener('mousemove', handleUserActivity);
|
||||
window.addEventListener('keydown', handleUserActivity);
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('touchstart', handleImmediateActivity);
|
||||
document.addEventListener('touchstart', preventPinchZoom, {passive: false});
|
||||
document.addEventListener('touchmove', preventPinchZoom, {passive: false});
|
||||
}
|
||||
|
||||
return () => {
|
||||
FocusManager.destroy();
|
||||
if (shouldRegisterWindowListeners) {
|
||||
document.removeEventListener('scroll', preventScroll);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('mousedown', handleImmediateActivity);
|
||||
window.removeEventListener('mousemove', handleUserActivity);
|
||||
window.removeEventListener('keydown', handleUserActivity);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('touchstart', handleImmediateActivity);
|
||||
document.removeEventListener('touchstart', preventPinchZoom);
|
||||
document.removeEventListener('touchmove', preventPinchZoom);
|
||||
}
|
||||
};
|
||||
}, [handleImmediateActivity, handleUserActivity, handleResize, isNative]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isNative) {
|
||||
return;
|
||||
}
|
||||
const htmlNode = document.documentElement;
|
||||
const updateClass = (focused: boolean) => {
|
||||
htmlNode.classList.toggle('window-focused', focused);
|
||||
};
|
||||
const handleFocus = () => {
|
||||
updateClass(true);
|
||||
WindowActionCreators.focus(true);
|
||||
handleImmediateActivity();
|
||||
};
|
||||
const handleBlur = () => {
|
||||
updateClass(false);
|
||||
WindowActionCreators.focus(false);
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
WindowActionCreators.visibilityChanged(!document.hidden);
|
||||
};
|
||||
const preventPinchZoom = (event: TouchEvent) => {
|
||||
if (event.touches.length > 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
updateClass(document.hasFocus());
|
||||
window.addEventListener('focus', handleFocus);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('mousedown', handleImmediateActivity);
|
||||
window.addEventListener('mousemove', handleUserActivity);
|
||||
window.addEventListener('keydown', handleUserActivity);
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('touchstart', handleImmediateActivity);
|
||||
document.addEventListener('touchstart', preventPinchZoom, {passive: false});
|
||||
document.addEventListener('touchmove', preventPinchZoom, {passive: false});
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('mousedown', handleImmediateActivity);
|
||||
window.removeEventListener('mousemove', handleUserActivity);
|
||||
window.removeEventListener('keydown', handleUserActivity);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('touchstart', handleImmediateActivity);
|
||||
document.removeEventListener('touchstart', preventPinchZoom);
|
||||
document.removeEventListener('touchmove', preventPinchZoom);
|
||||
};
|
||||
}, [handleImmediateActivity, handleResize, handleUserActivity, isNative]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const htmlNode = document.documentElement;
|
||||
const platformClasses = [isNative ? 'platform-native' : 'platform-web', `platform-${platform}`];
|
||||
|
||||
htmlNode.classList.add(...platformClasses);
|
||||
|
||||
return () => {
|
||||
htmlNode.classList.remove(...platformClasses);
|
||||
};
|
||||
}, [isNative, platform]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isNative) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePageUnload = () => {
|
||||
const guildId = MediaEngineStore.guildId;
|
||||
const connected = MediaEngineStore.connected;
|
||||
const room = MediaEngineStore.room;
|
||||
const socket = ConnectionStore.socket;
|
||||
|
||||
if (socket && connected && guildId) {
|
||||
try {
|
||||
if (room) {
|
||||
room.disconnect(true);
|
||||
}
|
||||
|
||||
socket.updateVoiceState({
|
||||
guild_id: guildId,
|
||||
channel_id: null,
|
||||
self_mute: true,
|
||||
self_deaf: true,
|
||||
self_video: false,
|
||||
self_stream: false,
|
||||
connection_id: MediaEngineStore.connectionId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send disconnect on page unload:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handlePageUnload);
|
||||
window.addEventListener('pagehide', handlePageUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handlePageUnload);
|
||||
window.removeEventListener('pagehide', handlePageUnload);
|
||||
};
|
||||
}, [isNative]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const htmlNode = document.documentElement;
|
||||
htmlNode.classList.add(`theme-${effectiveTheme}`);
|
||||
htmlNode.style.setProperty('--saturation-factor', saturationFactor.toString());
|
||||
htmlNode.style.setProperty('--user-select', enableTextSelection ? 'auto' : 'none');
|
||||
htmlNode.style.setProperty('--font-size', `${fontSize}px`);
|
||||
htmlNode.style.setProperty('--chat-horizontal-padding', `${messageGutter}px`);
|
||||
htmlNode.style.setProperty('--message-group-spacing', `${messageGroupSpacing}px`);
|
||||
|
||||
if (alwaysUnderlineLinks) {
|
||||
htmlNode.style.setProperty('--link-decoration', 'underline');
|
||||
} else {
|
||||
htmlNode.style.removeProperty('--link-decoration');
|
||||
}
|
||||
|
||||
return () => {
|
||||
htmlNode.classList.remove(`theme-${effectiveTheme}`);
|
||||
htmlNode.style.removeProperty('--saturation-factor');
|
||||
htmlNode.style.removeProperty('--link-decoration');
|
||||
htmlNode.style.removeProperty('--user-select');
|
||||
htmlNode.style.removeProperty('--font-size');
|
||||
htmlNode.style.removeProperty('--chat-horizontal-padding');
|
||||
htmlNode.style.removeProperty('--message-group-spacing');
|
||||
};
|
||||
}, [
|
||||
effectiveTheme,
|
||||
saturationFactor,
|
||||
alwaysUnderlineLinks,
|
||||
enableTextSelection,
|
||||
fontSize,
|
||||
messageGutter,
|
||||
messageGroupSpacing,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const styleElementId = 'fluxer-custom-theme-style';
|
||||
const existing = document.getElementById(styleElementId) as HTMLStyleElement | null;
|
||||
|
||||
const css = customThemeCss?.trim() ?? '';
|
||||
|
||||
if (!css) {
|
||||
if (existing?.parentNode) {
|
||||
existing.parentNode.removeChild(existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const styleElement = existing ?? document.createElement('style');
|
||||
styleElement.id = styleElementId;
|
||||
styleElement.textContent = css;
|
||||
|
||||
if (!existing) {
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
}, [customThemeCss]);
|
||||
|
||||
return (
|
||||
<LayoutVariantProvider value={layoutVariantContextValue}>
|
||||
<SVGMasks />
|
||||
<RoomContext.Provider value={room ?? undefined}>
|
||||
{room && <RoomAudioRenderer />}
|
||||
<div ref={ringsContainerRef} className={styles.appContainer}>
|
||||
<FocusRingScope containerRef={ringsContainerRef}>
|
||||
<NativeTrafficLightsBackdrop variant={layoutVariant} />
|
||||
{isNative && !isMacOS && <NativeTitlebar platform={platform} />}
|
||||
{children}
|
||||
</FocusRingScope>
|
||||
</div>
|
||||
<div ref={overlayScopeRef} className={styles.overlayScope}>
|
||||
<div
|
||||
id={QUICK_SWITCHER_PORTAL_ID}
|
||||
className={styles.quickSwitcherPortal}
|
||||
data-overlay-pass-through="true"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<GlobalOverlays />
|
||||
<IncomingCallManager />
|
||||
</div>
|
||||
</RoomContext.Provider>
|
||||
</LayoutVariantProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export const App = observer((): React.ReactElement => {
|
||||
const currentUser = UserStore.currentUser;
|
||||
|
||||
React.useEffect(() => {
|
||||
const initAutostart = async () => {
|
||||
const platform = await getNativePlatform();
|
||||
if (platform === 'macos') {
|
||||
void ensureAutostartDefaultEnabled();
|
||||
}
|
||||
};
|
||||
|
||||
void initAutostart();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void startDeepLinkHandling();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const detach = attachExternalLinkInterceptor();
|
||||
return () => detach?.();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentUser) {
|
||||
Sentry.setUser({
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
email: currentUser.email ?? undefined,
|
||||
});
|
||||
} else {
|
||||
Sentry.setUser(null);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<IconContext.Provider value={{color: 'currentColor', weight: 'fill'}}>
|
||||
<RouterProvider router={router}>
|
||||
<AppWrapper>
|
||||
<Outlet />
|
||||
</AppWrapper>
|
||||
</RouterProvider>
|
||||
</IconContext.Provider>
|
||||
</I18nProvider>
|
||||
);
|
||||
});
|
||||
65
fluxer_app/src/Config.tsx
Normal file
65
fluxer_app/src/Config.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as v from 'valibot';
|
||||
|
||||
const envSchema = v.object({
|
||||
PUBLIC_BUILD_SHA: v.optional(v.string(), 'dev'),
|
||||
PUBLIC_BUILD_NUMBER: v.optional(v.pipe(v.string(), v.transform(Number), v.number()), '0'),
|
||||
PUBLIC_BUILD_TIMESTAMP: v.optional(
|
||||
v.pipe(v.string(), v.transform(Number), v.number()),
|
||||
`${Math.floor(Date.now() / 1000)}`,
|
||||
),
|
||||
PUBLIC_PROJECT_ENV: v.optional(v.picklist(['stable', 'canary', 'development']), 'development'),
|
||||
PUBLIC_SENTRY_DSN: v.optional(v.nullable(v.string()), null),
|
||||
PUBLIC_SENTRY_PROJECT_ID: v.optional(v.nullable(v.string()), null),
|
||||
PUBLIC_SENTRY_PUBLIC_KEY: v.optional(v.nullable(v.string()), null),
|
||||
PUBLIC_SENTRY_PROXY_PATH: v.optional(v.string(), '/error-reporting-proxy'),
|
||||
PUBLIC_API_VERSION: v.optional(v.pipe(v.string(), v.transform(Number), v.number()), '1'),
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: v.optional(v.string(), '/api'),
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const env = v.parse(envSchema, {
|
||||
PUBLIC_BUILD_SHA: import.meta.env.PUBLIC_BUILD_SHA,
|
||||
PUBLIC_BUILD_NUMBER: import.meta.env.PUBLIC_BUILD_NUMBER,
|
||||
PUBLIC_BUILD_TIMESTAMP: import.meta.env.PUBLIC_BUILD_TIMESTAMP,
|
||||
PUBLIC_PROJECT_ENV: import.meta.env.PUBLIC_PROJECT_ENV,
|
||||
PUBLIC_SENTRY_DSN: import.meta.env.PUBLIC_SENTRY_DSN,
|
||||
PUBLIC_SENTRY_PROJECT_ID: import.meta.env.PUBLIC_SENTRY_PROJECT_ID,
|
||||
PUBLIC_SENTRY_PUBLIC_KEY: import.meta.env.PUBLIC_SENTRY_PUBLIC_KEY,
|
||||
PUBLIC_SENTRY_PROXY_PATH: import.meta.env.PUBLIC_SENTRY_PROXY_PATH,
|
||||
PUBLIC_API_VERSION: import.meta.env.PUBLIC_API_VERSION,
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: import.meta.env.PUBLIC_BOOTSTRAP_API_ENDPOINT,
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: import.meta.env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT,
|
||||
});
|
||||
|
||||
export default {
|
||||
PUBLIC_BUILD_SHA: env.PUBLIC_BUILD_SHA,
|
||||
PUBLIC_BUILD_NUMBER: env.PUBLIC_BUILD_NUMBER,
|
||||
PUBLIC_BUILD_TIMESTAMP: env.PUBLIC_BUILD_TIMESTAMP,
|
||||
PUBLIC_PROJECT_ENV: env.PUBLIC_PROJECT_ENV,
|
||||
PUBLIC_SENTRY_DSN: env.PUBLIC_SENTRY_DSN,
|
||||
PUBLIC_SENTRY_PROJECT_ID: env.PUBLIC_SENTRY_PROJECT_ID,
|
||||
PUBLIC_SENTRY_PUBLIC_KEY: env.PUBLIC_SENTRY_PUBLIC_KEY,
|
||||
PUBLIC_SENTRY_PROXY_PATH: env.PUBLIC_SENTRY_PROXY_PATH,
|
||||
PUBLIC_API_VERSION: env.PUBLIC_API_VERSION,
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: env.PUBLIC_BOOTSTRAP_API_ENDPOINT,
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: env.PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT ?? env.PUBLIC_BOOTSTRAP_API_ENDPOINT,
|
||||
};
|
||||
648
fluxer_app/src/Constants.tsx
Normal file
648
fluxer_app/src/Constants.tsx
Normal file
@@ -0,0 +1,648 @@
|
||||
/*
|
||||
* 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 {I18n, MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
export const FLUXER_EPOCH = 1420070400000;
|
||||
export const ME = '@me';
|
||||
export const FAVORITES_GUILD_ID = '1337';
|
||||
export const FLUXERBOT_ID = '0';
|
||||
|
||||
export const OAuth2Scopes = ['identify', 'email', 'guilds', 'bot', 'applications.commands'] as const;
|
||||
export type OAuth2Scope = (typeof OAuth2Scopes)[number];
|
||||
|
||||
const OAuth2ScopeDescriptorsInternal: Record<OAuth2Scope, MessageDescriptor> = {
|
||||
identify: msg`Access your basic profile information (username, avatar, etc.)`,
|
||||
email: msg`View your email address`,
|
||||
guilds: msg`View the communities you are a member of`,
|
||||
bot: msg`Add a bot to a community with requested permissions`,
|
||||
'applications.commands': msg`Manage slash commands for this application`,
|
||||
};
|
||||
|
||||
export function getOAuth2ScopeDescription(i18n: I18n, scope: OAuth2Scope): string {
|
||||
return i18n._(OAuth2ScopeDescriptorsInternal[scope]);
|
||||
}
|
||||
|
||||
export const DEFAULT_ACCENT_COLOR = '#4641D9';
|
||||
|
||||
export const MAX_GUILDS_PREMIUM = 200;
|
||||
export const MAX_GUILDS_NON_PREMIUM = 100;
|
||||
export const MAX_GUILD_EMOJIS_ANIMATED = 50;
|
||||
export const MAX_GUILD_EMOJIS_STATIC = 50;
|
||||
export const MAX_GUILD_EMOJIS_ANIMATED_MORE_EMOJI = 250;
|
||||
export const MAX_GUILD_EMOJIS_STATIC_MORE_EMOJI = 250;
|
||||
export const MAX_GUILD_STICKERS = 50;
|
||||
export const MAX_GUILD_STICKERS_MORE_STICKERS = 250;
|
||||
export const MAX_CHANNELS_PER_CATEGORY = 50;
|
||||
|
||||
export const MAX_MESSAGE_LENGTH_PREMIUM = 4000;
|
||||
export const MAX_MESSAGE_LENGTH_NON_PREMIUM = 2000;
|
||||
export const MAX_ATTACHMENTS_PER_MESSAGE = 10;
|
||||
|
||||
export const MAX_MESSAGES_PER_CHANNEL = 50;
|
||||
export const MAX_LOADED_MESSAGES = MAX_MESSAGES_PER_CHANNEL * 4;
|
||||
export const TRUNCATED_MESSAGE_VIEW_SIZE = MAX_LOADED_MESSAGES * 0.5;
|
||||
export const MAX_MESSAGE_CACHE_SIZE = MAX_MESSAGES_PER_CHANNEL * 5;
|
||||
|
||||
export const NEW_MESSAGES_BAR_BUFFER = 32;
|
||||
|
||||
export const MAX_BOOKMARKS_PREMIUM = 300;
|
||||
export const MAX_BOOKMARKS_NON_PREMIUM = 50;
|
||||
export const MAX_FAVORITE_MEMES_PREMIUM = 500;
|
||||
export const MAX_FAVORITE_MEMES_NON_PREMIUM = 50;
|
||||
export const MAX_FAVORITE_MEME_TAGS = 10;
|
||||
|
||||
export const MAX_BIO_LENGTH_PREMIUM = 320;
|
||||
export const MAX_BIO_LENGTH_NON_PREMIUM = 160;
|
||||
|
||||
export const EMOJI_MAX_SIZE = 384 * 1024;
|
||||
export const SKIN_TONE_SURROGATES = ['🏻', '🏼', '🏽', '🏾', '🏿'];
|
||||
|
||||
export const STICKER_MAX_SIZE = 512 * 1024;
|
||||
|
||||
export const ATTACHMENT_MAX_SIZE_PREMIUM = 500 * 1024 * 1024;
|
||||
export const ATTACHMENT_MAX_SIZE_NON_PREMIUM = 25 * 1024 * 1024;
|
||||
|
||||
export const API_CODE_VERSION = 1;
|
||||
|
||||
export const UserAuthenticatorTypes = {
|
||||
TOTP: 0,
|
||||
SMS: 1,
|
||||
WEBAUTHN: 2,
|
||||
} as const;
|
||||
|
||||
export const UserPremiumTypes = {
|
||||
NONE: 0,
|
||||
SUBSCRIPTION: 1,
|
||||
LIFETIME: 2,
|
||||
} as const;
|
||||
|
||||
export const UserFlags = {
|
||||
STAFF: 1 << 0,
|
||||
CTP_MEMBER: 1 << 1,
|
||||
PARTNER: 1 << 2,
|
||||
BUG_HUNTER: 1 << 3,
|
||||
} as const;
|
||||
|
||||
export const StatusTypes = {
|
||||
ONLINE: 'online',
|
||||
DND: 'dnd',
|
||||
IDLE: 'idle',
|
||||
INVISIBLE: 'invisible',
|
||||
OFFLINE: 'offline',
|
||||
} as const;
|
||||
|
||||
export type StatusType = (typeof StatusTypes)[keyof typeof StatusTypes];
|
||||
|
||||
const STATUS_VALUES = Object.values(StatusTypes) as Array<StatusType>;
|
||||
const STATUS_SET = new Set<StatusType>(STATUS_VALUES);
|
||||
|
||||
export const isStatusType = (value: unknown): value is StatusType =>
|
||||
typeof value === 'string' && STATUS_SET.has(value as StatusType);
|
||||
|
||||
export const normalizeStatus = (value: unknown): StatusType => (isStatusType(value) ? value : StatusTypes.OFFLINE);
|
||||
|
||||
export const OFFLINE_STATUS_TYPES: Set<StatusType> = new Set([StatusTypes.OFFLINE, StatusTypes.INVISIBLE]);
|
||||
|
||||
export const isOfflineStatus = (
|
||||
status: StatusType,
|
||||
): status is typeof StatusTypes.OFFLINE | typeof StatusTypes.INVISIBLE =>
|
||||
status === StatusTypes.OFFLINE || status === StatusTypes.INVISIBLE;
|
||||
|
||||
const StatusTypeToLabelDescriptorsInternal: Record<StatusType, MessageDescriptor> = {
|
||||
[StatusTypes.ONLINE]: msg`Online`,
|
||||
[StatusTypes.DND]: msg`Do Not Disturb`,
|
||||
[StatusTypes.IDLE]: msg`Idle`,
|
||||
[StatusTypes.INVISIBLE]: msg`Invisible`,
|
||||
[StatusTypes.OFFLINE]: msg`Offline`,
|
||||
};
|
||||
|
||||
export function getStatusTypeLabel(i18n: I18n, statusType: StatusType | string): string {
|
||||
const normalized = isStatusType(statusType) ? statusType : normalizeStatus(statusType);
|
||||
return i18n._(StatusTypeToLabelDescriptorsInternal[normalized]);
|
||||
}
|
||||
|
||||
const StatusTypeToDescriptionDescriptorsInternal: Record<StatusType, MessageDescriptor> = {
|
||||
[StatusTypes.ONLINE]: msg`Charged up on 1.21 gigawatts, ready to talk`,
|
||||
[StatusTypes.DND]: msg`In the zone, please do not disturb`,
|
||||
[StatusTypes.IDLE]: msg`Took the DeLorean out, but I'll be back in time`,
|
||||
[StatusTypes.INVISIBLE]: msg`Currently stuck in 1885, appearing offline`,
|
||||
[StatusTypes.OFFLINE]: msg`Currently stuck in 1885, appearing offline`,
|
||||
};
|
||||
|
||||
export function getStatusTypeDescription(i18n: I18n, statusType: StatusType | string): string {
|
||||
const normalized = isStatusType(statusType) ? statusType : normalizeStatus(statusType);
|
||||
return i18n._(StatusTypeToDescriptionDescriptorsInternal[normalized]);
|
||||
}
|
||||
|
||||
export const InviteTypes = {
|
||||
GUILD: 0,
|
||||
GROUP_DM: 1,
|
||||
EMOJI_PACK: 2,
|
||||
STICKER_PACK: 3,
|
||||
} as const;
|
||||
|
||||
export const ThemeTypes = {
|
||||
DARK: 'dark',
|
||||
COAL: 'coal',
|
||||
LIGHT: 'light',
|
||||
SYSTEM: 'system',
|
||||
} as const;
|
||||
|
||||
export const FeatureFlags = {
|
||||
MESSAGE_SCHEDULING: 'message_scheduling',
|
||||
EXPRESSION_PACKS: 'expression_packs',
|
||||
} as const;
|
||||
|
||||
export type FeatureFlag = (typeof FeatureFlags)[keyof typeof FeatureFlags];
|
||||
|
||||
export const ALL_FEATURE_FLAGS: Array<FeatureFlag> = Object.values(FeatureFlags);
|
||||
|
||||
export const TimeFormatTypes = {
|
||||
AUTO: 0,
|
||||
TWELVE_HOUR: 1,
|
||||
TWENTY_FOUR_HOUR: 2,
|
||||
} as const;
|
||||
|
||||
export const StickerAnimationOptions = {
|
||||
ALWAYS_ANIMATE: 0,
|
||||
ANIMATE_ON_INTERACTION: 1,
|
||||
NEVER_ANIMATE: 2,
|
||||
} as const;
|
||||
|
||||
export const RenderSpoilers = {
|
||||
ALWAYS: 0,
|
||||
ON_CLICK: 1,
|
||||
IF_MODERATOR: 2,
|
||||
} as const;
|
||||
|
||||
export const GuildExplicitContentFilterTypes = {
|
||||
DISABLED: 0,
|
||||
MEMBERS_WITHOUT_ROLES: 1,
|
||||
ALL_MEMBERS: 2,
|
||||
} as const;
|
||||
|
||||
export const FriendSourceFlags = {
|
||||
MUTUAL_FRIENDS: 1 << 0,
|
||||
MUTUAL_GUILDS: 1 << 1,
|
||||
NO_RELATION: 1 << 2,
|
||||
} as const;
|
||||
|
||||
export const IncomingCallFlags = {
|
||||
FRIENDS_OF_FRIENDS: 1 << 0,
|
||||
GUILD_MEMBERS: 1 << 1,
|
||||
EVERYONE: 1 << 2,
|
||||
FRIENDS_ONLY: 1 << 3,
|
||||
NOBODY: 1 << 4,
|
||||
SILENT_EVERYONE: 1 << 5,
|
||||
} as const;
|
||||
|
||||
export const GroupDmAddPermissionFlags = {
|
||||
FRIENDS_OF_FRIENDS: 1 << 0,
|
||||
GUILD_MEMBERS: 1 << 1,
|
||||
EVERYONE: 1 << 2,
|
||||
FRIENDS_ONLY: 1 << 3,
|
||||
NOBODY: 1 << 4,
|
||||
} as const;
|
||||
|
||||
export const MessageNotifications = {
|
||||
NULL: -1,
|
||||
ALL_MESSAGES: 0,
|
||||
ONLY_MENTIONS: 1,
|
||||
NO_MESSAGES: 2,
|
||||
INHERIT: 3,
|
||||
} as const;
|
||||
|
||||
export const RelationshipTypes = {
|
||||
FRIEND: 1,
|
||||
BLOCKED: 2,
|
||||
INCOMING_REQUEST: 3,
|
||||
OUTGOING_REQUEST: 4,
|
||||
} as const;
|
||||
|
||||
export const GuildVerificationLevel = {
|
||||
NONE: 0,
|
||||
LOW: 1,
|
||||
MEDIUM: 2,
|
||||
HIGH: 3,
|
||||
VERY_HIGH: 4,
|
||||
} as const;
|
||||
|
||||
export const GuildMFALevel = {
|
||||
NONE: 0,
|
||||
ELEVATED: 1,
|
||||
} as const;
|
||||
|
||||
export const GuildSplashCardAlignment = {
|
||||
CENTER: 0,
|
||||
LEFT: 1,
|
||||
RIGHT: 2,
|
||||
} as const;
|
||||
|
||||
export type GuildSplashCardAlignmentValue = (typeof GuildSplashCardAlignment)[keyof typeof GuildSplashCardAlignment];
|
||||
|
||||
export const SystemChannelFlags = {
|
||||
SUPPRESS_JOIN_NOTIFICATIONS: 1 << 0,
|
||||
} as const;
|
||||
|
||||
export const GuildOperations = {
|
||||
PUSH_NOTIFICATIONS: 1 << 0,
|
||||
EVERYONE_MENTIONS: 1 << 1,
|
||||
TYPING_EVENTS: 1 << 2,
|
||||
INSTANT_INVITES: 1 << 3,
|
||||
SEND_MESSAGE: 1 << 4,
|
||||
REACTIONS: 1 << 5,
|
||||
} as const;
|
||||
|
||||
export const StickerFormatTypes = {
|
||||
PNG: 1,
|
||||
APNG: 2,
|
||||
LOTTIE: 3,
|
||||
GIF: 4,
|
||||
} as const;
|
||||
|
||||
export const GuildMemberProfileFlags = {
|
||||
AVATAR_UNSET: 1 << 0,
|
||||
BANNER_UNSET: 1 << 1,
|
||||
} as const;
|
||||
|
||||
export const ChannelTypes = {
|
||||
GUILD_TEXT: 0,
|
||||
DM: 1,
|
||||
GUILD_VOICE: 2,
|
||||
GROUP_DM: 3,
|
||||
GUILD_CATEGORY: 4,
|
||||
GUILD_LINK: 998,
|
||||
DM_PERSONAL_NOTES: 999,
|
||||
} as const;
|
||||
|
||||
export const TEXT_BASED_CHANNEL_TYPES = new Set<number>([
|
||||
ChannelTypes.GUILD_TEXT,
|
||||
ChannelTypes.DM,
|
||||
ChannelTypes.DM_PERSONAL_NOTES,
|
||||
ChannelTypes.GROUP_DM,
|
||||
]);
|
||||
|
||||
export const QuickSwitcherResultTypes = {
|
||||
HEADER: 'header',
|
||||
USER: 'user',
|
||||
GROUP_DM: 'group_dm',
|
||||
TEXT_CHANNEL: 'text_channel',
|
||||
VOICE_CHANNEL: 'voice_channel',
|
||||
GUILD: 'guild',
|
||||
VIRTUAL_GUILD: 'virtual_guild',
|
||||
SETTINGS: 'settings',
|
||||
QUICK_ACTION: 'quick_action',
|
||||
LINK: 'link',
|
||||
} as const;
|
||||
|
||||
export type QuickSwitcherResultType = (typeof QuickSwitcherResultTypes)[keyof typeof QuickSwitcherResultTypes];
|
||||
|
||||
export const MessageTypes = {
|
||||
DEFAULT: 0,
|
||||
RECIPIENT_ADD: 1,
|
||||
RECIPIENT_REMOVE: 2,
|
||||
CALL: 3,
|
||||
CHANNEL_NAME_CHANGE: 4,
|
||||
CHANNEL_ICON_CHANGE: 5,
|
||||
CHANNEL_PINNED_MESSAGE: 6,
|
||||
USER_JOIN: 7,
|
||||
REPLY: 19,
|
||||
CLIENT_SYSTEM: 99,
|
||||
} as const;
|
||||
export type MessageTypeValue = (typeof MessageTypes)[keyof typeof MessageTypes];
|
||||
|
||||
export const MESSAGE_TYPE_DELETABLE = {
|
||||
[MessageTypes.DEFAULT]: true,
|
||||
[MessageTypes.REPLY]: true,
|
||||
[MessageTypes.CHANNEL_PINNED_MESSAGE]: true,
|
||||
[MessageTypes.USER_JOIN]: true,
|
||||
[MessageTypes.RECIPIENT_ADD]: false,
|
||||
[MessageTypes.RECIPIENT_REMOVE]: false,
|
||||
[MessageTypes.CALL]: false,
|
||||
[MessageTypes.CHANNEL_NAME_CHANGE]: false,
|
||||
[MessageTypes.CHANNEL_ICON_CHANGE]: false,
|
||||
[MessageTypes.CLIENT_SYSTEM]: false,
|
||||
} as const satisfies Record<MessageTypeValue, boolean>;
|
||||
|
||||
export const isMessageTypeDeletable = (type: number): boolean => {
|
||||
return type in MESSAGE_TYPE_DELETABLE ? MESSAGE_TYPE_DELETABLE[type as MessageTypeValue] : false;
|
||||
};
|
||||
|
||||
export const MessageFlags = {
|
||||
SUPPRESS_EMBEDS: 1 << 2,
|
||||
SUPPRESS_NOTIFICATIONS: 1 << 12,
|
||||
COMPACT_ATTACHMENTS: 1 << 17,
|
||||
} as const;
|
||||
|
||||
export const MessageAttachmentFlags = {
|
||||
IS_SPOILER: 1 << 3,
|
||||
CONTAINS_EXPLICIT_MEDIA: 1 << 4,
|
||||
IS_ANIMATED: 1 << 5,
|
||||
} as const;
|
||||
|
||||
export const MessageEmbedTypes = {
|
||||
RICH: 'rich',
|
||||
ARTICLE: 'article',
|
||||
LINK: 'link',
|
||||
IMAGE: 'image',
|
||||
VIDEO: 'video',
|
||||
AUDIO: 'audio',
|
||||
GIFV: 'gifv',
|
||||
} as const;
|
||||
|
||||
export const MessageStates = {
|
||||
SENT: 'SENT',
|
||||
SENDING: 'SENDING',
|
||||
EDITING: 'EDITING',
|
||||
FAILED: 'FAILED',
|
||||
} as const;
|
||||
|
||||
export const MessagePreviewContext = {
|
||||
SETTINGS: 'SETTINGS',
|
||||
LIST_POPOUT: 'LIST_POPOUT',
|
||||
} as const;
|
||||
|
||||
export const Permissions = {
|
||||
CREATE_INSTANT_INVITE: 1n << 0n,
|
||||
KICK_MEMBERS: 1n << 1n,
|
||||
BAN_MEMBERS: 1n << 2n,
|
||||
ADMINISTRATOR: 1n << 3n,
|
||||
MANAGE_CHANNELS: 1n << 4n,
|
||||
MANAGE_GUILD: 1n << 5n,
|
||||
ADD_REACTIONS: 1n << 6n,
|
||||
VIEW_AUDIT_LOG: 1n << 7n,
|
||||
PRIORITY_SPEAKER: 1n << 8n,
|
||||
STREAM: 1n << 9n,
|
||||
VIEW_CHANNEL: 1n << 10n,
|
||||
SEND_MESSAGES: 1n << 11n,
|
||||
SEND_TTS_MESSAGES: 1n << 12n,
|
||||
MANAGE_MESSAGES: 1n << 13n,
|
||||
EMBED_LINKS: 1n << 14n,
|
||||
ATTACH_FILES: 1n << 15n,
|
||||
READ_MESSAGE_HISTORY: 1n << 16n,
|
||||
MENTION_EVERYONE: 1n << 17n,
|
||||
USE_EXTERNAL_EMOJIS: 1n << 18n,
|
||||
CONNECT: 1n << 20n,
|
||||
SPEAK: 1n << 21n,
|
||||
MUTE_MEMBERS: 1n << 22n,
|
||||
DEAFEN_MEMBERS: 1n << 23n,
|
||||
MOVE_MEMBERS: 1n << 24n,
|
||||
USE_VAD: 1n << 25n,
|
||||
CHANGE_NICKNAME: 1n << 26n,
|
||||
MANAGE_NICKNAMES: 1n << 27n,
|
||||
MANAGE_ROLES: 1n << 28n,
|
||||
MANAGE_WEBHOOKS: 1n << 29n,
|
||||
MANAGE_EXPRESSIONS: 1n << 30n,
|
||||
USE_EXTERNAL_STICKERS: 1n << 37n,
|
||||
MODERATE_MEMBERS: 1n << 40n,
|
||||
CREATE_EXPRESSIONS: 1n << 43n,
|
||||
PIN_MESSAGES: 1n << 51n,
|
||||
BYPASS_SLOWMODE: 1n << 52n,
|
||||
UPDATE_RTC_REGION: 1n << 53n,
|
||||
} as const;
|
||||
|
||||
export const ALL_PERMISSIONS = Object.values(Permissions).reduce((acc, p) => acc | p, 0n);
|
||||
|
||||
export const DEFAULT_PERMISSIONS =
|
||||
Permissions.CREATE_INSTANT_INVITE |
|
||||
Permissions.ADD_REACTIONS |
|
||||
Permissions.STREAM |
|
||||
Permissions.VIEW_CHANNEL |
|
||||
Permissions.SEND_MESSAGES |
|
||||
Permissions.EMBED_LINKS |
|
||||
Permissions.ATTACH_FILES |
|
||||
Permissions.READ_MESSAGE_HISTORY |
|
||||
Permissions.USE_EXTERNAL_EMOJIS |
|
||||
Permissions.CONNECT |
|
||||
Permissions.SPEAK |
|
||||
Permissions.USE_VAD |
|
||||
Permissions.CHANGE_NICKNAME |
|
||||
Permissions.USE_EXTERNAL_STICKERS |
|
||||
Permissions.CREATE_EXPRESSIONS;
|
||||
|
||||
export const ElevatedPermissions =
|
||||
Permissions.KICK_MEMBERS |
|
||||
Permissions.BAN_MEMBERS |
|
||||
Permissions.ADMINISTRATOR |
|
||||
Permissions.MANAGE_CHANNELS |
|
||||
Permissions.MANAGE_GUILD |
|
||||
Permissions.MANAGE_ROLES |
|
||||
Permissions.MANAGE_MESSAGES |
|
||||
Permissions.MANAGE_WEBHOOKS |
|
||||
Permissions.MANAGE_EXPRESSIONS |
|
||||
Permissions.MODERATE_MEMBERS;
|
||||
|
||||
export const GatewayOpcodes = {
|
||||
DISPATCH: 0,
|
||||
HEARTBEAT: 1,
|
||||
IDENTIFY: 2,
|
||||
PRESENCE_UPDATE: 3,
|
||||
VOICE_STATE_UPDATE: 4,
|
||||
VOICE_SERVER_PING: 5,
|
||||
RESUME: 6,
|
||||
RECONNECT: 7,
|
||||
REQUEST_GUILD_MEMBERS: 8,
|
||||
INVALID_SESSION: 9,
|
||||
HELLO: 10,
|
||||
HEARTBEAT_ACK: 11,
|
||||
GATEWAY_ERROR: 12,
|
||||
CALL_CONNECT: 13,
|
||||
LAZY_REQUEST: 14,
|
||||
} as const;
|
||||
|
||||
export const LARGE_GUILD_THRESHOLD = 250;
|
||||
export const MEMBER_CHUNK_SIZE = 1000;
|
||||
|
||||
export const GatewayIdentifyFlags = {
|
||||
USE_CANARY_API: 1 << 0,
|
||||
} as const;
|
||||
|
||||
export const GatewayCloseCodes = {
|
||||
UNKNOWN_ERROR: 4000,
|
||||
UNKNOWN_OPCODE: 4001,
|
||||
DECODE_ERROR: 4002,
|
||||
NOT_AUTHENTICATED: 4003,
|
||||
AUTHENTICATION_FAILED: 4004,
|
||||
ALREADY_AUTHENTICATED: 4005,
|
||||
INVALID_SEQ: 4007,
|
||||
RATE_LIMITED: 4008,
|
||||
SESSION_TIMEOUT: 4009,
|
||||
INVALID_SHARD: 4010,
|
||||
SHARDING_REQUIRED: 4011,
|
||||
INVALID_API_VERSION: 4012,
|
||||
} as const;
|
||||
|
||||
export const GatewayErrorCodes = {
|
||||
VOICE_CONNECTION_NOT_FOUND: 'VOICE_CONNECTION_NOT_FOUND',
|
||||
VOICE_CHANNEL_NOT_FOUND: 'VOICE_CHANNEL_NOT_FOUND',
|
||||
VOICE_INVALID_CHANNEL_TYPE: 'VOICE_INVALID_CHANNEL_TYPE',
|
||||
VOICE_MEMBER_NOT_FOUND: 'VOICE_MEMBER_NOT_FOUND',
|
||||
VOICE_MEMBER_TIMED_OUT: 'VOICE_MEMBER_TIMED_OUT',
|
||||
VOICE_USER_NOT_IN_VOICE: 'VOICE_USER_NOT_IN_VOICE',
|
||||
VOICE_GUILD_NOT_FOUND: 'VOICE_GUILD_NOT_FOUND',
|
||||
VOICE_PERMISSION_DENIED: 'VOICE_PERMISSION_DENIED',
|
||||
VOICE_CHANNEL_FULL: 'VOICE_CHANNEL_FULL',
|
||||
VOICE_MISSING_CONNECTION_ID: 'VOICE_MISSING_CONNECTION_ID',
|
||||
VOICE_INVALID_USER_ID: 'VOICE_INVALID_USER_ID',
|
||||
VOICE_INVALID_CHANNEL_ID: 'VOICE_INVALID_CHANNEL_ID',
|
||||
VOICE_INVALID_STATE: 'VOICE_INVALID_STATE',
|
||||
VOICE_USER_MISMATCH: 'VOICE_USER_MISMATCH',
|
||||
VOICE_TOKEN_FAILED: 'VOICE_TOKEN_FAILED',
|
||||
VOICE_GUILD_ID_MISSING: 'VOICE_GUILD_ID_MISSING',
|
||||
VOICE_INVALID_GUILD_ID: 'VOICE_INVALID_GUILD_ID',
|
||||
DM_NOT_RECIPIENT: 'DM_NOT_RECIPIENT',
|
||||
DM_INVALID_CHANNEL_TYPE: 'DM_INVALID_CHANNEL_TYPE',
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
||||
} as const;
|
||||
export type GatewayErrorCode = (typeof GatewayErrorCodes)[keyof typeof GatewayErrorCodes];
|
||||
|
||||
export const APIErrorCodes = {
|
||||
GENERAL_ERROR: 'GENERAL_ERROR',
|
||||
UNKNOWN_CHANNEL: 'UNKNOWN_CHANNEL',
|
||||
UNKNOWN_GUILD: 'UNKNOWN_GUILD',
|
||||
UNKNOWN_INVITE: 'UNKNOWN_INVITE',
|
||||
UNKNOWN_MEMBER: 'UNKNOWN_MEMBER',
|
||||
UNKNOWN_MESSAGE: 'UNKNOWN_MESSAGE',
|
||||
UNKNOWN_ROLE: 'UNKNOWN_ROLE',
|
||||
UNKNOWN_USER: 'UNKNOWN_USER',
|
||||
UNKNOWN_EMOJI: 'UNKNOWN_EMOJI',
|
||||
UNKNOWN_PACK: 'UNKNOWN_PACK',
|
||||
UNKNOWN_WEBHOOK: 'UNKNOWN_WEBHOOK',
|
||||
UNKNOWN_BETA_CODE: 'UNKNOWN_BETA_CODE',
|
||||
EXPLICIT_CONTENT_CANNOT_BE_SENT: 'EXPLICIT_CONTENT_CANNOT_BE_SENT',
|
||||
FILE_SIZE_TOO_LARGE: 'FILE_SIZE_TOO_LARGE',
|
||||
MAX_GUILDS: 'MAX_GUILDS',
|
||||
MAX_FRIENDS: 'MAX_FRIENDS',
|
||||
MAX_PINS_PER_CHANNEL: 'MAX_PINS_PER_CHANNEL',
|
||||
MAX_GUILD_ROLES: 'MAX_GUILD_ROLES',
|
||||
MAX_WEBHOOKS: 'MAX_WEBHOOKS',
|
||||
MAX_EMOJIS: 'MAX_EMOJIS',
|
||||
MAX_REACTIONS: 'MAX_REACTIONS',
|
||||
MAX_GUILD_CHANNELS: 'MAX_GUILD_CHANNELS',
|
||||
MAX_CATEGORY_CHANNELS: 'MAX_CATEGORY_CHANNELS',
|
||||
MAX_INVITES: 'MAX_INVITES',
|
||||
MAX_PACKS: 'MAX_PACKS',
|
||||
MAX_ANIMATED_EMOJIS: 'MAX_ANIMATED_EMOJIS',
|
||||
MAX_GUILD_MEMBERS: 'MAX_GUILD_MEMBERS',
|
||||
MAX_WEBHOOKS_PER_GUILD: 'MAX_WEBHOOKS_PER_GUILD',
|
||||
MAX_BETA_CODES_REACHED: 'MAX_BETA_CODES_REACHED',
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
SLOWMODE_RATE_LIMITED: 'SLOWMODE_RATE_LIMITED',
|
||||
CAPTCHA_REQUIRED: 'CAPTCHA_REQUIRED',
|
||||
INVALID_CAPTCHA: 'INVALID_CAPTCHA',
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
USER_BANNED_FROM_GUILD: 'USER_BANNED_FROM_GUILD',
|
||||
USER_IP_BANNED_FROM_GUILD: 'USER_IP_BANNED_FROM_GUILD',
|
||||
MISSING_ACCESS: 'MISSING_ACCESS',
|
||||
PREMIUM_REQUIRED: 'PREMIUM_REQUIRED',
|
||||
CANNOT_EXECUTE_ON_DM: 'CANNOT_EXECUTE_ON_DM',
|
||||
CANNOT_EDIT_OTHER_USER_MESSAGE: 'CANNOT_EDIT_OTHER_USER_MESSAGE',
|
||||
CANNOT_SEND_EMPTY_MESSAGE: 'CANNOT_SEND_EMPTY_MESSAGE',
|
||||
CANNOT_SEND_MESSAGES_TO_USER: 'CANNOT_SEND_MESSAGES_TO_USER',
|
||||
CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL: 'CANNOT_SEND_MESSAGES_IN_NON_TEXT_CHANNEL',
|
||||
COMMUNICATION_DISABLED: 'COMMUNICATION_DISABLED',
|
||||
MISSING_PERMISSIONS: 'MISSING_PERMISSIONS',
|
||||
INVALID_FORM_BODY: 'INVALID_FORM_BODY',
|
||||
CANNOT_MODIFY_SYSTEM_WEBHOOK: 'CANNOT_MODIFY_SYSTEM_WEBHOOK',
|
||||
TWO_FACTOR_REQUIRED: 'TWO_FACTOR_REQUIRED',
|
||||
FRIEND_REQUEST_BLOCKED: 'FRIEND_REQUEST_BLOCKED',
|
||||
CANNOT_SEND_FRIEND_REQUEST_TO_BLOCKED_USER: 'CANNOT_SEND_FRIEND_REQUEST_TO_BLOCKED_USER',
|
||||
BOTS_CANNOT_HAVE_FRIENDS: 'BOTS_CANNOT_HAVE_FRIENDS',
|
||||
CANNOT_SEND_FRIEND_REQUEST_TO_SELF: 'CANNOT_SEND_FRIEND_REQUEST_TO_SELF',
|
||||
NO_USERS_WITH_FLUXERTAG_EXIST: 'NO_USERS_WITH_FLUXERTAG_EXIST',
|
||||
ALREADY_FRIENDS: 'ALREADY_FRIENDS',
|
||||
DISCRIMINATOR_REQUIRED: 'DISCRIMINATOR_REQUIRED',
|
||||
TWO_FA_NOT_ENABLED: 'TWO_FA_NOT_ENABLED',
|
||||
MISSING_ACL: 'MISSING_ACL',
|
||||
IP_BANNED: 'IP_BANNED',
|
||||
IP_AUTHORIZATION_REQUIRED: 'IP_AUTHORIZATION_REQUIRED',
|
||||
USER_NOT_IN_VOICE: 'USER_NOT_IN_VOICE',
|
||||
CANNOT_MODIFY_VOICE_STATE: 'CANNOT_MODIFY_VOICE_STATE',
|
||||
ACCOUNT_DISABLED: 'ACCOUNT_DISABLED',
|
||||
ACCOUNT_SCHEDULED_FOR_DELETION: 'ACCOUNT_SCHEDULED_FOR_DELETION',
|
||||
TAG_ALREADY_TAKEN: 'TAG_ALREADY_TAKEN',
|
||||
INVALID_PHONE_NUMBER: 'INVALID_PHONE_NUMBER',
|
||||
PHONE_ALREADY_USED: 'PHONE_ALREADY_USED',
|
||||
PHONE_VERIFICATION_REQUIRED: 'PHONE_VERIFICATION_REQUIRED',
|
||||
INVALID_PHONE_VERIFICATION_CODE: 'INVALID_PHONE_VERIFICATION_CODE',
|
||||
PHONE_RATE_LIMIT_EXCEEDED: 'PHONE_RATE_LIMIT_EXCEEDED',
|
||||
SMS_MFA_NOT_ENABLED: 'SMS_MFA_NOT_ENABLED',
|
||||
SMS_MFA_REQUIRES_TOTP: 'SMS_MFA_REQUIRES_TOTP',
|
||||
PHONE_REQUIRED_FOR_SMS_MFA: 'PHONE_REQUIRED_FOR_SMS_MFA',
|
||||
INVALID_WEBAUTHN_CREDENTIAL: 'INVALID_WEBAUTHN_CREDENTIAL',
|
||||
WEBAUTHN_CREDENTIAL_LIMIT_REACHED: 'WEBAUTHN_CREDENTIAL_LIMIT_REACHED',
|
||||
UNKNOWN_WEBAUTHN_CREDENTIAL: 'UNKNOWN_WEBAUTHN_CREDENTIAL',
|
||||
PASSKEY_AUTHENTICATION_FAILED: 'PASSKEY_AUTHENTICATION_FAILED',
|
||||
ACCOUNT_SUSPICIOUS_ACTIVITY: 'ACCOUNT_SUSPICIOUS_ACTIVITY',
|
||||
PREMIUM_PURCHASE_BLOCKED: 'PREMIUM_PURCHASE_BLOCKED',
|
||||
HARVEST_ON_COOLDOWN: 'HARVEST_ON_COOLDOWN',
|
||||
UNKNOWN_GIFT_CODE: 'UNKNOWN_GIFT_CODE',
|
||||
GIFT_CODE_ALREADY_REDEEMED: 'GIFT_CODE_ALREADY_REDEEMED',
|
||||
STRIPE_ERROR: 'STRIPE_ERROR',
|
||||
STRIPE_WEBHOOK_SIGNATURE_INVALID: 'STRIPE_WEBHOOK_SIGNATURE_INVALID',
|
||||
USER_OWNS_GUILDS: 'USER_OWNS_GUILDS',
|
||||
FEATURE_TEMPORARILY_DISABLED: 'FEATURE_TEMPORARILY_DISABLED',
|
||||
INVITES_DISABLED: 'INVITES_DISABLED',
|
||||
TEMPORARY_INVITE_REQUIRES_PRESENCE: 'TEMPORARY_INVITE_REQUIRES_PRESENCE',
|
||||
MAX_GROUP_DM_RECIPIENTS: 'MAX_GROUP_DM_RECIPIENTS',
|
||||
NOT_FRIENDS_WITH_USER: 'NOT_FRIENDS_WITH_USER',
|
||||
INVALID_CHANNEL_TYPE: 'INVALID_CHANNEL_TYPE',
|
||||
NSFW_CONTENT_AGE_RESTRICTED: 'NSFW_CONTENT_AGE_RESTRICTED',
|
||||
MAX_BOOKMARKS: 'MAX_BOOKMARKS',
|
||||
MAX_PACK_EXPRESSIONS: 'MAX_PACK_EXPRESSIONS',
|
||||
MAX_FAVORITE_MEMES: 'MAX_FAVORITE_MEMES',
|
||||
CANNOT_REDEEM_PLUTONIUM_WITH_VISIONARY: 'CANNOT_REDEEM_PLUTONIUM_WITH_VISIONARY',
|
||||
NO_VISIONARY_SLOTS_AVAILABLE: 'NO_VISIONARY_SLOTS_AVAILABLE',
|
||||
CANNOT_SHRINK_RESERVED_SLOTS: 'CANNOT_SHRINK_RESERVED_SLOTS',
|
||||
UNCLAIMED_ACCOUNT_RESTRICTED: 'UNCLAIMED_ACCOUNT_RESTRICTED',
|
||||
GUILD_DISALLOWS_UNCLAIMED_ACCOUNTS: 'GUILD_DISALLOWS_UNCLAIMED_ACCOUNTS',
|
||||
} as const;
|
||||
|
||||
export const GuildFeatures = {
|
||||
ANIMATED_ICON: 'ANIMATED_ICON',
|
||||
ANIMATED_BANNER: 'ANIMATED_BANNER',
|
||||
BANNER: 'BANNER',
|
||||
DETACHED_BANNER: 'DETACHED_BANNER',
|
||||
INVITE_SPLASH: 'INVITE_SPLASH',
|
||||
INVITES_DISABLED: 'INVITES_DISABLED',
|
||||
TEXT_CHANNEL_FLEXIBLE_NAMES: 'TEXT_CHANNEL_FLEXIBLE_NAMES',
|
||||
MORE_EMOJI: 'MORE_EMOJI',
|
||||
MORE_STICKERS: 'MORE_STICKERS',
|
||||
UNLIMITED_EMOJI: 'UNLIMITED_EMOJI',
|
||||
UNLIMITED_STICKERS: 'UNLIMITED_STICKERS',
|
||||
EXPRESSION_PURGE_ALLOWED: 'EXPRESSION_PURGE_ALLOWED',
|
||||
VANITY_URL: 'VANITY_URL',
|
||||
VERIFIED: 'VERIFIED',
|
||||
VIP_VOICE: 'VIP_VOICE',
|
||||
UNAVAILABLE_FOR_EVERYONE: 'UNAVAILABLE_FOR_EVERYONE',
|
||||
UNAVAILABLE_FOR_EVERYONE_BUT_STAFF: 'UNAVAILABLE_FOR_EVERYONE_BUT_STAFF',
|
||||
VISIONARY: 'VISIONARY',
|
||||
OPERATOR: 'OPERATOR',
|
||||
DISALLOW_UNCLAIMED_ACCOUNTS: 'DISALLOW_UNCLAIMED_ACCOUNTS',
|
||||
LARGE_GUILD_OVERRIDE: 'LARGE_GUILD_OVERRIDE',
|
||||
} as const;
|
||||
|
||||
export const JumpTypes = {
|
||||
ANIMATED: 'ANIMATED',
|
||||
INSTANT: 'INSTANT',
|
||||
NONE: 'NONE',
|
||||
} as const;
|
||||
export type JumpTypes = (typeof JumpTypes)[keyof typeof JumpTypes];
|
||||
231
fluxer_app/src/Endpoints.tsx
Normal file
231
fluxer_app/src/Endpoints.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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 {ME} from '~/Constants';
|
||||
|
||||
export const Endpoints = {
|
||||
INSTANCE: '/instance',
|
||||
|
||||
AUTH_LOGIN: '/auth/login',
|
||||
AUTH_LOGIN_MFA_TOTP: '/auth/login/mfa/totp',
|
||||
AUTH_LOGIN_MFA_SMS_SEND: '/auth/login/mfa/sms/send',
|
||||
AUTH_LOGIN_MFA_SMS: '/auth/login/mfa/sms',
|
||||
AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS: '/auth/login/mfa/webauthn/authentication-options',
|
||||
AUTH_LOGIN_MFA_WEBAUTHN: '/auth/login/mfa/webauthn',
|
||||
AUTH_WEBAUTHN_OPTIONS: '/auth/webauthn/authentication-options',
|
||||
AUTH_WEBAUTHN_AUTHENTICATE: '/auth/webauthn/authenticate',
|
||||
AUTH_LOGOUT: '/auth/logout',
|
||||
AUTH_REGISTER: '/auth/register',
|
||||
AUTH_USERNAME_SUGGESTIONS: '/auth/username-suggestions',
|
||||
AUTH_REDEEM_BETA_CODE: '/auth/redeem-beta-code',
|
||||
AUTH_SESSIONS: '/auth/sessions',
|
||||
AUTH_SESSIONS_LOGOUT: '/auth/sessions/logout',
|
||||
AUTH_HANDOFF_INITIATE: '/auth/handoff/initiate',
|
||||
AUTH_HANDOFF_COMPLETE: '/auth/handoff/complete',
|
||||
AUTH_HANDOFF_STATUS: (code: string) => `/auth/handoff/${code}/status`,
|
||||
AUTH_HANDOFF_CANCEL: (code: string) => `/auth/handoff/${code}`,
|
||||
AUTH_FORGOT_PASSWORD: '/auth/forgot',
|
||||
AUTH_RESET_PASSWORD: '/auth/reset',
|
||||
AUTH_EMAIL_REVERT: '/auth/email-revert',
|
||||
AUTH_VERIFY_EMAIL: '/auth/verify',
|
||||
AUTH_RESEND_VERIFICATION: '/auth/verify/resend',
|
||||
AUTH_AUTHORIZE_IP: '/auth/authorize-ip',
|
||||
AUTH_IP_AUTHORIZATION_RESEND: '/auth/ip-authorization/resend',
|
||||
AUTH_IP_AUTHORIZATION_STREAM: (ticket: string) =>
|
||||
`/auth/ip-authorization/stream?ticket=${encodeURIComponent(ticket)}`,
|
||||
|
||||
SUDO_MFA_METHODS: '/users/@me/sudo/mfa-methods',
|
||||
SUDO_SMS_SEND: '/users/@me/sudo/mfa/sms/send',
|
||||
SUDO_WEBAUTHN_OPTIONS: '/users/@me/sudo/webauthn/authentication-options',
|
||||
|
||||
OAUTH_AUTHORIZE: '/oauth2/authorize',
|
||||
OAUTH_CONSENT: '/oauth2/authorize/consent',
|
||||
|
||||
OAUTH_APPLICATIONS: '/oauth2/applications',
|
||||
OAUTH_APPLICATIONS_LIST: '/oauth2/applications/@me',
|
||||
OAUTH_APPLICATION: (applicationId: string) => `/oauth2/applications/${applicationId}`,
|
||||
OAUTH_APPLICATION_BOT_TOKEN_RESET: (applicationId: string) => `/oauth2/applications/${applicationId}/bot/reset-token`,
|
||||
OAUTH_APPLICATION_CLIENT_SECRET_RESET: (applicationId: string) =>
|
||||
`/oauth2/applications/${applicationId}/client-secret/reset`,
|
||||
OAUTH_APPLICATION_BOT_PROFILE: (applicationId: string) => `/oauth2/applications/${applicationId}/bot`,
|
||||
OAUTH_PUBLIC_APPLICATION: (applicationId: string) => `/oauth2/applications/${applicationId}/public`,
|
||||
|
||||
OAUTH_AUTHORIZATIONS: '/oauth2/@me/authorizations',
|
||||
OAUTH_AUTHORIZATION: (applicationId: string) => `/oauth2/@me/authorizations/${applicationId}`,
|
||||
|
||||
CHANNEL: (channelId: string) => `/channels/${channelId}`,
|
||||
CHANNEL_ATTACHMENTS: (channelId: string) => `/channels/${channelId}/attachments`,
|
||||
CHANNEL_INVITES: (channelId: string) => `/channels/${channelId}/invites`,
|
||||
CHANNEL_RECIPIENT: (channelId: string, userId: string) => `/channels/${channelId}/recipients/${userId}`,
|
||||
CHANNEL_MESSAGES: (channelId: string) => `/channels/${channelId}/messages`,
|
||||
CHANNEL_MESSAGE_SCHEDULE: (channelId: string) => `/channels/${channelId}/messages/schedule`,
|
||||
CHANNEL_MESSAGE: (channelId: string, messageId: string) => `/channels/${channelId}/messages/${messageId}`,
|
||||
CHANNEL_MESSAGE_ATTACHMENT: (channelId: string, messageId: string, attachmentId: string) =>
|
||||
`/channels/${channelId}/messages/${messageId}/attachments/${attachmentId}`,
|
||||
CHANNEL_MESSAGE_ACK: (channelId: string, messageId: string) => `/channels/${channelId}/messages/${messageId}/ack`,
|
||||
CHANNEL_MESSAGE_REACTION: (channelId: string, messageId: string, emoji: string) =>
|
||||
`/channels/${channelId}/messages/${messageId}/reactions/${emoji}`,
|
||||
CHANNEL_MESSAGE_REACTION_QUERY: (channelId: string, messageId: string, emoji: string, query = ME) =>
|
||||
`/channels/${channelId}/messages/${messageId}/reactions/${emoji}/${query}`,
|
||||
CHANNEL_MESSAGE_REACTIONS: (channelId: string, messageId: string) =>
|
||||
`/channels/${channelId}/messages/${messageId}/reactions`,
|
||||
CHANNEL_MESSAGES_ACK: (channelId: string) => `/channels/${channelId}/messages/ack`,
|
||||
CHANNEL_PIN: (channelId: string, messageId: string) => `/channels/${channelId}/pins/${messageId}`,
|
||||
CHANNEL_PINS: (channelId: string) => `/channels/${channelId}/messages/pins`,
|
||||
CHANNEL_PINS_ACK: (channelId: string) => `/channels/${channelId}/pins/ack`,
|
||||
CHANNEL_TYPING: (channelId: string) => `/channels/${channelId}/typing`,
|
||||
CHANNEL_WEBHOOKS: (channelId: string) => `/channels/${channelId}/webhooks`,
|
||||
CHANNEL_RTC_REGIONS: (channelId: string) => `/channels/${channelId}/rtc-regions`,
|
||||
CHANNEL_CALL: (channelId: string) => `/channels/${channelId}/call`,
|
||||
CHANNEL_CALL_RING: (channelId: string) => `/channels/${channelId}/call/ring`,
|
||||
CHANNEL_CALL_STOP_RINGING: (channelId: string) => `/channels/${channelId}/call/stop-ringing`,
|
||||
|
||||
GUILDS: '/guilds',
|
||||
GUILD: (guildId: string) => `/guilds/${guildId}`,
|
||||
GUILD_CHANNELS: (guildId: string) => `/guilds/${guildId}/channels`,
|
||||
GUILD_MEMBER: (guildId: string, query = ME) => `/guilds/${guildId}/members/${query}`,
|
||||
GUILD_MEMBERS: (guildId: string) => `/guilds/${guildId}/members`,
|
||||
GUILD_MEMBER_ROLE: (guildId: string, userId: string, roleId: string) =>
|
||||
`/guilds/${guildId}/members/${userId}/roles/${roleId}`,
|
||||
GUILD_BAN: (guildId: string, userId: string) => `/guilds/${guildId}/bans/${userId}`,
|
||||
GUILD_BANS: (guildId: string) => `/guilds/${guildId}/bans`,
|
||||
GUILD_ROLE: (guildId: string, roleId: string) => `/guilds/${guildId}/roles/${roleId}`,
|
||||
GUILD_ROLES: (guildId: string) => `/guilds/${guildId}/roles`,
|
||||
GUILD_ROLE_HOIST_POSITIONS: (guildId: string) => `/guilds/${guildId}/roles/hoist-positions`,
|
||||
GUILD_DELETE: (guildId: string) => `/guilds/${guildId}/delete`,
|
||||
GUILD_TRANSFER_OWNERSHIP: (guildId: string) => `/guilds/${guildId}/transfer-ownership`,
|
||||
GUILD_TEXT_CHANNEL_FLEXIBLE_NAMES: (guildId: string) => `/guilds/${guildId}/text-channel-flexible-names`,
|
||||
GUILD_DETACHED_BANNER: (guildId: string) => `/guilds/${guildId}/detached-banner`,
|
||||
GUILD_DISALLOW_UNCLAIMED_ACCOUNTS: (guildId: string) => `/guilds/${guildId}/disallow-unclaimed-accounts`,
|
||||
GUILD_EMOJI: (guildId: string, emojiId: string) => `/guilds/${guildId}/emojis/${emojiId}`,
|
||||
GUILD_EMOJIS: (guildId: string) => `/guilds/${guildId}/emojis`,
|
||||
GUILD_STICKER: (guildId: string, stickerId: string) => `/guilds/${guildId}/stickers/${stickerId}`,
|
||||
GUILD_STICKERS: (guildId: string) => `/guilds/${guildId}/stickers`,
|
||||
GUILD_INVITES: (guildId: string) => `/guilds/${guildId}/invites`,
|
||||
GUILD_VANITY_URL: (guildId: string) => `/guilds/${guildId}/vanity-url`,
|
||||
GUILD_WEBHOOKS: (guildId: string) => `/guilds/${guildId}/webhooks`,
|
||||
GUILD_AUDIT_LOGS: (guildId: string) => `/guilds/${guildId}/audit-logs`,
|
||||
|
||||
INVITE: (code: string) => `/invites/${code}`,
|
||||
|
||||
GIFT: (code: string) => `/gifts/${code}`,
|
||||
GIFT_REDEEM: (code: string) => `/gifts/${code}/redeem`,
|
||||
USER_GIFTS: '/users/@me/gifts',
|
||||
|
||||
PREMIUM_VISIONARY_SLOTS: '/premium/visionary/slots',
|
||||
PREMIUM_VISIONARY_REJOIN: '/premium/visionary/rejoin',
|
||||
PREMIUM_OPERATOR_REJOIN: '/premium/operator/rejoin',
|
||||
PREMIUM_PRICE_IDS: '/premium/price-ids',
|
||||
PREMIUM_CUSTOMER_PORTAL: '/premium/customer-portal',
|
||||
PREMIUM_CANCEL_SUBSCRIPTION: '/premium/cancel-subscription',
|
||||
PREMIUM_REACTIVATE_SUBSCRIPTION: '/premium/reactivate-subscription',
|
||||
STRIPE_CHECKOUT_SUBSCRIPTION: '/stripe/checkout/subscription',
|
||||
STRIPE_CHECKOUT_GIFT: '/stripe/checkout/gift',
|
||||
|
||||
SWISH_AVAILABLE: '/swish/available',
|
||||
SWISH_PRICES: '/swish/prices',
|
||||
SWISH_CHECKOUT: '/swish/checkout',
|
||||
SWISH_PAYMENT: (paymentId: string) => `/swish/payments/${paymentId}`,
|
||||
SWISH_PAYMENTS: '/swish/payments',
|
||||
|
||||
READ_STATES_ACK_BULK: '/read-states/ack-bulk',
|
||||
|
||||
DSA_REPORT_EMAIL_SEND: '/reports/dsa/email/send',
|
||||
DSA_REPORT_EMAIL_VERIFY: '/reports/dsa/email/verify',
|
||||
DSA_REPORT_CREATE: '/reports/dsa',
|
||||
|
||||
TENOR_FEATURED: '/tenor/featured',
|
||||
TENOR_REGISTER_SHARE: '/tenor/register-share',
|
||||
TENOR_SEARCH: '/tenor/search',
|
||||
TENOR_SUGGEST: '/tenor/suggest',
|
||||
TENOR_TRENDING_GIFS: '/tenor/trending-gifs',
|
||||
|
||||
USER_BETA_CODE: (code: string) => `/users/@me/beta-codes/${code}`,
|
||||
USER_BETA_CODES: '/users/@me/beta-codes',
|
||||
USER_CHANNELS: '/users/@me/channels',
|
||||
USER_CHANNEL_PIN: (channelId: string) => `/users/@me/channels/${channelId}/pin`,
|
||||
USER_GUILDS_LIST: '/users/@me/guilds',
|
||||
USER_GUILDS: (guildId: string) => `/users/@me/guilds/${guildId}`,
|
||||
USER_ME: '/users/@me',
|
||||
USER_MENTION: (messageId: string) => `/users/@me/mentions/${messageId}`,
|
||||
USER_MENTIONS: '/users/@me/mentions',
|
||||
USER_MFA_BACKUP_CODES: '/users/@me/mfa/backup-codes',
|
||||
USER_MFA_TOTP_DISABLE: '/users/@me/mfa/totp/disable',
|
||||
USER_MFA_TOTP_ENABLE: '/users/@me/mfa/totp/enable',
|
||||
USER_MFA_SMS_ENABLE: '/users/@me/mfa/sms/enable',
|
||||
USER_MFA_SMS_DISABLE: '/users/@me/mfa/sms/disable',
|
||||
USER_MFA_WEBAUTHN_CREDENTIALS: '/users/@me/mfa/webauthn/credentials',
|
||||
USER_MFA_WEBAUTHN_REGISTRATION_OPTIONS: '/users/@me/mfa/webauthn/credentials/registration-options',
|
||||
USER_MFA_WEBAUTHN_CREDENTIAL: (credentialId: string) => `/users/@me/mfa/webauthn/credentials/${credentialId}`,
|
||||
USER_PHONE_SEND_VERIFICATION: '/users/@me/phone/send-verification',
|
||||
USER_PHONE_VERIFY: '/users/@me/phone/verify',
|
||||
USER_PHONE: '/users/@me/phone',
|
||||
USER_EMAIL_CHANGE_START: '/users/@me/email-change/start',
|
||||
USER_EMAIL_CHANGE_RESEND_ORIGINAL: '/users/@me/email-change/resend-original',
|
||||
USER_EMAIL_CHANGE_VERIFY_ORIGINAL: '/users/@me/email-change/verify-original',
|
||||
USER_EMAIL_CHANGE_REQUEST_NEW: '/users/@me/email-change/request-new',
|
||||
USER_EMAIL_CHANGE_RESEND_NEW: '/users/@me/email-change/resend-new',
|
||||
USER_EMAIL_CHANGE_VERIFY_NEW: '/users/@me/email-change/verify-new',
|
||||
USER_DISABLE: '/users/@me/disable',
|
||||
USER_DELETE: '/users/@me/delete',
|
||||
USER_BULK_DELETE_MESSAGES: '/users/@me/messages/delete',
|
||||
USER_BULK_DELETE_MESSAGES_TEST: '/users/@me/messages/delete/test',
|
||||
USER_HARVEST: '/users/@me/harvest',
|
||||
USER_HARVEST_LATEST: '/users/@me/harvest/latest',
|
||||
USER_HARVEST_STATUS: (harvestId: string) => `/users/@me/harvest/${harvestId}`,
|
||||
USER_PRELOAD_MESSAGES: '/users/@me/preload-messages',
|
||||
USER_NOTE: (userId: string) => `/users/@me/notes/${userId}`,
|
||||
USER_CHECK_TAG: '/users/check-tag',
|
||||
USER_PROFILE: (query = ME) => `/users/${query}/profile`,
|
||||
USER_RELATIONSHIP: (userId: string) => `/users/@me/relationships/${userId}`,
|
||||
USER_RELATIONSHIPS: '/users/@me/relationships',
|
||||
USER_THEMES: '/users/@me/themes',
|
||||
USER_SAVED_MESSAGE: (messageId: string) => `/users/@me/saved-messages/${messageId}`,
|
||||
USER_SAVED_MESSAGES: '/users/@me/saved-messages',
|
||||
USER_SCHEDULED_MESSAGES: '/users/@me/scheduled-messages',
|
||||
USER_SCHEDULED_MESSAGE: (messageId: string) => `/users/@me/scheduled-messages/${messageId}`,
|
||||
USER_FAVORITE_MEMES: (query = ME) => `/users/${query}/memes`,
|
||||
USER_FAVORITE_MEME: (query = ME, memeId: string) => `/users/${query}/memes/${memeId}`,
|
||||
CHANNEL_MESSAGE_FAVORITE_MEMES: (channelId: string, messageId: string) =>
|
||||
`/channels/${channelId}/messages/${messageId}/memes`,
|
||||
STREAM_PREVIEW: (streamKey: string) => `/streams/${streamKey}/preview`,
|
||||
USER_SETTINGS: '/users/@me/settings',
|
||||
USER_GUILD_SETTINGS_ME: '/users/@me/guilds/@me/settings',
|
||||
USER_GUILD_SETTINGS: (guildId: string) => `/users/@me/guilds/${guildId}/settings`,
|
||||
USER_PUSH_SUBSCRIBE: '/users/@me/push/subscribe',
|
||||
USER_PUSH_SUBSCRIPTIONS: '/users/@me/push/subscriptions',
|
||||
USER_PUSH_SUBSCRIPTION: (subscriptionId: string) => `/users/@me/push/subscriptions/${subscriptionId}`,
|
||||
PACKS: '/packs',
|
||||
PACK: (packId: string) => `/packs/${packId}`,
|
||||
PACK_CREATE: (packType: 'emoji' | 'sticker') => `/packs/${packType}`,
|
||||
PACK_INSTALL: (packId: string) => `/packs/${packId}/install`,
|
||||
PACK_EMOJIS: (packId: string) => `/packs/emojis/${packId}`,
|
||||
PACK_EMOJI: (packId: string, emojiId: string) => `/packs/emojis/${packId}/${emojiId}`,
|
||||
PACK_EMOJI_BULK: (packId: string) => `/packs/emojis/${packId}/bulk`,
|
||||
PACK_STICKERS: (packId: string) => `/packs/stickers/${packId}`,
|
||||
PACK_STICKER: (packId: string, stickerId: string) => `/packs/stickers/${packId}/${stickerId}`,
|
||||
PACK_STICKERS_BULK: (packId: string) => `/packs/stickers/${packId}/bulk`,
|
||||
PACK_INVITES: (packId: string) => `/packs/${packId}/invites`,
|
||||
|
||||
WEBHOOK: (webhookId: string) => `/webhooks/${webhookId}`,
|
||||
|
||||
REPORT_MESSAGE: '/reports/message',
|
||||
REPORT_USER: '/reports/user',
|
||||
REPORT_GUILD: '/reports/guild',
|
||||
} as const;
|
||||
90
fluxer_app/src/Routes.tsx
Normal file
90
fluxer_app/src/Routes.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 {marketingUrl} from '~/utils/UrlUtils';
|
||||
|
||||
export const Routes = {
|
||||
HOME: '/',
|
||||
LOGIN: '/login',
|
||||
REGISTER: '/register',
|
||||
FORGOT_PASSWORD: '/forgot',
|
||||
RESET_PASSWORD: '/reset',
|
||||
VERIFY_EMAIL: '/verify',
|
||||
AUTHORIZE_IP: '/authorize-ip',
|
||||
EMAIL_REVERT: '/wasntme',
|
||||
PENDING_VERIFICATION: '/pending',
|
||||
OAUTH_AUTHORIZE: '/oauth2/authorize',
|
||||
|
||||
INVITE_REGISTER: '/invite/:code',
|
||||
INVITE_LOGIN: '/invite/:code/login',
|
||||
GIFT_REGISTER: '/gift/:code',
|
||||
GIFT_LOGIN: '/gift/:code/login',
|
||||
THEME_REGISTER: '/theme/:themeId',
|
||||
THEME_LOGIN: '/theme/:themeId/login',
|
||||
|
||||
ME: '/channels/@me',
|
||||
FAVORITES: '/channels/@favorites',
|
||||
BOOKMARKS: '/bookmarks',
|
||||
MENTIONS: '/mentions',
|
||||
NOTIFICATIONS: '/notifications',
|
||||
YOU: '/you',
|
||||
REPORT: '/report',
|
||||
PREMIUM_CALLBACK: '/premium-callback',
|
||||
|
||||
terms: () => marketingUrl('terms'),
|
||||
privacy: () => marketingUrl('privacy'),
|
||||
guidelines: () => marketingUrl('guidelines'),
|
||||
careers: () => marketingUrl('careers'),
|
||||
partners: () => marketingUrl('partners'),
|
||||
bugs: () => marketingUrl('bugs'),
|
||||
plutonium: () => marketingUrl('plutonium'),
|
||||
plutoniumVisionary: () => marketingUrl('plutonium#visionary'),
|
||||
help: () => marketingUrl('help'),
|
||||
|
||||
dmChannel: (channelId: string) => `/channels/@me/${channelId}`,
|
||||
favoritesChannel: (channelId: string) => `/channels/@favorites/${channelId}`,
|
||||
guildChannel: (guildId: string, channelId?: string) =>
|
||||
channelId ? `/channels/${guildId}/${channelId}` : `/channels/${guildId}`,
|
||||
channelMessage: (guildId: string, channelId: string, messageId: string) =>
|
||||
`${Routes.guildChannel(guildId, channelId)}/${messageId}`,
|
||||
dmChannelMessage: (channelId: string, messageId: string) => `${Routes.dmChannel(channelId)}/${messageId}`,
|
||||
favoritesChannelMessage: (channelId: string, messageId: string) =>
|
||||
`${Routes.favoritesChannel(channelId)}/${messageId}`,
|
||||
inviteRegister: (code: string) => `/invite/${code}`,
|
||||
inviteLogin: (code: string) => `/invite/${code}/login`,
|
||||
giftRegister: (code: string) => `/gift/${code}`,
|
||||
giftLogin: (code: string) => `/gift/${code}/login`,
|
||||
theme: (themeId: string) => `/theme/${themeId}`,
|
||||
themeRegister: (themeId: string) => `/theme/${themeId}`,
|
||||
themeLogin: (themeId: string) => `/theme/${themeId}/login`,
|
||||
|
||||
isSpecialPage: (pathname: string) =>
|
||||
pathname === Routes.BOOKMARKS ||
|
||||
pathname === Routes.MENTIONS ||
|
||||
pathname === Routes.NOTIFICATIONS ||
|
||||
pathname === Routes.YOU,
|
||||
|
||||
isDMRoute: (pathname: string) => pathname.startsWith('/channels/@me'),
|
||||
isFavoritesRoute: (pathname: string) => pathname.startsWith('/channels/@favorites'),
|
||||
isChannelRoute: (pathname: string) => pathname.startsWith('/channels/'),
|
||||
isGuildChannelRoute: (pathname: string) =>
|
||||
pathname.startsWith('/channels/') &&
|
||||
!pathname.startsWith('/channels/@me') &&
|
||||
!pathname.startsWith('/channels/@favorites'),
|
||||
} as const;
|
||||
24
fluxer_app/src/actions/AccessibilityActionCreators.tsx
Normal file
24
fluxer_app/src/actions/AccessibilityActionCreators.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 AccessibilityStore, {type AccessibilitySettings} from '~/stores/AccessibilityStore';
|
||||
|
||||
export const update = (settings: Partial<AccessibilitySettings>): void => {
|
||||
AccessibilityStore.updateSettings(settings);
|
||||
};
|
||||
65
fluxer_app/src/actions/AuthSessionActionCreators.tsx
Normal file
65
fluxer_app/src/actions/AuthSessionActionCreators.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {AuthSession} from '~/records/AuthSessionRecord';
|
||||
import AuthSessionStore from '~/stores/AuthSessionStore';
|
||||
|
||||
const logger = new Logger('AuthSessionsService');
|
||||
|
||||
export const fetch = async (): Promise<void> => {
|
||||
logger.debug('Fetching authentication sessions');
|
||||
AuthSessionStore.fetchPending();
|
||||
|
||||
try {
|
||||
const response = await http.get<Array<AuthSession>>({url: Endpoints.AUTH_SESSIONS, retries: 2});
|
||||
const sessions = response.body ?? [];
|
||||
logger.info(`Fetched ${sessions.length} authentication sessions`);
|
||||
AuthSessionStore.fetchSuccess(sessions);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch authentication sessions:', error);
|
||||
AuthSessionStore.fetchError();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async (sessionIdHashes: Array<string>): Promise<void> => {
|
||||
if (!sessionIdHashes.length) {
|
||||
logger.warn('Attempted to logout with empty session list');
|
||||
return;
|
||||
}
|
||||
logger.debug(`Logging out ${sessionIdHashes.length} sessions`);
|
||||
AuthSessionStore.logoutPending();
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_SESSIONS_LOGOUT,
|
||||
body: {session_id_hashes: sessionIdHashes},
|
||||
timeout: 10000,
|
||||
retries: 0,
|
||||
});
|
||||
logger.info(`Successfully logged out ${sessionIdHashes.length} sessions`);
|
||||
AuthSessionStore.logoutSuccess(sessionIdHashes);
|
||||
} catch (error) {
|
||||
logger.error('Failed to log out sessions:', error);
|
||||
AuthSessionStore.logoutError();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
627
fluxer_app/src/actions/AuthenticationActionCreators.tsx
Normal file
627
fluxer_app/src/actions/AuthenticationActionCreators.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
/*
|
||||
* 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 {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
import {APIErrorCodes} from '~/Constants';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import type {UserData} from '~/lib/AccountStorage';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import AccountManager from '~/stores/AccountManager';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
||||
import {isDesktop} from '~/utils/NativeUtils';
|
||||
|
||||
const logger = new Logger('AuthService');
|
||||
|
||||
const getPlatformHeaderValue = (): 'web' | 'desktop' | 'mobile' => (isDesktop() ? 'desktop' : 'web');
|
||||
const withPlatformHeader = (headers?: Record<string, string>): Record<string, string> => ({
|
||||
'X-Fluxer-Platform': getPlatformHeaderValue(),
|
||||
...(headers ?? {}),
|
||||
});
|
||||
|
||||
export const VerificationResult = {
|
||||
SUCCESS: 'SUCCESS',
|
||||
EXPIRED_TOKEN: 'EXPIRED_TOKEN',
|
||||
RATE_LIMITED: 'RATE_LIMITED',
|
||||
SERVER_ERROR: 'SERVER_ERROR',
|
||||
} as const;
|
||||
export type VerificationResult = (typeof VerificationResult)[keyof typeof VerificationResult];
|
||||
|
||||
interface RegisterData {
|
||||
email?: string;
|
||||
global_name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
beta_code: string;
|
||||
date_of_birth: string;
|
||||
consent: boolean;
|
||||
captchaToken?: string;
|
||||
captchaType?: 'turnstile' | 'hcaptcha';
|
||||
invite_code?: string;
|
||||
}
|
||||
|
||||
interface StandardLoginResponse {
|
||||
mfa: false;
|
||||
user_id: string;
|
||||
token: string;
|
||||
theme?: string;
|
||||
pending_verification?: boolean;
|
||||
}
|
||||
|
||||
interface MfaLoginResponse {
|
||||
mfa: true;
|
||||
ticket: string;
|
||||
sms: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}
|
||||
|
||||
type LoginResponse = StandardLoginResponse | MfaLoginResponse;
|
||||
|
||||
export interface IpAuthorizationRequiredResponse {
|
||||
ip_authorization_required: true;
|
||||
ticket: string;
|
||||
email: string;
|
||||
resend_available_in: number;
|
||||
}
|
||||
|
||||
export const isIpAuthorizationRequiredResponse = (
|
||||
response: LoginResponse | IpAuthorizationRequiredResponse,
|
||||
): response is IpAuthorizationRequiredResponse => {
|
||||
return (response as IpAuthorizationRequiredResponse).ip_authorization_required === true;
|
||||
};
|
||||
|
||||
interface TokenResponse {
|
||||
user_id: string;
|
||||
token: string;
|
||||
theme?: string;
|
||||
pending_verification?: boolean;
|
||||
}
|
||||
|
||||
interface DesktopHandoffInitiateResponse {
|
||||
code: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
interface DesktopHandoffStatusResponse {
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
token?: string;
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export const login = async ({
|
||||
email,
|
||||
password,
|
||||
captchaToken,
|
||||
inviteCode,
|
||||
captchaType,
|
||||
customApiEndpoint,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
captchaToken?: string;
|
||||
inviteCode?: string;
|
||||
captchaType?: 'turnstile' | 'hcaptcha';
|
||||
customApiEndpoint?: string;
|
||||
}): Promise<LoginResponse | IpAuthorizationRequiredResponse> => {
|
||||
try {
|
||||
if (customApiEndpoint) {
|
||||
await RuntimeConfigStore.connectToEndpoint(customApiEndpoint);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (captchaToken) {
|
||||
headers['X-Captcha-Token'] = captchaToken;
|
||||
headers['X-Captcha-Type'] = captchaType || 'hcaptcha';
|
||||
}
|
||||
const body: {
|
||||
email: string;
|
||||
password: string;
|
||||
invite_code?: string;
|
||||
} = {email, password};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const response = await http.post<LoginResponse>({
|
||||
url: Endpoints.AUTH_LOGIN,
|
||||
body,
|
||||
headers: withPlatformHeader(headers),
|
||||
});
|
||||
logger.debug('Login successful', {mfa: response.body?.mfa});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
const httpError = error as {status?: number; body?: any};
|
||||
if (httpError.status === 403 && httpError.body?.code === APIErrorCodes.IP_AUTHORIZATION_REQUIRED) {
|
||||
logger.info('Login requires IP authorization', {email});
|
||||
return {
|
||||
ip_authorization_required: true,
|
||||
ticket: httpError.body?.ticket,
|
||||
email: httpError.body?.email,
|
||||
resend_available_in: httpError.body?.resend_available_in ?? 30,
|
||||
};
|
||||
}
|
||||
logger.error('Login failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const loginMfaTotp = async (code: string, ticket: string, inviteCode?: string): Promise<TokenResponse> => {
|
||||
try {
|
||||
const body: {
|
||||
code: string;
|
||||
ticket: string;
|
||||
invite_code?: string;
|
||||
} = {code, ticket};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_TOTP,
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('MFA TOTP authentication successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('MFA TOTP authentication failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const loginMfaSmsSend = async (ticket: string): Promise<void> => {
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_SMS_SEND,
|
||||
body: {ticket},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
logger.debug('SMS MFA code sent');
|
||||
} catch (error) {
|
||||
logger.error('Failed to send SMS MFA code', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const loginMfaSms = async (code: string, ticket: string, inviteCode?: string): Promise<TokenResponse> => {
|
||||
try {
|
||||
const body: {
|
||||
code: string;
|
||||
ticket: string;
|
||||
invite_code?: string;
|
||||
} = {code, ticket};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_SMS,
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('MFA SMS authentication successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('MFA SMS authentication failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const loginMfaWebAuthn = async (
|
||||
response: AuthenticationResponseJSON,
|
||||
challenge: string,
|
||||
ticket: string,
|
||||
inviteCode?: string,
|
||||
): Promise<TokenResponse> => {
|
||||
try {
|
||||
const body: {
|
||||
response: AuthenticationResponseJSON;
|
||||
challenge: string;
|
||||
ticket: string;
|
||||
invite_code?: string;
|
||||
} = {response, challenge, ticket};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const httpResponse = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_WEBAUTHN,
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = httpResponse.body;
|
||||
logger.debug('MFA WebAuthn authentication successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('MFA WebAuthn authentication failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getWebAuthnMfaOptions = async (ticket: string): Promise<PublicKeyCredentialRequestOptionsJSON> => {
|
||||
try {
|
||||
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>({
|
||||
url: Endpoints.AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS,
|
||||
body: {ticket},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('WebAuthn MFA options retrieved');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get WebAuthn MFA options', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getWebAuthnAuthenticationOptions = async (): Promise<PublicKeyCredentialRequestOptionsJSON> => {
|
||||
try {
|
||||
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>({
|
||||
url: Endpoints.AUTH_WEBAUTHN_OPTIONS,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('WebAuthn authentication options retrieved');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get WebAuthn authentication options', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const authenticateWithWebAuthn = async (
|
||||
response: AuthenticationResponseJSON,
|
||||
challenge: string,
|
||||
inviteCode?: string,
|
||||
): Promise<TokenResponse> => {
|
||||
try {
|
||||
const body: {
|
||||
response: AuthenticationResponseJSON;
|
||||
challenge: string;
|
||||
invite_code?: string;
|
||||
} = {response, challenge};
|
||||
if (inviteCode) {
|
||||
body.invite_code = inviteCode;
|
||||
}
|
||||
const httpResponse = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_WEBAUTHN_AUTHENTICATE,
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = httpResponse.body;
|
||||
logger.debug('WebAuthn authentication successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('WebAuthn authentication failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const register = async (data: RegisterData): Promise<TokenResponse> => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (data.captchaToken) {
|
||||
headers['X-Captcha-Token'] = data.captchaToken;
|
||||
headers['X-Captcha-Type'] = data.captchaType || 'hcaptcha';
|
||||
}
|
||||
const {captchaToken: _, captchaType: __, ...bodyData} = data;
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_REGISTER,
|
||||
body: bodyData,
|
||||
headers: withPlatformHeader(headers),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Registration successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Registration failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
interface UsernameSuggestionsResponse {
|
||||
suggestions: Array<string>;
|
||||
}
|
||||
|
||||
export const getUsernameSuggestions = async (globalName: string): Promise<Array<string>> => {
|
||||
try {
|
||||
const response = await http.post<UsernameSuggestionsResponse>({
|
||||
url: Endpoints.AUTH_USERNAME_SUGGESTIONS,
|
||||
body: {global_name: globalName},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('Username suggestions retrieved', {count: responseBody?.suggestions?.length || 0});
|
||||
return responseBody?.suggestions ?? [];
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch username suggestions', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const forgotPassword = async (
|
||||
email: string,
|
||||
captchaToken?: string,
|
||||
captchaType?: 'turnstile' | 'hcaptcha',
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (captchaToken) {
|
||||
headers['X-Captcha-Token'] = captchaToken;
|
||||
headers['X-Captcha-Type'] = captchaType || 'hcaptcha';
|
||||
}
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_FORGOT_PASSWORD,
|
||||
body: {email},
|
||||
headers: withPlatformHeader(headers),
|
||||
});
|
||||
logger.debug('Password reset email sent');
|
||||
} catch (error) {
|
||||
logger.warn('Password reset request failed, but returning success to user', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const resetPassword = async (token: string, password: string): Promise<TokenResponse> => {
|
||||
try {
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_RESET_PASSWORD,
|
||||
body: {token, password},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Password reset successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Password reset failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const revertEmailChange = async (token: string, password: string): Promise<TokenResponse> => {
|
||||
try {
|
||||
const response = await http.post<TokenResponse>({
|
||||
url: Endpoints.AUTH_EMAIL_REVERT,
|
||||
body: {token, password},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Email revert successful');
|
||||
return responseBody;
|
||||
} catch (error) {
|
||||
logger.error('Email revert failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyEmail = async (token: string): Promise<VerificationResult> => {
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_VERIFY_EMAIL,
|
||||
body: {token},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
logger.info('Email verification successful');
|
||||
return VerificationResult.SUCCESS;
|
||||
} catch (error) {
|
||||
const httpError = error as {status?: number};
|
||||
if (httpError.status === 400) {
|
||||
logger.warn('Email verification failed - expired or invalid token');
|
||||
return VerificationResult.EXPIRED_TOKEN;
|
||||
}
|
||||
logger.error('Email verification failed - server error', error);
|
||||
return VerificationResult.SERVER_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
export const resendVerificationEmail = async (): Promise<VerificationResult> => {
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_RESEND_VERIFICATION,
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
logger.info('Verification email resent');
|
||||
return VerificationResult.SUCCESS;
|
||||
} catch (error) {
|
||||
const httpError = error as {status?: number};
|
||||
if (httpError.status === 429) {
|
||||
logger.warn('Rate limited when resending verification email');
|
||||
return VerificationResult.RATE_LIMITED;
|
||||
}
|
||||
logger.error('Failed to resend verification email - server error', error);
|
||||
return VerificationResult.SERVER_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async (): Promise<void> => {
|
||||
await AccountManager.logout();
|
||||
};
|
||||
|
||||
export const authorizeIp = async (token: string): Promise<VerificationResult> => {
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_AUTHORIZE_IP,
|
||||
body: {token},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
logger.info('IP authorization successful');
|
||||
return VerificationResult.SUCCESS;
|
||||
} catch (error) {
|
||||
const httpError = error as {status?: number};
|
||||
if (httpError.status === 400) {
|
||||
logger.warn('IP authorization failed - expired or invalid token');
|
||||
return VerificationResult.EXPIRED_TOKEN;
|
||||
}
|
||||
logger.error('IP authorization failed - server error', error);
|
||||
return VerificationResult.SERVER_ERROR;
|
||||
}
|
||||
};
|
||||
|
||||
export const resendIpAuthorization = async (ticket: string): Promise<void> => {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_IP_AUTHORIZATION_RESEND,
|
||||
body: {ticket},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
};
|
||||
|
||||
export const subscribeToIpAuthorization = (ticket: string): EventSource => {
|
||||
const base = RuntimeConfigStore.apiEndpoint || '';
|
||||
const url = `${base}${Endpoints.AUTH_IP_AUTHORIZATION_STREAM(ticket)}`;
|
||||
return new EventSource(url);
|
||||
};
|
||||
|
||||
export const initiateDesktopHandoff = async (): Promise<DesktopHandoffInitiateResponse> => {
|
||||
const response = await http.post<DesktopHandoffInitiateResponse>({
|
||||
url: Endpoints.AUTH_HANDOFF_INITIATE,
|
||||
skipAuth: true,
|
||||
});
|
||||
return response.body;
|
||||
};
|
||||
|
||||
export const pollDesktopHandoffStatus = async (
|
||||
code: string,
|
||||
customApiEndpoint?: string,
|
||||
): Promise<DesktopHandoffStatusResponse> => {
|
||||
const url = customApiEndpoint
|
||||
? `${customApiEndpoint}${Endpoints.AUTH_HANDOFF_STATUS(code)}`
|
||||
: Endpoints.AUTH_HANDOFF_STATUS(code);
|
||||
const response = await http.get<DesktopHandoffStatusResponse>({
|
||||
url,
|
||||
skipAuth: true,
|
||||
});
|
||||
return response.body;
|
||||
};
|
||||
|
||||
export const completeDesktopHandoff = async ({
|
||||
code,
|
||||
token,
|
||||
userId,
|
||||
}: {
|
||||
code: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
}): Promise<void> => {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_HANDOFF_COMPLETE,
|
||||
body: {code, token, user_id: userId},
|
||||
skipAuth: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const startSession = (token: string, options: {startGateway?: boolean} = {}): void => {
|
||||
const {startGateway = true} = options;
|
||||
|
||||
logger.info('Starting new session');
|
||||
AuthenticationStore.handleSessionStart({token});
|
||||
|
||||
if (!startGateway) {
|
||||
return;
|
||||
}
|
||||
|
||||
ConnectionStore.startSession(token);
|
||||
};
|
||||
|
||||
let sessionStartInProgress = false;
|
||||
|
||||
export const ensureSessionStarted = async (): Promise<void> => {
|
||||
if (sessionStartInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (AccountManager.isSwitching) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AuthenticationStore.isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConnectionStore.isConnected || ConnectionStore.isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ConnectionStore.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStartInProgress = true;
|
||||
|
||||
try {
|
||||
logger.info('Ensuring session is started');
|
||||
|
||||
const token = AuthenticationStore.authToken;
|
||||
if (token) {
|
||||
ConnectionStore.startSession(token);
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
sessionStartInProgress = false;
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
export const completeLogin = async ({
|
||||
token,
|
||||
userId,
|
||||
userData,
|
||||
}: {
|
||||
token: string;
|
||||
userId: string;
|
||||
userData?: UserData;
|
||||
}): Promise<void> => {
|
||||
logger.info('Completing login process');
|
||||
|
||||
if (userId && token) {
|
||||
await AccountManager.switchToNewAccount(userId, token, userData, false);
|
||||
} else {
|
||||
startSession(token, {startGateway: true});
|
||||
}
|
||||
};
|
||||
|
||||
interface SetMfaTicketPayload {
|
||||
ticket: string;
|
||||
sms: boolean;
|
||||
totp: boolean;
|
||||
webauthn: boolean;
|
||||
}
|
||||
|
||||
export const setMfaTicket = ({ticket, sms, totp, webauthn}: SetMfaTicketPayload): void => {
|
||||
logger.debug('Setting MFA ticket');
|
||||
AuthenticationStore.handleMfaTicketSet({ticket, sms, totp, webauthn});
|
||||
};
|
||||
|
||||
export const clearMfaTicket = (): void => {
|
||||
logger.debug('Clearing MFA ticket');
|
||||
AuthenticationStore.handleMfaTicketClear();
|
||||
};
|
||||
|
||||
export const redeemBetaCode = async (betaCode: string): Promise<void> => {
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.AUTH_REDEEM_BETA_CODE,
|
||||
body: {beta_code: betaCode},
|
||||
headers: withPlatformHeader(),
|
||||
});
|
||||
logger.info('Beta code redeemed successfully');
|
||||
} catch (error) {
|
||||
logger.error('Beta code redemption failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
75
fluxer_app/src/actions/BetaCodeActionCreators.tsx
Normal file
75
fluxer_app/src/actions/BetaCodeActionCreators.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {type BetaCode, BetaCodeRecord} from '~/records/BetaCodeRecord';
|
||||
import BetaCodeStore from '~/stores/BetaCodeStore';
|
||||
|
||||
const logger = new Logger('BetaCodes');
|
||||
|
||||
interface BetaCodesListResponse {
|
||||
beta_codes: Array<BetaCode>;
|
||||
allowance: number;
|
||||
next_reset_at: string | null;
|
||||
}
|
||||
|
||||
export const fetch = async () => {
|
||||
BetaCodeStore.fetchPending();
|
||||
try {
|
||||
const response = await http.get<BetaCodesListResponse>({url: Endpoints.USER_BETA_CODES, retries: 1});
|
||||
const data = response.body;
|
||||
BetaCodeStore.fetchSuccess(data.beta_codes, data.allowance, data.next_reset_at);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch beta codes:', error);
|
||||
BetaCodeStore.fetchError();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const create = async () => {
|
||||
BetaCodeStore.createPending();
|
||||
try {
|
||||
const response = await http.post<BetaCode>(Endpoints.USER_BETA_CODES);
|
||||
const betaCode = new BetaCodeRecord(response.body);
|
||||
BetaCodeStore.createSuccess(betaCode);
|
||||
return betaCode;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create beta code:', error);
|
||||
BetaCodeStore.createError();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (code: string) => {
|
||||
if (!code) {
|
||||
throw new Error('No beta code provided');
|
||||
}
|
||||
BetaCodeStore.deletePending();
|
||||
try {
|
||||
await http.delete({url: Endpoints.USER_BETA_CODE(code)});
|
||||
BetaCodeStore.deleteSuccess(code);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete beta code ${code}:`, error);
|
||||
BetaCodeStore.deleteError();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
186
fluxer_app/src/actions/CallActionCreators.tsx
Normal file
186
fluxer_app/src/actions/CallActionCreators.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* 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 {reaction} from 'mobx';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import HttpClient from '~/lib/HttpClient';
|
||||
import CallInitiatorStore from '~/stores/CallInitiatorStore';
|
||||
import CallStateStore from '~/stores/CallStateStore';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import GeoIPStore from '~/stores/GeoIPStore';
|
||||
import SoundStore from '~/stores/SoundStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
import {SoundType} from '~/utils/SoundUtils';
|
||||
|
||||
interface PendingRing {
|
||||
channelId: string;
|
||||
recipients: Array<string>;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
let pendingRing: PendingRing | null = null;
|
||||
|
||||
export async function checkCallEligibility(channelId: string): Promise<{ringable: boolean}> {
|
||||
const response = await HttpClient.get<{ringable: boolean}>(Endpoints.CHANNEL_CALL(channelId));
|
||||
return response.body ?? {ringable: false};
|
||||
}
|
||||
|
||||
async function ringCallRecipients(channelId: string, recipients?: Array<string>): Promise<void> {
|
||||
const latitude = GeoIPStore.latitude;
|
||||
const longitude = GeoIPStore.longitude;
|
||||
const body: {recipients?: Array<string>; latitude?: string; longitude?: string} = {};
|
||||
if (recipients) {
|
||||
body.recipients = recipients;
|
||||
}
|
||||
if (latitude && longitude) {
|
||||
body.latitude = latitude;
|
||||
body.longitude = longitude;
|
||||
}
|
||||
await HttpClient.post(Endpoints.CHANNEL_CALL_RING(channelId), body);
|
||||
}
|
||||
|
||||
async function stopRingingCallRecipients(channelId: string, recipients?: Array<string>): Promise<void> {
|
||||
await HttpClient.post(Endpoints.CHANNEL_CALL_STOP_RINGING(channelId), recipients ? {recipients} : {});
|
||||
}
|
||||
|
||||
export async function ringParticipants(channelId: string, recipients?: Array<string>): Promise<void> {
|
||||
return ringCallRecipients(channelId, recipients);
|
||||
}
|
||||
|
||||
export async function stopRingingParticipants(channelId: string, recipients?: Array<string>): Promise<void> {
|
||||
return stopRingingCallRecipients(channelId, recipients);
|
||||
}
|
||||
|
||||
function clearPendingRing(): void {
|
||||
if (pendingRing) {
|
||||
pendingRing.dispose();
|
||||
pendingRing = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setupPendingRing(channelId: string, recipients: Array<string>): void {
|
||||
clearPendingRing();
|
||||
|
||||
const dispose = reaction(
|
||||
() => ({
|
||||
connected: MediaEngineStore.connected,
|
||||
currentChannelId: MediaEngineStore.channelId,
|
||||
}),
|
||||
({connected, currentChannelId}) => {
|
||||
if (connected && currentChannelId === channelId && pendingRing?.channelId === channelId) {
|
||||
void ringCallRecipients(channelId, pendingRing.recipients).catch((error) => {
|
||||
console.error('Failed to ring call recipients:', error);
|
||||
});
|
||||
clearPendingRing();
|
||||
}
|
||||
},
|
||||
{fireImmediately: true},
|
||||
);
|
||||
|
||||
pendingRing = {channelId, recipients, dispose};
|
||||
}
|
||||
|
||||
export function startCall(channelId: string, silent = false): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
const recipients = channel ? channel.recipientIds.filter((id) => id !== currentUser.id) : [];
|
||||
|
||||
CallInitiatorStore.markInitiated(channelId, recipients);
|
||||
|
||||
if (!silent) {
|
||||
setupPendingRing(channelId, recipients);
|
||||
}
|
||||
|
||||
void MediaEngineStore.connectToVoiceChannel(null, channelId);
|
||||
}
|
||||
|
||||
export function joinCall(channelId: string): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
CallStateStore.clearPendingRinging(channelId, [currentUser.id]);
|
||||
SoundStore.stopIncomingRing();
|
||||
SoundStore.playSound(SoundType.UserJoin);
|
||||
void MediaEngineStore.connectToVoiceChannel(null, channelId);
|
||||
}
|
||||
|
||||
export async function leaveCall(channelId: string): Promise<void> {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingRing?.channelId === channelId) {
|
||||
clearPendingRing();
|
||||
}
|
||||
|
||||
SoundStore.stopIncomingRing();
|
||||
|
||||
const call = CallStateStore.getCall(channelId);
|
||||
const callRinging = call?.ringing ?? [];
|
||||
const initiatedRecipients = CallInitiatorStore.getInitiatedRecipients(channelId);
|
||||
const toStop =
|
||||
initiatedRecipients.length > 0 ? callRinging.filter((userId) => initiatedRecipients.includes(userId)) : callRinging;
|
||||
|
||||
if (toStop.length > 0) {
|
||||
try {
|
||||
await stopRingingCallRecipients(channelId, toStop);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop ringing pending recipients:', error);
|
||||
}
|
||||
}
|
||||
|
||||
CallInitiatorStore.clearChannel(channelId);
|
||||
|
||||
void MediaEngineStore.disconnectFromVoiceChannel('user');
|
||||
}
|
||||
|
||||
export function rejectCall(channelId: string): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
CallStateStore.clearPendingRinging(channelId, [currentUser.id]);
|
||||
const connectedChannelId = MediaEngineStore.channelId;
|
||||
if (connectedChannelId === channelId) {
|
||||
void MediaEngineStore.disconnectFromVoiceChannel('user');
|
||||
}
|
||||
void stopRingingCallRecipients(channelId).catch((error) => {
|
||||
console.error('Failed to stop ringing:', error);
|
||||
});
|
||||
SoundStore.stopIncomingRing();
|
||||
CallInitiatorStore.clearChannel(channelId);
|
||||
}
|
||||
|
||||
export function ignoreCall(channelId: string): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return;
|
||||
}
|
||||
CallStateStore.clearPendingRinging(channelId, [currentUser.id]);
|
||||
void stopRingingCallRecipients(channelId, [currentUser.id]).catch((error) => {
|
||||
console.error('Failed to stop ringing:', error);
|
||||
});
|
||||
SoundStore.stopIncomingRing();
|
||||
}
|
||||
147
fluxer_app/src/actions/ChannelActionCreators.tsx
Normal file
147
fluxer_app/src/actions/ChannelActionCreators.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 {ChannelTypes} from '~/Constants';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {Channel} from '~/records/ChannelRecord';
|
||||
import type {Invite} from '~/records/MessageRecord';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import InviteStore from '~/stores/InviteStore';
|
||||
|
||||
const logger = new Logger('Channels');
|
||||
|
||||
export interface ChannelRtcRegion {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
export const create = async (
|
||||
guildId: string,
|
||||
params: Pick<Channel, 'name' | 'url' | 'type' | 'parent_id' | 'bitrate' | 'user_limit'>,
|
||||
) => {
|
||||
try {
|
||||
const response = await http.post<Channel>(Endpoints.GUILD_CHANNELS(guildId), params);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create channel:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const update = async (
|
||||
channelId: string,
|
||||
params: Partial<Pick<Channel, 'name' | 'topic' | 'url' | 'nsfw' | 'icon' | 'owner_id' | 'rtc_region'>>,
|
||||
) => {
|
||||
try {
|
||||
const response = await http.patch<Channel>(Endpoints.CHANNEL(channelId), params);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateGroupDMNickname = async (channelId: string, userId: string, nickname: string | null) => {
|
||||
try {
|
||||
const response = await http.patch<Channel>({
|
||||
url: Endpoints.CHANNEL(channelId),
|
||||
body: {
|
||||
nicks: {
|
||||
[userId]: nickname,
|
||||
},
|
||||
},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update nickname for user ${userId} in channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export interface RemoveChannelOptions {
|
||||
optimistic?: boolean;
|
||||
}
|
||||
|
||||
export const remove = async (channelId: string, silent?: boolean, options?: RemoveChannelOptions) => {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
const isPrivateChannel =
|
||||
channel != null && !channel.guildId && (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM);
|
||||
const shouldOptimisticallyRemove = options?.optimistic ?? isPrivateChannel;
|
||||
|
||||
if (shouldOptimisticallyRemove) {
|
||||
ChannelStore.removeChannelOptimistically(channelId);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = silent ? `${Endpoints.CHANNEL(channelId)}?silent=true` : Endpoints.CHANNEL(channelId);
|
||||
await http.delete({url});
|
||||
if (shouldOptimisticallyRemove) {
|
||||
ChannelStore.clearOptimisticallyRemovedChannel(channelId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shouldOptimisticallyRemove) {
|
||||
ChannelStore.rollbackChannelDeletion(channelId);
|
||||
}
|
||||
logger.error(`Failed to delete channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePermissionOverwrites = async (
|
||||
channelId: string,
|
||||
permissionOverwrites: Array<{id: string; type: 0 | 1; allow: string; deny: string}>,
|
||||
) => {
|
||||
try {
|
||||
const response = await http.patch<Channel>({
|
||||
url: Endpoints.CHANNEL(channelId),
|
||||
body: {permission_overwrites: permissionOverwrites},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update permission overwrites for channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchChannelInvites = async (channelId: string): Promise<Array<Invite>> => {
|
||||
try {
|
||||
InviteStore.handleChannelInvitesFetchPending(channelId);
|
||||
const response = await http.get<Array<Invite>>({url: Endpoints.CHANNEL_INVITES(channelId)});
|
||||
const data = response.body ?? [];
|
||||
InviteStore.handleChannelInvitesFetchSuccess(channelId, data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch invites for channel ${channelId}:`, error);
|
||||
InviteStore.handleChannelInvitesFetchError(channelId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchRtcRegions = async (channelId: string): Promise<Array<ChannelRtcRegion>> => {
|
||||
try {
|
||||
const response = await http.get<Array<ChannelRtcRegion>>({url: Endpoints.CHANNEL_RTC_REGIONS(channelId)});
|
||||
return response.body ?? [];
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch RTC regions for channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
127
fluxer_app/src/actions/ChannelPinsActionCreators.tsx
Normal file
127
fluxer_app/src/actions/ChannelPinsActionCreators.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {APIErrorCodes} from '~/Constants';
|
||||
import {PinFailedModal, type PinFailureReason} from '~/components/alerts/PinFailedModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http, {type HttpError} from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {Message} from '~/records/MessageRecord';
|
||||
import ChannelPinsStore from '~/stores/ChannelPinsStore';
|
||||
|
||||
interface ApiErrorBody {
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const getApiErrorCode = (error: HttpError): string | undefined => {
|
||||
const body = typeof error?.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
|
||||
return body?.code;
|
||||
};
|
||||
|
||||
const logger = new Logger('Pins');
|
||||
const PIN_PAGE_SIZE = 25;
|
||||
|
||||
interface ChannelPinResponse {
|
||||
message: Message;
|
||||
pinned_at: string;
|
||||
}
|
||||
interface ChannelPinsPayload {
|
||||
items: Array<ChannelPinResponse>;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export const fetch = async (channelId: string) => {
|
||||
ChannelPinsStore.handleFetchPending(channelId);
|
||||
try {
|
||||
const response = await http.get<ChannelPinsPayload>({
|
||||
url: Endpoints.CHANNEL_PINS(channelId),
|
||||
query: {limit: PIN_PAGE_SIZE},
|
||||
});
|
||||
const body = response.body ?? {items: [], has_more: false};
|
||||
ChannelPinsStore.handleChannelPinsFetchSuccess(channelId, body.items, body.has_more);
|
||||
return body.items.map((pin) => pin.message);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch pins for channel ${channelId}:`, error);
|
||||
ChannelPinsStore.handleChannelPinsFetchError(channelId);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const loadMore = async (channelId: string): Promise<Array<Message>> => {
|
||||
if (!ChannelPinsStore.getHasMore(channelId) || ChannelPinsStore.getIsLoading(channelId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const before = ChannelPinsStore.getOldestPinnedAt(channelId);
|
||||
if (!before) {
|
||||
return [];
|
||||
}
|
||||
|
||||
ChannelPinsStore.handleFetchPending(channelId);
|
||||
try {
|
||||
logger.debug(`Loading more pins for channel ${channelId} before ${before}`);
|
||||
const response = await http.get<ChannelPinsPayload>({
|
||||
url: Endpoints.CHANNEL_PINS(channelId),
|
||||
query: {
|
||||
limit: PIN_PAGE_SIZE,
|
||||
before,
|
||||
},
|
||||
});
|
||||
const body = response.body ?? {items: [], has_more: false};
|
||||
ChannelPinsStore.handleChannelPinsFetchSuccess(channelId, body.items, body.has_more);
|
||||
return body.items.map((pin) => pin.message);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load more pins for channel ${channelId}:`, error);
|
||||
ChannelPinsStore.handleChannelPinsFetchError(channelId);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getFailureReason = (error: HttpError): PinFailureReason => {
|
||||
const errorCode = getApiErrorCode(error);
|
||||
if (errorCode === APIErrorCodes.CANNOT_SEND_MESSAGES_TO_USER) {
|
||||
return 'dm_restricted';
|
||||
}
|
||||
return 'generic';
|
||||
};
|
||||
|
||||
export const pin = async (channelId: string, messageId: string): Promise<void> => {
|
||||
try {
|
||||
await http.put({url: Endpoints.CHANNEL_PIN(channelId, messageId)});
|
||||
logger.debug(`Pinned message ${messageId} in channel ${channelId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to pin message ${messageId} in channel ${channelId}:`, error);
|
||||
const reason = getFailureReason(error as HttpError);
|
||||
ModalActionCreators.push(modal(() => <PinFailedModal reason={reason} />));
|
||||
}
|
||||
};
|
||||
|
||||
export const unpin = async (channelId: string, messageId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.CHANNEL_PIN(channelId, messageId)});
|
||||
logger.debug(`Unpinned message ${messageId} from channel ${channelId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to unpin message ${messageId} from channel ${channelId}:`, error);
|
||||
const reason = getFailureReason(error as HttpError);
|
||||
ModalActionCreators.push(modal(() => <PinFailedModal isUnpin reason={reason} />));
|
||||
}
|
||||
};
|
||||
29
fluxer_app/src/actions/ChannelStickerActionCreators.tsx
Normal file
29
fluxer_app/src/actions/ChannelStickerActionCreators.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 {GuildStickerRecord} from '~/records/GuildStickerRecord';
|
||||
import ChannelStickerStore from '~/stores/ChannelStickerStore';
|
||||
|
||||
export function setPendingSticker(channelId: string, sticker: GuildStickerRecord): void {
|
||||
ChannelStickerStore.setPendingSticker(channelId, sticker);
|
||||
}
|
||||
|
||||
export function removePendingSticker(channelId: string): void {
|
||||
ChannelStickerStore.removePendingSticker(channelId);
|
||||
}
|
||||
149
fluxer_app/src/actions/ContextMenuActionCreators.tsx
Normal file
149
fluxer_app/src/actions/ContextMenuActionCreators.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import type {ContextMenu, ContextMenuConfig, ContextMenuTargetElement} from '~/stores/ContextMenuStore';
|
||||
import ContextMenuStore from '~/stores/ContextMenuStore';
|
||||
|
||||
const nativeContextMenuTarget: ContextMenuTargetElement = {
|
||||
tagName: 'ReactNativeContextMenu',
|
||||
isConnected: true,
|
||||
focus: (): void => undefined,
|
||||
addEventListener: (..._args: Parameters<HTMLElement['addEventListener']>) => undefined,
|
||||
removeEventListener: (..._args: Parameters<HTMLElement['removeEventListener']>) => undefined,
|
||||
};
|
||||
|
||||
const makeId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random()}`;
|
||||
|
||||
const getViewportCenterForElement = (el: Element) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const scrollX = window.scrollX || window.pageXOffset || 0;
|
||||
const scrollY = window.scrollY || window.pageYOffset || 0;
|
||||
return {x: rect.left + rect.width / 2 + scrollX, y: rect.top + rect.height / 2 + scrollY};
|
||||
};
|
||||
|
||||
const toHTMLElement = (value: unknown): HTMLElement | null => {
|
||||
if (!value) return null;
|
||||
if (value instanceof HTMLElement) return value;
|
||||
if (value instanceof Element) {
|
||||
return (value.closest('button,[role="button"],a,[data-contextmenu-anchor="true"]') as HTMLElement | null) ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const close = (): void => {
|
||||
ContextMenuStore.close();
|
||||
};
|
||||
|
||||
type RenderFn = (props: {onClose: () => void}) => React.ReactNode;
|
||||
|
||||
export const openAtPoint = (
|
||||
point: {x: number; y: number},
|
||||
render: RenderFn,
|
||||
config?: ContextMenuConfig,
|
||||
target: ContextMenuTargetElement = nativeContextMenuTarget,
|
||||
): void => {
|
||||
const contextMenu: ContextMenu = {
|
||||
id: makeId('context-menu'),
|
||||
target: {x: point.x, y: point.y, target},
|
||||
render,
|
||||
config: {noBlurEvent: true, ...config},
|
||||
};
|
||||
|
||||
ContextMenuStore.open(contextMenu);
|
||||
};
|
||||
|
||||
export const openForElement = (
|
||||
element: HTMLElement,
|
||||
render: RenderFn,
|
||||
options?: {point?: {x: number; y: number}; config?: ContextMenuConfig},
|
||||
): void => {
|
||||
const point = options?.point ?? getViewportCenterForElement(element);
|
||||
openAtPoint(point, render, options?.config, element);
|
||||
};
|
||||
|
||||
export const openFromEvent = (
|
||||
event: React.MouseEvent | MouseEvent,
|
||||
render: RenderFn,
|
||||
config?: ContextMenuConfig,
|
||||
): void => {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
|
||||
const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
|
||||
|
||||
const currentTarget = 'currentTarget' in event ? toHTMLElement(event.currentTarget) : null;
|
||||
const target = 'target' in event ? toHTMLElement(event.target) : null;
|
||||
const anchor = currentTarget ?? target;
|
||||
|
||||
const hasPointerCoords = !(event.pageX === 0 && event.pageY === 0 && nativeEvent.detail === 0);
|
||||
const point = hasPointerCoords
|
||||
? {x: event.pageX + 2, y: event.pageY + 2}
|
||||
: anchor
|
||||
? (() => {
|
||||
const c = getViewportCenterForElement(anchor);
|
||||
return {x: c.x + 2, y: c.y + 2};
|
||||
})()
|
||||
: {x: 0, y: 0};
|
||||
|
||||
openAtPoint(point, render, config, anchor ?? nativeContextMenuTarget);
|
||||
};
|
||||
|
||||
export const openFromElementBottomRight = (
|
||||
event: React.MouseEvent | MouseEvent,
|
||||
render: RenderFn,
|
||||
config?: ContextMenuConfig,
|
||||
): void => {
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
|
||||
const currentTarget = 'currentTarget' in event ? toHTMLElement(event.currentTarget) : null;
|
||||
const target = 'target' in event ? toHTMLElement(event.target) : null;
|
||||
const anchor = currentTarget ?? target;
|
||||
|
||||
if (!anchor) {
|
||||
openFromEvent(event, render, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const scrollX = window.scrollX || window.pageXOffset || 0;
|
||||
const scrollY = window.scrollY || window.pageYOffset || 0;
|
||||
const point = {x: rect.right + scrollX, y: rect.bottom + scrollY + 4};
|
||||
|
||||
openAtPoint(point, render, {align: 'top-right', ...config}, anchor);
|
||||
};
|
||||
|
||||
export const openNativeContextMenu = (render: RenderFn, config?: ContextMenu['config']): void => {
|
||||
const contextMenu: ContextMenu = {
|
||||
id: makeId('native-context-menu'),
|
||||
target: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
target: nativeContextMenuTarget,
|
||||
},
|
||||
render,
|
||||
config: {
|
||||
returnFocus: false,
|
||||
...config,
|
||||
},
|
||||
};
|
||||
|
||||
ContextMenuStore.open(contextMenu);
|
||||
};
|
||||
89
fluxer_app/src/actions/DeveloperOptionsActionCreators.tsx
Normal file
89
fluxer_app/src/actions/DeveloperOptionsActionCreators.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* 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 {ChannelTypes} from '~/Constants';
|
||||
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {type Channel, ChannelRecord} from '~/records/ChannelRecord';
|
||||
import {type UserPartial, UserRecord} from '~/records/UserRecord';
|
||||
import DeveloperOptionsStore, {type DeveloperOptionsState} from '~/stores/DeveloperOptionsStore';
|
||||
import MockIncomingCallStore from '~/stores/MockIncomingCallStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
const logger = new Logger('DeveloperOptions');
|
||||
|
||||
export const updateOption = <K extends keyof DeveloperOptionsState>(key: K, value: DeveloperOptionsState[K]): void => {
|
||||
logger.debug(`Updating developer option: ${String(key)} = ${value}`);
|
||||
DeveloperOptionsStore.updateOption(key, value);
|
||||
};
|
||||
|
||||
export function setAttachmentMock(
|
||||
attachmentId: string,
|
||||
mock: DeveloperOptionsState['mockAttachmentStates'][string] | null,
|
||||
): void {
|
||||
const next = {...DeveloperOptionsStore.mockAttachmentStates};
|
||||
if (mock === null) {
|
||||
delete next[attachmentId];
|
||||
} else {
|
||||
next[attachmentId] = mock;
|
||||
}
|
||||
updateOption('mockAttachmentStates', next);
|
||||
ComponentDispatch.dispatch('LAYOUT_RESIZED');
|
||||
}
|
||||
|
||||
export function clearAllAttachmentMocks(): void {
|
||||
updateOption('mockAttachmentStates', {});
|
||||
ComponentDispatch.dispatch('LAYOUT_RESIZED');
|
||||
}
|
||||
|
||||
export function triggerMockIncomingCall(): void {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
logger.warn('Cannot trigger mock incoming call: No current user');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = Date.now() - 1420070400000;
|
||||
const random = Math.floor(Math.random() * 4096);
|
||||
const mockChannelId = ((timestamp << 22) | random).toString();
|
||||
|
||||
const initiatorPartial: UserPartial = {
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
discriminator: currentUser.discriminator,
|
||||
avatar: currentUser.avatar ?? null,
|
||||
flags: currentUser.flags ?? 0,
|
||||
};
|
||||
|
||||
const channelData: Channel = {
|
||||
id: mockChannelId,
|
||||
type: ChannelTypes.DM,
|
||||
recipients: [initiatorPartial],
|
||||
};
|
||||
|
||||
const channelRecord = new ChannelRecord(channelData);
|
||||
const initiatorRecord = new UserRecord(initiatorPartial);
|
||||
|
||||
MockIncomingCallStore.setMockCall({
|
||||
channel: channelRecord,
|
||||
initiator: initiatorRecord,
|
||||
});
|
||||
|
||||
logger.info(`Triggered mock incoming call from user ${currentUser.username}`);
|
||||
}
|
||||
40
fluxer_app/src/actions/DimensionActionCreators.tsx
Normal file
40
fluxer_app/src/actions/DimensionActionCreators.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 {Logger} from '~/lib/Logger';
|
||||
import DimensionStore from '~/stores/DimensionStore';
|
||||
|
||||
const logger = new Logger('DimensionActions');
|
||||
|
||||
type GuildId = string;
|
||||
|
||||
export const updateChannelListScroll = (guildId: GuildId, scrollTop: number): void => {
|
||||
logger.debug(`Updating channel list scroll: guildId=${guildId}, scrollTop=${scrollTop}`);
|
||||
DimensionStore.updateGuildDimensions(guildId, scrollTop, undefined);
|
||||
};
|
||||
|
||||
export const clearChannelListScrollTo = (guildId: GuildId): void => {
|
||||
logger.debug(`Clearing channel list scroll target: guildId=${guildId}`);
|
||||
DimensionStore.updateGuildDimensions(guildId, undefined, null);
|
||||
};
|
||||
|
||||
export const updateGuildListScroll = (scrollTop: number): void => {
|
||||
logger.debug(`Updating guild list scroll: scrollTop=${scrollTop}`);
|
||||
DimensionStore.updateGuildListDimensions(scrollTop);
|
||||
};
|
||||
33
fluxer_app/src/actions/DraftActionCreators.tsx
Normal file
33
fluxer_app/src/actions/DraftActionCreators.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {Logger} from '~/lib/Logger';
|
||||
import DraftStore from '~/stores/DraftStore';
|
||||
|
||||
const logger = new Logger('Draft');
|
||||
|
||||
export const createDraft = (channelId: string, content: string): void => {
|
||||
logger.debug(`Creating draft for channel ${channelId}`);
|
||||
DraftStore.createDraft(channelId, content);
|
||||
};
|
||||
|
||||
export const deleteDraft = (channelId: string): void => {
|
||||
logger.debug(`Deleting draft for channel ${channelId}`);
|
||||
DraftStore.deleteDraft(channelId);
|
||||
};
|
||||
28
fluxer_app/src/actions/EmojiActionCreators.tsx
Normal file
28
fluxer_app/src/actions/EmojiActionCreators.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 {Logger} from '~/lib/Logger';
|
||||
import EmojiStore from '~/stores/EmojiStore';
|
||||
|
||||
const logger = new Logger('Emoji');
|
||||
|
||||
export const setSkinTone = (skinTone: string): void => {
|
||||
logger.debug(`Setting emoji skin tone: ${skinTone}`);
|
||||
EmojiStore.setSkinTone(skinTone);
|
||||
};
|
||||
40
fluxer_app/src/actions/EmojiPickerActionCreators.tsx
Normal file
40
fluxer_app/src/actions/EmojiPickerActionCreators.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 EmojiPickerStore from '~/stores/EmojiPickerStore';
|
||||
import type {Emoji} from '~/stores/EmojiStore';
|
||||
|
||||
function getEmojiKey(emoji: Emoji): string {
|
||||
if (emoji.id) {
|
||||
return `custom:${emoji.guildId}:${emoji.id}`;
|
||||
}
|
||||
return `unicode:${emoji.uniqueName}`;
|
||||
}
|
||||
|
||||
export function trackEmojiUsage(emoji: Emoji): void {
|
||||
EmojiPickerStore.trackEmojiUsage(getEmojiKey(emoji));
|
||||
}
|
||||
|
||||
export function toggleFavorite(emoji: Emoji): void {
|
||||
EmojiPickerStore.toggleFavorite(getEmojiKey(emoji));
|
||||
}
|
||||
|
||||
export function toggleCategory(category: string): void {
|
||||
EmojiPickerStore.toggleCategory(category);
|
||||
}
|
||||
44
fluxer_app/src/actions/ExpressionPickerActionCreators.tsx
Normal file
44
fluxer_app/src/actions/ExpressionPickerActionCreators.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 {ExpressionPickerTabType} from '~/components/popouts/ExpressionPickerPopout';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import ExpressionPickerStore from '~/stores/ExpressionPickerStore';
|
||||
|
||||
const logger = new Logger('ExpressionPicker');
|
||||
|
||||
export const open = (channelId: string, tab?: ExpressionPickerTabType): void => {
|
||||
logger.debug(`Opening expression picker for channel ${channelId}, tab: ${tab}`);
|
||||
ExpressionPickerStore.open(channelId, tab);
|
||||
};
|
||||
|
||||
export const close = (): void => {
|
||||
logger.debug('Closing expression picker');
|
||||
ExpressionPickerStore.close();
|
||||
};
|
||||
|
||||
export const toggle = (channelId: string, tab: ExpressionPickerTabType): void => {
|
||||
logger.debug(`Toggling expression picker for channel ${channelId}, tab: ${tab}`);
|
||||
ExpressionPickerStore.toggle(channelId, tab);
|
||||
};
|
||||
|
||||
export const setTab = (tab: ExpressionPickerTabType): void => {
|
||||
logger.debug(`Setting expression picker tab to: ${tab}`);
|
||||
ExpressionPickerStore.setTab(tab);
|
||||
};
|
||||
180
fluxer_app/src/actions/FavoriteMemeActionCreators.tsx
Normal file
180
fluxer_app/src/actions/FavoriteMemeActionCreators.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {APIErrorCodes, ME} from '~/Constants';
|
||||
import {MaxFavoriteMemesModal} from '~/components/alerts/MaxFavoriteMemesModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {FavoriteMeme} from '~/records/FavoriteMemeRecord';
|
||||
import FavoriteMemeStore from '~/stores/FavoriteMemeStore';
|
||||
|
||||
const logger = new Logger('FavoriteMemes');
|
||||
|
||||
const getApiErrorCode = (error: unknown): string | undefined => {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error) {
|
||||
const {code} = error as {code?: unknown};
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const createFavoriteMeme = async (
|
||||
i18n: I18n,
|
||||
{
|
||||
channelId,
|
||||
messageId,
|
||||
attachmentId,
|
||||
embedIndex,
|
||||
name,
|
||||
altText,
|
||||
tags,
|
||||
}: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
attachmentId?: string;
|
||||
embedIndex?: number;
|
||||
name: string;
|
||||
altText?: string;
|
||||
tags?: Array<string>;
|
||||
},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await http.post<FavoriteMeme>(Endpoints.CHANNEL_MESSAGE_FAVORITE_MEMES(channelId, messageId), {
|
||||
attachment_id: attachmentId,
|
||||
embed_index: embedIndex,
|
||||
name,
|
||||
alt_text: altText,
|
||||
tags,
|
||||
});
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Added to saved media`),
|
||||
});
|
||||
logger.debug(`Successfully added favorite meme from message ${messageId}`);
|
||||
} catch (error: unknown) {
|
||||
logger.error(`Failed to add favorite meme from message ${messageId}:`, error);
|
||||
|
||||
if (getApiErrorCode(error) === APIErrorCodes.MAX_FAVORITE_MEMES) {
|
||||
ModalActionCreators.push(modal(() => <MaxFavoriteMemesModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createFavoriteMemeFromUrl = async (
|
||||
i18n: I18n,
|
||||
{
|
||||
url,
|
||||
name,
|
||||
altText,
|
||||
tags,
|
||||
tenorId,
|
||||
}: {
|
||||
url: string;
|
||||
name: string;
|
||||
altText?: string;
|
||||
tags?: Array<string>;
|
||||
tenorId?: string;
|
||||
},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await http.post<FavoriteMeme>(Endpoints.USER_FAVORITE_MEMES(ME), {
|
||||
url,
|
||||
name,
|
||||
alt_text: altText,
|
||||
tags,
|
||||
tenor_id: tenorId,
|
||||
});
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Added to saved media`),
|
||||
});
|
||||
logger.debug(`Successfully added favorite meme from URL ${url}`);
|
||||
} catch (error: unknown) {
|
||||
logger.error(`Failed to add favorite meme from URL ${url}:`, error);
|
||||
|
||||
if (getApiErrorCode(error) === APIErrorCodes.MAX_FAVORITE_MEMES) {
|
||||
ModalActionCreators.push(modal(() => <MaxFavoriteMemesModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFavoriteMeme = async (
|
||||
i18n: I18n,
|
||||
{
|
||||
memeId,
|
||||
name,
|
||||
altText,
|
||||
tags,
|
||||
}: {
|
||||
memeId: string;
|
||||
name?: string;
|
||||
altText?: string | null;
|
||||
tags?: Array<string>;
|
||||
},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const response = await http.patch<FavoriteMeme>(Endpoints.USER_FAVORITE_MEME(ME, memeId), {
|
||||
name,
|
||||
alt_text: altText,
|
||||
tags,
|
||||
});
|
||||
|
||||
FavoriteMemeStore.updateMeme(response.body);
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Updated saved media`),
|
||||
});
|
||||
logger.debug(`Successfully updated favorite meme ${memeId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update favorite meme ${memeId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFavoriteMeme = async (i18n: I18n, memeId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.USER_FAVORITE_MEME(ME, memeId)});
|
||||
|
||||
FavoriteMemeStore.deleteMeme(memeId);
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Removed from saved media`),
|
||||
});
|
||||
logger.debug(`Successfully deleted favorite meme ${memeId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete favorite meme ${memeId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
51
fluxer_app/src/actions/FavoritesActionCreators.tsx
Normal file
51
fluxer_app/src/actions/FavoritesActionCreators.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const confirmHideFavorites = (onConfirm: (() => void) | undefined, i18n: I18n): void => {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(msg`Hide Favorites`)}
|
||||
description={
|
||||
<div>
|
||||
<Trans>
|
||||
This will hide all favorites-related UI elements including buttons and menu items. Your existing favorites
|
||||
will be preserved and can be re-enabled anytime from{' '}
|
||||
<strong>User Settings → Look & Feel → Favorites</strong>.
|
||||
</Trans>
|
||||
</div>
|
||||
}
|
||||
primaryText={i18n._(msg`Hide Favorites`)}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={() => {
|
||||
AccessibilityActionCreators.update({showFavorites: false});
|
||||
onConfirm?.();
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
220
fluxer_app/src/actions/GiftActionCreators.tsx
Normal file
220
fluxer_app/src/actions/GiftActionCreators.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {APIErrorCodes} from '~/Constants';
|
||||
import {GenericErrorModal} from '~/components/alerts/GenericErrorModal';
|
||||
import {GiftAcceptModal} from '~/components/modals/GiftAcceptModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http, {HttpError} from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {UserPartial} from '~/records/UserRecord';
|
||||
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
|
||||
import GiftStore from '~/stores/GiftStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
interface ApiErrorResponse {
|
||||
code?: string;
|
||||
message?: string;
|
||||
errors?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const logger = new Logger('Gifts');
|
||||
|
||||
export interface Gift {
|
||||
code: string;
|
||||
duration_months: number;
|
||||
redeemed: boolean;
|
||||
created_by?: UserPartial;
|
||||
}
|
||||
|
||||
export interface GiftMetadata {
|
||||
code: string;
|
||||
duration_months: number;
|
||||
created_at: string;
|
||||
created_by: UserPartial;
|
||||
redeemed_at: string | null;
|
||||
redeemed_by: UserPartial | null;
|
||||
}
|
||||
|
||||
export const fetch = async (code: string): Promise<Gift> => {
|
||||
try {
|
||||
const response = await http.get<Gift>({url: Endpoints.GIFT(code)});
|
||||
const gift = response.body;
|
||||
logger.debug('Gift fetched', {code});
|
||||
return gift;
|
||||
} catch (error) {
|
||||
logger.error('Gift fetch failed', error);
|
||||
|
||||
if (error instanceof HttpError && error.status === 404) {
|
||||
GiftStore.markAsInvalid(code);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchWithCoalescing = async (code: string): Promise<Gift> => {
|
||||
return GiftStore.fetchGift(code);
|
||||
};
|
||||
|
||||
export const openAcceptModal = async (code: string): Promise<void> => {
|
||||
void fetchWithCoalescing(code).catch(() => {});
|
||||
ModalActionCreators.pushWithKey(
|
||||
modal(() => <GiftAcceptModal code={code} />),
|
||||
`gift-accept-${code}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const redeem = async (i18n: I18n, code: string): Promise<void> => {
|
||||
try {
|
||||
await http.post({url: Endpoints.GIFT_REDEEM(code)});
|
||||
logger.info('Gift redeemed', {code});
|
||||
GiftStore.markAsRedeemed(code);
|
||||
ToastActionCreators.success(i18n._(msg`Gift redeemed successfully!`));
|
||||
} catch (error) {
|
||||
logger.error('Gift redeem failed', error);
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
const errorResponse = error.body as ApiErrorResponse;
|
||||
const errorCode = errorResponse?.code;
|
||||
|
||||
switch (errorCode) {
|
||||
case APIErrorCodes.CANNOT_REDEEM_PLUTONIUM_WITH_VISIONARY:
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Cannot Redeem Gift`)}
|
||||
message={i18n._(msg`You cannot redeem Plutonium gift codes while you have Visionary premium.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
break;
|
||||
case APIErrorCodes.UNKNOWN_GIFT_CODE:
|
||||
GiftStore.markAsInvalid(code);
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Invalid Gift Code`)}
|
||||
message={i18n._(msg`This gift code is invalid or has already been redeemed.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
break;
|
||||
case APIErrorCodes.GIFT_CODE_ALREADY_REDEEMED:
|
||||
GiftStore.markAsRedeemed(code);
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Gift Already Redeemed`)}
|
||||
message={i18n._(msg`This gift code has already been redeemed.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
if (error.status === 404) {
|
||||
GiftStore.markAsInvalid(code);
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Gift Not Found`)}
|
||||
message={i18n._(msg`This gift code could not be found.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Failed to Redeem Gift`)}
|
||||
message={i18n._(msg`We couldn't redeem this gift code. Please try again.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Failed to Redeem Gift`)}
|
||||
message={i18n._(msg`We couldn't redeem this gift code. Please try again.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchUserGifts = async (): Promise<Array<GiftMetadata>> => {
|
||||
if (DeveloperOptionsStore.mockGiftInventory) {
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const userPartial: UserPartial = currentUser
|
||||
? {
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
discriminator: currentUser.discriminator,
|
||||
avatar: currentUser.avatar,
|
||||
flags: currentUser.flags,
|
||||
}
|
||||
: {
|
||||
id: '000000000000000000',
|
||||
username: 'MockUser',
|
||||
discriminator: '0000',
|
||||
avatar: null,
|
||||
flags: 0,
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const durationMonths = DeveloperOptionsStore.mockGiftDurationMonths ?? 12;
|
||||
const isRedeemed = DeveloperOptionsStore.mockGiftRedeemed ?? false;
|
||||
|
||||
const mockGift: GiftMetadata = {
|
||||
code: 'MOCK-GIFT-TEST-1234',
|
||||
duration_months: durationMonths,
|
||||
created_at: sevenDaysAgo.toISOString(),
|
||||
created_by: userPartial,
|
||||
redeemed_at: isRedeemed ? twoDaysAgo.toISOString() : null,
|
||||
redeemed_by: isRedeemed ? userPartial : null,
|
||||
};
|
||||
|
||||
logger.debug('Returning mock user gifts', {count: 1});
|
||||
return [mockGift];
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await http.get<Array<GiftMetadata>>({url: Endpoints.USER_GIFTS});
|
||||
const gifts = response.body;
|
||||
logger.debug('User gifts fetched', {count: gifts.length});
|
||||
return gifts;
|
||||
} catch (error) {
|
||||
logger.error('User gifts fetch failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
409
fluxer_app/src/actions/GuildActionCreators.tsx
Normal file
409
fluxer_app/src/actions/GuildActionCreators.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
/*
|
||||
* 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 {ChannelMoveOperation} from '~/components/layout/utils/channelMoveOperation';
|
||||
import type {AuditLogActionType} from '~/constants/AuditLogActionType';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {Guild} from '~/records/GuildRecord';
|
||||
import type {GuildRole} from '~/records/GuildRoleRecord';
|
||||
import type {Invite} from '~/records/MessageRecord';
|
||||
import InviteStore from '~/stores/InviteStore';
|
||||
|
||||
const logger = new Logger('Guilds');
|
||||
|
||||
import type {UserPartial} from '~/records/UserRecord';
|
||||
|
||||
export interface AuditLogChangeEntry {
|
||||
key?: string;
|
||||
old_value?: unknown;
|
||||
new_value?: unknown;
|
||||
oldValue?: unknown;
|
||||
newValue?: unknown;
|
||||
}
|
||||
|
||||
export type GuildAuditLogChangePayload = Array<AuditLogChangeEntry> | null;
|
||||
|
||||
export interface GuildAuditLogEntry {
|
||||
id: string;
|
||||
action_type: number;
|
||||
user_id: string | null;
|
||||
target_id: string | null;
|
||||
reason?: string;
|
||||
options?: Record<string, unknown>;
|
||||
changes?: GuildAuditLogChangePayload;
|
||||
}
|
||||
|
||||
export interface GuildAuditLogFetchParams {
|
||||
userId?: string;
|
||||
actionType?: AuditLogActionType;
|
||||
limit?: number;
|
||||
beforeLogId?: string;
|
||||
afterLogId?: string;
|
||||
}
|
||||
|
||||
interface GuildAuditLogFetchResponse {
|
||||
audit_log_entries: Array<GuildAuditLogEntry>;
|
||||
users: Array<UserPartial>;
|
||||
webhooks: Array<unknown>;
|
||||
}
|
||||
|
||||
export interface GuildBan {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
tag: string;
|
||||
discriminator: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
reason: string | null;
|
||||
moderator_id: string;
|
||||
banned_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export const create = async (params: Pick<Guild, 'name'> & {icon?: string | null}): Promise<Guild> => {
|
||||
try {
|
||||
const response = await http.post<Guild>(Endpoints.GUILDS, params);
|
||||
const guild = response.body;
|
||||
logger.debug(`Created new guild: ${params.name}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create guild:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const update = async (
|
||||
guildId: string,
|
||||
params: Partial<
|
||||
Pick<
|
||||
Guild,
|
||||
| 'name'
|
||||
| 'icon'
|
||||
| 'banner'
|
||||
| 'splash'
|
||||
| 'embed_splash'
|
||||
| 'splash_card_alignment'
|
||||
| 'afk_channel_id'
|
||||
| 'afk_timeout'
|
||||
| 'system_channel_id'
|
||||
| 'system_channel_flags'
|
||||
| 'features'
|
||||
| 'default_message_notifications'
|
||||
| 'verification_level'
|
||||
| 'mfa_level'
|
||||
| 'explicit_content_filter'
|
||||
>
|
||||
>,
|
||||
): Promise<Guild> => {
|
||||
try {
|
||||
const response = await http.patch<Guild>(Endpoints.GUILD(guildId), params);
|
||||
const guild = response.body;
|
||||
logger.debug(`Updated guild ${guildId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const moveChannel = async (guildId: string, operation: ChannelMoveOperation): Promise<void> => {
|
||||
try {
|
||||
await http.patch({
|
||||
url: Endpoints.GUILD_CHANNELS(guildId),
|
||||
body: [
|
||||
{
|
||||
id: operation.channelId,
|
||||
parent_id: operation.newParentId,
|
||||
lock_permissions: false,
|
||||
position: operation.position,
|
||||
},
|
||||
],
|
||||
retries: 5,
|
||||
});
|
||||
logger.debug(`Moved channel ${operation.channelId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to move channel ${operation.channelId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getVanityURL = async (guildId: string): Promise<{code: string | null; uses: number}> => {
|
||||
try {
|
||||
const response = await http.get<{code: string | null; uses: number}>(Endpoints.GUILD_VANITY_URL(guildId));
|
||||
const result = response.body;
|
||||
logger.debug(`Fetched vanity URL for guild ${guildId}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch vanity URL for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateVanityURL = async (guildId: string, code: string | null): Promise<string> => {
|
||||
try {
|
||||
const response = await http.patch<{code: string}>(Endpoints.GUILD_VANITY_URL(guildId), {code});
|
||||
logger.debug(`Updated vanity URL for guild ${guildId} to ${code || 'none'}`);
|
||||
return response.body.code;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update vanity URL for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createRole = async (guildId: string, name: string): Promise<void> => {
|
||||
try {
|
||||
await http.post({url: Endpoints.GUILD_ROLES(guildId), body: {name}});
|
||||
logger.debug(`Created role "${name}" in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create role in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRole = async (guildId: string, roleId: string, patch: Partial<GuildRole>): Promise<void> => {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_ROLE(guildId, roleId), body: patch});
|
||||
logger.debug(`Updated role ${roleId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update role ${roleId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRole = async (guildId: string, roleId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_ROLE(guildId, roleId)});
|
||||
logger.debug(`Deleted role ${roleId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete role ${roleId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const setRoleOrder = async (guildId: string, orderedRoleIds: Array<string>): Promise<void> => {
|
||||
try {
|
||||
const filteredIds = orderedRoleIds.filter((id) => id !== guildId);
|
||||
const payload = filteredIds.map((id, index) => ({id, position: filteredIds.length - index}));
|
||||
await http.patch({url: Endpoints.GUILD_ROLES(guildId), body: payload, retries: 5});
|
||||
logger.debug(`Updated role ordering in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update role ordering in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const setRoleHoistOrder = async (guildId: string, orderedRoleIds: Array<string>): Promise<void> => {
|
||||
try {
|
||||
const filteredIds = orderedRoleIds.filter((id) => id !== guildId);
|
||||
const payload = filteredIds.map((id, index) => ({id, hoist_position: filteredIds.length - index}));
|
||||
await http.patch({url: Endpoints.GUILD_ROLE_HOIST_POSITIONS(guildId), body: payload, retries: 5});
|
||||
logger.debug(`Updated role hoist ordering in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update role hoist ordering in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const resetRoleHoistOrder = async (guildId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_ROLE_HOIST_POSITIONS(guildId)});
|
||||
logger.debug(`Reset role hoist ordering in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to reset role hoist ordering in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (guildId: string): Promise<void> => {
|
||||
try {
|
||||
await http.post({url: Endpoints.GUILD_DELETE(guildId), body: {}});
|
||||
logger.debug(`Deleted guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const leave = async (guildId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.USER_GUILDS(guildId)});
|
||||
logger.debug(`Left guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to leave guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchGuildInvites = async (guildId: string): Promise<Array<Invite>> => {
|
||||
try {
|
||||
InviteStore.handleGuildInvitesFetchPending(guildId);
|
||||
const response = await http.get<Array<Invite>>(Endpoints.GUILD_INVITES(guildId));
|
||||
const invites = response.body;
|
||||
InviteStore.handleGuildInvitesFetchSuccess(guildId, invites);
|
||||
return invites;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch invites for guild ${guildId}:`, error);
|
||||
InviteStore.handleGuildInvitesFetchError(guildId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleInvitesDisabled = async (guildId: string, disabled: boolean): Promise<Guild> => {
|
||||
try {
|
||||
const response = await http.patch<Guild>(Endpoints.GUILD(guildId), {
|
||||
features: disabled ? ['INVITES_DISABLED'] : [],
|
||||
});
|
||||
const guild = response.body;
|
||||
logger.debug(`${disabled ? 'Disabled' : 'Enabled'} invites for guild ${guildId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${disabled ? 'disable' : 'enable'} invites for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleTextChannelFlexibleNames = async (guildId: string, enabled: boolean): Promise<Guild> => {
|
||||
try {
|
||||
const response = await http.patch<Guild>(Endpoints.GUILD_TEXT_CHANNEL_FLEXIBLE_NAMES(guildId), {enabled});
|
||||
const guild = response.body;
|
||||
logger.debug(`${enabled ? 'Enabled' : 'Disabled'} flexible text channel names for guild ${guildId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to ${enabled ? 'enable' : 'disable'} flexible text channel names for guild ${guildId}:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleDetachedBanner = async (guildId: string, enabled: boolean): Promise<Guild> => {
|
||||
try {
|
||||
const response = await http.patch<Guild>(Endpoints.GUILD_DETACHED_BANNER(guildId), {enabled});
|
||||
const guild = response.body;
|
||||
logger.debug(`${enabled ? 'Enabled' : 'Disabled'} detached banner for guild ${guildId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${enabled ? 'enable' : 'disable'} detached banner for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleDisallowUnclaimedAccounts = async (guildId: string, enabled: boolean): Promise<Guild> => {
|
||||
try {
|
||||
const response = await http.patch<Guild>(Endpoints.GUILD_DISALLOW_UNCLAIMED_ACCOUNTS(guildId), {enabled});
|
||||
const guild = response.body;
|
||||
logger.debug(`${enabled ? 'Enabled' : 'Disabled'} disallow unclaimed accounts for guild ${guildId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to ${enabled ? 'enable' : 'disable'} disallow unclaimed accounts for guild ${guildId}:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const transferOwnership = async (guildId: string, newOwnerId: string): Promise<Guild> => {
|
||||
try {
|
||||
const response = await http.post<Guild>(Endpoints.GUILD_TRANSFER_OWNERSHIP(guildId), {
|
||||
new_owner_id: newOwnerId,
|
||||
});
|
||||
const guild = response.body;
|
||||
logger.debug(`Transferred ownership of guild ${guildId} to ${newOwnerId}`);
|
||||
return guild;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to transfer ownership of guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const banMember = async (
|
||||
guildId: string,
|
||||
userId: string,
|
||||
deleteMessageDays?: number,
|
||||
reason?: string,
|
||||
banDurationSeconds?: number,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await http.put({
|
||||
url: Endpoints.GUILD_BAN(guildId, userId),
|
||||
body: {
|
||||
delete_message_days: deleteMessageDays ?? 0,
|
||||
reason: reason ?? null,
|
||||
ban_duration_seconds: banDurationSeconds,
|
||||
},
|
||||
});
|
||||
logger.debug(`Banned user ${userId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ban user ${userId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const unbanMember = async (guildId: string, userId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_BAN(guildId, userId)});
|
||||
logger.debug(`Unbanned user ${userId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to unban user ${userId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchBans = async (guildId: string): Promise<Array<GuildBan>> => {
|
||||
try {
|
||||
const response = await http.get<Array<GuildBan>>(Endpoints.GUILD_BANS(guildId));
|
||||
const bans = response.body;
|
||||
logger.debug(`Fetched ${bans.length} bans for guild ${guildId}`);
|
||||
return bans;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch bans for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchGuildAuditLogs = async (
|
||||
guildId: string,
|
||||
params: GuildAuditLogFetchParams,
|
||||
): Promise<GuildAuditLogFetchResponse> => {
|
||||
try {
|
||||
const query: Record<string, string | number> = {};
|
||||
if (params.limit !== undefined) query.limit = params.limit;
|
||||
if (params.beforeLogId !== undefined) query.before = params.beforeLogId;
|
||||
if (params.afterLogId !== undefined) query.after = params.afterLogId;
|
||||
if (params.userId) query.user_id = params.userId;
|
||||
if (params.actionType !== undefined) query.action_type = params.actionType;
|
||||
|
||||
const response = await http.get<GuildAuditLogFetchResponse>({
|
||||
url: Endpoints.GUILD_AUDIT_LOGS(guildId),
|
||||
query,
|
||||
});
|
||||
|
||||
const data = response.body;
|
||||
logger.debug(`Fetched ${data.audit_log_entries.length} audit log entries for guild ${guildId}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch audit logs for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
87
fluxer_app/src/actions/GuildEmojiActionCreators.tsx
Normal file
87
fluxer_app/src/actions/GuildEmojiActionCreators.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {GuildEmojiWithUser} from '~/records/GuildEmojiRecord';
|
||||
|
||||
const logger = new Logger('Emojis');
|
||||
|
||||
export const sanitizeEmojiName = (fileName: string): string => {
|
||||
const name =
|
||||
fileName
|
||||
.split('.')
|
||||
.shift()
|
||||
?.replace(/[^a-zA-Z0-9_]/g, '') ?? '';
|
||||
return name.padEnd(2, '_').slice(0, 32);
|
||||
};
|
||||
|
||||
export const list = async (guildId: string): Promise<ReadonlyArray<GuildEmojiWithUser>> => {
|
||||
try {
|
||||
const response = await http.get<ReadonlyArray<GuildEmojiWithUser>>({url: Endpoints.GUILD_EMOJIS(guildId)});
|
||||
const emojis = response.body;
|
||||
logger.debug(`Retrieved ${emojis.length} emojis for guild ${guildId}`);
|
||||
return emojis;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list emojis for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const bulkUpload = async (
|
||||
guildId: string,
|
||||
emojis: Array<{name: string; image: string}>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{success: Array<any>; failed: Array<{name: string; error: string}>}> => {
|
||||
try {
|
||||
const response = await http.post<{success: Array<any>; failed: Array<{name: string; error: string}>}>({
|
||||
url: `${Endpoints.GUILD_EMOJIS(guildId)}/bulk`,
|
||||
body: {emojis},
|
||||
signal,
|
||||
});
|
||||
const result = response.body;
|
||||
logger.debug(`Bulk uploaded ${result.success.length} emojis to guild ${guildId}, ${result.failed.length} failed`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to bulk upload emojis to guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const update = async (guildId: string, emojiId: string, data: {name: string}): Promise<void> => {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_EMOJI(guildId, emojiId), body: data});
|
||||
logger.debug(`Updated emoji ${emojiId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update emoji ${emojiId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (guildId: string, emojiId: string, purge = false): Promise<void> => {
|
||||
try {
|
||||
const query = purge ? '?purge=true' : '';
|
||||
await http.delete({url: `${Endpoints.GUILD_EMOJI(guildId, emojiId)}${query}`});
|
||||
logger.debug(`Removed emoji ${emojiId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove emoji ${emojiId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
114
fluxer_app/src/actions/GuildMemberActionCreators.tsx
Normal file
114
fluxer_app/src/actions/GuildMemberActionCreators.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {GuildMember} from '~/records/GuildMemberRecord';
|
||||
|
||||
const logger = new Logger('GuildMembers');
|
||||
|
||||
export const update = async (
|
||||
guildId: string,
|
||||
userId: string,
|
||||
params: Partial<GuildMember> & {channel_id?: string | null; connection_id?: string},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_MEMBER(guildId, userId), body: params});
|
||||
logger.debug(`Updated member ${userId} in guild ${guildId}`, {connection_id: params.connection_id});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update member ${userId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addRole = async (guildId: string, userId: string, roleId: string): Promise<void> => {
|
||||
try {
|
||||
await http.put({url: Endpoints.GUILD_MEMBER_ROLE(guildId, userId, roleId)});
|
||||
logger.debug(`Added role ${roleId} to member ${userId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to add role ${roleId} to member ${userId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeRole = async (guildId: string, userId: string, roleId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_MEMBER_ROLE(guildId, userId, roleId)});
|
||||
logger.debug(`Removed role ${roleId} from member ${userId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove role ${roleId} from member ${userId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateProfile = async (
|
||||
guildId: string,
|
||||
params: {
|
||||
avatar?: string | null;
|
||||
banner?: string | null;
|
||||
bio?: string | null;
|
||||
pronouns?: string | null;
|
||||
accent_color?: number | null;
|
||||
nick?: string | null;
|
||||
profile_flags?: number | null;
|
||||
},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_MEMBER(guildId), body: params});
|
||||
logger.debug(`Updated current user's per-guild profile in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update current user's per-guild profile in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const kick = async (guildId: string, userId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.GUILD_MEMBER(guildId, userId)});
|
||||
logger.debug(`Kicked member ${userId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to kick member ${userId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const timeout = async (
|
||||
guildId: string,
|
||||
userId: string,
|
||||
communicationDisabledUntil: string | null,
|
||||
timeoutReason?: string | null,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const body: Record<string, string | null> = {
|
||||
communication_disabled_until: communicationDisabledUntil,
|
||||
};
|
||||
if (timeoutReason) {
|
||||
body.timeout_reason = timeoutReason;
|
||||
}
|
||||
await http.patch({
|
||||
url: Endpoints.GUILD_MEMBER(guildId, userId),
|
||||
body,
|
||||
});
|
||||
logger.debug(`Updated timeout for member ${userId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update timeout for member ${userId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
24
fluxer_app/src/actions/GuildNSFWActionCreators.tsx
Normal file
24
fluxer_app/src/actions/GuildNSFWActionCreators.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* 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 GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore';
|
||||
|
||||
export function agreeToNSFWChannel(channelId: string): void {
|
||||
GuildNSFWAgreeStore.agreeToChannel(channelId);
|
||||
}
|
||||
84
fluxer_app/src/actions/GuildStickerActionCreators.tsx
Normal file
84
fluxer_app/src/actions/GuildStickerActionCreators.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {GuildStickerWithUser} from '~/records/GuildStickerRecord';
|
||||
|
||||
const logger = new Logger('Stickers');
|
||||
|
||||
export const sanitizeStickerName = (fileName: string): string => {
|
||||
const name =
|
||||
fileName
|
||||
.split('.')
|
||||
.shift()
|
||||
?.replace(/[^a-zA-Z0-9_]/g, '') ?? '';
|
||||
return name.padEnd(2, '_').slice(0, 30);
|
||||
};
|
||||
|
||||
export const list = async (guildId: string): Promise<ReadonlyArray<GuildStickerWithUser>> => {
|
||||
try {
|
||||
const response = await http.get<ReadonlyArray<GuildStickerWithUser>>({url: Endpoints.GUILD_STICKERS(guildId)});
|
||||
const stickers = response.body;
|
||||
logger.debug(`Retrieved ${stickers.length} stickers for guild ${guildId}`);
|
||||
return stickers;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list stickers for guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const create = async (
|
||||
guildId: string,
|
||||
sticker: {name: string; description: string; tags: Array<string>; image: string},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await http.post({url: Endpoints.GUILD_STICKERS(guildId), body: sticker});
|
||||
logger.debug(`Created sticker ${sticker.name} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create sticker ${sticker.name} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const update = async (
|
||||
guildId: string,
|
||||
stickerId: string,
|
||||
data: {name?: string; description?: string; tags?: Array<string>},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await http.patch({url: Endpoints.GUILD_STICKER(guildId, stickerId), body: data});
|
||||
logger.debug(`Updated sticker ${stickerId} in guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update sticker ${stickerId} in guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (guildId: string, stickerId: string, purge = false): Promise<void> => {
|
||||
try {
|
||||
const query = purge ? '?purge=true' : '';
|
||||
await http.delete({url: `${Endpoints.GUILD_STICKER(guildId, stickerId)}${query}`});
|
||||
logger.debug(`Removed sticker ${stickerId} from guild ${guildId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove sticker ${stickerId} from guild ${guildId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
28
fluxer_app/src/actions/HighlightActionCreators.tsx
Normal file
28
fluxer_app/src/actions/HighlightActionCreators.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 AutocompleteStore from '~/stores/AutocompleteStore';
|
||||
|
||||
export const highlightChannel = (channelId: string): void => {
|
||||
AutocompleteStore.highlightChannel(channelId);
|
||||
};
|
||||
|
||||
export const clearChannelHighlight = (): void => {
|
||||
AutocompleteStore.highlightChannelClear();
|
||||
};
|
||||
90
fluxer_app/src/actions/IARActionCreators.tsx
Normal file
90
fluxer_app/src/actions/IARActionCreators.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
|
||||
const logger = new Logger('IAR');
|
||||
|
||||
export const reportMessage = async (
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
category: string,
|
||||
additionalInfo?: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Reporting message ${messageId} in channel ${channelId}`);
|
||||
await http.post({
|
||||
url: Endpoints.REPORT_MESSAGE,
|
||||
body: {
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
category,
|
||||
additional_info: additionalInfo || undefined,
|
||||
},
|
||||
});
|
||||
logger.info('Message report submitted successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to submit message report:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const reportUser = async (
|
||||
userId: string,
|
||||
category: string,
|
||||
additionalInfo?: string,
|
||||
guildId?: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Reporting user ${userId}${guildId ? ` in guild ${guildId}` : ''}`);
|
||||
await http.post({
|
||||
url: Endpoints.REPORT_USER,
|
||||
body: {
|
||||
user_id: userId,
|
||||
category,
|
||||
additional_info: additionalInfo || undefined,
|
||||
guild_id: guildId || undefined,
|
||||
},
|
||||
});
|
||||
logger.info('User report submitted successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to submit user report:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const reportGuild = async (guildId: string, category: string, additionalInfo?: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Reporting guild ${guildId}`);
|
||||
await http.post({
|
||||
url: Endpoints.REPORT_GUILD,
|
||||
body: {
|
||||
guild_id: guildId,
|
||||
category,
|
||||
additional_info: additionalInfo || undefined,
|
||||
},
|
||||
});
|
||||
logger.info('Guild report submitted successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to submit guild report:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
25
fluxer_app/src/actions/InboxActionCreators.tsx
Normal file
25
fluxer_app/src/actions/InboxActionCreators.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 {InboxTab} from '~/stores/InboxStore';
|
||||
import InboxStore from '~/stores/InboxStore';
|
||||
|
||||
export const setTab = (tab: InboxTab): void => {
|
||||
InboxStore.setTab(tab);
|
||||
};
|
||||
398
fluxer_app/src/actions/InviteActionCreators.tsx
Normal file
398
fluxer_app/src/actions/InviteActionCreators.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {APIErrorCodes} from '~/Constants';
|
||||
import {FeatureTemporarilyDisabledModal} from '~/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {GenericErrorModal} from '~/components/alerts/GenericErrorModal';
|
||||
import {GuildAtCapacityModal} from '~/components/alerts/GuildAtCapacityModal';
|
||||
import {InviteAcceptFailedModal} from '~/components/alerts/InviteAcceptFailedModal';
|
||||
import {InvitesDisabledModal} from '~/components/alerts/InvitesDisabledModal';
|
||||
import {MaxGuildsModal} from '~/components/alerts/MaxGuildsModal';
|
||||
import {TemporaryInviteRequiresPresenceModal} from '~/components/alerts/TemporaryInviteRequiresPresenceModal';
|
||||
import {UserBannedFromGuildModal} from '~/components/alerts/UserBannedFromGuildModal';
|
||||
import {UserIpBannedFromGuildModal} from '~/components/alerts/UserIpBannedFromGuildModal';
|
||||
import {InviteAcceptModal} from '~/components/modals/InviteAcceptModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http, {HttpError} from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {Routes} from '~/Routes';
|
||||
import type {Invite} from '~/records/MessageRecord';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import GuildMemberStore from '~/stores/GuildMemberStore';
|
||||
import InviteStore from '~/stores/InviteStore';
|
||||
import {isGroupDmInvite, isGuildInvite, isPackInvite} from '~/types/InviteTypes';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
|
||||
const logger = new Logger('Invites');
|
||||
const extractErrorCode = (error: unknown): string | undefined => {
|
||||
if (error instanceof HttpError) {
|
||||
const body = error.body;
|
||||
if (body && typeof body === 'object' && 'code' in body) {
|
||||
const {code} = body as {code?: unknown};
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const fetch = async (code: string): Promise<Invite> => {
|
||||
try {
|
||||
logger.debug(`Fetching invite with code ${code}`);
|
||||
const response = await http.get<Invite>(Endpoints.INVITE(code));
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch invite with code ${code}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchWithCoalescing = async (code: string): Promise<Invite> => {
|
||||
return InviteStore.fetchInvite(code);
|
||||
};
|
||||
|
||||
const accept = async (code: string): Promise<Invite> => {
|
||||
try {
|
||||
logger.debug(`Accepting invite with code ${code}`);
|
||||
const response = await http.post<Invite>(Endpoints.INVITE(code), {} as Invite);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to accept invite with code ${code}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const acceptInvite = accept;
|
||||
|
||||
export const acceptAndTransitionToChannel = async (code: string, i18n: I18n): Promise<void> => {
|
||||
let invite: Invite | null = null;
|
||||
try {
|
||||
logger.debug(`Fetching invite details before accepting: ${code}`);
|
||||
invite = await fetchWithCoalescing(code);
|
||||
if (!invite) {
|
||||
throw new Error(`Invite ${code} returned no data`);
|
||||
}
|
||||
|
||||
if (isPackInvite(invite)) {
|
||||
await accept(code);
|
||||
const packLabel = invite.pack.type === 'emoji' ? 'emoji pack' : 'sticker pack';
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: (
|
||||
<Trans>
|
||||
The {packLabel} {invite.pack.name} has been installed.
|
||||
</Trans>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupDmInvite(invite)) {
|
||||
const channelId = invite.channel.id;
|
||||
logger.debug(`Accepting group DM invite ${code} and opening channel ${channelId}`);
|
||||
await accept(code);
|
||||
RouterUtils.transitionTo(Routes.dmChannel(channelId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isGuildInvite(invite)) {
|
||||
throw new Error(`Invite ${code} is not a guild, group DM, or pack invite`);
|
||||
}
|
||||
|
||||
const channelId = invite.channel.id;
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
const guildId = invite.guild.id;
|
||||
const isMember = currentUserId ? GuildMemberStore.getMember(guildId, currentUserId) != null : false;
|
||||
if (isMember) {
|
||||
logger.debug(`User already in guild ${guildId}, transitioning to channel ${channelId}`);
|
||||
RouterUtils.transitionTo(Routes.guildChannel(guildId, channelId));
|
||||
return;
|
||||
}
|
||||
logger.debug(`User not in guild ${guildId}, accepting invite ${code}`);
|
||||
await accept(code);
|
||||
logger.debug(`Transitioning to channel ${channelId} in guild ${guildId}`);
|
||||
RouterUtils.transitionTo(Routes.guildChannel(guildId, channelId));
|
||||
} catch (error) {
|
||||
const httpError = error instanceof HttpError ? error : null;
|
||||
const errorCode = extractErrorCode(error);
|
||||
logger.error(`Failed to accept invite and transition for code ${code}:`, error);
|
||||
|
||||
if (httpError?.status === 404 || errorCode === APIErrorCodes.UNKNOWN_INVITE) {
|
||||
logger.debug(`Invite ${code} not found, removing from store`);
|
||||
InviteStore.handleInviteDelete(code);
|
||||
}
|
||||
|
||||
if (handlePackInviteError({invite, errorCode, httpError, i18n})) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (errorCode === APIErrorCodes.INVITES_DISABLED) {
|
||||
ModalActionCreators.push(modal(() => <InvitesDisabledModal />));
|
||||
} else if (httpError?.status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
} else if (errorCode === APIErrorCodes.MAX_GUILD_MEMBERS) {
|
||||
ModalActionCreators.push(modal(() => <GuildAtCapacityModal />));
|
||||
} else if (errorCode === APIErrorCodes.MAX_GUILDS) {
|
||||
ModalActionCreators.push(modal(() => <MaxGuildsModal />));
|
||||
} else if (errorCode === APIErrorCodes.TEMPORARY_INVITE_REQUIRES_PRESENCE) {
|
||||
ModalActionCreators.push(modal(() => <TemporaryInviteRequiresPresenceModal />));
|
||||
} else if (errorCode === APIErrorCodes.USER_BANNED_FROM_GUILD) {
|
||||
ModalActionCreators.push(modal(() => <UserBannedFromGuildModal />));
|
||||
} else if (errorCode === APIErrorCodes.USER_IP_BANNED_FROM_GUILD) {
|
||||
ModalActionCreators.push(modal(() => <UserIpBannedFromGuildModal />));
|
||||
} else if (errorCode === APIErrorCodes.GUILD_DISALLOWS_UNCLAIMED_ACCOUNTS) {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Cannot Join Community`)}
|
||||
message={i18n._(
|
||||
msg`This community requires you to verify your account before joining. Please set an email and password for your account.`,
|
||||
)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
} else if (errorCode === APIErrorCodes.UNCLAIMED_ACCOUNT_RESTRICTED) {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Account Verification Required`)}
|
||||
message={i18n._(
|
||||
msg`Please verify your account by setting an email and password before joining communities.`,
|
||||
)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
} else if (httpError?.status && httpError.status >= 400) {
|
||||
ModalActionCreators.push(modal(() => <InviteAcceptFailedModal />));
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const openAcceptModal = async (code: string): Promise<void> => {
|
||||
void fetchWithCoalescing(code).catch(() => {});
|
||||
ModalActionCreators.pushWithKey(
|
||||
modal(() => <InviteAcceptModal code={code} />),
|
||||
`invite-accept-${code}`,
|
||||
);
|
||||
};
|
||||
|
||||
interface HandlePackInviteErrorParams {
|
||||
invite: Invite | null;
|
||||
errorCode?: string;
|
||||
httpError?: HttpError | null;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
interface PackLimitPayload {
|
||||
packType?: 'emoji' | 'sticker';
|
||||
limit?: number;
|
||||
action?: 'create' | 'install';
|
||||
}
|
||||
|
||||
const getPackLimitPayload = (httpError?: HttpError | null): PackLimitPayload | null => {
|
||||
const body = httpError?.body;
|
||||
if (!body || typeof body !== 'object') return null;
|
||||
const data = (body as {data?: unknown}).data;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const limit = (data as {limit?: unknown}).limit;
|
||||
const packType = (data as {pack_type?: unknown}).pack_type;
|
||||
const action = (data as {action?: unknown}).action;
|
||||
return {
|
||||
packType: packType === 'emoji' || packType === 'sticker' ? packType : undefined,
|
||||
limit: typeof limit === 'number' ? limit : undefined,
|
||||
action: action === 'create' || action === 'install' ? action : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const buildPackLimitStrings = (
|
||||
i18n: I18n,
|
||||
packType: 'emoji' | 'sticker',
|
||||
action: 'install' | 'create',
|
||||
limit?: number,
|
||||
): {title: string; message: string} => {
|
||||
switch (packType) {
|
||||
case 'emoji': {
|
||||
switch (action) {
|
||||
case 'install': {
|
||||
const title = i18n._(msg`Emoji pack limit reached`);
|
||||
const message =
|
||||
typeof limit === 'number'
|
||||
? i18n._(
|
||||
limit === 1
|
||||
? msg`You have installed the maximum of ${limit} emoji pack. Remove one to install another.`
|
||||
: msg`You have installed the maximum of ${limit} emoji packs. Remove one to install another.`,
|
||||
)
|
||||
: i18n._(
|
||||
msg`You have reached the limit for installing emoji packs. Remove one of your installed packs to install another.`,
|
||||
);
|
||||
return {title, message};
|
||||
}
|
||||
default: {
|
||||
const title = i18n._(msg`Emoji pack creation limit reached`);
|
||||
const message =
|
||||
typeof limit === 'number'
|
||||
? i18n._(
|
||||
limit === 1
|
||||
? msg`You have created the maximum of ${limit} emoji pack. Delete one to create another.`
|
||||
: msg`You have created the maximum of ${limit} emoji packs. Delete one to create another.`,
|
||||
)
|
||||
: i18n._(
|
||||
msg`You have reached the limit for creating emoji packs. Delete one of your packs to create another.`,
|
||||
);
|
||||
return {title, message};
|
||||
}
|
||||
}
|
||||
}
|
||||
default: {
|
||||
switch (action) {
|
||||
case 'install': {
|
||||
const title = i18n._(msg`Sticker pack limit reached`);
|
||||
const message =
|
||||
typeof limit === 'number'
|
||||
? i18n._(
|
||||
limit === 1
|
||||
? msg`You have installed the maximum of ${limit} sticker pack. Remove one to install another.`
|
||||
: msg`You have installed the maximum of ${limit} sticker packs. Remove one to install another.`,
|
||||
)
|
||||
: i18n._(
|
||||
msg`You have reached the limit for installing sticker packs. Remove one of your installed packs to install another.`,
|
||||
);
|
||||
return {title, message};
|
||||
}
|
||||
default: {
|
||||
const title = i18n._(msg`Sticker pack creation limit reached`);
|
||||
const message =
|
||||
typeof limit === 'number'
|
||||
? i18n._(
|
||||
limit === 1
|
||||
? msg`You have created the maximum of ${limit} sticker pack. Delete one to create another.`
|
||||
: msg`You have created the maximum of ${limit} sticker packs. Delete one to create another.`,
|
||||
)
|
||||
: i18n._(
|
||||
msg`You have reached the limit for creating sticker packs. Delete one of your packs to create another.`,
|
||||
);
|
||||
return {title, message};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const handlePackInviteError = (params: HandlePackInviteErrorParams): boolean => {
|
||||
const {invite, errorCode, httpError, i18n} = params;
|
||||
if (!invite || !isPackInvite(invite)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isEmojiPack = invite.pack.type === 'emoji';
|
||||
const cannotInstallTitle = isEmojiPack
|
||||
? i18n._(msg`Cannot install emoji pack`)
|
||||
: i18n._(msg`Cannot install sticker pack`);
|
||||
const cannotInstallMessage = isEmojiPack
|
||||
? i18n._(msg`You don't have permission to install this emoji pack.`)
|
||||
: i18n._(msg`You don't have permission to install this sticker pack.`);
|
||||
const defaultTitle = isEmojiPack
|
||||
? i18n._(msg`Unable to install emoji pack`)
|
||||
: i18n._(msg`Unable to install sticker pack`);
|
||||
const defaultMessage = isEmojiPack
|
||||
? i18n._(msg`Failed to install this emoji pack. Please try again later.`)
|
||||
: i18n._(msg`Failed to install this sticker pack. Please try again later.`);
|
||||
|
||||
if (errorCode === APIErrorCodes.PREMIUM_REQUIRED) {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(msg`Premium required`)}
|
||||
message={i18n._(msg`Installing emoji and sticker packs requires a premium subscription.`)}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (errorCode === APIErrorCodes.MISSING_ACCESS) {
|
||||
ModalActionCreators.push(
|
||||
modal(() => <GenericErrorModal title={cannotInstallTitle} message={cannotInstallMessage} />),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (errorCode === APIErrorCodes.MAX_PACKS) {
|
||||
const payload = getPackLimitPayload(httpError);
|
||||
const packType = payload?.packType ?? invite.pack.type;
|
||||
const action = payload?.action ?? 'install';
|
||||
const limit = payload?.limit;
|
||||
const {title, message} = buildPackLimitStrings(i18n, packType, action, limit);
|
||||
|
||||
ModalActionCreators.push(modal(() => <GenericErrorModal title={title} message={message} />));
|
||||
return true;
|
||||
}
|
||||
|
||||
const fallbackMessage =
|
||||
httpError?.body && typeof httpError.body === 'object' && 'message' in httpError.body
|
||||
? (httpError.body as {message?: unknown}).message?.toString()
|
||||
: null;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => <GenericErrorModal title={defaultTitle} message={fallbackMessage || defaultMessage} />),
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const create = async (
|
||||
channelId: string,
|
||||
params?: {max_age?: number; max_uses?: number; temporary?: boolean},
|
||||
): Promise<Invite> => {
|
||||
try {
|
||||
logger.debug(`Creating invite for channel ${channelId}`);
|
||||
const response = await http.post<Invite>(Endpoints.CHANNEL_INVITES(channelId), params ?? {});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create invite for channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const list = async (channelId: string): Promise<Array<Invite>> => {
|
||||
try {
|
||||
logger.debug(`Listing invites for channel ${channelId}`);
|
||||
const response = await http.get<Array<Invite>>(Endpoints.CHANNEL_INVITES(channelId));
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to list invites for channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (code: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Deleting invite with code ${code}`);
|
||||
await http.delete({url: Endpoints.INVITE(code)});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete invite with code ${code}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
33
fluxer_app/src/actions/LayoutActionCreators.tsx
Normal file
33
fluxer_app/src/actions/LayoutActionCreators.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {Logger} from '~/lib/Logger';
|
||||
import MemberListStore from '~/stores/MemberListStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
|
||||
const logger = new Logger('Layout');
|
||||
|
||||
export const updateMobileLayoutState = (navExpanded: boolean, chatExpanded: boolean): void => {
|
||||
logger.debug(`Updating mobile layout state: nav=${navExpanded}, chat=${chatExpanded}`);
|
||||
MobileLayoutStore.updateState({navExpanded, chatExpanded});
|
||||
};
|
||||
|
||||
export const toggleMembers = (_isOpen: boolean): void => {
|
||||
MemberListStore.toggleMembers();
|
||||
};
|
||||
41
fluxer_app/src/actions/MediaViewerActionCreators.tsx
Normal file
41
fluxer_app/src/actions/MediaViewerActionCreators.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 {MessageRecord} from '~/records/MessageRecord';
|
||||
import MediaViewerStore, {type MediaViewerItem} from '~/stores/MediaViewerStore';
|
||||
|
||||
export function openMediaViewer(
|
||||
items: ReadonlyArray<MediaViewerItem>,
|
||||
currentIndex: number,
|
||||
options?: {
|
||||
channelId?: string;
|
||||
messageId?: string;
|
||||
message?: MessageRecord;
|
||||
},
|
||||
): void {
|
||||
MediaViewerStore.open(items, currentIndex, options?.channelId, options?.messageId, options?.message);
|
||||
}
|
||||
|
||||
export function closeMediaViewer(): void {
|
||||
MediaViewerStore.close();
|
||||
}
|
||||
|
||||
export function navigateMediaViewer(index: number): void {
|
||||
MediaViewerStore.navigate(index);
|
||||
}
|
||||
558
fluxer_app/src/actions/MessageActionCreators.tsx
Normal file
558
fluxer_app/src/actions/MessageActionCreators.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
||||
import {APIErrorCodes, type JumpTypes, MAX_MESSAGES_PER_CHANNEL, MessageFlags} from '~/Constants';
|
||||
import {FeatureTemporarilyDisabledModal} from '~/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {MessageDeleteFailedModal} from '~/components/alerts/MessageDeleteFailedModal';
|
||||
import {MessageDeleteTooQuickModal} from '~/components/alerts/MessageDeleteTooQuickModal';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import type {JumpOptions} from '~/lib/ChannelMessages';
|
||||
import http, {HttpError} from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import MessageQueue from '~/lib/MessageQueue';
|
||||
import type {
|
||||
AllowedMentions,
|
||||
Message,
|
||||
MessageRecord,
|
||||
MessageReference,
|
||||
MessageStickerItem,
|
||||
} from '~/records/MessageRecord';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
|
||||
import GuildMemberStore from '~/stores/GuildMemberStore';
|
||||
import MessageEditMobileStore from '~/stores/MessageEditMobileStore';
|
||||
import MessageEditStore from '~/stores/MessageEditStore';
|
||||
import MessageReferenceStore from '~/stores/MessageReferenceStore';
|
||||
import MessageReplyStore from '~/stores/MessageReplyStore';
|
||||
import MessageStore from '~/stores/MessageStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
|
||||
const logger = new Logger('MessageActionCreators');
|
||||
|
||||
const pendingDeletePromises = new Map<string, Promise<void>>();
|
||||
const pendingFetchPromises = new Map<string, Promise<Array<Message>>>();
|
||||
|
||||
function makeFetchKey(
|
||||
channelId: string,
|
||||
before: string | null,
|
||||
after: string | null,
|
||||
limit: number,
|
||||
jump?: JumpOptions,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
channelId,
|
||||
before,
|
||||
after,
|
||||
limit,
|
||||
jump: jump
|
||||
? {
|
||||
present: !!jump.present,
|
||||
messageId: jump.messageId ?? null,
|
||||
offset: jump.offset ?? 0,
|
||||
flash: !!jump.flash,
|
||||
returnMessageId: jump.returnMessageId ?? null,
|
||||
jumpType: jump.jumpType ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function requestMissingGuildMembers(channelId: string, messages: Array<Message>): Promise<void> {
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (!channel?.guildId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guildId = channel.guildId;
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
|
||||
const authorIds = messages
|
||||
.filter((msg) => !msg.webhook_id && msg.author.id !== currentUserId)
|
||||
.map((msg) => msg.author.id);
|
||||
|
||||
if (authorIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await GuildMemberStore.ensureMembersLoaded(guildId, authorIds);
|
||||
}
|
||||
|
||||
interface SendMessageParams {
|
||||
content: string;
|
||||
nonce: string;
|
||||
hasAttachments?: boolean;
|
||||
allowedMentions?: AllowedMentions;
|
||||
messageReference?: MessageReference;
|
||||
flags?: number;
|
||||
favoriteMemeId?: string;
|
||||
stickers?: Array<MessageStickerItem>;
|
||||
tts?: boolean;
|
||||
}
|
||||
|
||||
export const jumpToPresent = (channelId: string, limit = MAX_MESSAGES_PER_CHANNEL): void => {
|
||||
logger.debug(`Jumping to present in channel ${channelId}`);
|
||||
ReadStateActionCreators.clearStickyUnread(channelId);
|
||||
|
||||
const jump: JumpOptions = {
|
||||
present: true,
|
||||
};
|
||||
|
||||
if (MessageStore.hasPresent(channelId)) {
|
||||
MessageStore.handleLoadMessagesSuccessCached({channelId, jump, limit});
|
||||
} else {
|
||||
fetchMessages(channelId, null, null, limit, jump);
|
||||
}
|
||||
};
|
||||
|
||||
export const jumpToMessage = (
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
flash = true,
|
||||
offset?: number,
|
||||
returnTargetId?: string,
|
||||
jumpType?: JumpTypes,
|
||||
): void => {
|
||||
logger.debug(`Jumping to message ${messageId} in channel ${channelId}`);
|
||||
|
||||
fetchMessages(channelId, null, null, MAX_MESSAGES_PER_CHANNEL, {
|
||||
messageId,
|
||||
flash,
|
||||
offset,
|
||||
returnMessageId: returnTargetId,
|
||||
jumpType,
|
||||
});
|
||||
};
|
||||
|
||||
const tryFetchMessagesCached = (
|
||||
channelId: string,
|
||||
before: string | null,
|
||||
after: string | null,
|
||||
limit: number,
|
||||
jump?: JumpOptions,
|
||||
): boolean => {
|
||||
const messages = MessageStore.getMessages(channelId);
|
||||
|
||||
if (jump?.messageId && messages.has(jump.messageId, true)) {
|
||||
MessageStore.handleLoadMessagesSuccessCached({channelId, jump, limit});
|
||||
return true;
|
||||
} else if (before && messages.hasBeforeCached(before)) {
|
||||
MessageStore.handleLoadMessagesSuccessCached({channelId, before, limit});
|
||||
return true;
|
||||
} else if (after && messages.hasAfterCached(after)) {
|
||||
MessageStore.handleLoadMessagesSuccessCached({channelId, after, limit});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const fetchMessages = async (
|
||||
channelId: string,
|
||||
before: string | null,
|
||||
after: string | null,
|
||||
limit: number,
|
||||
jump?: JumpOptions,
|
||||
): Promise<Array<Message>> => {
|
||||
const key = makeFetchKey(channelId, before, after, limit, jump);
|
||||
const inFlight = pendingFetchPromises.get(key);
|
||||
if (inFlight) {
|
||||
logger.debug(`Using in-flight fetchMessages for channel ${channelId} (deduped)`);
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
if (tryFetchMessagesCached(channelId, before, after, limit, jump)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
if (DeveloperOptionsStore.slowMessageLoad) {
|
||||
logger.debug('Slow message load enabled, delaying by 3 seconds');
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
MessageStore.handleLoadMessages({channelId, jump});
|
||||
|
||||
try {
|
||||
const timeStart = Date.now();
|
||||
logger.debug(`Fetching messages for channel ${channelId}`);
|
||||
|
||||
const around = jump?.messageId;
|
||||
const response = await http.get<Array<Message>>({
|
||||
url: Endpoints.CHANNEL_MESSAGES(channelId),
|
||||
query: {before, after, limit, around: around ?? null},
|
||||
retries: 2,
|
||||
});
|
||||
const messages = response.body ?? [];
|
||||
|
||||
const isBefore = before != null;
|
||||
const isAfter = after != null;
|
||||
const isReplacement = before == null && after == null;
|
||||
|
||||
const halfLimit = Math.floor(limit / 2);
|
||||
let hasMoreBefore = around != null || (messages.length === limit && (isBefore || isReplacement));
|
||||
let hasMoreAfter = around != null || (isAfter && messages.length === limit);
|
||||
|
||||
if (around) {
|
||||
const knownLatestMessageId =
|
||||
ReadStateStore.lastMessageId(channelId) ?? ChannelStore.getChannel(channelId)?.lastMessageId ?? null;
|
||||
const newestFetchedMessageId = messages[0]?.id ?? null;
|
||||
const targetIndex = messages.findIndex((msg: Message) => msg.id === around);
|
||||
const pageFilled = messages.length === limit;
|
||||
|
||||
if (targetIndex === -1) {
|
||||
logger.warn(`Target message ${around} not found in response!`);
|
||||
} else {
|
||||
const messagesNewerThanTarget = targetIndex;
|
||||
const messagesOlderThanTarget = messages.length - targetIndex - 1;
|
||||
const isAtKnownLatest = newestFetchedMessageId != null && newestFetchedMessageId === knownLatestMessageId;
|
||||
|
||||
hasMoreBefore = pageFilled || messagesOlderThanTarget >= halfLimit;
|
||||
hasMoreAfter = pageFilled || (messagesNewerThanTarget >= halfLimit && !isAtKnownLatest);
|
||||
|
||||
logger.debug(
|
||||
`Jump to message ${around}: targetIndex=${targetIndex}, messagesNewer=${messagesNewerThanTarget}, messagesOlder=${messagesOlderThanTarget}, pageFilled=${pageFilled}, hasMoreBefore=${hasMoreBefore}, hasMoreAfter=${hasMoreAfter}, limit=${limit}, knownLatestMessageId=${knownLatestMessageId}, newestFetched=${newestFetchedMessageId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Fetched ${messages.length} messages for channel ${channelId}, took ${Date.now() - timeStart}ms`);
|
||||
|
||||
MessageStore.handleLoadMessagesSuccess({
|
||||
channelId,
|
||||
messages,
|
||||
isBefore,
|
||||
isAfter,
|
||||
hasMoreBefore,
|
||||
hasMoreAfter,
|
||||
cached: false,
|
||||
jump,
|
||||
});
|
||||
ReadStateStore.handleLoadMessages({
|
||||
channelId,
|
||||
isAfter,
|
||||
messages,
|
||||
});
|
||||
MessageReferenceStore.handleMessagesFetchSuccess(channelId, messages);
|
||||
|
||||
void requestMissingGuildMembers(channelId, messages);
|
||||
|
||||
return messages;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch messages for channel ${channelId}:`, error);
|
||||
MessageStore.handleLoadMessagesFailure({channelId});
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
pendingFetchPromises.set(key, promise);
|
||||
promise.finally(() => pendingFetchPromises.delete(key));
|
||||
return promise;
|
||||
};
|
||||
|
||||
export const send = async (channelId: string, params: SendMessageParams): Promise<Message> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.debug(`Enqueueing message for channel ${channelId}`);
|
||||
|
||||
MessageQueue.enqueue(
|
||||
{
|
||||
type: 'send',
|
||||
channelId,
|
||||
nonce: params.nonce,
|
||||
content: params.content,
|
||||
hasAttachments: params.hasAttachments,
|
||||
allowedMentions: params.allowedMentions,
|
||||
messageReference: params.messageReference,
|
||||
flags: params.flags,
|
||||
favoriteMemeId: params.favoriteMemeId,
|
||||
stickers: params.stickers,
|
||||
tts: params.tts,
|
||||
},
|
||||
(result) => {
|
||||
if (result?.body) {
|
||||
logger.debug(`Message sent successfully in channel ${channelId}`);
|
||||
resolve(result.body);
|
||||
} else {
|
||||
const error = new Error('Message send failed');
|
||||
logger.error(`Message send failed in channel ${channelId}`);
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const edit = async (
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
content?: string,
|
||||
flags?: number,
|
||||
): Promise<Message> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.debug(`Enqueueing edit for message ${messageId} in channel ${channelId}`);
|
||||
|
||||
MessageQueue.enqueue(
|
||||
{
|
||||
type: 'edit',
|
||||
channelId,
|
||||
messageId,
|
||||
content,
|
||||
flags,
|
||||
},
|
||||
(result) => {
|
||||
if (result?.body) {
|
||||
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
|
||||
resolve(result.body);
|
||||
} else {
|
||||
const error = new Error('Message edit failed');
|
||||
logger.error(`Message edit failed: ${messageId} in channel ${channelId}`);
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const remove = async (channelId: string, messageId: string): Promise<void> => {
|
||||
const pendingPromise = pendingDeletePromises.get(messageId);
|
||||
if (pendingPromise) {
|
||||
logger.debug(`Using in-flight delete request for message ${messageId}`);
|
||||
return pendingPromise;
|
||||
}
|
||||
|
||||
const deletePromise = (async () => {
|
||||
try {
|
||||
logger.debug(`Deleting message ${messageId} in channel ${channelId}`);
|
||||
await http.delete({url: Endpoints.CHANNEL_MESSAGE(channelId, messageId)});
|
||||
logger.debug(`Successfully deleted message ${messageId} in channel ${channelId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete message ${messageId} in channel ${channelId}:`, error);
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
const {status, body} = error;
|
||||
const errorCode =
|
||||
typeof body === 'object' && body != null && 'code' in body ? (body as {code?: string}).code : undefined;
|
||||
|
||||
if (status === 429) {
|
||||
ModalActionCreators.push(modal(() => <MessageDeleteTooQuickModal />));
|
||||
} else if (status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
} else if (status === 404) {
|
||||
logger.debug(`Message ${messageId} was already deleted (404 response)`);
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <MessageDeleteFailedModal />));
|
||||
}
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <MessageDeleteFailedModal />));
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
pendingDeletePromises.delete(messageId);
|
||||
}
|
||||
})();
|
||||
|
||||
pendingDeletePromises.set(messageId, deletePromise);
|
||||
return deletePromise;
|
||||
};
|
||||
|
||||
interface ShowDeleteConfirmationOptions {
|
||||
message: MessageRecord;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export const showDeleteConfirmation = (i18n: I18n, {message, onDelete}: ShowDeleteConfirmationOptions): void => {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(msg`Delete Message`)}
|
||||
description={i18n._(msg`This will create a rift in the space-time continuum and cannot be undone.`)}
|
||||
message={message}
|
||||
primaryText={i18n._(msg`Delete`)}
|
||||
onPrimary={() => {
|
||||
remove(message.channelId, message.id);
|
||||
onDelete?.();
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
export const deleteLocal = (channelId: string, messageId: string): void => {
|
||||
logger.debug(`Deleting message ${messageId} locally in channel ${channelId}`);
|
||||
MessageStore.handleMessageDelete({id: messageId, channelId});
|
||||
};
|
||||
|
||||
export const revealMessage = (channelId: string, messageId: string | null): void => {
|
||||
logger.debug(`Revealing message ${messageId} in channel ${channelId}`);
|
||||
MessageStore.handleMessageReveal({channelId, messageId});
|
||||
};
|
||||
|
||||
export const startReply = (channelId: string, messageId: string, mentioning: boolean): void => {
|
||||
logger.debug(`Starting reply to message ${messageId} in channel ${channelId}, mentioning=${mentioning}`);
|
||||
MessageReplyStore.startReply(channelId, messageId, mentioning);
|
||||
};
|
||||
|
||||
export const stopReply = (channelId: string): void => {
|
||||
logger.debug(`Stopping reply in channel ${channelId}`);
|
||||
MessageReplyStore.stopReply(channelId);
|
||||
};
|
||||
|
||||
export const setReplyMentioning = (channelId: string, mentioning: boolean): void => {
|
||||
logger.debug(`Setting reply mentioning in channel ${channelId}: ${mentioning}`);
|
||||
MessageReplyStore.setMentioning(channelId, mentioning);
|
||||
};
|
||||
|
||||
export const startEdit = (channelId: string, messageId: string, initialContent: string): void => {
|
||||
logger.debug(`Starting edit for message ${messageId} in channel ${channelId}`);
|
||||
MessageEditStore.startEditing(channelId, messageId, initialContent);
|
||||
};
|
||||
|
||||
export const stopEdit = (channelId: string): void => {
|
||||
logger.debug(`Stopping edit in channel ${channelId}`);
|
||||
MessageEditStore.stopEditing(channelId);
|
||||
};
|
||||
|
||||
export const startEditMobile = (channelId: string, messageId: string): void => {
|
||||
logger.debug(`Starting mobile edit for message ${messageId} in channel ${channelId}`);
|
||||
MessageEditMobileStore.startEditingMobile(channelId, messageId);
|
||||
};
|
||||
|
||||
export const stopEditMobile = (channelId: string): void => {
|
||||
logger.debug(`Stopping mobile edit in channel ${channelId}`);
|
||||
MessageEditMobileStore.stopEditingMobile(channelId);
|
||||
};
|
||||
|
||||
export const createOptimistic = (channelId: string, message: Message): void => {
|
||||
logger.debug(`Creating optimistic message in channel ${channelId}`);
|
||||
MessageStore.handleIncomingMessage({channelId, message});
|
||||
};
|
||||
|
||||
export const deleteOptimistic = (channelId: string, messageId: string): void => {
|
||||
logger.debug(`Deleting optimistic message ${messageId} in channel ${channelId}`);
|
||||
MessageStore.handleMessageDelete({channelId, id: messageId});
|
||||
};
|
||||
|
||||
export const sendError = (channelId: string, nonce: string): void => {
|
||||
logger.debug(`Message send error for nonce ${nonce} in channel ${channelId}`);
|
||||
MessageStore.handleSendFailed({channelId, nonce});
|
||||
};
|
||||
|
||||
export const editOptimistic = (
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
content: string,
|
||||
): {originalContent: string; originalEditedTimestamp: string | null} | null => {
|
||||
logger.debug(`Applying optimistic edit for message ${messageId} in channel ${channelId}`);
|
||||
return MessageStore.handleOptimisticEdit({channelId, messageId, content});
|
||||
};
|
||||
|
||||
export const editRollback = (
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
originalContent: string,
|
||||
originalEditedTimestamp: string | null,
|
||||
): void => {
|
||||
logger.debug(`Rolling back edit for message ${messageId} in channel ${channelId}`);
|
||||
MessageStore.handleEditRollback({channelId, messageId, originalContent, originalEditedTimestamp});
|
||||
};
|
||||
|
||||
export const forward = async (
|
||||
channelIds: Array<string>,
|
||||
messageReference: {message_id: string; channel_id: string; guild_id?: string | null},
|
||||
optionalMessage?: string,
|
||||
): Promise<void> => {
|
||||
logger.debug(`Forwarding message ${messageReference.message_id} to ${channelIds.length} channels`);
|
||||
|
||||
try {
|
||||
for (const channelId of channelIds) {
|
||||
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
|
||||
await send(channelId, {
|
||||
content: '',
|
||||
nonce,
|
||||
messageReference: {
|
||||
message_id: messageReference.message_id,
|
||||
channel_id: messageReference.channel_id,
|
||||
guild_id: messageReference.guild_id || undefined,
|
||||
type: 1,
|
||||
},
|
||||
flags: 1,
|
||||
});
|
||||
|
||||
if (optionalMessage) {
|
||||
const commentNonce = SnowflakeUtils.fromTimestamp(Date.now() + 1);
|
||||
await send(channelId, {
|
||||
content: optionalMessage,
|
||||
nonce: commentNonce,
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.debug('Successfully forwarded message to all channels');
|
||||
} catch (error) {
|
||||
logger.error('Failed to forward message:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleSuppressEmbeds = async (
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
currentFlags: number,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const isSuppressed = (currentFlags & MessageFlags.SUPPRESS_EMBEDS) === MessageFlags.SUPPRESS_EMBEDS;
|
||||
const newFlags = isSuppressed
|
||||
? currentFlags & ~MessageFlags.SUPPRESS_EMBEDS
|
||||
: currentFlags | MessageFlags.SUPPRESS_EMBEDS;
|
||||
|
||||
logger.debug(`${isSuppressed ? 'Unsuppressing' : 'Suppressing'} embeds for message ${messageId}`);
|
||||
|
||||
await http.patch<Message>({
|
||||
url: Endpoints.CHANNEL_MESSAGE(channelId, messageId),
|
||||
body: {flags: newFlags},
|
||||
});
|
||||
|
||||
logger.debug(`Successfully ${isSuppressed ? 'unsuppressed' : 'suppressed'} embeds for message ${messageId}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to toggle suppress embeds:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteAttachment = async (channelId: string, messageId: string, attachmentId: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Deleting attachment ${attachmentId} from message ${messageId}`);
|
||||
|
||||
await http.delete({
|
||||
url: Endpoints.CHANNEL_MESSAGE_ATTACHMENT(channelId, messageId, attachmentId),
|
||||
});
|
||||
|
||||
logger.debug(`Successfully deleted attachment ${attachmentId} from message ${messageId}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete attachment:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
71
fluxer_app/src/actions/MfaActionCreators.tsx
Normal file
71
fluxer_app/src/actions/MfaActionCreators.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {BackupCode} from '~/records/UserRecord';
|
||||
import SudoStore from '~/stores/SudoStore';
|
||||
|
||||
const logger = new Logger('MFA');
|
||||
|
||||
export const enableMfaTotp = async (secret: string, code: string): Promise<Array<BackupCode>> => {
|
||||
try {
|
||||
logger.debug('Enabling TOTP-based MFA');
|
||||
const response = await http.post<{backup_codes: Array<BackupCode>}>({
|
||||
url: Endpoints.USER_MFA_TOTP_ENABLE,
|
||||
body: {secret, code},
|
||||
});
|
||||
const result = response.body;
|
||||
logger.debug('Successfully enabled TOTP-based MFA');
|
||||
SudoStore.clearToken();
|
||||
return result.backup_codes;
|
||||
} catch (error) {
|
||||
logger.error('Failed to enable TOTP-based MFA:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const disableMfaTotp = async (code: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Disabling TOTP-based MFA');
|
||||
await http.post({url: Endpoints.USER_MFA_TOTP_DISABLE, body: {code}});
|
||||
logger.debug('Successfully disabled TOTP-based MFA');
|
||||
} catch (error) {
|
||||
logger.error('Failed to disable TOTP-based MFA:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getBackupCodes = async (regenerate = false): Promise<Array<BackupCode>> => {
|
||||
try {
|
||||
logger.debug(`${regenerate ? 'Regenerating' : 'Fetching'} MFA backup codes`);
|
||||
const response = await http.post<{backup_codes: Array<BackupCode>}>({
|
||||
url: Endpoints.USER_MFA_BACKUP_CODES,
|
||||
body: {regenerate},
|
||||
});
|
||||
const result = response.body;
|
||||
|
||||
logger.debug(`Successfully ${regenerate ? 'regenerated' : 'fetched'} MFA backup codes`);
|
||||
return result.backup_codes;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ${regenerate ? 'regenerate' : 'fetch'} MFA backup codes:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
114
fluxer_app/src/actions/ModalActionCreators.tsx
Normal file
114
fluxer_app/src/actions/ModalActionCreators.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 lodash from 'lodash';
|
||||
import type React from 'react';
|
||||
import {ChannelSettingsModal} from '~/components/modals/ChannelSettingsModal';
|
||||
import {GuildSettingsModal} from '~/components/modals/GuildSettingsModal';
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import ModalStore from '~/stores/ModalStore';
|
||||
|
||||
const logger = new Logger('Modal');
|
||||
|
||||
const BACKGROUND_MODAL_TYPES = [UserSettingsModal, GuildSettingsModal, ChannelSettingsModal] as const;
|
||||
|
||||
const isBackgroundModal = (element: React.ReactElement): boolean => {
|
||||
return BACKGROUND_MODAL_TYPES.some((type) => element.type === type);
|
||||
};
|
||||
|
||||
declare const ModalRenderBrand: unique symbol;
|
||||
|
||||
export interface ModalRender {
|
||||
(): React.ReactElement;
|
||||
[ModalRenderBrand]: true;
|
||||
}
|
||||
|
||||
export function modal(render: () => React.ReactElement): ModalRender {
|
||||
return render as ModalRender;
|
||||
}
|
||||
|
||||
export const push = (modal: ModalRender): void => {
|
||||
const renderedModal = modal();
|
||||
const isBackground = isBackgroundModal(renderedModal);
|
||||
|
||||
if (renderedModal.type === UserSettingsModal && ModalStore.hasModalOfType(UserSettingsModal)) {
|
||||
logger.debug('Skipping duplicate UserSettingsModal');
|
||||
return;
|
||||
}
|
||||
if (renderedModal.type === GuildSettingsModal && ModalStore.hasModalOfType(GuildSettingsModal)) {
|
||||
logger.debug('Skipping duplicate GuildSettingsModal');
|
||||
return;
|
||||
}
|
||||
if (renderedModal.type === ChannelSettingsModal && ModalStore.hasModalOfType(ChannelSettingsModal)) {
|
||||
logger.debug('Skipping duplicate ChannelSettingsModal');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = lodash.uniqueId('modal');
|
||||
logger.debug(`Pushing modal: ${key} (background=${isBackground})`);
|
||||
ModalStore.push(modal, key, {isBackground});
|
||||
};
|
||||
|
||||
export const pushWithKey = (modal: ModalRender, key: string): void => {
|
||||
const renderedModal = modal();
|
||||
const isBackground = isBackgroundModal(renderedModal);
|
||||
|
||||
if (renderedModal.type === UserSettingsModal && ModalStore.hasModalOfType(UserSettingsModal)) {
|
||||
logger.debug('Skipping duplicate UserSettingsModal');
|
||||
return;
|
||||
}
|
||||
if (renderedModal.type === GuildSettingsModal && ModalStore.hasModalOfType(GuildSettingsModal)) {
|
||||
logger.debug('Skipping duplicate GuildSettingsModal');
|
||||
return;
|
||||
}
|
||||
if (renderedModal.type === ChannelSettingsModal && ModalStore.hasModalOfType(ChannelSettingsModal)) {
|
||||
logger.debug('Skipping duplicate ChannelSettingsModal');
|
||||
return;
|
||||
}
|
||||
|
||||
if (ModalStore.hasModal(key)) {
|
||||
logger.debug(`Updating existing modal with key: ${key}`);
|
||||
ModalStore.update(key, () => modal, {isBackground});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Pushing modal with key: ${key} (background=${isBackground})`);
|
||||
ModalStore.push(modal, key, {isBackground});
|
||||
};
|
||||
|
||||
export const update = (key: string, updater: (currentModal: ModalRender) => ModalRender): void => {
|
||||
logger.debug(`Updating modal with key: ${key}`);
|
||||
ModalStore.update(key, updater);
|
||||
};
|
||||
|
||||
export const pop = (): void => {
|
||||
logger.debug('Popping most recent modal');
|
||||
ModalStore.pop();
|
||||
};
|
||||
|
||||
export const popWithKey = (key: string): void => {
|
||||
logger.debug(`Popping modal with key: ${key}`);
|
||||
ModalStore.pop(key);
|
||||
};
|
||||
|
||||
export const popAll = (): void => {
|
||||
logger.debug('Popping all modals');
|
||||
ModalStore.popAll();
|
||||
};
|
||||
48
fluxer_app/src/actions/NagbarActionCreators.tsx
Normal file
48
fluxer_app/src/actions/NagbarActionCreators.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 NagbarStore, {type NagbarToggleKey} from '~/stores/NagbarStore';
|
||||
|
||||
export const dismissNagbar = (nagbarType: NagbarToggleKey): void => {
|
||||
NagbarStore.dismiss(nagbarType);
|
||||
};
|
||||
|
||||
export const dismissInvitesDisabledNagbar = (guildId: string): void => {
|
||||
NagbarStore.dismissInvitesDisabled(guildId);
|
||||
};
|
||||
|
||||
export const resetNagbar = (nagbarType: NagbarToggleKey): void => {
|
||||
NagbarStore.reset(nagbarType);
|
||||
};
|
||||
|
||||
export const resetAllNagbars = (): void => {
|
||||
NagbarStore.resetAll();
|
||||
};
|
||||
|
||||
export const setForceHideNagbar = (key: NagbarToggleKey, value: boolean): void => {
|
||||
NagbarStore.setFlag(key, value);
|
||||
};
|
||||
|
||||
export const dismissPendingBulkDeletionNagbar = (scheduleKey: string): void => {
|
||||
NagbarStore.dismissPendingBulkDeletion(scheduleKey);
|
||||
};
|
||||
|
||||
export const clearPendingBulkDeletionNagbarDismissal = (scheduleKey: string): void => {
|
||||
NagbarStore.clearPendingBulkDeletionDismissed(scheduleKey);
|
||||
};
|
||||
43
fluxer_app/src/actions/NavigationActionCreators.tsx
Normal file
43
fluxer_app/src/actions/NavigationActionCreators.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 {Logger} from '~/lib/Logger';
|
||||
import MessageStore from '~/stores/MessageStore';
|
||||
import NotificationStore from '~/stores/NotificationStore';
|
||||
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
||||
import SelectedGuildStore from '~/stores/SelectedGuildStore';
|
||||
|
||||
const logger = new Logger('Navigation');
|
||||
|
||||
export const selectChannel = (guildId?: string, channelId?: string | null, messageId?: string): void => {
|
||||
logger.debug(`Selecting channel: guildId=${guildId}, channelId=${channelId}, messageId=${messageId}`);
|
||||
MessageStore.handleChannelSelect({guildId, channelId, messageId});
|
||||
NotificationStore.handleChannelSelect({channelId});
|
||||
SelectedChannelStore.selectChannel(guildId, channelId);
|
||||
};
|
||||
|
||||
export const selectGuild = (guildId: string): void => {
|
||||
logger.debug(`Selecting guild: ${guildId}`);
|
||||
SelectedGuildStore.selectGuild(guildId);
|
||||
};
|
||||
|
||||
export const deselectGuild = (): void => {
|
||||
logger.debug('Deselecting guild');
|
||||
SelectedGuildStore.deselectGuild();
|
||||
};
|
||||
65
fluxer_app/src/actions/NotificationActionCreators.tsx
Normal file
65
fluxer_app/src/actions/NotificationActionCreators.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import NotificationStore from '~/stores/NotificationStore';
|
||||
|
||||
const logger = new Logger('Notification');
|
||||
|
||||
export const permissionDenied = (i18n: I18n, suppressModal = false): void => {
|
||||
logger.debug('Notification permission denied');
|
||||
NotificationStore.handleNotificationPermissionDenied();
|
||||
|
||||
if (suppressModal) return;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(msg`Notifications Blocked`)}
|
||||
description={
|
||||
<p>
|
||||
<Trans>
|
||||
Desktop notifications have been blocked. You can enable them later in your browser settings or in User
|
||||
Settings > Notifications.
|
||||
</Trans>
|
||||
</p>
|
||||
}
|
||||
primaryText={i18n._(msg`OK`)}
|
||||
primaryVariant="primary"
|
||||
secondaryText={false}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
export const permissionGranted = (): void => {
|
||||
logger.debug('Notification permission granted');
|
||||
NotificationStore.handleNotificationPermissionGranted();
|
||||
};
|
||||
|
||||
export const toggleUnreadMessageBadge = (enabled: boolean): void => {
|
||||
NotificationStore.handleNotificationSoundToggle(enabled);
|
||||
};
|
||||
55
fluxer_app/src/actions/OAuth2AuthorizationActionCreators.tsx
Normal file
55
fluxer_app/src/actions/OAuth2AuthorizationActionCreators.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
|
||||
const logger = new Logger('OAuth2AuthorizationActionCreators');
|
||||
|
||||
export interface OAuth2Authorization {
|
||||
application: {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
description: string | null;
|
||||
bot_public: boolean;
|
||||
};
|
||||
scopes: Array<string>;
|
||||
authorized_at: string;
|
||||
}
|
||||
|
||||
export const listAuthorizations = async (): Promise<Array<OAuth2Authorization>> => {
|
||||
try {
|
||||
const response = await http.get<Array<OAuth2Authorization>>({url: Endpoints.OAUTH_AUTHORIZATIONS});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to list OAuth2 authorizations:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deauthorize = async (applicationId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.OAUTH_AUTHORIZATION(applicationId)});
|
||||
} catch (error) {
|
||||
logger.error('Failed to deauthorize application:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
98
fluxer_app/src/actions/PackActionCreators.tsx
Normal file
98
fluxer_app/src/actions/PackActionCreators.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {PackDashboardResponse, PackSummary} from '~/types/PackTypes';
|
||||
|
||||
const logger = new Logger('Packs');
|
||||
|
||||
export const list = async (): Promise<PackDashboardResponse> => {
|
||||
try {
|
||||
logger.debug('Requesting pack dashboard');
|
||||
const response = await http.get<PackDashboardResponse>({url: Endpoints.PACKS});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch pack dashboard:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const create = async (
|
||||
type: 'emoji' | 'sticker',
|
||||
name: string,
|
||||
description?: string | null,
|
||||
): Promise<PackSummary> => {
|
||||
try {
|
||||
logger.debug(`Creating ${type} pack ${name}`);
|
||||
const response = await http.post<PackSummary>({
|
||||
url: Endpoints.PACK_CREATE(type),
|
||||
body: {name, description: description ?? null},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create ${type} pack:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const update = async (
|
||||
packId: string,
|
||||
data: {name?: string; description?: string | null},
|
||||
): Promise<PackSummary> => {
|
||||
try {
|
||||
logger.debug(`Updating pack ${packId}`);
|
||||
const response = await http.patch<PackSummary>({url: Endpoints.PACK(packId), body: data});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update pack ${packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (packId: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Deleting pack ${packId}`);
|
||||
await http.delete({url: Endpoints.PACK(packId)});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete pack ${packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const install = async (packId: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Installing pack ${packId}`);
|
||||
await http.post({url: Endpoints.PACK_INSTALL(packId)});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to install pack ${packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const uninstall = async (packId: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Uninstalling pack ${packId}`);
|
||||
await http.delete({url: Endpoints.PACK_INSTALL(packId)});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to uninstall pack ${packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
50
fluxer_app/src/actions/PackInviteActionCreators.tsx
Normal file
50
fluxer_app/src/actions/PackInviteActionCreators.tsx
Normal 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {PackInviteMetadata} from '~/types/PackTypes';
|
||||
|
||||
const logger = new Logger('PackInvites');
|
||||
|
||||
export interface CreatePackInviteParams {
|
||||
packId: string;
|
||||
maxUses?: number;
|
||||
maxAge?: number;
|
||||
unique?: boolean;
|
||||
}
|
||||
|
||||
export const createInvite = async (params: CreatePackInviteParams): Promise<PackInviteMetadata> => {
|
||||
try {
|
||||
logger.debug(`Creating invite for pack ${params.packId}`);
|
||||
const response = await http.post<PackInviteMetadata>({
|
||||
url: Endpoints.PACK_INVITES(params.packId),
|
||||
body: {
|
||||
max_uses: params.maxUses ?? 0,
|
||||
max_age: params.maxAge ?? 0,
|
||||
unique: params.unique ?? false,
|
||||
},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create invite for pack ${params.packId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
33
fluxer_app/src/actions/PopoutActionCreators.tsx
Normal file
33
fluxer_app/src/actions/PopoutActionCreators.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {Popout} from '~/components/uikit/Popout';
|
||||
import PopoutStore from '~/stores/PopoutStore';
|
||||
|
||||
export const open = (popout: Popout): void => {
|
||||
PopoutStore.open(popout);
|
||||
};
|
||||
|
||||
export const close = (key?: string | number): void => {
|
||||
PopoutStore.close(key);
|
||||
};
|
||||
|
||||
export const closeAll = (): void => {
|
||||
PopoutStore.closeAll();
|
||||
};
|
||||
127
fluxer_app/src/actions/PremiumActionCreators.tsx
Normal file
127
fluxer_app/src/actions/PremiumActionCreators.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
|
||||
const logger = new Logger('Premium');
|
||||
|
||||
export interface VisionarySlots {
|
||||
total: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface PriceIds {
|
||||
monthly: string | null;
|
||||
yearly: string | null;
|
||||
visionary: string | null;
|
||||
giftVisionary: string | null;
|
||||
gift1Month: string | null;
|
||||
gift1Year: string | null;
|
||||
currency: 'USD' | 'EUR';
|
||||
}
|
||||
|
||||
export const fetchVisionarySlots = async (): Promise<VisionarySlots> => {
|
||||
try {
|
||||
const response = await http.get<VisionarySlots>(Endpoints.PREMIUM_VISIONARY_SLOTS);
|
||||
logger.debug('Visionary slots fetched', response.body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Visionary slots fetch failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchPriceIds = async (countryCode?: string): Promise<PriceIds> => {
|
||||
try {
|
||||
const url = countryCode
|
||||
? `${Endpoints.PREMIUM_PRICE_IDS}?country_code=${encodeURIComponent(countryCode)}`
|
||||
: Endpoints.PREMIUM_PRICE_IDS;
|
||||
const response = await http.get<PriceIds>(url);
|
||||
logger.debug('Price IDs fetched', response.body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Price IDs fetch failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createCustomerPortalSession = async (): Promise<string> => {
|
||||
try {
|
||||
const response = await http.post<{url: string}>(Endpoints.PREMIUM_CUSTOMER_PORTAL);
|
||||
logger.info('Customer portal session created');
|
||||
return response.body.url;
|
||||
} catch (error) {
|
||||
logger.error('Customer portal session creation failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createCheckoutSession = async (priceId: string, isGift: boolean = false): Promise<string> => {
|
||||
try {
|
||||
const url = isGift ? Endpoints.STRIPE_CHECKOUT_GIFT : Endpoints.STRIPE_CHECKOUT_SUBSCRIPTION;
|
||||
const response = await http.post<{url: string}>(url, {price_id: priceId});
|
||||
logger.info('Checkout session created', {priceId, isGift});
|
||||
return response.body.url;
|
||||
} catch (error) {
|
||||
logger.error('Checkout session creation failed', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cancelSubscriptionAtPeriodEnd = async (): Promise<void> => {
|
||||
try {
|
||||
await http.post({url: Endpoints.PREMIUM_CANCEL_SUBSCRIPTION});
|
||||
logger.info('Subscription set to cancel at period end');
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel subscription at period end', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const reactivateSubscription = async (): Promise<void> => {
|
||||
try {
|
||||
await http.post({url: Endpoints.PREMIUM_REACTIVATE_SUBSCRIPTION});
|
||||
logger.info('Subscription reactivated');
|
||||
} catch (error) {
|
||||
logger.error('Failed to reactivate subscription', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const rejoinVisionaryGuild = async (): Promise<void> => {
|
||||
try {
|
||||
await http.post({url: Endpoints.PREMIUM_VISIONARY_REJOIN});
|
||||
logger.info('Visionary guild rejoin requested');
|
||||
} catch (error) {
|
||||
logger.error('Failed to rejoin Visionary guild', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const rejoinOperatorGuild = async (): Promise<void> => {
|
||||
try {
|
||||
await http.post({url: Endpoints.PREMIUM_OPERATOR_REJOIN});
|
||||
logger.info('Operator guild rejoin requested');
|
||||
} catch (error) {
|
||||
logger.error('Failed to rejoin Operator guild', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
40
fluxer_app/src/actions/PremiumModalActionCreators.tsx
Normal file
40
fluxer_app/src/actions/PremiumModalActionCreators.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {PremiumModal} from '~/components/modals/PremiumModal';
|
||||
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
||||
|
||||
interface OpenOptions {
|
||||
defaultGiftMode?: boolean;
|
||||
}
|
||||
|
||||
export const open = (optionsOrDefaultGiftMode: OpenOptions | boolean = {}): void => {
|
||||
if (RuntimeConfigStore.isSelfHosted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options =
|
||||
typeof optionsOrDefaultGiftMode === 'boolean'
|
||||
? {defaultGiftMode: optionsOrDefaultGiftMode}
|
||||
: optionsOrDefaultGiftMode;
|
||||
const {defaultGiftMode = false} = options;
|
||||
ModalActionCreators.push(modal(() => <PremiumModal defaultGiftMode={defaultGiftMode} />));
|
||||
};
|
||||
130
fluxer_app/src/actions/PrivateChannelActionCreators.tsx
Normal file
130
fluxer_app/src/actions/PrivateChannelActionCreators.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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 {ChannelTypes} from '~/Constants';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {Routes} from '~/Routes';
|
||||
import type {Channel} from '~/records/ChannelRecord';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
|
||||
const logger = new Logger('PrivateChannelActionCreators');
|
||||
|
||||
export const create = async (userId: string) => {
|
||||
try {
|
||||
const response = await http.post<Channel>({
|
||||
url: Endpoints.USER_CHANNELS,
|
||||
body: {recipient_id: userId},
|
||||
});
|
||||
const channel = response.body;
|
||||
return channel;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create private channel:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createGroupDM = async (recipientIds: Array<string>) => {
|
||||
try {
|
||||
const response = await http.post<Channel>({
|
||||
url: Endpoints.USER_CHANNELS,
|
||||
body: {recipients: recipientIds},
|
||||
});
|
||||
const channel = response.body;
|
||||
return channel;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create group DM:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeRecipient = async (channelId: string, userId: string) => {
|
||||
try {
|
||||
await http.delete({
|
||||
url: Endpoints.CHANNEL_RECIPIENT(channelId, userId),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove recipient:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const ensureDMChannel = async (userId: string): Promise<string> => {
|
||||
try {
|
||||
const existingChannels = ChannelStore.dmChannels;
|
||||
const existingChannel = existingChannels.find(
|
||||
(channel) => channel.type === ChannelTypes.DM && channel.recipientIds.includes(userId),
|
||||
);
|
||||
|
||||
if (existingChannel) {
|
||||
return existingChannel.id;
|
||||
}
|
||||
|
||||
const channel = await create(userId);
|
||||
return channel.id;
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure DM channel:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const openDMChannel = async (userId: string): Promise<void> => {
|
||||
try {
|
||||
const channelId = await ensureDMChannel(userId);
|
||||
RouterUtils.transitionTo(Routes.dmChannel(channelId));
|
||||
} catch (error) {
|
||||
logger.error('Failed to open DM channel:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const pinDmChannel = async (channelId: string): Promise<void> => {
|
||||
try {
|
||||
await http.put({
|
||||
url: Endpoints.USER_CHANNEL_PIN(channelId),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to pin DM channel:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const unpinDmChannel = async (channelId: string): Promise<void> => {
|
||||
try {
|
||||
await http.delete({
|
||||
url: Endpoints.USER_CHANNEL_PIN(channelId),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to unpin DM channel:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addRecipient = async (channelId: string, userId: string): Promise<void> => {
|
||||
try {
|
||||
await http.put({
|
||||
url: Endpoints.CHANNEL_RECIPIENT(channelId, userId),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to add recipient:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
159
fluxer_app/src/actions/QuickSwitcherActionCreators.tsx
Normal file
159
fluxer_app/src/actions/QuickSwitcherActionCreators.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as NavigationActionCreators from '~/actions/NavigationActionCreators';
|
||||
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
|
||||
import {FAVORITES_GUILD_ID, ME, QuickSwitcherResultTypes} from '~/Constants';
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import {Routes} from '~/Routes';
|
||||
import type {QuickSwitcherExecutableResult} from '~/stores/QuickSwitcherStore';
|
||||
import QuickSwitcherStore from '~/stores/QuickSwitcherStore';
|
||||
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
||||
import {goToMessage, parseMessagePath} from '~/utils/MessageNavigator';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
|
||||
const QUICK_SWITCHER_MODAL_KEY = 'quick-switcher';
|
||||
|
||||
export const hide = (): void => {
|
||||
QuickSwitcherStore.hide();
|
||||
};
|
||||
|
||||
export const search = (query: string): void => {
|
||||
QuickSwitcherStore.search(query);
|
||||
};
|
||||
|
||||
export const select = (selectedIndex: number): void => {
|
||||
QuickSwitcherStore.select(selectedIndex);
|
||||
};
|
||||
|
||||
export const moveSelection = (direction: 'up' | 'down'): void => {
|
||||
const nextIndex = QuickSwitcherStore.findNextSelectableIndex(direction);
|
||||
select(nextIndex);
|
||||
};
|
||||
|
||||
export const confirmSelection = async (): Promise<void> => {
|
||||
const result = QuickSwitcherStore.getSelectedResult();
|
||||
if (!result) return;
|
||||
await switchTo(result);
|
||||
};
|
||||
|
||||
export const switchTo = async (result: QuickSwitcherExecutableResult): Promise<void> => {
|
||||
try {
|
||||
switch (result.type) {
|
||||
case QuickSwitcherResultTypes.USER: {
|
||||
if (result.dmChannelId) {
|
||||
RouterUtils.transitionTo(Routes.dmChannel(result.dmChannelId));
|
||||
} else {
|
||||
await PrivateChannelActionCreators.openDMChannel(result.user.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.GROUP_DM: {
|
||||
RouterUtils.transitionTo(Routes.dmChannel(result.channel.id));
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.TEXT_CHANNEL: {
|
||||
if (result.viewContext === FAVORITES_GUILD_ID) {
|
||||
NavigationActionCreators.selectChannel(FAVORITES_GUILD_ID, result.channel.id);
|
||||
RouterUtils.transitionTo(Routes.favoritesChannel(result.channel.id));
|
||||
} else if (result.guild) {
|
||||
NavigationActionCreators.selectGuild(result.guild.id);
|
||||
NavigationActionCreators.selectChannel(result.guild.id, result.channel.id);
|
||||
RouterUtils.transitionTo(Routes.guildChannel(result.guild.id, result.channel.id));
|
||||
} else {
|
||||
RouterUtils.transitionTo(Routes.dmChannel(result.channel.id));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.VOICE_CHANNEL: {
|
||||
if (result.viewContext === FAVORITES_GUILD_ID) {
|
||||
NavigationActionCreators.selectChannel(FAVORITES_GUILD_ID, result.channel.id);
|
||||
RouterUtils.transitionTo(Routes.favoritesChannel(result.channel.id));
|
||||
} else if (result.guild) {
|
||||
NavigationActionCreators.selectGuild(result.guild.id);
|
||||
NavigationActionCreators.selectChannel(result.guild.id, result.channel.id);
|
||||
RouterUtils.transitionTo(Routes.guildChannel(result.guild.id, result.channel.id));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.GUILD: {
|
||||
const channelId = SelectedChannelStore.selectedChannelIds.get(result.guild.id);
|
||||
NavigationActionCreators.selectGuild(result.guild.id);
|
||||
if (channelId) {
|
||||
NavigationActionCreators.selectChannel(result.guild.id, channelId);
|
||||
RouterUtils.transitionTo(Routes.guildChannel(result.guild.id, channelId));
|
||||
} else {
|
||||
RouterUtils.transitionTo(Routes.guildChannel(result.guild.id));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.VIRTUAL_GUILD: {
|
||||
if (result.virtualGuildType === 'favorites') {
|
||||
const validChannelId = SelectedChannelStore.getValidatedFavoritesChannel();
|
||||
if (validChannelId) {
|
||||
RouterUtils.transitionTo(Routes.favoritesChannel(validChannelId));
|
||||
} else {
|
||||
RouterUtils.transitionTo(Routes.FAVORITES);
|
||||
}
|
||||
} else if (result.virtualGuildType === 'home') {
|
||||
const dmChannelId = SelectedChannelStore.selectedChannelIds.get(ME);
|
||||
if (dmChannelId) {
|
||||
RouterUtils.transitionTo(Routes.dmChannel(dmChannelId));
|
||||
} else {
|
||||
RouterUtils.transitionTo(Routes.ME);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.SETTINGS: {
|
||||
const initialTab = result.settingsTab.type;
|
||||
const initialSubtab = result.settingsSubtab?.type;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => <UserSettingsModal initialTab={initialTab} initialSubtab={initialSubtab} />),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.QUICK_ACTION: {
|
||||
result.action();
|
||||
break;
|
||||
}
|
||||
case QuickSwitcherResultTypes.LINK: {
|
||||
const parsed = parseMessagePath(result.path);
|
||||
if (parsed) {
|
||||
const viewContext = result.path.startsWith(Routes.favoritesChannel(parsed.channelId))
|
||||
? 'favorites'
|
||||
: undefined;
|
||||
goToMessage(parsed.channelId, parsed.messageId, {viewContext});
|
||||
} else {
|
||||
RouterUtils.transitionTo(result.path);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
export const getModalKey = (): string => QUICK_SWITCHER_MODAL_KEY;
|
||||
267
fluxer_app/src/actions/ReactionActionCreators.tsx
Normal file
267
fluxer_app/src/actions/ReactionActionCreators.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {APIErrorCodes, ME} from '~/Constants';
|
||||
import {FeatureTemporarilyDisabledModal} from '~/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {TooManyReactionsModal} from '~/components/alerts/TooManyReactionsModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http, {HttpError} from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {UserPartial} from '~/records/UserRecord';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import MessageReactionsStore from '~/stores/MessageReactionsStore';
|
||||
import MessageStore from '~/stores/MessageStore';
|
||||
import type {ReactionEmoji} from '~/utils/ReactionUtils';
|
||||
|
||||
const logger = new Logger('MessageReactions');
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
const checkReactionResponse = (i18n: I18n, error: any, retry: () => void): boolean => {
|
||||
if (error.status === 403) {
|
||||
const errorCode = error.body?.code as string;
|
||||
if (errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
||||
logger.debug('Feature temporarily disabled, not retrying');
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
return true;
|
||||
}
|
||||
if (errorCode === APIErrorCodes.COMMUNICATION_DISABLED) {
|
||||
logger.debug('Communication disabled while timed out, not retrying');
|
||||
ToastActionCreators.createToast({
|
||||
type: 'info',
|
||||
children: i18n._(msg`You can't add new reactions while you're on timeout.`),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (error.status === 429) {
|
||||
const retryAfter = error.body?.retry_after || 1000;
|
||||
logger.debug(`Rate limited, retrying after ${retryAfter}ms`);
|
||||
setTimeout(retry, retryAfter);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (error.status === 400) {
|
||||
const errorCode = error.body?.code as string;
|
||||
switch (errorCode) {
|
||||
case APIErrorCodes.MAX_REACTIONS:
|
||||
logger.debug(`Reaction limit reached: ${errorCode}`);
|
||||
ModalActionCreators.push(modal(() => <TooManyReactionsModal />));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const optimisticUpdate = (
|
||||
type:
|
||||
| 'MESSAGE_REACTION_ADD'
|
||||
| 'MESSAGE_REACTION_REMOVE'
|
||||
| 'MESSAGE_REACTION_REMOVE_ALL'
|
||||
| 'MESSAGE_REACTION_REMOVE_EMOJI',
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: ReactionEmoji,
|
||||
userId?: string,
|
||||
): void => {
|
||||
const actualUserId = userId ?? AuthenticationStore.currentUserId;
|
||||
|
||||
if (!actualUserId) {
|
||||
logger.warn('Skipping optimistic reaction update because user ID is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'MESSAGE_REACTION_ADD') {
|
||||
MessageReactionsStore.handleReactionAdd(messageId, actualUserId, emoji);
|
||||
} else if (type === 'MESSAGE_REACTION_REMOVE') {
|
||||
MessageReactionsStore.handleReactionRemove(messageId, actualUserId, emoji);
|
||||
} else if (type === 'MESSAGE_REACTION_REMOVE_ALL') {
|
||||
MessageReactionsStore.handleReactionRemoveAll(messageId);
|
||||
} else if (type === 'MESSAGE_REACTION_REMOVE_EMOJI') {
|
||||
MessageReactionsStore.handleReactionRemoveEmoji(messageId, emoji);
|
||||
}
|
||||
|
||||
if (type === 'MESSAGE_REACTION_ADD' || type === 'MESSAGE_REACTION_REMOVE') {
|
||||
MessageStore.handleReaction({
|
||||
type,
|
||||
channelId,
|
||||
messageId,
|
||||
userId: actualUserId,
|
||||
emoji,
|
||||
optimistic: true,
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Optimistically applied ${type} for message ${messageId} ` +
|
||||
`with emoji ${emoji.name}${emoji.id ? `:${emoji.id}` : ''} by user ${actualUserId}`,
|
||||
);
|
||||
};
|
||||
|
||||
const makeUrl = ({
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
userId,
|
||||
}: {
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
emoji: ReactionEmoji;
|
||||
userId?: string;
|
||||
}): string => {
|
||||
const emojiCode = encodeURIComponent(emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name);
|
||||
return userId
|
||||
? Endpoints.CHANNEL_MESSAGE_REACTION_QUERY(channelId, messageId, emojiCode, userId)
|
||||
: Endpoints.CHANNEL_MESSAGE_REACTION(channelId, messageId, emojiCode);
|
||||
};
|
||||
|
||||
const retryWithExponentialBackoff = async (func: () => Promise<any>, attempts = 0): Promise<any> => {
|
||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
try {
|
||||
return await func();
|
||||
} catch (error) {
|
||||
const status = error instanceof HttpError ? error.status : undefined;
|
||||
if (status !== 429) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempts < MAX_RETRIES) {
|
||||
const backoffTime = 2 ** attempts * 1000;
|
||||
logger.debug(`Rate limited, retrying in ${backoffTime}ms (attempt ${attempts + 1}/${MAX_RETRIES})`);
|
||||
await delay(backoffTime);
|
||||
return retryWithExponentialBackoff(func, attempts + 1);
|
||||
}
|
||||
|
||||
logger.error(`Operation failed after ${MAX_RETRIES} attempts:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const performReactionAction = (
|
||||
i18n: I18n,
|
||||
type: 'MESSAGE_REACTION_ADD' | 'MESSAGE_REACTION_REMOVE',
|
||||
apiFunc: () => Promise<any>,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: ReactionEmoji,
|
||||
userId?: string,
|
||||
): void => {
|
||||
optimisticUpdate(type, channelId, messageId, emoji, userId);
|
||||
|
||||
retryWithExponentialBackoff(apiFunc).catch((error) => {
|
||||
if (
|
||||
checkReactionResponse(i18n, error, () =>
|
||||
performReactionAction(i18n, type, apiFunc, channelId, messageId, emoji, userId),
|
||||
)
|
||||
) {
|
||||
logger.debug(`Reverting optimistic update for reaction in message ${messageId}`);
|
||||
optimisticUpdate(
|
||||
type === 'MESSAGE_REACTION_ADD' ? 'MESSAGE_REACTION_REMOVE' : 'MESSAGE_REACTION_ADD',
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getReactions = async (
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: ReactionEmoji,
|
||||
limit?: number,
|
||||
): Promise<Array<UserPartial>> => {
|
||||
MessageReactionsStore.handleFetchPending(messageId, emoji);
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
`Fetching reactions for message ${messageId} in channel ${channelId} with emoji ${emoji.name}${limit ? ` (limit: ${limit})` : ''}`,
|
||||
);
|
||||
|
||||
const query: Record<string, number> = {};
|
||||
if (limit !== undefined) query.limit = limit;
|
||||
|
||||
const response = await http.get<Array<UserPartial>>({
|
||||
url: makeUrl({channelId, messageId, emoji}),
|
||||
query: Object.keys(query).length > 0 ? query : undefined,
|
||||
});
|
||||
const data = response.body ?? [];
|
||||
MessageReactionsStore.handleFetchSuccess(messageId, data, emoji);
|
||||
|
||||
logger.debug(`Retrieved ${data.length} reactions for message ${messageId}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get reactions for message ${messageId}:`, error);
|
||||
MessageReactionsStore.handleFetchError(messageId, emoji);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addReaction = (i18n: I18n, channelId: string, messageId: string, emoji: ReactionEmoji): void => {
|
||||
logger.debug(`Adding reaction ${emoji.name} to message ${messageId}`);
|
||||
|
||||
const apiFunc = () =>
|
||||
http.put({
|
||||
url: makeUrl({channelId, messageId, emoji, userId: ME}),
|
||||
query: {session_id: ConnectionStore.sessionId ?? null},
|
||||
});
|
||||
|
||||
performReactionAction(i18n, 'MESSAGE_REACTION_ADD', apiFunc, channelId, messageId, emoji);
|
||||
};
|
||||
|
||||
export const removeReaction = (
|
||||
i18n: I18n,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
emoji: ReactionEmoji,
|
||||
userId?: string,
|
||||
): void => {
|
||||
logger.debug(`Removing reaction ${emoji.name} from message ${messageId}`);
|
||||
|
||||
const apiFunc = () =>
|
||||
http.delete({
|
||||
url: makeUrl({channelId, messageId, emoji, userId: userId || ME}),
|
||||
query: {session_id: ConnectionStore.sessionId ?? null},
|
||||
});
|
||||
|
||||
performReactionAction(i18n, 'MESSAGE_REACTION_REMOVE', apiFunc, channelId, messageId, emoji, userId);
|
||||
};
|
||||
|
||||
export const removeAllReactions = (i18n: I18n, channelId: string, messageId: string): void => {
|
||||
logger.debug(`Removing all reactions from message ${messageId} in channel ${channelId}`);
|
||||
|
||||
const apiFunc = () =>
|
||||
http.delete({
|
||||
url: Endpoints.CHANNEL_MESSAGE_REACTIONS(channelId, messageId),
|
||||
});
|
||||
|
||||
retryWithExponentialBackoff(apiFunc).catch((error) => {
|
||||
checkReactionResponse(i18n, error, () => removeAllReactions(i18n, channelId, messageId));
|
||||
});
|
||||
};
|
||||
159
fluxer_app/src/actions/ReadStateActionCreators.tsx
Normal file
159
fluxer_app/src/actions/ReadStateActionCreators.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import MessageStore from '~/stores/MessageStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import SnowflakeUtil from '~/utils/SnowflakeUtil';
|
||||
|
||||
const logger = new Logger('ReadStateActionCreators');
|
||||
|
||||
type ChannelId = string;
|
||||
type MessageId = string;
|
||||
|
||||
export const ack = (channelId: ChannelId, immediate = false, force = false): void => {
|
||||
logger.debug(`Acking channel ${channelId}, immediate=${immediate}, force=${force}`);
|
||||
ReadStateStore.handleChannelAck({channelId, immediate, force});
|
||||
};
|
||||
|
||||
export const ackWithStickyUnread = (channelId: ChannelId): void => {
|
||||
logger.debug(`Acking channel ${channelId} with sticky unread preservation`);
|
||||
ReadStateStore.handleChannelAckWithStickyUnread({channelId});
|
||||
};
|
||||
|
||||
export const manualAck = async (channelId: ChannelId, messageId: MessageId): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Manual ack: ${messageId} in ${channelId}`);
|
||||
const mentionCount = ReadStateStore.getManualAckMentionCount(channelId, messageId);
|
||||
|
||||
await http.post({
|
||||
url: Endpoints.CHANNEL_MESSAGE_ACK(channelId, messageId),
|
||||
body: {
|
||||
manual: true,
|
||||
mention_count: mentionCount,
|
||||
},
|
||||
});
|
||||
|
||||
ReadStateStore.handleMessageAck({channelId, messageId, manual: true});
|
||||
logger.debug(`Successfully manual acked ${messageId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to manual ack ${messageId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const markAsUnread = async (channelId: ChannelId, messageId: MessageId): Promise<void> => {
|
||||
const messages = MessageStore.getMessages(channelId);
|
||||
const messagesArray = messages.toArray();
|
||||
const messageIndex = messagesArray.findIndex((m) => m.id === messageId);
|
||||
|
||||
logger.debug(`Marking message ${messageId} as unread, index: ${messageIndex}, total: ${messagesArray.length}`);
|
||||
|
||||
if (messageIndex < 0) {
|
||||
logger.debug('Message not found in cache; skipping mark-as-unread request');
|
||||
return;
|
||||
}
|
||||
|
||||
const ackMessageId =
|
||||
messageIndex > 0 ? messagesArray[messageIndex - 1].id : SnowflakeUtil.atPreviousMillisecond(messageId);
|
||||
|
||||
if (!ackMessageId || ackMessageId === '0') {
|
||||
logger.debug('Unable to determine a previous message to ack; skipping mark-as-unread request');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Acking ${ackMessageId} to mark ${messageId} as unread`);
|
||||
await manualAck(channelId, ackMessageId);
|
||||
};
|
||||
|
||||
export const clearManualAck = (channelId: ChannelId): void => {
|
||||
ReadStateStore.handleClearManualAck({channelId});
|
||||
};
|
||||
|
||||
export const clearStickyUnread = (channelId: ChannelId): void => {
|
||||
logger.debug(`Clearing sticky unread for ${channelId}`);
|
||||
ReadStateStore.clearStickyUnread(channelId);
|
||||
};
|
||||
|
||||
interface BulkAckEntry {
|
||||
channelId: ChannelId;
|
||||
messageId: MessageId;
|
||||
}
|
||||
|
||||
const BULK_ACK_BATCH_SIZE = 100;
|
||||
|
||||
function chunkEntries<T>(entries: Array<T>, size: number): Array<Array<T>> {
|
||||
const chunks: Array<Array<T>> = [];
|
||||
for (let i = 0; i < entries.length; i += size) {
|
||||
chunks.push(entries.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function createBulkEntry(channelId: ChannelId): BulkAckEntry | null {
|
||||
const messageId =
|
||||
ReadStateStore.lastMessageId(channelId) ?? ChannelStore.getChannel(channelId)?.lastMessageId ?? null;
|
||||
|
||||
if (messageId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {channelId, messageId};
|
||||
}
|
||||
|
||||
async function sendBulkAck(entries: Array<BulkAckEntry>): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
try {
|
||||
await http.post({
|
||||
url: Endpoints.READ_STATES_ACK_BULK,
|
||||
body: {
|
||||
read_states: entries.map((entry) => ({
|
||||
channel_id: entry.channelId,
|
||||
message_id: entry.messageId,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to bulk ack read states:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateReadStatesLocally(entries: Array<BulkAckEntry>): void {
|
||||
for (const entry of entries) {
|
||||
ReadStateStore.handleMessageAck({channelId: entry.channelId, messageId: entry.messageId, manual: false});
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkAckChannels(channelIds: Array<ChannelId>): Promise<void> {
|
||||
const entries = channelIds
|
||||
.map((channelId) => createBulkEntry(channelId))
|
||||
.filter((entry): entry is BulkAckEntry => entry != null);
|
||||
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const chunks = chunkEntries(entries, BULK_ACK_BATCH_SIZE);
|
||||
for (const chunk of chunks) {
|
||||
updateReadStatesLocally(chunk);
|
||||
await sendBulkAck(chunk);
|
||||
}
|
||||
}
|
||||
101
fluxer_app/src/actions/RecentMentionActionCreators.tsx
Normal file
101
fluxer_app/src/actions/RecentMentionActionCreators.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {Message} from '~/records/MessageRecord';
|
||||
import type {MentionFilters} from '~/stores/RecentMentionsStore';
|
||||
import RecentMentionsStore from '~/stores/RecentMentionsStore';
|
||||
|
||||
const logger = new Logger('Mentions');
|
||||
|
||||
export const fetch = async (): Promise<Array<Message>> => {
|
||||
RecentMentionsStore.handleFetchPending();
|
||||
try {
|
||||
const filters = RecentMentionsStore.getFilters();
|
||||
logger.debug('Fetching recent mentions');
|
||||
const response = await http.get<Array<Message>>({
|
||||
url: Endpoints.USER_MENTIONS,
|
||||
query: {
|
||||
everyone: filters.includeEveryone,
|
||||
roles: filters.includeRoles,
|
||||
guilds: filters.includeGuilds,
|
||||
limit: 25,
|
||||
},
|
||||
});
|
||||
const data = response.body ?? [];
|
||||
RecentMentionsStore.handleRecentMentionsFetchSuccess(data);
|
||||
logger.debug(`Successfully fetched ${data.length} recent mentions`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
RecentMentionsStore.handleRecentMentionsFetchError();
|
||||
logger.error('Failed to fetch recent mentions:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const loadMore = async (): Promise<Array<Message>> => {
|
||||
const recentMentions = RecentMentionsStore.recentMentions;
|
||||
if (recentMentions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lastMessage = recentMentions[recentMentions.length - 1];
|
||||
const filters = RecentMentionsStore.getFilters();
|
||||
|
||||
RecentMentionsStore.handleFetchPending();
|
||||
try {
|
||||
logger.debug(`Loading more mentions before ${lastMessage.id}`);
|
||||
const response = await http.get<Array<Message>>({
|
||||
url: Endpoints.USER_MENTIONS,
|
||||
query: {
|
||||
everyone: filters.includeEveryone,
|
||||
roles: filters.includeRoles,
|
||||
guilds: filters.includeGuilds,
|
||||
limit: 25,
|
||||
before: lastMessage.id,
|
||||
},
|
||||
});
|
||||
const data = response.body ?? [];
|
||||
RecentMentionsStore.handleRecentMentionsFetchSuccess(data);
|
||||
logger.debug(`Successfully loaded ${data.length} more mentions`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
RecentMentionsStore.handleRecentMentionsFetchError();
|
||||
logger.error('Failed to load more mentions:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFilters = (filters: Partial<MentionFilters>): void => {
|
||||
RecentMentionsStore.updateFilters(filters);
|
||||
};
|
||||
|
||||
export const remove = async (messageId: string): Promise<void> => {
|
||||
try {
|
||||
RecentMentionsStore.handleMessageDelete(messageId);
|
||||
logger.debug(`Removing message ${messageId} from recent mentions`);
|
||||
await http.delete({url: Endpoints.USER_MENTION(messageId)});
|
||||
logger.debug(`Successfully removed message ${messageId} from recent mentions`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove message ${messageId} from recent mentions:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
79
fluxer_app/src/actions/RelationshipActionCreators.tsx
Normal file
79
fluxer_app/src/actions/RelationshipActionCreators.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 {RelationshipTypes} from '~/Constants';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
|
||||
const logger = new Logger('RelationshipActionCreators');
|
||||
|
||||
export const sendFriendRequest = async (userId: string) => {
|
||||
try {
|
||||
await http.post({url: Endpoints.USER_RELATIONSHIP(userId)});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send friend request:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendFriendRequestByTag = async (username: string, discriminator: string) => {
|
||||
try {
|
||||
await http.post({url: Endpoints.USER_RELATIONSHIPS, body: {username, discriminator}});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send friend request by tag:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const acceptFriendRequest = async (userId: string) => {
|
||||
try {
|
||||
await http.put({url: Endpoints.USER_RELATIONSHIP(userId)});
|
||||
} catch (error) {
|
||||
logger.error('Failed to accept friend request:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeRelationship = async (userId: string) => {
|
||||
try {
|
||||
await http.delete({url: Endpoints.USER_RELATIONSHIP(userId)});
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove relationship:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const blockUser = async (userId: string) => {
|
||||
try {
|
||||
await http.put({url: Endpoints.USER_RELATIONSHIP(userId), body: {type: RelationshipTypes.BLOCKED}});
|
||||
} catch (error) {
|
||||
logger.error('Failed to block user:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFriendNickname = async (userId: string, nickname: string | null) => {
|
||||
try {
|
||||
await http.patch({url: Endpoints.USER_RELATIONSHIP(userId), body: {nickname}});
|
||||
} catch (error) {
|
||||
logger.error('Failed to update friend nickname:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
92
fluxer_app/src/actions/SavedMessageActionCreators.tsx
Normal file
92
fluxer_app/src/actions/SavedMessageActionCreators.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {APIErrorCodes} from '~/Constants';
|
||||
import {MaxBookmarksModal} from '~/components/alerts/MaxBookmarksModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http, {HttpError} from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {SavedMessageEntryRecord, type SavedMessageEntryResponse} from '~/records/SavedMessageEntryRecord';
|
||||
import SavedMessagesStore from '~/stores/SavedMessagesStore';
|
||||
|
||||
const logger = new Logger('SavedMessages');
|
||||
|
||||
export const fetch = async (): Promise<Array<SavedMessageEntryRecord>> => {
|
||||
try {
|
||||
logger.debug('Fetching saved messages');
|
||||
const response = await http.get<Array<SavedMessageEntryResponse>>({url: Endpoints.USER_SAVED_MESSAGES});
|
||||
const data = response.body ?? [];
|
||||
const entries = data.map(SavedMessageEntryRecord.fromResponse);
|
||||
SavedMessagesStore.fetchSuccess(entries);
|
||||
logger.debug(`Successfully fetched ${entries.length} saved messages`);
|
||||
return entries;
|
||||
} catch (error) {
|
||||
SavedMessagesStore.fetchError();
|
||||
logger.error('Failed to fetch saved messages:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const create = async (i18n: I18n, channelId: string, messageId: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Saving message ${messageId} from channel ${channelId}`);
|
||||
await http.post({url: Endpoints.USER_SAVED_MESSAGES, body: {channel_id: channelId, message_id: messageId}});
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Added to bookmarks`),
|
||||
});
|
||||
logger.debug(`Successfully saved message ${messageId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save message ${messageId}:`, error);
|
||||
|
||||
if (
|
||||
error instanceof HttpError &&
|
||||
typeof error.body === 'object' &&
|
||||
error.body != null &&
|
||||
'code' in error.body &&
|
||||
(error.body as {code?: string}).code === APIErrorCodes.MAX_BOOKMARKS
|
||||
) {
|
||||
ModalActionCreators.push(modal(() => <MaxBookmarksModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const remove = async (i18n: I18n, messageId: string): Promise<void> => {
|
||||
try {
|
||||
SavedMessagesStore.handleMessageDelete(messageId);
|
||||
logger.debug(`Removing message ${messageId} from saved messages`);
|
||||
await http.delete({url: Endpoints.USER_SAVED_MESSAGE(messageId)});
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Removed from bookmarks`),
|
||||
});
|
||||
logger.debug(`Successfully removed message ${messageId} from saved messages`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to remove message ${messageId} from saved messages:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
419
fluxer_app/src/actions/ScheduledMessageActionCreators.tsx
Normal file
419
fluxer_app/src/actions/ScheduledMessageActionCreators.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as DraftActionCreators from '~/actions/DraftActionCreators';
|
||||
import * as MessageActionCreators from '~/actions/MessageActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as SlowmodeActionCreators from '~/actions/SlowmodeActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {APIErrorCodes} from '~/Constants';
|
||||
import {FeatureTemporarilyDisabledModal} from '~/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {FileSizeTooLargeModal} from '~/components/alerts/FileSizeTooLargeModal';
|
||||
import {MessageSendFailedModal} from '~/components/alerts/MessageSendFailedModal';
|
||||
import {MessageSendTooQuickModal} from '~/components/alerts/MessageSendTooQuickModal';
|
||||
import {NSFWContentRejectedModal} from '~/components/alerts/NSFWContentRejectedModal';
|
||||
import {SlowmodeRateLimitedModal} from '~/components/alerts/SlowmodeRateLimitedModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import {CloudUpload} from '~/lib/CloudUpload';
|
||||
import http, {type HttpError, type HttpResponse} from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {AllowedMentions} from '~/records/MessageRecord';
|
||||
import {
|
||||
type ScheduledAttachment,
|
||||
type ScheduledMessagePayload,
|
||||
ScheduledMessageRecord,
|
||||
type ScheduledMessageResponse,
|
||||
} from '~/records/ScheduledMessageRecord';
|
||||
import ScheduledMessagesStore from '~/stores/ScheduledMessagesStore';
|
||||
import {prepareAttachmentsForNonce} from '~/utils/MessageAttachmentUtils';
|
||||
import {
|
||||
type ApiAttachmentMetadata,
|
||||
buildMessageCreateRequest,
|
||||
type MessageCreateRequest,
|
||||
type MessageReference,
|
||||
type MessageStickerItem,
|
||||
type NormalizedMessageContent,
|
||||
normalizeMessageContent,
|
||||
} from '~/utils/MessageRequestUtils';
|
||||
import * as MessageSubmitUtils from '~/utils/MessageSubmitUtils';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
import {TypingUtils} from '~/utils/TypingUtils';
|
||||
|
||||
const logger = new Logger('ScheduledMessages');
|
||||
|
||||
type ScheduledMessageRequest = MessageCreateRequest & {
|
||||
scheduled_local_at: string;
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
interface ApiErrorBody {
|
||||
code?: number | string;
|
||||
retry_after?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface ScheduleMessageParams {
|
||||
channelId: string;
|
||||
content: string;
|
||||
scheduledLocalAt: string;
|
||||
timezone: string;
|
||||
messageReference?: MessageReference;
|
||||
replyMentioning?: boolean;
|
||||
favoriteMemeId?: string;
|
||||
stickers?: Array<MessageStickerItem>;
|
||||
tts?: boolean;
|
||||
hasAttachments: boolean;
|
||||
}
|
||||
|
||||
interface UpdateScheduledMessageParams {
|
||||
channelId: string;
|
||||
scheduledMessageId: string;
|
||||
scheduledLocalAt: string;
|
||||
timezone: string;
|
||||
normalized: NormalizedMessageContent;
|
||||
payload: ScheduledMessagePayload;
|
||||
replyMentioning?: boolean;
|
||||
}
|
||||
|
||||
const formatScheduledLabel = (local: string, timezone: string): string => {
|
||||
return `${local.replace('T', ' ')} (${timezone})`;
|
||||
};
|
||||
|
||||
function mapScheduledAttachments(
|
||||
attachments?: ReadonlyArray<ScheduledAttachment>,
|
||||
): Array<ApiAttachmentMetadata> | undefined {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return attachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
filename: attachment.filename,
|
||||
title: attachment.title ?? attachment.filename,
|
||||
description: attachment.description ?? undefined,
|
||||
flags: attachment.flags,
|
||||
}));
|
||||
}
|
||||
|
||||
export const fetchScheduledMessages = async (): Promise<Array<ScheduledMessageRecord>> => {
|
||||
logger.debug('Fetching scheduled messages');
|
||||
ScheduledMessagesStore.fetchStart();
|
||||
|
||||
try {
|
||||
const response = await http.get<Array<ScheduledMessageResponse>>({
|
||||
url: Endpoints.USER_SCHEDULED_MESSAGES,
|
||||
});
|
||||
const data = response.body ?? [];
|
||||
const messages = data.map(ScheduledMessageRecord.fromResponse);
|
||||
ScheduledMessagesStore.fetchSuccess(messages);
|
||||
logger.debug('Scheduled messages fetched successfully');
|
||||
return messages;
|
||||
} catch (error) {
|
||||
ScheduledMessagesStore.fetchError();
|
||||
logger.error('Failed to fetch scheduled messages:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const scheduleMessage = async (i18n: I18n, params: ScheduleMessageParams): Promise<ScheduledMessageRecord> => {
|
||||
logger.debug('Scheduling message', params);
|
||||
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
|
||||
const normalized = normalizeMessageContent(params.content, params.favoriteMemeId);
|
||||
const allowedMentions: AllowedMentions = {replied_user: params.replyMentioning ?? true};
|
||||
|
||||
if (params.hasAttachments) {
|
||||
MessageSubmitUtils.claimMessageAttachments(
|
||||
params.channelId,
|
||||
nonce,
|
||||
params.content,
|
||||
params.messageReference,
|
||||
params.replyMentioning,
|
||||
params.favoriteMemeId,
|
||||
);
|
||||
}
|
||||
|
||||
let attachments: Array<ApiAttachmentMetadata> | undefined;
|
||||
let files: Array<File> | undefined;
|
||||
|
||||
if (params.hasAttachments) {
|
||||
const result = await prepareAttachmentsForNonce(nonce, params.favoriteMemeId);
|
||||
attachments = result.attachments;
|
||||
files = result.files;
|
||||
}
|
||||
|
||||
const requestBody = buildMessageCreateRequest({
|
||||
content: normalized.content,
|
||||
nonce,
|
||||
attachments,
|
||||
allowedMentions,
|
||||
messageReference: params.messageReference,
|
||||
flags: normalized.flags,
|
||||
favoriteMemeId: params.favoriteMemeId,
|
||||
stickers: params.stickers,
|
||||
tts: params.tts,
|
||||
});
|
||||
|
||||
const payload: ScheduledMessageRequest = {
|
||||
...requestBody,
|
||||
scheduled_local_at: params.scheduledLocalAt,
|
||||
timezone: params.timezone,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await scheduleMessageRequest(params.channelId, payload, files, nonce);
|
||||
const record = ScheduledMessageRecord.fromResponse(response.body);
|
||||
ScheduledMessagesStore.upsert(record);
|
||||
DraftActionCreators.deleteDraft(params.channelId);
|
||||
TypingUtils.clear(params.channelId);
|
||||
MessageActionCreators.stopReply(params.channelId);
|
||||
if (params.hasAttachments) {
|
||||
CloudUpload.removeMessageUpload(nonce);
|
||||
}
|
||||
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Scheduled message for ${formatScheduledLabel(params.scheduledLocalAt, params.timezone)}`),
|
||||
});
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
handleScheduleError(
|
||||
i18n,
|
||||
error as HttpError,
|
||||
params.channelId,
|
||||
nonce,
|
||||
params.content,
|
||||
params.messageReference,
|
||||
params.replyMentioning,
|
||||
params.hasAttachments,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateScheduledMessage = async (
|
||||
i18n: I18n,
|
||||
params: UpdateScheduledMessageParams,
|
||||
): Promise<ScheduledMessageRecord> => {
|
||||
logger.debug('Updating scheduled message', params);
|
||||
const requestBody: ScheduledMessageRequest = {
|
||||
content: params.normalized.content,
|
||||
attachments: mapScheduledAttachments(params.payload.attachments),
|
||||
allowed_mentions: params.payload.allowed_mentions ?? (params.replyMentioning ? {replied_user: true} : undefined),
|
||||
message_reference:
|
||||
params.payload.message_reference?.channel_id && params.payload.message_reference.message_id
|
||||
? {
|
||||
channel_id: params.payload.message_reference.channel_id,
|
||||
message_id: params.payload.message_reference.message_id,
|
||||
guild_id: params.payload.message_reference.guild_id,
|
||||
type: params.payload.message_reference.type,
|
||||
}
|
||||
: undefined,
|
||||
flags: params.normalized.flags,
|
||||
favorite_meme_id: params.payload.favorite_meme_id ?? undefined,
|
||||
sticker_ids: params.payload.sticker_ids,
|
||||
tts: params.payload.tts ? true : undefined,
|
||||
scheduled_local_at: params.scheduledLocalAt,
|
||||
timezone: params.timezone,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await http.patch<ScheduledMessageResponse>({
|
||||
url: Endpoints.USER_SCHEDULED_MESSAGE(params.scheduledMessageId),
|
||||
body: requestBody,
|
||||
rejectWithError: true,
|
||||
});
|
||||
const record = ScheduledMessageRecord.fromResponse(response.body);
|
||||
ScheduledMessagesStore.upsert(record);
|
||||
DraftActionCreators.deleteDraft(params.channelId);
|
||||
TypingUtils.clear(params.channelId);
|
||||
MessageActionCreators.stopReply(params.channelId);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(
|
||||
msg`Updated scheduled message for ${formatScheduledLabel(params.scheduledLocalAt, params.timezone)}`,
|
||||
),
|
||||
});
|
||||
return record;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update scheduled message', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cancelScheduledMessage = async (i18n: I18n, scheduledMessageId: string): Promise<void> => {
|
||||
logger.debug('Canceling scheduled message', scheduledMessageId);
|
||||
try {
|
||||
await http.delete({url: Endpoints.USER_SCHEDULED_MESSAGE(scheduledMessageId)});
|
||||
ScheduledMessagesStore.remove(scheduledMessageId);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(msg`Removed scheduled message`),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel scheduled message', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
function restoreDraftAfterScheduleFailure(
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
content: string,
|
||||
messageReference?: MessageReference,
|
||||
replyMentioning?: boolean,
|
||||
hadAttachments?: boolean,
|
||||
): void {
|
||||
if (hadAttachments) {
|
||||
CloudUpload.restoreAttachmentsToTextarea(nonce);
|
||||
}
|
||||
DraftActionCreators.createDraft(channelId, content);
|
||||
if (messageReference && replyMentioning !== undefined) {
|
||||
MessageActionCreators.startReply(channelId, messageReference.message_id, replyMentioning);
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleMessageRequest(
|
||||
channelId: string,
|
||||
payload: ScheduledMessageRequest,
|
||||
files?: Array<File>,
|
||||
nonce?: string,
|
||||
): Promise<HttpResponse<ScheduledMessageResponse>> {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
if (files?.length) {
|
||||
return await scheduleMultipartMessage(channelId, payload, files, abortController.signal, nonce);
|
||||
}
|
||||
return await http.post<ScheduledMessageResponse>({
|
||||
url: Endpoints.CHANNEL_MESSAGE_SCHEDULE(channelId),
|
||||
body: payload,
|
||||
signal: abortController.signal,
|
||||
rejectWithError: true,
|
||||
});
|
||||
} finally {
|
||||
abortController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleMultipartMessage(
|
||||
channelId: string,
|
||||
payload: ScheduledMessageRequest,
|
||||
files: Array<File>,
|
||||
signal: AbortSignal,
|
||||
nonce?: string,
|
||||
): Promise<HttpResponse<ScheduledMessageResponse>> {
|
||||
const formData = new FormData();
|
||||
formData.append('payload_json', JSON.stringify(payload));
|
||||
|
||||
files.forEach((file, index) => {
|
||||
formData.append(`files[${index}]`, file);
|
||||
});
|
||||
|
||||
return http.post<ScheduledMessageResponse>({
|
||||
url: Endpoints.CHANNEL_MESSAGE_SCHEDULE(channelId),
|
||||
body: formData,
|
||||
signal,
|
||||
rejectWithError: true,
|
||||
onRequestProgress: nonce
|
||||
? (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
const progress = (event.loaded / event.total) * 100;
|
||||
CloudUpload.updateSendingProgress(nonce, progress);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const getApiErrorBody = (error: HttpError): ApiErrorBody | undefined => {
|
||||
return typeof error?.body === 'object' && error.body !== null ? (error.body as ApiErrorBody) : undefined;
|
||||
};
|
||||
|
||||
function handleScheduleError(
|
||||
i18n: I18n,
|
||||
error: HttpError,
|
||||
channelId: string,
|
||||
nonce: string,
|
||||
content: string,
|
||||
messageReference?: MessageReference,
|
||||
replyMentioning?: boolean,
|
||||
hadAttachments?: boolean,
|
||||
): void {
|
||||
restoreDraftAfterScheduleFailure(channelId, nonce, content, messageReference, replyMentioning, hadAttachments);
|
||||
|
||||
if (isRateLimitError(error)) {
|
||||
handleScheduleRateLimit(i18n, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSlowmodeError(error)) {
|
||||
const retryAfter = Math.ceil(getApiErrorBody(error)?.retry_after ?? 0);
|
||||
const timestamp = Date.now() - retryAfter * 1000;
|
||||
SlowmodeActionCreators.updateSlowmodeTimestamp(channelId, timestamp);
|
||||
ModalActionCreators.push(modal(() => <SlowmodeRateLimitedModal retryAfter={retryAfter} />));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFeatureDisabledError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FeatureTemporarilyDisabledModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExplicitContentError(error)) {
|
||||
ModalActionCreators.push(modal(() => <NSFWContentRejectedModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFileTooLargeError(error)) {
|
||||
ModalActionCreators.push(modal(() => <FileSizeTooLargeModal />));
|
||||
return;
|
||||
}
|
||||
|
||||
ModalActionCreators.push(modal(() => <MessageSendFailedModal />));
|
||||
}
|
||||
|
||||
function handleScheduleRateLimit(_i18n: I18n, error: HttpError): void {
|
||||
const retryAfterSeconds = getApiErrorBody(error)?.retry_after ?? 0;
|
||||
ModalActionCreators.push(
|
||||
modal(() => <MessageSendTooQuickModal retryAfter={retryAfterSeconds} onRetry={undefined} />),
|
||||
);
|
||||
logger.warn('Scheduled message rate limited, retry after', retryAfterSeconds);
|
||||
}
|
||||
|
||||
function isRateLimitError(error: HttpError): boolean {
|
||||
return error?.status === 429;
|
||||
}
|
||||
|
||||
function isSlowmodeError(error: HttpError): boolean {
|
||||
return error?.status === 400 && getApiErrorBody(error)?.code === APIErrorCodes.SLOWMODE_RATE_LIMITED;
|
||||
}
|
||||
|
||||
function isFeatureDisabledError(error: HttpError): boolean {
|
||||
return error?.status === 403 && getApiErrorBody(error)?.code === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED;
|
||||
}
|
||||
|
||||
function isExplicitContentError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.EXPLICIT_CONTENT_CANNOT_BE_SENT;
|
||||
}
|
||||
|
||||
function isFileTooLargeError(error: HttpError): boolean {
|
||||
return getApiErrorBody(error)?.code === APIErrorCodes.FILE_SIZE_TOO_LARGE;
|
||||
}
|
||||
30
fluxer_app/src/actions/SlowmodeActionCreators.tsx
Normal file
30
fluxer_app/src/actions/SlowmodeActionCreators.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 ChannelStickerStore from '~/stores/ChannelStickerStore';
|
||||
import SlowmodeStore from '~/stores/SlowmodeStore';
|
||||
|
||||
export function recordMessageSend(channelId: string): void {
|
||||
ChannelStickerStore.clearPendingStickerOnMessageSend(channelId);
|
||||
SlowmodeStore.recordMessageSend(channelId);
|
||||
}
|
||||
|
||||
export function updateSlowmodeTimestamp(channelId: string, timestamp: number): void {
|
||||
SlowmodeStore.updateSlowmodeTimestamp(channelId, timestamp);
|
||||
}
|
||||
37
fluxer_app/src/actions/SoundActionCreators.tsx
Normal file
37
fluxer_app/src/actions/SoundActionCreators.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 SoundStore from '~/stores/SoundStore';
|
||||
import type {SoundType} from '~/utils/SoundUtils';
|
||||
|
||||
export const playSound = (sound: SoundType, loop = false): void => {
|
||||
SoundStore.playSound(sound, loop);
|
||||
};
|
||||
|
||||
export const stopAllSounds = (): void => {
|
||||
SoundStore.stopAllSounds();
|
||||
};
|
||||
|
||||
export const updateSoundSettings = (settings: {
|
||||
allSoundsDisabled?: boolean;
|
||||
soundType?: SoundType;
|
||||
enabled?: boolean;
|
||||
}): void => {
|
||||
SoundStore.updateSettings(settings);
|
||||
};
|
||||
37
fluxer_app/src/actions/StickerPickerActionCreators.tsx
Normal file
37
fluxer_app/src/actions/StickerPickerActionCreators.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 {GuildStickerRecord} from '~/records/GuildStickerRecord';
|
||||
import StickerPickerStore from '~/stores/StickerPickerStore';
|
||||
|
||||
function getStickerKey(sticker: GuildStickerRecord): string {
|
||||
return `${sticker.guildId}:${sticker.id}`;
|
||||
}
|
||||
|
||||
export function trackStickerUsage(sticker: GuildStickerRecord): void {
|
||||
StickerPickerStore.trackStickerUsage(getStickerKey(sticker));
|
||||
}
|
||||
|
||||
export function toggleFavorite(sticker: GuildStickerRecord): void {
|
||||
StickerPickerStore.toggleFavorite(getStickerKey(sticker));
|
||||
}
|
||||
|
||||
export function toggleCategory(category: string): void {
|
||||
StickerPickerStore.toggleCategory(category);
|
||||
}
|
||||
132
fluxer_app/src/actions/TenorActionCreators.tsx
Normal file
132
fluxer_app/src/actions/TenorActionCreators.tsx
Normal 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import * as LocaleUtils from '~/utils/LocaleUtils';
|
||||
|
||||
const logger = new Logger('Tenor');
|
||||
|
||||
const getLocale = (): string => LocaleUtils.getCurrentLocale();
|
||||
|
||||
export interface TenorGif {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
src: string;
|
||||
proxy_src: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface TenorCategory {
|
||||
name: string;
|
||||
src: string;
|
||||
proxy_src: string;
|
||||
}
|
||||
|
||||
export interface TenorFeatured {
|
||||
categories: Array<TenorCategory>;
|
||||
gifs: Array<TenorGif>;
|
||||
}
|
||||
|
||||
let tenorFeaturedCache: TenorFeatured | null = null;
|
||||
|
||||
export const search = async (q: string): Promise<Array<TenorGif>> => {
|
||||
try {
|
||||
logger.debug(`Searching for GIFs with query: "${q}"`);
|
||||
const response = await http.get<Array<TenorGif>>({
|
||||
url: Endpoints.TENOR_SEARCH,
|
||||
query: {q, locale: getLocale()},
|
||||
});
|
||||
const gifs = response.body;
|
||||
logger.debug(`Found ${gifs.length} GIFs for query "${q}"`);
|
||||
return gifs;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to search for GIFs with query "${q}":`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFeatured = async (): Promise<TenorFeatured> => {
|
||||
if (tenorFeaturedCache) {
|
||||
logger.debug('Returning cached featured Tenor content');
|
||||
return tenorFeaturedCache;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('Fetching featured Tenor content');
|
||||
const response = await http.get<TenorFeatured>({
|
||||
url: Endpoints.TENOR_FEATURED,
|
||||
query: {locale: getLocale()},
|
||||
});
|
||||
const featured = response.body;
|
||||
tenorFeaturedCache = featured;
|
||||
logger.debug(
|
||||
`Fetched featured Tenor content: ${featured.categories.length} categories and ${featured.gifs.length} GIFs`,
|
||||
);
|
||||
return featured;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch featured Tenor content:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getTrending = async (): Promise<Array<TenorGif>> => {
|
||||
try {
|
||||
logger.debug('Fetching trending Tenor GIFs');
|
||||
const response = await http.get<Array<TenorGif>>({
|
||||
url: Endpoints.TENOR_TRENDING_GIFS,
|
||||
query: {locale: getLocale()},
|
||||
});
|
||||
const gifs = response.body;
|
||||
logger.debug(`Fetched ${gifs.length} trending Tenor GIFs`);
|
||||
return gifs;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch trending Tenor GIFs:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const registerShare = async (id: string, q: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Registering GIF share: id=${id}, query="${q}"`);
|
||||
await http.post({url: Endpoints.TENOR_REGISTER_SHARE, body: {id, q, locale: getLocale()}});
|
||||
logger.debug(`Successfully registered GIF share for id=${id}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to register GIF share for id=${id}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const suggest = async (q: string): Promise<Array<string>> => {
|
||||
try {
|
||||
logger.debug(`Getting Tenor search suggestions for: "${q}"`);
|
||||
const response = await http.get<Array<string>>({
|
||||
url: Endpoints.TENOR_SUGGEST,
|
||||
query: {q, locale: getLocale()},
|
||||
});
|
||||
const suggestions = response.body;
|
||||
logger.debug(`Received ${suggestions.length} suggestions for query "${q}"`);
|
||||
return suggestions;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get suggestions for query "${q}":`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
76
fluxer_app/src/actions/TextCopyActionCreators.tsx
Normal file
76
fluxer_app/src/actions/TextCopyActionCreators.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {getElectronAPI, isDesktop} from '~/utils/NativeUtils';
|
||||
|
||||
const logger = new Logger('Clipboard');
|
||||
|
||||
const writeWithFallback = async (text: string): Promise<void> => {
|
||||
const electronApi = getElectronAPI();
|
||||
if (electronApi?.clipboardWriteText) {
|
||||
logger.debug('Using Electron clipboard');
|
||||
await electronApi.clipboardWriteText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
logger.debug('Using navigator.clipboard');
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('Falling back to temporary textarea copy');
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (success) return;
|
||||
|
||||
throw new Error('No clipboard API available');
|
||||
};
|
||||
|
||||
export const copy = async (i18n: I18n, text: string, suppressToast = false): Promise<boolean> => {
|
||||
try {
|
||||
logger.debug('Copying text to clipboard');
|
||||
if (!isDesktop()) {
|
||||
logger.debug('Desktop runtime not detected; continuing with web clipboard');
|
||||
}
|
||||
await writeWithFallback(text);
|
||||
logger.debug('Text successfully copied to clipboard');
|
||||
if (!suppressToast) {
|
||||
ToastActionCreators.createToast({type: 'success', children: i18n._(msg`Copied to clipboard`)});
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy text to clipboard:', error);
|
||||
if (!suppressToast) {
|
||||
ToastActionCreators.createToast({type: 'error', children: i18n._(msg`Failed to copy to clipboard`)});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
60
fluxer_app/src/actions/ThemeActionCreators.tsx
Normal file
60
fluxer_app/src/actions/ThemeActionCreators.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {ThemeAcceptModal} from '~/components/modals/ThemeAcceptModal';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {ThemeData} from '~/stores/ThemeStore';
|
||||
import ThemeStore from '~/stores/ThemeStore';
|
||||
|
||||
const logger = new Logger('Themes');
|
||||
|
||||
export const fetchWithCoalescing = async (themeId: string): Promise<ThemeData> => {
|
||||
return ThemeStore.fetchTheme(themeId);
|
||||
};
|
||||
|
||||
export const applyTheme = (css: string, i18n: I18n): void => {
|
||||
try {
|
||||
AccessibilityActionCreators.update({customThemeCss: css});
|
||||
ToastActionCreators.success(i18n._(msg`Imported theme has been applied.`));
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply theme:', error);
|
||||
ToastActionCreators.error(i18n._(msg`We couldn't apply this theme.`));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const openAcceptModal = (themeId: string | undefined, i18n: I18n): void => {
|
||||
if (!themeId) {
|
||||
ToastActionCreators.error(i18n._(msg`This theme link is missing data.`));
|
||||
return;
|
||||
}
|
||||
|
||||
void fetchWithCoalescing(themeId).catch(() => {});
|
||||
|
||||
ModalActionCreators.pushWithKey(
|
||||
modal(() => <ThemeAcceptModal themeId={themeId} />),
|
||||
`theme-accept-${themeId}`,
|
||||
);
|
||||
};
|
||||
37
fluxer_app/src/actions/ToastActionCreators.tsx
Normal file
37
fluxer_app/src/actions/ToastActionCreators.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 {ToastProps} from '~/components/uikit/Toast';
|
||||
import ToastStore from '~/stores/ToastStore';
|
||||
|
||||
export const createToast = (data: ToastProps): string => {
|
||||
return ToastStore.createToast(data);
|
||||
};
|
||||
|
||||
export const destroyToast = (id: string): void => {
|
||||
ToastStore.destroyToast(id);
|
||||
};
|
||||
|
||||
export const success = (message: string): string => {
|
||||
return ToastStore.success(message);
|
||||
};
|
||||
|
||||
export const error = (message: string): string => {
|
||||
return ToastStore.error(message);
|
||||
};
|
||||
28
fluxer_app/src/actions/TrustedDomainActionCreators.tsx
Normal file
28
fluxer_app/src/actions/TrustedDomainActionCreators.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 {Logger} from '~/lib/Logger';
|
||||
import TrustedDomainStore from '~/stores/TrustedDomainStore';
|
||||
|
||||
const logger = new Logger('TrustedDomain');
|
||||
|
||||
export const addTrustedDomain = (domain: string): void => {
|
||||
logger.debug(`Adding trusted domain: ${domain}`);
|
||||
TrustedDomainStore.addTrustedDomain(domain);
|
||||
};
|
||||
45
fluxer_app/src/actions/TypingActionCreators.tsx
Normal file
45
fluxer_app/src/actions/TypingActionCreators.tsx
Normal 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import TypingStore from '~/stores/TypingStore';
|
||||
|
||||
const logger = new Logger('Typing');
|
||||
|
||||
export const sendTyping = async (channelId: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug(`Sending typing indicator to channel ${channelId}`);
|
||||
await http.post({url: Endpoints.CHANNEL_TYPING(channelId)});
|
||||
logger.debug(`Successfully sent typing indicator to channel ${channelId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send typing indicator to channel ${channelId}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const startTyping = (channelId: string, userId: string): void => {
|
||||
logger.debug(`Starting typing indicator for user ${userId} in channel ${channelId}`);
|
||||
TypingStore.startTyping(channelId, userId);
|
||||
};
|
||||
|
||||
export const stopTyping = (channelId: string, userId: string): void => {
|
||||
logger.debug(`Stopping typing indicator for user ${userId} in channel ${channelId}`);
|
||||
TypingStore.stopTyping(channelId, userId);
|
||||
};
|
||||
39
fluxer_app/src/actions/UnsavedChangesActionCreators.tsx
Normal file
39
fluxer_app/src/actions/UnsavedChangesActionCreators.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 UnsavedChangesStore from '~/stores/UnsavedChangesStore';
|
||||
|
||||
export const setUnsavedChanges = (tabId: string, hasChanges: boolean): void => {
|
||||
UnsavedChangesStore.setUnsavedChanges(tabId, hasChanges);
|
||||
};
|
||||
|
||||
export const triggerFlashEffect = (tabId: string): void => {
|
||||
UnsavedChangesStore.triggerFlash(tabId);
|
||||
};
|
||||
|
||||
export const clearUnsavedChanges = (tabId: string): void => {
|
||||
UnsavedChangesStore.clearUnsavedChanges(tabId);
|
||||
};
|
||||
|
||||
export const setTabData = (
|
||||
tabId: string,
|
||||
data: {onReset?: () => void; onSave?: () => void; isSubmitting?: boolean},
|
||||
): void => {
|
||||
UnsavedChangesStore.setTabData(tabId, data);
|
||||
};
|
||||
456
fluxer_app/src/actions/UserActionCreators.tsx
Normal file
456
fluxer_app/src/actions/UserActionCreators.tsx
Normal file
@@ -0,0 +1,456 @@
|
||||
/*
|
||||
* 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 {PublicKeyCredentialCreationOptionsJSON, RegistrationResponseJSON} from '@simplewebauthn/browser';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {Message} from '~/records/MessageRecord';
|
||||
import type {UserPrivate} from '~/records/UserRecord';
|
||||
import MessageStore from '~/stores/MessageStore';
|
||||
import SudoStore from '~/stores/SudoStore';
|
||||
|
||||
const logger = new Logger('User');
|
||||
|
||||
interface FluxerTagAvailabilityResponse {
|
||||
taken: boolean;
|
||||
}
|
||||
|
||||
export interface WebAuthnCredential {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
interface PhoneTokenResponse {
|
||||
phone_token: string;
|
||||
}
|
||||
|
||||
interface EmailChangeStartResponse {
|
||||
ticket: string;
|
||||
require_original: boolean;
|
||||
original_proof?: string | null;
|
||||
original_code_expires_at?: string;
|
||||
resend_available_at?: string | null;
|
||||
}
|
||||
|
||||
interface EmailChangeVerifyOriginalResponse {
|
||||
original_proof: string;
|
||||
}
|
||||
|
||||
interface EmailChangeRequestNewResponse {
|
||||
ticket: string;
|
||||
new_email: string;
|
||||
new_code_expires_at: string;
|
||||
resend_available_at: string | null;
|
||||
}
|
||||
|
||||
interface EmailChangeVerifyNewResponse {
|
||||
email_token: string;
|
||||
}
|
||||
|
||||
export const update = async (
|
||||
user: Partial<UserPrivate> & {
|
||||
avatar?: string | null;
|
||||
new_password?: string;
|
||||
premium_badge_hidden?: boolean;
|
||||
premium_badge_masked?: boolean;
|
||||
premium_badge_timestamp_hidden?: boolean;
|
||||
premium_badge_sequence_hidden?: boolean;
|
||||
accent_color?: number | null;
|
||||
has_dismissed_premium_onboarding?: boolean;
|
||||
has_unread_gift_inventory?: boolean;
|
||||
email_token?: string;
|
||||
},
|
||||
): Promise<UserPrivate & {token?: string}> => {
|
||||
try {
|
||||
logger.debug('Updating current user profile');
|
||||
const response = await http.patch<UserPrivate & {token?: string}>(Endpoints.USER_ME, user);
|
||||
const userData = response.body;
|
||||
logger.debug('Successfully updated user profile');
|
||||
const updatedFields = Object.keys(user).filter((key) => key !== 'new_password');
|
||||
if (updatedFields.length > 0) {
|
||||
logger.debug(`Updated fields: ${updatedFields.join(', ')}`);
|
||||
}
|
||||
if (userData.token) {
|
||||
logger.debug('Authentication token was refreshed');
|
||||
}
|
||||
return userData;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update user profile:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkFluxerTagAvailability = async ({
|
||||
username,
|
||||
discriminator,
|
||||
}: {
|
||||
username: string;
|
||||
discriminator: string;
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
logger.debug(`Checking availability for FluxerTag ${username}#${discriminator}`);
|
||||
const response = await http.get<FluxerTagAvailabilityResponse>({
|
||||
url: Endpoints.USER_CHECK_TAG,
|
||||
query: {username, discriminator},
|
||||
});
|
||||
return response.body.taken;
|
||||
} catch (error) {
|
||||
logger.error('Failed to check FluxerTag availability:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendPhoneVerification = async (phone: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Sending phone verification code');
|
||||
await http.post({url: Endpoints.USER_PHONE_SEND_VERIFICATION, body: {phone}});
|
||||
logger.debug('Phone verification code sent');
|
||||
} catch (error) {
|
||||
logger.error('Failed to send phone verification code', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyPhone = async (phone: string, code: string): Promise<PhoneTokenResponse> => {
|
||||
try {
|
||||
logger.debug('Verifying phone code');
|
||||
const response = await http.post<PhoneTokenResponse>(Endpoints.USER_PHONE_VERIFY, {phone, code});
|
||||
logger.debug('Phone code verified');
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify phone code', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const addPhone = async (phoneToken: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Adding phone to account');
|
||||
await http.post({url: Endpoints.USER_PHONE, body: {phone_token: phoneToken}});
|
||||
logger.info('Phone added to account');
|
||||
} catch (error) {
|
||||
logger.error('Failed to add phone to account', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const startEmailChange = async (): Promise<EmailChangeStartResponse> => {
|
||||
try {
|
||||
logger.debug('Starting email change flow');
|
||||
const response = await http.post<EmailChangeStartResponse>({
|
||||
url: Endpoints.USER_EMAIL_CHANGE_START,
|
||||
body: {},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to start email change', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const resendEmailChangeOriginal = async (ticket: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Resending email change original code');
|
||||
await http.post({
|
||||
url: Endpoints.USER_EMAIL_CHANGE_RESEND_ORIGINAL,
|
||||
body: {ticket},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to resend original email code', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyEmailChangeOriginal = async (
|
||||
ticket: string,
|
||||
code: string,
|
||||
): Promise<EmailChangeVerifyOriginalResponse> => {
|
||||
try {
|
||||
logger.debug('Verifying original email code');
|
||||
const response = await http.post<EmailChangeVerifyOriginalResponse>({
|
||||
url: Endpoints.USER_EMAIL_CHANGE_VERIFY_ORIGINAL,
|
||||
body: {ticket, code},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify original email code', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestEmailChangeNew = async (
|
||||
ticket: string,
|
||||
newEmail: string,
|
||||
originalProof: string,
|
||||
): Promise<EmailChangeRequestNewResponse> => {
|
||||
try {
|
||||
logger.debug('Requesting new email code');
|
||||
const response = await http.post<EmailChangeRequestNewResponse>({
|
||||
url: Endpoints.USER_EMAIL_CHANGE_REQUEST_NEW,
|
||||
body: {ticket, new_email: newEmail, original_proof: originalProof},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to request new email code', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const resendEmailChangeNew = async (ticket: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Resending new email code');
|
||||
await http.post({
|
||||
url: Endpoints.USER_EMAIL_CHANGE_RESEND_NEW,
|
||||
body: {ticket},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to resend new email code', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyEmailChangeNew = async (
|
||||
ticket: string,
|
||||
code: string,
|
||||
originalProof: string,
|
||||
): Promise<EmailChangeVerifyNewResponse> => {
|
||||
try {
|
||||
logger.debug('Verifying new email code');
|
||||
const response = await http.post<EmailChangeVerifyNewResponse>({
|
||||
url: Endpoints.USER_EMAIL_CHANGE_VERIFY_NEW,
|
||||
body: {ticket, code, original_proof: originalProof},
|
||||
});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify new email code', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const removePhone = async (): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Removing phone from account');
|
||||
await http.delete({url: Endpoints.USER_PHONE, body: {}});
|
||||
logger.info('Phone removed from account');
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove phone from account', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const enableSmsMfa = async (): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Enabling SMS MFA');
|
||||
await http.post({url: Endpoints.USER_MFA_SMS_ENABLE, body: {}});
|
||||
logger.info('SMS MFA enabled');
|
||||
SudoStore.clearToken();
|
||||
} catch (error) {
|
||||
logger.error('Failed to enable SMS MFA', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const disableSmsMfa = async (): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Disabling SMS MFA');
|
||||
await http.post({url: Endpoints.USER_MFA_SMS_DISABLE, body: {}});
|
||||
logger.info('SMS MFA disabled');
|
||||
} catch (error) {
|
||||
logger.error('Failed to disable SMS MFA', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const listWebAuthnCredentials = async (): Promise<Array<WebAuthnCredential>> => {
|
||||
try {
|
||||
logger.debug('Fetching WebAuthn credentials');
|
||||
const response = await http.get<Array<WebAuthnCredential>>({url: Endpoints.USER_MFA_WEBAUTHN_CREDENTIALS});
|
||||
const data = response.body ?? [];
|
||||
logger.debug(`Found ${data.length} WebAuthn credentials`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch WebAuthn credentials', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getWebAuthnRegistrationOptions = async (): Promise<PublicKeyCredentialCreationOptionsJSON> => {
|
||||
try {
|
||||
logger.debug('Getting WebAuthn registration options');
|
||||
const response = await http.post<PublicKeyCredentialCreationOptionsJSON>({
|
||||
url: Endpoints.USER_MFA_WEBAUTHN_REGISTRATION_OPTIONS,
|
||||
body: {},
|
||||
});
|
||||
const data = response.body;
|
||||
logger.debug('WebAuthn registration options retrieved');
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get WebAuthn registration options', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const registerWebAuthnCredential = async (
|
||||
response: RegistrationResponseJSON,
|
||||
challenge: string,
|
||||
name: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Registering WebAuthn credential');
|
||||
await http.post({url: Endpoints.USER_MFA_WEBAUTHN_CREDENTIALS, body: {response, challenge, name}});
|
||||
logger.info('WebAuthn credential registered');
|
||||
SudoStore.clearToken();
|
||||
} catch (error) {
|
||||
logger.error('Failed to register WebAuthn credential', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const renameWebAuthnCredential = async (credentialId: string, name: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Renaming WebAuthn credential');
|
||||
await http.patch({url: Endpoints.USER_MFA_WEBAUTHN_CREDENTIAL(credentialId), body: {name}});
|
||||
logger.info('WebAuthn credential renamed');
|
||||
} catch (error) {
|
||||
logger.error('Failed to rename WebAuthn credential', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteWebAuthnCredential = async (credentialId: string): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Deleting WebAuthn credential');
|
||||
await http.delete({url: Endpoints.USER_MFA_WEBAUTHN_CREDENTIAL(credentialId), body: {}});
|
||||
logger.info('WebAuthn credential deleted');
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete WebAuthn credential', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const disableAccount = async (): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Disabling account');
|
||||
await http.post({url: Endpoints.USER_DISABLE, body: {}});
|
||||
logger.info('Account disabled');
|
||||
} catch (error) {
|
||||
logger.error('Failed to disable account', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteAccount = async (): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Deleting account');
|
||||
await http.post({url: Endpoints.USER_DELETE, body: {}});
|
||||
logger.info('Account scheduled for deletion');
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete account', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const bulkDeleteAllMessages = async (): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Requesting bulk deletion of all messages');
|
||||
await http.post({url: Endpoints.USER_BULK_DELETE_MESSAGES, body: {}});
|
||||
logger.info('Bulk message deletion queued');
|
||||
} catch (error) {
|
||||
logger.error('Failed to queue bulk message deletion', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cancelBulkDeleteAllMessages = async (): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Cancelling bulk deletion of all messages');
|
||||
await http.delete({url: Endpoints.USER_BULK_DELETE_MESSAGES, body: {}});
|
||||
logger.info('Bulk message deletion cancelled');
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel bulk message deletion', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const testBulkDeleteAllMessages = async (): Promise<void> => {
|
||||
try {
|
||||
logger.debug('Requesting test bulk deletion of all messages (15s delay)');
|
||||
await http.post({url: Endpoints.USER_BULK_DELETE_MESSAGES_TEST});
|
||||
logger.info('Test bulk message deletion queued (15s delay)');
|
||||
} catch (error) {
|
||||
logger.error('Failed to queue test bulk message deletion', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestDataHarvest = async (): Promise<{harvestId: string}> => {
|
||||
try {
|
||||
logger.debug('Requesting data harvest');
|
||||
const response = await http.post<{harvest_id: string}>({url: Endpoints.USER_HARVEST});
|
||||
logger.info('Data harvest request submitted', {harvestId: response.body.harvest_id});
|
||||
return {harvestId: response.body.harvest_id};
|
||||
} catch (error) {
|
||||
logger.error('Failed to request data harvest', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestHarvest = async (): Promise<any> => {
|
||||
try {
|
||||
logger.debug('Fetching latest harvest');
|
||||
const response = await http.get<any>({url: Endpoints.USER_HARVEST_LATEST});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch latest harvest', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getHarvestStatus = async (harvestId: string): Promise<any> => {
|
||||
try {
|
||||
logger.debug('Fetching harvest status', {harvestId});
|
||||
const response = await http.get<any>({url: Endpoints.USER_HARVEST_STATUS(harvestId)});
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch harvest status', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export type PreloadedDirectMessages = Record<string, Message>;
|
||||
|
||||
export const preloadDMMessages = async (channelIds: Array<string>): Promise<PreloadedDirectMessages> => {
|
||||
try {
|
||||
logger.debug('Preloading DM messages', {channelCount: channelIds.length});
|
||||
const response = await http.post<PreloadedDirectMessages>(Endpoints.USER_PRELOAD_MESSAGES, {
|
||||
channels: channelIds,
|
||||
});
|
||||
const preloadedData = response.body ?? {};
|
||||
|
||||
MessageStore.handleMessagePreload({messages: preloadedData});
|
||||
|
||||
return preloadedData;
|
||||
} catch (error) {
|
||||
logger.error('Failed to preload DM messages', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
251
fluxer_app/src/actions/UserGuildSettingsActionCreators.tsx
Normal file
251
fluxer_app/src/actions/UserGuildSettingsActionCreators.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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 {ME, MessageNotifications} from '~/Constants';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {UserGuildSettingsPartial} from '~/records/UserGuildSettingsRecord';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import type {ChannelOverride, GatewayGuildSettings} from '~/stores/UserGuildSettingsStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
|
||||
const logger = new Logger('UserGuildSettingsActionCreators');
|
||||
|
||||
const pendingUpdates: Map<string, NodeJS.Timeout> = new Map();
|
||||
const pendingPayloads: Map<string, UserGuildSettingsPartial> = new Map();
|
||||
|
||||
interface PersistenceOptions {
|
||||
persistImmediately?: boolean;
|
||||
}
|
||||
|
||||
const mergePayloads = (a: UserGuildSettingsPartial, b: UserGuildSettingsPartial): UserGuildSettingsPartial => {
|
||||
let merged: UserGuildSettingsPartial = {...a, ...b};
|
||||
|
||||
const aCO = a.channel_overrides;
|
||||
const bCO = b.channel_overrides;
|
||||
|
||||
if (aCO != null && bCO != null) {
|
||||
if (aCO !== null && bCO !== null) {
|
||||
merged = {...merged, channel_overrides: {...aCO, ...bCO}};
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const scheduleUpdate = (guildId: string | null, updates: UserGuildSettingsPartial, options?: PersistenceOptions) => {
|
||||
const key = guildId ?? ME;
|
||||
|
||||
const currentPending = pendingPayloads.get(key) ?? {};
|
||||
const mergedUpdates = mergePayloads(currentPending, updates);
|
||||
pendingPayloads.set(key, mergedUpdates);
|
||||
|
||||
const flush = async () => {
|
||||
pendingUpdates.delete(key);
|
||||
const payload = pendingPayloads.get(key);
|
||||
pendingPayloads.delete(key);
|
||||
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Persisting settings update for guild ${key}`, payload);
|
||||
const endpoint = guildId == null ? Endpoints.USER_GUILD_SETTINGS_ME : Endpoints.USER_GUILD_SETTINGS(guildId);
|
||||
|
||||
await http.patch({
|
||||
url: endpoint,
|
||||
body: payload,
|
||||
});
|
||||
|
||||
logger.debug(`Successfully updated settings for guild ${key}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update settings for guild ${key}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
if (options?.persistImmediately) {
|
||||
const pendingTimeout = pendingUpdates.get(key);
|
||||
if (pendingTimeout) {
|
||||
clearTimeout(pendingTimeout);
|
||||
pendingUpdates.delete(key);
|
||||
logger.debug(`Cancelled coalesced update for guild ${key} to flush immediately`);
|
||||
}
|
||||
void flush();
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = pendingUpdates.get(key);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
logger.debug(`Cleared pending update for guild ${key} (coalescing with new update)`);
|
||||
}
|
||||
|
||||
pendingUpdates.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
void flush();
|
||||
}, 3000),
|
||||
);
|
||||
|
||||
logger.debug(`Scheduled coalesced settings update for guild ${key} in 3 seconds`);
|
||||
};
|
||||
|
||||
export const updateGuildSettings = (
|
||||
guildId: string | null,
|
||||
updates: UserGuildSettingsPartial,
|
||||
options?: PersistenceOptions,
|
||||
): void => {
|
||||
UserGuildSettingsStore.getSettings(guildId);
|
||||
UserGuildSettingsStore.updateGuildSettings(guildId, updates as Partial<GatewayGuildSettings>);
|
||||
scheduleUpdate(guildId, updates, options);
|
||||
};
|
||||
|
||||
export const toggleHideMutedChannels = (guildId: string | null): void => {
|
||||
const currentSettings = UserGuildSettingsStore.getSettings(guildId);
|
||||
const newValue = !currentSettings.hide_muted_channels;
|
||||
updateGuildSettings(guildId, {hide_muted_channels: newValue});
|
||||
};
|
||||
|
||||
export const updateChannelOverride = (
|
||||
guildId: string | null,
|
||||
channelId: string,
|
||||
override: Partial<ChannelOverride> | null,
|
||||
options?: PersistenceOptions,
|
||||
): void => {
|
||||
const currentSettings = UserGuildSettingsStore.getSettings(guildId);
|
||||
const currentOverride = UserGuildSettingsStore.getChannelOverride(guildId, channelId);
|
||||
|
||||
let newOverride: ChannelOverride | null = null;
|
||||
if (override != null) {
|
||||
newOverride = {
|
||||
channel_id: channelId,
|
||||
collapsed: false,
|
||||
message_notifications: MessageNotifications.INHERIT,
|
||||
muted: false,
|
||||
mute_config: null,
|
||||
...currentOverride,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
const newChannelOverrides = {...(currentSettings.channel_overrides ?? {})};
|
||||
|
||||
if (newOverride == null) {
|
||||
delete newChannelOverrides[channelId];
|
||||
} else {
|
||||
newChannelOverrides[channelId] = newOverride;
|
||||
}
|
||||
|
||||
const hasOverrides = Object.keys(newChannelOverrides).length > 0;
|
||||
|
||||
UserGuildSettingsStore.updateGuildSettings(guildId, {
|
||||
channel_overrides: hasOverrides ? newChannelOverrides : {},
|
||||
} as Partial<GatewayGuildSettings>);
|
||||
|
||||
scheduleUpdate(
|
||||
guildId,
|
||||
{
|
||||
channel_overrides: hasOverrides ? newChannelOverrides : null,
|
||||
},
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
export const toggleChannelCollapsed = (guildId: string | null, channelId: string): void => {
|
||||
const isCollapsed = UserGuildSettingsStore.isChannelCollapsed(guildId, channelId);
|
||||
updateChannelOverride(guildId, channelId, {collapsed: !isCollapsed});
|
||||
};
|
||||
|
||||
export const updateMessageNotifications = (
|
||||
guildId: string | null,
|
||||
level: number,
|
||||
channelId?: string,
|
||||
options?: PersistenceOptions,
|
||||
): void => {
|
||||
if (channelId) {
|
||||
updateChannelOverride(guildId, channelId, {message_notifications: level}, options);
|
||||
} else {
|
||||
updateGuildSettings(guildId, {message_notifications: level}, options);
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleChannelMuted = (guildId: string | null, channelId: string, options?: PersistenceOptions): void => {
|
||||
const isMuted = UserGuildSettingsStore.isChannelMuted(guildId, channelId);
|
||||
updateChannelOverride(guildId, channelId, {muted: !isMuted}, options);
|
||||
};
|
||||
|
||||
export const toggleAllCategoriesCollapsed = (guildId: string | null, categoryIds: Array<string>): void => {
|
||||
if (categoryIds.length === 0) return;
|
||||
|
||||
const allCollapsed = categoryIds.every((categoryId) =>
|
||||
UserGuildSettingsStore.isChannelCollapsed(guildId, categoryId),
|
||||
);
|
||||
|
||||
const newCollapsedState = !allCollapsed;
|
||||
|
||||
if (guildId) {
|
||||
for (const categoryId of categoryIds) {
|
||||
UserGuildSettingsStore.updateChannelOverride(guildId, categoryId, {collapsed: newCollapsedState});
|
||||
}
|
||||
}
|
||||
|
||||
scheduleUpdate(guildId, {
|
||||
channel_overrides: (() => {
|
||||
const currentSettings = UserGuildSettingsStore.getSettings(guildId);
|
||||
|
||||
const newChannelOverrides = {...(currentSettings.channel_overrides ?? {})};
|
||||
for (const categoryId of categoryIds) {
|
||||
const currentOverride = UserGuildSettingsStore.getChannelOverride(guildId, categoryId);
|
||||
if (newCollapsedState) {
|
||||
newChannelOverrides[categoryId] = {
|
||||
channel_id: categoryId,
|
||||
collapsed: true,
|
||||
message_notifications: currentOverride?.message_notifications ?? MessageNotifications.INHERIT,
|
||||
muted: currentOverride?.muted ?? false,
|
||||
mute_config: currentOverride?.mute_config ?? null,
|
||||
};
|
||||
} else {
|
||||
delete newChannelOverrides[categoryId];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(newChannelOverrides).length > 0 ? newChannelOverrides : null;
|
||||
})(),
|
||||
});
|
||||
};
|
||||
|
||||
export const repairGuildNotificationInheritance = (): void => {
|
||||
const guildIds = UserGuildSettingsStore.getGuildIds();
|
||||
if (guildIds.length === 0) return;
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
const guild = GuildStore.getGuild(guildId);
|
||||
if (!guild) continue;
|
||||
|
||||
const storedLevel = UserGuildSettingsStore.getStoredGuildMessageNotifications(guildId);
|
||||
if (storedLevel === MessageNotifications.INHERIT || storedLevel === MessageNotifications.NULL) continue;
|
||||
|
||||
const guildDefault = guild.defaultMessageNotifications ?? MessageNotifications.ALL_MESSAGES;
|
||||
if (storedLevel !== guildDefault) continue;
|
||||
|
||||
updateGuildSettings(guildId, {message_notifications: MessageNotifications.INHERIT});
|
||||
}
|
||||
};
|
||||
34
fluxer_app/src/actions/UserNoteActionCreators.tsx
Normal file
34
fluxer_app/src/actions/UserNoteActionCreators.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
|
||||
const logger = new Logger('Notes');
|
||||
|
||||
export const update = async (userId: string, note: string | null): Promise<void> => {
|
||||
try {
|
||||
await http.put({url: Endpoints.USER_NOTE(userId), body: {note}});
|
||||
logger.debug(`Updated note for user ${userId} to ${note ? 'new value' : 'null'}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update note for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
138
fluxer_app/src/actions/UserProfileActionCreators.tsx
Normal file
138
fluxer_app/src/actions/UserProfileActionCreators.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ME} from '~/Constants';
|
||||
import {UserProfileModal} from '~/components/modals/UserProfileModal';
|
||||
import {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {type Profile, ProfileRecord} from '~/records/ProfileRecord';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
|
||||
import UserProfileStore from '~/stores/UserProfileStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
const logger = new Logger('UserProfiles');
|
||||
|
||||
const pendingRequests: Map<string, Promise<ProfileRecord>> = new Map();
|
||||
|
||||
function buildKey(userId: string, guildId?: string): string {
|
||||
return `${userId}:${guildId ?? ME}`;
|
||||
}
|
||||
|
||||
export const fetch = async (userId: string, guildId?: string, force = false): Promise<ProfileRecord> => {
|
||||
try {
|
||||
const key = buildKey(userId, guildId);
|
||||
|
||||
if (!force) {
|
||||
const existingProfile = UserProfileStore.getProfile(userId, guildId);
|
||||
if (existingProfile) {
|
||||
logger.debug(`Using cached profile for user ${userId}${guildId ? ` in guild ${guildId}` : ''}`);
|
||||
return existingProfile;
|
||||
}
|
||||
const existingRequest = pendingRequests.get(key);
|
||||
if (existingRequest) {
|
||||
logger.debug(`Reusing in-flight profile request for user ${userId}${guildId ? ` in guild ${guildId}` : ''}`);
|
||||
return existingRequest;
|
||||
}
|
||||
} else {
|
||||
const existingRequest = pendingRequests.get(key);
|
||||
if (existingRequest) {
|
||||
logger.debug(
|
||||
`Force refresh requested but request already in-flight for user ${userId}${guildId ? ` in guild ${guildId}` : ''}`,
|
||||
);
|
||||
return existingRequest;
|
||||
} else {
|
||||
logger.debug(`Force refreshing profile for user ${userId}${guildId ? ` in guild ${guildId}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`Fetching profile for user ${userId}${guildId ? ` in guild ${guildId}` : ''}`);
|
||||
const promise = (async () => {
|
||||
const response = await http.get<Profile>({
|
||||
url: Endpoints.USER_PROFILE(userId),
|
||||
query: {
|
||||
...(guildId ? {guild_id: guildId} : {}),
|
||||
with_mutual_friends: true,
|
||||
},
|
||||
});
|
||||
const profile = response.body;
|
||||
const profileRecord = new ProfileRecord(profile, guildId);
|
||||
UserStore.handleUserUpdate(profile.user);
|
||||
UserProfileStore.handleProfileCreate(profileRecord);
|
||||
logger.debug(`Fetched and cached profile for user ${userId}${guildId ? ` in guild ${guildId}` : ''}`);
|
||||
return profileRecord;
|
||||
})();
|
||||
|
||||
pendingRequests.set(key, promise);
|
||||
|
||||
try {
|
||||
const res = await promise;
|
||||
pendingRequests.delete(key);
|
||||
return res;
|
||||
} catch (e) {
|
||||
pendingRequests.delete(key);
|
||||
throw e;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch profile for user ${userId}${guildId ? ` in guild ${guildId}` : ''}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const invalidate = (userId: string, guildId?: string): void => {
|
||||
const scope = guildId ? ` in guild ${guildId}` : '';
|
||||
logger.debug(`Invalidating cached profile for user ${userId}${scope}`);
|
||||
try {
|
||||
UserProfileStore.handleProfileInvalidate(userId, guildId);
|
||||
pendingRequests.delete(buildKey(userId, guildId));
|
||||
} catch (err) {
|
||||
logger.warn('Failed to invalidate cached profile:', err);
|
||||
}
|
||||
};
|
||||
|
||||
export const clearCurrentUserProfiles = (): void => {
|
||||
logger.debug('Clearing cached profiles for current user');
|
||||
try {
|
||||
UserProfileStore.handleProfilesClear();
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
if (currentUserId) {
|
||||
for (const key of Array.from(pendingRequests.keys())) {
|
||||
if (key.startsWith(`${currentUserId}:`)) {
|
||||
pendingRequests.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clear current user profiles:', err);
|
||||
}
|
||||
};
|
||||
|
||||
export const openUserProfile = (userId: string, guildId?: string, autoFocusNote?: boolean): void => {
|
||||
if (MobileLayoutStore.enabled) {
|
||||
UserProfileMobileStore.open(userId, guildId, autoFocusNote);
|
||||
} else {
|
||||
ModalActionCreators.push(
|
||||
modal(() => <UserProfileModal userId={userId} guildId={guildId} autoFocusNote={autoFocusNote} />),
|
||||
);
|
||||
}
|
||||
};
|
||||
25
fluxer_app/src/actions/UserSettingsActionCreators.tsx
Normal file
25
fluxer_app/src/actions/UserSettingsActionCreators.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 {UserSettings} from '~/stores/UserSettingsStore';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
|
||||
export const update = async (settings: Partial<UserSettings>): Promise<void> => {
|
||||
await UserSettingsStore.saveSettings(settings);
|
||||
};
|
||||
33
fluxer_app/src/actions/VoiceCallLayoutActionCreators.tsx
Normal file
33
fluxer_app/src/actions/VoiceCallLayoutActionCreators.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {LayoutMode} from '~/stores/VoiceCallLayoutStore';
|
||||
import VoiceCallLayoutStore from '~/stores/VoiceCallLayoutStore';
|
||||
|
||||
export const setLayoutMode = (mode: LayoutMode): void => {
|
||||
VoiceCallLayoutStore.setLayoutMode(mode);
|
||||
};
|
||||
|
||||
export const setPinnedParticipant = (identity: string | null): void => {
|
||||
VoiceCallLayoutStore.setPinnedParticipant(identity);
|
||||
};
|
||||
|
||||
export const markUserOverride = (): void => {
|
||||
VoiceCallLayoutStore.markUserOverride();
|
||||
};
|
||||
43
fluxer_app/src/actions/VoiceSettingsActionCreators.tsx
Normal file
43
fluxer_app/src/actions/VoiceSettingsActionCreators.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 VoiceSettingsStore from '~/stores/VoiceSettingsStore';
|
||||
|
||||
export const update = (
|
||||
settings: Partial<{
|
||||
inputDeviceId: string;
|
||||
outputDeviceId: string;
|
||||
videoDeviceId: string;
|
||||
inputVolume: number;
|
||||
outputVolume: number;
|
||||
echoCancellation: boolean;
|
||||
noiseSuppression: boolean;
|
||||
autoGainControl: boolean;
|
||||
cameraResolution: 'low' | 'medium' | 'high';
|
||||
screenshareResolution: 'low' | 'medium' | 'high' | 'ultra' | '4k';
|
||||
videoFrameRate: number;
|
||||
backgroundImageId: string;
|
||||
backgroundImages: Array<{id: string; createdAt: number}>;
|
||||
showGridView: boolean;
|
||||
showMyOwnCamera: boolean;
|
||||
showNonVideoParticipants: boolean;
|
||||
}>,
|
||||
): void => {
|
||||
VoiceSettingsStore.updateSettings(settings);
|
||||
};
|
||||
442
fluxer_app/src/actions/VoiceStateActionCreators.tsx
Normal file
442
fluxer_app/src/actions/VoiceStateActionCreators.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
/*
|
||||
* 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 {LocalTrackPublication, RemoteParticipant, Room} from 'livekit-client';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as SoundActionCreators from '~/actions/SoundActionCreators';
|
||||
import {MicrophonePermissionDeniedModal} from '~/components/alerts/MicrophonePermissionDeniedModal';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import ConnectionStore from '~/stores/ConnectionStore';
|
||||
import LocalVoiceStateStore from '~/stores/LocalVoiceStateStore';
|
||||
import MediaPermissionStore from '~/stores/MediaPermissionStore';
|
||||
import ParticipantVolumeStore from '~/stores/ParticipantVolumeStore';
|
||||
import VoiceSettingsStore from '~/stores/VoiceSettingsStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
import VoiceDevicePermissionStore from '~/stores/voice/VoiceDevicePermissionStore';
|
||||
import {ensureNativePermission} from '~/utils/NativePermissions';
|
||||
import {isDesktop} from '~/utils/NativeUtils';
|
||||
import {SoundType} from '~/utils/SoundUtils';
|
||||
|
||||
const logger = new Logger('VoiceStateActionCreators');
|
||||
|
||||
export const toggleSelfDeaf = async (_guildId: string | null = null): Promise<void> => {
|
||||
const connectedGuildId = MediaEngineStore.guildId;
|
||||
const connectedChannelId = MediaEngineStore.channelId;
|
||||
|
||||
const currentDeaf = LocalVoiceStateStore.getSelfDeaf();
|
||||
const willUndeafen = currentDeaf;
|
||||
const willDeafen = !currentDeaf;
|
||||
|
||||
logger.info('toggleSelfDeaf', {
|
||||
currentDeaf,
|
||||
willUndeafen,
|
||||
willDeafen,
|
||||
connectedGuildId,
|
||||
connectedChannelId,
|
||||
micPermissionState: MediaPermissionStore.getMicrophonePermissionState(),
|
||||
});
|
||||
|
||||
if (willUndeafen) {
|
||||
const hasMicPermission = MediaPermissionStore.isMicrophoneGranted();
|
||||
|
||||
if (!hasMicPermission) {
|
||||
logger.info('Undeafening without mic permission, keeping user muted');
|
||||
LocalVoiceStateStore.updateSelfDeaf(false);
|
||||
LocalVoiceStateStore.updateSelfMute(true);
|
||||
|
||||
SoundActionCreators.playSound(SoundType.Undeaf);
|
||||
|
||||
MediaEngineStore.syncLocalVoiceStateWithServer({
|
||||
self_mute: true,
|
||||
self_deaf: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
LocalVoiceStateStore.toggleSelfDeaf();
|
||||
const newDeafState = LocalVoiceStateStore.getSelfDeaf();
|
||||
const newMuteState = LocalVoiceStateStore.getSelfMute();
|
||||
|
||||
logger.debug('Voice state updated', {newDeafState, newMuteState});
|
||||
|
||||
const room = MediaEngineStore.room;
|
||||
if (room?.localParticipant) {
|
||||
room.localParticipant.audioTrackPublications.forEach((publication: LocalTrackPublication) => {
|
||||
const track = publication.track;
|
||||
if (!track) return;
|
||||
const operation = newMuteState ? track.mute() : track.unmute();
|
||||
operation.catch((error) =>
|
||||
logger.error(newMuteState ? 'Failed to mute local track' : 'Failed to unmute local track', {error}),
|
||||
);
|
||||
});
|
||||
|
||||
room.remoteParticipants.forEach((participant: RemoteParticipant) => {
|
||||
ParticipantVolumeStore.applySettingsToParticipant(participant, newDeafState);
|
||||
});
|
||||
|
||||
logger.debug('Applied mute/deafen state to LiveKit tracks immediately', {
|
||||
newDeafState,
|
||||
newMuteState,
|
||||
localTrackCount: room.localParticipant.audioTrackPublications.size,
|
||||
remoteParticipantCount: room.remoteParticipants.size,
|
||||
});
|
||||
}
|
||||
|
||||
if (newDeafState) {
|
||||
SoundActionCreators.playSound(SoundType.Deaf);
|
||||
} else {
|
||||
SoundActionCreators.playSound(SoundType.Undeaf);
|
||||
}
|
||||
|
||||
MediaEngineStore.syncLocalVoiceStateWithServer({
|
||||
self_mute: newMuteState,
|
||||
self_deaf: newDeafState,
|
||||
});
|
||||
};
|
||||
|
||||
const showMicrophonePermissionDeniedModal = () => {
|
||||
ModalActionCreators.push(modal(() => <MicrophonePermissionDeniedModal />));
|
||||
};
|
||||
|
||||
const requestMicrophoneInVoiceChannel = async (room: Room, channelId: string | null): Promise<boolean> => {
|
||||
const channel = ChannelStore.getChannel(channelId ?? '');
|
||||
const audioBitrate = channel?.bitrate ? channel.bitrate * 1000 : undefined;
|
||||
|
||||
try {
|
||||
if (isDesktop()) {
|
||||
const nativeResult = await ensureNativePermission('microphone');
|
||||
if (nativeResult === 'denied') {
|
||||
logger.warn('Microphone permission denied via native API before LiveKit request');
|
||||
throw Object.assign(new Error('Native microphone permission denied'), {
|
||||
name: 'NotAllowedError',
|
||||
});
|
||||
}
|
||||
if (nativeResult === 'granted') {
|
||||
MediaPermissionStore.updateMicrophonePermissionGranted();
|
||||
}
|
||||
}
|
||||
|
||||
await VoiceDevicePermissionStore.ensureDevices({requestPermissions: false}).catch(() => {});
|
||||
|
||||
let inputDeviceId = VoiceSettingsStore.getInputDeviceId();
|
||||
const deviceState = VoiceDevicePermissionStore.getState();
|
||||
|
||||
const deviceExists =
|
||||
inputDeviceId === 'default' || deviceState.inputDevices.some((device) => device.deviceId === inputDeviceId);
|
||||
|
||||
if (!deviceExists && deviceState.inputDevices.length > 0) {
|
||||
inputDeviceId = 'default';
|
||||
}
|
||||
|
||||
const micSettings = {
|
||||
deviceId: inputDeviceId,
|
||||
echoCancellation: VoiceSettingsStore.getEchoCancellation(),
|
||||
noiseSuppression: VoiceSettingsStore.getNoiseSuppression(),
|
||||
autoGainControl: VoiceSettingsStore.getAutoGainControl(),
|
||||
...(audioBitrate && {audioBitrate}),
|
||||
};
|
||||
|
||||
logger.debug('Requesting microphone permission via LiveKit');
|
||||
await room.localParticipant.setMicrophoneEnabled(true, micSettings);
|
||||
|
||||
MediaPermissionStore.updateMicrophonePermissionGranted();
|
||||
logger.info('Microphone permission granted via LiveKit');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to enable microphone', {
|
||||
error,
|
||||
errorName: error instanceof Error ? error.name : 'unknown',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError')) {
|
||||
MediaPermissionStore.markMicrophoneExplicitlyDenied();
|
||||
showMicrophonePermissionDeniedModal();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const requestMicrophoneDirectly = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (isDesktop()) {
|
||||
const nativeResult = await ensureNativePermission('microphone');
|
||||
if (nativeResult === 'granted') {
|
||||
MediaPermissionStore.updateMicrophonePermissionGranted();
|
||||
logger.info('Microphone permission granted via native API');
|
||||
return true;
|
||||
}
|
||||
if (nativeResult === 'denied') {
|
||||
logger.warn('Microphone permission denied via native API');
|
||||
MediaPermissionStore.markMicrophoneExplicitlyDenied();
|
||||
showMicrophonePermissionDeniedModal();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Requesting microphone permission via getUserMedia');
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
|
||||
MediaPermissionStore.updateMicrophonePermissionGranted();
|
||||
logger.info('Microphone permission granted via getUserMedia');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get microphone permission', {
|
||||
error,
|
||||
errorName: error instanceof Error ? error.name : 'unknown',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof Error && (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError')) {
|
||||
MediaPermissionStore.markMicrophoneExplicitlyDenied();
|
||||
showMicrophonePermissionDeniedModal();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleSelfMute = async (_guildId: string | null = null): Promise<void> => {
|
||||
const room = MediaEngineStore.room;
|
||||
const connectedChannelId = MediaEngineStore.channelId;
|
||||
|
||||
const currentMute = LocalVoiceStateStore.getSelfMute();
|
||||
const currentDeaf = LocalVoiceStateStore.getSelfDeaf();
|
||||
|
||||
const willUndeafen = currentDeaf;
|
||||
const willUnmute = currentMute;
|
||||
const willMute = !currentMute && !currentDeaf;
|
||||
const willBeUnmuted = willUnmute || willUndeafen;
|
||||
|
||||
logger.info('toggleSelfMute', {
|
||||
currentMute,
|
||||
currentDeaf,
|
||||
willUnmute,
|
||||
willUndeafen,
|
||||
willMute,
|
||||
willBeUnmuted,
|
||||
hasRoom: !!room,
|
||||
micPermissionState: MediaPermissionStore.getMicrophonePermissionState(),
|
||||
});
|
||||
|
||||
if (willBeUnmuted) {
|
||||
if (MediaPermissionStore.isMicrophoneExplicitlyDenied()) {
|
||||
logger.warn('Microphone permission explicitly denied, cannot unmute');
|
||||
showMicrophonePermissionDeniedModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!MediaPermissionStore.isMicrophoneGranted()) {
|
||||
logger.info('Microphone permission not granted, requesting permission');
|
||||
|
||||
const permissionGranted = room?.localParticipant
|
||||
? await requestMicrophoneInVoiceChannel(room, connectedChannelId)
|
||||
: await requestMicrophoneDirectly();
|
||||
|
||||
if (!permissionGranted) {
|
||||
logger.warn('Microphone permission request failed, staying muted');
|
||||
LocalVoiceStateStore.updateSelfMute(true);
|
||||
if (room) {
|
||||
MediaEngineStore.syncLocalVoiceStateWithServer({self_mute: true});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMuteAfterPermission = LocalVoiceStateStore.getSelfMute();
|
||||
if (!currentMuteAfterPermission) {
|
||||
logger.debug('Already unmuted after permission grant, skipping toggle');
|
||||
SoundActionCreators.playSound(SoundType.Unmute);
|
||||
if (room) {
|
||||
MediaEngineStore.syncLocalVoiceStateWithServer({
|
||||
self_mute: false,
|
||||
self_deaf: LocalVoiceStateStore.getSelfDeaf(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocalVoiceStateStore.toggleSelfMute();
|
||||
const newMute = LocalVoiceStateStore.getSelfMute();
|
||||
const newDeaf = LocalVoiceStateStore.getSelfDeaf();
|
||||
|
||||
logger.debug('Voice state updated', {newMute, newDeaf});
|
||||
|
||||
if (room?.localParticipant) {
|
||||
room.localParticipant.audioTrackPublications.forEach((publication: LocalTrackPublication) => {
|
||||
const track = publication.track;
|
||||
if (!track) return;
|
||||
const operation = newMute ? track.mute() : track.unmute();
|
||||
operation.catch((error) =>
|
||||
logger.error(newMute ? 'Failed to mute local track' : 'Failed to unmute local track', {error}),
|
||||
);
|
||||
});
|
||||
|
||||
logger.debug('Applied mute state to LiveKit tracks immediately', {
|
||||
newMute,
|
||||
newDeaf,
|
||||
localTrackCount: room.localParticipant.audioTrackPublications.size,
|
||||
});
|
||||
}
|
||||
|
||||
if (!newMute) {
|
||||
SoundActionCreators.playSound(SoundType.Unmute);
|
||||
} else {
|
||||
SoundActionCreators.playSound(SoundType.Mute);
|
||||
}
|
||||
|
||||
if (room) {
|
||||
MediaEngineStore.syncLocalVoiceStateWithServer({
|
||||
self_mute: newMute,
|
||||
self_deaf: newDeaf,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type VoiceStateProperty = 'self_mute' | 'self_deaf' | 'self_video' | 'self_stream';
|
||||
|
||||
const updateConnectionProperty = async (
|
||||
connectionId: string,
|
||||
property: VoiceStateProperty,
|
||||
value: boolean,
|
||||
): Promise<void> => {
|
||||
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
|
||||
if (!voiceState) return;
|
||||
|
||||
const socket = ConnectionStore.socket;
|
||||
if (!socket) return;
|
||||
|
||||
socket.updateVoiceState({
|
||||
guild_id: voiceState.guild_id,
|
||||
channel_id: voiceState.channel_id,
|
||||
connection_id: connectionId,
|
||||
self_mute: property === 'self_mute' ? value : voiceState.self_mute,
|
||||
self_deaf: property === 'self_deaf' ? value : voiceState.self_deaf,
|
||||
self_video: property === 'self_video' ? value : voiceState.self_video,
|
||||
self_stream: property === 'self_stream' ? value : voiceState.self_stream,
|
||||
});
|
||||
};
|
||||
|
||||
const updateConnectionsProperty = async (
|
||||
connectionIds: Array<string>,
|
||||
property: VoiceStateProperty,
|
||||
value: boolean,
|
||||
): Promise<void> => {
|
||||
const socket = ConnectionStore.socket;
|
||||
if (!socket) return;
|
||||
|
||||
for (const connectionId of connectionIds) {
|
||||
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
|
||||
if (!voiceState) continue;
|
||||
|
||||
socket.updateVoiceState({
|
||||
guild_id: voiceState.guild_id,
|
||||
channel_id: voiceState.channel_id,
|
||||
connection_id: connectionId,
|
||||
self_mute: property === 'self_mute' ? value : voiceState.self_mute,
|
||||
self_deaf: property === 'self_deaf' ? value : voiceState.self_deaf,
|
||||
self_video: property === 'self_video' ? value : voiceState.self_video,
|
||||
self_stream: property === 'self_stream' ? value : voiceState.self_stream,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleSelfMuteForConnection = async (connectionId: string): Promise<void> => {
|
||||
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
|
||||
if (!voiceState) return;
|
||||
const target = !voiceState.self_mute;
|
||||
await updateConnectionProperty(connectionId, 'self_mute', target);
|
||||
if (target) SoundActionCreators.playSound(SoundType.Mute);
|
||||
else SoundActionCreators.playSound(SoundType.Unmute);
|
||||
};
|
||||
|
||||
export const toggleSelfDeafenForConnection = async (connectionId: string): Promise<void> => {
|
||||
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
|
||||
if (!voiceState) return;
|
||||
const target = !voiceState.self_deaf;
|
||||
await updateConnectionProperty(connectionId, 'self_deaf', target);
|
||||
if (target) SoundActionCreators.playSound(SoundType.Deaf);
|
||||
else SoundActionCreators.playSound(SoundType.Undeaf);
|
||||
};
|
||||
|
||||
export const turnOffCameraForConnection = async (connectionId: string): Promise<void> => {
|
||||
await updateConnectionProperty(connectionId, 'self_video', false);
|
||||
};
|
||||
|
||||
export const turnOffStreamForConnection = async (connectionId: string): Promise<void> => {
|
||||
await updateConnectionProperty(connectionId, 'self_stream', false);
|
||||
};
|
||||
|
||||
export const bulkMuteConnections = async (connectionIds: Array<string>, mute: boolean = true): Promise<void> => {
|
||||
await updateConnectionsProperty(connectionIds, 'self_mute', mute);
|
||||
};
|
||||
|
||||
export const bulkDeafenConnections = async (connectionIds: Array<string>, deafen: boolean = true): Promise<void> => {
|
||||
await updateConnectionsProperty(connectionIds, 'self_deaf', deafen);
|
||||
};
|
||||
|
||||
export const bulkTurnOffCameras = async (connectionIds: Array<string>): Promise<void> => {
|
||||
await updateConnectionsProperty(connectionIds, 'self_video', false);
|
||||
};
|
||||
|
||||
export const bulkDisconnect = async (connectionIds: Array<string>): Promise<void> => {
|
||||
const socket = ConnectionStore.socket;
|
||||
if (!socket) return;
|
||||
|
||||
for (const connectionId of connectionIds) {
|
||||
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
|
||||
if (!voiceState) continue;
|
||||
|
||||
socket.updateVoiceState({
|
||||
guild_id: voiceState.guild_id,
|
||||
channel_id: null,
|
||||
connection_id: connectionId,
|
||||
self_mute: true,
|
||||
self_deaf: true,
|
||||
self_video: false,
|
||||
self_stream: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const bulkMoveConnections = async (connectionIds: Array<string>, targetChannelId: string): Promise<void> => {
|
||||
const socket = ConnectionStore.socket;
|
||||
if (!socket) return;
|
||||
|
||||
for (const connectionId of connectionIds) {
|
||||
const voiceState = MediaEngineStore.getVoiceStateByConnectionId(connectionId);
|
||||
if (!voiceState) continue;
|
||||
|
||||
socket.updateVoiceState({
|
||||
guild_id: voiceState.guild_id,
|
||||
channel_id: targetChannelId,
|
||||
connection_id: connectionId,
|
||||
self_mute: voiceState.self_mute,
|
||||
self_deaf: voiceState.self_deaf,
|
||||
self_video: voiceState.self_video,
|
||||
self_stream: voiceState.self_stream,
|
||||
});
|
||||
}
|
||||
};
|
||||
156
fluxer_app/src/actions/WebhookActionCreators.tsx
Normal file
156
fluxer_app/src/actions/WebhookActionCreators.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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 {Endpoints} from '~/Endpoints';
|
||||
import http from '~/lib/HttpClient';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import type {Webhook} from '~/records/WebhookRecord';
|
||||
import WebhookStore from '~/stores/WebhookStore';
|
||||
|
||||
const logger = new Logger('WebhookActionCreators');
|
||||
|
||||
export interface CreateWebhookParams {
|
||||
channelId: string;
|
||||
name: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateWebhookParams {
|
||||
webhookId: string;
|
||||
name?: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export const fetchGuildWebhooks = async (guildId: string): Promise<Array<Webhook>> => {
|
||||
WebhookStore.handleGuildWebhooksFetchPending(guildId);
|
||||
|
||||
try {
|
||||
const response = await http.get<Array<Webhook>>(Endpoints.GUILD_WEBHOOKS(guildId));
|
||||
const data = response.body;
|
||||
|
||||
WebhookStore.handleGuildWebhooksFetchSuccess(guildId, data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch webhooks for guild ${guildId}:`, error);
|
||||
WebhookStore.handleGuildWebhooksFetchError(guildId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchChannelWebhooks = async ({
|
||||
guildId,
|
||||
channelId,
|
||||
}: {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
}): Promise<Array<Webhook>> => {
|
||||
WebhookStore.handleChannelWebhooksFetchPending(channelId);
|
||||
|
||||
try {
|
||||
const response = await http.get<Array<Webhook>>(Endpoints.CHANNEL_WEBHOOKS(channelId));
|
||||
const data = response.body;
|
||||
|
||||
WebhookStore.handleChannelWebhooksFetchSuccess(channelId, guildId, data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch webhooks for channel ${channelId}:`, error);
|
||||
WebhookStore.handleChannelWebhooksFetchError(channelId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebhook = async ({channelId, name, avatar}: CreateWebhookParams): Promise<Webhook> => {
|
||||
try {
|
||||
const response = await http.post<Webhook>(Endpoints.CHANNEL_WEBHOOKS(channelId), {name, avatar: avatar ?? null});
|
||||
const data = response.body;
|
||||
|
||||
WebhookStore.handleWebhookCreate(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create webhook for channel ${channelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteWebhook = async (webhookId: string): Promise<void> => {
|
||||
const existing = WebhookStore.getWebhook(webhookId);
|
||||
|
||||
try {
|
||||
await http.delete({
|
||||
url: Endpoints.WEBHOOK(webhookId),
|
||||
});
|
||||
|
||||
WebhookStore.handleWebhookDelete(webhookId, existing?.channelId ?? null, existing?.guildId ?? null);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete webhook ${webhookId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const moveWebhook = async (webhookId: string, newChannelId: string): Promise<Webhook> => {
|
||||
const existing = WebhookStore.getWebhook(webhookId);
|
||||
if (!existing) {
|
||||
throw new Error(`Webhook ${webhookId} not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await http.patch<Webhook>(Endpoints.WEBHOOK(webhookId), {channel_id: newChannelId});
|
||||
const data = response.body;
|
||||
|
||||
WebhookStore.handleWebhooksUpdate(existing.guildId, existing.channelId);
|
||||
WebhookStore.handleWebhookCreate(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to move webhook ${webhookId} to channel ${newChannelId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const updateWebhook = async ({webhookId, name, avatar}: UpdateWebhookParams): Promise<Webhook> => {
|
||||
try {
|
||||
const response = await http.patch<Webhook>(Endpoints.WEBHOOK(webhookId), {name, avatar: avatar ?? null});
|
||||
const data = response.body;
|
||||
|
||||
WebhookStore.handleWebhookCreate(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update webhook ${webhookId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateWebhooks = async (updates: Array<UpdateWebhookParams>): Promise<Array<Webhook>> => {
|
||||
const results: Array<Webhook> = [];
|
||||
|
||||
for (const update of updates) {
|
||||
try {
|
||||
const result = await updateWebhook(update);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update webhook ${update.webhookId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
41
fluxer_app/src/actions/WindowActionCreators.tsx
Normal file
41
fluxer_app/src/actions/WindowActionCreators.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 GuildReadStateStore from '~/stores/GuildReadStateStore';
|
||||
import IdleStore from '~/stores/IdleStore';
|
||||
import NotificationStore from '~/stores/NotificationStore';
|
||||
import WindowStore from '~/stores/WindowStore';
|
||||
|
||||
export const focus = (focused: boolean): void => {
|
||||
WindowStore.setFocused(focused);
|
||||
GuildReadStateStore.handleWindowFocus();
|
||||
NotificationStore.handleWindowFocus({focused});
|
||||
|
||||
if (focused) {
|
||||
IdleStore.recordActivity();
|
||||
}
|
||||
};
|
||||
|
||||
export const resized = (): void => {
|
||||
WindowStore.updateWindowSize();
|
||||
};
|
||||
|
||||
export const visibilityChanged = (visible: boolean): void => {
|
||||
WindowStore.setVisible(visible);
|
||||
};
|
||||
30
fluxer_app/src/bootstrap/setupHttpClient.ts
Normal file
30
fluxer_app/src/bootstrap/setupHttpClient.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 HttpClient from '~/lib/HttpClient';
|
||||
import SessionManager from '~/lib/SessionManager';
|
||||
import SudoPromptStore from '~/stores/SudoPromptStore';
|
||||
import SudoStore from '~/stores/SudoStore';
|
||||
|
||||
export function setupHttpClient(): void {
|
||||
HttpClient.setAuthTokenProvider(() => SessionManager.token);
|
||||
|
||||
SudoStore.init();
|
||||
SudoPromptStore.init();
|
||||
}
|
||||
37
fluxer_app/src/bootstrap/setupI18n.ts
Normal file
37
fluxer_app/src/bootstrap/setupI18n.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 i18n from '~/i18n';
|
||||
import CaptchaInterceptor from '~/lib/CaptchaInterceptor';
|
||||
import ChannelDisplayNameStore from '~/stores/ChannelDisplayNameStore';
|
||||
import KeybindStore from '~/stores/KeybindStore';
|
||||
import NewDeviceMonitoringStore from '~/stores/NewDeviceMonitoringStore';
|
||||
import NotificationStore from '~/stores/NotificationStore';
|
||||
import QuickSwitcherStore from '~/stores/QuickSwitcherStore';
|
||||
import MediaEngineFacade from '~/stores/voice/MediaEngineFacade';
|
||||
|
||||
export function setupI18nStores(): void {
|
||||
QuickSwitcherStore.setI18n(i18n);
|
||||
ChannelDisplayNameStore.setI18n(i18n);
|
||||
KeybindStore.setI18n(i18n);
|
||||
NewDeviceMonitoringStore.setI18n(i18n);
|
||||
NotificationStore.setI18n(i18n);
|
||||
MediaEngineFacade.setI18n(i18n);
|
||||
CaptchaInterceptor.setI18n(i18n);
|
||||
}
|
||||
145
fluxer_app/src/components/AppBadge.tsx
Normal file
145
fluxer_app/src/components/AppBadge.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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 Favico from 'favico.js';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect} from 'react';
|
||||
import {RelationshipTypes} from '~/Constants';
|
||||
import {updateDocumentTitleBadge} from '~/hooks/useFluxerDocumentTitle';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import GuildReadStateStore from '~/stores/GuildReadStateStore';
|
||||
import NotificationStore from '~/stores/NotificationStore';
|
||||
import RelationshipStore from '~/stores/RelationshipStore';
|
||||
import {getElectronAPI} from '~/utils/NativeUtils';
|
||||
|
||||
declare global {
|
||||
interface Navigator {
|
||||
setAppBadge?: (contents?: number) => Promise<void>;
|
||||
clearAppBadge?: () => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
const logger = new Logger('AppBadge');
|
||||
|
||||
const UNREAD_INDICATOR = -1;
|
||||
type BadgeValue = number;
|
||||
|
||||
let favico: Favico | null = null;
|
||||
|
||||
const initFavico = (): Favico | null => {
|
||||
if (favico) return favico;
|
||||
|
||||
try {
|
||||
favico = new Favico({animation: 'none'});
|
||||
return favico;
|
||||
} catch (e) {
|
||||
logger.warn('Failed to initialize Favico', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setElectronBadge = (badge: BadgeValue): void => {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
|
||||
const electronBadge = badge > 0 ? badge : 0;
|
||||
try {
|
||||
electronApi.setBadgeCount(electronBadge);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to set Electron badge', e);
|
||||
}
|
||||
};
|
||||
|
||||
const setFaviconBadge = (badge: BadgeValue): void => {
|
||||
const fav = initFavico();
|
||||
if (!fav) return;
|
||||
|
||||
try {
|
||||
if (badge === UNREAD_INDICATOR) {
|
||||
fav.badge('•');
|
||||
} else {
|
||||
fav.badge(badge);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to set favicon badge', e);
|
||||
}
|
||||
};
|
||||
|
||||
const setPwaBadge = (badge: BadgeValue): void => {
|
||||
if (!navigator.setAppBadge || !navigator.clearAppBadge) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (badge > 0) {
|
||||
void navigator.setAppBadge(badge);
|
||||
} else if (badge === UNREAD_INDICATOR) {
|
||||
void navigator.setAppBadge();
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to set PWA badge', e);
|
||||
}
|
||||
};
|
||||
|
||||
const setBadge = (badge: BadgeValue): void => {
|
||||
setElectronBadge(badge);
|
||||
setFaviconBadge(badge);
|
||||
setPwaBadge(badge);
|
||||
};
|
||||
|
||||
export const AppBadge: React.FC = observer(() => {
|
||||
const relationships = RelationshipStore.getRelationships();
|
||||
const unreadMessageBadgeEnabled = NotificationStore.unreadMessageBadgeEnabled;
|
||||
|
||||
const mentionCount = GuildReadStateStore.getTotalMentionCount();
|
||||
const hasUnread = GuildReadStateStore.hasAnyUnread;
|
||||
|
||||
const pendingCount = relationships.filter(
|
||||
(relationship) => relationship.type === RelationshipTypes.INCOMING_REQUEST,
|
||||
).length;
|
||||
|
||||
const totalCount = mentionCount + pendingCount;
|
||||
|
||||
let badge: BadgeValue = 0;
|
||||
if (totalCount > 0) {
|
||||
badge = totalCount;
|
||||
} else if (hasUnread && unreadMessageBadgeEnabled) {
|
||||
badge = UNREAD_INDICATOR;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setBadge(badge);
|
||||
}, [badge]);
|
||||
|
||||
useEffect(() => {
|
||||
updateDocumentTitleBadge(totalCount, hasUnread && unreadMessageBadgeEnabled);
|
||||
}, [totalCount, hasUnread, unreadMessageBadgeEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setBadge(0);
|
||||
updateDocumentTitleBadge(0, false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
});
|
||||
76
fluxer_app/src/components/BootstrapErrorScreen.tsx
Normal file
76
fluxer_app/src/components/BootstrapErrorScreen.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import React from 'react';
|
||||
import {FluxerIcon} from '~/components/icons/FluxerIcon';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import AppStorage from '~/lib/AppStorage';
|
||||
import styles from './ErrorFallback.module.css';
|
||||
|
||||
interface BootstrapErrorScreenProps {
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error}) => {
|
||||
const handleRetry = React.useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
AppStorage.clear();
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.errorFallbackContainer}>
|
||||
<FluxerIcon className={styles.errorFallbackIcon} />
|
||||
<div className={styles.errorFallbackContent}>
|
||||
<h1 className={styles.errorFallbackTitle}>
|
||||
<Trans>Failed to Start</Trans>
|
||||
</h1>
|
||||
<p className={styles.errorFallbackDescription}>
|
||||
<Trans>Fluxer failed to start properly. This could be due to corrupted data or a temporary issue.</Trans>
|
||||
</p>
|
||||
{error && (
|
||||
<p className={styles.errorFallbackDescription} style={{fontSize: '0.875rem', opacity: 0.8}}>
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<p className={styles.errorFallbackDescription}>
|
||||
<Trans>
|
||||
Check our{' '}
|
||||
<a href="https://bsky.app/profile/fluxer.app" target="_blank" rel="noopener noreferrer">
|
||||
Bluesky (@fluxer.app)
|
||||
</a>{' '}
|
||||
for status updates.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions}>
|
||||
<Button onClick={handleRetry}>
|
||||
<Trans>Try Again</Trans>
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="danger-primary">
|
||||
<Trans>Reset App Data</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
fluxer_app/src/components/ErrorFallback.module.css
Normal file
70
fluxer_app/src/components/ErrorFallback.module.css
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.errorFallbackContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-8);
|
||||
height: 100vh;
|
||||
padding: var(--spacing-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorFallbackIcon {
|
||||
height: 6rem;
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.errorFallbackContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorFallbackTitle {
|
||||
font-weight: 600;
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
.errorFallbackDescription {
|
||||
max-width: 21rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.errorFallbackActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.errorFallbackCopyAction {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.errorFallbackActions {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
125
fluxer_app/src/components/ErrorFallback.tsx
Normal file
125
fluxer_app/src/components/ErrorFallback.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import errorFallbackStyles from '~/components/ErrorFallback.module.css';
|
||||
import {FluxerIcon} from '~/components/icons/FluxerIcon';
|
||||
import {NativeTitlebar} from '~/components/layout/NativeTitlebar';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {useNativePlatform} from '~/hooks/useNativePlatform';
|
||||
import AppStorage from '~/lib/AppStorage';
|
||||
import {ensureLatestAssets} from '~/lib/versioning';
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
error?: Error;
|
||||
reset?: () => void;
|
||||
}
|
||||
|
||||
export const ErrorFallback: React.FC<ErrorFallbackProps> = observer(() => {
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
const [updateAvailable, setUpdateAvailable] = React.useState(false);
|
||||
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||
const [checkingForUpdates, setCheckingForUpdates] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const {updateFound} = await ensureLatestAssets({force: true});
|
||||
if (isMounted) {
|
||||
setUpdateAvailable(updateFound);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ErrorFallback] Failed to check for updates:', error);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setCheckingForUpdates(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpdate = React.useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const {updateFound} = await ensureLatestAssets({force: true});
|
||||
if (!updateFound) {
|
||||
setIsUpdating(false);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ErrorFallback] Failed to apply update:', error);
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={errorFallbackStyles.errorFallbackContainer}>
|
||||
{isNative && !isMacOS && <NativeTitlebar platform={platform} />}
|
||||
<FluxerIcon className={errorFallbackStyles.errorFallbackIcon} />
|
||||
<div className={errorFallbackStyles.errorFallbackContent}>
|
||||
<h1 className={errorFallbackStyles.errorFallbackTitle}>
|
||||
<Trans>Whoa, this is heavy.</Trans>
|
||||
</h1>
|
||||
<p className={errorFallbackStyles.errorFallbackDescription}>
|
||||
{checkingForUpdates ? (
|
||||
<Trans>The app has crashed. Checking for updates that might fix this issue...</Trans>
|
||||
) : updateAvailable ? (
|
||||
<Trans>Something went wrong and the app crashed. An update is available that may fix this issue.</Trans>
|
||||
) : (
|
||||
<Trans>Something went wrong and the app crashed. Try reloading or resetting the app.</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={errorFallbackStyles.errorFallbackActions}>
|
||||
<Button
|
||||
onClick={updateAvailable ? handleUpdate : () => location.reload()}
|
||||
disabled={checkingForUpdates || isUpdating}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Trans>Updating...</Trans>
|
||||
) : checkingForUpdates || updateAvailable ? (
|
||||
<Trans>Update app</Trans>
|
||||
) : (
|
||||
<Trans>Reload app</Trans>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
AppStorage.clear();
|
||||
location.reload();
|
||||
}}
|
||||
variant="danger-primary"
|
||||
disabled={checkingForUpdates}
|
||||
>
|
||||
<Trans>Reset app data</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
317
fluxer_app/src/components/LongPressable.tsx
Normal file
317
fluxer_app/src/components/LongPressable.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
|
||||
export const LONG_PRESS_DURATION_MS = 500;
|
||||
|
||||
const LONG_PRESS_MOVEMENT_THRESHOLD = 10;
|
||||
|
||||
const SWIPE_VELOCITY_THRESHOLD = 0.4;
|
||||
|
||||
const MIN_VELOCITY_SAMPLES = 2;
|
||||
|
||||
const MAX_VELOCITY_SAMPLE_AGE = 100;
|
||||
|
||||
const PRESS_HIGHLIGHT_DELAY_MS = 100;
|
||||
|
||||
const HAS_POINTER_EVENTS = 'PointerEvent' in window;
|
||||
|
||||
interface VelocitySample {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type LongPressEvent = React.PointerEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>;
|
||||
|
||||
interface LongPressableProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
delay?: number;
|
||||
onLongPress?: (event: LongPressEvent) => void;
|
||||
disabled?: boolean;
|
||||
pressedClassName?: string;
|
||||
onPressStateChange?: (isPressed: boolean) => void;
|
||||
}
|
||||
|
||||
export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps>(
|
||||
(
|
||||
{delay = LONG_PRESS_DURATION_MS, onLongPress, disabled, pressedClassName, onPressStateChange, ...rest},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const innerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const highlightTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pressStartPos = React.useRef<{x: number; y: number} | null>(null);
|
||||
const pointerIdRef = React.useRef<number | null>(null);
|
||||
const storedEvent = React.useRef<LongPressEvent | null>(null);
|
||||
const velocitySamples = React.useRef<Array<VelocitySample>>([]);
|
||||
const isPressIntent = React.useRef(false);
|
||||
const [isPressed, setIsPressed] = React.useState(false);
|
||||
|
||||
React.useImperativeHandle(forwardedRef, () => innerRef.current as HTMLDivElement);
|
||||
|
||||
const setPressed = React.useCallback(
|
||||
(pressed: boolean) => {
|
||||
setIsPressed(pressed);
|
||||
onPressStateChange?.(pressed);
|
||||
},
|
||||
[onPressStateChange],
|
||||
);
|
||||
|
||||
const calculateVelocity = React.useCallback((): number => {
|
||||
const samples = velocitySamples.current;
|
||||
if (samples.length < MIN_VELOCITY_SAMPLES) return 0;
|
||||
|
||||
const now = performance.now();
|
||||
const recentSamples = samples.filter((s) => now - s.timestamp < MAX_VELOCITY_SAMPLE_AGE);
|
||||
if (recentSamples.length < MIN_VELOCITY_SAMPLES) return 0;
|
||||
|
||||
const first = recentSamples[0];
|
||||
const last = recentSamples[recentSamples.length - 1];
|
||||
const dt = last.timestamp - first.timestamp;
|
||||
if (dt === 0) return 0;
|
||||
|
||||
const dx = last.x - first.x;
|
||||
const dy = last.y - first.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
return distance / dt;
|
||||
}, []);
|
||||
|
||||
const clearTimer = React.useCallback(() => {
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
highlightTimer.current = null;
|
||||
}
|
||||
if (pointerIdRef.current !== null && innerRef.current?.releasePointerCapture) {
|
||||
try {
|
||||
innerRef.current.releasePointerCapture(pointerIdRef.current);
|
||||
} catch {}
|
||||
}
|
||||
pointerIdRef.current = null;
|
||||
pressStartPos.current = null;
|
||||
storedEvent.current = null;
|
||||
velocitySamples.current = [];
|
||||
isPressIntent.current = false;
|
||||
setPressed(false);
|
||||
}, [setPressed]);
|
||||
|
||||
const {
|
||||
onPointerDown: userOnPointerDown,
|
||||
onPointerMove: userOnPointerMove,
|
||||
onPointerUp: userOnPointerUp,
|
||||
onPointerCancel: userOnPointerCancel,
|
||||
onTouchStart: userOnTouchStart,
|
||||
onTouchMove: userOnTouchMove,
|
||||
onTouchEnd: userOnTouchEnd,
|
||||
onTouchCancel: userOnTouchCancel,
|
||||
className,
|
||||
...restWithoutPointer
|
||||
} = rest;
|
||||
|
||||
const startLongPressTimer = React.useCallback(
|
||||
(event: LongPressEvent, x: number, y: number, pointerId?: number, capturePointer = false) => {
|
||||
if (disabled || !onLongPress) return;
|
||||
clearTimer();
|
||||
pressStartPos.current = {x, y};
|
||||
pointerIdRef.current = pointerId ?? null;
|
||||
velocitySamples.current = [{x, y, timestamp: performance.now()}];
|
||||
isPressIntent.current = true;
|
||||
|
||||
if (capturePointer && pointerId != null && innerRef.current?.setPointerCapture) {
|
||||
try {
|
||||
innerRef.current.setPointerCapture(pointerId);
|
||||
} catch {}
|
||||
}
|
||||
storedEvent.current = event;
|
||||
|
||||
highlightTimer.current = setTimeout(() => {
|
||||
if (isPressIntent.current) {
|
||||
setPressed(true);
|
||||
}
|
||||
highlightTimer.current = null;
|
||||
}, PRESS_HIGHLIGHT_DELAY_MS);
|
||||
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
if (!disabled && onLongPress && storedEvent.current && isPressIntent.current) {
|
||||
onLongPress(storedEvent.current);
|
||||
setPressed(false);
|
||||
}
|
||||
clearTimer();
|
||||
}, delay);
|
||||
},
|
||||
[clearTimer, delay, disabled, onLongPress, setPressed],
|
||||
);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
userOnPointerDown?.(event);
|
||||
if (disabled || !onLongPress || event.button !== 0) return;
|
||||
if (event.pointerType !== 'touch') return;
|
||||
startLongPressTimer(event, event.clientX, event.clientY, event.pointerId, true);
|
||||
},
|
||||
[disabled, onLongPress, startLongPressTimer, userOnPointerDown],
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
userOnPointerMove?.(event);
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
const startPos = pressStartPos.current;
|
||||
if (!startPos) return;
|
||||
|
||||
velocitySamples.current.push({x: event.clientX, y: event.clientY, timestamp: performance.now()});
|
||||
if (velocitySamples.current.length > 10) {
|
||||
velocitySamples.current = velocitySamples.current.slice(-10);
|
||||
}
|
||||
|
||||
const deltaX = Math.abs(event.clientX - startPos.x);
|
||||
const deltaY = Math.abs(event.clientY - startPos.y);
|
||||
|
||||
if (deltaX > LONG_PRESS_MOVEMENT_THRESHOLD || deltaY > LONG_PRESS_MOVEMENT_THRESHOLD) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const velocity = calculateVelocity();
|
||||
if (velocity > SWIPE_VELOCITY_THRESHOLD) {
|
||||
clearTimer();
|
||||
}
|
||||
},
|
||||
[clearTimer, calculateVelocity, userOnPointerMove],
|
||||
);
|
||||
|
||||
const handlePointerUp = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (pointerIdRef.current === event.pointerId) {
|
||||
clearTimer();
|
||||
}
|
||||
userOnPointerUp?.(event);
|
||||
},
|
||||
[clearTimer, userOnPointerUp],
|
||||
);
|
||||
|
||||
const handlePointerCancel = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (pointerIdRef.current === event.pointerId) {
|
||||
clearTimer();
|
||||
}
|
||||
userOnPointerCancel?.(event);
|
||||
},
|
||||
[clearTimer, userOnPointerCancel],
|
||||
);
|
||||
|
||||
const handleTouchStart = React.useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
userOnTouchStart?.(event);
|
||||
if (disabled || !onLongPress) return;
|
||||
const touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
startLongPressTimer(event, touch.clientX, touch.clientY);
|
||||
},
|
||||
[disabled, onLongPress, startLongPressTimer, userOnTouchStart],
|
||||
);
|
||||
|
||||
const handleTouchMove = React.useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
userOnTouchMove?.(event);
|
||||
if (!pressStartPos.current) return;
|
||||
const touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
velocitySamples.current.push({x: touch.clientX, y: touch.clientY, timestamp: performance.now()});
|
||||
if (velocitySamples.current.length > 10) {
|
||||
velocitySamples.current = velocitySamples.current.slice(-10);
|
||||
}
|
||||
|
||||
const deltaX = Math.abs(touch.clientX - pressStartPos.current.x);
|
||||
const deltaY = Math.abs(touch.clientY - pressStartPos.current.y);
|
||||
|
||||
if (deltaX > LONG_PRESS_MOVEMENT_THRESHOLD || deltaY > LONG_PRESS_MOVEMENT_THRESHOLD) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const velocity = calculateVelocity();
|
||||
if (velocity > SWIPE_VELOCITY_THRESHOLD) {
|
||||
clearTimer();
|
||||
}
|
||||
},
|
||||
[clearTimer, calculateVelocity, userOnTouchMove],
|
||||
);
|
||||
|
||||
const handleTouchEnd = React.useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
clearTimer();
|
||||
userOnTouchEnd?.(event);
|
||||
},
|
||||
[clearTimer, userOnTouchEnd],
|
||||
);
|
||||
|
||||
const handleTouchCancel = React.useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
clearTimer();
|
||||
userOnTouchCancel?.(event);
|
||||
},
|
||||
[clearTimer, userOnTouchCancel],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (isPressIntent.current) {
|
||||
clearTimer();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, {capture: true, passive: true});
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, {capture: true});
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
}
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
}
|
||||
};
|
||||
}, [clearTimer]);
|
||||
|
||||
const finalClassName = isPressed && pressedClassName ? `${className ?? ''} ${pressedClassName}`.trim() : className;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={finalClassName}
|
||||
onPointerDown={HAS_POINTER_EVENTS ? handlePointerDown : undefined}
|
||||
onPointerMove={HAS_POINTER_EVENTS ? handlePointerMove : undefined}
|
||||
onPointerUp={HAS_POINTER_EVENTS ? handlePointerUp : undefined}
|
||||
onPointerCancel={HAS_POINTER_EVENTS ? handlePointerCancel : undefined}
|
||||
onTouchStart={!HAS_POINTER_EVENTS ? handleTouchStart : undefined}
|
||||
onTouchMove={!HAS_POINTER_EVENTS ? handleTouchMove : undefined}
|
||||
onTouchEnd={!HAS_POINTER_EVENTS ? handleTouchEnd : undefined}
|
||||
onTouchCancel={!HAS_POINTER_EVENTS ? handleTouchCancel : undefined}
|
||||
{...restWithoutPointer}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
LongPressable.displayName = 'LongPressable';
|
||||
61
fluxer_app/src/components/NetworkErrorScreen.tsx
Normal file
61
fluxer_app/src/components/NetworkErrorScreen.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import React from 'react';
|
||||
import {FluxerIcon} from '~/components/icons/FluxerIcon';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from './ErrorFallback.module.css';
|
||||
|
||||
export const NetworkErrorScreen: React.FC = () => {
|
||||
const handleRetry = React.useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.errorFallbackContainer}>
|
||||
<FluxerIcon className={styles.errorFallbackIcon} />
|
||||
<div className={styles.errorFallbackContent}>
|
||||
<h1 className={styles.errorFallbackTitle}>
|
||||
<Trans>Connection Issue</Trans>
|
||||
</h1>
|
||||
<p className={styles.errorFallbackDescription}>
|
||||
<Trans>
|
||||
We're having trouble connecting to Fluxer's servers. This could be a temporary network issue or scheduled
|
||||
maintenance.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className={styles.errorFallbackDescription}>
|
||||
<Trans>
|
||||
Check our{' '}
|
||||
<a href="https://bsky.app/profile/fluxer.app" target="_blank" rel="noopener noreferrer">
|
||||
Bluesky (@fluxer.app)
|
||||
</a>{' '}
|
||||
for status updates.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions}>
|
||||
<Button onClick={handleRetry}>
|
||||
<Trans>Try Again</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
167
fluxer_app/src/components/accounts/AccountListItem.module.css
Normal file
167
fluxer_app/src/components/accounts/AccountListItem.module.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.accountItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
padding: var(--spacing-3);
|
||||
text-align: left;
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.accountItem.compact {
|
||||
padding: 0.5rem 0.625rem;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.accountItem:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.accountItem.compact:hover:not(:disabled) {
|
||||
background-color: var(--surface-interactive-hover-bg);
|
||||
}
|
||||
|
||||
.accountItem:active:not(:disabled) {
|
||||
background-color: var(--background-modifier-active);
|
||||
}
|
||||
|
||||
.accountItem.compact:active:not(:disabled) {
|
||||
background-color: var(--surface-interactive-active-bg);
|
||||
}
|
||||
|
||||
.accountItem:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.accountItem.compact:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.accountItem.current {
|
||||
background-color: var(--surface-interactive-selected-bg);
|
||||
color: var(--surface-interactive-selected-color);
|
||||
}
|
||||
|
||||
.accountItem.current:hover:not(:disabled) {
|
||||
background-color: var(--surface-interactive-selected-bg);
|
||||
}
|
||||
|
||||
.accountItemContent {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.accountItem.compact .accountItemContent {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.accountInfo {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.accountItem.compact .accountInfo {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.accountName {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.accountItem.compact .accountName {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.accountItem.current .accountName {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.accountMeta {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.accountItem.compact .accountMeta {
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.accountItem.current .accountMeta {
|
||||
color: var(--surface-interactive-selected-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.instanceLabel {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-left: var(--spacing-2);
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
color: white;
|
||||
background-color: var(--status-online);
|
||||
}
|
||||
|
||||
.badge.expired {
|
||||
color: var(--text-danger);
|
||||
background-color: color-mix(in srgb, var(--status-danger) 15%, transparent);
|
||||
}
|
||||
108
fluxer_app/src/components/accounts/AccountListItem.tsx
Normal file
108
fluxer_app/src/components/accounts/AccountListItem.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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 clsx from 'clsx';
|
||||
import type {ReactNode} from 'react';
|
||||
import {MockAvatar} from '~/components/uikit/MockAvatar';
|
||||
import type {AccountSummary} from '~/stores/AccountManager';
|
||||
import RuntimeConfigStore, {describeApiEndpoint} from '~/stores/RuntimeConfigStore';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import styles from './AccountListItem.module.css';
|
||||
|
||||
export interface AccountListItemProps {
|
||||
account: AccountSummary;
|
||||
disabled?: boolean;
|
||||
isCurrent?: boolean;
|
||||
onClick?: () => void;
|
||||
variant?: 'default' | 'compact';
|
||||
showInstance?: boolean;
|
||||
badge?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
}
|
||||
|
||||
export const getAccountAvatarUrl = (account: AccountSummary): string | undefined => {
|
||||
const avatar = account.userData?.avatar ?? null;
|
||||
try {
|
||||
const mediaEndpoint = account.instance?.mediaEndpoint ?? RuntimeConfigStore.getSnapshot().mediaEndpoint;
|
||||
if (mediaEndpoint) {
|
||||
return AvatarUtils.getUserAvatarURLWithProxy({id: account.userId, avatar}, mediaEndpoint, false) ?? undefined;
|
||||
}
|
||||
return AvatarUtils.getUserAvatarURL({id: account.userId, avatar}, false) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatLastActive = (timestamp: number): string => {
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'short'});
|
||||
return formatter.format(new Date(timestamp));
|
||||
};
|
||||
|
||||
export const AccountListItem = ({
|
||||
account,
|
||||
disabled = false,
|
||||
isCurrent = false,
|
||||
onClick,
|
||||
variant = 'default',
|
||||
showInstance = false,
|
||||
badge,
|
||||
meta,
|
||||
}: AccountListItemProps) => {
|
||||
const {t} = useLingui();
|
||||
const displayName = account.userData?.username ?? t`Unknown user`;
|
||||
const avatarUrl = getAccountAvatarUrl(account);
|
||||
const avatarSize = variant === 'compact' ? 32 : 40;
|
||||
|
||||
const defaultMeta =
|
||||
variant === 'compact' ? (
|
||||
isCurrent ? (
|
||||
(account.userData?.email ?? t`Email unavailable`)
|
||||
) : (
|
||||
<Trans>Last active {formatLastActive(account.lastActive)}</Trans>
|
||||
)
|
||||
) : (
|
||||
(account.userData?.email ?? t`Email unavailable`)
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(styles.accountItem, isCurrent && styles.current, variant === 'compact' && styles.compact)}
|
||||
onClick={isCurrent && !onClick ? undefined : onClick}
|
||||
disabled={disabled || (isCurrent && !onClick)}
|
||||
type="button"
|
||||
>
|
||||
<div className={styles.accountItemContent}>
|
||||
<MockAvatar size={avatarSize} avatarUrl={avatarUrl} userTag={account.userData?.username ?? account.userId} />
|
||||
<div className={styles.accountInfo}>
|
||||
<span className={styles.accountName}>{displayName}</span>
|
||||
<span className={styles.accountMeta}>{meta ?? defaultMeta}</span>
|
||||
{showInstance && account.instance && (
|
||||
<span className={styles.instanceLabel}>{describeApiEndpoint(account.instance.apiEndpoint)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{badge}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountListItemBadge = ({variant, children}: {variant: 'active' | 'expired'; children: ReactNode}) => {
|
||||
return <span className={clsx(styles.badge, styles[variant])}>{children}</span>;
|
||||
};
|
||||
195
fluxer_app/src/components/accounts/AccountRow.module.css
Normal file
195
fluxer_app/src/components/accounts/AccountRow.module.css
Normal file
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.mainButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
button.clickable {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
button.clickable:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
button.clickable:active {
|
||||
background-color: var(--background-modifier-active);
|
||||
}
|
||||
|
||||
.avatarWrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.displayName {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.primaryLine {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.currentName {
|
||||
color: var(--text-success);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.discriminator {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.currentFlag {
|
||||
font-size: 0.75rem;
|
||||
color: var(--status-online);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.expired {
|
||||
font-size: 0.75rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.menuButton:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.compact .mainButton {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background-color: var(--background-primary);
|
||||
}
|
||||
|
||||
.manage .mainButton {
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.manage .primaryLine {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.compactRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.globeButtonCompact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.globeButtonCompact:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.checkIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background-color: var(--brand-primary);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.caretIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
187
fluxer_app/src/components/accounts/AccountRow.tsx
Normal file
187
fluxer_app/src/components/accounts/AccountRow.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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 {CaretRightIcon, CheckIcon, DotsThreeIcon, GlobeIcon} from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {MockAvatar} from '~/components/uikit/MockAvatar';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip';
|
||||
import type {AccountSummary} from '~/stores/AccountManager';
|
||||
import {getAccountAvatarUrl} from './AccountListItem';
|
||||
import styles from './AccountRow.module.css';
|
||||
|
||||
const STANDARD_INSTANCES = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
|
||||
|
||||
function getInstanceHost(account: AccountSummary): string | null {
|
||||
const endpoint = account.instance?.apiEndpoint;
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(endpoint).hostname;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse instance host:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getInstanceEndpoint(account: AccountSummary): string | null {
|
||||
return account.instance?.apiEndpoint ?? null;
|
||||
}
|
||||
|
||||
type AccountRowVariant = 'default' | 'manage' | 'compact';
|
||||
|
||||
interface AccountRowProps {
|
||||
account: AccountSummary;
|
||||
variant?: AccountRowVariant;
|
||||
isCurrent?: boolean;
|
||||
isExpired?: boolean;
|
||||
showInstance?: boolean;
|
||||
onMenuClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onClick?: () => void;
|
||||
showCaretIndicator?: boolean;
|
||||
className?: string;
|
||||
meta?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AccountRow = observer(
|
||||
({
|
||||
account,
|
||||
variant = 'default',
|
||||
isCurrent = false,
|
||||
isExpired = false,
|
||||
showInstance = false,
|
||||
onMenuClick,
|
||||
onClick,
|
||||
showCaretIndicator = false,
|
||||
className,
|
||||
meta,
|
||||
}: AccountRowProps) => {
|
||||
const {t} = useLingui();
|
||||
const avatarUrl = getAccountAvatarUrl(account);
|
||||
const displayName = account.userData?.username ?? t`Unknown user`;
|
||||
const discriminator = account.userData?.discriminator ?? '0000';
|
||||
const instanceHost = showInstance ? getInstanceHost(account) : null;
|
||||
const instanceEndpoint = showInstance ? getInstanceEndpoint(account) : null;
|
||||
const shouldShowInstance = typeof instanceHost === 'string' && !STANDARD_INSTANCES.has(instanceHost);
|
||||
|
||||
const handleMenuClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
onMenuClick?.(event);
|
||||
},
|
||||
[onMenuClick],
|
||||
);
|
||||
|
||||
const avatarSize = variant === 'compact' ? 32 : 40;
|
||||
const variantClassName = variant === 'manage' ? styles.manage : variant === 'compact' ? styles.compact : undefined;
|
||||
const isClickable = typeof onClick === 'function';
|
||||
const MainButtonComponent = isClickable ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.row, variantClassName, className)}>
|
||||
<MainButtonComponent
|
||||
type={isClickable ? 'button' : undefined}
|
||||
className={clsx(styles.mainButton, isClickable && styles.clickable)}
|
||||
onClick={isClickable ? onClick : undefined}
|
||||
>
|
||||
<div className={styles.avatarWrap}>
|
||||
<MockAvatar size={avatarSize} avatarUrl={avatarUrl} userTag={displayName} />
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{variant === 'compact' ? (
|
||||
<div className={styles.compactRow}>
|
||||
<span className={clsx('user-text', 'truncate', styles.primaryLine, isCurrent && styles.currentName)}>
|
||||
{displayName}
|
||||
<span className={styles.discriminator}>#{discriminator}</span>
|
||||
</span>
|
||||
{shouldShowInstance && instanceEndpoint ? (
|
||||
<Tooltip text={instanceEndpoint} position="right">
|
||||
<span className={styles.globeButtonCompact}>
|
||||
<GlobeIcon size={12} weight="bold" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.titleRow}>
|
||||
{variant === 'manage' ? (
|
||||
<span
|
||||
className={clsx('user-text', 'truncate', styles.primaryLine, isCurrent && styles.currentName)}
|
||||
>
|
||||
{displayName}
|
||||
<span className={styles.discriminator}>#{discriminator}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={clsx('user-text', styles.displayName, isCurrent && styles.currentName)}>
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{shouldShowInstance && instanceEndpoint ? (
|
||||
<Tooltip text={instanceEndpoint} position="right">
|
||||
<span className={styles.globeButtonCompact}>
|
||||
<GlobeIcon size={12} weight="bold" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
{variant !== 'manage' ? (
|
||||
<span className={clsx('user-text', styles.tag)}>
|
||||
{displayName}
|
||||
<span className={styles.discriminator}>#{discriminator}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{variant === 'manage' && isCurrent ? (
|
||||
<span className={styles.currentFlag}>
|
||||
<Trans>Active account</Trans>
|
||||
</span>
|
||||
) : null}
|
||||
{meta && <span className={styles.meta}>{meta}</span>}
|
||||
{isExpired && <span className={styles.expired}>{t`Expired`}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && variant !== 'manage' ? (
|
||||
<div className={styles.checkIndicator}>
|
||||
<CheckIcon size={10} weight="bold" />
|
||||
</div>
|
||||
) : null}
|
||||
{showCaretIndicator ? (
|
||||
<div className={styles.caretIndicator}>
|
||||
<CaretRightIcon size={18} weight="bold" />
|
||||
</div>
|
||||
) : null}
|
||||
{onMenuClick && variant !== 'compact' && !showCaretIndicator ? (
|
||||
<FocusRing offset={-2}>
|
||||
<button type="button" className={styles.menuButton} onClick={handleMenuClick} aria-label={t`More`}>
|
||||
<DotsThreeIcon size={20} weight="bold" className={styles.menuIcon} />
|
||||
</button>
|
||||
</FocusRing>
|
||||
) : null}
|
||||
</MainButtonComponent>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: hsla(0, calc(100% * var(--saturation-factor)), 50%, 0.1);
|
||||
border: 1px solid hsla(0, calc(100% * var(--saturation-factor)), 50%, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--status-danger);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accountListWrapper {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.accountList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-1) 0;
|
||||
}
|
||||
|
||||
.noAccounts {
|
||||
display: flex;
|
||||
height: 120px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
184
fluxer_app/src/components/accounts/AccountSelector.tsx
Normal file
184
fluxer_app/src/components/accounts/AccountSelector.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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 {PlusIcon, SignOutIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
|
||||
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
|
||||
import {Scroller} from '~/components/uikit/Scroller';
|
||||
import AccountManager, {type AccountSummary} from '~/stores/AccountManager';
|
||||
import {AccountRow} from './AccountRow';
|
||||
import styles from './AccountSelector.module.css';
|
||||
|
||||
interface AccountSelectorProps {
|
||||
accounts: Array<AccountSummary>;
|
||||
currentAccountId?: string | null;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
error?: string | null;
|
||||
disabled?: boolean;
|
||||
showInstance?: boolean;
|
||||
clickableRows?: boolean;
|
||||
addButtonLabel?: React.ReactNode;
|
||||
onSelectAccount: (account: AccountSummary) => void;
|
||||
onAddAccount?: () => void;
|
||||
scrollerKey?: string;
|
||||
}
|
||||
|
||||
export const AccountSelector = observer(
|
||||
({
|
||||
accounts,
|
||||
currentAccountId,
|
||||
title,
|
||||
description,
|
||||
error,
|
||||
disabled = false,
|
||||
showInstance = false,
|
||||
clickableRows = false,
|
||||
addButtonLabel,
|
||||
onSelectAccount,
|
||||
onAddAccount,
|
||||
scrollerKey,
|
||||
}: AccountSelectorProps) => {
|
||||
const {t} = useLingui();
|
||||
const defaultTitle = <Trans>Choose an account</Trans>;
|
||||
const defaultDescription = <Trans>Select an account to continue, or add a different one.</Trans>;
|
||||
const hasMultipleAccounts = accounts.length > 1;
|
||||
|
||||
const openSignOutConfirm = React.useCallback(
|
||||
(account: AccountSummary) => {
|
||||
const displayName = account.userData?.username ?? account.userId;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={<Trans>Remove {displayName}</Trans>}
|
||||
description={
|
||||
hasMultipleAccounts ? (
|
||||
<Trans>This will remove the saved session for this account.</Trans>
|
||||
) : (
|
||||
<Trans>This will remove the only saved account on this device.</Trans>
|
||||
)
|
||||
}
|
||||
primaryText={<Trans>Remove</Trans>}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
await AccountManager.removeStoredAccount(account.userId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove account', error);
|
||||
ToastActionCreators.error(t`We couldn't remove that account. Please try again.`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
},
|
||||
[hasMultipleAccounts, t],
|
||||
);
|
||||
|
||||
const openMenu = React.useCallback(
|
||||
(account: AccountSummary) => (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ContextMenuActionCreators.openFromEvent(event, (props) => (
|
||||
<MenuGroup>
|
||||
<MenuItem
|
||||
icon={<SignOutIcon size={18} />}
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
onSelectAccount(account);
|
||||
}}
|
||||
>
|
||||
<Trans>Select account</Trans>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<SignOutIcon size={18} />}
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
openSignOutConfirm(account);
|
||||
}}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
));
|
||||
},
|
||||
[openSignOutConfirm, onSelectAccount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.title}>{title ?? defaultTitle}</h1>
|
||||
<p className={styles.description}>{description ?? defaultDescription}</p>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.accountListWrapper}>
|
||||
{accounts.length === 0 ? (
|
||||
<div className={styles.noAccounts}>
|
||||
<Trans>No accounts</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<Scroller className={styles.scroller} key={scrollerKey ?? 'account-selector-scroller'}>
|
||||
<div className={styles.accountList}>
|
||||
{accounts.map((account) => {
|
||||
const isCurrent = account.userId === currentAccountId;
|
||||
return (
|
||||
<AccountRow
|
||||
key={account.userId}
|
||||
account={account}
|
||||
variant="manage"
|
||||
isCurrent={isCurrent}
|
||||
isExpired={account.isValid === false}
|
||||
showInstance={showInstance}
|
||||
onClick={clickableRows && !disabled ? () => onSelectAccount(account) : undefined}
|
||||
showCaretIndicator={clickableRows}
|
||||
onMenuClick={!clickableRows && !disabled ? openMenu(account) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onAddAccount && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<PlusIcon size={18} weight="bold" />}
|
||||
onClick={onAddAccount}
|
||||
fitContainer
|
||||
>
|
||||
{addButtonLabel ?? <Trans>Add an account</Trans>}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
height: 140px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.noAccounts {
|
||||
display: flex;
|
||||
height: 120px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.scroller {
|
||||
padding: 0.5rem;
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.accountList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0.75rem 0.75rem 0.5rem;
|
||||
}
|
||||
119
fluxer_app/src/components/accounts/AccountSwitcherModal.tsx
Normal file
119
fluxer_app/src/components/accounts/AccountSwitcherModal.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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} from '@lingui/react/macro';
|
||||
import {PlusIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as Modal from '~/components/modals/Modal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Scroller} from '~/components/uikit/Scroller';
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import {openAccountContextMenu, useAccountSwitcherLogic} from '~/utils/accounts/AccountSwitcherModalUtils';
|
||||
import {AccountRow} from './AccountRow';
|
||||
import styles from './AccountSwitcherModal.module.css';
|
||||
|
||||
const AccountSwitcherModal = observer(() => {
|
||||
const {
|
||||
accounts,
|
||||
currentAccount,
|
||||
isBusy,
|
||||
handleSwitchAccount,
|
||||
handleReLogin,
|
||||
handleAddAccount,
|
||||
handleLogout,
|
||||
handleRemoveAccount,
|
||||
} = useAccountSwitcherLogic();
|
||||
|
||||
const hasMultipleAccounts = accounts.length > 1;
|
||||
|
||||
const openMenu = React.useCallback(
|
||||
(account: (typeof accounts)[number]) => (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openAccountContextMenu(event, {
|
||||
account,
|
||||
currentAccountId: currentAccount?.userId ?? null,
|
||||
hasMultipleAccounts,
|
||||
onSwitch: handleSwitchAccount,
|
||||
onReLogin: handleReLogin,
|
||||
onLogout: handleLogout,
|
||||
onRemoveAccount: handleRemoveAccount,
|
||||
});
|
||||
},
|
||||
[
|
||||
currentAccount?.userId,
|
||||
hasMultipleAccounts,
|
||||
handleSwitchAccount,
|
||||
handleReLogin,
|
||||
handleLogout,
|
||||
handleRemoveAccount,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal.Root size="small" centered>
|
||||
<Modal.Header title={<Trans>Manage Accounts</Trans>} />
|
||||
<Modal.Content className={styles.content}>
|
||||
{isBusy && accounts.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className={styles.noAccounts}>
|
||||
<Trans>No accounts</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<Scroller className={styles.scroller} key="account-switcher-scroller">
|
||||
<div className={styles.accountList}>
|
||||
{accounts.map((account) => {
|
||||
const isCurrent = account.userId === currentAccount?.userId;
|
||||
return (
|
||||
<AccountRow
|
||||
key={account.userId}
|
||||
account={account}
|
||||
variant="manage"
|
||||
isCurrent={isCurrent}
|
||||
isExpired={account.isValid === false}
|
||||
showInstance
|
||||
onMenuClick={openMenu(account)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Footer className={styles.footer}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<PlusIcon size={18} weight="bold" />}
|
||||
onClick={handleAddAccount}
|
||||
disabled={isBusy}
|
||||
fitContainer
|
||||
>
|
||||
<Trans>Add an account</Trans>
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export default AccountSwitcherModal;
|
||||
33
fluxer_app/src/components/alerts/CallNotRingableModal.tsx
Normal file
33
fluxer_app/src/components/alerts/CallNotRingableModal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const CallNotRingableModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Unable to Start Call`}
|
||||
message={t`This user is not available to receive calls right now. They may have calls disabled.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {openNativePermissionSettings} from '~/utils/NativePermissions';
|
||||
import {isDesktop, isNativeMacOS} from '~/utils/NativeUtils';
|
||||
|
||||
export const CameraPermissionDeniedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
if (isDesktop() && isNativeMacOS()) {
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Camera Permission Required`}
|
||||
description={t`Fluxer needs access to your camera. Open System Settings → Privacy & Security → Camera, allow Fluxer, and then restart the app.`}
|
||||
primaryText={t`Open Settings`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => openNativePermissionSettings('camera')}
|
||||
secondaryText={t`Close`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const message = isDesktop()
|
||||
? t`Fluxer needs access to your camera. Allow camera access in your operating system privacy settings and restart the app.`
|
||||
: t`Fluxer needs access to your camera to enable video chat. Please grant camera permission in your browser settings and try again.`;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Camera Permission Required`}
|
||||
description={message}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const ChannelPermissionsUpdateFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Failed to update channel permissions`}
|
||||
message={t`We couldn't save your channel permission changes at this time.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
30
fluxer_app/src/components/alerts/DMCloseFailedModal.tsx
Normal file
30
fluxer_app/src/components/alerts/DMCloseFailedModal.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const DMCloseFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal title={t`Failed to close DM`} message={t`We couldn't close the direct message at this time.`} />
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const FeatureTemporarilyDisabledModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Feature Temporarily Disabled`}
|
||||
description={t`This feature has been temporarily disabled. Please try again later.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
52
fluxer_app/src/components/alerts/FileSizeTooLargeModal.tsx
Normal file
52
fluxer_app/src/components/alerts/FileSizeTooLargeModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 * as PremiumModalActionCreators from '~/actions/PremiumModalActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
export const FileSizeTooLargeModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const user = UserStore.currentUser;
|
||||
const hasPremium = user?.isPremium() ?? false;
|
||||
|
||||
if (hasPremium) {
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`File size too large`}
|
||||
description={t`The file you're trying to upload exceeds the maximum size limit of 500 MB for Plutonium subscribers.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`File Size Limit Exceeded`}
|
||||
description={t`The file you're trying to upload exceeds the maximum size limit of 25 MB for non-subscribers. With Plutonium, you can upload files up to 500 MB, use animated avatars and banners, write longer bios, and unlock many other premium features.`}
|
||||
primaryText={t`Get Plutonium`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => PremiumModalActionCreators.open()}
|
||||
secondaryText={t`Cancel`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
32
fluxer_app/src/components/alerts/GenericErrorModal.tsx
Normal file
32
fluxer_app/src/components/alerts/GenericErrorModal.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
interface GenericErrorModalProps {
|
||||
title: string;
|
||||
message: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GenericErrorModal: React.FC<GenericErrorModalProps> = observer(({title, message}) => {
|
||||
const {t} = useLingui();
|
||||
return <ConfirmModal title={title} description={message} primaryText={t`Understood`} onPrimary={() => {}} />;
|
||||
});
|
||||
33
fluxer_app/src/components/alerts/GroupLeaveFailedModal.tsx
Normal file
33
fluxer_app/src/components/alerts/GroupLeaveFailedModal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const GroupLeaveFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Failed to leave group`}
|
||||
message={t`We couldn't remove you from the group at this time.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 type {ReactElement} from 'react';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const GroupOwnershipTransferFailedModal: React.FC<{username?: string}> = observer(({username}) => {
|
||||
const {t} = useLingui();
|
||||
const message: ReactElement = (
|
||||
<Trans>
|
||||
Ownership could not be transferred to <strong>{username}</strong> at this time.
|
||||
</Trans>
|
||||
);
|
||||
return <GenericErrorModal title={t`Failed to transfer ownership`} message={message} />;
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user