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,31 @@
/*
* 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 {HttpError} from '~/lib/HttpError';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function getApiErrorCode(error: unknown): string | undefined {
if (!(error instanceof HttpError)) return undefined;
if (!isRecord(error.body)) return undefined;
const code = error.body.code;
return typeof code === 'string' ? code : undefined;
}

View File

@@ -0,0 +1,80 @@
/*
* 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 {afterEach, describe, expect, test} from 'vitest';
import {isCustomInstanceUrl, isElectronApiProxyUrl, wrapUrlWithElectronApiProxy} from './ApiProxyUtils';
const ELECTRON_API_PROXY_BASE = 'http://127.0.0.1:21862/proxy';
const setElectronApiProxy = () => {
Object.defineProperty(window, 'electron', {
value: {
getApiProxyUrl: () => ELECTRON_API_PROXY_BASE,
},
writable: true,
configurable: true,
});
};
const clearElectron = () => {
Object.defineProperty(window, 'electron', {
value: undefined,
writable: true,
configurable: true,
});
};
afterEach(() => {
clearElectron();
});
describe('ApiProxyUtils (Electron API proxy wrapping)', () => {
test('detects custom instances outside the default list', () => {
const url = 'https://self-hosted.example/api';
expect(isCustomInstanceUrl(url)).toBe(true);
});
test('ignores default Fluxer hosts', () => {
const url = 'https://api.fluxer.app/v1';
expect(isCustomInstanceUrl(url)).toBe(false);
});
test('recognizes the electron proxy URL (including query params)', () => {
setElectronApiProxy();
const wrapped = `${ELECTRON_API_PROXY_BASE}?target=https://self-hosted.example/api`;
expect(isElectronApiProxyUrl(ELECTRON_API_PROXY_BASE)).toBe(true);
expect(isElectronApiProxyUrl(wrapped)).toBe(true);
expect(isElectronApiProxyUrl('https://self-hosted.example/api')).toBe(false);
});
test('wraps custom hosts but skips already wrapped URLs', () => {
setElectronApiProxy();
const target = 'https://self-hosted.example/api';
const wrapped = wrapUrlWithElectronApiProxy(target);
const parsed = new URL(wrapped);
expect(parsed.origin).toBe('http://127.0.0.1:21862');
expect(parsed.pathname).toBe('/proxy');
expect(parsed.searchParams.get('target')).toBe(target);
const alreadyWrapped = wrapUrlWithElectronApiProxy(wrapped);
expect(alreadyWrapped).toBe(wrapped);
});
});

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/>.
*/
const DEFAULT_FLUXER_HOSTS = new Set([
'api.fluxer.app',
'api.canary.fluxer.app',
'fluxer.app',
'canary.fluxer.app',
'web.fluxer.app',
'web.canary.fluxer.app',
]);
const normalizeProxyPath = (path: string): string => {
const trimmed = path.replace(/\/+$/, '');
return trimmed === '' ? '/' : trimmed;
};
export function isElectronApiProxyUrl(raw: string): boolean {
const base = getElectronApiProxyBaseUrl();
if (!base) return false;
try {
const parsed = new URL(raw);
if (parsed.origin !== base.origin) {
return false;
}
const rawPath = normalizeProxyPath(parsed.pathname);
const basePath = normalizeProxyPath(base.pathname);
return rawPath === basePath;
} catch {
return false;
}
}
export function isCustomInstanceUrl(url: string): boolean {
try {
const parsed = new URL(url);
return !DEFAULT_FLUXER_HOSTS.has(parsed.hostname);
} catch {
return false;
}
}
export function getElectronApiProxyBaseUrl(): URL | null {
if (typeof window === 'undefined') {
return null;
}
const getter = window.electron?.getApiProxyUrl;
if (typeof getter !== 'function') {
return null;
}
const raw = getter();
if (!raw) return null;
try {
return new URL(raw);
} catch {
return null;
}
}
export function wrapUrlWithElectronApiProxy(raw: string): string {
const base = getElectronApiProxyBaseUrl();
if (!base) return raw;
if (isElectronApiProxyUrl(raw)) return raw;
if (!isCustomInstanceUrl(raw)) return raw;
const proxyUrl = new URL(base.toString());
proxyUrl.searchParams.set('target', raw);
return proxyUrl.toString();
}

View File

@@ -0,0 +1,136 @@
/*
* 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 {msg} from '@lingui/core/macro';
import i18n from '~/i18n';
import {TimestampStyle} from '~/lib/markdown/parser/types/enums';
import {formatTimestamp} from '~/lib/markdown/utils/date-formatter';
import type {MessageAttachment} from '~/records/MessageRecord';
import {getFormattedShortDate} from '~/utils/DateUtils';
export interface AttachmentExpiryOverride {
expired?: boolean;
expiresAt?: string | null;
}
interface FormatExpiryParams {
expiresAt: Date | null;
isExpired?: boolean;
}
export interface AttachmentExpirySummary {
expiresAt: Date | null;
latestAt: Date | null;
isExpired: boolean;
}
export function formatAttachmentExpiryTooltip({expiresAt, isExpired = false}: FormatExpiryParams): string | null {
if (!expiresAt) return null;
const timestampSeconds = Math.floor(expiresAt.getTime() / 1000);
const absolute = formatTimestamp(timestampSeconds, TimestampStyle.LongDateTime, i18n);
const relativeText = formatTimestamp(timestampSeconds, TimestampStyle.RelativeTime, i18n);
return isExpired ? i18n._(msg`Expired ${absolute}`) : i18n._(msg`Expires ${absolute} (${relativeText})`);
}
export function getEarliestAttachmentExpiry(attachments: ReadonlyArray<MessageAttachment>): AttachmentExpirySummary {
let earliest: Date | null = null;
let latest: Date | null = null;
let isExpired = false;
for (const att of attachments) {
let attDate: Date | null = null;
if (att.expires_at) {
attDate = new Date(att.expires_at);
}
if (!attDate && att.expired) {
attDate = new Date();
}
if (!attDate) continue;
if (!earliest || attDate.getTime() < earliest.getTime()) {
earliest = attDate;
}
if (!latest || attDate.getTime() > latest.getTime()) {
latest = attDate;
}
if (att.expired || attDate.getTime() <= Date.now()) {
isExpired = true;
}
}
return {
expiresAt: earliest,
latestAt: latest,
isExpired,
};
}
export function formatAttachmentDate(date: Date | null): string | null {
if (!date) return null;
return getFormattedShortDate(date);
}
export interface AttachmentExpiryResult {
attachment: MessageAttachment;
expiresAt: Date | null;
isExpired: boolean;
}
export function getEffectiveAttachmentExpiry(
attachment: MessageAttachment,
override?: AttachmentExpiryOverride,
now = Date.now(),
): AttachmentExpiryResult {
const expiresAt = attachment.expires_at ? new Date(attachment.expires_at) : null;
const overrideExpiresAt = override?.expiresAt ? new Date(override.expiresAt) : null;
const effectiveExpiresAt = overrideExpiresAt ?? expiresAt;
const baseExpired = Boolean(attachment.expired) || (expiresAt ? expiresAt.getTime() <= now : false);
const effectiveExpired =
(override?.expired ?? baseExpired) || (effectiveExpiresAt ? effectiveExpiresAt.getTime() <= now : false);
return {
attachment: {
...attachment,
expired: effectiveExpired,
expires_at: effectiveExpiresAt?.toISOString() ?? attachment.expires_at ?? null,
},
expiresAt: effectiveExpiresAt,
isExpired: effectiveExpired,
};
}
export function mapAttachmentsWithExpiry(
attachments: ReadonlyArray<MessageAttachment>,
overrides?: Record<string, AttachmentExpiryOverride>,
now = Date.now(),
): ReadonlyArray<AttachmentExpiryResult> {
return attachments.map((att) => getEffectiveAttachmentExpiry(att, overrides?.[att.id], now));
}
export function filterExpiredAttachments(
results: ReadonlyArray<AttachmentExpiryResult>,
): ReadonlyArray<MessageAttachment> {
return results.filter((r) => !r.isExpired).map((r) => r.attachment);
}

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 {ATTACHMENT_MAX_SIZE_NON_PREMIUM, ATTACHMENT_MAX_SIZE_PREMIUM} from '~/Constants';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
export function getAttachmentMaxSize(isPremium: boolean): number {
if (RuntimeConfigStore.isSelfHosted()) {
return ATTACHMENT_MAX_SIZE_PREMIUM;
}
return isPremium ? ATTACHMENT_MAX_SIZE_PREMIUM : ATTACHMENT_MAX_SIZE_NON_PREMIUM;
}

View File

@@ -0,0 +1,77 @@
/*
* 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 {getElectronAPI} from './NativeUtils';
export const setAutostartEnabled = async (enabled: boolean): Promise<boolean | null> => {
const electronApi = getElectronAPI();
if (!electronApi) return null;
try {
if (enabled) {
await electronApi.autostartEnable();
} else {
await electronApi.autostartDisable();
}
return await electronApi.autostartIsEnabled();
} catch (error) {
console.error('Failed to update autostart status', error);
return null;
}
};
export const getAutostartStatus = async (): Promise<boolean | null> => {
const electronApi = getElectronAPI();
if (!electronApi) return null;
try {
return await electronApi.autostartIsEnabled();
} catch (error) {
console.error('Failed to read autostart status', error);
return null;
}
};
export const ensureAutostartDefaultEnabled = async (): Promise<boolean | null> => {
const electronApi = getElectronAPI();
if (!electronApi) return null;
try {
if (electronApi.platform !== 'darwin') {
return await electronApi.autostartIsEnabled();
}
const initialized = await electronApi.autostartIsInitialized();
let enabled = await electronApi.autostartIsEnabled();
if (!initialized && !enabled) {
await electronApi.autostartEnable();
enabled = await electronApi.autostartIsEnabled();
}
if (!initialized) {
await electronApi.autostartMarkInitialized();
}
return enabled;
} catch (error) {
console.error('Failed to ensure default autostart', error);
return null;
}
};

View File

@@ -0,0 +1,331 @@
/*
* 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 {UserRecord} from '~/records/UserRecord';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import {buildMediaProxyURL} from '~/utils/MediaProxyUtils';
import {cdnUrl, mediaUrl} from '~/utils/UrlUtils';
const DEFAULT_AVATAR_PRIMARY_COLORS = [0x4641d9, 0xf0b100, 0x00bba7, 0x2b7fff, 0xad46ff, 0x6a7282];
const DEFAULT_AVATAR_COUNT = DEFAULT_AVATAR_PRIMARY_COLORS.length;
const getDefaultAvatar = (index: number): string => cdnUrl(`avatars/${index}.png`);
const getDefaultAvatarIndex = (id: string) => Number(BigInt(id) % BigInt(DEFAULT_AVATAR_COUNT));
export const getDefaultAvatarPrimaryColor = (id: string) => DEFAULT_AVATAR_PRIMARY_COLORS[getDefaultAvatarIndex(id)];
type AvatarOptions = Pick<UserRecord, 'id' | 'avatar'>;
type BannerOptions = Pick<UserRecord, 'id' | 'banner'>;
interface IconOptions {
id: string;
icon: string | null;
}
const getMediaURL = ({
path,
id,
hash,
size,
format,
}: {
path: string;
id: string;
hash: string;
size?: number;
format: string;
}) => {
if (DeveloperOptionsStore.forceRenderPlaceholders) {
return '';
}
const basePath = `${path}/${id}/${hash}.${format}`;
return size ? mediaUrl(`${basePath}?size=${size}`) : mediaUrl(basePath);
};
const getGuildMemberMediaURL = ({
path,
guildId,
userId,
hash,
size,
format,
}: {
path: string;
guildId: string;
userId: string;
hash: string;
size?: number;
format: string;
}) => {
if (DeveloperOptionsStore.forceRenderPlaceholders) {
return '';
}
const basePath = `guilds/${guildId}/users/${userId}/${path}/${hash}.${format}`;
return size ? mediaUrl(`${basePath}?size=${size}`) : mediaUrl(basePath);
};
const parseAvatar = (avatar: string) => {
const animated = avatar.startsWith('a_');
const hash = animated ? avatar.slice(2) : avatar;
return {
animated,
hash,
};
};
export const getUserAvatarURL = ({id, avatar}: AvatarOptions, animated = false) => {
if (!avatar) {
return getDefaultAvatar(getDefaultAvatarIndex(id));
}
const parsedAvatar = parseAvatar(avatar);
const shouldAnimate = parsedAvatar.animated ? animated : false;
return getMediaURL({
path: 'avatars',
id,
hash: parsedAvatar.hash,
size: 160,
format: shouldAnimate ? 'gif' : 'webp',
});
};
export const getUserAvatarURLWithProxy = (
{id, avatar}: AvatarOptions,
mediaProxyEndpoint: string,
animated = false,
) => {
if (!avatar) {
return getDefaultAvatar(getDefaultAvatarIndex(id));
}
if (DeveloperOptionsStore.forceRenderPlaceholders) {
return '';
}
const parsedAvatar = parseAvatar(avatar);
const shouldAnimate = parsedAvatar.animated ? animated : false;
const format = shouldAnimate ? 'gif' : 'webp';
return buildMediaProxyURL(`${mediaProxyEndpoint}/avatars/${id}/${parsedAvatar.hash}.${format}?size=160`);
};
export const getWebhookAvatarURL = ({id, avatar}: {id: string; avatar: string | null}, animated = false) => {
if (!avatar) {
return getDefaultAvatar(getDefaultAvatarIndex(id));
}
const parsedAvatar = parseAvatar(avatar);
const shouldAnimate = parsedAvatar.animated ? animated : false;
return getMediaURL({
path: 'avatars',
id,
hash: parsedAvatar.hash,
size: 160,
format: shouldAnimate ? 'gif' : 'webp',
});
};
export const getUserBannerURL = ({id, banner}: BannerOptions, animated = false, size = 1024) => {
if (!banner) {
return null;
}
const parsedBanner = parseAvatar(banner);
const shouldAnimate = parsedBanner.animated ? animated : false;
return getMediaURL({
path: 'banners',
id,
hash: parsedBanner.hash,
size,
format: shouldAnimate ? 'gif' : 'webp',
});
};
export const getGuildIconURL = ({id, icon}: IconOptions, animated = false) => {
if (!icon) {
return null;
}
const parsedIcon = parseAvatar(icon);
const shouldAnimate = parsedIcon.animated ? animated : false;
return getMediaURL({
path: 'icons',
id,
hash: parsedIcon.hash,
size: 160,
format: shouldAnimate ? 'gif' : 'webp',
});
};
export const getGuildBannerURL = ({id, banner}: {id: string; banner: string | null}, animated = false) => {
if (!banner) {
return null;
}
const parsedBanner = parseAvatar(banner);
const shouldAnimate = parsedBanner.animated ? animated : false;
return getMediaURL({
path: 'banners',
id,
hash: parsedBanner.hash,
size: 1024,
format: shouldAnimate ? 'gif' : 'webp',
});
};
export const getGuildSplashURL = ({id, splash}: {id: string; splash: string | null}, size = 1024) => {
if (!splash) {
return null;
}
const parsedSplash = parseAvatar(splash);
return getMediaURL({
path: 'splashes',
id,
hash: parsedSplash.hash,
size,
format: 'webp',
});
};
export const getGuildEmbedSplashURL = ({id, embedSplash}: {id: string; embedSplash: string | null}, size = 1024) => {
if (!embedSplash) {
return null;
}
const parsedEmbedSplash = parseAvatar(embedSplash);
return getMediaURL({
path: 'embed-splashes',
id,
hash: parsedEmbedSplash.hash,
size,
format: 'webp',
});
};
export const getChannelIconURL = ({id, icon}: IconOptions, size?: number) => {
if (!icon) {
return null;
}
const parsedIcon = parseAvatar(icon);
return getMediaURL({
path: 'icons',
id,
hash: parsedIcon.hash,
size: size || 160,
format: 'webp',
});
};
export const getGuildMemberAvatarURL = ({
guildId,
userId,
avatar,
animated = false,
}: {
guildId: string;
userId: string;
avatar: string | null;
animated?: boolean;
}) => {
if (!avatar) {
return null;
}
const parsedAvatar = parseAvatar(avatar);
const shouldAnimate = parsedAvatar.animated ? animated : false;
return getGuildMemberMediaURL({
path: 'avatars',
guildId,
userId,
hash: parsedAvatar.hash,
size: 160,
format: shouldAnimate ? 'gif' : 'webp',
});
};
export const getGuildMemberBannerURL = ({
guildId,
userId,
banner,
animated = false,
size = 1024,
}: {
guildId: string;
userId: string;
banner: string | null;
animated?: boolean;
size?: number;
}) => {
if (!banner) {
return null;
}
const parsedBanner = parseAvatar(banner);
const shouldAnimate = parsedBanner.animated ? animated : false;
return getGuildMemberMediaURL({
path: 'banners',
guildId,
userId,
hash: parsedBanner.hash,
size,
format: shouldAnimate ? 'gif' : 'webp',
});
};
export const getEmojiURL = ({id, animated}: {id: string; animated?: boolean}) => {
if (DeveloperOptionsStore.forceRenderPlaceholders) {
return '';
}
return mediaUrl(`emojis/${id}.${animated ? 'gif' : 'webp'}`);
};
type StickerSize = 160 | 320;
export const getStickerURL = ({id, animated, size = 320}: {id: string; animated?: boolean; size?: StickerSize}) => {
if (DeveloperOptionsStore.forceRenderPlaceholders) {
return '';
}
const safeSize: StickerSize = size === 320 ? 320 : 160;
const ext = animated ? 'gif' : 'webp';
return mediaUrl(`stickers/${id}.${ext}?size=${safeSize}`);
};
export const fileToBase64 = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});

View File

@@ -0,0 +1,121 @@
/*
* 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/>.
*/
const DB_NAME = 'FluxerBackgroundImages';
const DB_VERSION = 1;
const STORE_NAME = 'backgroundImages';
interface BackgroundImageData {
id: string;
blob: Blob;
createdAt: number;
}
let dbInstance: IDBDatabase | null = null;
const openDB = (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
if (dbInstance) {
resolve(dbInstance);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
reject(new Error('Failed to open IndexedDB'));
};
request.onsuccess = () => {
dbInstance = request.result;
resolve(dbInstance);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, {keyPath: 'id'});
}
};
});
};
export const saveBackgroundImage = async (id: string, blob: Blob): Promise<void> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const backgroundImage: BackgroundImageData = {
id,
blob,
createdAt: Date.now(),
};
const request = store.put(backgroundImage);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error('Failed to save background image'));
};
});
};
const getBackgroundImage = async (id: string): Promise<BackgroundImageData | null> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(id);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error('Failed to get background image'));
};
});
};
export const deleteBackgroundImage = async (id: string): Promise<void> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error('Failed to delete background image'));
};
});
};
export const getBackgroundImageURL = async (id: string): Promise<string | null> => {
const imageData = await getBackgroundImage(id);
if (!imageData) return null;
return URL.createObjectURL(imageData.blob);
};

View File

@@ -0,0 +1,99 @@
/*
* 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/>.
*/
const HIGHLIGHT_NAME = 'settings-search-highlight';
export function isHighlightAPISupported(): boolean {
return typeof CSS !== 'undefined' && 'highlights' in CSS;
}
export function clearHighlights(): void {
if (!isHighlightAPISupported()) return;
CSS.highlights.clear();
}
function findAllTextNodes(container: HTMLElement): Array<Text> {
const textNodes: Array<Text> = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let currentNode = walker.nextNode();
while (currentNode) {
const textNode = currentNode as Text;
if (textNode.textContent && textNode.textContent.trim().length > 0) {
textNodes.push(textNode);
}
currentNode = walker.nextNode();
}
return textNodes;
}
function createRangesForMatches(textNodes: Array<Text>, query: string): Array<Range> {
const ranges: Array<Range> = [];
const cleanQuery = query.trim().toLowerCase();
if (!cleanQuery) return ranges;
textNodes.forEach((textNode) => {
const text = textNode.textContent || '';
const lowerText = text.toLowerCase();
let startPos = 0;
while (startPos < lowerText.length) {
const index = lowerText.indexOf(cleanQuery, startPos);
if (index === -1) break;
const range = new Range();
range.setStart(textNode, index);
range.setEnd(textNode, index + cleanQuery.length);
ranges.push(range);
startPos = index + cleanQuery.length;
}
});
return ranges;
}
export function createRangesForSection(container: HTMLElement, query: string): Array<Range> {
if (!isHighlightAPISupported()) {
return [];
}
const cleanQuery = query.trim();
if (!cleanQuery) {
return [];
}
const textNodes = findAllTextNodes(container);
return createRangesForMatches(textNodes, cleanQuery);
}
export function setHighlightRanges(ranges: Array<Range>): void {
if (!isHighlightAPISupported()) return;
CSS.highlights.clear();
if (ranges.length === 0) {
return;
}
const highlight = new Highlight(...ranges);
CSS.highlights.set(HIGHLIGHT_NAME, highlight);
}

View File

@@ -0,0 +1,38 @@
/*
* 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 CallActionCreators from '~/actions/CallActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {CallNotRingableModal} from '~/components/alerts/CallNotRingableModal';
export async function checkAndStartCall(channelId: string, silent = false): Promise<boolean> {
try {
const {ringable} = await CallActionCreators.checkCallEligibility(channelId);
if (!ringable) {
ModalActionCreators.push(modal(() => <CallNotRingableModal />));
return false;
}
CallActionCreators.startCall(channelId, silent);
return true;
} catch (error) {
console.error('Failed to check call eligibility:', error);
return false;
}
}

View File

@@ -0,0 +1,91 @@
/*
* 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/>.
*/
const HIGHLIGHT_NAME = 'channel-search-highlight';
function isHighlightAPISupported(): boolean {
return typeof CSS !== 'undefined' && 'highlights' in CSS;
}
function findAllTextNodes(container: HTMLElement): Array<Text> {
const textNodes: Array<Text> = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let currentNode = walker.nextNode();
while (currentNode) {
const textNode = currentNode as Text;
if (textNode.textContent && textNode.textContent.trim().length > 0) {
textNodes.push(textNode);
}
currentNode = walker.nextNode();
}
return textNodes;
}
function createRangesForSearchTerms(textNodes: Array<Text>, searchTerms: Array<string>): Array<Range> {
const ranges: Array<Range> = [];
const cleanTerms = searchTerms.map((term) => term.trim().toLowerCase()).filter((term) => term.length > 0);
if (cleanTerms.length === 0) return ranges;
for (const textNode of textNodes) {
const text = textNode.textContent || '';
const lowerText = text.toLowerCase();
for (const term of cleanTerms) {
let startPos = 0;
while (startPos < lowerText.length) {
const index = lowerText.indexOf(term, startPos);
if (index === -1) break;
const range = new Range();
range.setStart(textNode, index);
range.setEnd(textNode, index + term.length);
ranges.push(range);
startPos = index + term.length;
}
}
}
return ranges;
}
export function applyChannelSearchHighlight(container: HTMLElement, searchTerms: Array<string>): void {
if (!isHighlightAPISupported()) return;
CSS.highlights.delete(HIGHLIGHT_NAME);
if (searchTerms.length === 0) return;
const textNodes = findAllTextNodes(container);
const ranges = createRangesForSearchTerms(textNodes, searchTerms);
if (ranges.length === 0) return;
const highlight = new Highlight(...ranges);
CSS.highlights.set(HIGHLIGHT_NAME, highlight);
}
export function clearChannelSearchHighlight(): void {
if (!isHighlightAPISupported()) return;
CSS.highlights.delete(HIGHLIGHT_NAME);
}

View File

@@ -0,0 +1,113 @@
/*
* 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 {msg} from '@lingui/core/macro';
import {
CaretDownIcon,
HashIcon,
type IconProps,
LinkIcon,
NotePencilIcon,
SpeakerHighIcon,
} from '@phosphor-icons/react';
import {ChannelTypes} from '~/Constants';
import {NSFWIcon} from '~/components/icons/NSFWIcon';
import i18n from '~/i18n';
import type {ChannelRecord} from '~/records/ChannelRecord';
import ChannelDisplayNameStore from '~/stores/ChannelDisplayNameStore';
import UserStore from '~/stores/UserStore';
import * as NicknameUtils from '~/utils/NicknameUtils';
import {compareChannelPosition} from './channelShared';
export const compareChannels = (a: ChannelRecord, b: ChannelRecord): number => compareChannelPosition(a, b);
export const getIcon = (channel: {type: number; nsfw?: boolean}, props: IconProps = {}) => {
if (channel.type === ChannelTypes.GUILD_TEXT && channel.nsfw) {
return <NSFWIcon {...props} />;
}
switch (channel.type) {
case ChannelTypes.GUILD_VOICE:
return <SpeakerHighIcon weight="fill" {...props} />;
case ChannelTypes.GUILD_CATEGORY:
return <CaretDownIcon weight="bold" {...props} />;
case ChannelTypes.GUILD_LINK:
return <LinkIcon weight="bold" {...props} />;
case ChannelTypes.DM_PERSONAL_NOTES:
return <NotePencilIcon weight="bold" {...props} />;
default:
return <HashIcon weight="bold" {...props} />;
}
};
export const getName = (channel: ChannelRecord) => {
let baseName: string;
switch (channel.type) {
case ChannelTypes.GUILD_VOICE:
baseName = i18n._(msg`Voice`);
break;
case ChannelTypes.GUILD_CATEGORY:
baseName = i18n._(msg`Category`);
break;
case ChannelTypes.GUILD_LINK:
baseName = i18n._(msg`Link`);
break;
default:
baseName = i18n._(msg`Text`);
break;
}
if (channel.type === ChannelTypes.GUILD_TEXT && channel.nsfw) {
return i18n._(msg`Text (NSFW)`);
}
return baseName;
};
const getDirectMessageDisplayName = (channel: ChannelRecord): string => {
if (channel.recipientIds.length === 0) {
return i18n._(msg`Unknown User`);
}
const recipient = UserStore.getUser(channel.recipientIds[0]);
const nickname = recipient ? NicknameUtils.getNickname(recipient) : null;
return nickname ?? i18n._(msg`Unknown User`);
};
const getGroupDMDisplayName = (channel: ChannelRecord): string => {
const customName = channel.name?.trim() ?? '';
if (customName.length > 0) {
return customName;
}
return ChannelDisplayNameStore.getDisplayName(channel.id) ?? i18n._(msg`Unnamed Group`);
};
export const getDMDisplayName = (channel: ChannelRecord): string => {
switch (channel.type) {
case ChannelTypes.DM_PERSONAL_NOTES:
return i18n._(msg`Personal Notes`);
case ChannelTypes.DM:
return getDirectMessageDisplayName(channel);
case ChannelTypes.GROUP_DM:
return getGroupDMDisplayName(channel);
default:
return channel.name || i18n._(msg`Unknown Channel`);
}
};

View File

@@ -0,0 +1,178 @@
/*
* 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 {UAParser} from 'ua-parser-js';
import Config from '~/Config';
import {getElectronAPI, isDesktop} from '~/utils/NativeUtils';
interface ClientInfo {
browserName?: string;
browserVersion?: string;
osName?: string;
osVersion?: string;
arch?: string;
desktopVersion?: string;
desktopChannel?: string;
desktopArch?: string;
desktopOS?: string;
}
const normalize = <T>(value: T | null | undefined): T | undefined => value ?? undefined;
let cachedClientInfo: ClientInfo | null = null;
let preloadPromise: Promise<ClientInfo> | null = null;
const parseUserAgent = (): ClientInfo => {
const parser = new UAParser();
const result = parser.getResult();
return {
browserName: normalize(result.browser.name),
browserVersion: normalize(result.browser.version),
osName: normalize(result.os.name),
osVersion: normalize(result.os.version),
arch: normalize(result.cpu.architecture),
};
};
export const getClientInfoSync = (): ClientInfo => {
if (cachedClientInfo) {
return cachedClientInfo;
}
try {
return parseUserAgent();
} catch {
return {};
}
};
export const preloadClientInfo = (): Promise<ClientInfo> => {
if (cachedClientInfo) {
return Promise.resolve(cachedClientInfo);
}
if (preloadPromise) {
return preloadPromise;
}
preloadPromise = getClientInfo().then((info) => {
cachedClientInfo = info;
return info;
});
return preloadPromise;
};
async function getDesktopContext(): Promise<Partial<ClientInfo>> {
const electronApi = getElectronAPI();
if (electronApi) {
try {
const desktopInfo = await electronApi.getDesktopInfo();
return {
desktopVersion: normalize(desktopInfo.version),
desktopChannel: normalize(desktopInfo.channel),
desktopArch: normalize(desktopInfo.arch),
desktopOS: normalize(desktopInfo.os),
};
} catch (error) {
console.warn('[ClientInfo] Failed to load desktop context', error);
return {};
}
}
return {};
}
function getWindowsVersionName(osVersion: string): string {
const parts = osVersion.split('.');
const majorVersion = parseInt(parts[0], 10);
const buildNumber = parseInt(parts[2], 10);
if (majorVersion === 10) {
if (buildNumber >= 22000) {
return 'Windows 11';
}
return 'Windows 10';
}
return 'Windows';
}
async function getOsContext(): Promise<Partial<ClientInfo>> {
const electronApi = getElectronAPI();
if (electronApi) {
try {
const desktopInfo = await electronApi.getDesktopInfo();
let osName: string | undefined;
let osVersion: string | undefined;
switch (desktopInfo.os) {
case 'darwin':
osName = 'macOS';
break;
case 'win32':
osName = getWindowsVersionName(desktopInfo.osVersion);
osVersion = desktopInfo.osVersion;
break;
case 'linux':
osName = 'Linux';
break;
default:
osName = desktopInfo.os;
}
return {
osName,
osVersion,
arch: normalize(desktopInfo.arch),
};
} catch (error) {
console.warn('[ClientInfo] Failed to load OS context', error);
return {};
}
}
return {};
}
export const getClientInfo = async (): Promise<ClientInfo> => {
const base = getClientInfoSync();
if (!isDesktop()) return base;
const [osContext, desktop] = await Promise.all([getOsContext(), getDesktopContext()]);
return {...base, ...osContext, ...desktop};
};
export const getGatewayClientProperties = async (geo?: {latitude?: string | null; longitude?: string | null}) => {
const info = await getClientInfo();
return {
os: info.osName ?? 'Unknown',
os_version: info.osVersion ?? '',
browser: info.browserName ?? 'Unknown',
browser_version: info.browserVersion ?? '',
device: info.arch ?? 'unknown',
system_locale: navigator.language,
locale: navigator.language,
user_agent: navigator.userAgent,
build_timestamp: Config.PUBLIC_BUILD_TIMESTAMP != null ? String(Config.PUBLIC_BUILD_TIMESTAMP) : '',
build_sha: Config.PUBLIC_BUILD_SHA ?? '',
build_number: Config.PUBLIC_BUILD_NUMBER != null ? Config.PUBLIC_BUILD_NUMBER : null,
desktop_app_version: info.desktopVersion ?? null,
desktop_app_channel: info.desktopChannel ?? null,
desktop_arch: info.desktopArch ?? info.arch ?? null,
desktop_os: info.desktopOS ?? info.osName ?? null,
...(geo?.latitude ? {latitude: geo.latitude} : {}),
...(geo?.longitude ? {longitude: geo.longitude} : {}),
};
};

View File

@@ -0,0 +1,86 @@
/*
* 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 RegexUtils from '~/utils/RegexUtils';
export interface CodeLinkConfig {
shortHost: string;
path: string;
}
const patternCache = new Map<string, RegExp>();
function createPattern(config: CodeLinkConfig): RegExp {
const cacheKey = `${config.shortHost}:${config.path}`;
let pattern = patternCache.get(cacheKey);
if (pattern) {
return pattern;
}
pattern = new RegExp(
[
'(?:https?:\\/\\/)?',
'(?:',
`${RegexUtils.escapeRegex(config.shortHost)}(?:\\/#)?\\/(?!${config.path}\\/)([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
'|',
`${RegexUtils.escapeRegex(location.host)}(?:\\/#)?\\/${config.path}\\/([a-zA-Z0-9\\-]{2,32})(?![a-zA-Z0-9\\-])`,
')',
].join(''),
'gi',
);
patternCache.set(cacheKey, pattern);
return pattern;
}
export function findCodes(content: string | null, config: CodeLinkConfig): Array<string> {
if (!content) return [];
const codes: Array<string> = [];
const seenCodes = new Set<string>();
const pattern = createPattern(config);
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null && codes.length < 10) {
const code = match[1] || match[2];
if (code && !seenCodes.has(code)) {
seenCodes.add(code);
codes.push(code);
}
}
return codes;
}
export function findCode(content: string | null, config: CodeLinkConfig): string | null {
if (!content) return null;
const pattern = createPattern(config);
pattern.lastIndex = 0;
const match = pattern.exec(content);
if (match) {
return match[1] || match[2];
}
return null;
}

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/>.
*/
const pad2 = (s: string) => (s.length === 1 ? `0${s}` : s);
export const int2hex = (colorInt: number) => {
const r = (colorInt >> 16) & 0xff;
const g = (colorInt >> 8) & 0xff;
const b = colorInt & 0xff;
return `#${pad2(r.toString(16))}${pad2(g.toString(16))}${pad2(b.toString(16))}`;
};
export const int2rgba = (colorInt: number, alpha?: number) => {
if (alpha == null) {
alpha = ((colorInt >> 24) & 0xff) / 255;
}
const r = (colorInt >> 16) & 0xff;
const g = (colorInt >> 8) & 0xff;
const b = colorInt & 0xff;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
export const int2rgb = (colorInt: number) => {
if (colorInt === 0) {
return 'rgb(219, 222, 225)';
}
const r = (colorInt >> 16) & 0xff;
const g = (colorInt >> 8) & 0xff;
const b = colorInt & 0xff;
return `rgb(${r}, ${g}, ${b})`;
};
export const getBestContrastColor = (colorInt: number): 'black' | 'white' => {
if (colorInt === 0) {
return 'black';
}
const r = (colorInt >> 16) & 0xff;
const g = (colorInt >> 8) & 0xff;
const b = colorInt & 0xff;
const rsRGB = r / 255;
const gsRGB = g / 255;
const bsRGB = b / 255;
const rLinear = rsRGB <= 0.03928 ? rsRGB / 12.92 : ((rsRGB + 0.055) / 1.055) ** 2.4;
const gLinear = gsRGB <= 0.03928 ? gsRGB / 12.92 : ((gsRGB + 0.055) / 1.055) ** 2.4;
const bLinear = bsRGB <= 0.03928 ? bsRGB / 12.92 : ((bsRGB + 0.055) / 1.055) ** 2.4;
const luminance = 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
return luminance > 0.5 ? 'black' : 'white';
};

View File

@@ -0,0 +1,191 @@
/*
* 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 {describe, expect, test} from 'vitest';
import {isCommand, parseCommand, transformWrappingCommands} from './CommandUtils';
describe('CommandUtils', () => {
describe('parseCommand', () => {
test('should parse /nick command', () => {
const result = parseCommand('/nick NewNickname');
expect(result).toEqual({
type: 'nick',
nickname: 'NewNickname',
});
});
test('should parse /kick command with user mention', () => {
const result = parseCommand('/kick <@123456789> Spamming');
expect(result).toEqual({
type: 'kick',
userId: '123456789',
reason: 'Spamming',
});
});
test('should parse /kick command with user mention and no reason', () => {
const result = parseCommand('/kick <@123456789>');
expect(result).toEqual({
type: 'kick',
userId: '123456789',
reason: undefined,
});
});
test('should parse /ban command with user mention', () => {
const result = parseCommand('/ban <@123456789> Harassment');
expect(result).toEqual({
type: 'ban',
userId: '123456789',
deleteMessageDays: 1,
duration: 0,
reason: 'Harassment',
});
});
test('should parse /msg command with user mention', () => {
const result = parseCommand('/msg <@123456789> Hello there!');
expect(result).toEqual({
type: 'msg',
userId: '123456789',
message: 'Hello there!',
});
});
test('should return unknown for invalid /msg command', () => {
const result = parseCommand('/msg <@123456789>');
expect(result).toEqual({type: 'unknown'});
});
test('should return unknown for command without user mention', () => {
const result = parseCommand('/kick username');
expect(result).toEqual({type: 'unknown'});
});
test('should return unknown for unknown command', () => {
const result = parseCommand('/unknown command');
expect(result).toEqual({type: 'unknown'});
});
test('should handle whitespace in commands', () => {
const result = parseCommand(' /nick NewNickname ');
expect(result).toEqual({
type: 'nick',
nickname: 'NewNickname',
});
});
test('should parse /me command', () => {
const result = parseCommand('/me does something');
expect(result).toEqual({type: 'me', content: 'does something'});
});
test('should parse /me command with whitespace', () => {
const result = parseCommand(' /me does something ');
expect(result).toEqual({type: 'me', content: 'does something'});
});
test('should return unknown for empty /me command', () => {
const result = parseCommand('/me ');
expect(result).toEqual({type: 'unknown'});
});
test('should parse /spoiler command', () => {
const result = parseCommand('/spoiler secret message');
expect(result).toEqual({type: 'spoiler', content: 'secret message'});
});
test('should parse /spoiler command with whitespace', () => {
const result = parseCommand(' /spoiler secret message ');
expect(result).toEqual({type: 'spoiler', content: 'secret message'});
});
test('should return unknown for empty /spoiler command', () => {
const result = parseCommand('/spoiler ');
expect(result).toEqual({type: 'unknown'});
});
});
describe('isCommand', () => {
test('should return true for valid commands', () => {
expect(isCommand('/nick test')).toBe(true);
expect(isCommand('/kick <@123456789>')).toBe(true);
expect(isCommand('/ban <@123456789>')).toBe(true);
expect(isCommand('/msg <@123456789> hello')).toBe(true);
expect(isCommand('/me does something')).toBe(true);
expect(isCommand('/spoiler secret message')).toBe(true);
expect(isCommand('_action_')).toBe(true);
});
test('should return false for non-commands', () => {
expect(isCommand('hello world')).toBe(false);
expect(isCommand('/unknown')).toBe(false);
expect(isCommand('')).toBe(false);
});
test('should return false for incomplete commands', () => {
expect(isCommand('/nick')).toBe(false);
expect(isCommand('/kick')).toBe(false);
expect(isCommand('/ban')).toBe(false);
expect(isCommand('/msg')).toBe(false);
});
test('should handle whitespace', () => {
expect(isCommand(' /nick test ')).toBe(true);
expect(isCommand(' _action_ ')).toBe(true);
});
});
describe('transformWrappingCommands', () => {
test('should transform /me command', () => {
const result = transformWrappingCommands('/me does something');
expect(result).toBe('_does something_');
});
test('should transform /me command with whitespace', () => {
const result = transformWrappingCommands(' /me does something ');
expect(result).toBe('_does something_');
});
test('should not transform empty /me command', () => {
const result = transformWrappingCommands('/me ');
expect(result).toBe('/me ');
});
test('should transform /spoiler command', () => {
const result = transformWrappingCommands('/spoiler secret message');
expect(result).toBe('||secret message||');
});
test('should transform /spoiler command with whitespace', () => {
const result = transformWrappingCommands(' /spoiler secret message ');
expect(result).toBe('||secret message||');
});
test('should not transform empty /spoiler command', () => {
const result = transformWrappingCommands('/spoiler ');
expect(result).toBe('/spoiler ');
});
test('should return original content for non-wrapping commands', () => {
const result = transformWrappingCommands('hello world');
expect(result).toBe('hello world');
});
});
});

View File

@@ -0,0 +1,281 @@
/*
* 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 GuildActionCreators from '~/actions/GuildActionCreators';
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
import {FLUXERBOT_ID, MessageStates, MessageTypes} from '~/Constants';
import {MessageRecord} from '~/records/MessageRecord';
import {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import UserStore from '~/stores/UserStore';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
const USER_MENTION_REGEX = /<@!?(\d+)>/;
type ParsedCommand =
| {type: 'nick'; nickname: string}
| {type: 'kick'; userId: string; reason?: string}
| {type: 'ban'; userId: string; deleteMessageDays: number; duration: number; reason?: string}
| {type: 'msg'; userId: string; message: string}
| {type: 'me'; content: string}
| {type: 'spoiler'; content: string}
| {type: 'tts'; content: string}
| {type: 'unknown'};
export function parseCommand(content: string): ParsedCommand {
const trimmed = content.trim();
if (trimmed.startsWith('/nick ')) {
const nickname = trimmed.slice(6).trim();
return {type: 'nick', nickname};
}
if (trimmed.startsWith('/kick ')) {
const rest = trimmed.slice(6).trim();
const userMatch = rest.match(USER_MENTION_REGEX);
if (!userMatch) {
return {type: 'unknown'};
}
const userId = userMatch[1];
const afterMention = rest.slice(userMatch[0].length).trim();
const reason = afterMention || undefined;
return {type: 'kick', userId, reason};
}
if (trimmed.startsWith('/ban ')) {
const rest = trimmed.slice(5).trim();
const userMatch = rest.match(USER_MENTION_REGEX);
if (!userMatch) {
return {type: 'unknown'};
}
const userId = userMatch[1];
const afterMention = rest.slice(userMatch[0].length).trim();
// TODO: Parse these from the command
const deleteMessageDays = 1;
const duration = 0;
const reason = afterMention || undefined;
return {type: 'ban', userId, deleteMessageDays, duration, reason};
}
if (trimmed.startsWith('/msg ')) {
const rest = trimmed.slice(5).trim();
const userMatch = rest.match(USER_MENTION_REGEX);
if (!userMatch) {
return {type: 'unknown'};
}
const userId = userMatch[1];
const message = rest.slice(userMatch[0].length).trim();
if (!message) {
return {type: 'unknown'};
}
return {type: 'msg', userId, message};
}
if (trimmed.startsWith('/me ')) {
const content = trimmed.slice(4).trim();
if (!content) {
return {type: 'unknown'};
}
return {type: 'me', content};
}
if (trimmed.startsWith('/spoiler ')) {
const content = trimmed.slice(9).trim();
if (!content) {
return {type: 'unknown'};
}
return {type: 'spoiler', content};
}
if (trimmed.startsWith('/tts ')) {
const content = trimmed.slice(5).trim();
if (!content) {
return {type: 'unknown'};
}
return {type: 'tts', content};
}
return {type: 'unknown'};
}
export function transformWrappingCommands(content: string): string {
const trimmed = content.trim();
if (trimmed.startsWith('/me ')) {
const messageContent = trimmed.slice(4).trim();
if (messageContent) {
return `_${messageContent}_`;
}
}
if (trimmed.startsWith('/spoiler ')) {
const messageContent = trimmed.slice(9).trim();
if (messageContent) {
return `||${messageContent}||`;
}
}
return content;
}
export function isCommand(content: string): boolean {
const trimmed = content.trim();
return (
trimmed.startsWith('/nick ') ||
trimmed.startsWith('/kick ') ||
trimmed.startsWith('/ban ') ||
trimmed.startsWith('/msg ') ||
trimmed.startsWith('/me ') ||
trimmed.startsWith('/spoiler ') ||
trimmed.startsWith('/tts ') ||
(trimmed.startsWith('_') && trimmed.endsWith('_') && trimmed.length > 2)
);
}
export function createSystemMessage(channelId: string, content: string): MessageRecord {
const fluxerbotUser = new UserRecord({
id: FLUXERBOT_ID,
username: 'Fluxerbot',
discriminator: '0000',
avatar: null,
bot: true,
system: true,
flags: 0,
});
const nonce = SnowflakeUtils.fromTimestamp(Date.now());
return new MessageRecord({
id: nonce,
channel_id: channelId,
author: fluxerbotUser.toJSON(),
type: MessageTypes.CLIENT_SYSTEM,
flags: 0,
pinned: false,
mention_everyone: false,
content,
timestamp: new Date().toISOString(),
state: MessageStates.SENT,
nonce,
attachments: [],
});
}
export async function executeCommand(command: ParsedCommand, channelId: string, guildId?: string): Promise<void> {
const currentUserId = AuthenticationStore.currentUserId;
switch (command.type) {
case 'nick': {
if (!guildId) {
throw new Error('Cannot change nickname outside of a guild');
}
const currentMember = GuildMemberStore.getMember(guildId, currentUserId);
const prevNickname = currentMember?.nick || UserStore.getCurrentUser()?.username || 'Unknown';
const newNickname = command.nickname || UserStore.getCurrentUser()?.username || 'Unknown';
await GuildMemberActionCreators.updateProfile(guildId, {
nick: command.nickname || null,
});
const systemMessage = createSystemMessage(
channelId,
`You changed your nickname in this community from **${prevNickname}** to **${newNickname}**.`,
);
MessageActionCreators.createOptimistic(channelId, systemMessage.toJSON());
break;
}
case 'kick': {
if (!guildId) {
throw new Error('Cannot kick members outside of a guild');
}
await GuildMemberActionCreators.kick(guildId, command.userId);
break;
}
case 'ban': {
if (!guildId) {
throw new Error('Cannot ban members outside of a guild');
}
await GuildActionCreators.banMember(
guildId,
command.userId,
command.deleteMessageDays,
command.reason,
command.duration,
);
break;
}
case 'msg': {
try {
const dmChannelId = await PrivateChannelActionCreators.ensureDMChannel(command.userId);
await MessageActionCreators.send(dmChannelId, {
content: command.message,
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
hasAttachments: false,
flags: 0,
});
await PrivateChannelActionCreators.openDMChannel(command.userId);
} catch (_error) {
const user = UserStore.getUser(command.userId);
const username = user?.username || 'user';
const systemMessage = createSystemMessage(
channelId,
`Failed to send a message to **${username}**. They may have DMs disabled or you may be blocked.`,
);
MessageActionCreators.createOptimistic(channelId, systemMessage.toJSON());
}
break;
}
case 'me': {
break;
}
case 'spoiler': {
break;
}
default:
break;
}
}

View File

@@ -0,0 +1,175 @@
/*
* 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 {msg} from '@lingui/core/macro';
import type React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {GuildMemberContextMenu} from '~/components/uikit/ContextMenu/GuildMemberContextMenu';
import {UserContextMenu} from '~/components/uikit/ContextMenu/UserContextMenu';
import i18n from '~/i18n';
import type {MuteConfig} from '~/records/UserGuildSettingsRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import UserStore from '~/stores/UserStore';
import {isLegacyDocument} from '~/types/browser';
import {getFormattedDateTime} from '~/utils/DateUtils';
function getSelectionText(): string {
let text = '';
if (window.getSelection) {
text = window.getSelection()?.toString() || '';
} else if (isLegacyDocument(document) && document.selection && document.selection.type !== 'Control') {
text = document.selection.createRange().text;
}
return text;
}
function findUserData(element: HTMLElement): {userId?: string; guildId?: string; channelId?: string} {
let current: HTMLElement | null = element;
while (current) {
const userId = current.dataset.userId || current.getAttribute('data-user-id');
const guildId = current.dataset.guildId || current.getAttribute('data-guild-id');
const channelId = current.dataset.channelId || current.getAttribute('data-channel-id');
if (userId) {
return {userId, guildId: guildId || undefined, channelId: channelId || undefined};
}
current = current.parentElement;
}
return {};
}
export function handleContextMenu(e: MouseEvent): void {
const target = e.target as HTMLElement;
const {userId, guildId, channelId} = findUserData(target);
if (userId) {
const user = UserStore.getUser(userId);
if (user) {
e.preventDefault();
e.stopPropagation();
const isGuildMember = guildId ? GuildMemberStore.getMember(guildId, user.id) : null;
const view = (e.view ?? null) as unknown as React.MouseEvent<HTMLElement>['view'];
const reactEvent = {
nativeEvent: e,
currentTarget: target,
target: target,
pageX: e.pageX,
pageY: e.pageY,
preventDefault: () => e.preventDefault(),
stopPropagation: () => e.stopPropagation(),
altKey: e.altKey,
button: e.button,
buttons: e.buttons,
clientX: e.clientX,
clientY: e.clientY,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
shiftKey: e.shiftKey,
screenX: e.screenX,
screenY: e.screenY,
detail: e.detail,
bubbles: e.bubbles,
cancelable: e.cancelable,
defaultPrevented: e.defaultPrevented,
eventPhase: e.eventPhase,
isTrusted: e.isTrusted,
movementX: e.movementX,
movementY: e.movementY,
relatedTarget: e.relatedTarget,
timeStamp: e.timeStamp,
type: e.type,
view,
getModifierState: e.getModifierState.bind(e),
isDefaultPrevented: () => e.defaultPrevented,
isPropagationStopped: () => false,
persist: () => {},
} satisfies React.MouseEvent<HTMLElement>;
ContextMenuActionCreators.openFromEvent(reactEvent, ({onClose}) =>
guildId && isGuildMember ? (
<GuildMemberContextMenu user={user} onClose={onClose} guildId={guildId} channelId={channelId} />
) : (
<UserContextMenu user={user} onClose={onClose} guildId={guildId} channelId={channelId} />
),
);
return;
}
}
const selectedText = getSelectionText();
let href: string | null = null;
let src: string | null = null;
let node: HTMLElement | null = target;
while (node) {
if (node instanceof HTMLAnchorElement) {
href = node.href;
}
if (node instanceof HTMLImageElement) {
src = node.src;
}
node = node.parentElement;
}
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
if (selectedText) {
return;
}
if (href || src) {
return;
}
e.preventDefault();
}
export function getMutedText(isMuted: boolean, muteConfig?: MuteConfig): string | undefined {
if (!isMuted) return;
const now = Date.now();
if (muteConfig?.end_time && new Date(muteConfig.end_time).getTime() <= now) {
return;
}
if (muteConfig?.end_time) {
return i18n._(msg`Muted until ${getFormattedDateTime(new Date(muteConfig.end_time))}`);
}
return i18n._(msg`Muted`);
}
export function getNotificationSettingsLabel(currentNotificationLevel: number): string | undefined {
switch (currentNotificationLevel) {
case 0:
return i18n._(msg`All Messages`);
case 1:
return i18n._(msg`Only @mentions`);
case 2:
return i18n._(msg`Nothing`);
case 3:
return i18n._(msg`Use Category Default`);
default:
return;
}
}

View File

@@ -0,0 +1,272 @@
/*
* 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 {SoundType} from './SoundUtils';
const DB_NAME = 'FluxerCustomSounds';
const DB_VERSION = 2;
const STORE_NAME = 'customSounds';
const ENTRANCE_SOUND_STORE = 'entranceSound';
export interface CustomSound {
soundType: SoundType;
blob: Blob;
fileName: string;
uploadedAt: number;
}
let dbInstance: IDBDatabase | null = null;
const openDB = (): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
if (dbInstance) {
resolve(dbInstance);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
reject(new Error('Failed to open IndexedDB'));
};
request.onsuccess = () => {
dbInstance = request.result;
resolve(dbInstance);
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, {keyPath: 'soundType'});
}
if (!db.objectStoreNames.contains(ENTRANCE_SOUND_STORE)) {
db.createObjectStore(ENTRANCE_SOUND_STORE);
}
};
});
};
export const saveCustomSound = async (soundType: SoundType, blob: Blob, fileName: string): Promise<void> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const customSound: CustomSound = {
soundType,
blob,
fileName,
uploadedAt: Date.now(),
};
const request = store.put(customSound);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error('Failed to save custom sound'));
};
});
};
export const getCustomSound = async (soundType: SoundType): Promise<CustomSound | null> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.get(soundType);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error('Failed to get custom sound'));
};
});
};
export const deleteCustomSound = async (soundType: SoundType): Promise<void> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
const request = store.delete(soundType);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error('Failed to delete custom sound'));
};
});
};
export const getAllCustomSounds = async (): Promise<Array<CustomSound>> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result || []);
};
request.onerror = () => {
reject(new Error('Failed to get all custom sounds'));
};
});
};
const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.opus', '.webm'] as const;
export const SUPPORTED_MIME_TYPES = [
'audio/mpeg',
'audio/wav',
'audio/ogg',
'audio/mp4',
'audio/aac',
'audio/flac',
'audio/opus',
'audio/webm',
] as const;
const MAX_FILE_SIZE = 2 * 1024 * 1024;
const MAX_ENTRANCE_SOUND_DURATION = 5.2;
export const isValidAudioFile = (file: File): {valid: boolean; error?: string} => {
if (file.size > MAX_FILE_SIZE) {
return {valid: false, error: 'File size must be 2MB or less'};
}
const fileExtension = `.${file.name.split('.').pop()?.toLowerCase()}`;
const isValidExtension = SUPPORTED_AUDIO_FORMATS.some((ext) => ext === fileExtension);
const isValidMimeType = SUPPORTED_MIME_TYPES.some((mime) => file.type.startsWith(mime));
if (!isValidExtension && !isValidMimeType) {
return {
valid: false,
error: `Invalid file type. Supported formats: ${SUPPORTED_AUDIO_FORMATS.join(', ')}`,
};
}
return {valid: true};
};
export const validateAudioDuration = (file: File): Promise<{valid: boolean; error?: string; duration?: number}> => {
return new Promise((resolve) => {
const audio = new Audio();
const url = URL.createObjectURL(file);
audio.onloadedmetadata = () => {
URL.revokeObjectURL(url);
const duration = audio.duration;
if (duration > MAX_ENTRANCE_SOUND_DURATION) {
resolve({
valid: false,
error: `Audio duration must be ${MAX_ENTRANCE_SOUND_DURATION} seconds or less`,
duration,
});
} else {
resolve({valid: true, duration});
}
};
audio.onerror = () => {
URL.revokeObjectURL(url);
resolve({valid: false, error: 'Failed to load audio file'});
};
audio.src = url;
});
};
export interface EntranceSound {
blob: Blob;
fileName: string;
duration: number;
uploadedAt: number;
}
const ENTRANCE_SOUND_KEY = 'userEntranceSound';
export const saveEntranceSound = async (blob: Blob, fileName: string, duration: number): Promise<void> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([ENTRANCE_SOUND_STORE], 'readwrite');
const store = transaction.objectStore(ENTRANCE_SOUND_STORE);
const entranceSound: EntranceSound = {
blob,
fileName,
duration,
uploadedAt: Date.now(),
};
const request = store.put(entranceSound, ENTRANCE_SOUND_KEY);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error('Failed to save entrance sound'));
};
});
};
export const getEntranceSound = async (): Promise<EntranceSound | null> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([ENTRANCE_SOUND_STORE], 'readonly');
const store = transaction.objectStore(ENTRANCE_SOUND_STORE);
const request = store.get(ENTRANCE_SOUND_KEY);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error('Failed to get entrance sound'));
};
});
};
export const deleteEntranceSound = async (): Promise<void> => {
const db = await openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([ENTRANCE_SOUND_STORE], 'readwrite');
const store = transaction.objectStore(ENTRANCE_SOUND_STORE);
const request = store.delete(ENTRANCE_SOUND_KEY);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error('Failed to delete entrance sound'));
};
});
};

View File

@@ -0,0 +1,176 @@
/*
* 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 {DateTime} from 'luxon';
import {TimeFormatTypes} from '~/Constants';
import AccessibilityStore from '~/stores/AccessibilityStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import {getCurrentLocale} from '~/utils/LocaleUtils';
const localeUses12Hour = (locale: string): boolean => {
const lang = locale.toLowerCase();
const twelveHourLocales = [
'en-us',
'en-ca',
'en-au',
'en-nz',
'en-ph',
'en-in',
'en-pk',
'en-bd',
'en-za',
'es-mx',
'es-co',
'ar',
'hi',
'bn',
'ur',
'fil',
'tl',
];
return twelveHourLocales.some((l) => lang.startsWith(l));
};
export const shouldUse12HourFormat = (locale: string): boolean => {
const timeFormat = UserSettingsStore.getTimeFormat();
switch (timeFormat) {
case TimeFormatTypes.TWELVE_HOUR:
return true;
case TimeFormatTypes.TWENTY_FOUR_HOUR:
return false;
default: {
const useBrowserLocale = AccessibilityStore.useBrowserLocaleForTimeFormat;
const effectiveLocale = useBrowserLocale ? navigator.language : locale;
return localeUses12Hour(effectiveLocale);
}
}
};
const parseDateTime = (timestamp: number | Date | string): DateTime => {
if (timestamp instanceof Date) {
return DateTime.fromJSDate(timestamp);
}
if (typeof timestamp === 'string') {
return DateTime.fromISO(timestamp);
}
if (timestamp == null || Number.isNaN(timestamp)) {
console.warn('[DateUtils] Invalid timestamp provided, using current time:', timestamp);
return DateTime.now();
}
return DateTime.fromMillis(timestamp);
};
export const isSameDay = (timestamp1: number | Date | string, timestamp2?: number | Date | string): boolean => {
const dt1 = parseDateTime(timestamp1);
const dt2 = timestamp2 != null ? parseDateTime(timestamp2) : DateTime.now();
return dt1.hasSame(dt2, 'day');
};
export const getRelativeDateString = (timestamp: number | Date | string, i18n: I18n): string => {
const locale = getCurrentLocale();
const dt = parseDateTime(timestamp).setLocale(locale);
const now = DateTime.now().setLocale(locale);
const timeString = dt.toLocaleString({
hour: 'numeric',
minute: '2-digit',
hour12: shouldUse12HourFormat(locale),
});
if (dt.hasSame(now, 'day')) {
return i18n._(msg`Today at ${timeString}`);
}
if (dt.hasSame(now.minus({days: 1}), 'day')) {
return i18n._(msg`Yesterday at ${timeString}`);
}
return dt.toLocaleString({
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: shouldUse12HourFormat(locale),
});
};
export const getFormattedDateTime = (timestamp: number | Date | string): string => {
const locale = getCurrentLocale();
const dt = parseDateTime(timestamp).setLocale(locale);
return dt.toLocaleString({
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: shouldUse12HourFormat(locale),
});
};
export const getFormattedShortDate = (timestamp: number | Date | string): string => {
return parseDateTime(timestamp).setLocale(getCurrentLocale()).toLocaleString(DateTime.DATE_MED);
};
export const getFormattedTime = (timestamp: number | Date | string): string => {
const locale = getCurrentLocale();
const dt = parseDateTime(timestamp).setLocale(locale);
return dt.toLocaleString({
hour: 'numeric',
minute: '2-digit',
hour12: shouldUse12HourFormat(locale),
});
};
export const getFormattedCompactDateTime = (timestamp: number | Date | string): string => {
const locale = getCurrentLocale();
const dt = parseDateTime(timestamp).setLocale(locale);
return dt.toFormat('M/d/yy, h:mm a');
};
export const getFormattedFullDate = (timestamp: number | Date | string): string => {
return parseDateTime(timestamp).setLocale(getCurrentLocale()).toLocaleString(DateTime.DATE_FULL);
};
export const getFormattedDateTimeWithSeconds = (timestamp: number | Date | string): string => {
const locale = getCurrentLocale();
const dt = parseDateTime(timestamp).setLocale(locale);
const datePart = dt.toLocaleString({
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
const timePart = dt.toLocaleString({
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: shouldUse12HourFormat(locale),
});
return `${datePart} ${timePart}`;
};
export const getShortRelativeDateString = (timestamp: number | Date | string): string => {
const result = parseDateTime(timestamp).setLocale(getCurrentLocale()).toRelative({style: 'short'});
return result ?? '';
};

View File

@@ -0,0 +1,109 @@
/*
* 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 {afterAll, beforeAll, describe, expect, test} from 'vitest';
import {parseChannelJumpLink, parseChannelUrl, parseMessageJumpLink} from './DeepLinkUtils';
const CANARY_BASE = 'https://canary.fluxer.app';
const STABLE_BASE = 'https://fluxer.app';
const CHANNEL_LINKS = [
`${CANARY_BASE}/channels/@me/1130650140672000000/1447659936007000077`,
`${CANARY_BASE}/channels/@me/1130650140672000000`,
`${CANARY_BASE}/channels/12345678901234567/23456789012345678`,
`${CANARY_BASE}/channels/12345678901234567/23456789012345678/34567890123456789`,
`${STABLE_BASE}/channels/@me/1130650140672000000/1447659936007000077`,
`${STABLE_BASE}/channels/@me/1130650140672000000`,
`${STABLE_BASE}/channels/12345678901234567/23456789012345678`,
`${STABLE_BASE}/channels/12345678901234567/23456789012345678/34567890123456789`,
];
const GUILD_CHANNEL = `${STABLE_BASE}/channels/12345678901234567/23456789012345678`;
const GUILD_MESSAGE = `${STABLE_BASE}/channels/12345678901234567/23456789012345678/34567890123456789`;
describe('parseChannelUrl', () => {
const originalLocationHref = globalThis.location.href;
beforeAll(() => {
globalThis.location.href = CANARY_BASE;
});
afterAll(() => {
globalThis.location.href = originalLocationHref;
});
for (const url of CHANNEL_LINKS) {
test(`returns pathname for ${url}`, () => {
const expectedPath = new URL(url).pathname;
expect(parseChannelUrl(url)).toBe(expectedPath);
});
}
});
describe('parseMessageJumpLink', () => {
const originalLocationHref = globalThis.location.href;
beforeAll(() => {
globalThis.location.href = CANARY_BASE;
});
afterAll(() => {
globalThis.location.href = originalLocationHref;
});
test('returns channel, guild, and message info when the path points to a message', () => {
expect(parseMessageJumpLink(GUILD_MESSAGE)).toEqual({
scope: '12345678901234567',
channelId: '23456789012345678',
messageId: '34567890123456789',
});
});
});
describe('parseChannelJumpLink', () => {
const originalLocationHref = globalThis.location.href;
beforeAll(() => {
globalThis.location.href = STABLE_BASE;
});
afterAll(() => {
globalThis.location.href = originalLocationHref;
});
test('returns scope and channel for a DM path', () => {
const url = `${STABLE_BASE}/channels/@me/1130650140672000000`;
expect(parseChannelJumpLink(url)).toEqual({
scope: '@me',
channelId: '1130650140672000000',
});
});
test('returns scope and channel for a guild path', () => {
expect(parseChannelJumpLink(GUILD_CHANNEL)).toEqual({
scope: '12345678901234567',
channelId: '23456789012345678',
});
});
test('still returns scope and channel when a message ID is appended', () => {
expect(parseChannelJumpLink(GUILD_MESSAGE)).toEqual({
scope: '12345678901234567',
channelId: '23456789012345678',
});
});
});

View File

@@ -0,0 +1,281 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import React from 'react';
import * as GiftActionCreators from '~/actions/GiftActionCreators';
import * as InviteActionCreators from '~/actions/InviteActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
import {ME} from '~/Constants';
import {UserProfileModal} from '~/components/modals/UserProfileModal';
import {Routes} from '~/Routes';
import AuthenticationStore from '~/stores/AuthenticationStore';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import UserStore from '~/stores/UserStore';
import * as RouterUtils from '~/utils/RouterUtils';
import SnowflakeUtil from '~/utils/SnowflakeUtil';
import {APP_PROTOCOL_PREFIX} from './appProtocol';
import {getElectronAPI} from './NativeUtils';
type DeepLinkTarget =
| {type: 'invite'; code: string; preferLogin: boolean}
| {type: 'gift'; code: string; preferLogin: boolean}
| {type: 'user'; userId: string};
const parseDeepLink = (rawUrl: string): DeepLinkTarget | null => {
const tryFromSegments = (segments: Array<string>, search?: string): DeepLinkTarget | null => {
const [first, second, third] = segments.filter(Boolean);
const preferLogin = third === 'login' || search?.includes('login=1') || search?.includes('action=login') || false;
if (first === 'invite' && second) {
return {type: 'invite', code: second, preferLogin};
}
if (first === 'gift' && second) {
return {type: 'gift', code: second, preferLogin};
}
if (first === 'users' && second) {
return {type: 'user', userId: second};
}
return null;
};
try {
const parsed = new URL(rawUrl);
const segments = [parsed.host, ...parsed.pathname.split('/')];
const target = tryFromSegments(segments, parsed.search);
if (target) return target;
} catch {}
const protocolPattern = new RegExp(`^${APP_PROTOCOL_PREFIX.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}`);
const sanitized = rawUrl.replace(protocolPattern, '').replace(/^\/+/, '');
const [pathPart, searchPart] = sanitized.split('?');
const target = tryFromSegments(pathPart.split('/'), searchPart ? `?${searchPart}` : undefined);
return target;
};
const navigateForTarget = (target: DeepLinkTarget) => {
const isAuthenticated = AuthenticationStore.isAuthenticated;
if (target.type === 'gift' && RuntimeConfigStore.isSelfHosted()) {
return;
}
if (isAuthenticated) {
if (target.type === 'invite') {
void InviteActionCreators.openAcceptModal(target.code);
} else {
if (target.type === 'gift') {
void GiftActionCreators.openAcceptModal(target.code);
} else if (target.type === 'user') {
void openUserProfile(target.userId);
}
}
RouterUtils.transitionTo(Routes.ME);
return;
}
if (target.type === 'user') {
RouterUtils.transitionTo(Routes.LOGIN);
return;
}
if (target.type === 'invite') {
const dest = target.preferLogin ? Routes.inviteLogin(target.code) : Routes.inviteRegister(target.code);
RouterUtils.transitionTo(dest);
return;
}
const dest = target.preferLogin ? Routes.giftLogin(target.code) : Routes.giftRegister(target.code);
RouterUtils.transitionTo(dest);
};
export const handleDeepLinkUrl = (rawUrl: string): boolean => {
const target = parseDeepLink(rawUrl);
if (!target) return false;
navigateForTarget(target);
return true;
};
export const handleRpcNavigation = (path: string): void => {
RouterUtils.transitionTo(path);
};
let listenerStarted = false;
export const startDeepLinkHandling = async (): Promise<void> => {
if (listenerStarted) return;
const electronApi = getElectronAPI();
if (electronApi) {
listenerStarted = true;
try {
const initialUrl = await electronApi.getInitialDeepLink();
if (initialUrl) {
handleDeepLinkUrl(initialUrl);
}
} catch (error) {
console.error('[DeepLink] Failed to get initial deep link', error);
}
electronApi.onDeepLink((url) => {
try {
handleDeepLinkUrl(url);
} catch (error) {
console.error('[DeepLink] Failed to handle URL', url, error);
}
});
electronApi.onRpcNavigate((path) => {
try {
handleRpcNavigation(path);
} catch (error) {
console.error('[DeepLink] Failed to handle RPC navigation', path, error);
}
});
return;
}
};
const EXTRA_INTERNAL_CHANNEL_HOSTS = ['fluxer.app', 'canary.fluxer.app'];
export const isInternalChannelHost = (host: string): boolean => {
if (!host) return false;
if (typeof location !== 'undefined' && host === location.host) {
return true;
}
if (RuntimeConfigStore.marketingHost && host === RuntimeConfigStore.marketingHost) {
return true;
}
return EXTRA_INTERNAL_CHANNEL_HOSTS.includes(host);
};
export function parseChannelUrl(url: string): string | null {
try {
const parsed = new URL(url);
const isInternal = isInternalChannelHost(parsed.host) && parsed.pathname.startsWith('/channels/');
if (!isInternal) return null;
const normalizedPath = parsed.pathname;
const segments = normalizedPath.split('/').filter(Boolean);
if (segments[0] !== 'channels') return null;
const [, scope, channelId, messageId] = segments;
const segmentCount = segments.length;
const isSnowflake = (value?: string) => SnowflakeUtil.isProbablyAValidSnowflake(value ?? null);
const isDmScope = scope === ME;
let isValid = false;
if (isDmScope) {
if (segmentCount === 2) {
isValid = true;
} else if (segmentCount === 3 && isSnowflake(channelId)) {
isValid = true;
} else if (segmentCount === 4 && isSnowflake(channelId) && isSnowflake(messageId)) {
isValid = true;
}
} else {
if (segmentCount === 3 && isSnowflake(scope) && isSnowflake(channelId)) {
isValid = true;
} else if (segmentCount === 4 && isSnowflake(scope) && isSnowflake(channelId) && isSnowflake(messageId)) {
isValid = true;
}
}
if (isValid) {
return normalizedPath;
}
} catch {
return null;
}
return null;
}
export interface ChannelJumpLink {
scope: string;
channelId: string;
}
export interface MessageJumpLink extends ChannelJumpLink {
messageId: string;
}
const getChannelSegments = (url: string): Array<string> | null => {
const channelPath = parseChannelUrl(url);
if (!channelPath) return null;
return channelPath.split('/').filter(Boolean);
};
export function parseChannelJumpLink(url: string): ChannelJumpLink | null {
const segments = getChannelSegments(url);
if (!segments || segments.length < 3) return null;
const [, scope, channelId] = segments;
if (!scope || !channelId) return null;
return {
scope,
channelId,
};
}
export function parseMessageJumpLink(url: string): MessageJumpLink | null {
const segments = getChannelSegments(url);
if (!segments || segments.length !== 4) return null;
const [, scope, channelId, messageId] = segments;
if (!messageId || !SnowflakeUtil.isProbablyAValidSnowflake(messageId)) {
return null;
}
return {
scope,
channelId,
messageId,
};
}
const openUserProfile = async (userId: string, guildId?: string) => {
try {
await UserProfileActionCreators.fetch(userId, guildId);
} catch (error) {
console.error('[DeepLink] Failed to fetch user profile', userId, error);
}
const user = UserStore.getUser(userId);
ModalActionCreators.pushWithKey(
modal(() =>
React.createElement(UserProfileModal, {
userId,
guildId,
previewUser: user ?? undefined,
}),
),
`user-profile-${userId}-${guildId ?? 'global'}`,
);
};

View File

@@ -0,0 +1,142 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import Config from '~/Config';
const RPC_PORT_STABLE = 21863;
const RPC_PORT_CANARY = 21864;
const RPC_PORTS =
Config.PUBLIC_PROJECT_ENV === 'canary' ? [RPC_PORT_CANARY, RPC_PORT_STABLE] : [RPC_PORT_STABLE, RPC_PORT_CANARY];
type RpcResponse<T = unknown> = {
success: boolean;
data?: T;
error?: string;
};
interface HealthResponse {
status: string;
channel: string;
version: string;
platform: string;
}
interface NavigateResponse {
navigated: boolean;
path: string;
}
let cachedAvailablePort: number | null = null;
let lastHealthCheck = 0;
const HEALTH_CHECK_CACHE_MS = 5000;
const rpcFetch = async <T>(port: number, endpoint: string, options?: RequestInit): Promise<RpcResponse<T> | null> => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`http://127.0.0.1:${port}${endpoint}`, {
...options,
signal: controller.signal,
});
return (await response.json()) as RpcResponse<T>;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
};
export const checkDesktopAvailable = async (): Promise<{
available: boolean;
port: number | null;
info: HealthResponse | null;
}> => {
const now = Date.now();
if (cachedAvailablePort !== null && now - lastHealthCheck < HEALTH_CHECK_CACHE_MS) {
const result = await rpcFetch<HealthResponse>(cachedAvailablePort, '/health');
if (result?.success && result.data) {
return {available: true, port: cachedAvailablePort, info: result.data};
}
cachedAvailablePort = null;
}
for (const port of RPC_PORTS) {
const result = await rpcFetch<HealthResponse>(port, '/health');
if (result?.success && result.data) {
cachedAvailablePort = port;
lastHealthCheck = now;
return {available: true, port, info: result.data};
}
}
cachedAvailablePort = null;
return {available: false, port: null, info: null};
};
export const navigateInDesktop = async (path: string): Promise<{success: boolean; error?: string}> => {
const {available, port} = await checkDesktopAvailable();
if (!available || port === null) {
return {success: false, error: 'Desktop app not available'};
}
const result = await rpcFetch<NavigateResponse>(port, '/navigate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({method: 'navigate', params: {path}}),
});
if (!result) {
return {success: false, error: 'Failed to communicate with desktop app'};
}
if (!result.success) {
return {success: false, error: result.error ?? 'Unknown error'};
}
return {success: true};
};
export const focusDesktop = async (): Promise<{success: boolean; error?: string}> => {
const {available, port} = await checkDesktopAvailable();
if (!available || port === null) {
return {success: false, error: 'Desktop app not available'};
}
const result = await rpcFetch(port, '/focus', {method: 'POST'});
if (!result) {
return {success: false, error: 'Failed to communicate with desktop app'};
}
if (!result.success) {
return {success: false, error: result.error ?? 'Unknown error'};
}
return {success: true};
};
export const resetDesktopRpcCache = (): void => {
cachedAvailablePort = null;
lastHealthCheck = 0;
};

View File

@@ -0,0 +1,176 @@
/*
* 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 {CSSProperties} from 'react';
interface MediaDimensions {
width: number;
height: number;
}
interface DimensionOptions {
maxWidth?: number;
maxHeight?: number;
preserve?: boolean;
forceScale?: boolean;
aspectRatio?: boolean;
responsive?: boolean;
}
interface DimensionResult {
style: CSSProperties;
dimensions: MediaDimensions;
scale: number;
}
const DEFAULT_OPTIONS: Required<DimensionOptions> = {
maxWidth: 550,
maxHeight: 400,
preserve: false,
forceScale: false,
aspectRatio: true,
responsive: true,
};
export class MediaDimensionCalculator {
private options: Required<DimensionOptions>;
constructor(options?: DimensionOptions) {
this.options = {...DEFAULT_OPTIONS, ...options};
}
public calculate(dimensions: MediaDimensions, options?: DimensionOptions): DimensionResult {
const config = {...this.options, ...options};
if (config.preserve) {
return this.preserveDimensions(dimensions);
}
if (config.forceScale) {
return this.forceScaleDimensions(dimensions, config);
}
return this.calculateResponsiveDimensions(dimensions, config);
}
public calculateImage(dimensions: MediaDimensions, options?: Omit<DimensionOptions, 'forceScale'>): DimensionResult {
return this.calculate(dimensions, {...options, forceScale: false});
}
public calculateVideo(dimensions: MediaDimensions, options?: Omit<DimensionOptions, 'preserve'>): DimensionResult {
return this.calculate(dimensions, {...options, preserve: false});
}
private preserveDimensions(dimensions: MediaDimensions): DimensionResult {
return {
style: {
width: dimensions.width,
aspectRatio: `${dimensions.width}/${dimensions.height}`,
},
dimensions: {...dimensions},
scale: 1,
};
}
private forceScaleDimensions(dimensions: MediaDimensions, options: Required<DimensionOptions>): DimensionResult {
const scale = Math.min(1, options.maxWidth / dimensions.width, options.maxHeight / dimensions.height);
const scaledDimensions = {
width: Math.round(dimensions.width * scale),
height: Math.round(dimensions.height * scale),
};
return {
style: {
width: scaledDimensions.width,
height: scaledDimensions.height,
maxWidth: '100%',
maxHeight: '100%',
...(options.aspectRatio && {
aspectRatio: `${scaledDimensions.width}/${scaledDimensions.height}`,
}),
},
dimensions: scaledDimensions,
scale,
};
}
private calculateResponsiveDimensions(
dimensions: MediaDimensions,
options: Required<DimensionOptions>,
): DimensionResult {
const isPortrait = dimensions.height > dimensions.width;
let style: CSSProperties;
let scaledDimensions: MediaDimensions;
let scale: number;
if (isPortrait) {
const targetWidth = Math.round((options.maxHeight * dimensions.width) / dimensions.height);
const maxAllowedWidth = Math.min(options.maxWidth, targetWidth);
scale = maxAllowedWidth / dimensions.width;
scaledDimensions = {
width: maxAllowedWidth,
height: Math.round(dimensions.height * scale),
};
style = {
maxWidth: `${maxAllowedWidth}px`,
width: options.responsive ? '100%' : maxAllowedWidth,
...(options.aspectRatio && {
aspectRatio: `${maxAllowedWidth}/${options.maxHeight}`,
}),
};
} else {
const scaleByWidth = options.maxWidth / dimensions.width;
const scaleByHeight = options.maxHeight / dimensions.height;
scale = Math.min(1, scaleByWidth, scaleByHeight);
scaledDimensions = {
width: Math.round(dimensions.width * scale),
height: Math.round(dimensions.height * scale),
};
style = {
maxWidth: `${options.maxWidth}px`,
width: options.responsive ? '100%' : scaledDimensions.width,
...(options.aspectRatio && {
aspectRatio: `${scaledDimensions.width}/${scaledDimensions.height}`,
}),
};
}
return {style, dimensions: scaledDimensions, scale};
}
public static scale(
width: number,
height: number,
maxWidth = DEFAULT_OPTIONS.maxWidth,
maxHeight = DEFAULT_OPTIONS.maxHeight,
): [number, number] {
const calculator = new MediaDimensionCalculator();
const result = calculator.calculate({width, height}, {maxWidth, maxHeight, forceScale: true});
return [result.dimensions.width, result.dimensions.height];
}
}
export const createCalculator = (options?: DimensionOptions): MediaDimensionCalculator => {
return new MediaDimensionCalculator(options);
};

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 {FC, SVGProps} from 'react';
import {MODE} from '~/lib/env';
import {Platform} from '~/lib/Platform';
export type TwemojiComponent = FC<SVGProps<SVGSVGElement>>;
const TWEMOJI_CDN = 'https://fluxerstatic.com/emoji';
export const shouldUseNativeEmoji = Platform.isAppleDevice;
export const convertToCodePoints = (emoji: string): string => {
const containsZWJ = emoji.includes('\u200D');
const processedEmoji = containsZWJ ? emoji : emoji.replace(/\uFE0F/g, '');
return Array.from(processedEmoji)
.map((char) => char.codePointAt(0)?.toString(16).replace(/^0+/, '') || '')
.join('-');
};
export const fromHexCodePoint = (hex: string): string => String.fromCodePoint(Number.parseInt(hex, 16));
export const getTwemojiURL = (codePoints: string): string | null => {
if (shouldUseNativeEmoji || MODE === 'test' || !codePoints) {
return null;
}
return `${TWEMOJI_CDN}/${codePoints}.svg`;
};
export const getEmojiURL = (unicode: string): string | null => getTwemojiURL(convertToCodePoints(unicode));
export const getTwemojiSvg = (_codePoints: string): TwemojiComponent | null => null;
export const getEmojiSvg = (_unicode: string): TwemojiComponent | null => null;

View File

@@ -0,0 +1,252 @@
/*
* 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 {Permissions} from '~/Constants';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildStickerRecord} from '~/records/GuildStickerRecord';
import type {Emoji} from '~/stores/EmojiStore';
import PermissionStore from '~/stores/PermissionStore';
import UserStore from '~/stores/UserStore';
export interface AvailabilityCheck {
canUse: boolean;
isLockedByPremium: boolean;
isLockedByPermission: boolean;
lockReason?: string;
}
export function checkEmojiAvailability(i18n: I18n, emoji: Emoji, channel: ChannelRecord | null): AvailabilityCheck {
if (!emoji.guildId) {
return {
canUse: true,
isLockedByPremium: false,
isLockedByPermission: false,
};
}
const currentUser = UserStore.getCurrentUser();
const hasPremium = currentUser?.isPremium() ?? false;
if (!channel?.guildId) {
if (!hasPremium) {
return {
canUse: false,
isLockedByPremium: true,
isLockedByPermission: false,
lockReason: i18n._(msg`Unlock custom emojis in DMs with Plutonium`),
};
}
return {
canUse: true,
isLockedByPremium: false,
isLockedByPermission: false,
};
}
const isExternalEmoji = emoji.guildId !== channel.guildId;
if (!isExternalEmoji) {
return {
canUse: true,
isLockedByPremium: false,
isLockedByPermission: false,
};
}
const hasPermission = PermissionStore.can(Permissions.USE_EXTERNAL_EMOJIS, {
guildId: channel.guildId,
channelId: channel.id,
});
if (!hasPermission) {
if (!hasPremium) {
return {
canUse: false,
isLockedByPremium: false,
isLockedByPermission: true,
lockReason: i18n._(msg`You lack permission to use external emojis in this channel`),
};
}
return {
canUse: false,
isLockedByPremium: false,
isLockedByPermission: true,
lockReason: i18n._(msg`You lack permission to use external emojis in this channel`),
};
}
if (!hasPremium) {
return {
canUse: false,
isLockedByPremium: true,
isLockedByPermission: false,
lockReason: i18n._(msg`Unlock external custom emojis with Plutonium`),
};
}
return {
canUse: true,
isLockedByPremium: false,
isLockedByPermission: false,
};
}
export function checkStickerAvailability(
i18n: I18n,
sticker: GuildStickerRecord,
channel: ChannelRecord | null,
): AvailabilityCheck {
if (!sticker.guildId) {
return {
canUse: true,
isLockedByPremium: false,
isLockedByPermission: false,
};
}
const currentUser = UserStore.getCurrentUser();
const hasPremium = currentUser?.isPremium() ?? false;
if (!channel?.guildId) {
if (!hasPremium) {
return {
canUse: false,
isLockedByPremium: true,
isLockedByPermission: false,
lockReason: i18n._(msg`Unlock stickers in DMs with Plutonium`),
};
}
return {
canUse: true,
isLockedByPremium: false,
isLockedByPermission: false,
};
}
const isExternalSticker = sticker.guildId !== channel.guildId;
if (!isExternalSticker) {
return {
canUse: true,
isLockedByPremium: false,
isLockedByPermission: false,
};
}
const hasPermission = PermissionStore.can(Permissions.USE_EXTERNAL_STICKERS, {
guildId: channel.guildId,
channelId: channel.id,
});
if (!hasPermission) {
if (!hasPremium) {
return {
canUse: false,
isLockedByPremium: false,
isLockedByPermission: true,
lockReason: i18n._(msg`You lack permission to use external stickers in this channel`),
};
}
return {
canUse: false,
isLockedByPremium: false,
isLockedByPermission: true,
lockReason: i18n._(msg`You lack permission to use external stickers in this channel`),
};
}
if (!hasPremium) {
return {
canUse: false,
isLockedByPremium: true,
isLockedByPermission: false,
lockReason: i18n._(msg`Unlock external stickers with Plutonium`),
};
}
return {
canUse: true,
isLockedByPremium: false,
isLockedByPermission: false,
};
}
export function filterEmojisForAutocomplete(
i18n: I18n,
emojis: ReadonlyArray<Emoji>,
channel: ChannelRecord | null,
): ReadonlyArray<Emoji> {
return emojis.filter((emoji) => {
const check = checkEmojiAvailability(i18n, emoji, channel);
return check.canUse;
});
}
export function filterStickersForAutocomplete(
i18n: I18n,
stickers: ReadonlyArray<GuildStickerRecord>,
channel: ChannelRecord | null,
): ReadonlyArray<GuildStickerRecord> {
return stickers.filter((sticker) => {
const check = checkStickerAvailability(i18n, sticker, channel);
return check.canUse;
});
}
export function shouldShowEmojiPremiumUpsell(channel: ChannelRecord | null): boolean {
const currentUser = UserStore.getCurrentUser();
const hasPremium = currentUser?.isPremium() ?? false;
if (hasPremium) {
return false;
}
if (!channel?.guildId) {
return true;
}
const hasPermission = PermissionStore.can(Permissions.USE_EXTERNAL_EMOJIS, {
guildId: channel.guildId,
channelId: channel.id,
});
return hasPermission;
}
export function shouldShowStickerPremiumUpsell(channel: ChannelRecord | null): boolean {
const currentUser = UserStore.getCurrentUser();
const hasPremium = currentUser?.isPremium() ?? false;
if (hasPremium) {
return false;
}
if (!channel?.guildId) {
return true;
}
const hasPermission = PermissionStore.can(Permissions.USE_EXTERNAL_STICKERS, {
guildId: channel.guildId,
channelId: channel.id,
});
return hasPermission;
}

View File

@@ -0,0 +1,146 @@
/*
* 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 type {FavoriteMemeRecord} from '~/records/FavoriteMemeRecord';
import type {EmbedMedia, MessageAttachment, MessageEmbed} from '~/records/MessageRecord';
function extractTenorName(url: string): string | null {
try {
const tenorRegex = /tenor\.com\/view\/([a-z0-9-]+)-(?:gif|gifv?)-\d+/i;
const match = url.match(tenorRegex);
if (match?.[1]) {
return match[1].split('-').join(' ');
}
} catch {}
return null;
}
function extractFilenameFromUrl(url: string): string | null {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
if (!filename) return null;
const nameWithoutExt = filename.replace(/\.[^.]+$/, '');
const cleaned = nameWithoutExt.replace(/[-_]/g, ' ').trim();
return cleaned || null;
} catch {
return null;
}
}
export function deriveDefaultNameFromAttachment(i18n: I18n, attachment: MessageAttachment): string {
if (attachment.title?.trim()) {
return attachment.title.trim();
}
if (attachment.filename) {
const nameWithoutExt = attachment.filename.replace(/\.[^.]+$/, '');
const cleaned = nameWithoutExt.replace(/[-_]/g, ' ').trim();
if (cleaned) return cleaned;
}
if (attachment.url) {
const urlName = extractFilenameFromUrl(attachment.url);
if (urlName) return urlName;
}
if (attachment.content_type) {
if (attachment.content_type.startsWith('image/gif')) return i18n._(msg`GIF`);
if (attachment.content_type.startsWith('image/')) return i18n._(msg`Image`);
if (attachment.content_type.startsWith('video/')) return i18n._(msg`Video`);
if (attachment.content_type.startsWith('audio/')) return i18n._(msg`Audio`);
}
return i18n._(msg`Media`);
}
export function deriveDefaultNameFromEmbedMedia(i18n: I18n, embedMedia: EmbedMedia, embed?: MessageEmbed): string {
if (embed?.title?.trim()) {
return embed.title.trim();
}
if (embedMedia.description?.trim()) {
return embedMedia.description.trim();
}
if (embedMedia.url) {
const tenorName = extractTenorName(embedMedia.url);
if (tenorName) return tenorName;
const urlName = extractFilenameFromUrl(embedMedia.url);
if (urlName) return urlName;
}
if (embedMedia.content_type) {
if (embedMedia.content_type.startsWith('image/gif')) return i18n._(msg`GIF`);
if (embedMedia.content_type.startsWith('image/')) return i18n._(msg`Image`);
if (embedMedia.content_type.startsWith('video/')) return i18n._(msg`Video`);
if (embedMedia.content_type.startsWith('audio/')) return i18n._(msg`Audio`);
}
return i18n._(msg`Media`);
}
export function isFavoritedByContentHash(
memes: ReadonlyArray<FavoriteMemeRecord>,
contentHash: string | null | undefined,
): boolean {
if (!contentHash) return false;
return memes.some((meme) => meme.contentHash === contentHash);
}
export function isFavoritedByTenorId(
memes: ReadonlyArray<FavoriteMemeRecord>,
tenorId: string | null | undefined,
): boolean {
if (!tenorId) return false;
return memes.some((meme) => meme.tenorId === tenorId);
}
export function isFavorited(
memes: ReadonlyArray<FavoriteMemeRecord>,
params: {contentHash?: string | null; tenorId?: string | null},
): boolean {
if (params.tenorId) {
return isFavoritedByTenorId(memes, params.tenorId);
}
if (params.contentHash) {
return isFavoritedByContentHash(memes, params.contentHash);
}
return false;
}
export function findFavoritedMeme(
memes: ReadonlyArray<FavoriteMemeRecord>,
params: {contentHash?: string | null; tenorId?: string | null},
): FavoriteMemeRecord | null {
if (params.tenorId) {
return memes.find((meme) => meme.tenorId === params.tenorId) ?? null;
}
if (params.contentHash) {
return memes.find((meme) => meme.contentHash === params.contentHash) ?? null;
}
return null;
}

View File

@@ -0,0 +1,31 @@
/*
* 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 {openExternalUrl} from '~/utils/NativeUtils';
type MediaType = 'image' | 'video' | 'audio' | 'file';
export const downloadFile = async (src: string, _type: MediaType, _providedFilename?: string) => {
if (!src) return;
await openExternalUrl(src);
};
export const createSaveHandler = (src: string, type: MediaType, providedFilename?: string) => async () => {
await downloadFile(src, type, providedFilename);
};

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/>.
*/
interface PickerOptions {
multiple?: boolean;
accept?: string;
}
export const openFilePicker = ({multiple = false, accept}: PickerOptions = {}): Promise<Array<File>> =>
new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = multiple;
if (accept) input.accept = accept;
input.onchange = () => {
const files = Array.from(input.files ?? []);
resolve(files);
input.remove();
};
input.oncancel = () => {
resolve([]);
input.remove();
};
input.click();
});

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 {MAX_ATTACHMENTS_PER_MESSAGE} from '~/Constants';
import {CloudUpload} from '~/lib/CloudUpload';
interface FileUploadResult {
success: boolean;
error?: 'too_many_attachments' | 'no_files';
}
export async function handleFileUpload(
channelId: string,
files: FileList | Array<File>,
currentAttachmentCount: number,
): Promise<FileUploadResult> {
const fileArray = Array.from(files);
if (fileArray.length === 0) {
return {success: false, error: 'no_files'};
}
if (currentAttachmentCount + fileArray.length > MAX_ATTACHMENTS_PER_MESSAGE) {
return {success: false, error: 'too_many_attachments'};
}
await CloudUpload.addFiles(channelId, fileArray);
return {success: true};
}

View File

@@ -0,0 +1,26 @@
/*
* 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/>.
*/
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1000;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`;
};

View File

@@ -0,0 +1,69 @@
/*
* 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 type {FieldValues, Path, UseFormReturn} from 'react-hook-form';
import {APIErrorCodes} from '~/Constants';
import type {HttpError, HttpResponse} from '~/lib/HttpClient';
interface ValidationError {
path: string;
message: string;
}
interface APIErrorResponse {
code: string;
message: string;
errors?: Array<ValidationError>;
}
export const handleError = <T extends FieldValues>(
i18n: I18n,
form: UseFormReturn<T>,
error: HttpResponse<unknown> | HttpError,
defaultPath: Path<T>,
) => {
if ('body' in error && error.body) {
const errorData = error.body as APIErrorResponse;
if (errorData.code === APIErrorCodes.INVALID_FORM_BODY && errorData.errors?.length) {
const formFields = Object.keys(form.getValues()) as Array<Path<T>>;
for (const validationError of errorData.errors) {
const path = validationError.path as Path<T>;
const message = validationError.message;
if (formFields.includes(path)) {
form.setError(path, {type: 'server', message});
} else {
form.setError(defaultPath, {type: 'server', message});
}
}
} else if (errorData.message) {
form.setError(defaultPath, {type: 'server', message: errorData.message});
}
return;
}
form.setError(defaultPath, {
type: 'server',
message: i18n._(msg`An unexpected error occurred. Please try again.`),
});
};

View File

@@ -0,0 +1,102 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
export const formatRegion = (i18n: I18n, countryCode: string | null, regionCode: string | null): string => {
if (!countryCode) {
return i18n._(msg`your region`);
}
try {
const countryName = new Intl.DisplayNames(['en'], {type: 'region'}).of(countryCode);
if (countryCode === 'US' && regionCode) {
try {
const stateName = getUSStateName(regionCode);
return `${stateName}, ${countryName || 'United States'}`;
} catch {
return countryName || countryCode;
}
}
return countryName || countryCode;
} catch {
return countryCode;
}
};
const getUSStateName = (stateCode: string): string => {
const states: Record<string, string> = {
AL: 'Alabama',
AK: 'Alaska',
AZ: 'Arizona',
AR: 'Arkansas',
CA: 'California',
CO: 'Colorado',
CT: 'Connecticut',
DE: 'Delaware',
FL: 'Florida',
GA: 'Georgia',
HI: 'Hawaii',
ID: 'Idaho',
IL: 'Illinois',
IN: 'Indiana',
IA: 'Iowa',
KS: 'Kansas',
KY: 'Kentucky',
LA: 'Louisiana',
ME: 'Maine',
MD: 'Maryland',
MA: 'Massachusetts',
MI: 'Michigan',
MN: 'Minnesota',
MS: 'Mississippi',
MO: 'Missouri',
MT: 'Montana',
NE: 'Nebraska',
NV: 'Nevada',
NH: 'New Hampshire',
NJ: 'New Jersey',
NM: 'New Mexico',
NY: 'New York',
NC: 'North Carolina',
ND: 'North Dakota',
OH: 'Ohio',
OK: 'Oklahoma',
OR: 'Oregon',
PA: 'Pennsylvania',
RI: 'Rhode Island',
SC: 'South Carolina',
SD: 'South Dakota',
TN: 'Tennessee',
TX: 'Texas',
UT: 'Utah',
VT: 'Vermont',
VA: 'Virginia',
WA: 'Washington',
WV: 'West Virginia',
WI: 'Wisconsin',
WY: 'Wyoming',
DC: 'District of Columbia',
};
return states[stateCode] || stateCode;
};

View File

@@ -0,0 +1,36 @@
/*
* 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/>.
*/
const GROUP_DM_COLORS = ['#dc2626', '#ea580c', '#65a30d', '#2563eb', '#9333ea', '#db2777'];
const hashString = (value: string): number => {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash * 31 + value.charCodeAt(i)) & 0xffffffff;
}
return hash;
};
export const getGroupDMAccentColor = (channelId: string): string => {
if (!channelId) {
return GROUP_DM_COLORS[0]!;
}
const hash = Math.abs(hashString(channelId));
return GROUP_DM_COLORS[hash % GROUP_DM_COLORS.length]!;
};

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/>.
*/
export const getInitialsLength = (initials: string): 'short' | 'medium' | 'long' => {
const length = initials.length;
if (length <= 2) return 'short';
if (length <= 4) return 'medium';
return 'long';
};

View File

@@ -0,0 +1,26 @@
/*
* 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 {Routes} from '~/Routes';
import * as LocaleUtils from '~/utils/LocaleUtils';
export function getURL(articleId: string): string {
const locale = LocaleUtils.getCurrentLocale().toLowerCase();
return `${Routes.help()}/${locale}/articles/${articleId}`;
}

View File

@@ -0,0 +1,63 @@
/*
* 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 {LRUCache} from 'lru-cache';
interface ImageCacheEntry {
loaded: boolean;
}
const imageCache = new LRUCache<string, ImageCacheEntry>({
max: 500,
ttl: 1000 * 60 * 10,
});
const isCached = (src: string | null): boolean => {
if (!src) return false;
return imageCache.has(src);
};
export const hasImage = (src: string | null): boolean => {
return isCached(src);
};
export const loadImage = (src: string | null, onLoad: () => void, onError?: () => void): void => {
if (!src) {
onError?.();
return;
}
if (imageCache.has(src)) {
onLoad();
return;
}
const image = new Image();
image.onload = () => {
imageCache.set(src, {loaded: true});
onLoad();
};
image.onerror = () => {
onError?.();
};
image.src = src;
};

View File

@@ -0,0 +1,103 @@
/*
* 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 {EMOJI_MAX_SIZE, STICKER_MAX_SIZE} from '~/Constants';
export async function optimizeEmojiImage(file: File, maxSizeBytes = EMOJI_MAX_SIZE, targetSize = 128): Promise<string> {
const isGif = file.type === 'image/gif';
if (isGif) {
if (file.size <= maxSizeBytes) {
return fileToBase64NoPrefix(file);
}
throw new Error('Animated GIF exceeds size limit and cannot be compressed further');
}
return containToSquareBase64(file, targetSize, maxSizeBytes, 'image/png');
}
export async function optimizeStickerImage(
file: File,
maxSizeBytes = STICKER_MAX_SIZE,
targetSize = 320,
): Promise<string> {
return optimizeEmojiImage(file, maxSizeBytes, targetSize);
}
async function fileToBase64NoPrefix(file: File): Promise<string> {
const dataUrl = await new Promise<string>((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result));
r.onerror = () => rej(new Error('Failed to read file'));
r.readAsDataURL(file);
});
return dataUrl.split(',')[1] ?? '';
}
async function containToSquareBase64(
file: File,
target: number,
maxBytes: number,
mime: 'image/png' | 'image/webp' | 'image/jpeg',
): Promise<string> {
const dataUrl = await new Promise<string>((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result));
r.onerror = () => rej(new Error('Failed to read file'));
r.readAsDataURL(file);
});
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const im = new Image();
im.crossOrigin = 'anonymous';
im.onload = () => resolve(im);
im.onerror = () => reject(new Error('Failed to load image'));
im.src = dataUrl;
});
const canvas = document.createElement('canvas');
canvas.width = target;
canvas.height = target;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Could not create canvas context');
ctx.clearRect(0, 0, target, target);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
const s = Math.min(target / img.width, target / img.height);
const dw = Math.max(1, Math.round(img.width * s));
const dh = Math.max(1, Math.round(img.height * s));
const dx = Math.floor((target - dw) / 2);
const dy = Math.floor((target - dh) / 2);
ctx.drawImage(img, 0, 0, img.width, img.height, dx, dy, dw, dh);
const blob: Blob = await new Promise((res, rej) =>
canvas.toBlob((b) => (b ? res(b) : rej(new Error('Canvas toBlob failed'))), mime, 0.95),
);
if (blob.size > maxBytes) {
throw new Error(`Image size ${(blob.size / 1024).toFixed(1)} KB exceeds max ${(maxBytes / 1024).toFixed(0)} KB`);
}
const arr = new Uint8Array(await blob.arrayBuffer());
let bin = '';
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
return btoa(bin);
}

View File

@@ -0,0 +1,107 @@
/*
* 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, Permissions} from '~/Constants';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import PermissionStore from '~/stores/PermissionStore';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import SelectedChannelStore from '~/stores/SelectedChannelStore';
import * as CodeLinkUtils from '~/utils/CodeLinkUtils';
const INVITE_CONFIG: CodeLinkUtils.CodeLinkConfig = {
get shortHost() {
return RuntimeConfigStore.inviteHost;
},
path: 'invite',
};
export function findInvites(content: string | null): Array<string> {
return CodeLinkUtils.findCodes(content, INVITE_CONFIG);
}
export function findInvite(content: string | null): string | null {
return CodeLinkUtils.findCode(content, INVITE_CONFIG);
}
const INVITABLE_CHANNEL_TYPES: Set<number> = new Set([ChannelTypes.GUILD_TEXT, ChannelTypes.GUILD_VOICE]);
export function getFirstInvitableChannel(guildId: string): string | undefined {
const channels = ChannelStore.getGuildChannels(guildId);
const invitableChannel = channels.find((channel) => INVITABLE_CHANNEL_TYPES.has(channel.type));
return invitableChannel?.id;
}
export function getInvitableChannelId(guildId: string): string | undefined {
const selectedChannelId = SelectedChannelStore.selectedChannelIds.get(guildId);
if (selectedChannelId) {
const selectedChannel = ChannelStore.getChannel(selectedChannelId);
if (selectedChannel && INVITABLE_CHANNEL_TYPES.has(selectedChannel.type)) {
return selectedChannelId;
}
}
return getFirstInvitableChannel(guildId);
}
export function isChannelVisibleToEveryone(channel: ChannelRecord, guild: GuildRecord): boolean {
const everyoneOverwrite = channel.permissionOverwrites[guild.id];
if (!everyoneOverwrite) {
return true;
}
return (everyoneOverwrite.deny & Permissions.VIEW_CHANNEL) === 0n;
}
export interface InviteCapability {
canInvite: boolean;
useVanityUrl: boolean;
vanityUrlCode: string | null;
}
export function getInviteCapability(channelId: string | undefined, guildId: string | undefined): InviteCapability {
if (!channelId || !guildId) {
return {canInvite: false, useVanityUrl: false, vanityUrlCode: null};
}
const canCreateInvite = PermissionStore.can(Permissions.CREATE_INSTANT_INVITE, {channelId, guildId});
if (canCreateInvite) {
return {canInvite: true, useVanityUrl: false, vanityUrlCode: null};
}
const guild = GuildStore.getGuild(guildId);
const channel = ChannelStore.getChannel(channelId);
if (!guild || !channel || !guild.vanityURLCode) {
return {canInvite: false, useVanityUrl: false, vanityUrlCode: null};
}
if (isChannelVisibleToEveryone(channel, guild)) {
return {canInvite: true, useVanityUrl: true, vanityUrlCode: guild.vanityURLCode};
}
return {canInvite: false, useVanityUrl: false, vanityUrlCode: null};
}
export function canInviteToChannel(channelId: string | undefined, guildId: string | undefined): boolean {
return getInviteCapability(channelId, guildId).canInvite;
}
export function getVanityInviteUrl(vanityUrlCode: string): string {
return `${RuntimeConfigStore.inviteEndpoint}/${vanityUrlCode}`;
}

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 type {KeyCombo} from '~/stores/KeybindStore';
import {SHIFT_KEY_SYMBOL} from './KeyboardUtils';
const isMac = () => /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const CONTROL_KEY_SYMBOL = '⌃';
export const formatKeyCombo = (combo: KeyCombo): string => {
const parts: Array<string> = [];
if (combo.ctrl) {
parts.push(isMac() ? CONTROL_KEY_SYMBOL : 'Ctrl');
} else if (combo.ctrlOrMeta) {
parts.push(isMac() ? '⌘' : 'Ctrl');
}
if (combo.meta) {
parts.push(isMac() ? '⌘' : 'Win');
}
if (combo.shift) {
const shiftLabel = isMac() ? SHIFT_KEY_SYMBOL : 'Shift';
parts.push(shiftLabel);
}
if (combo.alt) parts.push(isMac() ? '⌥' : 'Alt');
const key = combo.code ?? combo.key ?? '';
if (key === ' ') {
parts.push('Space');
} else if (key.length === 1) {
parts.push(key.toUpperCase());
} else {
parts.push(key);
}
return parts.join(' + ');
};

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 type React from 'react';
export const SHIFT_KEY_SYMBOL = '⇧';
export function stopPropagationOnEnterSpace(e: React.KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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 {msg} from '@lingui/core/macro';
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import i18n, {loadLocaleCatalog} from '~/i18n';
import UserSettingsStore from '~/stores/UserSettingsStore';
interface LocaleInfo {
code: string;
name: import('@lingui/core').MessageDescriptor;
nativeName: string;
flag: string;
region?: string;
}
const SUPPORTED_LOCALES: Array<LocaleInfo> = [
{code: 'ar', name: msg`Arabic`, nativeName: 'العربية', flag: '🇸🇦'},
{code: 'bg', name: msg`Bulgarian`, nativeName: 'Български', flag: '🇧🇬'},
{code: 'cs', name: msg`Czech`, nativeName: 'Čeština', flag: '🇨🇿'},
{code: 'da', name: msg`Danish`, nativeName: 'Dansk', flag: '🇩🇰'},
{code: 'de', name: msg`German`, nativeName: 'Deutsch', flag: '🇩🇪'},
{code: 'el', name: msg`Greek`, nativeName: 'Ελληνικά', flag: '🇬🇷'},
{code: 'en-GB', name: msg`English`, nativeName: 'English', flag: '🇬🇧'},
{code: 'en-US', name: msg`English (US)`, nativeName: 'English (US)', flag: '🇺🇸'},
{code: 'es-ES', name: msg`Spanish (Spain)`, nativeName: 'Español (España)', flag: '🇪🇸'},
{code: 'es-419', name: msg`Spanish (Latin America)`, nativeName: 'Español (Latinoamérica)', flag: '🌎'},
{code: 'fi', name: msg`Finnish`, nativeName: 'Suomi', flag: '🇫🇮'},
{code: 'fr', name: msg`French`, nativeName: 'Français', flag: '🇫🇷'},
{code: 'he', name: msg`Hebrew`, nativeName: 'עברית', flag: '🇮🇱'},
{code: 'hi', name: msg`Hindi`, nativeName: 'हिन्दी', flag: '🇮🇳'},
{code: 'hr', name: msg`Croatian`, nativeName: 'Hrvatski', flag: '🇭🇷'},
{code: 'hu', name: msg`Hungarian`, nativeName: 'Magyar', flag: '🇭🇺'},
{code: 'id', name: msg`Indonesian`, nativeName: 'Bahasa Indonesia', flag: '🇮🇩'},
{code: 'it', name: msg`Italian`, nativeName: 'Italiano', flag: '🇮🇹'},
{code: 'ja', name: msg`Japanese`, nativeName: '日本語', flag: '🇯🇵'},
{code: 'ko', name: msg`Korean`, nativeName: '한국어', flag: '🇰🇷'},
{code: 'lt', name: msg`Lithuanian`, nativeName: 'Lietuvių', flag: '🇱🇹'},
{code: 'nl', name: msg`Dutch`, nativeName: 'Nederlands', flag: '🇳🇱'},
{code: 'no', name: msg`Norwegian`, nativeName: 'Norsk', flag: '🇳🇴'},
{code: 'pl', name: msg`Polish`, nativeName: 'Polski', flag: '🇵🇱'},
{code: 'pt-BR', name: msg`Portuguese (Brazil)`, nativeName: 'Português (Brasil)', flag: '🇧🇷'},
{code: 'ro', name: msg`Romanian`, nativeName: 'Română', flag: '🇷🇴'},
{code: 'ru', name: msg`Russian`, nativeName: 'Русский', flag: '🇷🇺'},
{code: 'sv-SE', name: msg`Swedish`, nativeName: 'Svenska', flag: '🇸🇪'},
{code: 'th', name: msg`Thai`, nativeName: 'ไทย', flag: '🇹🇭'},
{code: 'tr', name: msg`Turkish`, nativeName: 'Türkçe', flag: '🇹🇷'},
{code: 'uk', name: msg`Ukrainian`, nativeName: 'Українська', flag: '🇺🇦'},
{code: 'vi', name: msg`Vietnamese`, nativeName: 'Tiếng Việt', flag: '🇻🇳'},
{code: 'zh-CN', name: msg`Chinese (Simplified)`, nativeName: '中文 (简体)', flag: '🇨🇳'},
{code: 'zh-TW', name: msg`Chinese (Traditional)`, nativeName: '中文 (繁體)', flag: '🇹🇼'},
];
const DEFAULT_LOCALE = 'en-US';
export const getCurrentLocale = (): string => {
return UserSettingsStore.getLocale() || DEFAULT_LOCALE;
};
export const setLocale = (localeCode: string): void => {
if (!SUPPORTED_LOCALES.find((locale) => locale.code === localeCode)) {
console.warn(`Unsupported locale: ${localeCode}`);
return;
}
try {
const normalized = loadLocaleCatalog(localeCode);
UserSettingsActionCreators.update({
locale: normalized,
});
} catch (error) {
console.error(`Failed to load locale ${localeCode}:`, error);
}
};
interface TranslatedLocaleInfo {
code: string;
name: string;
nativeName: string;
flag: string;
region?: string;
}
export const getSortedLocales = (): Array<TranslatedLocaleInfo> => {
return [...SUPPORTED_LOCALES]
.map((locale) => ({
...locale,
name: i18n._(locale.name),
}))
.sort((a, b) => a.nativeName.localeCompare(b.nativeName));
};

View File

@@ -0,0 +1,145 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import ChannelStore from '~/stores/ChannelStore';
import EmojiStore from '~/stores/EmojiStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
import type {MentionSegment, TextareaSegmentManager} from './TextareaSegmentManager';
const MARKDOWN_SEGMENT_PATTERN = /<(@|#|@&)([a-zA-Z0-9]+)>|<(a)?:([^:]+):([a-zA-Z0-9]+)>/g;
interface SegmentConversionResult {
displayText: string;
segments: Array<{
start: number;
displayText: string;
actualText: string;
type: MentionSegment['type'];
id: string;
}>;
}
export function convertMarkdownToSegments(markdown: string, guildId?: string | null): SegmentConversionResult {
let displayText = markdown;
let offset = 0;
const segments: SegmentConversionResult['segments'] = [];
const matches = Array.from(markdown.matchAll(MARKDOWN_SEGMENT_PATTERN));
for (const match of matches) {
const fullMatch = match[0];
const originalStart = match.index!;
const adjustedStart = originalStart + offset;
let segmentDisplayText: string | null = null;
let segmentType: MentionSegment['type'] | null = null;
let segmentId: string | null = null;
if (match[1] && match[2]) {
const prefix = match[1];
const id = match[2];
if (prefix === '@') {
const user = UserStore.getUser(id);
if (user) {
segmentDisplayText = `@${user.tag}`;
segmentType = 'user';
segmentId = id;
}
} else if (prefix === '#') {
const foundChannel = ChannelStore.getChannel(id);
if (foundChannel?.name) {
segmentDisplayText = `#${foundChannel.name}`;
segmentType = 'channel';
segmentId = id;
}
} else if (prefix === '@&') {
const roles = GuildStore.getGuildRoles(guildId ?? '');
const role = roles.find((r) => r.id === id);
if (role) {
segmentDisplayText = `@${role.name}`;
segmentType = 'role';
segmentId = id;
}
}
} else if (match[4] && match[5]) {
const emojiId = match[5];
let emoji: {id: string; name: string} | undefined;
if (guildId) {
const emojis = EmojiStore.getGuildEmoji(guildId);
emoji = emojis.find((e) => e.id === emojiId);
}
if (!emoji) {
const guilds = GuildStore.getGuilds();
for (const guild of guilds) {
const emojis = EmojiStore.getGuildEmoji(guild.id);
emoji = emojis.find((e) => e.id === emojiId);
if (emoji) break;
}
}
if (emoji) {
segmentDisplayText = `:${emoji.name}:`;
segmentType = 'emoji';
segmentId = emojiId;
}
}
if (segmentDisplayText && segmentType && segmentId) {
displayText =
displayText.slice(0, adjustedStart) + segmentDisplayText + displayText.slice(adjustedStart + fullMatch.length);
segments.push({
start: adjustedStart,
displayText: segmentDisplayText,
actualText: fullMatch,
type: segmentType,
id: segmentId,
});
offset += segmentDisplayText.length - fullMatch.length;
}
}
return {displayText, segments};
}
export function applyMarkdownSegments(
markdown: string,
guildId: string | null | undefined,
segmentManager: TextareaSegmentManager,
): string {
const {displayText, segments} = convertMarkdownToSegments(markdown, guildId);
for (const segment of segments) {
segmentManager.insertSegment(
displayText.slice(0, segment.start + segment.displayText.length),
segment.start,
segment.displayText,
segment.actualText,
segment.type,
segment.id,
);
}
return displayText;
}

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 {mediaDeviceCache} from '~/lib/MediaDeviceCache';
import VoiceDevicePermissionStore from '~/stores/voice/VoiceDevicePermissionStore';
const logger = new Logger('MediaDeviceRefresh');
export enum MediaDeviceRefreshType {
audio = 'audio',
video = 'video',
}
export interface RefreshMediaDeviceListsOptions {
type: MediaDeviceRefreshType;
}
export const refreshMediaDeviceLists = async (options: RefreshMediaDeviceListsOptions): Promise<void> => {
const {type} = options;
mediaDeviceCache.invalidate(type);
try {
await VoiceDevicePermissionStore.ensureDevices({requestPermissions: true});
} catch (error) {
logger.error('Failed to refresh media device lists', error);
}
};

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {MessageFlags} from '~/Constants';
import type {MessageRecord} from '~/records/MessageRecord';
import AccessibilityStore, {MediaDimensionSize} from '~/stores/AccessibilityStore';
export interface MediaDimensions {
maxWidth: number;
maxHeight: number;
}
const DIMENSION_PRESETS = {
SMALL: {
maxWidth: 400,
maxHeight: 300,
},
LARGE: {
maxWidth: 550,
maxHeight: 400,
},
} as const;
export const getAttachmentMediaDimensions = (message?: MessageRecord): MediaDimensions => {
if (message && (message.flags & MessageFlags.COMPACT_ATTACHMENTS) !== 0) {
return DIMENSION_PRESETS.SMALL;
}
const size = AccessibilityStore.attachmentMediaDimensionSize;
return size === MediaDimensionSize.SMALL ? DIMENSION_PRESETS.SMALL : DIMENSION_PRESETS.LARGE;
};
export const getEmbedMediaDimensions = (): MediaDimensions => {
const size = AccessibilityStore.embedMediaDimensionSize;
return size === MediaDimensionSize.SMALL ? DIMENSION_PRESETS.SMALL : DIMENSION_PRESETS.LARGE;
};
export const getMosaicMediaDimensions = (message?: MessageRecord): MediaDimensions => {
return getAttachmentMediaDimensions(message);
};

View File

@@ -0,0 +1,105 @@
/*
* 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 {afterEach, describe, expect, test} from 'vitest';
import {buildMediaProxyURL, stripMediaProxyParams} from './MediaProxyUtils';
const ELECTRON_MEDIA_PROXY_BASE = 'http://127.0.0.1:21867/media?token=test-token';
const setElectronMediaProxy = () => {
Object.defineProperty(window, 'electron', {
value: {
getMediaProxyUrl: () => ELECTRON_MEDIA_PROXY_BASE,
},
writable: true,
configurable: true,
});
};
const clearElectron = () => {
Object.defineProperty(window, 'electron', {
value: undefined,
writable: true,
configurable: true,
});
};
afterEach(() => {
clearElectron();
});
describe('MediaProxyUtils (Electron media proxy wrapping)', () => {
test('does not wrap when Electron API is missing', () => {
const url = 'https://example.com/media.png';
expect(buildMediaProxyURL(url)).toBe(url);
});
test('wraps custom-host media URLs when Electron media proxy is available', () => {
setElectronMediaProxy();
const target = 'https://example.com/media.png';
const result = buildMediaProxyURL(target);
const parsed = new URL(result);
expect(parsed.origin).toBe('http://127.0.0.1:21867');
expect(parsed.pathname).toBe('/media');
expect(parsed.searchParams.get('target')).toBe(target);
});
test('does not wrap Fluxer-hosted URLs that are already allowed by the default CSP', () => {
setElectronMediaProxy();
const url = 'https://fluxerusercontent.com/media.png';
expect(buildMediaProxyURL(url)).toBe(url);
});
test('appends params onto the wrapped target URL (not the wrapper URL)', () => {
setElectronMediaProxy();
const base = 'https://example.com/media.png';
const first = buildMediaProxyURL(base, {width: 100});
const second = buildMediaProxyURL(first, {format: 'webp'});
const parsed = new URL(second);
const target = parsed.searchParams.get('target');
expect(target).toBeTruthy();
const targetUrl = new URL(target ?? '');
expect(targetUrl.searchParams.get('width')).toBe('100');
expect(targetUrl.searchParams.get('format')).toBe('webp');
});
test('stripMediaProxyParams removes params from wrapped target URLs', () => {
setElectronMediaProxy();
const base = 'https://example.com/media.png';
const proxied = buildMediaProxyURL(base, {width: 100, format: 'webp', quality: 'high', animated: true});
const stripped = stripMediaProxyParams(proxied);
const parsed = new URL(stripped);
const target = parsed.searchParams.get('target');
expect(target).toBeTruthy();
const targetUrl = new URL(target ?? '');
expect(targetUrl.searchParams.get('width')).toBeNull();
expect(targetUrl.searchParams.get('format')).toBeNull();
expect(targetUrl.searchParams.get('quality')).toBeNull();
expect(targetUrl.searchParams.get('animated')).toBeNull();
});
});

View File

@@ -0,0 +1,167 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
interface MediaProxyOptions {
width?: number;
height?: number;
format?: string;
quality?: 'high' | 'low' | 'lossless';
animated?: boolean;
}
const CSP_ALLOWED_RESOURCE_HOSTS = new Set(['fluxerusercontent.com', 'fluxerstatic.com', 'i.ytimg.com']);
const CSP_ALLOWED_RESOURCE_SUFFIXES = ['.fluxer.app', '.fluxer.media', '.youtube.com'];
function hasURLGlobals(): boolean {
return typeof URL !== 'undefined' && typeof URLSearchParams !== 'undefined';
}
function getElectronMediaProxyBase(): URL | null {
if (!hasURLGlobals()) return null;
if (typeof window.electron?.getMediaProxyUrl !== 'function') return null;
const raw = window.electron.getMediaProxyUrl();
if (!raw) return null;
try {
return new URL(raw);
} catch {
return null;
}
}
function isAllowedByDefaultCsp(hostname: string): boolean {
if (CSP_ALLOWED_RESOURCE_HOSTS.has(hostname)) return true;
return CSP_ALLOWED_RESOURCE_SUFFIXES.some((suffix) => hostname.endsWith(suffix));
}
function unwrapElectronMediaProxyUrl(url: string): {base: URL; target: string} | null {
if (!hasURLGlobals()) return null;
const base = getElectronMediaProxyBase();
if (!base) return null;
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.origin !== base.origin) return null;
if (parsed.pathname !== base.pathname) return null;
const target = parsed.searchParams.get('target');
if (!target) return null;
return {base, target};
}
function shouldWrapWithElectronMediaProxy(targetUrl: string): boolean {
const base = getElectronMediaProxyBase();
if (!base) return false;
try {
const parsed = new URL(targetUrl);
if (parsed.protocol === 'blob:' || parsed.protocol === 'data:') return false;
return !isAllowedByDefaultCsp(parsed.hostname);
} catch {
return false;
}
}
function wrapWithElectronMediaProxy(targetUrl: string, base: URL): string {
const proxied = new URL(base.toString());
proxied.searchParams.set('target', targetUrl);
return proxied.toString();
}
function appendMediaProxyParams(proxyURL: string, options: MediaProxyOptions): string {
const {width, height, format, quality, animated} = options;
if (!width && !height && !format && !quality && !animated) {
return proxyURL;
}
const params = new URLSearchParams();
if (format) {
params.append('format', format);
}
if (width !== undefined) {
params.append('width', width.toString());
}
if (height !== undefined) {
params.append('height', height.toString());
}
if (quality) {
params.append('quality', quality);
}
if (animated !== undefined) {
params.append('animated', animated.toString());
}
const separator = proxyURL.includes('?') ? '&' : '?';
return `${proxyURL}${separator}${params.toString()}`;
}
export function buildMediaProxyURL(proxyURL: string, options: MediaProxyOptions = {}): string {
if (!proxyURL) return proxyURL;
const unwrapped = unwrapElectronMediaProxyUrl(proxyURL);
const base = unwrapped?.base ?? getElectronMediaProxyBase();
const rawUrl = unwrapped ? unwrapped.target : proxyURL;
const updated = appendMediaProxyParams(rawUrl, options);
if (unwrapped && base) {
return wrapWithElectronMediaProxy(updated, base);
}
if (base && shouldWrapWithElectronMediaProxy(updated)) {
return wrapWithElectronMediaProxy(updated, base);
}
return updated;
}
export function stripMediaProxyParams(proxyURL: string): string {
const unwrapped = unwrapElectronMediaProxyUrl(proxyURL);
const base = unwrapped?.base ?? getElectronMediaProxyBase();
const rawUrl = unwrapped ? unwrapped.target : proxyURL;
const url = new URL(rawUrl);
url.searchParams.delete('width');
url.searchParams.delete('height');
url.searchParams.delete('format');
url.searchParams.delete('quality');
url.searchParams.delete('animated');
const stripped = url.toString();
if (unwrapped && base) {
return wrapWithElectronMediaProxy(stripped, base);
}
if (base && shouldWrapWithElectronMediaProxy(stripped)) {
return wrapWithElectronMediaProxy(stripped, base);
}
return stripped;
}

View File

@@ -0,0 +1,193 @@
/*
* 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 {msg} from '@lingui/core/macro';
import {Permissions, StatusTypes} from '~/Constants';
import i18n from '~/i18n';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {GuildRecord} from '~/records/GuildRecord';
import type {UserRecord} from '~/records/UserRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import PresenceStore from '~/stores/PresenceStore';
import * as NicknameUtils from '~/utils/NicknameUtils';
import * as PermissionUtils from '~/utils/PermissionUtils';
export interface MemberGroup {
id: string;
displayName: string;
count: number;
members: Array<GuildMemberRecord>;
position: number;
}
export interface GroupDMMemberGroup {
id: string;
displayName: string;
count: number;
users: Array<UserRecord>;
}
function getVisibleMembers(guild: GuildRecord, channel: ChannelRecord): Array<GuildMemberRecord> {
return GuildMemberStore.getMembers(guild.id).filter((member) =>
PermissionUtils.can(Permissions.VIEW_CHANNEL, member.user, channel.toJSON()),
);
}
function sortMembers(members: Array<GuildMemberRecord>, guildId: string): Array<GuildMemberRecord> {
return [...members].sort((a, b) =>
NicknameUtils.getNickname(a.user, guildId).localeCompare(NicknameUtils.getNickname(b.user, guildId)),
);
}
function getHighestHoistedRole(member: GuildMemberRecord, guild: GuildRecord) {
const hoistedRoles = [...member.roles].map((roleId) => guild.getRole(roleId)).filter((role) => role?.hoist);
if (hoistedRoles.length === 0) return null;
hoistedRoles.sort((a, b) => {
const aPos = a!.effectiveHoistPosition;
const bPos = b!.effectiveHoistPosition;
if (bPos !== aPos) {
return bPos - aPos;
}
return BigInt(a!.id) < BigInt(b!.id) ? -1 : 1;
});
const highestRole = hoistedRoles[0]!;
return {
id: highestRole.id,
name: highestRole.name,
hoistPosition: highestRole.effectiveHoistPosition,
};
}
function groupMembersByRole(members: Array<GuildMemberRecord>, guild: GuildRecord): Array<MemberGroup> {
const roleGroups: Record<string, Array<GuildMemberRecord>> = {};
const roleHoistPositions: Record<string, number> = {};
const roleNames: Record<string, string> = {};
for (const member of members) {
const highestRole = getHighestHoistedRole(member, guild);
if (!highestRole) continue;
if (!roleGroups[highestRole.id]) {
roleGroups[highestRole.id] = [];
roleHoistPositions[highestRole.id] = highestRole.hoistPosition;
roleNames[highestRole.id] = highestRole.name;
}
roleGroups[highestRole.id].push(member);
}
return Object.keys(roleGroups)
.map((roleId) => ({
id: roleId,
displayName: roleNames[roleId],
count: roleGroups[roleId].length,
members: sortMembers(roleGroups[roleId], guild.id),
position: roleHoistPositions[roleId],
}))
.sort((a, b) => {
if (b.position !== a.position) {
return b.position - a.position;
}
return BigInt(a.id) < BigInt(b.id) ? -1 : 1;
});
}
function getOnlineWithoutHoistedRole(members: Array<GuildMemberRecord>, guild: GuildRecord): Array<GuildMemberRecord> {
return sortMembers(
members.filter((member) => !getHighestHoistedRole(member, guild)),
guild.id,
);
}
export function getMemberGroups(guild: GuildRecord, channel: ChannelRecord): Array<MemberGroup> {
const members = getVisibleMembers(guild, channel);
const onlineMembers: Array<GuildMemberRecord> = [];
const offlineMembers: Array<GuildMemberRecord> = [];
for (const member of members) {
const status = PresenceStore.getStatus(member.user.id);
if (status === StatusTypes.OFFLINE || status === StatusTypes.INVISIBLE) {
offlineMembers.push(member);
continue;
}
onlineMembers.push(member);
}
const groupedMembers = groupMembersByRole(onlineMembers, guild);
return [
...groupedMembers,
{
id: 'online',
displayName: i18n._(msg`Online`),
count: onlineMembers.length - groupedMembers.reduce((acc, group) => acc + group.count, 0),
members: getOnlineWithoutHoistedRole(onlineMembers, guild),
position: 10000,
},
{
id: 'offline',
displayName: i18n._(msg`Offline`),
count: offlineMembers.length,
members: sortMembers(offlineMembers, guild.id),
position: 20000,
},
].filter((group) => group.count > 0);
}
function sortUsersByDisplayName(users: Array<UserRecord>): Array<UserRecord> {
return [...users].sort((a, b) => {
const nameA = a.globalName ?? a.username;
const nameB = b.globalName ?? b.username;
return nameA.localeCompare(nameB);
});
}
export function getGroupDMMemberGroups(users: Array<UserRecord>): Array<GroupDMMemberGroup> {
const onlineUsers: Array<UserRecord> = [];
const offlineUsers: Array<UserRecord> = [];
for (const user of users) {
const status = PresenceStore.getStatus(user.id);
if (status === StatusTypes.OFFLINE || status === StatusTypes.INVISIBLE) {
offlineUsers.push(user);
} else {
onlineUsers.push(user);
}
}
return [
{
id: 'online',
displayName: i18n._(msg`Online`),
count: onlineUsers.length,
users: sortUsersByDisplayName(onlineUsers),
},
{
id: 'offline',
displayName: i18n._(msg`Offline`),
count: offlineUsers.length,
users: sortUsersByDisplayName(offlineUsers),
},
].filter((group) => group.count > 0);
}

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 {type CloudAttachment, CloudUpload} from '~/lib/CloudUpload';
import {Logger} from '~/lib/Logger';
import type {ApiAttachmentMetadata} from '~/utils/MessageRequestUtils';
const logger = new Logger('MessageAttachmentUtils');
export interface PreparedMessageAttachments {
attachments?: Array<ApiAttachmentMetadata>;
files?: Array<File>;
}
export const prepareAttachmentsForNonce = async (
nonce: string,
favoriteMemeId?: string,
): Promise<PreparedMessageAttachments> => {
logger.debug(`Preparing attachments for nonce ${nonce}`);
const messageUpload = CloudUpload.getMessageUpload(nonce);
if (!messageUpload) {
throw new Error('No message upload found');
}
const files = messageUpload.attachments.map((att) => att.file);
const attachments = favoriteMemeId ? undefined : mapMessageUploadAttachments(messageUpload.attachments);
return {attachments, files};
};
export const mapMessageUploadAttachments = (attachments: Array<CloudAttachment>): Array<ApiAttachmentMetadata> =>
attachments.map((att, index) => ({
id: String(index),
filename: att.filename,
title: att.filename,
description: att.description,
flags: att.flags,
}));

View File

@@ -0,0 +1,66 @@
/*
* 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 {MessageTypes} from '~/Constants';
import {CallMessage} from '~/components/channel/CallMessage';
import {ChannelIconChangeMessage} from '~/components/channel/ChannelIconChangeMessage';
import {ChannelNameChangeMessage} from '~/components/channel/ChannelNameChangeMessage';
import {GuildJoinMessage} from '~/components/channel/GuildJoinMessage';
import {PinSystemMessage} from '~/components/channel/PinSystemMessage';
import {RecipientAddMessage} from '~/components/channel/RecipientAddMessage';
import {RecipientRemoveMessage} from '~/components/channel/RecipientRemoveMessage';
import {UnknownMessage} from '~/components/channel/UnknownMessage';
import {UserMessage} from '~/components/channel/UserMessage';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {MessageRecord} from '~/records/MessageRecord';
import UserStore from '~/stores/UserStore';
export const getMessageComponent = (
_channel: ChannelRecord,
message: MessageRecord,
forceUnknownMessageType: boolean,
) => {
const currentUser = UserStore.getCurrentUser();
if (forceUnknownMessageType && currentUser && message.author.id === currentUser.id) {
return <UnknownMessage />;
}
switch (message.type) {
case MessageTypes.USER_JOIN:
return <GuildJoinMessage message={message} />;
case MessageTypes.CHANNEL_PINNED_MESSAGE:
return <PinSystemMessage message={message} />;
case MessageTypes.RECIPIENT_ADD:
return <RecipientAddMessage message={message} />;
case MessageTypes.RECIPIENT_REMOVE:
return <RecipientRemoveMessage message={message} />;
case MessageTypes.CALL:
return <CallMessage message={message} />;
case MessageTypes.CHANNEL_NAME_CHANGE:
return <ChannelNameChangeMessage message={message} />;
case MessageTypes.CHANNEL_ICON_CHANGE:
return <ChannelIconChangeMessage message={message} />;
case MessageTypes.DEFAULT:
case MessageTypes.REPLY:
case MessageTypes.CLIENT_SYSTEM:
return <UserMessage />;
default:
return <UnknownMessage />;
}
};

View File

@@ -0,0 +1,246 @@
/*
* 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 {MessageFlags, MessageTypes} from '~/Constants';
import type {ChannelMessages} from '~/lib/ChannelMessages';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {MessageRecord} from '~/records/MessageRecord';
import * as DateUtils from '~/utils/DateUtils';
import SnowflakeUtils from '~/utils/SnowflakeUtil';
export const ChannelStreamType = {
MESSAGE: 'MESSAGE',
MESSAGE_GROUP_BLOCKED: 'MESSAGE_GROUP_BLOCKED',
MESSAGE_GROUP_IGNORED: 'MESSAGE_GROUP_IGNORED',
MESSAGE_GROUP_SPAMMER: 'MESSAGE_GROUP_SPAMMER',
DIVIDER: 'DIVIDER',
} as const;
export type ChannelStreamType = (typeof ChannelStreamType)[keyof typeof ChannelStreamType];
export interface ChannelStreamItem {
type: ChannelStreamType;
content: MessageRecord | Array<ChannelStreamItem> | string;
groupId?: string;
key?: string;
flashKey?: number;
jumpTarget?: boolean;
hasUnread?: boolean;
hasJumpTarget?: boolean;
unreadId?: string;
contentKey?: string;
showUnreadDividerBefore?: boolean;
}
export const MESSAGE_GROUP_TIMEOUT = 7 * 60 * 1000;
export function isNewMessageGroup(
_channel: ChannelRecord | undefined,
prevMessage: MessageRecord | undefined,
currentMessage: MessageRecord,
): boolean {
if (!prevMessage) {
return true;
}
if (currentMessage.type === MessageTypes.REPLY) {
return true;
}
const isCurrentUserContent = currentMessage.isUserMessage();
const isPrevUserContent = prevMessage.isUserMessage();
const isCurrentSystemMessage = !isCurrentUserContent;
const isPrevSystemMessage = !isPrevUserContent;
if (isCurrentSystemMessage && isPrevSystemMessage) {
return false;
}
if (currentMessage.type !== prevMessage.type && !(isCurrentUserContent && isPrevUserContent)) {
return true;
}
if (prevMessage.type <= MessageTypes.REPLY && prevMessage.author.id !== currentMessage.author.id) {
return true;
}
if (currentMessage.webhookId && prevMessage.author.username !== currentMessage.author.username) {
return true;
}
if (!prevMessage.timestamp || !currentMessage.timestamp) {
return true;
}
if (!DateUtils.isSameDay(prevMessage.timestamp, currentMessage.timestamp)) {
return true;
}
const timeDiff = currentMessage.timestamp.getTime() - prevMessage.timestamp.getTime();
if (timeDiff > MESSAGE_GROUP_TIMEOUT) {
return true;
}
const prevSuppressed = prevMessage.hasFlag(MessageFlags.SUPPRESS_NOTIFICATIONS);
const currSuppressed = currentMessage.hasFlag(MessageFlags.SUPPRESS_NOTIFICATIONS);
if (currSuppressed !== prevSuppressed) {
if (!prevSuppressed && currSuppressed) {
return true;
}
if (prevSuppressed && !currSuppressed) {
const hasMentions =
currentMessage.mentions.length > 0 || currentMessage.mentionRoles.length > 0 || currentMessage.mentionEveryone;
if (hasMentions) {
return true;
}
}
}
return false;
}
export function getCollapsedGroupType(
_channel: ChannelRecord,
message: MessageRecord,
_treatSpam: boolean,
): ChannelStreamType | null {
if (message.blocked) {
return ChannelStreamType.MESSAGE_GROUP_BLOCKED;
}
return null;
}
export function createChannelStream(props: {
channel: ChannelRecord;
messages: ChannelMessages;
oldestUnreadMessageId: string | null;
treatSpam: boolean;
}): Array<ChannelStreamItem> {
const {channel, messages, oldestUnreadMessageId, treatSpam} = props;
const stream: Array<ChannelStreamItem> = [];
let lastDateDivider: string | undefined;
let groupId: string | undefined;
let lastMessageInGroup: MessageRecord | undefined;
let unreadTimestamp: number | null = oldestUnreadMessageId
? SnowflakeUtils.extractTimestamp(oldestUnreadMessageId)
: null;
messages.forEach((message): boolean | undefined => {
const dateString = DateUtils.getFormattedFullDate(message.timestamp);
if (dateString !== lastDateDivider) {
stream.push({
type: ChannelStreamType.DIVIDER,
content: dateString,
contentKey: dateString,
});
lastDateDivider = dateString;
}
const lastItem = stream[stream.length - 1];
let collapsedGroupItem: ChannelStreamItem | null = null;
let lastInCollapsedGroup: ChannelStreamItem | undefined;
const collapsedType = getCollapsedGroupType(channel, message, treatSpam);
if (collapsedType !== null) {
if (lastItem?.type !== collapsedType) {
collapsedGroupItem = {
type: collapsedType,
content: [],
key: message.id,
};
stream.push(collapsedGroupItem);
} else {
collapsedGroupItem = lastItem;
const collapsedContent = collapsedGroupItem.content as Array<ChannelStreamItem>;
lastInCollapsedGroup = collapsedContent[collapsedContent.length - 1];
}
}
let shouldShowUnreadDividerBefore = false;
if (oldestUnreadMessageId === message.id && unreadTimestamp != null) {
if (lastItem?.type === ChannelStreamType.DIVIDER) {
lastItem.unreadId = message.id;
} else {
shouldShowUnreadDividerBefore = true;
if (collapsedGroupItem !== null) {
collapsedGroupItem.hasUnread = true;
}
}
unreadTimestamp = null;
} else if (unreadTimestamp != null && SnowflakeUtils.extractTimestamp(message.id) > unreadTimestamp) {
shouldShowUnreadDividerBefore = true;
unreadTimestamp = null;
}
let prevMessageForGrouping: MessageRecord | undefined;
if (collapsedGroupItem && lastInCollapsedGroup && lastInCollapsedGroup.type === ChannelStreamType.MESSAGE) {
prevMessageForGrouping = lastInCollapsedGroup.content as MessageRecord;
} else if (lastItem?.type === ChannelStreamType.MESSAGE) {
prevMessageForGrouping = lastMessageInGroup ?? (lastItem.content as MessageRecord);
} else {
prevMessageForGrouping = lastMessageInGroup;
}
const shouldStartNewGroup = isNewMessageGroup(channel, prevMessageForGrouping, message);
if (shouldStartNewGroup) {
groupId = message.id;
}
const messageItem: ChannelStreamItem = {
type: ChannelStreamType.MESSAGE,
content: message,
groupId,
showUnreadDividerBefore: shouldShowUnreadDividerBefore,
};
if (groupId === message.id) {
lastMessageInGroup = message;
}
const {jumpSequenceId, jumpFlash, jumpTargetId} = messages;
if (jumpFlash && message.id === jumpTargetId && jumpSequenceId != null) {
messageItem.flashKey = jumpSequenceId;
}
if (messages.jumpTargetId === message.id) {
messageItem.jumpTarget = true;
}
if (collapsedGroupItem !== null) {
(collapsedGroupItem.content as Array<ChannelStreamItem>).push(messageItem);
if (messageItem.jumpTarget) {
collapsedGroupItem.hasJumpTarget = true;
}
} else {
stream.push(messageItem);
}
return undefined;
});
return stream;
}

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 * as MessageActionCreators from '~/actions/MessageActionCreators';
import {type JumpTypes, ME} from '~/Constants';
import {Routes} from '~/Routes';
import ChannelStore from '~/stores/ChannelStore';
import * as RouterUtils from '~/utils/RouterUtils';
interface MessageJumpOptions {
flash?: boolean;
offset?: number;
returnTargetId?: string;
jumpType?: JumpTypes;
viewContext?: 'favorites';
}
export const buildMessagePath = (
channelId: string,
messageId: string,
viewContext?: MessageJumpOptions['viewContext'],
): string => {
if (viewContext === 'favorites') {
return Routes.favoritesChannelMessage(channelId, messageId);
}
const channel = ChannelStore.getChannel(channelId);
const guildId = channel?.guildId;
if (guildId && guildId !== ME) {
return Routes.channelMessage(guildId, channelId, messageId);
}
return Routes.dmChannelMessage(channelId, messageId);
};
export const goToMessage = (channelId: string, messageId: string, options?: MessageJumpOptions): void => {
const path = buildMessagePath(channelId, messageId, options?.viewContext);
RouterUtils.transitionTo(path);
MessageActionCreators.jumpToMessage(
channelId,
messageId,
options?.flash ?? true,
options?.offset,
options?.returnTargetId,
options?.jumpType,
);
};
export const parseMessagePath = (path: string): {channelId: string; messageId: string} | null => {
const parts = path.split('/').filter(Boolean);
if (parts.length < 4) return null;
if (parts[0] !== 'channels') return null;
const channelId = parts[2];
const messageId = parts[3];
if (!channelId || !messageId) return null;
return {channelId, messageId};
};

View File

@@ -0,0 +1,160 @@
/*
* 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 {MessageFlags} from '~/Constants';
import type {AllowedMentions, MessageReference, MessageStickerItem} from '~/records/MessageRecord';
export type {MessageReference, MessageStickerItem};
const DEFAULT_ALLOWED_MENTIONS: AllowedMentions = {replied_user: true};
export interface ApiAttachmentMetadata {
id: string;
filename: string;
title: string;
description?: string;
flags?: number;
}
export interface MessageCreateRequest {
content?: string | null;
nonce?: string;
attachments?: Array<ApiAttachmentMetadata>;
allowed_mentions?: AllowedMentions;
message_reference?: MessageReference;
flags?: number;
favorite_meme_id?: string;
sticker_ids?: Array<string>;
tts?: true;
}
export interface MessageEditRequest {
content?: string;
attachments?: Array<ApiAttachmentMetadata>;
flags?: number;
}
export interface MessageCreatePayload {
content?: string | null;
nonce?: string;
attachments?: Array<ApiAttachmentMetadata>;
allowedMentions?: AllowedMentions;
messageReference?: MessageReference;
flags?: number;
favoriteMemeId?: string;
stickers?: Array<MessageStickerItem>;
tts?: boolean;
}
export interface NormalizedMessageContent {
content: string;
flags: number;
}
export const normalizeMessageContent = (content: string, favoriteMemeId?: string): NormalizedMessageContent => {
const sanitized = removeSilentFlag(content);
const flags = getMessageFlags(content, favoriteMemeId);
return {content: sanitized, flags};
};
export const buildMessageCreateRequest = (payload: MessageCreatePayload): MessageCreateRequest => {
const {content, nonce, attachments, allowedMentions, messageReference, flags, favoriteMemeId, stickers, tts} =
payload;
const requestBody: MessageCreateRequest = {};
if (content != null) {
requestBody.content = content;
}
if (nonce != null) {
requestBody.nonce = nonce;
}
if (attachments?.length) {
requestBody.attachments = attachments;
}
if (shouldIncludeAllowedMentions(allowedMentions)) {
requestBody.allowed_mentions = allowedMentions;
}
if (messageReference) {
requestBody.message_reference = messageReference;
}
if (flags != null) {
requestBody.flags = flags;
}
if (favoriteMemeId) {
requestBody.favorite_meme_id = favoriteMemeId;
}
if (stickers?.length) {
requestBody.sticker_ids = stickers.map((sticker) => sticker.id);
}
if (tts) {
requestBody.tts = true;
}
return requestBody;
};
const isSilentMessage = (content: string): boolean => {
return content.startsWith('@silent ');
};
const removeSilentFlag = (content: string): string => {
return content.startsWith('@silent ') ? content.replace('@silent ', '') : content;
};
const getMessageFlags = (content: string, favoriteMemeId?: string): number => {
let flags = 0;
if (isSilentMessage(content)) {
flags |= MessageFlags.SUPPRESS_NOTIFICATIONS;
}
if (favoriteMemeId) {
flags |= MessageFlags.COMPACT_ATTACHMENTS;
}
return flags;
};
const shouldIncludeAllowedMentions = (allowedMentions?: AllowedMentions): boolean => {
if (!allowedMentions) {
return false;
}
const allowedKeys = Object.keys(allowedMentions) as Array<keyof AllowedMentions>;
if (allowedKeys.length !== Object.keys(DEFAULT_ALLOWED_MENTIONS).length) {
return true;
}
for (const key of allowedKeys) {
if (allowedMentions[key] !== DEFAULT_ALLOWED_MENTIONS[key]) {
return true;
}
}
return false;
};

View File

@@ -0,0 +1,124 @@
/*
* 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 {MessageStates, MessageTypes} from '~/Constants';
import {CloudUpload} from '~/lib/CloudUpload';
import type {AllowedMentions, MessageReference, MessageStickerItem} from '~/records/MessageRecord';
import {MessageRecord} from '~/records/MessageRecord';
import type {UserRecord} from '~/records/UserRecord';
import {normalizeMessageContent} from '~/utils/MessageRequestUtils';
interface MessageSubmitData {
content: string;
channelId: string;
nonce: string;
currentUser: UserRecord;
referencedMessage?: MessageRecord | null;
replyMentioning?: boolean;
stickers?: Array<MessageStickerItem>;
favoriteMemeId?: string;
}
interface UploadingAttachment {
id: string;
filename: string;
title?: string;
size: number;
url: string;
proxy_url: string;
content_type: string;
flags: number;
}
export function createUploadingAttachments(
claimedAttachments: Array<{filename: string; file: {size: number}}>,
): Array<UploadingAttachment> {
if (claimedAttachments.length === 0) {
return [];
}
return [
{
id: 'uploading',
filename:
claimedAttachments.length === 1
? claimedAttachments[0].filename
: `Uploading ${claimedAttachments.length} Files`,
title: claimedAttachments.length === 1 ? claimedAttachments[0].filename : undefined,
size: claimedAttachments.reduce((total, att) => total + att.file.size, 0),
url: '',
proxy_url: '',
content_type: 'application/octet-stream',
flags: 0x1000,
},
];
}
export function createOptimisticMessage(
data: MessageSubmitData,
attachments: Array<UploadingAttachment>,
): MessageRecord {
const normalized = normalizeMessageContent(data.content, data.favoriteMemeId);
const content = normalized.content;
const flags = normalized.flags;
return new MessageRecord({
id: data.nonce,
channel_id: data.channelId,
author: data.currentUser,
type: data.referencedMessage ? MessageTypes.REPLY : MessageTypes.DEFAULT,
flags,
pinned: false,
mention_everyone: false,
content,
timestamp: new Date().toISOString(),
mentions: [...(data.referencedMessage && data.replyMentioning ? [data.referencedMessage.author] : [])],
message_reference: data.referencedMessage
? {channel_id: data.channelId, message_id: data.referencedMessage.id, type: 0}
: undefined,
state: MessageStates.SENDING,
nonce: data.nonce,
attachments,
});
}
export function prepareMessageReference(
channelId: string,
referencedMessage?: MessageRecord | null,
): MessageReference | undefined {
return referencedMessage ? {channel_id: channelId, message_id: referencedMessage.id, type: 0} : undefined;
}
export function claimMessageAttachments(
channelId: string,
nonce: string,
content: string,
messageReference?: MessageReference,
replyMentioning?: boolean,
favoriteMemeId?: string,
): Array<{filename: string; file: {size: number}}> {
const normalized = normalizeMessageContent(content, favoriteMemeId);
const allowedMentions: AllowedMentions = {replied_user: replyMentioning ?? true};
return CloudUpload.claimAttachmentsForMessage(channelId, nonce, undefined, {
content: normalized.content,
messageReference,
allowedMentions,
flags: normalized.flags,
});
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageRecord} from '~/records/MessageRecord';
import type {UserRecord} from '~/records/UserRecord';
import ChannelStore from '~/stores/ChannelStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
export const isMentioned = (user: UserRecord, message: MessageRecord): boolean => {
const channel = ChannelStore.getChannel(message.channelId);
if (channel == null) {
console.warn(`${message.channelId} does not exist!`);
return false;
}
const suppressEveryone = UserGuildSettingsStore.isSuppressEveryoneEnabled(channel.guildId ?? null);
const mentionEveryone = message.mentionEveryone && !suppressEveryone;
if (mentionEveryone) {
return true;
}
if (message.mentions.some((mention) => mention.id === user.id)) {
return true;
}
if (channel.guildId == null) {
return false;
}
const guild = GuildStore.getGuild(channel.guildId);
if (!guild) {
return false;
}
const guildMember = GuildMemberStore.getMember(guild.id, user.id);
if (!guildMember) {
return false;
}
return message.mentionRoles.some((roleId) => guildMember.roles.has(roleId));
};

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/>.
*/
const getRandomBytes = (size = 10) => crypto.getRandomValues(new Uint8Array(size));
const encodeTotpKey = (bin: Uint8Array) => {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '';
for (const byte of bin) {
bits += byte.toString(2).padStart(8, '0');
}
let base32 = '';
for (let i = 0; i < bits.length; i += 5) {
const chunk = bits.substring(i, i + 5).padEnd(5, '0');
base32 += alphabet[Number.parseInt(chunk, 2)];
}
return base32
.toLowerCase()
.replace(/(.{4})/g, '$1 ')
.trim();
};
export const generateTotpSecret = () => encodeTotpKey(getRandomBytes());
export const encodeTotpSecret = (secret: string) => secret.replace(/[\s._-]+/g, '').toUpperCase();
export const encodeTotpSecretAsURL = (accountName: string, secret: string, issuer = 'Fluxer') =>
`otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}\
?secret=${encodeTotpSecret(secret)}\
&issuer=${encodeURIComponent(issuer)}`;

View File

@@ -0,0 +1,96 @@
/*
* 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 {Routes} from '~/Routes';
import * as RouterUtils from '~/utils/RouterUtils';
export interface Navigator {
replace: (path: string) => void;
push: (path: string) => void;
getPath: () => string;
}
const defaultNavigator: Navigator = {
replace: (p: string) => RouterUtils.replaceWith(p),
push: (p: string) => RouterUtils.transitionTo(p),
getPath: () => RouterUtils.getHistory()?.location.pathname ?? '',
};
let inProgress = false;
let pendingTarget: string | null = null;
function computeBasePath(url: string): string | null {
if (Routes.isDMRoute(url) && url !== Routes.ME) {
return Routes.ME;
}
if (Routes.isGuildChannelRoute(url) && url.split('/').length === 4) {
const parts = url.split('/');
const guildId = parts[2];
return Routes.guildChannel(guildId);
}
return null;
}
export function navigateToWithMobileHistory(url: string, isMobile: boolean, nav: Navigator = defaultNavigator): void {
if (!isMobile) {
inProgress = false;
pendingTarget = null;
nav.replace(url);
return;
}
if (inProgress && (pendingTarget === url || pendingTarget !== null)) {
return;
}
const base = computeBasePath(url);
if (!base) {
nav.replace(url);
return;
}
const current = nav.getPath();
if (current === base) {
inProgress = true;
pendingTarget = url;
nav.push(url);
inProgress = false;
pendingTarget = null;
return;
}
inProgress = true;
pendingTarget = url;
nav.replace(base);
setTimeout(() => {
try {
if (pendingTarget === url) {
nav.push(url);
}
} finally {
inProgress = false;
pendingTarget = null;
}
}, 0);
}
export function __resetMobileNavigationGuardsForTests() {
inProgress = false;
pendingTarget = null;
}

View File

@@ -0,0 +1,161 @@
/*
* 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 {getElectronAPI, isNativeMacOS} from '~/utils/NativeUtils';
type PermissionKind = 'microphone' | 'camera' | 'screen' | 'accessibility' | 'input-monitoring';
export type NativePermissionResult = 'granted' | 'denied' | 'not-determined' | 'unsupported';
const permissionCache = new Map<
PermissionKind,
{
value: NativePermissionResult;
timestamp: number;
}
>();
const CACHE_DURATION = 1000;
export const getCachedPermission = (kind: PermissionKind): NativePermissionResult | null => {
const cached = permissionCache.get(kind);
if (!cached) return null;
const age = Date.now() - cached.timestamp;
if (age > CACHE_DURATION) {
permissionCache.delete(kind);
return null;
}
return cached.value;
};
const setCachedPermission = (kind: PermissionKind, value: NativePermissionResult): void => {
permissionCache.set(kind, {value, timestamp: Date.now()});
};
export const checkNativePermission = async (kind: PermissionKind): Promise<NativePermissionResult> => {
const electronApi = getElectronAPI();
if (!electronApi) {
const result = 'unsupported';
setCachedPermission(kind, result);
return result;
}
if (!isNativeMacOS()) {
const result = 'granted';
setCachedPermission(kind, result);
return result;
}
let result: NativePermissionResult;
if (kind === 'input-monitoring') {
const hasAccess = await electronApi.checkInputMonitoringAccess();
result = hasAccess ? 'granted' : 'denied';
setCachedPermission(kind, result);
return result;
}
if (kind === 'accessibility') {
const isTrusted = await electronApi.checkAccessibility(false);
result = isTrusted ? 'granted' : 'denied';
setCachedPermission(kind, result);
return result;
}
const status = await electronApi.checkMediaAccess(kind);
switch (status) {
case 'granted':
result = 'granted';
break;
case 'denied':
case 'restricted':
result = 'denied';
break;
case 'not-determined':
result = 'not-determined';
break;
default:
result = 'not-determined';
break;
}
setCachedPermission(kind, result);
return result;
};
export const requestNativePermission = async (kind: PermissionKind): Promise<NativePermissionResult> => {
const electronApi = getElectronAPI();
if (!electronApi) return 'unsupported';
if (!isNativeMacOS()) {
return 'granted';
}
if (kind === 'input-monitoring') {
const hasAccess = await electronApi.checkInputMonitoringAccess();
return hasAccess ? 'granted' : 'denied';
}
if (kind === 'accessibility') {
const isTrusted = await electronApi.checkAccessibility(true);
return isTrusted ? 'granted' : 'denied';
}
const granted = await electronApi.requestMediaAccess(kind);
return granted ? 'granted' : 'denied';
};
export const ensureNativePermission = async (kind: PermissionKind): Promise<NativePermissionResult> => {
const current = await checkNativePermission(kind);
if (current === 'granted' || current === 'unsupported') {
return current;
}
if (current === 'not-determined') {
return requestNativePermission(kind);
}
return 'denied';
};
export const openNativePermissionSettings = async (kind: PermissionKind): Promise<void> => {
const electronApi = getElectronAPI();
if (!electronApi) return;
if (!isNativeMacOS()) {
return;
}
switch (kind) {
case 'accessibility':
await electronApi.openAccessibilitySettings();
break;
case 'input-monitoring':
await electronApi.openInputMonitoringSettings();
break;
case 'microphone':
case 'camera':
case 'screen':
await electronApi.openMediaAccessSettings(kind);
break;
}
};

View File

@@ -0,0 +1,141 @@
/*
* 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 {ElectronAPI} from '../../src-electron/common/types';
export const isElectron = (): boolean => (window as {electron?: ElectronAPI}).electron !== undefined;
export const getElectronAPI = (): ElectronAPI | null => {
if (!isElectron()) return null;
return (window as {electron?: ElectronAPI}).electron ?? null;
};
export const isDesktop = (): boolean => isElectron();
export type NativePlatform = 'macos' | 'windows' | 'linux' | 'unknown';
const normalizePlatform = (platform: string | null | undefined): NativePlatform => {
const value = platform?.toLowerCase() ?? '';
if (value.startsWith('mac')) return 'macos';
if (value.startsWith('darwin')) return 'macos';
if (value.startsWith('win')) return 'windows';
if (value.includes('linux')) return 'linux';
return 'unknown';
};
export const guessPlatform = (): NativePlatform => {
const uaDataPlatform = (navigator as {userAgentData?: {platform?: string}}).userAgentData?.platform;
if (uaDataPlatform) {
return normalizePlatform(uaDataPlatform);
}
return normalizePlatform(navigator.platform);
};
export const getNativePlatform = async (): Promise<NativePlatform> => {
const electronApi = getElectronAPI();
if (electronApi) {
switch (electronApi.platform) {
case 'darwin':
return 'macos';
case 'win32':
return 'windows';
case 'linux':
return 'linux';
default:
return 'unknown';
}
}
return guessPlatform();
};
export const isNativeMacOS = (platform?: NativePlatform) => (platform ?? guessPlatform()) === 'macos';
export const isNativeWindows = (platform?: NativePlatform) => (platform ?? guessPlatform()) === 'windows';
export const isNativeLinux = (platform?: NativePlatform) => (platform ?? guessPlatform()) === 'linux';
let externalLinkHandlerAttached = false;
const isLikelyExternal = (href: string | null): href is string => {
if (!href) return false;
if (href.startsWith('javascript:')) return false;
try {
const url = new URL(href, window.location.href);
const allowedProtocols = ['http:', 'https:', 'mailto:', 'x-apple.systempreferences:'];
return allowedProtocols.includes(url.protocol);
} catch {
return false;
}
};
export const openExternalUrl = async (url: string, target: string = '_blank') => {
const electronApi = getElectronAPI();
if (electronApi) {
try {
await electronApi.openExternal(url);
return;
} catch (error) {
console.error('[NativeUtils] Failed to open via Electron, falling back', error);
}
}
window.open(url, target, 'noopener,noreferrer');
};
export const attachExternalLinkInterceptor = () => {
if (!isDesktop() || externalLinkHandlerAttached) return () => undefined;
const handler = (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
const anchor = target?.closest?.('a[target="_blank"]') as HTMLAnchorElement | null;
if (!anchor) return;
const href = anchor.getAttribute('href');
if (!isLikelyExternal(href)) return;
event.preventDefault();
void openExternalUrl(href ?? '');
};
document.addEventListener('click', handler);
externalLinkHandlerAttached = true;
return () => {
document.removeEventListener('click', handler);
externalLinkHandlerAttached = false;
};
};
export const downloadWithNative = async (options: {
url: string;
suggestedName?: string;
title?: string;
}): Promise<boolean> => {
const electronApi = getElectronAPI();
if (electronApi) {
try {
const result = await electronApi.downloadFile(options.url, options.suggestedName ?? 'download');
return result.success;
} catch (error) {
console.error('[NativeUtils] Native download failed, falling back to browser', error);
return false;
}
}
return false;
};

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 type {UserRecord} from '~/records/UserRecord';
import ChannelStore from '~/stores/ChannelStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import RelationshipStore from '~/stores/RelationshipStore';
import SelectedGuildStore from '~/stores/SelectedGuildStore';
export const getNickname = (user: UserRecord, guildId?: string | null, channelId?: string | null) => {
let name = user.displayName;
const relationship = RelationshipStore.getRelationship(user.id);
if (relationship?.nickname) {
name = relationship.nickname;
}
guildId ??= SelectedGuildStore.selectedGuildId;
if (guildId) {
const member = GuildMemberStore.getMember(guildId, user.id);
if (member?.nick) {
name = member.nick;
}
} else if (channelId) {
const channel = ChannelStore.getChannel(channelId);
if (channel?.nicks?.[user.id]) {
name = channel.nicks[user.id];
}
}
return name;
};

View File

@@ -0,0 +1,189 @@
/*
* 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 NotificationActionCreators from '~/actions/NotificationActionCreators';
import * as SoundActionCreators from '~/actions/SoundActionCreators';
import AuthenticationStore from '~/stores/AuthenticationStore';
import SoundStore from '~/stores/SoundStore';
import UserStore from '~/stores/UserStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {getElectronAPI, isDesktop} from '~/utils/NativeUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import {SoundType} from '~/utils/SoundUtils';
let notificationClickHandlerInitialized = false;
export const ensureDesktopNotificationClickHandler = (): void => {
if (notificationClickHandlerInitialized) return;
const electronApi = getElectronAPI();
if (!electronApi) return;
notificationClickHandlerInitialized = true;
electronApi.onNotificationClick((_id: string, url?: string) => {
if (url) {
RouterUtils.transitionTo(url);
}
});
};
export const hasNotification = (): boolean => {
if (isDesktop()) return true;
return typeof Notification !== 'undefined';
};
export const isGranted = async (): Promise<boolean> => {
if (isDesktop()) return true;
return typeof Notification !== 'undefined' && Notification.permission === 'granted';
};
export const playNotificationSoundIfEnabled = (): void => {
if (!SoundStore.isSoundTypeEnabled(SoundType.Message)) return;
SoundActionCreators.playSound(SoundType.Message);
};
type PermissionResult = 'granted' | 'denied' | 'unsupported';
const requestBrowserPermission = async (): Promise<PermissionResult> => {
if (typeof Notification === 'undefined') {
return 'unsupported';
}
try {
const permission = await Notification.requestPermission();
return permission === 'granted' ? 'granted' : 'denied';
} catch {
return 'denied';
}
};
const getCurrentUserAvatar = (): string | null => {
const currentUserId = AuthenticationStore.currentUserId;
if (!currentUserId) return null;
const currentUser = UserStore.getUser(currentUserId);
if (!currentUser) return null;
return AvatarUtils.getUserAvatarURL(currentUser);
};
export const requestPermission = async (i18n: I18n): Promise<void> => {
if (isDesktop()) {
NotificationActionCreators.permissionGranted();
playNotificationSoundIfEnabled();
const icon = getCurrentUserAvatar() ?? '';
void showNotification({
title: i18n._(msg`Access granted`),
body: i18n._(msg`Huzzah! Desktop notifications are enabled`),
icon,
});
return;
}
const result = await requestBrowserPermission();
if (result !== 'granted') {
NotificationActionCreators.permissionDenied(i18n);
return;
}
NotificationActionCreators.permissionGranted();
playNotificationSoundIfEnabled();
const icon = getCurrentUserAvatar() ?? '';
void showNotification({
title: i18n._(msg`Access granted`),
body: i18n._(msg`Huzzah! Browser notifications are enabled`),
icon,
});
};
export interface NotificationResult {
browserNotification: Notification | null;
nativeNotificationId: string | null;
}
export const showNotification = async ({
title,
body,
url,
icon,
playSound = true,
}: {
title: string;
body: string;
url?: string;
icon?: string;
playSound?: boolean;
}): Promise<NotificationResult> => {
if (playSound) {
playNotificationSoundIfEnabled();
}
const electronApi = getElectronAPI();
if (electronApi) {
try {
const result = await electronApi.showNotification({
title,
body,
icon: icon ?? '',
url,
});
return {browserNotification: null, nativeNotificationId: result.id};
} catch {
return {browserNotification: null, nativeNotificationId: null};
}
}
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
const notificationOptions: NotificationOptions = icon ? {body, icon} : {body};
const notification = new Notification(title, notificationOptions);
notification.addEventListener('click', (event) => {
event.preventDefault();
window.focus();
if (url) {
RouterUtils.transitionTo(url);
}
notification.close();
});
return {browserNotification: notification, nativeNotificationId: null};
}
return {browserNotification: null, nativeNotificationId: null};
};
export const closeNativeNotification = (id: string): void => {
const electronApi = getElectronAPI();
if (electronApi) {
electronApi.closeNotification(id);
}
};
export const closeNativeNotifications = (ids: Array<string>): void => {
if (ids.length === 0) return;
const electronApi = getElectronAPI();
if (electronApi) {
electronApi.closeNotifications(ids);
}
};

View File

@@ -0,0 +1,117 @@
/*
* 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 {MentionSegment} from './TextareaSegmentManager';
const MARKDOWN_MENTION_PATTERN = /<(@|#|@&)([a-zA-Z0-9]+)>/g;
const EMOJI_MARKDOWN_PATTERN = /<(a)?:([^:]+):([a-zA-Z0-9]+)>/g;
interface PastedSegmentInfo {
displayText: string;
actualText: string;
type: MentionSegment['type'];
id: string;
start: number;
end: number;
}
export interface LookupFunctions {
userById: (id: string) => {id: string; tag: string} | null;
channelById: (id: string) => {id: string; name: string} | null;
roleById: (id: string) => {id: string; name: string} | null;
emojiById: (id: string) => {id: string; name: string; uniqueName: string} | null;
}
export function detectPastedSegments(
pastedText: string,
pastePosition: number,
lookups: LookupFunctions,
): Array<PastedSegmentInfo> {
const segments: Array<PastedSegmentInfo> = [];
let match: RegExpExecArray | null;
MARKDOWN_MENTION_PATTERN.lastIndex = 0;
while ((match = MARKDOWN_MENTION_PATTERN.exec(pastedText)) !== null) {
const [fullMatch, prefix, id] = match;
const start = pastePosition + match.index;
const end = start + fullMatch.length;
let type: MentionSegment['type'];
let displayText: string | null = null;
if (prefix === '@') {
type = 'user';
const user = lookups.userById(id);
if (user) {
displayText = `@${user.tag}`;
}
} else if (prefix === '#') {
type = 'channel';
const channel = lookups.channelById(id);
if (channel) {
displayText = `#${channel.name}`;
}
} else if (prefix === '@&') {
type = 'role';
const role = lookups.roleById(id);
if (role) {
displayText = `@${role.name}`;
}
} else {
continue;
}
if (displayText) {
segments.push({
displayText,
actualText: fullMatch,
type,
id,
start,
end,
});
}
}
EMOJI_MARKDOWN_PATTERN.lastIndex = 0;
while ((match = EMOJI_MARKDOWN_PATTERN.exec(pastedText)) !== null) {
const [fullMatch, , , emojiId] = match;
const emoji = lookups.emojiById(emojiId);
if (emoji) {
const start = pastePosition + match.index;
const end = start + fullMatch.length;
const overlaps = segments.some((seg) => start < seg.end && end > seg.start);
if (!overlaps) {
segments.push({
displayText: `:${emoji.name}:`,
actualText: fullMatch,
type: 'emoji',
id: emoji.id,
start,
end,
});
}
}
}
return segments.sort((a, b) => a.start - b.start);
}

View File

@@ -0,0 +1,570 @@
/*
* 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 {ALL_PERMISSIONS, DEFAULT_PERMISSIONS, ElevatedPermissions, GuildMFALevel, Permissions} from '~/Constants';
import type {Channel} from '~/records/ChannelRecord';
import type {Guild} from '~/records/GuildRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
export const NONE = 0n;
type UserId = string;
type RoleId = string;
interface PermissionOverwrite {
id: string;
type: 0 | 1;
allow: bigint;
deny: bigint;
}
interface Role {
id: RoleId;
permissions: bigint;
position: number;
}
export interface PermissionSpec {
title: string;
permissions: Array<{
title: string;
description?: string;
flag: bigint;
}>;
}
function calculateElevatedPermissions(permissions: bigint, guild: Guild, userId: UserId, checkElevated = true): bigint {
if (
checkElevated &&
(guild.mfa_level ?? 0) === GuildMFALevel.ELEVATED &&
userId === AuthenticationStore.currentUserId
) {
const currentUser = UserStore.getCurrentUser();
if (currentUser && !currentUser.mfaEnabled) {
permissions &= ~ElevatedPermissions;
}
}
return permissions;
}
export function computePermissions(
user: {id: UserId} | UserId,
context: Channel | Guild,
overwrites?: Record<string, PermissionOverwrite> | null,
roles?: Record<RoleId, Role> | null,
checkElevated = true,
): bigint {
const userId = typeof user === 'string' ? user : user.id;
let guild: Guild | null = null;
let guildRoles: Record<RoleId, Role> | null = null;
if ('guild_id' in context) {
const channel = context as Channel;
const channelOverwrites = channel.permission_overwrites ?? [];
const convertedOverwrites = Object.fromEntries(
channelOverwrites.map((ow) => [
ow.id,
{id: ow.id, type: ow.type, allow: BigInt(ow.allow), deny: BigInt(ow.deny)} as PermissionOverwrite,
]),
) as Record<string, PermissionOverwrite>;
overwrites = overwrites != null ? {...convertedOverwrites, ...overwrites} : convertedOverwrites;
const guildRecord = channel.guild_id != null ? GuildStore.getGuild(channel.guild_id) : null;
if (guildRecord) {
guild = guildRecord.toJSON();
guildRoles = Object.fromEntries(
Object.entries(guildRecord.roles).map(([id, roleRecord]) => [
id,
{
id: roleRecord.id,
permissions: roleRecord.permissions,
position: roleRecord.position,
},
]),
);
}
} else {
overwrites = overwrites || {};
guild = context as Guild;
const guildRecord = GuildStore.getGuild(guild.id);
if (guildRecord) {
guildRoles = Object.fromEntries(
Object.entries(guildRecord.roles).map(([id, roleRecord]) => [
id,
{
id: roleRecord.id,
permissions: roleRecord.permissions,
position: roleRecord.position,
},
]),
);
}
}
if (guild == null) {
return NONE;
}
if (guild.owner_id === userId) {
return calculateElevatedPermissions(ALL_PERMISSIONS, guild, userId, checkElevated);
}
roles = roles != null && guildRoles ? {...guildRoles, ...roles} : (guildRoles ?? roles ?? {});
const member = GuildMemberStore.getMember(guild.id, userId);
const roleEveryone = roles?.[guild.id];
let permissions = roleEveryone != null ? roleEveryone.permissions : DEFAULT_PERMISSIONS;
if (member != null && roles) {
for (const roleId of member.roles) {
const role = roles[roleId];
if (role !== undefined) {
permissions |= role.permissions;
}
}
}
if ((permissions & Permissions.ADMINISTRATOR) === Permissions.ADMINISTRATOR) {
permissions = ALL_PERMISSIONS;
} else if (overwrites) {
const overwriteEveryone = overwrites[guild.id];
if (overwriteEveryone != null) {
permissions ^= permissions & overwriteEveryone.deny;
permissions |= overwriteEveryone.allow;
}
if (member != null) {
let allow = NONE;
let deny = NONE;
for (const roleId of member.roles) {
const overwriteRole = overwrites[roleId];
if (overwriteRole != null) {
allow |= overwriteRole.allow;
deny |= overwriteRole.deny;
}
}
permissions ^= permissions & deny;
permissions |= allow;
const overwriteMember = overwrites[userId];
if (overwriteMember != null) {
permissions ^= permissions & overwriteMember.deny;
permissions |= overwriteMember.allow;
}
}
}
return calculateElevatedPermissions(permissions, guild, userId, checkElevated);
}
export function isRoleHigher(guild: Guild, userId: UserId, a: Role | null, b: Role | null): boolean {
if (guild.owner_id === userId) return true;
if (a == null) return false;
const guildRecord = GuildStore.getGuild(guild.id);
if (!guildRecord) return false;
const rolesList = Object.values(guildRecord.roles)
.sort((r1, r2) => r1.position - r2.position)
.map((role) => role.id);
return rolesList.indexOf(a.id) > (b != null ? rolesList.indexOf(b.id) : -1);
}
export function getHighestRole(guild: Guild, userId: UserId): Role | null {
const member = GuildMemberStore.getMember(guild.id, userId);
if (member == null) return null;
const guildRecord = GuildStore.getGuild(guild.id);
if (!guildRecord) return null;
const memberRoles = Object.values(guildRecord.roles)
.filter((roleRecord) => Array.from(member.roles).includes(roleRecord.id))
.sort((a, b) => b.position - a.position)
.map((roleRecord) => ({
id: roleRecord.id,
permissions: roleRecord.permissions,
position: roleRecord.position,
}));
return memberRoles[0] ?? null;
}
export function can(
permission: bigint,
user: {id: UserId} | UserId,
context: Channel | Guild,
overwrites?: Record<string, PermissionOverwrite> | null,
roles?: Record<RoleId, Role> | null,
): boolean {
return (computePermissions(user, context, overwrites, roles) & permission) === permission;
}
function generateGuildGeneralPermissionSpec(i18n: I18n): PermissionSpec {
return {
title: i18n._(msg`Community-wide`),
permissions: [
{
title: i18n._(msg`Administrator`),
description: i18n._(msg`Grants all permissions and bypasses channel restrictions. Highly sensitive.`),
flag: Permissions.ADMINISTRATOR,
},
{
title: i18n._(msg`View Activity Log`),
description: i18n._(msg`Read the community's audit log of changes and moderation actions.`),
flag: Permissions.VIEW_AUDIT_LOG,
},
{
title: i18n._(msg`Manage Community`),
description: i18n._(msg`Edit global settings like name, description, and icon.`),
flag: Permissions.MANAGE_GUILD,
},
{
title: i18n._(msg`Manage Roles`),
description: i18n._(
msg`Create, edit, or delete roles below your highest role. Also allows editing channel permission overwrites.`,
),
flag: Permissions.MANAGE_ROLES,
},
{
title: i18n._(msg`Manage Channels`),
description: i18n._(msg`Create, edit, or delete channels and categories.`),
flag: Permissions.MANAGE_CHANNELS,
},
{
title: i18n._(msg`Kick Members`),
flag: Permissions.KICK_MEMBERS,
},
{
title: i18n._(msg`Ban Members`),
flag: Permissions.BAN_MEMBERS,
},
{
title: i18n._(msg`Create Invite Links`),
flag: Permissions.CREATE_INSTANT_INVITE,
},
{
title: i18n._(msg`Change Own Nickname`),
description: i18n._(msg`Update your own nickname.`),
flag: Permissions.CHANGE_NICKNAME,
},
{
title: i18n._(msg`Manage Nicknames`),
description: i18n._(msg`Change other members' nicknames.`),
flag: Permissions.MANAGE_NICKNAMES,
},
{
title: i18n._(msg`Manage Emoji & Stickers`),
flag: Permissions.MANAGE_EXPRESSIONS,
},
{
title: i18n._(msg`Manage Webhooks`),
description: i18n._(msg`Create, edit, or delete webhooks.`),
flag: Permissions.MANAGE_WEBHOOKS,
},
],
};
}
function generateGuildTextPermissionSpec(i18n: I18n): PermissionSpec {
return {
title: i18n._(msg`Messages & Media`),
permissions: [
{
title: i18n._(msg`Send Messages`),
flag: Permissions.SEND_MESSAGES,
},
{
title: i18n._(msg`Send TTS Messages`),
description: i18n._(msg`Send text-to-speech messages.`),
flag: Permissions.SEND_TTS_MESSAGES,
},
{
title: i18n._(msg`Manage Messages`),
description: i18n._(msg`Delete others' messages. (Pinning is separate below.)`),
flag: Permissions.MANAGE_MESSAGES,
},
{
title: i18n._(msg`Pin Messages`),
flag: Permissions.PIN_MESSAGES,
},
{
title: i18n._(msg`Embed Links`),
flag: Permissions.EMBED_LINKS,
},
{
title: i18n._(msg`Attach Files`),
flag: Permissions.ATTACH_FILES,
},
{
title: i18n._(msg`Read Message History`),
flag: Permissions.READ_MESSAGE_HISTORY,
},
{
title: i18n._(msg`Use @everyone/@here and @roles`),
description: i18n._(msg`Mention everyone or any role (even if the role isn't set to be mentionable).`),
flag: Permissions.MENTION_EVERYONE,
},
{
title: i18n._(msg`Use External Emoji`),
description: i18n._(msg`Use emoji from other communities.`),
flag: Permissions.USE_EXTERNAL_EMOJIS,
},
{
title: i18n._(msg`Use External Stickers`),
flag: Permissions.USE_EXTERNAL_STICKERS,
},
{
title: i18n._(msg`Add Reactions`),
description: i18n._(msg`Add new reactions to messages.`),
flag: Permissions.ADD_REACTIONS,
},
{
title: i18n._(msg`Bypass Slowmode`),
description: i18n._(msg`Ignore per-channel message rate limits.`),
flag: Permissions.BYPASS_SLOWMODE,
},
],
};
}
function generateGuildModerationPermissionSpec(i18n: I18n): PermissionSpec {
return {
title: i18n._(msg`Moderation`),
permissions: [
{
title: i18n._(msg`Timeout Members`),
description: i18n._(msg`Prevent members from sending messages, reacting, and joining voice for a duration.`),
flag: Permissions.MODERATE_MEMBERS,
},
],
};
}
function generateGuildAccessPermissionSpec(i18n: I18n): PermissionSpec {
return {
title: i18n._(msg`Channel Access`),
permissions: [{title: i18n._(msg`View Channel`), flag: Permissions.VIEW_CHANNEL}],
};
}
function generateGuildVoicePermissionSpec(i18n: I18n): PermissionSpec {
return {
title: i18n._(msg`Audio & Video`),
permissions: [
{
title: i18n._(msg`Connect (Join Voice)`),
flag: Permissions.CONNECT,
},
{
title: i18n._(msg`Speak`),
flag: Permissions.SPEAK,
},
{
title: i18n._(msg`Stream Video`),
flag: Permissions.STREAM,
},
{
title: i18n._(msg`Use Voice Activity`),
description: i18n._(msg`Otherwise Push-to-talk is required.`),
flag: Permissions.USE_VAD,
},
{
title: i18n._(msg`Priority Speaker`),
flag: Permissions.PRIORITY_SPEAKER,
},
{
title: i18n._(msg`Mute Members`),
flag: Permissions.MUTE_MEMBERS,
},
{
title: i18n._(msg`Deafen Members`),
flag: Permissions.DEAFEN_MEMBERS,
},
{
title: i18n._(msg`Move Members`),
description: i18n._(msg`Drag members between channels they can access.`),
flag: Permissions.MOVE_MEMBERS,
},
{
title: i18n._(msg`Set Voice Region`),
flag: Permissions.UPDATE_RTC_REGION,
},
],
};
}
export function generateChannelGeneralPermissionSpec(i18n: I18n): PermissionSpec {
return {
title: i18n._(msg`Channel Management`),
permissions: [
{
title: i18n._(msg`Create Invite Links`),
flag: Permissions.CREATE_INSTANT_INVITE,
},
{
title: i18n._(msg`Manage Channel`),
description: i18n._(msg`Rename and edit this channel's settings.`),
flag: Permissions.MANAGE_CHANNELS,
},
{
title: i18n._(msg`Manage Permissions`),
description: i18n._(msg`Edit overwrites for roles and members in this channel.`),
flag: Permissions.MANAGE_ROLES,
},
{
title: i18n._(msg`Manage Webhooks`),
description: i18n._(msg`Create, edit, or delete webhooks for this channel.`),
flag: Permissions.MANAGE_WEBHOOKS,
},
],
};
}
export function generateChannelAccessPermissionSpec(i18n: I18n): PermissionSpec {
return {
title: i18n._(msg`Channel Access`),
permissions: [{title: i18n._(msg`View Channel`), flag: Permissions.VIEW_CHANNEL}],
};
}
export function generateChannelTextPermissionSpec(i18n: I18n): PermissionSpec {
return {
title: i18n._(msg`Messages & Media`),
permissions: [
{title: i18n._(msg`Send Messages`), flag: Permissions.SEND_MESSAGES},
{
title: i18n._(msg`Manage Messages`),
description: i18n._(msg`Delete others' messages. (Pinning is separate below.)`),
flag: Permissions.MANAGE_MESSAGES,
},
{title: i18n._(msg`Pin Messages`), flag: Permissions.PIN_MESSAGES},
{title: i18n._(msg`Embed Links`), flag: Permissions.EMBED_LINKS},
{title: i18n._(msg`Attach Files`), flag: Permissions.ATTACH_FILES},
{title: i18n._(msg`Read Message History`), flag: Permissions.READ_MESSAGE_HISTORY},
{
title: i18n._(msg`Use @everyone/@here and @roles`),
description: i18n._(msg`Mention everyone or any role (even if the role isn't set to be mentionable).`),
flag: Permissions.MENTION_EVERYONE,
},
{title: i18n._(msg`Use External Emoji`), flag: Permissions.USE_EXTERNAL_EMOJIS},
{title: i18n._(msg`Use External Stickers`), flag: Permissions.USE_EXTERNAL_STICKERS},
{
title: i18n._(msg`Add Reactions`),
description: i18n._(msg`Add new reactions to messages.`),
flag: Permissions.ADD_REACTIONS,
},
{
title: i18n._(msg`Bypass Slowmode`),
description: i18n._(msg`Ignore per-channel message rate limits.`),
flag: Permissions.BYPASS_SLOWMODE,
},
],
};
}
export function generateChannelVoicePermissionSpec(i18n: I18n): PermissionSpec {
return {
title: i18n._(msg`Audio & Video`),
permissions: [
{title: i18n._(msg`Connect (Join Voice)`), flag: Permissions.CONNECT},
{title: i18n._(msg`Speak`), flag: Permissions.SPEAK},
{title: i18n._(msg`Stream Video`), flag: Permissions.STREAM},
{
title: i18n._(msg`Use Voice Activity`),
description: i18n._(msg`Otherwise Push-to-talk is required.`),
flag: Permissions.USE_VAD,
},
{title: i18n._(msg`Priority Speaker`), flag: Permissions.PRIORITY_SPEAKER},
{title: i18n._(msg`Mute Members`), flag: Permissions.MUTE_MEMBERS},
{title: i18n._(msg`Deafen Members`), flag: Permissions.DEAFEN_MEMBERS},
{
title: i18n._(msg`Move Members`),
description: i18n._(msg`Drag members between channels they can access.`),
flag: Permissions.MOVE_MEMBERS,
},
{title: i18n._(msg`Set Voice Region`), flag: Permissions.UPDATE_RTC_REGION},
],
};
}
export function generatePermissionSpec(i18n: I18n): Array<PermissionSpec> {
return [
generateGuildGeneralPermissionSpec(i18n),
generateGuildAccessPermissionSpec(i18n),
generateGuildTextPermissionSpec(i18n),
generateGuildModerationPermissionSpec(i18n),
generateGuildVoicePermissionSpec(i18n),
];
}
export interface BotPermissionOption {
id: keyof typeof Permissions;
label: string;
}
export function getAllBotPermissions(i18n: I18n): Array<BotPermissionOption> {
return generatePermissionSpec(i18n).flatMap((spec) =>
spec.permissions.map((perm) => ({
id: Object.keys(Permissions).find(
(key) => Permissions[key as keyof typeof Permissions] === perm.flag,
) as keyof typeof Permissions,
label: perm.title,
})),
);
}
const permissionLabelCache = new Map<bigint, string>();
const populatePermissionLabels = (i18n: I18n): void => {
if (permissionLabelCache.size > 0) return;
for (const {permissions} of generatePermissionSpec(i18n)) {
for (const perm of permissions) {
permissionLabelCache.set(perm.flag, perm.title);
}
}
};
export function getPermissionLabel(i18n: I18n, permission: bigint): string | null {
populatePermissionLabels(i18n);
return permissionLabelCache.get(permission) ?? null;
}
export function formatPermissionLabel(i18n: I18n, permission: bigint, preferChannelSingular = false): string | null {
if (permission === Permissions.MANAGE_CHANNELS) {
return preferChannelSingular ? i18n._(msg`Manage Channel`) : i18n._(msg`Manage Channels`);
}
return getPermissionLabel(i18n, permission);
}
export function formatBotPermissionsQuery(permissions: Array<string>): string {
const total = permissions.reduce((acc, perm) => {
const key = perm as keyof typeof Permissions;
const value = Permissions[key];
return acc | (value ?? 0n);
}, 0n);
return total.toString();
}

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/>.
*/
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) {
return text;
}
if (maxLength <= 3) {
return text.slice(0, maxLength);
}
return `${text.slice(0, maxLength - 3)}...`;
}
export function getChannelPlaceholder(channelName: string, prefix: string, maxLength: number): string {
const availableLength = maxLength - prefix.length;
if (availableLength <= 0) {
return prefix;
}
const truncatedName = truncateText(channelName, availableLength);
return prefix + truncatedName;
}
export function getDMPlaceholder(username: string, prefix: string, maxLength: number): string {
const availableLength = maxLength - prefix.length;
if (availableLength <= 0) {
return prefix;
}
const truncatedName = truncateText(username, availableLength);
return prefix + truncatedName;
}

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 RuntimeConfigStore from '~/stores/RuntimeConfigStore';
export function shouldShowPremiumFeatures(): boolean {
return !RuntimeConfigStore.isSelfHosted();
}

View File

@@ -0,0 +1,105 @@
/*
* 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/>.
*/
export enum PricingTier {
Monthly = 'monthly',
Yearly = 'yearly',
Visionary = 'visionary',
}
enum Currency {
USD = 'USD',
EUR = 'EUR',
}
const EEA_COUNTRIES = [
'AT',
'BE',
'BG',
'HR',
'CY',
'CZ',
'DK',
'EE',
'FI',
'FR',
'DE',
'GR',
'HU',
'IE',
'IT',
'LV',
'LT',
'LU',
'MT',
'NL',
'PL',
'PT',
'RO',
'SK',
'SI',
'ES',
'SE',
'IS',
'LI',
'NO',
];
function getCurrency(countryCode: string | null): Currency {
if (!countryCode) return Currency.USD;
return isEEACountry(countryCode) ? Currency.EUR : Currency.USD;
}
function isEEACountry(countryCode: string): boolean {
return EEA_COUNTRIES.includes(countryCode.toUpperCase());
}
function getPrice(tier: PricingTier, currency: Currency): number {
const prices: Record<PricingTier, Record<Currency, number>> = {
[PricingTier.Monthly]: {
[Currency.USD]: 4.99,
[Currency.EUR]: 4.99,
},
[PricingTier.Yearly]: {
[Currency.USD]: 49.99,
[Currency.EUR]: 49.99,
},
[PricingTier.Visionary]: {
[Currency.USD]: 299,
[Currency.EUR]: 299,
},
};
return prices[tier][currency];
}
function formatPrice(price: number, currency: Currency): string {
const currencySymbols: Record<Currency, string> = {
[Currency.USD]: '$',
[Currency.EUR]: '€',
};
return `${currencySymbols[currency]}${price.toFixed(2).replace(/\.00$/, '')}`;
}
export function getFormattedPrice(tier: PricingTier, countryCode: string | null): string {
const currency = getCurrency(countryCode);
const price = getPrice(tier, currency);
return formatPrice(price, currency);
}

View File

@@ -0,0 +1,139 @@
/*
* 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 {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {ProfileRecord} from '~/records/ProfileRecord';
import type {UserProfile, UserRecord} from '~/records/UserRecord';
import * as AvatarUtils from '~/utils/AvatarUtils';
export interface ProfileDisplayContext {
user: UserRecord;
profile?: ProfileRecord | null;
guildId?: string | null;
guildMember?: GuildMemberRecord | null;
guildMemberProfile?: UserProfile | null;
}
export interface ProfilePreviewOverrides {
previewAvatarUrl?: string | null;
previewBannerUrl?: string | null;
hasClearedAvatar?: boolean;
hasClearedBanner?: boolean;
ignoreGuildAvatar?: boolean;
ignoreGuildBanner?: boolean;
}
function getProfileAvatarUrl(
context: ProfileDisplayContext,
overrides?: ProfilePreviewOverrides,
animated = false,
): string | null {
const {user, guildId, guildMember} = context;
const {previewAvatarUrl, hasClearedAvatar, ignoreGuildAvatar} = overrides || {};
if (hasClearedAvatar) {
return null;
}
if (previewAvatarUrl) {
return previewAvatarUrl;
}
if (!ignoreGuildAvatar && guildId && guildMember) {
if (guildMember.isAvatarUnset()) {
return AvatarUtils.getUserAvatarURL({id: user.id, avatar: null}, animated);
}
if (guildMember.avatar) {
return AvatarUtils.getGuildMemberAvatarURL({
guildId,
userId: user.id,
avatar: guildMember.avatar,
animated,
});
}
}
return AvatarUtils.getUserAvatarURL(user, animated);
}
export function getProfileBannerUrl(
context: ProfileDisplayContext,
overrides?: ProfilePreviewOverrides,
animated = false,
size = 1024,
): string | null {
const {user, profile, guildId, guildMember, guildMemberProfile} = context;
const {previewBannerUrl, hasClearedBanner, ignoreGuildBanner} = overrides || {};
if (hasClearedBanner) {
return null;
}
if (previewBannerUrl) {
return previewBannerUrl;
}
let effectiveBanner: string | null = null;
if (!ignoreGuildBanner && guildId && guildMember) {
if (guildMember.isBannerUnset()) {
return null;
}
if (guildMemberProfile?.banner) {
if (guildMemberProfile.banner.startsWith('blob:') || guildMemberProfile.banner.startsWith('data:')) {
return guildMemberProfile.banner;
}
return AvatarUtils.getGuildMemberBannerURL({
guildId,
userId: user.id,
banner: guildMemberProfile.banner,
animated,
size,
});
}
}
if (profile?.userProfile?.banner) {
effectiveBanner = profile.userProfile.banner;
} else if (user.banner) {
effectiveBanner = user.banner;
}
if (effectiveBanner) {
if (effectiveBanner.startsWith('blob:') || effectiveBanner.startsWith('data:')) {
return effectiveBanner;
}
return AvatarUtils.getUserBannerURL({id: user.id, banner: effectiveBanner}, animated, size);
}
return null;
}
export function getProfileAvatarUrls(
context: ProfileDisplayContext,
overrides?: ProfilePreviewOverrides,
): {
avatarUrl: string | null;
hoverAvatarUrl: string | null;
} {
return {
avatarUrl: getProfileAvatarUrl(context, overrides, false),
hoverAvatarUrl: getProfileAvatarUrl(context, overrides, true),
};
}

View File

@@ -0,0 +1,124 @@
/*
* 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 {UserPremiumTypes} from '~/Constants';
import {ProfileRecord} from '~/records/ProfileRecord';
import type {UserRecord} from '~/records/UserRecord';
export interface BadgeSettings {
premium_badge_hidden?: boolean;
premium_badge_timestamp_hidden?: boolean;
premium_badge_masked?: boolean;
premium_badge_sequence_hidden?: boolean;
}
function computeVisiblePremiumData(user: UserRecord, previewBadgeSettings?: BadgeSettings) {
const premiumType = user.premiumType;
const premiumSince = user.premiumSince;
const premiumLifetimeSequence = user.premiumLifetimeSequence;
if (!premiumType || premiumType === UserPremiumTypes.NONE) {
return {
premiumType: null,
premiumSince: null,
premiumLifetimeSequence: null,
};
}
const premiumBadgeHidden = previewBadgeSettings?.premium_badge_hidden ?? user.premiumBadgeHidden;
const premiumBadgeTimestampHidden =
previewBadgeSettings?.premium_badge_timestamp_hidden ?? user.premiumBadgeTimestampHidden;
const premiumBadgeMasked = previewBadgeSettings?.premium_badge_masked ?? user.premiumBadgeMasked;
const premiumBadgeSequenceHidden =
previewBadgeSettings?.premium_badge_sequence_hidden ?? user.premiumBadgeSequenceHidden;
if (premiumBadgeHidden) {
return {
premiumType: null,
premiumSince: null,
premiumLifetimeSequence: null,
};
}
let visiblePremiumType = premiumType;
let visiblePremiumSince = premiumSince;
let visiblePremiumLifetimeSequence = premiumLifetimeSequence;
if (premiumType === UserPremiumTypes.LIFETIME) {
if (premiumBadgeMasked) {
visiblePremiumType = UserPremiumTypes.SUBSCRIPTION;
}
if (premiumBadgeSequenceHidden) {
visiblePremiumLifetimeSequence = null;
}
}
if (premiumBadgeTimestampHidden) {
visiblePremiumSince = null;
}
let premiumSinceString: string | null = null;
if (visiblePremiumSince) {
if (typeof visiblePremiumSince === 'string') {
premiumSinceString = visiblePremiumSince;
} else if (visiblePremiumSince instanceof Date) {
premiumSinceString = visiblePremiumSince.toISOString();
}
}
return {
premiumType: visiblePremiumType,
premiumSince: premiumSinceString,
premiumLifetimeSequence: visiblePremiumLifetimeSequence,
};
}
export function createMockProfile(
user: UserRecord,
options?: {
previewBannerUrl?: string | null;
hasClearedBanner?: boolean;
previewBio?: string | null;
previewPronouns?: string | null;
previewAccentColor?: string | null;
previewBadgeSettings?: BadgeSettings;
},
): ProfileRecord {
const finalBanner = options?.hasClearedBanner
? null
: options?.previewBannerUrl
? options.previewBannerUrl
: user.banner || null;
const finalBio = options?.previewBio !== undefined ? options.previewBio : user.bio || null;
const finalPronouns = options?.previewPronouns !== undefined ? options.previewPronouns : user.pronouns || null;
const visiblePremiumData = computeVisiblePremiumData(user, options?.previewBadgeSettings);
return new ProfileRecord({
user: user.toJSON(),
user_profile: {
bio: finalBio,
banner: finalBanner,
pronouns: finalPronouns,
accent_color: options?.previewAccentColor !== undefined ? options.previewAccentColor : user.accentColor || null,
},
timezone_offset: null,
premium_type: visiblePremiumData.premiumType ?? undefined,
premium_since: visiblePremiumData.premiumSince ?? undefined,
premium_lifetime_sequence: visiblePremiumData.premiumLifetimeSequence ?? undefined,
});
}

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 {isElectron} from '~/utils/NativeUtils';
export function isStandalonePwa(): boolean {
const matchDisplayMode = window.matchMedia?.('(display-mode: standalone)').matches ?? false;
const navigatorStandalone = (window.navigator as unknown as {standalone?: boolean})?.standalone === true;
const androidReferrer = document.referrer.includes('android-app://');
return matchDisplayMode || navigatorStandalone || androidReferrer;
}
export function isMobileOrTablet(): boolean {
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
}
export function isPwaOnMobileOrTablet(): boolean {
return isStandalonePwa() && isMobileOrTablet();
}
export function isInstalledPwa(): boolean {
return isStandalonePwa() && !isElectron();
}

View File

@@ -0,0 +1,139 @@
/*
* 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 {msg} from '@lingui/core/macro';
import i18n from '~/i18n';
import type {UnicodeEmoji} from '~/lib/UnicodeEmojis';
import UnicodeEmojis from '~/lib/UnicodeEmojis';
import type {MessageRecord} from '~/records/MessageRecord';
import ChannelStore from '~/stores/ChannelStore';
import MessageReactionsStore from '~/stores/MessageReactionsStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import * as EmojiUtils from '~/utils/EmojiUtils';
import {shouldUseNativeEmoji} from '~/utils/EmojiUtils';
import * as NicknameUtils from '~/utils/NicknameUtils';
export type {UnicodeEmoji} from '~/lib/UnicodeEmojis';
export interface ReactionEmoji {
id?: string;
name: string;
animated?: boolean;
}
export const getReactionTooltip = (message: MessageRecord, emoji: ReactionEmoji) => {
const channel = ChannelStore.getChannel(message.channelId)!;
const guildId = channel.guildId;
const users = MessageReactionsStore.getReactions(message.id, emoji)
.slice(0, 3)
.map((user) => NicknameUtils.getNickname(user, guildId));
if (users.length === 0) {
return '';
}
const reaction = message.getReaction(emoji);
const othersCount = Math.max(0, (reaction?.count || 0) - users.length);
const emojiName = getEmojiNameWithColons(emoji);
if (users.length === 1) {
if (othersCount > 0) {
return othersCount === 1
? i18n._(msg`${emojiName} reacted by ${users[0]} and ${othersCount} other`)
: i18n._(msg`${emojiName} reacted by ${users[0]} and ${othersCount} others`);
}
return i18n._(msg`${emojiName} reacted by ${users[0]}`);
}
if (users.length === 2) {
if (othersCount > 0) {
return othersCount === 1
? i18n._(msg`${emojiName} reacted by ${users[0]}, ${users[1]} and ${othersCount} other`)
: i18n._(msg`${emojiName} reacted by ${users[0]}, ${users[1]} and ${othersCount} others`);
}
return i18n._(msg`${emojiName} reacted by ${users[0]} and ${users[1]}`);
}
if (users.length === 3) {
if (othersCount > 0) {
return othersCount === 1
? i18n._(msg`${emojiName} reacted by ${users[0]}, ${users[1]}, ${users[2]} and ${othersCount} other`)
: i18n._(msg`${emojiName} reacted by ${users[0]}, ${users[1]}, ${users[2]} and ${othersCount} others`);
}
return i18n._(msg`${emojiName} reacted by ${users[0]}, ${users[1]} and ${users[2]}`);
}
return othersCount === 1
? i18n._(msg`${emojiName} reacted by ${othersCount} other`)
: i18n._(msg`${emojiName} reacted by ${othersCount} others`);
};
const isCustomEmoji = (emoji: UnicodeEmoji | ReactionEmoji): emoji is ReactionEmoji =>
'id' in emoji && emoji.id != null;
export const toReactionEmoji = (emoji: UnicodeEmoji | ReactionEmoji) =>
isCustomEmoji(emoji) ? emoji : {name: emoji.surrogates};
export const emojiEquals = (reactionEmoji: ReactionEmoji, emoji: UnicodeEmoji | ReactionEmoji) =>
isCustomEmoji(emoji) ? emoji.id === reactionEmoji.id : reactionEmoji.id == null && emoji.name === reactionEmoji.name;
export const getReactionKey = (messageId: string, emoji: ReactionEmoji) =>
`${messageId}:${emoji.name}:${emoji.id || ''}`;
export const getEmojiName = (emoji: ReactionEmoji): string =>
emoji.id == null ? UnicodeEmojis.getSurrogateName(emoji.name) || emoji.name : `:${emoji.name}:`;
export const getEmojiNameWithColons = (emoji: ReactionEmoji): string => {
const name = emoji.id == null ? UnicodeEmojis.getSurrogateName(emoji.name) || emoji.name : emoji.name;
return `:${name}:`;
};
export const useEmojiURL = ({
emoji,
isHovering = false,
size = 128,
forceAnimate = false,
}: {
emoji: ReactionEmoji;
isHovering?: boolean;
size?: number;
forceAnimate?: boolean;
}): string | null => {
const {animateEmoji} = UserSettingsStore;
let shouldAnimate = false;
if (forceAnimate) {
shouldAnimate = emoji.animated ?? false;
} else if (animateEmoji) {
shouldAnimate = emoji.animated ?? false;
} else if (emoji.animated) {
shouldAnimate = isHovering;
}
if (emoji.id == null) {
if (shouldUseNativeEmoji) {
return null;
}
return EmojiUtils.getEmojiURL(emoji.name);
}
const url = AvatarUtils.getEmojiURL({id: emoji.id, animated: shouldAnimate});
return `${url}?size=${size}&quality=lossless`;
};

View File

@@ -0,0 +1,20 @@
/*
* 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/>.
*/
export const escapeRegex = (str: string) => str.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');

View File

@@ -0,0 +1,187 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {APIErrorCodes, RelationshipTypes} from '~/Constants';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import type {UserRecord} from '~/records/UserRecord';
import RelationshipStore from '~/stores/RelationshipStore';
import {getApiErrorCode} from '~/utils/ApiErrorUtils';
export function getSendFriendRequestErrorMessage(i18n: I18n, code: string | undefined): string {
switch (code) {
case APIErrorCodes.FRIEND_REQUEST_BLOCKED:
return i18n._(msg`This user isn't accepting friend requests right now.`);
case APIErrorCodes.CANNOT_SEND_FRIEND_REQUEST_TO_BLOCKED_USER:
return i18n._(msg`Unblock this user before sending a friend request.`);
case APIErrorCodes.BOTS_CANNOT_HAVE_FRIENDS:
return i18n._(msg`You can't send friend requests to bots.`);
case APIErrorCodes.CANNOT_SEND_FRIEND_REQUEST_TO_SELF:
return i18n._(msg`You can't send a friend request to yourself.`);
case APIErrorCodes.ALREADY_FRIENDS:
return i18n._(msg`You're already friends with this user.`);
case APIErrorCodes.UNCLAIMED_ACCOUNT_RESTRICTED:
return i18n._(msg`You need to claim your account to send friend requests.`);
default:
return i18n._(msg`Failed to send friend request. Please try again.`);
}
}
export function canSendFriendRequest(userId: string, isBot: boolean): boolean {
if (isBot) {
return false;
}
const relationship = RelationshipStore.getRelationship(userId);
if (!relationship) {
return true;
}
const blockedTypes = [
RelationshipTypes.FRIEND,
RelationshipTypes.BLOCKED,
RelationshipTypes.OUTGOING_REQUEST,
RelationshipTypes.INCOMING_REQUEST,
] as const;
return !blockedTypes.some((type) => type === relationship.type);
}
export async function sendFriendRequest(i18n: I18n, userId: string): Promise<boolean> {
try {
await RelationshipActionCreators.sendFriendRequest(userId);
ToastActionCreators.success(i18n._(msg`Friend request sent`));
return true;
} catch (err) {
ToastActionCreators.error(getSendFriendRequestErrorMessage(i18n, getApiErrorCode(err)));
return false;
}
}
export async function acceptFriendRequest(i18n: I18n, userId: string): Promise<boolean> {
try {
await RelationshipActionCreators.acceptFriendRequest(userId);
return true;
} catch (_error) {
ToastActionCreators.error(i18n._(msg`Failed to accept friend request. Please try again.`));
return false;
}
}
export async function cancelFriendRequest(i18n: I18n, userId: string): Promise<boolean> {
try {
await RelationshipActionCreators.removeRelationship(userId);
return true;
} catch (_error) {
ToastActionCreators.error(i18n._(msg`Failed to cancel friend request. Please try again.`));
return false;
}
}
export async function removeFriend(i18n: I18n, userId: string): Promise<boolean> {
try {
await RelationshipActionCreators.removeRelationship(userId);
return true;
} catch (_error) {
ToastActionCreators.error(i18n._(msg`Failed to remove friend. Please try again.`));
return false;
}
}
export function showRemoveFriendConfirmation(i18n: I18n, user: UserRecord): void {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={i18n._(msg`Remove Friend`)}
description={i18n._(msg`Are you sure you want to remove ${user.username} as a friend?`)}
primaryText={i18n._(msg`Remove Friend`)}
primaryVariant="danger-primary"
onPrimary={async () => {
await removeFriend(i18n, user.id);
}}
/>
)),
);
}
export async function blockUser(i18n: I18n, userId: string): Promise<boolean> {
try {
await RelationshipActionCreators.blockUser(userId);
return true;
} catch (_error) {
ToastActionCreators.error(i18n._(msg`Failed to block user. Please try again.`));
return false;
}
}
export function showBlockUserConfirmation(i18n: I18n, user: UserRecord): void {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={i18n._(msg`Block User`)}
description={i18n._(
msg`Are you sure you want to block ${user.username}? They won't be able to message you or send you friend requests.`,
)}
primaryText={i18n._(msg`Block`)}
primaryVariant="danger-primary"
onPrimary={async () => {
await blockUser(i18n, user.id);
}}
/>
)),
);
}
export async function unblockUser(i18n: I18n, userId: string): Promise<boolean> {
try {
await RelationshipActionCreators.removeRelationship(userId);
return true;
} catch (_error) {
ToastActionCreators.error(i18n._(msg`Failed to unblock user. Please try again.`));
return false;
}
}
export function showUnblockUserConfirmation(i18n: I18n, user: UserRecord): void {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={i18n._(msg`Unblock User`)}
description={i18n._(msg`Are you sure you want to unblock ${user.username}?`)}
primaryText={i18n._(msg`Unblock`)}
primaryVariant="primary"
onPrimary={async () => {
await unblockUser(i18n, user.id);
}}
/>
)),
);
}
export async function ignoreFriendRequest(i18n: I18n, userId: string): Promise<boolean> {
try {
await RelationshipActionCreators.removeRelationship(userId);
return true;
} catch (_error) {
ToastActionCreators.error(i18n._(msg`Failed to ignore friend request. Please try again.`));
return false;
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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/>.
*/
const REPLACE_REGEX = /^s\/([^/]+)\/([^/]+)(\/g)?$/;
interface ReplaceCommand {
source: string;
replacement: string;
global: boolean;
}
export function parseReplaceCommand(content: string): ReplaceCommand | null {
const match = content.match(REPLACE_REGEX);
if (!match) {
return null;
}
const [, source, replacement, globalFlag] = match;
return {
source,
replacement,
global: !!globalFlag,
};
}
export function executeReplaceCommand(text: string, command: ReplaceCommand): string {
const regex = new RegExp(command.source, command.global ? 'g' : '');
return text.replace(regex, command.replacement);
}
export function isReplaceCommand(content: string): boolean {
return REPLACE_REGEX.test(content);
}

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 {Logger} from '~/lib/Logger';
import {createBrowserHistory, type HistoryAdapter} from '~/lib/router';
const logger = new Logger('RouterUtils');
export const history: HistoryAdapter | null = createBrowserHistory();
export const transitionTo = (path: string) => {
logger.info('transitionTo', path);
if (history) {
const current = history.getLocation().url.pathname;
if (current === path) return;
history.push(new URL(path, window.location.origin));
}
};
export const replaceWith = (path: string) => {
logger.info('replaceWith', path);
if (history) {
const current = history.getLocation().url.pathname;
if (current === path) return;
history.replace(new URL(path, window.location.origin));
}
};
export const getHistory = () => history;

View File

@@ -0,0 +1,62 @@
/*
* 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 {ScreenRecordingPermissionDeniedModal} from '~/components/alerts/ScreenRecordingPermissionDeniedModal';
import {ScreenShareUnsupportedModal} from '~/components/alerts/ScreenShareUnsupportedModal';
import {ScreenRecordingPermissionDeniedError} from '~/utils/errors/ScreenRecordingPermissionDeniedError';
const isScreenShareUnsupportedError = (error: unknown): boolean => {
if (!(error instanceof Error)) return false;
return (
error.name === 'DeviceUnsupportedError' ||
error.message.includes('getDisplayMedia not supported') ||
error.message.includes('NotSupportedError') ||
error.message.includes('NotAllowedError')
);
};
const handleScreenShareError = (error: unknown): void => {
if (error instanceof ScreenRecordingPermissionDeniedError) {
ModalActionCreators.push(modal(() => <ScreenRecordingPermissionDeniedModal />));
return;
}
if (isScreenShareUnsupportedError(error)) {
ModalActionCreators.push(modal(() => <ScreenShareUnsupportedModal />));
} else {
console.error('Failed to start screen share:', error);
}
};
export const executeScreenShareOperation = async (
operation: () => Promise<void>,
onError?: (error: unknown) => void,
): Promise<void> => {
try {
await operation();
} catch (error) {
handleScreenShareError(error);
if (onError) {
onError(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/>.
*/
const SCROLLER_DRAG_ATTR = 'data-scroller-dragging';
let activeDrags = 0;
let pendingClear: number | null = null;
const getRoot = () => document.documentElement;
const updateAttribute = (isDragging: boolean) => {
const root = getRoot();
if (isDragging) {
root.setAttribute(SCROLLER_DRAG_ATTR, 'true');
} else {
root.removeAttribute(SCROLLER_DRAG_ATTR);
}
};
const clearPendingTimeout = () => {
if (pendingClear !== null) {
window.clearTimeout(pendingClear);
pendingClear = null;
}
};
const decrementDragCount = () => {
activeDrags = Math.max(0, activeDrags - 1);
if (activeDrags === 0) {
updateAttribute(false);
}
};
export const beginScrollbarDrag = () => {
clearPendingTimeout();
activeDrags += 1;
updateAttribute(true);
};
export const endScrollbarDrag = () => {
clearPendingTimeout();
decrementDragCount();
};
export const endScrollbarDragDeferred = () => {
clearPendingTimeout();
pendingClear = window.setTimeout(() => {
pendingClear = null;
decrementDragCount();
}, 0);
};
export const isScrollbarDragActive = () => activeDrags > 0;

View File

@@ -0,0 +1,562 @@
/*
* 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 {DateTime} from 'luxon';
import ChannelStore from '~/stores/ChannelStore';
import UserStore from '~/stores/UserStore';
import type {MessageSearchParams} from '~/utils/SearchUtils';
import {fromTimestamp} from '~/utils/SnowflakeUtils';
export interface SearchHints {
usersByTag?: Record<string, string>;
channelsByName?: Record<string, string>;
}
export interface ParsedToken {
key: string;
value: string;
start: number;
end: number;
raw: string;
quoted: boolean;
exclude: boolean;
}
const KNOWN_KEYS = new Set([
'from',
'-from',
'mentions',
'-mentions',
'in',
'-in',
'before',
'during',
'on',
'after',
'has',
'-has',
'pinned',
'author-type',
'sort',
'order',
'nsfw',
'embed-type',
'-embed-type',
'embed-provider',
'-embed-provider',
'link',
'-link',
'filename',
'-filename',
'ext',
'-ext',
'last',
'beforeid',
'afterid',
'any',
'scope',
]);
const USER_TAG_RE = /^([A-Za-z0-9_]+)#(\d{4})$/;
const normalizeSpaces = (s: string) => s.replace(/\s+/g, ' ').trim();
type HasFilter = NonNullable<MessageSearchParams['has']>[number];
const HAS_FILTERS: ReadonlySet<HasFilter> = new Set([
'image',
'sound',
'video',
'file',
'sticker',
'embed',
'link',
'poll',
'snapshot',
]);
const isHasFilter = (value: string): value is HasFilter => HAS_FILTERS.has(value as HasFilter);
type AuthorTypeFilter = NonNullable<MessageSearchParams['authorType']>[number];
const AUTHOR_FILTERS: ReadonlySet<AuthorTypeFilter> = new Set(['user', 'bot', 'webhook']);
const isAuthorFilter = (value: string): value is AuthorTypeFilter => AUTHOR_FILTERS.has(value as AuthorTypeFilter);
type EmbedTypeFilter = NonNullable<MessageSearchParams['embedType']>[number];
const EMBED_TYPE_FILTERS: ReadonlySet<EmbedTypeFilter> = new Set(['image', 'video', 'sound', 'article']);
const isEmbedTypeFilter = (value: string): value is EmbedTypeFilter => EMBED_TYPE_FILTERS.has(value as EmbedTypeFilter);
type SortField = NonNullable<MessageSearchParams['sortBy']>;
const SORT_FIELDS: ReadonlySet<SortField> = new Set(['timestamp', 'relevance']);
const isSortField = (value: string): value is SortField => SORT_FIELDS.has(value as SortField);
type SortDirection = NonNullable<MessageSearchParams['sortOrder']>;
const SORT_ORDERS: ReadonlySet<SortDirection> = new Set(['asc', 'desc']);
const isSortDirection = (value: string): value is SortDirection => SORT_ORDERS.has(value as SortDirection);
type SearchScope = NonNullable<MessageSearchParams['scope']>;
const SEARCH_SCOPES: ReadonlySet<SearchScope> = new Set([
'current',
'open_dms',
'all_dms',
'all_guilds',
'all',
'open_dms_and_all_guilds',
]);
const isSearchScope = (value: string): value is SearchScope => SEARCH_SCOPES.has(value as SearchScope);
export const tokenize = (query: string): {tokens: Array<ParsedToken>; content: string} => {
const tokens: Array<ParsedToken> = [];
const n = query.length;
let i = 0;
while (i < n) {
if (query[i] === ' ') {
i++;
continue;
}
const isExclude = query[i] === '-';
const keyStart = isExclude ? i + 1 : i;
let j = keyStart;
while (j < n && query[j] !== ':' && query[j] !== ' ') j++;
if (j >= n || query[j] !== ':') {
i++;
continue;
}
const key = query.slice(keyStart, j);
if (!KNOWN_KEYS.has(key)) {
i++;
continue;
}
j++;
if (j >= n) break;
let end = j;
let inQuotes = false;
let escaped = false;
while (end < n) {
const ch = query[end];
if (escaped) {
escaped = false;
end++;
continue;
}
if (ch === '\\') {
escaped = true;
end++;
continue;
}
if (ch === '"') {
inQuotes = !inQuotes;
end++;
continue;
}
if (!inQuotes && ch === ' ') break;
end++;
}
const value = query.slice(j, end);
const raw = query.slice(i, end);
const quoted = value.startsWith('"');
tokens.push({key, value, start: i, end, raw, quoted, exclude: isExclude});
i = end;
}
let content = '';
let pos = 0;
for (const tok of tokens) {
if (tok.start > pos) content += query.slice(pos, tok.start);
pos = tok.end;
}
if (pos < n) content += query.slice(pos);
return {tokens, content: normalizeSpaces(content)};
};
const splitCSV = (input: string): Array<string> => {
const items: Array<string> = [];
let i = 0;
const n = input.length;
while (i < n) {
while (i < n && /[\s,]/.test(input[i])) i++;
if (i >= n) break;
let quoted = false;
let buf = '';
if (input[i] === '"') {
quoted = true;
i++;
}
let escaped = false;
for (; i < n; i++) {
const ch = input[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (quoted) {
if (ch === '"') {
i++;
break;
}
buf += ch;
} else {
if (ch === ',') break;
buf += ch;
}
}
items.push(buf.trim());
while (i < n && input[i] !== ',') i++;
if (i < n && input[i] === ',') i++;
}
return items.filter(Boolean);
};
const normalizeToken = (value: string): string => value.trim().toLowerCase();
const CURRENT_USER_TOKENS = new Set(['@me']);
const isCurrentUserToken = (value: string): boolean => CURRENT_USER_TOKENS.has(normalizeToken(value));
const getCurrentUserId = (): string | null => UserStore.getCurrentUser()?.id ?? null;
const tryResolveUser = (tag: string, hints?: SearchHints): string | null => {
const trimmedTag = tag.trim();
if (!trimmedTag) {
return null;
}
if (isCurrentUserToken(trimmedTag)) {
return getCurrentUserId();
}
if (hints?.usersByTag?.[trimmedTag]) return hints.usersByTag[trimmedTag];
if (!USER_TAG_RE.test(trimmedTag)) return null;
const user = UserStore.getUserByTag(trimmedTag);
return user?.id ?? null;
};
const tryResolveChannel = (name: string, guildId?: string | null, hints?: SearchHints): string | null => {
if (hints?.channelsByName?.[name]) return hints.channelsByName[name];
if (!guildId) return null;
const channels = ChannelStore.getGuildChannels(guildId);
const matches = channels.filter((c) => (c.name || '').toLowerCase() === name.toLowerCase());
if (matches.length > 0) return matches[0].id;
const partial = channels.find((c) => (c.name || '').toLowerCase().includes(name.toLowerCase()));
return partial?.id ?? null;
};
export interface ParseContext {
guildId?: string | null;
}
const toSnowflakeAt = (dt: DateTime) => fromTimestamp(dt.toMillis()).toString();
export const parseCompactDateTime = (input: string, now: DateTime = DateTime.local()): DateTime | null => {
const trimmed = input.trim();
if (trimmed.length === 0) return null;
if (trimmed.toLowerCase() === 'now') return now;
if (trimmed.toLowerCase() === 'today') return now.startOf('day');
if (trimmed.toLowerCase() === 'yesterday') return now.minus({days: 1}).startOf('day');
const maybeIso = trimmed.replace('_', 'T').replace(' ', 'T');
let dt = DateTime.fromISO(maybeIso, {setZone: true});
if (dt.isValid) return dt;
const m = trimmed.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?)?Z?$/);
if (m) {
const [, y, mo, d, hh, mm, ss] = m;
dt = DateTime.fromObject(
{
year: Number(y),
month: Number(mo),
day: Number(d),
hour: hh ? Number(hh) : 0,
minute: mm ? Number(mm) : 0,
second: ss ? Number(ss) : 0,
},
{zone: now.zone},
);
return dt.isValid ? dt : null;
}
const js = new Date(trimmed);
if (!Number.isNaN(js.getTime())) return DateTime.fromJSDate(js);
return null;
};
const parseDuration = (input: string): {millis: number} | null => {
const m = input.trim().match(/^(\d+)(ms|s|m|h|d|w)$/i);
if (!m) return null;
const n = Number(m[1]);
const unit = m[2].toLowerCase();
const map: Record<string, number> = {
ms: 1,
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000,
};
return {millis: n * map[unit]};
};
export const parseQuery = (query: string, hints?: SearchHints, ctx?: ParseContext): MessageSearchParams => {
const {tokens, content} = tokenize(query);
const params: MessageSearchParams = {};
if (content) params.content = content;
const add = <T>(arr: Array<T> | undefined, v: T): Array<T> => (arr ? [...arr, v] : [v]);
for (const tok of tokens) {
const key = tok.key === 'on' ? 'during' : tok.key;
const normalizedKey = key.startsWith('-') ? key.slice(1) : key;
const isExcludeKey = key.startsWith('-') || tok.exclude;
switch (normalizedKey) {
case 'from': {
const values = splitCSV(tok.value);
for (const tag of values) {
const id = tryResolveUser(tag, hints);
if (id) {
if (isExcludeKey) {
params.excludeAuthorId = add(params.excludeAuthorId, id);
} else {
params.authorId = add(params.authorId, id);
}
}
}
break;
}
case 'mentions': {
const values = splitCSV(tok.value);
for (const v of values) {
const lower = v.toLowerCase();
if (lower === 'everyone' || lower === 'here') {
params.mentionEveryone = true;
} else {
const id = tryResolveUser(v, hints);
if (id) {
if (isExcludeKey) {
params.excludeMentions = add(params.excludeMentions, id);
} else {
params.mentions = add(params.mentions, id);
}
}
}
}
break;
}
case 'in': {
const values = splitCSV(tok.value);
for (const v of values) {
const id = tryResolveChannel(v, ctx?.guildId ?? null, hints);
if (id) {
if (isExcludeKey) {
params.excludeChannelId = add(params.excludeChannelId, id);
} else {
params.channelId = add(params.channelId, id);
}
}
}
break;
}
case 'has': {
const raw = tok.value.trim();
if (!raw) break;
const values = raw
.split(',')
.map((v) => v.trim())
.filter(Boolean);
for (const value of values) {
const normalized = value.toLowerCase();
if (!isHasFilter(normalized)) continue;
if (isExcludeKey) {
params.excludeHas = add(params.excludeHas, normalized);
} else {
params.has = add(params.has, normalized);
}
}
break;
}
case 'pinned': {
const v = tok.value.trim().toLowerCase();
if (['true', 'false', 'yes', 'no', '1', '0'].includes(v)) {
params.pinned = v === 'true' || v === 'yes' || v === '1';
}
break;
}
case 'author-type': {
const raw = tok.value.trim();
if (!raw) break;
const values = raw
.split(',')
.map((v) => v.trim())
.filter(Boolean);
for (const value of values) {
const normalized = value.toLowerCase();
if (isAuthorFilter(normalized)) {
params.authorType = add(params.authorType, normalized);
}
}
break;
}
case 'sort': {
const v = tok.value.trim().toLowerCase();
if (isSortField(v)) params.sortBy = v;
break;
}
case 'order': {
const v = tok.value.trim().toLowerCase();
if (isSortDirection(v)) params.sortOrder = v;
break;
}
case 'nsfw': {
const v = tok.value.trim().toLowerCase();
if (v === 'true' || v === 'false') params.includeNsfw = v === 'true';
break;
}
case 'embed-type': {
const values = splitCSV(tok.value).map((v) => v.toLowerCase());
for (const v of values) {
if (isExcludeKey) {
if (isEmbedTypeFilter(v)) {
params.excludeEmbedType = add(params.excludeEmbedType, v);
}
} else if (isEmbedTypeFilter(v)) {
params.embedType = add(params.embedType, v);
}
}
break;
}
case 'embed-provider': {
const values = splitCSV(tok.value);
for (const v of values) {
if (isExcludeKey) {
params.excludeEmbedProvider = add(params.excludeEmbedProvider, v);
} else {
params.embedProvider = add(params.embedProvider, v);
}
}
break;
}
case 'link': {
const values = splitCSV(tok.value);
for (const v of values) {
if (isExcludeKey) {
params.excludeLinkHostname = add(params.excludeLinkHostname, v);
} else {
params.linkHostname = add(params.linkHostname, v);
}
}
break;
}
case 'filename': {
const values = splitCSV(tok.value);
for (const v of values) {
if (isExcludeKey) {
params.excludeAttachmentFilename = add(params.excludeAttachmentFilename, v);
} else {
params.attachmentFilename = add(params.attachmentFilename, v);
}
}
break;
}
case 'ext': {
const values = splitCSV(tok.value).map((v) => v.replace(/^\./, ''));
for (const v of values) {
if (isExcludeKey) {
params.excludeAttachmentExtension = add(params.excludeAttachmentExtension, v);
} else {
params.attachmentExtension = add(params.attachmentExtension, v);
}
}
break;
}
case 'scope': {
const values = splitCSV(tok.value);
for (const value of values) {
const normalized = value.toLowerCase();
if (isSearchScope(normalized)) {
params.scope = normalized;
break;
}
}
break;
}
case 'last': {
const dur = parseDuration(tok.value);
if (dur) {
const dt = DateTime.local().minus({milliseconds: dur.millis});
params.minId = toSnowflakeAt(dt);
}
break;
}
case 'beforeid': {
const id = tok.value.trim();
if (id) params.maxId = id;
break;
}
case 'afterid': {
const id = tok.value.trim();
if (id) params.minId = id;
break;
}
case 'any': {
const values = splitCSV(tok.value);
for (const v of values) params.contents = add(params.contents, v);
break;
}
case 'before':
case 'after':
case 'during': {
if (tok.value.includes('..')) {
const [a, b] = tok.value.split('..');
const start = parseCompactDateTime(a);
const end = parseCompactDateTime(b);
if (start) params.minId = toSnowflakeAt(start.startOf('day'));
if (end) params.maxId = toSnowflakeAt(end.endOf('day'));
break;
}
const dt = parseCompactDateTime(tok.value);
if (!dt) break;
if (key === 'before') {
params.maxId = toSnowflakeAt(dt);
} else if (key === 'after') {
params.minId = toSnowflakeAt(dt);
} else {
const start = dt.startOf('day');
const end = dt.endOf('day');
params.minId = toSnowflakeAt(start);
params.maxId = toSnowflakeAt(end);
}
break;
}
default:
break;
}
}
return params;
};

View File

@@ -0,0 +1,161 @@
/*
* 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/>.
*/
const KNOWN_FILTER_PREFIXES = new Set([
'from:',
'-from:',
'mentions:',
'-mentions:',
'in:',
'-in:',
'before:',
'during:',
'on:',
'after:',
'has:',
'-has:',
'pinned:',
'author-type:',
'sort:',
'order:',
'nsfw:',
'embed-type:',
'-embed-type:',
'embed-provider:',
'-embed-provider:',
'link:',
'-link:',
'filename:',
'-filename:',
'ext:',
'-ext:',
'last:',
'beforeid:',
'afterid:',
'any:',
'scope:',
]);
const isFilterPrefix = (text: string): boolean => {
const lower = text.toLowerCase();
for (const prefix of KNOWN_FILTER_PREFIXES) {
if (lower.startsWith(prefix)) {
return true;
}
}
return false;
};
const skipFilterValue = (query: string, startIndex: number): number => {
let i = startIndex;
const n = query.length;
let inQuotes = false;
let escaped = false;
while (i < n) {
const ch = query[i];
if (escaped) {
escaped = false;
i++;
continue;
}
if (ch === '\\') {
escaped = true;
i++;
continue;
}
if (ch === '"') {
inQuotes = !inQuotes;
i++;
continue;
}
if (!inQuotes && ch === ' ') {
break;
}
i++;
}
return i;
};
export const tokenizeSearchQuery = (query: string): Array<string> => {
const tokens: Array<string> = [];
const n = query.length;
let i = 0;
while (i < n) {
while (i < n && query[i] === ' ') {
i++;
}
if (i >= n) {
break;
}
const remaining = query.slice(i);
if (isFilterPrefix(remaining)) {
const colonIndex = remaining.indexOf(':');
if (colonIndex !== -1) {
i = skipFilterValue(query, i + colonIndex + 1);
continue;
}
}
if (query[i] === '"') {
i++;
let token = '';
let escaped = false;
while (i < n) {
const ch = query[i];
if (escaped) {
token += ch;
escaped = false;
i++;
continue;
}
if (ch === '\\') {
escaped = true;
i++;
continue;
}
if (ch === '"') {
i++;
break;
}
token += ch;
i++;
}
const trimmed = token.trim();
if (trimmed) {
tokens.push(trimmed);
}
continue;
}
let token = '';
while (i < n && query[i] !== ' ' && query[i] !== '"') {
token += query[i];
i++;
}
const trimmed = token.trim();
if (trimmed) {
tokens.push(trimmed);
}
}
return tokens;
};

View File

@@ -0,0 +1,169 @@
/*
* 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/>.
*/
type SearchSegmentType = 'user' | 'channel' | 'value';
export interface SearchSegment {
type: SearchSegmentType;
filterKey: string;
id: string;
displayText: string;
start: number;
end: number;
}
export class SearchSegmentManager {
private segments: Array<SearchSegment> = [];
getSegments(): Array<SearchSegment> {
return [...this.segments];
}
setSegments(segments: Array<SearchSegment>): void {
this.segments = [...segments];
}
clear(): void {
this.segments = [];
}
getSegmentAt(position: number): SearchSegment | null {
return this.segments.find((seg) => seg.start <= position && seg.end >= position) || null;
}
updateSegmentsForTextChange(changeStart: number, changeEnd: number, replacementLength: number): Array<SearchSegment> {
const lengthDelta = replacementLength - (changeEnd - changeStart);
const updated = this.segments
.map((segment) => {
if (segment.end <= changeStart) {
return segment;
} else if (segment.start >= changeEnd) {
return {...segment, start: segment.start + lengthDelta, end: segment.end + lengthDelta};
} else {
return null;
}
})
.filter((s): s is SearchSegment => s !== null);
this.segments = updated;
return updated;
}
insertSegment(
currentText: string,
insertPosition: number,
filterKey: string,
filterSyntax: string,
displayText: string,
id: string,
type: SearchSegmentType,
): {newText: string; newSegments: Array<SearchSegment>} {
const fullDisplayText = `${filterSyntax}${displayText}`;
const newText = `${currentText.slice(0, insertPosition) + fullDisplayText} ${currentText.slice(insertPosition)}`;
const newSegment: SearchSegment = {
type,
filterKey,
id,
displayText: fullDisplayText,
start: insertPosition,
end: insertPosition + fullDisplayText.length,
};
const updatedSegments = this.segments.map((segment) => {
if (segment.start >= insertPosition) {
const offset = fullDisplayText.length + 1;
return {
...segment,
start: segment.start + offset,
end: segment.end + offset,
};
}
return segment;
});
this.segments = [...updatedSegments, newSegment];
return {newText, newSegments: this.segments};
}
replaceWithSegment(
currentText: string,
replaceStart: number,
replaceEnd: number,
filterKey: string,
filterSyntax: string,
displayText: string,
id: string,
type: SearchSegmentType,
): {newText: string; newSegments: Array<SearchSegment>} {
const fullDisplayText = `${filterSyntax}${displayText}`;
const lengthDelta = fullDisplayText.length + 1 - (replaceEnd - replaceStart);
const newText = `${currentText.slice(0, replaceStart) + fullDisplayText} ${currentText.slice(replaceEnd)}`;
const newSegment: SearchSegment = {
type,
filterKey,
id,
displayText: fullDisplayText,
start: replaceStart,
end: replaceStart + fullDisplayText.length,
};
const updatedSegments = this.segments
.map((segment) => {
if (segment.end <= replaceStart) {
return segment;
} else if (segment.start >= replaceEnd) {
return {...segment, start: segment.start + lengthDelta, end: segment.end + lengthDelta};
} else {
return null;
}
})
.filter((s): s is SearchSegment => s !== null);
this.segments = [...updatedSegments, newSegment];
return {newText, newSegments: this.segments};
}
static detectChange(
oldText: string,
newText: string,
): {changeStart: number; changeEnd: number; replacementLength: number} {
let changeStart = 0;
while (
changeStart < oldText.length &&
changeStart < newText.length &&
oldText[changeStart] === newText[changeStart]
) {
changeStart++;
}
let oldEnd = oldText.length;
let newEnd = newText.length;
while (oldEnd > changeStart && newEnd > changeStart && oldText[oldEnd - 1] === newText[newEnd - 1]) {
oldEnd--;
newEnd--;
}
const replacementLength = newEnd - changeStart;
return {changeStart, changeEnd: oldEnd, replacementLength};
}
}

View File

@@ -0,0 +1,402 @@
/*
* 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 http from '~/lib/HttpClient';
import type {Message} from '~/records/MessageRecord';
import {MessageRecord} from '~/records/MessageRecord';
import SearchHistoryStore from '~/stores/SearchHistoryStore';
import {parseQuery} from '~/utils/SearchQueryParser';
import type {SearchSegment} from '~/utils/SearchSegmentManager';
export type MessageSearchScope = 'current' | 'open_dms' | 'all_dms' | 'all_guilds' | 'all' | 'open_dms_and_all_guilds';
export interface SearchValueOption {
value: string;
label: string;
description?: string;
isDefault?: boolean;
}
export interface MessageSearchParams {
hitsPerPage?: number;
page?: number;
maxId?: string;
minId?: string;
content?: string;
contents?: Array<string>;
channelId?: Array<string>;
excludeChannelId?: Array<string>;
authorType?: Array<'user' | 'bot' | 'webhook'>;
excludeAuthorType?: Array<'user' | 'bot' | 'webhook'>;
authorId?: Array<string>;
excludeAuthorId?: Array<string>;
mentions?: Array<string>;
excludeMentions?: Array<string>;
mentionEveryone?: boolean;
pinned?: boolean;
has?: Array<'image' | 'sound' | 'video' | 'file' | 'sticker' | 'embed' | 'link' | 'poll' | 'snapshot'>;
excludeHas?: Array<'image' | 'sound' | 'video' | 'file' | 'sticker' | 'embed' | 'link' | 'poll' | 'snapshot'>;
embedType?: Array<'image' | 'video' | 'sound' | 'article'>;
excludeEmbedType?: Array<'image' | 'video' | 'sound' | 'article'>;
embedProvider?: Array<string>;
excludeEmbedProvider?: Array<string>;
linkHostname?: Array<string>;
excludeLinkHostname?: Array<string>;
attachmentFilename?: Array<string>;
excludeAttachmentFilename?: Array<string>;
attachmentExtension?: Array<string>;
excludeAttachmentExtension?: Array<string>;
sortBy?: 'timestamp' | 'relevance';
sortOrder?: 'asc' | 'desc';
scope?: MessageSearchScope;
includeNsfw?: boolean;
}
interface ApiMessageSearchResponse {
messages: Array<Message>;
total: number;
hits_per_page?: number;
page?: number;
}
interface MessageSearchResponse {
messages: Array<MessageRecord>;
total: number;
hitsPerPage: number;
page: number;
}
interface IndexingResponse {
indexing: true;
message: string;
}
type SearchResult = MessageSearchResponse | IndexingResponse;
type MessageSearchApiParams = Record<string, string | number | boolean | Array<string> | undefined>;
export interface SearchContext {
contextChannelId?: string;
contextGuildId?: string | null;
}
const isHttpStatusError = (error: unknown, statusCode: number): error is {status: number} => {
return (
typeof error === 'object' &&
error != null &&
'status' in error &&
(error as {status?: number}).status === statusCode
);
};
export const isIndexing = (result: SearchResult): result is IndexingResponse => {
return 'indexing' in result && result.indexing === true;
};
export const searchMessages = async (
i18n: I18n,
context: SearchContext,
params: MessageSearchParams,
): Promise<SearchResult> => {
try {
const extraParams: MessageSearchApiParams = {};
if (context.contextChannelId) {
extraParams.context_channel_id = context.contextChannelId;
}
if (context.contextGuildId) {
extraParams.context_guild_id = context.contextGuildId;
}
if (params.channelId && params.channelId.length > 0) {
extraParams.channel_ids = params.channelId;
}
const response = await http.post<ApiMessageSearchResponse>('/search/messages', toApiParams(params, extraParams));
if (response.status === 202) {
return {indexing: true, message: i18n._(msg`One or more channels are being indexed`)};
}
const body = response.body;
if (!body) {
throw new Error(i18n._(msg`No response body received`));
}
return {
messages: body.messages?.map((msg) => new MessageRecord(msg)) ?? [],
total: body.total ?? 0,
hitsPerPage: body.hits_per_page ?? 25,
page: body.page ?? 1,
};
} catch (error: unknown) {
if (isHttpStatusError(error, 202)) {
return {indexing: true, message: i18n._(msg`One or more channels are being indexed`)};
}
throw error;
}
};
export interface SearchFilterOption {
key: string;
label: string;
description: string;
syntax: string;
values?: Array<SearchValueOption>;
requiresValue?: boolean;
requiresGuild?: boolean;
}
export const getSearchFilterOptions = (i18n: I18n): Array<SearchFilterOption> => [
{
key: 'from',
label: i18n._(msg`from:`),
description: i18n._(msg`user`),
syntax: 'from:',
requiresValue: true,
},
{
key: '-from',
label: i18n._(msg`-from:`),
description: i18n._(msg`exclude user`),
syntax: '-from:',
requiresValue: true,
},
{
key: 'mentions',
label: i18n._(msg`mentions:`),
description: i18n._(msg`user`),
syntax: 'mentions:',
requiresValue: true,
},
{
key: '-mentions',
label: i18n._(msg`-mentions:`),
description: i18n._(msg`exclude user`),
syntax: '-mentions:',
requiresValue: true,
},
{
key: 'has',
label: i18n._(msg`has:`),
description: i18n._(msg`link, embed or file`),
syntax: 'has:',
values: [
{value: 'link', label: i18n._(msg`link`)},
{value: 'embed', label: i18n._(msg`embed`)},
{value: 'file', label: i18n._(msg`file`)},
{value: 'image', label: i18n._(msg`image`)},
{value: 'video', label: i18n._(msg`video`)},
{value: 'sound', label: i18n._(msg`sound`)},
{value: 'sticker', label: i18n._(msg`sticker`)},
{value: 'poll', label: i18n._(msg`poll`)},
],
},
{
key: '-has',
label: i18n._(msg`-has:`),
description: i18n._(msg`exclude link, embed or file`),
syntax: '-has:',
values: [
{value: 'link', label: i18n._(msg`link`)},
{value: 'embed', label: i18n._(msg`embed`)},
{value: 'file', label: i18n._(msg`file`)},
{value: 'image', label: i18n._(msg`image`)},
{value: 'video', label: i18n._(msg`video`)},
{value: 'sound', label: i18n._(msg`sound`)},
{value: 'sticker', label: i18n._(msg`sticker`)},
{value: 'poll', label: i18n._(msg`poll`)},
],
},
{
key: 'before',
label: i18n._(msg`before:`),
description: i18n._(msg`specific date`),
syntax: 'before:',
requiresValue: true,
},
{
key: 'on',
label: i18n._(msg`on:`),
description: i18n._(msg`specific date`),
syntax: 'on:',
requiresValue: true,
},
{
key: 'during',
label: i18n._(msg`during:`),
description: i18n._(msg`specific date`),
syntax: 'during:',
requiresValue: true,
},
{
key: 'after',
label: i18n._(msg`after:`),
description: i18n._(msg`specific date`),
syntax: 'after:',
requiresValue: true,
},
{
key: 'in',
label: i18n._(msg`in:`),
description: i18n._(msg`channel`),
syntax: 'in:',
requiresValue: true,
requiresGuild: true,
},
{
key: '-in',
label: i18n._(msg`-in:`),
description: i18n._(msg`exclude channel`),
syntax: '-in:',
requiresValue: true,
requiresGuild: true,
},
{
key: 'pinned',
label: i18n._(msg`pinned:`),
description: i18n._(msg`true or false`),
syntax: 'pinned:',
values: [
{value: 'true', label: i18n._(msg`true`)},
{value: 'false', label: i18n._(msg`false`)},
],
},
{
key: 'authorType',
label: i18n._(msg`author-type:`),
description: i18n._(msg`user, bot or webhook`),
syntax: 'author-type:',
values: [
{value: 'user', label: i18n._(msg`user`)},
{value: 'bot', label: i18n._(msg`bot`)},
{value: 'webhook', label: i18n._(msg`webhook`)},
],
},
{
key: 'link',
label: i18n._(msg`link:`),
description: i18n._(msg`e.g. example.com`),
syntax: 'link:',
requiresValue: true,
},
{
key: '-link',
label: i18n._(msg`-link:`),
description: i18n._(msg`exclude e.g. example.com`),
syntax: '-link:',
requiresValue: true,
},
{
key: 'filename',
label: i18n._(msg`filename:`),
description: i18n._(msg`attachment filename contains`),
syntax: 'filename:',
requiresValue: true,
},
{
key: '-filename',
label: i18n._(msg`-filename:`),
description: i18n._(msg`exclude attachment filename contains`),
syntax: '-filename:',
requiresValue: true,
},
{
key: 'ext',
label: i18n._(msg`ext:`),
description: i18n._(msg`attachment extension`),
syntax: 'ext:',
requiresValue: true,
},
{
key: '-ext',
label: i18n._(msg`-ext:`),
description: i18n._(msg`exclude attachment extension`),
syntax: '-ext:',
requiresValue: true,
},
];
export const toApiParams = (
params: MessageSearchParams,
extraParams?: MessageSearchApiParams,
): MessageSearchApiParams => {
const hitsPerPage = params.hitsPerPage ?? 25;
const page = params.page ?? 1;
const apiParams: MessageSearchApiParams = {
hits_per_page: hitsPerPage,
page,
max_id: params.maxId,
min_id: params.minId,
content: params.content,
contents: params.contents,
channel_id: params.channelId,
exclude_channel_id: params.excludeChannelId,
author_type: params.authorType,
exclude_author_type: params.excludeAuthorType,
author_id: params.authorId,
exclude_author_id: params.excludeAuthorId,
mentions: params.mentions,
exclude_mentions: params.excludeMentions,
mention_everyone: params.mentionEveryone,
pinned: params.pinned,
has: params.has,
exclude_has: params.excludeHas,
embed_type: params.embedType,
exclude_embed_type: params.excludeEmbedType,
embed_provider: params.embedProvider,
exclude_embed_provider: params.excludeEmbedProvider,
link_hostname: params.linkHostname,
exclude_link_hostname: params.excludeLinkHostname,
attachment_filename: params.attachmentFilename,
exclude_attachment_filename: params.excludeAttachmentFilename,
attachment_extension: params.attachmentExtension,
exclude_attachment_extension: params.excludeAttachmentExtension,
sort_by: params.sortBy,
sort_order: params.sortOrder,
scope: params.scope,
include_nsfw: params.includeNsfw,
...extraParams,
};
Object.keys(apiParams).forEach((key) => {
if (apiParams[key] === undefined) {
delete apiParams[key];
}
});
return apiParams;
};
export const parseSearchQueryWithSegments = (query: string, _segments: Array<SearchSegment>): MessageSearchParams => {
const entry = SearchHistoryStore.recent().find((e) => e.query === query);
return parseQuery(query, entry?.hints);
};

View File

@@ -0,0 +1,157 @@
/*
* 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 {GroupBase, StylesConfig} from 'react-select';
export const getSelectStyles = <
Option,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>,
>(
error?: boolean,
): StylesConfig<Option, IsMulti, Group> => ({
control: (provided, state) => {
const baseBorder = error ? 'var(--status-danger)' : 'var(--background-modifier-accent)';
const focusBorder = error ? 'var(--status-danger)' : 'var(--background-modifier-accent-focus)';
const surfaceBackground = 'var(--form-surface-background)';
return {
...provided,
backgroundColor: surfaceBackground,
borderColor: state.isFocused ? focusBorder : baseBorder,
borderWidth: '1px',
borderRadius: '8px',
minHeight: '44px',
paddingLeft: '16px',
paddingRight: '0px',
boxShadow: 'none',
outline: 'none',
transition: 'border-color 0.15s ease',
'&:hover': {
backgroundColor: surfaceBackground,
borderColor: state.isFocused ? focusBorder : baseBorder,
},
};
},
valueContainer: (provided) => ({
...provided,
padding: 0,
gap: 0,
}),
menu: (provided) => ({
...provided,
backgroundColor: 'var(--form-surface-background)',
border: '1px solid var(--background-modifier-accent)',
borderRadius: '8px',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.24)',
zIndex: 99999,
overflow: 'hidden',
overflowX: 'hidden',
fontSize: '0.875rem',
lineHeight: '1.25rem',
}),
menuList: (provided) => ({
...provided,
overflowY: 'auto',
overflowX: 'hidden',
paddingTop: '4px',
paddingBottom: '4px',
scrollbarWidth: 'thin',
scrollbarColor: 'var(--scrollbar-thumb-bg) var(--scrollbar-track-bg)',
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
backgroundColor: 'var(--scrollbar-track-bg)',
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'var(--scrollbar-thumb-bg)',
backgroundClip: 'padding-box',
border: '2px solid transparent',
borderRadius: '4px',
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: 'var(--scrollbar-thumb-bg-hover)',
},
}),
menuPortal: (provided) => ({
...provided,
zIndex: 99999,
}),
option: (provided, state) => ({
...provided,
backgroundColor: state.isSelected
? 'var(--brand-primary)'
: state.isFocused
? 'var(--background-modifier-hover)'
: 'transparent',
color: state.isSelected ? 'white' : 'var(--text-primary)',
cursor: 'pointer',
paddingTop: '8px',
paddingBottom: '8px',
paddingLeft: '12px',
paddingRight: '12px',
transition: 'background-color 0.1s ease',
fontSize: '0.875rem',
lineHeight: '1.25rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
'&:hover': {
backgroundColor: state.isSelected ? 'var(--brand-primary)' : 'var(--background-modifier-hover)',
},
}),
singleValue: (provided) => ({
...provided,
color: 'var(--text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
fontSize: '0.875rem',
lineHeight: '1.25rem',
}),
placeholder: (provided) => ({
...provided,
color: 'var(--text-primary-muted)',
fontSize: '0.875rem',
lineHeight: '1.25rem',
}),
input: (provided) => ({
...provided,
color: 'var(--text-primary)',
fontSize: '0.875rem',
lineHeight: '1.25rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
dropdownIndicator: (provided, state) => ({
...provided,
color: 'var(--text-tertiary)',
padding: '8px',
transform: state.selectProps.menuIsOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease, color 0.15s ease',
'&:hover': {
color: 'var(--text-primary)',
},
}),
indicatorSeparator: () => ({
display: 'none',
}),
});

View File

@@ -0,0 +1,243 @@
/*
* 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 {describe, expect, test} from 'vitest';
import type {Command} from '~/components/channel/Autocomplete';
import {
detectAutocompleteTrigger,
filterCommandsByQuery,
getCommandInsertionText,
isCommandRequiringUserMention,
} from './SlashCommandUtils';
describe('SlashCommandUtils', () => {
describe('detectAutocompleteTrigger', () => {
test('should detect mention trigger', () => {
const result = detectAutocompleteTrigger('hello @use');
expect(result).toEqual({
type: 'mention',
match: expect.any(Array),
matchedText: 'use',
});
});
test('should detect channel trigger', () => {
const result = detectAutocompleteTrigger('hello #cha');
expect(result).toEqual({
type: 'channel',
match: expect.any(Array),
matchedText: 'cha',
});
});
test('should detect emoji trigger', () => {
const result = detectAutocompleteTrigger('hello :sm');
expect(result).toEqual({
type: 'emoji',
match: expect.any(Array),
matchedText: 'sm',
});
});
test('should detect command trigger with slash only', () => {
const result = detectAutocompleteTrigger('/');
expect(result).toEqual({
type: 'command',
match: expect.any(Array),
matchedText: '',
});
});
test('should detect command trigger with partial command', () => {
const result = detectAutocompleteTrigger('/ba');
expect(result).toEqual({
type: 'command',
match: expect.any(Array),
matchedText: 'ba',
});
});
test('should detect meme search trigger', () => {
const result = detectAutocompleteTrigger('/saved cat');
expect(result).toEqual({
type: 'meme',
match: expect.any(Array),
matchedText: 'cat',
});
});
test('should detect meme search trigger with empty query', () => {
const result = detectAutocompleteTrigger('/saved ');
expect(result).toEqual({
type: 'meme',
match: expect.any(Array),
matchedText: '',
});
});
test('should detect sticker search trigger with empty query', () => {
const result = detectAutocompleteTrigger('/sticker ');
expect(result).toEqual({
type: 'sticker',
match: expect.any(Array),
matchedText: '',
});
});
test('should detect command argument trigger', () => {
const result = detectAutocompleteTrigger('/kick user');
expect(result).toEqual({
type: 'commandArg',
match: expect.any(Array),
matchedText: 'user',
});
});
test('should detect command argument trigger with empty query', () => {
const result = detectAutocompleteTrigger('/kick ');
expect(result).toEqual({
type: 'commandArg',
match: expect.any(Array),
matchedText: '',
});
});
test('should detect command argument mention trigger', () => {
const result = detectAutocompleteTrigger('/kick @user');
expect(result).toEqual({
type: 'commandArgMention',
match: expect.any(Array),
matchedText: 'user',
});
});
test('should detect gif search trigger', () => {
const result = detectAutocompleteTrigger('/gif funny');
expect(result).toEqual({
type: 'gif',
match: expect.any(Array),
matchedText: 'funny',
});
});
test('should detect command argument mention trigger', () => {
const result = detectAutocompleteTrigger('/ban @use');
expect(result).toEqual({
type: 'commandArgMention',
match: expect.any(Array),
matchedText: 'use',
});
});
test('should return null for no trigger', () => {
const result = detectAutocompleteTrigger('hello world');
expect(result).toBeNull();
});
test('should not trigger when / is not at start or after whitespace', () => {
const result = detectAutocompleteTrigger('hello/test');
expect(result).toBeNull();
});
test('should trigger when / is at start of text', () => {
const result = detectAutocompleteTrigger('/');
expect(result?.type).toBe('command');
});
test('should NOT trigger when / is in the middle of text', () => {
const result = detectAutocompleteTrigger('test /');
expect(result).toBeNull();
});
test('should trigger when / is after leading whitespace', () => {
const result = detectAutocompleteTrigger(' /');
expect(result?.type).toBe('command');
});
});
describe('filterCommandsByQuery', () => {
const mockCommands: Array<Command> = [
{type: 'simple', name: '/shrug', content: '¯\\_(ツ)_/¯'},
{type: 'action', name: '/ban', permission: 4n, requiresGuild: true},
{type: 'action', name: '/kick', permission: 2n, requiresGuild: true},
{type: 'action', name: '/msg'},
{type: 'action', name: '/saved'},
];
test('should return all commands when query is empty', () => {
const result = filterCommandsByQuery(mockCommands, '');
expect(result).toHaveLength(5);
});
test('should filter commands by name', () => {
const result = filterCommandsByQuery(mockCommands, 'ba');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('/ban');
});
test('should be case insensitive', () => {
const result = filterCommandsByQuery(mockCommands, 'BAN');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('/ban');
});
test('should return empty array when no matches', () => {
const result = filterCommandsByQuery(mockCommands, 'xyz');
expect(result).toHaveLength(0);
});
test('should handle partial matches', () => {
const result = filterCommandsByQuery(mockCommands, 'm');
expect(result).toHaveLength(1);
expect(result.map((c) => c.name)).toEqual(expect.arrayContaining(['/msg']));
});
});
describe('isCommandRequiringUserMention', () => {
test('should return true for commands requiring user mentions', () => {
expect(isCommandRequiringUserMention('/kick')).toBe(true);
expect(isCommandRequiringUserMention('/ban')).toBe(true);
expect(isCommandRequiringUserMention('/msg')).toBe(true);
expect(isCommandRequiringUserMention('/saved')).toBe(true);
});
test('should return false for other commands', () => {
expect(isCommandRequiringUserMention('/shrug')).toBe(false);
expect(isCommandRequiringUserMention('/nick')).toBe(false);
expect(isCommandRequiringUserMention('/me')).toBe(false);
});
});
describe('getCommandInsertionText', () => {
test('should return content for simple commands', () => {
const command: Command = {type: 'simple', name: '/shrug', content: '¯\\_(ツ)_/¯'};
expect(getCommandInsertionText(command)).toBe('¯\\_(ツ)_/¯');
});
test('should return command name with space for action commands', () => {
const command: Command = {type: 'action', name: '/ban', permission: 4n, requiresGuild: true};
expect(getCommandInsertionText(command)).toBe('/ban ');
});
test('should return command name with space for action commands without permission', () => {
const command: Command = {type: 'action', name: '/msg'};
expect(getCommandInsertionText(command)).toBe('/msg ');
});
});
});

View File

@@ -0,0 +1,163 @@
/*
* 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 {Command} from '~/components/channel/Autocomplete';
const MENTION_REGEX = /(^|\s)@(\S*)$/;
const CHANNEL_REGEX = /(^|\s)#(\S*)$/;
const EMOJI_REGEX = /(^|\s):([a-z0-9_+-]{2,})$/i;
const EMOJI_REACTION_REGEX = /^\+:([a-z0-9_+-]*)$/i;
const COMMAND_REGEX = /(^\s*)\/(\S*)$/;
const MEME_SEARCH_REGEX = /(^\s*)\/saved\s*(.*)$/;
const GIF_SEARCH_REGEX = /(^\s*)\/(tenor|gif)\s*(.*)$/;
const STICKER_SEARCH_REGEX = /(^\s*)\/sticker\s*(.*)$/;
const COMMAND_ARG_MENTION_REGEX = /(^\s*)\/(kick|ban|msg|saved)\s+@(\S*)$/;
const COMMAND_ARG_REGEX = /(^\s*)\/(kick|ban|msg)\s+(\S*)$/;
interface AutocompleteTrigger {
type:
| 'mention'
| 'channel'
| 'emoji'
| 'emojiReaction'
| 'command'
| 'meme'
| 'gif'
| 'sticker'
| 'commandArgMention'
| 'commandArg';
match: RegExpMatchArray;
matchedText: string;
}
export function detectAutocompleteTrigger(textUpToCursor: string): AutocompleteTrigger | null {
const emojiReactionMatch = textUpToCursor.match(EMOJI_REACTION_REGEX);
if (emojiReactionMatch) {
return {
type: 'emojiReaction',
match: emojiReactionMatch,
matchedText: emojiReactionMatch[1] || '',
};
}
const commandArgMentionMatch = textUpToCursor.match(COMMAND_ARG_MENTION_REGEX);
if (commandArgMentionMatch) {
return {
type: 'commandArgMention',
match: commandArgMentionMatch,
matchedText: commandArgMentionMatch[3],
};
}
const commandArgMatch = textUpToCursor.match(COMMAND_ARG_REGEX);
if (commandArgMatch) {
return {
type: 'commandArg',
match: commandArgMatch,
matchedText: commandArgMatch[3],
};
}
const mentionMatch = textUpToCursor.match(MENTION_REGEX);
if (mentionMatch) {
return {
type: 'mention',
match: mentionMatch,
matchedText: mentionMatch[2],
};
}
const channelMatch = textUpToCursor.match(CHANNEL_REGEX);
if (channelMatch) {
return {
type: 'channel',
match: channelMatch,
matchedText: channelMatch[2],
};
}
const emojiMatch = textUpToCursor.match(EMOJI_REGEX);
if (emojiMatch) {
const q = emojiMatch[2] || '';
if (q.length < 2) return null;
return {
type: 'emoji',
match: emojiMatch,
matchedText: q,
};
}
const memeSearchMatch = textUpToCursor.match(MEME_SEARCH_REGEX);
if (memeSearchMatch) {
return {
type: 'meme',
match: memeSearchMatch,
matchedText: memeSearchMatch[2],
};
}
const gifSearchMatch = textUpToCursor.match(GIF_SEARCH_REGEX);
if (gifSearchMatch) {
return {
type: 'gif',
match: gifSearchMatch,
matchedText: gifSearchMatch[3],
};
}
const stickerSearchMatch = textUpToCursor.match(STICKER_SEARCH_REGEX);
if (stickerSearchMatch) {
return {
type: 'sticker',
match: stickerSearchMatch,
matchedText: stickerSearchMatch[2],
};
}
const commandMatch = textUpToCursor.match(COMMAND_REGEX);
if (commandMatch) {
return {
type: 'command',
match: commandMatch,
matchedText: commandMatch[2],
};
}
return null;
}
export function filterCommandsByQuery(commands: Array<Command>, query: string): Array<Command> {
if (!query) {
return commands;
}
return commands.filter((command) => command.name.toLowerCase().includes(query.toLowerCase()));
}
export function isCommandRequiringUserMention(commandName: string): boolean {
return ['/kick', '/ban', '/msg', '/saved'].includes(commandName);
}
export function getCommandInsertionText(command: Command): string {
if (command.type === 'simple') {
return command.content;
}
return `${command.name} `;
}

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 {
atNextMillisecond as atNextMillisecondImpl,
atPreviousMillisecond as atPreviousMillisecondImpl,
compare as compareImpl,
extractTimestamp as extractTimestampImpl,
fromTimestamp as fromTimestampImpl,
fromTimestampWithSequence as fromTimestampWithSequenceImpl,
isProbablyAValidSnowflake as isProbablyAValidSnowflakeImpl,
type SnowflakeSequence,
} from './SnowflakeUtils';
function cast<T>(value: T): T {
return value;
}
const SnowflakeUtil = {
extractTimestamp(snowflake: string): number {
return extractTimestampImpl(snowflake);
},
compare(snowflake1: string | null, snowflake2: string | null): number {
return compareImpl(snowflake1, snowflake2);
},
atPreviousMillisecond(snowflake: string): string {
return atPreviousMillisecondImpl(snowflake);
},
atNextMillisecond(snowflake: string): string {
return atNextMillisecondImpl(snowflake);
},
fromTimestamp(timestamp: number): string {
return fromTimestampImpl(timestamp);
},
fromTimestampWithSequence(timestamp: number, sequence: SnowflakeSequence): string {
return fromTimestampWithSequenceImpl(timestamp, sequence);
},
isProbablyAValidSnowflake(value: string | null | undefined): boolean {
return isProbablyAValidSnowflakeImpl(value);
},
castChannelIdAsMessageId(channelId: string): string {
return cast(channelId);
},
castMessageIdAsChannelId(messageId: string): string {
return cast(messageId);
},
castGuildIdAsEveryoneGuildRoleId(guildId: string): string {
return cast(guildId);
},
cast,
};
export default SnowflakeUtil;

View File

@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {describe, expect, test} from 'vitest';
import {
compare,
extractTimestamp,
fromTimestamp,
fromTimestampWithSequence,
SnowflakeSequence,
} from '~/utils/SnowflakeUtils';
describe('SnowflakeUtils', () => {
test('extractTimestamp round-trips generated snowflakes without precision loss', () => {
const timestamp = Date.now();
const snowflake = fromTimestamp(timestamp);
expect(extractTimestamp(snowflake)).toBe(timestamp);
});
test('extractTimestamp returns NaN for invalid inputs instead of throwing', () => {
expect(extractTimestamp('not-a-valid-snowflake')).toBeNaN();
expect(extractTimestamp('123')).toBeNaN();
});
test('fromTimestampWithSequence preserves the timestamp while the sequence makes later IDs larger', () => {
const timestamp = Date.now();
const sequence = new SnowflakeSequence();
const first = fromTimestampWithSequence(timestamp, sequence);
const second = fromTimestampWithSequence(timestamp, sequence);
expect(extractTimestamp(first)).toBe(timestamp);
expect(extractTimestamp(second)).toBe(timestamp);
expect(compare(second, first)).toBeGreaterThan(0);
});
test('compare orders snowflakes by length and handles null values safely', () => {
expect(compare(null, null)).toBe(0);
expect(compare(null, '1')).toBe(-1);
expect(compare('2', null)).toBe(1);
expect(compare('99999999999999999', '1')).toBeGreaterThan(0);
expect(compare('1', '2')).toBeLessThan(0);
});
});

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 {FLUXER_EPOCH} from '~/Constants';
const MAX_SEQUENCE = 4095;
const TIMESTAMP_SHIFT = 22;
export function extractTimestamp(snowflake: string): number {
if (!/^\d{17,19}$/.test(snowflake)) {
return Number.NaN;
}
try {
const shifted = BigInt(snowflake) >> BigInt(TIMESTAMP_SHIFT);
return Number(shifted) + FLUXER_EPOCH;
} catch (_error) {
return Number.NaN;
}
}
export function fromTimestamp(timestamp: number): string {
const adjustedTimestamp = timestamp - FLUXER_EPOCH;
if (adjustedTimestamp <= 0) {
return '0';
}
return (BigInt(adjustedTimestamp) << BigInt(TIMESTAMP_SHIFT)).toString();
}
export function fromTimestampWithSequence(timestamp: number, sequence: SnowflakeSequence): string {
const adjustedTimestamp = timestamp - FLUXER_EPOCH;
const timestampValue = adjustedTimestamp <= 0 ? 0 : adjustedTimestamp;
return ((BigInt(timestampValue) << BigInt(TIMESTAMP_SHIFT)) + BigInt(sequence.next())).toString();
}
export function atPreviousMillisecond(snowflake: string): string {
return fromTimestamp(extractTimestamp(snowflake) - 1);
}
export function atNextMillisecond(snowflake: string): string {
return fromTimestamp(extractTimestamp(snowflake) + 1);
}
export function compare(snowflake1: string | null, snowflake2: string | null): number {
if (snowflake1 === snowflake2) return 0;
if (snowflake2 == null) return 1;
if (snowflake1 == null) return -1;
if (snowflake1.length > snowflake2.length) return 1;
if (snowflake1.length < snowflake2.length) return -1;
return snowflake1 > snowflake2 ? 1 : -1;
}
export function isProbablyAValidSnowflake(value: string | null | undefined): boolean {
if (value == null || !/^\d{17,19}$/.test(value)) {
return false;
}
try {
return extractTimestamp(value) >= FLUXER_EPOCH;
} catch (_error) {
return false;
}
}
export function sortBySnowflakeDesc<T extends {id: string}>(items: ReadonlyArray<T>): Array<T> {
return [...items].sort((a, b) => compare(b.id, a.id));
}
export function age(snowflake: string): number {
const timestamp = extractTimestamp(snowflake);
return Number.isNaN(timestamp) ? 0 : Date.now() - timestamp;
}
export class SnowflakeSequence {
private seq: number;
constructor() {
this.seq = 0;
}
next(): number {
if (this.seq > MAX_SEQUENCE) {
throw new Error(`Snowflake sequence number overflow: ${this.seq}`);
}
return this.seq++;
}
willOverflowNext(): boolean {
return this.seq > MAX_SEQUENCE;
}
reset(): void {
this.seq = 0;
}
}

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 {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {SoundType} from '~/utils/SoundUtils';
export const getSoundLabels = (i18n: I18n): Record<SoundType, string> => ({
[SoundType.Message]: i18n._(msg`Message Notifications`),
[SoundType.Mute]: i18n._(msg`Voice Mute`),
[SoundType.Unmute]: i18n._(msg`Voice Unmute`),
[SoundType.Deaf]: i18n._(msg`Voice Deafen`),
[SoundType.Undeaf]: i18n._(msg`Voice Undeafen`),
[SoundType.UserJoin]: i18n._(msg`User Joins Channel`),
[SoundType.UserLeave]: i18n._(msg`User Leaves Channel`),
[SoundType.UserMove]: i18n._(msg`User Moved Channel`),
[SoundType.ViewerJoin]: i18n._(msg`Viewer Joins Stream`),
[SoundType.ViewerLeave]: i18n._(msg`Viewer Leaves Stream`),
[SoundType.VoiceDisconnect]: i18n._(msg`Voice Disconnected`),
[SoundType.IncomingRing]: i18n._(msg`Incoming Call`),
[SoundType.CameraOn]: i18n._(msg`Camera On`),
[SoundType.CameraOff]: i18n._(msg`Camera Off`),
[SoundType.ScreenShareStart]: i18n._(msg`Screen Share Start`),
[SoundType.ScreenShareStop]: i18n._(msg`Screen Share Stop`),
});

View File

@@ -0,0 +1,314 @@
/*
* 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 cameraOffSound from '~/sounds/camera-off.mp3';
import cameraOnSound from '~/sounds/camera-on.mp3';
import deafSound from '~/sounds/deaf.mp3';
import incomingRingSound from '~/sounds/incoming-ring.mp3';
import messageSound from '~/sounds/message.mp3';
import muteSound from '~/sounds/mute.mp3';
import streamSound from '~/sounds/stream-start.mp3';
import streamStopSound from '~/sounds/stream-stop.mp3';
import undeafSound from '~/sounds/undeaf.mp3';
import unmuteSound from '~/sounds/unmute.mp3';
import userJoinSound from '~/sounds/user-join.mp3';
import userLeaveSound from '~/sounds/user-leave.mp3';
import userMoveSound from '~/sounds/user-move.mp3';
import viewerJoinSound from '~/sounds/viewer-join.mp3';
import viewerLeaveSound from '~/sounds/viewer-leave.mp3';
import voiceDisconnectSound from '~/sounds/voice-disconnect.mp3';
import * as CustomSoundDB from '~/utils/CustomSoundDB';
const MAX_EFFECTIVE_VOLUME = 0.4;
const MASTER_HEADROOM = 0.8;
const MIN_GAIN = 0.0001;
const DEFAULT_FADE_DURATION = 0.08;
export const SoundType = {
Deaf: 'deaf',
Undeaf: 'undeaf',
Mute: 'mute',
Unmute: 'unmute',
Message: 'message',
IncomingRing: 'incoming-ring',
UserJoin: 'user-join',
UserLeave: 'user-leave',
UserMove: 'user-move',
ViewerJoin: 'viewer-join',
ViewerLeave: 'viewer-leave',
VoiceDisconnect: 'voice-disconnect',
CameraOn: 'camera-on',
CameraOff: 'camera-off',
ScreenShareStart: 'screen-share-start',
ScreenShareStop: 'screen-share-stop',
} as const;
export type SoundType = (typeof SoundType)[keyof typeof SoundType];
const SOUND_FILES: Record<SoundType, string> = {
[SoundType.Deaf]: deafSound,
[SoundType.Undeaf]: undeafSound,
[SoundType.Mute]: muteSound,
[SoundType.Unmute]: unmuteSound,
[SoundType.Message]: messageSound,
[SoundType.IncomingRing]: incomingRingSound,
[SoundType.UserJoin]: userJoinSound,
[SoundType.UserLeave]: userLeaveSound,
[SoundType.UserMove]: userMoveSound,
[SoundType.ViewerJoin]: viewerJoinSound,
[SoundType.ViewerLeave]: viewerLeaveSound,
[SoundType.VoiceDisconnect]: voiceDisconnectSound,
[SoundType.CameraOn]: cameraOnSound,
[SoundType.CameraOff]: cameraOffSound,
[SoundType.ScreenShareStart]: streamSound,
[SoundType.ScreenShareStop]: streamStopSound,
};
interface AudioInstance {
audio: HTMLAudioElement;
gainNode: GainNode;
sourceNode: MediaElementAudioSourceNode;
}
const activeSounds: Map<SoundType, AudioInstance> = new Map();
const activePreviewSounds: Set<AudioInstance> = new Set();
const customSoundCache: Map<SoundType, string> = new Map();
let audioContext: AudioContext | null = null;
let masterGainNode: GainNode | null = null;
const clamp = (value: number, min = 0, max = 1): number => Math.min(Math.max(value, min), max);
const disconnectNodes = (...nodes: Array<AudioNode | null | undefined>): void => {
nodes.forEach((node) => {
if (!node) return;
try {
node.disconnect();
} catch {}
});
};
const getAudioContext = (): AudioContext => {
if (!audioContext) {
audioContext = new AudioContext();
}
return audioContext;
};
const getMasterGainNode = (): GainNode => {
const ctx = getAudioContext();
if (!masterGainNode || masterGainNode.context.state === 'closed') {
masterGainNode = ctx.createGain();
masterGainNode.gain.value = MASTER_HEADROOM;
masterGainNode.connect(ctx.destination);
}
return masterGainNode;
};
const resumeAudioContextIfNeeded = async (): Promise<AudioContext> => {
const ctx = getAudioContext();
if (ctx.state === 'suspended') {
try {
await ctx.resume();
} catch {}
}
return ctx;
};
const fadeIn = (gainNode: GainNode, targetVolume: number, duration = DEFAULT_FADE_DURATION): void => {
const ctx = getAudioContext();
const now = ctx.currentTime;
targetVolume = clamp(targetVolume, 0, MAX_EFFECTIVE_VOLUME);
gainNode.gain.cancelScheduledValues(now);
gainNode.gain.setValueAtTime(MIN_GAIN, now);
gainNode.gain.linearRampToValueAtTime(targetVolume, now + duration);
};
const fadeOut = (gainNode: GainNode, duration = DEFAULT_FADE_DURATION): Promise<void> => {
return new Promise((resolve) => {
const ctx = getAudioContext();
const now = ctx.currentTime;
const currentVolume = gainNode.gain.value;
if (currentVolume <= MIN_GAIN) {
gainNode.gain.setValueAtTime(MIN_GAIN, now);
resolve();
return;
}
gainNode.gain.cancelScheduledValues(now);
gainNode.gain.setValueAtTime(currentVolume, now);
gainNode.gain.linearRampToValueAtTime(MIN_GAIN, now + duration);
setTimeout(resolve, duration * 1000);
});
};
const getSoundUrl = async (type: SoundType): Promise<string> => {
const cachedUrl = customSoundCache.get(type);
if (cachedUrl) {
return cachedUrl;
}
const customSound = await CustomSoundDB.getCustomSound(type);
if (!customSound) {
return SOUND_FILES[type];
}
const url = URL.createObjectURL(customSound.blob);
customSoundCache.set(type, url);
return url;
};
const createAudioElement = (src: string): HTMLAudioElement => {
const audio = new Audio();
audio.crossOrigin = 'anonymous';
audio.src = src;
audio.preload = 'auto';
return audio;
};
export const playSound = async (type: SoundType, loop = false, volume = 0.4): Promise<HTMLAudioElement | null> => {
const activeSound = activeSounds.get(type);
if (loop && activeSound && !activeSound.audio.paused) {
return null;
}
try {
const ctx = await resumeAudioContextIfNeeded();
const soundUrl = await getSoundUrl(type);
const audio = createAudioElement(soundUrl);
audio.currentTime = 0;
audio.loop = loop;
const sourceNode = ctx.createMediaElementSource(audio);
const gainNode = ctx.createGain();
const masterGain = getMasterGainNode();
sourceNode.connect(gainNode);
gainNode.connect(masterGain);
const effectiveVolume = clamp(volume, 0, MAX_EFFECTIVE_VOLUME);
fadeIn(gainNode, effectiveVolume);
const playPromise = audio.play();
if (playPromise) {
playPromise.catch((error) => {
console.warn(`Failed to play sound ${type}:`, error);
});
}
const instance: AudioInstance = {
audio,
gainNode,
sourceNode,
};
if (loop) {
activeSounds.set(type, instance);
} else {
activePreviewSounds.add(instance);
audio.addEventListener(
'ended',
async () => {
try {
await fadeOut(gainNode, 0.05);
} finally {
activePreviewSounds.delete(instance);
disconnectNodes(sourceNode, gainNode);
}
},
{once: true},
);
}
return audio;
} catch (error) {
console.warn(`Failed to initialize or play sound ${type}:`, error);
return null;
}
};
export const clearCustomSoundCache = (type?: SoundType): void => {
if (type) {
const cachedUrl = customSoundCache.get(type);
if (cachedUrl) {
URL.revokeObjectURL(cachedUrl);
customSoundCache.delete(type);
}
return;
}
customSoundCache.forEach((url) => {
URL.revokeObjectURL(url);
});
customSoundCache.clear();
};
export const stopSound = async (type: SoundType): Promise<void> => {
const activeSound = activeSounds.get(type);
if (!activeSound) return;
const {audio, gainNode, sourceNode} = activeSound;
try {
await fadeOut(gainNode, 0.08);
} catch {}
audio.pause();
audio.currentTime = 0;
audio.loop = false;
disconnectNodes(sourceNode, gainNode);
activeSounds.delete(type);
};
export const stopAllSounds = async (): Promise<void> => {
const stopPromises: Array<Promise<void>> = [];
activeSounds.forEach((_, type) => {
stopPromises.push(stopSound(type));
});
activePreviewSounds.forEach((instance) => {
const {audio, gainNode, sourceNode} = instance;
const fadePromise = fadeOut(gainNode, 0.08)
.catch(() => {})
.finally(() => {
audio.pause();
audio.currentTime = 0;
disconnectNodes(sourceNode, gainNode);
});
stopPromises.push(fadePromise);
});
activePreviewSounds.clear();
await Promise.all(stopPromises);
};

View File

@@ -0,0 +1,144 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type React from 'react';
import {createContext, createElement, useCallback, useContext, useMemo, useState} from 'react';
import {Permissions, RenderSpoilers} from '~/Constants';
import PermissionStore from '~/stores/PermissionStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
const SPOILER_REGEX = /\|\|([\s\S]*?)\|\|/g;
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
export const normalizeUrl = (url: string): string | null => {
try {
const parsed = new URL(url);
return parsed.href.replace(/\/$/, '');
} catch {
return null;
}
};
const getRenderSpoilersSetting = (): number => UserSettingsStore.renderSpoilers;
const canAutoRevealForModerators = (channelId?: string): boolean => {
if (!channelId) return false;
const channelPermissions = PermissionStore.getChannelPermissions(channelId);
return channelPermissions ? (channelPermissions & Permissions.MANAGE_MESSAGES) !== 0n : false;
};
export const extractSpoileredUrls = (content: string | null | undefined): Set<string> => {
const spoileredUrls = new Set<string>();
if (!content) return spoileredUrls;
for (const match of content.matchAll(SPOILER_REGEX)) {
const spoilerBody = match[1];
if (!spoilerBody) continue;
for (const urlMatch of spoilerBody.matchAll(URL_REGEX)) {
const normalized = normalizeUrl(urlMatch[0]);
if (normalized) {
spoileredUrls.add(normalized);
}
}
}
return spoileredUrls;
};
interface SpoilerSyncContextValue {
isRevealed: (keys: Array<string>) => boolean;
reveal: (keys: Array<string>) => void;
}
const SpoilerSyncContext = createContext<SpoilerSyncContextValue | null>(null);
export const SpoilerSyncProvider: React.FC<{children: React.ReactNode}> = ({children}) => {
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
const reveal = useCallback((keys: Array<string>) => {
if (keys.length === 0) return;
setRevealedKeys((prev) => {
let changed = false;
const next = new Set(prev);
for (const key of keys) {
if (!next.has(key)) {
next.add(key);
changed = true;
}
}
return changed ? next : prev;
});
}, []);
const isRevealed = useCallback(
(keys: Array<string>) => {
if (keys.length === 0) return false;
for (const key of keys) {
if (revealedKeys.has(key)) return true;
}
return false;
},
[revealedKeys],
);
const value = useMemo(() => ({isRevealed, reveal}), [isRevealed, reveal]);
return createElement(SpoilerSyncContext.Provider, {value}, children);
};
export const useSpoilerState = (
isSpoiler: boolean,
channelId?: string,
syncKeys: Array<string> = [],
): {hidden: boolean; reveal: () => void; autoRevealed: boolean} => {
const [manuallyRevealed, setManuallyRevealed] = useState(false);
const spoilerSync = useContext(SpoilerSyncContext);
const renderSpoilersSetting = getRenderSpoilersSetting();
const autoReveal = useMemo(() => {
if (!isSpoiler) return true;
switch (renderSpoilersSetting) {
case RenderSpoilers.ALWAYS:
return true;
case RenderSpoilers.IF_MODERATOR:
return canAutoRevealForModerators(channelId);
default:
return false;
}
}, [channelId, isSpoiler, renderSpoilersSetting]);
const normalizedKeys = useMemo(() => Array.from(new Set(syncKeys)), [syncKeys]);
const sharedRevealed = useMemo(() => spoilerSync?.isRevealed(normalizedKeys) ?? false, [spoilerSync, normalizedKeys]);
const hidden = isSpoiler && !autoReveal && !manuallyRevealed && !sharedRevealed;
const reveal = useCallback(() => {
if (!manuallyRevealed) {
setManuallyRevealed(true);
}
if (normalizedKeys.length > 0) {
spoilerSync?.reveal(normalizedKeys);
}
}, [manuallyRevealed, normalizedKeys, spoilerSync]);
return {hidden, reveal, autoRevealed: autoReveal || sharedRevealed};
};

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/>.
*/
export const getInitialsFromName = (name: string) => {
const trimmed = name.trim();
if (!trimmed) {
return '';
}
const words = trimmed.split(/\s+/).filter((word) => word.length > 0);
if (words.length === 0) {
return '';
}
const initials = words.map((word) => Array.from(word)[0]).filter(Boolean);
return initials.join('');
};

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 type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import React from 'react';
import {MessageTypes} from '~/Constants';
import UserStore from '~/stores/UserStore';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
interface StringifyableMessage {
id: string;
type: number;
content: string;
author: {id: string};
mentions?: ReadonlyArray<{id: string}>;
}
const getGuildJoinMessagesPlaintext = (i18n: I18n): Array<(username: string) => string> => [
(username) => i18n._(msg`Glad you're here, ${username}! Watch out for Biff!`),
(username) => i18n._(msg`Greetings, ${username}! More fun than a hoverboard!`),
(username) => i18n._(msg`Hello, ${username}! Flux capacitor... fluxing. Welcome!`),
(username) => i18n._(msg`Hello, ${username}! Make your history right here.`),
(username) => i18n._(msg`Hey ${username}, great to see you! The flux capacitor is ready!`),
(username) => i18n._(msg`Hey there, ${username}! No hoverboards required.`),
(username) => i18n._(msg`Hey, ${username}, welcome! You can accomplish anything.`),
(username) => i18n._(msg`Hey, ${username}! When it hits 88mph, you'll see some serious stuff!`),
(username) => i18n._(msg`Hop in, ${username}! Mr Fusion is ready!`),
(username) => i18n._(msg`Step in, ${username}! No paradoxes here; we're all friends.`),
(username) => i18n._(msg`Welcome, ${username}! Don't forget to park your DeLorean.`),
(username) => i18n._(msg`Welcome, ${username}! No life preserver needed.`),
(username) => i18n._(msg`Welcome, ${username}! No need to hit 88mph; you're right on time.`),
(username) => i18n._(msg`Welcome, ${username}! We've been expecting you since last week!`),
(username) => i18n._(msg`Welcome, ${username}! Your future is whatever you make it!`),
(username) => i18n._(msg`Welcome, ${username}. Where we're going, we don't need roads.`),
(username) => i18n._(msg`Look at the time, ${username}! You're on schedule!`),
(username) => i18n._(msg`You made it, ${username}! We're about to hit 88mph!`),
(username) => i18n._(msg`You're here, ${username}! Just in time to rock 'n' roll!`),
(username) => i18n._(msg`You've arrived, ${username}! Enjoy the Jigowatt Joyride!`),
];
export const SystemMessageUtils = {
getGuildJoinMessage(messageId: string, username: React.ReactNode, i18n: I18n): React.ReactElement {
const messageList = getGuildJoinMessagesPlaintext(i18n);
const messageIndex = SnowflakeUtils.extractTimestamp(messageId) % messageList.length;
const messageGenerator = messageList[messageIndex];
return (
<>
{messageGenerator('__USERNAME__')
.split('__USERNAME__')
.map((part, i, arr) => (
<React.Fragment key={i}>
{part}
{i < arr.length - 1 && username}
</React.Fragment>
))}
</>
);
},
stringify(message: StringifyableMessage, i18n: I18n): string | null {
const author = UserStore.getUser(message.author.id);
if (!author) return null;
const username = author.username;
switch (message.type) {
case MessageTypes.USER_JOIN: {
const messageList = getGuildJoinMessagesPlaintext(i18n);
const messageIndex = SnowflakeUtils.extractTimestamp(message.id) % messageList.length;
const messageGenerator = messageList[messageIndex];
return messageGenerator(username);
}
case MessageTypes.CHANNEL_PINNED_MESSAGE:
return i18n._(msg`${username} pinned a message to this channel.`);
case MessageTypes.RECIPIENT_ADD: {
const mentionedUser =
message.mentions && message.mentions.length > 0 ? UserStore.getUser(message.mentions[0].id) : null;
if (mentionedUser) {
return i18n._(msg`${username} added ${mentionedUser.username} to the group.`);
}
return i18n._(msg`${username} added someone to the group.`);
}
case MessageTypes.RECIPIENT_REMOVE: {
const mentionedUserId = message.mentions && message.mentions.length > 0 ? message.mentions[0].id : null;
const isSelfRemove = mentionedUserId === message.author.id;
if (isSelfRemove) {
return i18n._(msg`${username} has left the group.`);
}
const mentionedUser = mentionedUserId ? UserStore.getUser(mentionedUserId) : null;
if (mentionedUser) {
return i18n._(msg`${username} removed ${mentionedUser.username} from the group.`);
}
return i18n._(msg`${username} removed someone from the group.`);
}
case MessageTypes.CHANNEL_NAME_CHANGE: {
const newName = message.content;
if (newName) {
return i18n._(msg`${username} changed the channel name to ${newName}.`);
}
return i18n._(msg`${username} changed the channel name.`);
}
case MessageTypes.CHANNEL_ICON_CHANGE:
return i18n._(msg`${username} changed the channel icon.`);
case MessageTypes.CALL:
return i18n._(msg`${username} started a call.`);
default:
return null;
}
},
};

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/>.
*/
export function parseTitleFromUrl(url: string): string {
if (!url) return '';
const viewMatch = url.match(/tenor\.com\/view\/([^?]+)/);
if (viewMatch?.[1]) {
return viewMatch[1]
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
const srcMatch = url.match(/\/([^/]+?)(?:\.[^.]+)?$/);
if (srcMatch?.[1]) {
return srcMatch[1]
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
return 'GIF';
}

View File

@@ -0,0 +1,225 @@
/*
* 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 {beforeEach, describe, expect, it} from 'vitest';
import {TextareaSegmentManager} from './TextareaSegmentManager';
describe('Textarea Autocomplete Flow Integration', () => {
let manager: TextareaSegmentManager;
beforeEach(() => {
manager = new TextareaSegmentManager();
});
function simulateAutocompleteSelect(
currentValue: string,
matchStart: number,
matchEnd: number,
displayText: string,
actualText: string,
segmentType: 'user' | 'role' | 'channel' | 'emoji' | 'special',
segmentId: string,
capturedWhitespace = '',
): string {
const hasLeadingSpace = capturedWhitespace.length > 0;
const beforeMatch = currentValue.slice(0, matchStart + capturedWhitespace.length);
const afterMatch = currentValue.slice(matchEnd);
const guildBeforeMatch = hasLeadingSpace || beforeMatch.endsWith(' ') || beforeMatch.length === 0 ? '' : ' ';
const insertPosition = beforeMatch.length + guildBeforeMatch.length;
const changeStart = beforeMatch.length;
const changeEnd = matchEnd;
manager.updateSegmentsForTextChange(changeStart, changeEnd, guildBeforeMatch.length);
const tempText = `${beforeMatch}${guildBeforeMatch}`;
const {newText: updatedText} = manager.insertSegment(
tempText,
insertPosition,
`${displayText} `,
`${actualText} `,
segmentType,
segmentId,
);
const finalText = updatedText + afterMatch;
const trimmed = finalText.trimStart();
return trimmed;
}
it('should handle first mention autocomplete', () => {
const value = '@Hampus';
const matchStart = 0;
const matchEnd = 7;
const result = simulateAutocompleteSelect(value, matchStart, matchEnd, '@Hampus#0001', '<@123>', 'user', '123');
expect(result).toBe('@Hampus#0001 ');
expect(manager.getSegments()).toHaveLength(1);
expect(manager.displayToActual(result)).toBe('<@123> ');
});
it('should handle second consecutive mention - the failing case', () => {
let value = '@Hampus';
let result = simulateAutocompleteSelect(value, 0, 7, '@Hampus#0001', '<@123>', 'user', '123');
expect(result).toBe('@Hampus#0001 ');
expect(manager.getSegments()).toHaveLength(1);
value = `${result}@Hampus`;
const matchStart = result.length;
const matchEnd = value.length;
result = simulateAutocompleteSelect(value, matchStart, matchEnd, '@Hampus#0001', '<@123>', 'user', '123');
expect(result).toBe('@Hampus#0001 @Hampus#0001 ');
const segments = manager.getSegments();
expect(segments).toHaveLength(2);
expect(manager.displayToActual(result)).toBe('<@123> <@123> ');
});
it('should handle three consecutive mentions', () => {
let value = '@Hampus';
let prevLength = 0;
let result = simulateAutocompleteSelect(value, 0, 7, '@Hampus#0001', '<@123>', 'user', '123');
prevLength = result.length;
value = `${result}@Hampus`;
result = simulateAutocompleteSelect(value, prevLength, value.length, '@Hampus#0001', '<@123>', 'user', '123');
prevLength = result.length;
value = `${result}@Hampus`;
result = simulateAutocompleteSelect(value, prevLength, value.length, '@Hampus#0001', '<@123>', 'user', '123');
expect(result).toBe('@Hampus#0001 @Hampus#0001 @Hampus#0001 ');
const segments = manager.getSegments();
expect(segments).toHaveLength(3);
expect(manager.displayToActual(result)).toBe('<@123> <@123> <@123> ');
});
it('should handle mention with text before it', () => {
const value = 'Hey @User';
const matchStart = 4;
const matchEnd = 9;
const result = simulateAutocompleteSelect(value, matchStart, matchEnd, '@User#0001', '<@123>', 'user', '123');
expect(result).toBe('Hey @User#0001 ');
expect(manager.getSegments()).toHaveLength(1);
expect(manager.displayToActual(result)).toBe('Hey <@123> ');
});
it('should handle multiple mentions with text between them', () => {
let value = 'Hey @User1';
let result = simulateAutocompleteSelect(value, 4, 10, '@User1#0001', '<@123>', 'user', '123');
value = `${result}and @User2`;
result = simulateAutocompleteSelect(value, result.length + 4, value.length, '@User2#0002', '<@456>', 'user', '456');
expect(result).toBe('Hey @User1#0001 and @User2#0002 ');
const segments = manager.getSegments();
expect(segments).toHaveLength(2);
expect(manager.displayToActual(result)).toBe('Hey <@123> and <@456> ');
});
it('should handle consecutive mentions', () => {
let value = '@H';
let result = simulateAutocompleteSelect(value, 0, 2, '@Hampus#0001', '<@123>', 'user', '123');
const prevLength = result.length;
value = `${result}@H`;
result = simulateAutocompleteSelect(value, prevLength, value.length, '@Hampus#0001', '<@123>', 'user', '123');
expect(result).toBe('@Hampus#0001 @Hampus#0001 ');
const segments = manager.getSegments();
expect(segments).toHaveLength(2);
expect(segments[0]).toMatchObject({id: '123', start: 0, end: 13});
expect(segments[1]).toMatchObject({id: '123', start: 13, end: 26});
expect(manager.displayToActual(result)).toBe('<@123> <@123> ');
});
it('should handle typing between autocompletes with handleTextChange', () => {
let previousValue = '';
let value = '@H';
value = simulateAutocompleteSelect(value, 0, 2, '@Hampus#0001', '<@123>', 'user', '123');
previousValue = value;
expect(value).toBe('@Hampus#0001 ');
expect(manager.getSegments()).toHaveLength(1);
const newValue = `${value}@`;
const change = TextareaSegmentManager.detectChange(previousValue, newValue);
manager.updateSegmentsForTextChange(change.changeStart, change.changeEnd, change.replacementLength);
value = newValue;
previousValue = value;
expect(manager.getSegments()).toHaveLength(1);
expect(manager.getSegments()[0]).toMatchObject({id: '123', start: 0, end: 13});
const newValue2 = `${value}H`;
const change2 = TextareaSegmentManager.detectChange(previousValue, newValue2);
manager.updateSegmentsForTextChange(change2.changeStart, change2.changeEnd, change2.replacementLength);
value = newValue2;
previousValue = value;
expect(manager.getSegments()).toHaveLength(1);
const matchStart = '@Hampus#0001 '.length;
const matchEnd = value.length;
value = simulateAutocompleteSelect(value, matchStart, matchEnd, '@Hampus#0001', '<@123>', 'user', '123');
expect(value).toBe('@Hampus#0001 @Hampus#0001 ');
expect(manager.getSegments()).toHaveLength(2);
expect(manager.displayToActual(value)).toBe('<@123> <@123> ');
});
it('should reproduce and fix the bug - regex match includes space', () => {
const MENTION_REGEX = /(^|\s)@(\S*)$/;
let value = '@Hampus#0001 ';
manager.insertSegment('', 0, '@Hampus#0001 ', '<@123> ', 'user', '123');
value += '@H';
const valueUpToCursor = value;
const match = valueUpToCursor.match(MENTION_REGEX);
if (match) {
const matchStart = match.index ?? 0;
const matchEnd = matchStart + match[0].length;
const capturedWhitespace = match[1] || '';
const result = simulateAutocompleteSelect(
value,
matchStart,
matchEnd,
'@Hampus#0001',
'<@123>',
'user',
'123',
capturedWhitespace,
);
expect(manager.getSegments()).toHaveLength(2);
expect(manager.displayToActual(result)).toBe('<@123> <@123> ');
}
});
});

View File

@@ -0,0 +1,118 @@
/*
* 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 {beforeEach, describe, expect, it} from 'vitest';
import {TextareaSegmentManager} from './TextareaSegmentManager';
describe('Textarea INSERT_MENTION Flow Integration', () => {
let manager: TextareaSegmentManager;
beforeEach(() => {
manager = new TextareaSegmentManager();
});
function simulateInsertMention(currentValue: string, userTag: string, userId: string): string {
const actualText = `<@${userId}>`;
const displayText = `@${userTag}`;
const needsSpace = currentValue.length > 0 && !currentValue.endsWith(' ');
const prefix = currentValue.length === 0 ? '' : needsSpace ? ' ' : '';
const insertPosition = currentValue.length + prefix.length;
const {newText} = manager.insertSegment(
currentValue + prefix,
insertPosition,
displayText,
actualText,
'user',
userId,
);
return newText;
}
it('should handle first INSERT_MENTION', () => {
const result = simulateInsertMention('', 'Hampus#0001', '123');
expect(result).toBe('@Hampus#0001');
expect(manager.getSegments()).toHaveLength(1);
expect(manager.displayToActual(result)).toBe('<@123>');
});
it('should handle two consecutive INSERT_MENTION calls', () => {
let value = '';
value = simulateInsertMention(value, 'Hampus#0001', '123');
expect(value).toBe('@Hampus#0001');
value += ' ';
value = simulateInsertMention(value, 'Hampus#0001', '123');
expect(value).toBe('@Hampus#0001 @Hampus#0001');
const segments = manager.getSegments();
expect(segments).toHaveLength(2);
expect(segments[0]).toMatchObject({id: '123', start: 0, end: 12});
expect(segments[1]).toMatchObject({id: '123', start: 13, end: 25});
expect(manager.displayToActual(value)).toBe('<@123> <@123>');
});
it('should handle INSERT_MENTION without manually adding space between', () => {
let value = '';
value = simulateInsertMention(value, 'Hampus#0001', '123');
value = simulateInsertMention(value, 'Hampus#0001', '123');
expect(value).toBe('@Hampus#0001 @Hampus#0001');
const segments = manager.getSegments();
expect(segments).toHaveLength(2);
expect(manager.displayToActual(value)).toBe('<@123> <@123>');
});
it('should handle three consecutive INSERT_MENTION calls', () => {
let value = '';
value = simulateInsertMention(value, 'Hampus#0001', '123');
value = simulateInsertMention(value, 'Hampus#0001', '123');
value = simulateInsertMention(value, 'Hampus#0001', '123');
expect(value).toBe('@Hampus#0001 @Hampus#0001 @Hampus#0001');
const segments = manager.getSegments();
expect(segments).toHaveLength(3);
expect(manager.displayToActual(value)).toBe('<@123> <@123> <@123>');
});
it('should handle INSERT_MENTION with text changes in between via handleTextChange', () => {
let value = '';
value = simulateInsertMention(value, 'Hampus#0001', '123');
const oldValue = value;
value = `${value} hello`;
const {changeStart, changeEnd, replacementLength} = TextareaSegmentManager.detectChange(oldValue, value);
manager.updateSegmentsForTextChange(changeStart, changeEnd, replacementLength);
value = simulateInsertMention(value, 'Other#0002', '456');
expect(value).toBe('@Hampus#0001 hello @Other#0002');
const segments = manager.getSegments();
expect(segments).toHaveLength(2);
expect(manager.displayToActual(value)).toBe('<@123> hello <@456>');
});
});

View File

@@ -0,0 +1,686 @@
/*
* 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 {beforeEach, describe, expect, it} from 'vitest';
import {type MentionSegment, TextareaSegmentManager} from './TextareaSegmentManager';
describe('TextareaSegmentManager', () => {
let manager: TextareaSegmentManager;
beforeEach(() => {
manager = new TextareaSegmentManager();
});
describe('basic operations', () => {
it('should start with empty segments', () => {
expect(manager.getSegments()).toEqual([]);
});
it('should set segments', () => {
const segments: Array<MentionSegment> = [
{
type: 'user',
id: '123',
displayText: '@User#0001',
actualText: '<@123>',
start: 0,
end: 10,
},
];
manager.setSegments(segments);
expect(manager.getSegments()).toEqual(segments);
});
it('should clear segments', () => {
const segments: Array<MentionSegment> = [
{
type: 'user',
id: '123',
displayText: '@User#0001',
actualText: '<@123>',
start: 0,
end: 10,
},
];
manager.setSegments(segments);
manager.clear();
expect(manager.getSegments()).toEqual([]);
});
});
describe('displayToActual', () => {
it('should convert display text to actual text with single mention', () => {
manager.setSegments([
{
type: 'user',
id: '123',
displayText: '@User#0001',
actualText: '<@123>',
start: 5,
end: 15,
},
]);
const result = manager.displayToActual('Hey, @User#0001 how are you?');
expect(result).toBe('Hey, <@123> how are you?');
});
it('should convert display text with multiple mentions', () => {
manager.setSegments([
{
type: 'user',
id: '123',
displayText: '@User#0001',
actualText: '<@123>',
start: 0,
end: 10,
},
{
type: 'user',
id: '456',
displayText: '@Other#0002',
actualText: '<@456>',
start: 15,
end: 26,
},
]);
const result = manager.displayToActual('@User#0001 and @Other#0002');
expect(result).toBe('<@123> and <@456>');
});
it('should handle emoji segments', () => {
manager.setSegments([
{
type: 'emoji',
id: 'emoji123',
displayText: ':smile:',
actualText: '<:smile:emoji123>',
start: 0,
end: 7,
},
]);
const result = manager.displayToActual(':smile: hello');
expect(result).toBe('<:smile:emoji123> hello');
});
it('should handle channel mentions', () => {
manager.setSegments([
{
type: 'channel',
id: 'chan123',
displayText: '#general',
actualText: '<#chan123>',
start: 8,
end: 16,
},
]);
const result = manager.displayToActual('Join us #general');
expect(result).toBe('Join us <#chan123>');
});
it('should handle role mentions', () => {
manager.setSegments([
{
type: 'role',
id: 'role123',
displayText: '@Admin',
actualText: '<@&role123>',
start: 0,
end: 6,
},
]);
const result = manager.displayToActual('@Admin please help');
expect(result).toBe('<@&role123> please help');
});
it('should return unchanged text when no segments', () => {
const result = manager.displayToActual('Hello world');
expect(result).toBe('Hello world');
});
});
describe('insertSegment', () => {
it('should insert segment at beginning', () => {
const {newText, newSegments} = manager.insertSegment('world', 0, '@User#0001 ', '<@123> ', 'user', '123');
expect(newText).toBe('@User#0001 world');
expect(newSegments).toHaveLength(1);
expect(newSegments[0]).toMatchObject({
start: 0,
end: 11,
displayText: '@User#0001 ',
actualText: '<@123> ',
});
});
it('should insert segment at end', () => {
const {newText, newSegments} = manager.insertSegment('Hello ', 6, '@User#0001', '<@123>', 'user', '123');
expect(newText).toBe('Hello @User#0001');
expect(newSegments).toHaveLength(1);
expect(newSegments[0]).toMatchObject({
start: 6,
end: 16,
});
});
it('should insert segment in middle', () => {
const {newText, newSegments} = manager.insertSegment('Hello world', 6, '@User#0001 ', '<@123> ', 'user', '123');
expect(newText).toBe('Hello @User#0001 world');
expect(newSegments).toHaveLength(1);
expect(newSegments[0]).toMatchObject({
start: 6,
end: 17,
});
});
it('should shift existing segments when inserting before them', () => {
manager.insertSegment('world', 0, '@User#0001 ', '<@123> ', 'user', '123');
const {newText, newSegments} = manager.insertSegment(
'@User#0001 world',
0,
'@Other#0002 ',
'<@456> ',
'user',
'456',
);
expect(newText).toBe('@Other#0002 @User#0001 world');
expect(newSegments).toHaveLength(2);
expect(newSegments[0]).toMatchObject({
start: 12,
end: 23,
id: '123',
});
expect(newSegments[1]).toMatchObject({
start: 0,
end: 12,
id: '456',
});
});
it('should not shift segments that are before insertion point', () => {
manager.insertSegment('world', 0, '@User#0001 ', '<@123> ', 'user', '123');
const {newText, newSegments} = manager.insertSegment(
'@User#0001 world',
17,
'@Other#0002',
'<@456>',
'user',
'456',
);
expect(newText).toBe('@User#0001 world@Other#0002');
expect(newSegments).toHaveLength(2);
expect(newSegments[0]).toMatchObject({
start: 0,
end: 11,
id: '123',
});
expect(newSegments[1]).toMatchObject({
start: 17,
end: 28,
id: '456',
});
});
});
describe('updateSegmentsForTextChange', () => {
beforeEach(() => {
manager.setSegments([
{
type: 'user',
id: '123',
displayText: '@User#0001',
actualText: '<@123>',
start: 6,
end: 16,
},
{
type: 'user',
id: '456',
displayText: '@Other#0002',
actualText: '<@456>',
start: 21,
end: 32,
},
]);
});
it('should keep segments before the change', () => {
const segments = manager.updateSegmentsForTextChange(32, 32, 6);
expect(segments).toHaveLength(2);
expect(segments[0]).toMatchObject({start: 6, end: 16});
expect(segments[1]).toMatchObject({start: 21, end: 32});
});
it('should shift segments after the change', () => {
const segments = manager.updateSegmentsForTextChange(0, 0, 4);
expect(segments).toHaveLength(2);
expect(segments[0]).toMatchObject({start: 10, end: 20});
expect(segments[1]).toMatchObject({start: 25, end: 36});
});
it('should remove segment that overlaps with change', () => {
const segments = manager.updateSegmentsForTextChange(6, 10, 0);
expect(segments).toHaveLength(1);
expect(segments[0]).toMatchObject({
id: '456',
start: 17,
end: 28,
});
});
it('should handle deletion in middle of text', () => {
const segments = manager.updateSegmentsForTextChange(16, 21, 0);
expect(segments).toHaveLength(2);
expect(segments[0]).toMatchObject({start: 6, end: 16});
expect(segments[1]).toMatchObject({start: 16, end: 27});
});
it('should handle replacement', () => {
const segments = manager.updateSegmentsForTextChange(0, 6, 3);
expect(segments).toHaveLength(2);
expect(segments[0]).toMatchObject({start: 3, end: 13});
expect(segments[1]).toMatchObject({start: 18, end: 29});
});
it('should handle complete deletion of segment', () => {
const segments = manager.updateSegmentsForTextChange(5, 17, 0);
expect(segments).toHaveLength(1);
expect(segments[0]).toMatchObject({
id: '456',
start: 9,
end: 20,
});
});
it('should handle multiple segments being removed', () => {
const segments = manager.updateSegmentsForTextChange(0, 32, 0);
expect(segments).toHaveLength(0);
});
});
describe('detectChange', () => {
it('should detect insertion at beginning', () => {
const change = TextareaSegmentManager.detectChange('world', 'Hello world');
expect(change).toEqual({
changeStart: 0,
changeEnd: 0,
replacementLength: 6,
});
});
it('should detect insertion at end', () => {
const change = TextareaSegmentManager.detectChange('Hello', 'Hello world');
expect(change).toEqual({
changeStart: 5,
changeEnd: 5,
replacementLength: 6,
});
});
it('should detect insertion in middle', () => {
const change = TextareaSegmentManager.detectChange('Helloworld', 'Hello world');
expect(change).toEqual({
changeStart: 5,
changeEnd: 5,
replacementLength: 1,
});
});
it('should detect deletion at beginning', () => {
const change = TextareaSegmentManager.detectChange('Hello world', 'world');
expect(change).toEqual({
changeStart: 0,
changeEnd: 6,
replacementLength: 0,
});
});
it('should detect deletion at end', () => {
const change = TextareaSegmentManager.detectChange('Hello world', 'Hello');
expect(change).toEqual({
changeStart: 5,
changeEnd: 11,
replacementLength: 0,
});
});
it('should detect deletion in middle', () => {
const change = TextareaSegmentManager.detectChange('Hello world', 'Helloworld');
expect(change).toEqual({
changeStart: 5,
changeEnd: 6,
replacementLength: 0,
});
});
it('should detect replacement', () => {
const change = TextareaSegmentManager.detectChange('Hello world', 'Hi world');
expect(change).toEqual({
changeStart: 1,
changeEnd: 5,
replacementLength: 1,
});
});
it('should detect no change', () => {
const change = TextareaSegmentManager.detectChange('Hello world', 'Hello world');
expect(change).toEqual({
changeStart: 11,
changeEnd: 11,
replacementLength: 0,
});
});
it('should detect complex replacement in middle', () => {
const change = TextareaSegmentManager.detectChange('The quick brown fox', 'The slow brown fox');
expect(change).toEqual({
changeStart: 4,
changeEnd: 9,
replacementLength: 4,
});
});
});
describe('integration scenarios', () => {
it('should handle user typing and then autocompleting a mention', () => {
const text = 'Hello @u';
const change = TextareaSegmentManager.detectChange(text, 'Hello ');
manager.updateSegmentsForTextChange(change.changeStart, change.changeEnd, change.replacementLength);
const {newText} = manager.insertSegment('Hello ', 6, '@User#0001 ', '<@123> ', 'user', '123');
expect(newText).toBe('Hello @User#0001 ');
expect(manager.getSegments()).toHaveLength(1);
expect(manager.displayToActual(newText)).toBe('Hello <@123> ');
});
it('should handle user deleting part of a mention', () => {
manager.insertSegment('Hello ', 6, '@User#0001 ', '<@123> ', 'user', '123');
const oldText = 'Hello @User#0001 world';
const newText = 'Hello @0001 world';
const change = TextareaSegmentManager.detectChange(oldText, newText);
const segments = manager.updateSegmentsForTextChange(
change.changeStart,
change.changeEnd,
change.replacementLength,
);
expect(segments).toHaveLength(0);
});
it('should handle multiple mentions and editing between them', () => {
manager.insertSegment('', 0, '@User#0001 ', '<@123> ', 'user', '123');
manager.insertSegment('@User#0001 and ', 15, '@Other#0002', '<@456>', 'user', '456');
const oldText = '@User#0001 and @Other#0002';
const newText = '@User#0001 hello and @Other#0002';
const change = TextareaSegmentManager.detectChange(oldText, newText);
manager.updateSegmentsForTextChange(change.changeStart, change.changeEnd, change.replacementLength);
const segments = manager.getSegments();
expect(segments).toHaveLength(2);
expect(segments[0]).toMatchObject({start: 0, end: 11});
expect(segments[1]).toMatchObject({start: 21, end: 32});
});
it('should handle complex scenario with emoji and mentions', () => {
manager.insertSegment('Hey ', 4, '@User#0001 ', '<@123> ', 'user', '123');
manager.insertSegment('Hey @User#0001 ', 15, ':smile: ', '<:smile:emoji123> ', 'emoji', 'emoji123');
const text = "Hey @User#0001 :smile: what's up?";
const actualText = manager.displayToActual(text);
expect(actualText).toBe("Hey <@123> <:smile:emoji123> what's up?");
expect(manager.getSegments()).toHaveLength(2);
});
it('should handle realistic message submission flow - insert mention then type more text', () => {
let displayText = 'Hey ';
const {newText: afterMention} = manager.insertSegment(displayText, 4, '@User#0001 ', '<@123> ', 'user', '123');
displayText = afterMention;
expect(displayText).toBe('Hey @User#0001 ');
expect(manager.getSegments()).toHaveLength(1);
const typedMore = 'Hey @User#0001 how are you?';
const change = TextareaSegmentManager.detectChange(displayText, typedMore);
manager.updateSegmentsForTextChange(change.changeStart, change.changeEnd, change.replacementLength);
displayText = typedMore;
const actualContent = manager.displayToActual(displayText);
expect(actualContent).toBe('Hey <@123> how are you?');
});
it('should handle realistic multiple mentions submission flow', () => {
let displayText = '';
const {newText: after1} = manager.insertSegment(displayText, 0, '@User#0001 ', '<@123> ', 'user', '123');
displayText = after1;
expect(displayText).toBe('@User#0001 ');
const withAnd = '@User#0001 and ';
const change1 = TextareaSegmentManager.detectChange(displayText, withAnd);
manager.updateSegmentsForTextChange(change1.changeStart, change1.changeEnd, change1.replacementLength);
displayText = withAnd;
const {newText: after2} = manager.insertSegment(
displayText,
displayText.length,
'@Other#0002 ',
'<@456> ',
'user',
'456',
);
displayText = after2;
expect(displayText).toBe('@User#0001 and @Other#0002 ');
const final = '@User#0001 and @Other#0002 are cool';
const change2 = TextareaSegmentManager.detectChange(displayText, final);
manager.updateSegmentsForTextChange(change2.changeStart, change2.changeEnd, change2.replacementLength);
displayText = final;
const actualContent = manager.displayToActual(displayText);
expect(actualContent).toBe('<@123> and <@456> are cool');
});
it('should handle mention at start of message after trimStart', () => {
const {newText} = manager.insertSegment(' ', 1, '@User#0001 ', '<@123> ', 'user', '123');
expect(newText).toBe(' @User#0001 ');
const trimmed = newText.trimStart();
const trimmedChars = newText.length - trimmed.length;
const segments = manager.getSegments();
const adjusted = segments.map((seg) => ({
...seg,
start: seg.start - trimmedChars,
end: seg.end - trimmedChars,
}));
manager.setSegments(adjusted);
const actualContent = manager.displayToActual(trimmed);
expect(actualContent).toBe('<@123> ');
});
it('should handle editing text before a mention', () => {
manager.insertSegment('Hello ', 6, '@User#0001 ', '<@123> ', 'user', '123');
const oldText = 'Hello @User#0001 ';
const newText = 'Hello there @User#0001 ';
const change = TextareaSegmentManager.detectChange(oldText, newText);
manager.updateSegmentsForTextChange(change.changeStart, change.changeEnd, change.replacementLength);
const segments = manager.getSegments();
expect(segments).toHaveLength(1);
expect(segments[0].start).toBe(12);
expect(segments[0].end).toBe(23);
const actualContent = manager.displayToActual(newText);
expect(actualContent).toBe('Hello there <@123> ');
});
it('should handle editing text after a mention', () => {
manager.insertSegment('', 0, '@User#0001 ', '<@123> ', 'user', '123');
const oldText = '@User#0001 ';
const newText = '@User#0001 hello';
const change = TextareaSegmentManager.detectChange(oldText, newText);
manager.updateSegmentsForTextChange(change.changeStart, change.changeEnd, change.replacementLength);
const segments = manager.getSegments();
expect(segments).toHaveLength(1);
expect(segments[0].start).toBe(0);
expect(segments[0].end).toBe(11);
const actualContent = manager.displayToActual(newText);
expect(actualContent).toBe('<@123> hello');
});
});
describe('displayToActualSubstring', () => {
beforeEach(() => {
manager.insertSegment('Hey ', 4, '@User#0001 ', '<@123> ', 'user', '123');
manager.insertSegment('Hey @User#0001 check ', 21, '#general ', '<#chan1> ', 'channel', 'chan1');
});
it('should convert substring with one segment', () => {
const text = 'Hey @User#0001 check #general and more';
const result = manager.displayToActualSubstring(text, 4, 15);
expect(result).toBe('<@123> ');
});
it('should convert substring with multiple segments', () => {
const text = 'Hey @User#0001 check #general and more';
const result = manager.displayToActualSubstring(text, 4, 30);
expect(result).toBe('<@123> check <#chan1> ');
});
it('should handle substring with no segments', () => {
const text = 'Hey @User#0001 check #general and more';
const result = manager.displayToActualSubstring(text, 0, 4);
expect(result).toBe('Hey ');
});
it('should handle substring that partially overlaps segments', () => {
const text = 'Hey @User#0001 check #general and more';
const result = manager.displayToActualSubstring(text, 8, 20);
expect(result).toBe('r#0001 check');
});
it('should handle full text selection', () => {
const text = 'Hey @User#0001 check #general and more';
const result = manager.displayToActualSubstring(text, 0, text.length);
expect(result).toBe('Hey <@123> check <#chan1> and more');
});
it('should work with consecutive mentions selection', () => {
manager.clear();
manager.insertSegment('', 0, '@User1#0001 ', '<@123> ', 'user', '123');
manager.insertSegment('@User1#0001 ', 12, '@User2#0002 ', '<@456> ', 'user', '456');
const text = '@User1#0001 @User2#0002 text';
const result = manager.displayToActualSubstring(text, 0, 24);
expect(result).toBe('<@123> <@456> ');
});
});
describe('integration scenarios', () => {
it('should handle consecutive mentions insertion via autocomplete', () => {
let displayText = '';
const {newText: after1} = manager.insertSegment(displayText, 0, '@User#0001 ', '<@123> ', 'user', '123');
displayText = after1;
expect(displayText).toBe('@User#0001 ');
expect(manager.getSegments()).toHaveLength(1);
const {newText: after2} = manager.insertSegment(
displayText,
displayText.length,
'@Other#0002 ',
'<@456> ',
'user',
'456',
);
displayText = after2;
expect(displayText).toBe('@User#0001 @Other#0002 ');
const segments = manager.getSegments();
expect(segments).toHaveLength(2);
expect(segments[0]).toMatchObject({
id: '123',
start: 0,
end: 11,
});
expect(segments[1]).toMatchObject({
id: '456',
start: 11,
end: 23,
});
const actualContent = manager.displayToActual(displayText);
expect(actualContent).toBe('<@123> <@456> ');
});
it('should handle three consecutive mentions', () => {
let displayText = '';
const {newText: after1} = manager.insertSegment(displayText, 0, '@User1#0001 ', '<@123> ', 'user', '123');
displayText = after1;
const {newText: after2} = manager.insertSegment(
displayText,
displayText.length,
'@User2#0002 ',
'<@456> ',
'user',
'456',
);
displayText = after2;
const {newText: after3} = manager.insertSegment(
displayText,
displayText.length,
'@User3#0003 ',
'<@789> ',
'user',
'789',
);
displayText = after3;
expect(displayText).toBe('@User1#0001 @User2#0002 @User3#0003 ');
const segments = manager.getSegments();
expect(segments).toHaveLength(3);
const actualContent = manager.displayToActual(displayText);
expect(actualContent).toBe('<@123> <@456> <@789> ');
});
});
});

View File

@@ -0,0 +1,148 @@
/*
* 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/>.
*/
export interface MentionSegment {
type: 'user' | 'role' | 'channel' | 'emoji' | 'special';
id: string;
displayText: string;
actualText: string;
start: number;
end: number;
}
export class TextareaSegmentManager {
private segments: Array<MentionSegment> = [];
getSegments(): Array<MentionSegment> {
return [...this.segments];
}
setSegments(segments: Array<MentionSegment>): void {
this.segments = [...segments];
}
clear(): void {
this.segments = [];
}
displayToActual(displayText: string): string {
let result = displayText;
const sortedSegments = [...this.segments].sort((a, b) => b.start - a.start);
for (const segment of sortedSegments) {
result = result.slice(0, segment.start) + segment.actualText + result.slice(segment.end);
}
return result;
}
displayToActualSubstring(displayText: string, start: number, end: number): string {
const relevantSegments = this.segments
.filter((segment) => segment.start >= start && segment.end <= end)
.sort((a, b) => b.start - a.start);
let result = displayText.slice(start, end);
for (const segment of relevantSegments) {
const relativeStart = segment.start - start;
const relativeEnd = segment.end - start;
result = result.slice(0, relativeStart) + segment.actualText + result.slice(relativeEnd);
}
return result;
}
updateSegmentsForTextChange(
changeStart: number,
changeEnd: number,
replacementLength: number,
): Array<MentionSegment> {
const lengthDelta = replacementLength - (changeEnd - changeStart);
const updated = this.segments
.map((segment) => {
if (segment.end <= changeStart) {
return segment;
} else if (segment.start >= changeEnd) {
return {...segment, start: segment.start + lengthDelta, end: segment.end + lengthDelta};
} else {
return null;
}
})
.filter((s): s is MentionSegment => s !== null);
this.segments = updated;
return updated;
}
insertSegment(
currentText: string,
insertPosition: number,
displayText: string,
actualText: string,
type: MentionSegment['type'],
id: string,
): {newText: string; newSegments: Array<MentionSegment>} {
const newText = currentText.slice(0, insertPosition) + displayText + currentText.slice(insertPosition);
const newSegment: MentionSegment = {
type,
id,
displayText,
actualText,
start: insertPosition,
end: insertPosition + displayText.length,
};
const updatedSegments = this.segments.map((segment) => {
if (segment.start >= insertPosition) {
return {
...segment,
start: segment.start + displayText.length,
end: segment.end + displayText.length,
};
}
return segment;
});
this.segments = [...updatedSegments, newSegment];
return {newText, newSegments: this.segments};
}
static detectChange(
oldText: string,
newText: string,
): {changeStart: number; changeEnd: number; replacementLength: number} {
let changeStart = 0;
while (
changeStart < oldText.length &&
changeStart < newText.length &&
oldText[changeStart] === newText[changeStart]
) {
changeStart++;
}
let oldEnd = oldText.length;
let newEnd = newText.length;
while (oldEnd > changeStart && newEnd > changeStart && oldText[oldEnd - 1] === newText[newEnd - 1]) {
oldEnd--;
newEnd--;
}
const replacementLength = newEnd - changeStart;
return {changeStart, changeEnd: oldEnd, replacementLength};
}
}

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 {beforeEach, describe, expect, it} from 'vitest';
import {TextareaStateManager} from './TextareaStateManager';
describe('TextareaStateManager', () => {
let manager: TextareaStateManager;
beforeEach(() => {
manager = new TextareaStateManager();
});
describe('basic state management', () => {
it('should start with empty text', () => {
expect(manager.getText()).toBe('');
});
it('should update text', () => {
manager.setText('Hello world');
expect(manager.getText()).toBe('Hello world');
});
it('should track cursor position', () => {
manager.setCursorPosition(5);
expect(manager.getCursorPosition()).toBe(5);
});
it('should clear all state', () => {
manager.setText('Hello');
manager.setCursorPosition(3);
manager.clear();
expect(manager.getText()).toBe('');
expect(manager.getCursorPosition()).toBe(0);
});
});
describe('text up to cursor', () => {
it('should return text up to cursor position', () => {
manager.setText('Hello world');
manager.setCursorPosition(5);
expect(manager.getTextUpToCursor()).toBe('Hello');
});
it('should return empty string when cursor is at start', () => {
manager.setText('Hello world');
manager.setCursorPosition(0);
expect(manager.getTextUpToCursor()).toBe('');
});
it('should return full text when cursor is at end', () => {
manager.setText('Hello world');
manager.setCursorPosition(11);
expect(manager.getTextUpToCursor()).toBe('Hello world');
});
});
describe('autocomplete detection', () => {
describe('mention detection', () => {
it('should detect mention at start', () => {
manager.setText('@user');
manager.setCursorPosition(5);
const match = manager.detectAutocompleteMatch();
expect(match).toEqual({
type: 'mention',
query: 'user',
matchStart: 0,
matchEnd: 5,
});
});
it('should detect mention after space', () => {
manager.setText('Hello @user');
manager.setCursorPosition(11);
const match = manager.detectAutocompleteMatch();
expect(match).toEqual({
type: 'mention',
query: 'user',
matchStart: 5,
matchEnd: 11,
});
});
it('should detect partial mention', () => {
manager.setText('Hello @u');
manager.setCursorPosition(8);
const match = manager.detectAutocompleteMatch();
expect(match).toEqual({
type: 'mention',
query: 'u',
matchStart: 5,
matchEnd: 8,
});
});
it('should not detect mention in middle of word', () => {
manager.setText('email@user');
manager.setCursorPosition(10);
const match = manager.detectAutocompleteMatch();
expect(match).toBeNull();
});
});
describe('channel detection', () => {
it('should detect channel mention', () => {
manager.setText('#general');
manager.setCursorPosition(8);
const match = manager.detectAutocompleteMatch();
expect(match).toEqual({
type: 'channel',
query: 'general',
matchStart: 0,
matchEnd: 8,
});
});
it('should detect partial channel', () => {
manager.setText('Join #gen');
manager.setCursorPosition(9);
const match = manager.detectAutocompleteMatch();
expect(match).toEqual({
type: 'channel',
query: 'gen',
matchStart: 4,
matchEnd: 9,
});
});
});
describe('emoji detection', () => {
it('should detect emoji', () => {
manager.setText(':smile');
manager.setCursorPosition(6);
const match = manager.detectAutocompleteMatch();
expect(match).toEqual({
type: 'emoji',
query: 'smile',
matchStart: 0,
matchEnd: 6,
});
});
it('should detect emoji with minimum 2 chars', () => {
manager.setText(':sm');
manager.setCursorPosition(3);
const match = manager.detectAutocompleteMatch();
expect(match).toEqual({
type: 'emoji',
query: 'sm',
matchStart: 0,
matchEnd: 3,
});
});
it('should not detect emoji with 1 char', () => {
manager.setText(':s');
manager.setCursorPosition(2);
const match = manager.detectAutocompleteMatch();
expect(match).toBeNull();
});
});
describe('command detection', () => {
it('should detect command at start', () => {
manager.setText('/');
manager.setCursorPosition(1);
const match = manager.detectAutocompleteMatch();
expect(match).toEqual({
type: 'command',
query: '',
matchStart: 0,
matchEnd: 1,
});
});
it('should detect command after space', () => {
manager.setText('Hello /');
manager.setCursorPosition(7);
const match = manager.detectAutocompleteMatch();
expect(match).toEqual({
type: 'command',
query: '',
matchStart: 5,
matchEnd: 7,
});
});
});
describe('priority', () => {
it('should prioritize mention over others', () => {
manager.setText('@user #channel :emoji');
manager.setCursorPosition(5);
const match = manager.detectAutocompleteMatch();
expect(match?.type).toBe('mention');
});
});
});
describe('insertAutocompleteSegment', () => {
it('should insert mention segment', () => {
manager.setText('Hello @u');
manager.setCursorPosition(8);
const match = manager.detectAutocompleteMatch()!;
const result = manager.insertAutocompleteSegment(match, '@User#0001', '<@123>', 'user', '123');
expect(result.newText).toBe('Hello @User#0001 ');
expect(manager.getText()).toBe('Hello @User#0001 ');
expect(manager.getSegmentManager().getSegments()).toHaveLength(1);
});
it('should add space before mention if needed', () => {
manager.setText('Hello @u');
manager.setCursorPosition(8);
const match = manager.detectAutocompleteMatch()!;
const result = manager.insertAutocompleteSegment(match, '@User#0001', '<@123>', 'user', '123');
expect(result.newText).toBe('Hello @User#0001 ');
});
it('should preserve text after match', () => {
manager.setText('Hello @u world');
manager.setCursorPosition(8);
const match = manager.detectAutocompleteMatch()!;
const result = manager.insertAutocompleteSegment(match, '@User#0001', '<@123>', 'user', '123');
expect(result.newText).toBe('Hello @User#0001 world');
});
it('should update cursor position correctly', () => {
manager.setText('Hello @u');
manager.setCursorPosition(8);
const match = manager.detectAutocompleteMatch()!;
const result = manager.insertAutocompleteSegment(match, '@User#0001', '<@123>', 'user', '123');
expect(result.newCursorPosition).toBe(17);
});
});
describe('insertPlainText', () => {
it('should insert command without segment', () => {
manager.setText('/');
manager.setCursorPosition(1);
const match = manager.detectAutocompleteMatch()!;
const result = manager.insertPlainText(match, '¯\\_(ツ)_/¯');
expect(result.newText).toBe('¯\\_(ツ)_/¯ ');
expect(manager.getSegmentManager().getSegments()).toHaveLength(0);
});
it('should add space before text if needed', () => {
manager.setText('Hello /');
manager.setCursorPosition(7);
const match = manager.detectAutocompleteMatch()!;
const result = manager.insertPlainText(match, 'shrug');
expect(result.newText).toBe('Hello shrug ');
});
});
describe('insertSegmentAtCursor', () => {
it('should insert segment at end of text', () => {
manager.setText('Hello ');
const result = manager.insertSegmentAtCursor('@User#0001', '<@123>', 'user', '123');
expect(result).toBe('Hello @User#0001');
expect(manager.getSegmentManager().getSegments()).toHaveLength(1);
});
it('should add space if text does not end with space', () => {
manager.setText('Hello');
const result = manager.insertSegmentAtCursor('@User#0001', '<@123>', 'user', '123');
expect(result).toBe('Hello @User#0001');
});
it('should work on empty text', () => {
const result = manager.insertSegmentAtCursor('@User#0001', '<@123>', 'user', '123');
expect(result).toBe('@User#0001');
});
});
describe('getActualContent', () => {
it('should convert display text to actual content', () => {
manager.setText('Hello ');
manager.insertSegmentAtCursor('@User#0001', '<@123>', 'user', '123');
const actual = manager.getActualContent();
expect(actual).toBe('Hello <@123>');
});
it('should handle multiple segments', () => {
manager.setText('');
manager.insertSegmentAtCursor('@User#0001', '<@123>', 'user', '123');
const newText = `${manager.getText()} and `;
manager.handleTextChange(newText);
manager.insertSegmentAtCursor('@Other#0002', '<@456>', 'user', '456');
const actual = manager.getActualContent();
expect(actual).toBe('<@123> and <@456>');
});
});
describe('hasOpenCodeBlock', () => {
it('should detect open code block', () => {
manager.setText('```\ncode');
manager.setCursorPosition(8);
expect(manager.hasOpenCodeBlock()).toBe(true);
});
it('should detect closed code block', () => {
manager.setText('```\ncode\n```');
manager.setCursorPosition(13);
expect(manager.hasOpenCodeBlock()).toBe(false);
});
it('should handle no code blocks', () => {
manager.setText('normal text');
manager.setCursorPosition(11);
expect(manager.hasOpenCodeBlock()).toBe(false);
});
it('should handle multiple open/close blocks', () => {
manager.setText('```\ncode\n``` ```\nmore');
manager.setCursorPosition(19);
expect(manager.hasOpenCodeBlock()).toBe(true);
});
});
describe('handleTextChange', () => {
it('should update text and adjust segments', () => {
manager.setText('Hello ');
manager.insertSegmentAtCursor('@User#0001', '<@123>', 'user', '123');
manager.handleTextChange('Hello @User#0001 world');
expect(manager.getText()).toBe('Hello @User#0001 world');
const segments = manager.getSegmentManager().getSegments();
expect(segments).toHaveLength(1);
expect(segments[0].start).toBe(6);
expect(segments[0].end).toBe(16);
});
it('should remove segment when edited', () => {
manager.setText('Hello ');
manager.insertSegmentAtCursor('@User#0001', '<@123>', 'user', '123');
manager.handleTextChange('Hello @User');
const segments = manager.getSegmentManager().getSegments();
expect(segments).toHaveLength(0);
});
});
describe('integration scenarios', () => {
it('should handle complete autocomplete flow', () => {
manager.setText('@u');
manager.setCursorPosition(2);
const match = manager.detectAutocompleteMatch();
expect(match?.type).toBe('mention');
manager.insertAutocompleteSegment(match!, '@User#0001', '<@123>', 'user', '123');
expect(manager.getText()).toBe('@User#0001 ');
expect(manager.getActualContent()).toBe('<@123> ');
});
it('should handle typing after autocomplete', () => {
manager.setText('');
manager.insertSegmentAtCursor('@User#0001', '<@123>', 'user', '123');
manager.handleTextChange('@User#0001 how are you?');
expect(manager.getText()).toBe('@User#0001 how are you?');
expect(manager.getActualContent()).toBe('<@123> how are you?');
});
it('should handle multiple autocompletes', () => {
manager.setText('Hey @u');
manager.setCursorPosition(6);
let match = manager.detectAutocompleteMatch()!;
manager.insertAutocompleteSegment(match, '@User#0001', '<@123>', 'user', '123');
const newText = 'Hey @User#0001 check :smi';
manager.handleTextChange(newText);
manager.setCursorPosition(newText.length);
match = manager.detectAutocompleteMatch()!;
expect(match.type).toBe('emoji');
manager.insertAutocompleteSegment(match, ':smile:', '<:smile:emoji123>', 'emoji', 'emoji123');
expect(manager.getActualContent()).toBe('Hey <@123> check <:smile:emoji123> ');
});
});
});

View File

@@ -0,0 +1,209 @@
/*
* 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 MentionSegment, TextareaSegmentManager} from './TextareaSegmentManager';
interface AutocompleteMatch {
type: 'mention' | 'channel' | 'emoji' | 'command';
query: string;
matchStart: number;
matchEnd: number;
}
const MENTION_REGEX = /(^|\s)@(\S*)$/;
const CHANNEL_REGEX = /(^|\s)#(\S*)$/;
const EMOJI_REGEX = /(^|\s):([^\s]{2,})$/;
const COMMAND_REGEX = /(^|\s)\/$/;
export class TextareaStateManager {
private segmentManager: TextareaSegmentManager;
private text: string = '';
private cursorPosition: number = 0;
constructor() {
this.segmentManager = new TextareaSegmentManager();
}
getText(): string {
return this.text;
}
setText(text: string): void {
this.text = text;
}
getCursorPosition(): number {
return this.cursorPosition;
}
setCursorPosition(position: number): void {
this.cursorPosition = position;
}
getSegmentManager(): TextareaSegmentManager {
return this.segmentManager;
}
clear(): void {
this.text = '';
this.cursorPosition = 0;
this.segmentManager.clear();
}
handleTextChange(newText: string): void {
const {changeStart, changeEnd, replacementLength} = TextareaSegmentManager.detectChange(this.text, newText);
this.segmentManager.updateSegmentsForTextChange(changeStart, changeEnd, replacementLength);
this.text = newText;
}
getTextUpToCursor(): string {
return this.text.slice(0, this.cursorPosition);
}
detectAutocompleteMatch(): AutocompleteMatch | null {
const textUpToCursor = this.getTextUpToCursor();
const mentionMatch = textUpToCursor.match(MENTION_REGEX);
if (mentionMatch) {
return {
type: 'mention',
query: mentionMatch[2],
matchStart: mentionMatch.index ?? 0,
matchEnd: (mentionMatch.index ?? 0) + mentionMatch[0].length,
};
}
const channelMatch = textUpToCursor.match(CHANNEL_REGEX);
if (channelMatch) {
return {
type: 'channel',
query: channelMatch[2],
matchStart: channelMatch.index ?? 0,
matchEnd: (channelMatch.index ?? 0) + channelMatch[0].length,
};
}
const emojiMatch = textUpToCursor.match(EMOJI_REGEX);
if (emojiMatch) {
return {
type: 'emoji',
query: emojiMatch[2],
matchStart: emojiMatch.index ?? 0,
matchEnd: (emojiMatch.index ?? 0) + emojiMatch[0].length,
};
}
const commandMatch = textUpToCursor.match(COMMAND_REGEX);
if (commandMatch) {
return {
type: 'command',
query: '',
matchStart: commandMatch.index ?? 0,
matchEnd: (commandMatch.index ?? 0) + commandMatch[0].length,
};
}
return null;
}
insertAutocompleteSegment(
match: AutocompleteMatch,
displayText: string,
actualText: string,
segmentType: MentionSegment['type'],
segmentId: string,
): {newText: string; newCursorPosition: number} {
const beforeMatch = this.text.slice(0, match.matchStart);
const afterMatch = this.text.slice(match.matchEnd);
const needsSpaceBefore = !beforeMatch.endsWith(' ') && beforeMatch.length > 0;
const prefix = needsSpaceBefore ? ' ' : '';
const insertPosition = match.matchStart + prefix.length;
this.segmentManager.updateSegmentsForTextChange(match.matchStart, match.matchEnd, prefix.length);
const tempText = beforeMatch + prefix;
const {newText: updatedText} = this.segmentManager.insertSegment(
tempText,
insertPosition,
`${displayText} `,
`${actualText} `,
segmentType,
segmentId,
);
const finalText = (updatedText + afterMatch).trimStart();
const newCursorPosition = beforeMatch.length + prefix.length + displayText.length + 1;
this.text = finalText;
this.cursorPosition = newCursorPosition;
return {newText: finalText, newCursorPosition};
}
insertPlainText(match: AutocompleteMatch, text: string): {newText: string; newCursorPosition: number} {
const beforeMatch = this.text.slice(0, match.matchStart);
const afterMatch = this.text.slice(match.matchEnd);
const needsSpaceBefore = !beforeMatch.endsWith(' ') && beforeMatch.length > 0;
const prefix = needsSpaceBefore ? ' ' : '';
const newText = `${beforeMatch + prefix + text} ${afterMatch}`.trimStart();
const newCursorPosition = beforeMatch.length + prefix.length + text.length + 1;
this.text = newText;
this.cursorPosition = newCursorPosition;
return {newText, newCursorPosition};
}
insertSegmentAtCursor(
displayText: string,
actualText: string,
segmentType: MentionSegment['type'],
segmentId: string,
): string {
const needsSpace = this.text.length > 0 && !this.text.endsWith(' ');
const prefix = this.text.length === 0 ? '' : needsSpace ? ' ' : '';
const insertPosition = this.text.length + prefix.length;
const {newText} = this.segmentManager.insertSegment(
this.text + prefix,
insertPosition,
displayText,
actualText,
segmentType,
segmentId,
);
this.text = newText;
return newText;
}
getActualContent(): string {
return this.segmentManager.displayToActual(this.text);
}
hasOpenCodeBlock(): boolean {
const textUpToCursor = this.getTextUpToCursor();
const match = textUpToCursor.match(/```/g);
return match != null && match.length > 0 && match.length % 2 !== 0;
}
}

View File

@@ -0,0 +1,82 @@
/*
* 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 RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import * as RegexUtils from '~/utils/RegexUtils';
const THEME_ID_REGEX = '[a-zA-Z0-9-]{2,32}';
const normalizeEndpoint = (endpoint: string | null | undefined): string | null => {
if (!endpoint) return null;
const trimmed = endpoint.replace(/\/$/, '');
return trimmed || null;
};
const buildThemePrefixes = (): Array<string> => {
const prefixes = new Set<string>();
const webApp = normalizeEndpoint(RuntimeConfigStore.webAppBaseUrl);
if (webApp) {
prefixes.add(`${webApp}/theme/`);
}
const marketingEndpoint = normalizeEndpoint(RuntimeConfigStore.marketingEndpoint);
if (marketingEndpoint) {
prefixes.add(`${marketingEndpoint}/theme/`);
}
return Array.from(prefixes);
};
const createThemeRegex = (): RegExp | null => {
const prefixes = buildThemePrefixes();
if (prefixes.length === 0) return null;
const escapedPrefix = prefixes.map((prefix) => RegexUtils.escapeRegex(prefix)).join('|');
return new RegExp(`(?:${escapedPrefix})(${THEME_ID_REGEX})(?![a-zA-Z0-9-])`, 'gi');
};
const matchThemes = (content: string | null, maxMatches = 1): Array<string> => {
const regex = createThemeRegex();
if (!regex || !content) return [];
const codes: Array<string> = [];
const seen = new Set<string>();
regex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null && codes.length < maxMatches) {
const code = match[1];
if (code && !seen.has(code)) {
seen.add(code);
codes.push(code);
}
}
return codes;
};
export function findThemes(content: string | null): Array<string> {
return matchThemes(content, 10);
}
export function findTheme(content: string | null): string | null {
const matches = matchThemes(content, 1);
return matches[0] ?? null;
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {DateTime} from 'luxon';
export function formatShortRelativeTime(timestamp: number): string {
const date = DateTime.fromMillis(timestamp);
const now = DateTime.now();
const diff = now.diff(date);
const minutes = diff.as('minutes');
const hours = diff.as('hours');
const days = diff.as('days');
const weeks = diff.as('weeks');
const months = diff.as('months');
const years = diff.as('years');
if (minutes < 1) {
return '1m';
}
if (minutes < 60) {
return `${Math.floor(minutes)}m`;
}
if (hours < 24) {
return `${Math.floor(hours)}h`;
}
if (days < 7) {
return `${Math.floor(days)}d`;
}
if (weeks < 4) {
return `${Math.floor(weeks)}w`;
}
if (months < 12) {
return `${Math.floor(months)}mo`;
}
return `${Math.floor(years)}y`;
}

Some files were not shown because too many files have changed in this diff Show More