initial commit
This commit is contained in:
31
fluxer_app/src/utils/ApiErrorUtils.ts
Normal file
31
fluxer_app/src/utils/ApiErrorUtils.ts
Normal 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;
|
||||
}
|
||||
80
fluxer_app/src/utils/ApiProxyUtils.test.ts
Normal file
80
fluxer_app/src/utils/ApiProxyUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
90
fluxer_app/src/utils/ApiProxyUtils.ts
Normal file
90
fluxer_app/src/utils/ApiProxyUtils.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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();
|
||||
}
|
||||
136
fluxer_app/src/utils/AttachmentExpiryUtils.ts
Normal file
136
fluxer_app/src/utils/AttachmentExpiryUtils.ts
Normal 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);
|
||||
}
|
||||
28
fluxer_app/src/utils/AttachmentUtils.tsx
Normal file
28
fluxer_app/src/utils/AttachmentUtils.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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;
|
||||
}
|
||||
77
fluxer_app/src/utils/AutostartUtils.ts
Normal file
77
fluxer_app/src/utils/AutostartUtils.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
331
fluxer_app/src/utils/AvatarUtils.tsx
Normal file
331
fluxer_app/src/utils/AvatarUtils.tsx
Normal 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);
|
||||
});
|
||||
121
fluxer_app/src/utils/BackgroundImageDB.ts
Normal file
121
fluxer_app/src/utils/BackgroundImageDB.ts
Normal 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);
|
||||
};
|
||||
99
fluxer_app/src/utils/CSSHighlightSearch.ts
Normal file
99
fluxer_app/src/utils/CSSHighlightSearch.ts
Normal 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);
|
||||
}
|
||||
38
fluxer_app/src/utils/CallUtils.tsx
Normal file
38
fluxer_app/src/utils/CallUtils.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
91
fluxer_app/src/utils/ChannelSearchHighlight.ts
Normal file
91
fluxer_app/src/utils/ChannelSearchHighlight.ts
Normal 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);
|
||||
}
|
||||
113
fluxer_app/src/utils/ChannelUtils.tsx
Normal file
113
fluxer_app/src/utils/ChannelUtils.tsx
Normal 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`);
|
||||
}
|
||||
};
|
||||
178
fluxer_app/src/utils/ClientInfoUtils.ts
Normal file
178
fluxer_app/src/utils/ClientInfoUtils.ts
Normal 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} : {}),
|
||||
};
|
||||
};
|
||||
86
fluxer_app/src/utils/CodeLinkUtils.tsx
Normal file
86
fluxer_app/src/utils/CodeLinkUtils.tsx
Normal 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;
|
||||
}
|
||||
71
fluxer_app/src/utils/ColorUtils.tsx
Normal file
71
fluxer_app/src/utils/ColorUtils.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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';
|
||||
};
|
||||
191
fluxer_app/src/utils/CommandUtils.test.ts
Normal file
191
fluxer_app/src/utils/CommandUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
281
fluxer_app/src/utils/CommandUtils.tsx
Normal file
281
fluxer_app/src/utils/CommandUtils.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
175
fluxer_app/src/utils/ContextMenuUtils.tsx
Normal file
175
fluxer_app/src/utils/ContextMenuUtils.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
272
fluxer_app/src/utils/CustomSoundDB.ts
Normal file
272
fluxer_app/src/utils/CustomSoundDB.ts
Normal 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'));
|
||||
};
|
||||
});
|
||||
};
|
||||
176
fluxer_app/src/utils/DateUtils.tsx
Normal file
176
fluxer_app/src/utils/DateUtils.tsx
Normal 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 ?? '';
|
||||
};
|
||||
109
fluxer_app/src/utils/DeepLinkUtils.test.ts
Normal file
109
fluxer_app/src/utils/DeepLinkUtils.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
281
fluxer_app/src/utils/DeepLinkUtils.ts
Normal file
281
fluxer_app/src/utils/DeepLinkUtils.ts
Normal 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'}`,
|
||||
);
|
||||
};
|
||||
142
fluxer_app/src/utils/DesktopRpcClient.ts
Normal file
142
fluxer_app/src/utils/DesktopRpcClient.ts
Normal 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;
|
||||
};
|
||||
176
fluxer_app/src/utils/DimensionUtils.tsx
Normal file
176
fluxer_app/src/utils/DimensionUtils.tsx
Normal 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);
|
||||
};
|
||||
51
fluxer_app/src/utils/EmojiUtils.tsx
Normal file
51
fluxer_app/src/utils/EmojiUtils.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {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;
|
||||
252
fluxer_app/src/utils/ExpressionPermissionUtils.tsx
Normal file
252
fluxer_app/src/utils/ExpressionPermissionUtils.tsx
Normal 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;
|
||||
}
|
||||
146
fluxer_app/src/utils/FavoriteMemeUtils.ts
Normal file
146
fluxer_app/src/utils/FavoriteMemeUtils.ts
Normal 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;
|
||||
}
|
||||
31
fluxer_app/src/utils/FileDownloadUtils.ts
Normal file
31
fluxer_app/src/utils/FileDownloadUtils.ts
Normal 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);
|
||||
};
|
||||
43
fluxer_app/src/utils/FilePickerUtils.ts
Normal file
43
fluxer_app/src/utils/FilePickerUtils.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
45
fluxer_app/src/utils/FileUploadUtils.ts
Normal file
45
fluxer_app/src/utils/FileUploadUtils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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};
|
||||
}
|
||||
26
fluxer_app/src/utils/FileUtils.ts
Normal file
26
fluxer_app/src/utils/FileUtils.ts
Normal 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]}`;
|
||||
};
|
||||
69
fluxer_app/src/utils/FormUtils.tsx
Normal file
69
fluxer_app/src/utils/FormUtils.tsx
Normal 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.`),
|
||||
});
|
||||
};
|
||||
102
fluxer_app/src/utils/GeoUtils.ts
Normal file
102
fluxer_app/src/utils/GeoUtils.ts
Normal 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;
|
||||
};
|
||||
36
fluxer_app/src/utils/GroupDMColorUtils.ts
Normal file
36
fluxer_app/src/utils/GroupDMColorUtils.ts
Normal 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]!;
|
||||
};
|
||||
25
fluxer_app/src/utils/GuildInitialsUtils.ts
Normal file
25
fluxer_app/src/utils/GuildInitialsUtils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const getInitialsLength = (initials: string): 'short' | 'medium' | 'long' => {
|
||||
const length = initials.length;
|
||||
if (length <= 2) return 'short';
|
||||
if (length <= 4) return 'medium';
|
||||
return 'long';
|
||||
};
|
||||
26
fluxer_app/src/utils/HelpCenterUtils.tsx
Normal file
26
fluxer_app/src/utils/HelpCenterUtils.tsx
Normal 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}`;
|
||||
}
|
||||
63
fluxer_app/src/utils/ImageCacheUtils.tsx
Normal file
63
fluxer_app/src/utils/ImageCacheUtils.tsx
Normal 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;
|
||||
};
|
||||
103
fluxer_app/src/utils/ImageCropUtils.ts
Normal file
103
fluxer_app/src/utils/ImageCropUtils.ts
Normal 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);
|
||||
}
|
||||
107
fluxer_app/src/utils/InviteUtils.tsx
Normal file
107
fluxer_app/src/utils/InviteUtils.tsx
Normal 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}`;
|
||||
}
|
||||
50
fluxer_app/src/utils/KeybindUtils.ts
Normal file
50
fluxer_app/src/utils/KeybindUtils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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(' + ');
|
||||
};
|
||||
28
fluxer_app/src/utils/KeyboardUtils.ts
Normal file
28
fluxer_app/src/utils/KeyboardUtils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
export const SHIFT_KEY_SYMBOL = '⇧';
|
||||
|
||||
export function stopPropagationOnEnterSpace(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
107
fluxer_app/src/utils/LocaleUtils.tsx
Normal file
107
fluxer_app/src/utils/LocaleUtils.tsx
Normal 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));
|
||||
};
|
||||
145
fluxer_app/src/utils/MarkdownToSegmentUtils.ts
Normal file
145
fluxer_app/src/utils/MarkdownToSegmentUtils.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
}
|
||||
43
fluxer_app/src/utils/MediaDeviceRefresh.ts
Normal file
43
fluxer_app/src/utils/MediaDeviceRefresh.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import {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);
|
||||
}
|
||||
};
|
||||
56
fluxer_app/src/utils/MediaDimensionConfig.tsx
Normal file
56
fluxer_app/src/utils/MediaDimensionConfig.tsx
Normal 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);
|
||||
};
|
||||
105
fluxer_app/src/utils/MediaProxyUtils.test.ts
Normal file
105
fluxer_app/src/utils/MediaProxyUtils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
167
fluxer_app/src/utils/MediaProxyUtils.ts
Normal file
167
fluxer_app/src/utils/MediaProxyUtils.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
193
fluxer_app/src/utils/MemberListUtils.ts
Normal file
193
fluxer_app/src/utils/MemberListUtils.ts
Normal 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);
|
||||
}
|
||||
55
fluxer_app/src/utils/MessageAttachmentUtils.ts
Normal file
55
fluxer_app/src/utils/MessageAttachmentUtils.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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,
|
||||
}));
|
||||
66
fluxer_app/src/utils/MessageComponentUtils.tsx
Normal file
66
fluxer_app/src/utils/MessageComponentUtils.tsx
Normal 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 />;
|
||||
}
|
||||
};
|
||||
246
fluxer_app/src/utils/MessageGroupingUtils.tsx
Normal file
246
fluxer_app/src/utils/MessageGroupingUtils.tsx
Normal 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;
|
||||
}
|
||||
76
fluxer_app/src/utils/MessageNavigator.ts
Normal file
76
fluxer_app/src/utils/MessageNavigator.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * 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};
|
||||
};
|
||||
160
fluxer_app/src/utils/MessageRequestUtils.ts
Normal file
160
fluxer_app/src/utils/MessageRequestUtils.ts
Normal 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;
|
||||
};
|
||||
124
fluxer_app/src/utils/MessageSubmitUtils.ts
Normal file
124
fluxer_app/src/utils/MessageSubmitUtils.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
53
fluxer_app/src/utils/MessageUtils.tsx
Normal file
53
fluxer_app/src/utils/MessageUtils.tsx
Normal 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));
|
||||
};
|
||||
48
fluxer_app/src/utils/MfaUtils.tsx
Normal file
48
fluxer_app/src/utils/MfaUtils.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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)}`;
|
||||
96
fluxer_app/src/utils/MobileNavigation.ts
Normal file
96
fluxer_app/src/utils/MobileNavigation.ts
Normal 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;
|
||||
}
|
||||
161
fluxer_app/src/utils/NativePermissions.ts
Normal file
161
fluxer_app/src/utils/NativePermissions.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
141
fluxer_app/src/utils/NativeUtils.ts
Normal file
141
fluxer_app/src/utils/NativeUtils.ts
Normal 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;
|
||||
};
|
||||
48
fluxer_app/src/utils/NicknameUtils.tsx
Normal file
48
fluxer_app/src/utils/NicknameUtils.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
};
|
||||
189
fluxer_app/src/utils/NotificationUtils.tsx
Normal file
189
fluxer_app/src/utils/NotificationUtils.tsx
Normal 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);
|
||||
}
|
||||
};
|
||||
117
fluxer_app/src/utils/PasteSegmentUtils.ts
Normal file
117
fluxer_app/src/utils/PasteSegmentUtils.ts
Normal 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);
|
||||
}
|
||||
570
fluxer_app/src/utils/PermissionUtils.tsx
Normal file
570
fluxer_app/src/utils/PermissionUtils.tsx
Normal 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();
|
||||
}
|
||||
48
fluxer_app/src/utils/PlaceholderUtils.ts
Normal file
48
fluxer_app/src/utils/PlaceholderUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
24
fluxer_app/src/utils/PremiumUtils.tsx
Normal file
24
fluxer_app/src/utils/PremiumUtils.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
||||
|
||||
export function shouldShowPremiumFeatures(): boolean {
|
||||
return !RuntimeConfigStore.isSelfHosted();
|
||||
}
|
||||
105
fluxer_app/src/utils/PricingUtils.ts
Normal file
105
fluxer_app/src/utils/PricingUtils.ts
Normal 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);
|
||||
}
|
||||
139
fluxer_app/src/utils/ProfileDisplayUtils.ts
Normal file
139
fluxer_app/src/utils/ProfileDisplayUtils.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
124
fluxer_app/src/utils/ProfileUtils.ts
Normal file
124
fluxer_app/src/utils/ProfileUtils.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
40
fluxer_app/src/utils/PwaUtils.ts
Normal file
40
fluxer_app/src/utils/PwaUtils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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();
|
||||
}
|
||||
139
fluxer_app/src/utils/ReactionUtils.tsx
Normal file
139
fluxer_app/src/utils/ReactionUtils.tsx
Normal 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`;
|
||||
};
|
||||
20
fluxer_app/src/utils/RegexUtils.tsx
Normal file
20
fluxer_app/src/utils/RegexUtils.tsx
Normal 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, '\\$&');
|
||||
187
fluxer_app/src/utils/RelationshipActionUtils.tsx
Normal file
187
fluxer_app/src/utils/RelationshipActionUtils.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
}
|
||||
}
|
||||
49
fluxer_app/src/utils/ReplaceCommandUtils.ts
Normal file
49
fluxer_app/src/utils/ReplaceCommandUtils.ts
Normal 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);
|
||||
}
|
||||
45
fluxer_app/src/utils/RouterUtils.tsx
Normal file
45
fluxer_app/src/utils/RouterUtils.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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;
|
||||
62
fluxer_app/src/utils/ScreenShareUtils.tsx
Normal file
62
fluxer_app/src/utils/ScreenShareUtils.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
71
fluxer_app/src/utils/ScrollbarDragState.ts
Normal file
71
fluxer_app/src/utils/ScrollbarDragState.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
562
fluxer_app/src/utils/SearchQueryParser.ts
Normal file
562
fluxer_app/src/utils/SearchQueryParser.ts
Normal 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;
|
||||
};
|
||||
161
fluxer_app/src/utils/SearchQueryTokenizer.ts
Normal file
161
fluxer_app/src/utils/SearchQueryTokenizer.ts
Normal 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;
|
||||
};
|
||||
169
fluxer_app/src/utils/SearchSegmentManager.ts
Normal file
169
fluxer_app/src/utils/SearchSegmentManager.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
402
fluxer_app/src/utils/SearchUtils.ts
Normal file
402
fluxer_app/src/utils/SearchUtils.ts
Normal 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);
|
||||
};
|
||||
157
fluxer_app/src/utils/SelectUtils.tsx
Normal file
157
fluxer_app/src/utils/SelectUtils.tsx
Normal 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',
|
||||
}),
|
||||
});
|
||||
243
fluxer_app/src/utils/SlashCommandUtils.test.ts
Normal file
243
fluxer_app/src/utils/SlashCommandUtils.test.ts
Normal 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 ');
|
||||
});
|
||||
});
|
||||
});
|
||||
163
fluxer_app/src/utils/SlashCommandUtils.ts
Normal file
163
fluxer_app/src/utils/SlashCommandUtils.ts
Normal 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} `;
|
||||
}
|
||||
79
fluxer_app/src/utils/SnowflakeUtil.tsx
Normal file
79
fluxer_app/src/utils/SnowflakeUtil.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
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;
|
||||
61
fluxer_app/src/utils/SnowflakeUtils.test.ts
Normal file
61
fluxer_app/src/utils/SnowflakeUtils.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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);
|
||||
});
|
||||
});
|
||||
114
fluxer_app/src/utils/SnowflakeUtils.tsx
Normal file
114
fluxer_app/src/utils/SnowflakeUtils.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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;
|
||||
}
|
||||
}
|
||||
41
fluxer_app/src/utils/SoundLabels.ts
Normal file
41
fluxer_app/src/utils/SoundLabels.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {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`),
|
||||
});
|
||||
314
fluxer_app/src/utils/SoundUtils.tsx
Normal file
314
fluxer_app/src/utils/SoundUtils.tsx
Normal 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);
|
||||
};
|
||||
144
fluxer_app/src/utils/SpoilerUtils.ts
Normal file
144
fluxer_app/src/utils/SpoilerUtils.ts
Normal 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};
|
||||
};
|
||||
34
fluxer_app/src/utils/StringUtils.tsx
Normal file
34
fluxer_app/src/utils/StringUtils.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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('');
|
||||
};
|
||||
127
fluxer_app/src/utils/SystemMessageUtils.tsx
Normal file
127
fluxer_app/src/utils/SystemMessageUtils.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
40
fluxer_app/src/utils/TenorUtils.ts
Normal file
40
fluxer_app/src/utils/TenorUtils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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';
|
||||
}
|
||||
225
fluxer_app/src/utils/TextareaAutocompleteFlow.test.ts
Normal file
225
fluxer_app/src/utils/TextareaAutocompleteFlow.test.ts
Normal 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> ');
|
||||
}
|
||||
});
|
||||
});
|
||||
118
fluxer_app/src/utils/TextareaInsertMentionFlow.test.ts
Normal file
118
fluxer_app/src/utils/TextareaInsertMentionFlow.test.ts
Normal 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>');
|
||||
});
|
||||
});
|
||||
686
fluxer_app/src/utils/TextareaSegmentManager.test.ts
Normal file
686
fluxer_app/src/utils/TextareaSegmentManager.test.ts
Normal 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> ');
|
||||
});
|
||||
});
|
||||
});
|
||||
148
fluxer_app/src/utils/TextareaSegmentManager.ts
Normal file
148
fluxer_app/src/utils/TextareaSegmentManager.ts
Normal 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};
|
||||
}
|
||||
}
|
||||
419
fluxer_app/src/utils/TextareaStateManager.test.ts
Normal file
419
fluxer_app/src/utils/TextareaStateManager.test.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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> ');
|
||||
});
|
||||
});
|
||||
});
|
||||
209
fluxer_app/src/utils/TextareaStateManager.ts
Normal file
209
fluxer_app/src/utils/TextareaStateManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
82
fluxer_app/src/utils/ThemeUtils.tsx
Normal file
82
fluxer_app/src/utils/ThemeUtils.tsx
Normal 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;
|
||||
}
|
||||
53
fluxer_app/src/utils/TimeUtils.ts
Normal file
53
fluxer_app/src/utils/TimeUtils.ts
Normal 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
Reference in New Issue
Block a user