initial commit

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

View File

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

View 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;
}
};

View 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;
}
};

View 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;
}
};

View 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();
}

View 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;
}
};

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

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

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

View 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}`);
}

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

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

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

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

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

View 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;
}
};

View 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?.();
}}
/>
)),
);
};

View 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;
}
};

View 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;
}
};

View 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;
}
};

View 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;
}
};

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

View 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;
}
};

View 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();
};

View 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;
}
};

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

View 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;
}
};

View 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();
};

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

View 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;
}
};

View 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;
}
};

View 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();
};

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

View 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();
};

View 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 &gt; 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);
};

View 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;
}
};

View 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;
}
};

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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;
}
};

View 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();
};

View 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;
}
};

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

View 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;
}
};

View 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;

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

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

View 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;
}
};

View 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;
}
};

View 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;
}
};

View 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;
}

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

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

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

View File

@@ -0,0 +1,132 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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;
}
};

View 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;
}
};

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

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

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

View File

@@ -0,0 +1,45 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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);
};

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

View 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;
}
};

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

View 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;
}
};

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

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

View 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();
};

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

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

View 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;
};

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