initial commit

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

View File

@@ -0,0 +1,366 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 TransformedUser {
id: string;
username: string;
isBot?: boolean;
isFriend?: boolean;
guildIds?: Array<string>;
_delete?: boolean;
_removeGuild?: string;
[key: string]: string | boolean | undefined | Array<string>;
}
interface SearchFilters {
friends?: boolean;
guild?: string;
}
interface SearchQuery {
query: string;
filters?: SearchFilters;
blacklist?: Array<string>;
whitelist?: Array<string>;
boosters?: Record<string, number>;
limit: number;
}
interface SearchResult {
id: string;
username: string;
comparator: string;
score: number;
isBot?: boolean;
}
export enum MessageTypes {
UPDATE_USERS = 'UPDATE_USERS',
USER_RESULTS = 'USER_RESULTS',
QUERY_SET = 'QUERY_SET',
QUERY_CLEAR = 'QUERY_CLEAR',
}
interface WorkerMessage<T = unknown> {
uuid?: string;
type: MessageTypes;
payload?: T;
}
interface UpdateUsersPayload {
users: Array<TransformedUser>;
}
const userIndex: Map<string, TransformedUser> = new Map();
const activeQueries: Map<string, SearchQuery> = new Map();
const pendingSearches: Set<string> = new Set();
const SCORE_EXACT_PREFIX = 10;
const SCORE_CONTAINS = 5;
const SCORE_FUZZY = 1;
const FRIEND_KEY = 'isFriend';
const BOT_KEY = 'isBot';
const USERNAME_KEY = 'username';
const IGNORED_KEYS = new Set([BOT_KEY, FRIEND_KEY, USERNAME_KEY, 'guildIds']);
function escapeRegex(text: string): string {
return text.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
}
function fuzzyMatch(needle: string, haystack: string): boolean {
const needleLength = needle.length;
const haystackLength = haystack.length;
if (needleLength > haystackLength) return false;
if (needleLength === haystackLength) return needle === haystack;
let needleIndex = 0;
for (let haystackIndex = 0; haystackIndex < haystackLength; haystackIndex++) {
if (needle.charCodeAt(needleIndex) === haystack.charCodeAt(haystackIndex)) {
needleIndex++;
if (needleIndex === needleLength) return true;
}
}
return false;
}
function sortByMatchScore(a: SearchResult, b: SearchResult): number {
if (a.score === b.score) {
const aComp = a.comparator.toLowerCase();
const bComp = b.comparator.toLowerCase();
if (aComp < bComp) return -1;
if (aComp > bComp) return 1;
return 0;
}
return b.score - a.score;
}
function shouldIncludeUser(
userId: string,
user: TransformedUser,
filters?: SearchFilters,
blacklist?: Array<string>,
whitelist?: Array<string>,
): boolean {
if (blacklist?.includes(userId)) return false;
if (whitelist?.includes(userId)) return true;
if (filters?.friends === true && user.isFriend !== true) {
return false;
}
if (filters?.guild) {
return user[filters.guild] === true;
}
return true;
}
function calculateScore(baseScore: number, booster?: number): number {
return baseScore * (booster ?? 1);
}
function postSearchResults(uuid: string, results: Array<SearchResult>): void {
const payload = results.map((r) => ({
id: r.id,
username: r.username,
isBot: r.isBot,
}));
const message: WorkerMessage<typeof payload> = {
uuid,
type: MessageTypes.USER_RESULTS,
payload,
};
postMessage(message);
}
function executeSearch(uuid: string, searchQuery: SearchQuery): void {
const {query, limit, filters, blacklist, whitelist, boosters} = searchQuery;
const results: Array<SearchResult> = [];
if (query === '') {
postSearchResults(uuid, results);
return;
}
const exactPrefixRegex = new RegExp(`^${escapeRegex(query)}`, 'i');
const containsRegex = new RegExp(escapeRegex(query), 'i');
const queryLower = query.toLowerCase();
userIndex.forEach((user, userId) => {
if (!shouldIncludeUser(userId, user, filters, blacklist, whitelist)) {
return;
}
const username = user.username;
let bestMatch: SearchResult | null = null;
for (const key of Object.keys(user)) {
const value = user[key];
if (key === BOT_KEY || key === FRIEND_KEY || !value || typeof value !== 'string') {
continue;
}
let matchResult: SearchResult | null = null;
if (exactPrefixRegex.test(value)) {
matchResult = {
id: userId,
username,
comparator: value,
score: calculateScore(SCORE_EXACT_PREFIX, boosters?.[userId]),
isBot: user.isBot,
};
} else if (containsRegex.test(value)) {
matchResult = {
id: userId,
username,
comparator: value,
score: calculateScore(SCORE_CONTAINS, boosters?.[userId]),
isBot: user.isBot,
};
} else if (fuzzyMatch(queryLower, value.toLowerCase())) {
matchResult = {
id: userId,
username,
comparator: value,
score: calculateScore(SCORE_FUZZY, boosters?.[userId]),
isBot: user.isBot,
};
}
if (matchResult && (!bestMatch || bestMatch.score < matchResult.score)) {
bestMatch = matchResult;
}
}
if (bestMatch) {
results.push(bestMatch);
}
});
results.sort(sortByMatchScore);
if (results.length > limit) {
results.length = limit;
}
postSearchResults(uuid, results);
}
function updateUsers(users: Array<TransformedUser>): void {
let shouldTriggerSearch = false;
const updatedGuilds = new Set<string>();
for (const update of users) {
const userId = update.id;
if (update._delete === true) {
userIndex.delete(userId);
shouldTriggerSearch = true;
continue;
}
const existingUser: TransformedUser = userIndex.get(userId) ?? {id: userId, username: ''};
const mergedUser: TransformedUser = {...existingUser, ...update};
const guildIdsSet = new Set<string>(existingUser.guildIds ?? []);
if (update.guildIds) {
for (const guildId of update.guildIds) {
guildIdsSet.add(guildId);
}
}
if (update._removeGuild) {
const guildKey = update._removeGuild;
if (guildKey in mergedUser) {
delete mergedUser[guildKey];
}
delete mergedUser._removeGuild;
guildIdsSet.delete(guildKey);
updatedGuilds.add(guildKey);
}
delete mergedUser._delete;
if (guildIdsSet.size > 0) {
mergedUser.guildIds = Array.from(guildIdsSet);
} else {
delete mergedUser.guildIds;
}
const wasFriend = Boolean(existingUser.isFriend);
const isFriendNow = Boolean(mergedUser.isFriend);
userIndex.set(userId, mergedUser);
if (activeQueries.size > 0) {
if (isFriendNow || wasFriend !== isFriendNow) {
shouldTriggerSearch = true;
}
for (const key of Object.keys(mergedUser)) {
if (IGNORED_KEYS.has(key)) {
continue;
}
updatedGuilds.add(key);
}
}
}
if (!shouldTriggerSearch && updatedGuilds.size === 0) {
return;
}
for (const [uuid, query] of activeQueries.entries()) {
const {filters} = query;
const interestedInFriends = !filters || filters.friends === true;
const interestedInGuild = !filters?.guild || updatedGuilds.has(filters.guild);
if ((shouldTriggerSearch && interestedInFriends) || interestedInGuild) {
pendingSearches.add(uuid);
}
}
if (pendingSearches.size > 0) {
debouncedExecuteSearches();
}
}
function setQuery(uuid: string, query: SearchQuery): void {
activeQueries.set(uuid, query);
executeSearch(uuid, query);
}
function clearQuery(uuid: string): void {
activeQueries.delete(uuid);
pendingSearches.delete(uuid);
}
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
function debouncedExecuteSearches(): void {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
for (const uuid of pendingSearches) {
const query = activeQueries.get(uuid);
if (query) {
executeSearch(uuid, query);
}
}
pendingSearches.clear();
debounceTimeout = null;
}, 100);
}
addEventListener('message', (event: MessageEvent<WorkerMessage>) => {
const data = event.data;
if (!data) {
throw new Error('Invalid data');
}
const {uuid, type, payload} = data;
switch (type) {
case MessageTypes.UPDATE_USERS: {
const p = payload as UpdateUsersPayload | undefined;
if (p?.users) {
updateUsers(p.users);
}
break;
}
case MessageTypes.QUERY_SET: {
if (!uuid) return;
setQuery(uuid, payload as SearchQuery);
break;
}
case MessageTypes.QUERY_CLEAR: {
if (!uuid) return;
clearQuery(uuid);
break;
}
}
});

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
/// <reference lib="webworker" />
import {cropAndRotateGif, ensureLibfluxcoreReady} from '~/lib/libfluxcore.worker';
export enum CropGifMessageType {
CROP_GIF_START = 0,
CROP_GIF_COMPLETE = 1,
CROP_GIF_ERROR = 2,
}
export interface CropGifStartMessage {
type: CropGifMessageType.CROP_GIF_START;
gif: Uint8Array;
x: number;
y: number;
width: number;
height: number;
imageRotation?: number;
resizeWidth?: number | null;
resizeHeight?: number | null;
}
export interface CropGifCompleteMessage {
type: CropGifMessageType.CROP_GIF_COMPLETE;
result: Uint8Array;
}
export interface CropGifErrorMessage {
type: CropGifMessageType.CROP_GIF_ERROR;
error: string;
}
type IncomingMessage = CropGifStartMessage;
declare const self: DedicatedWorkerGlobalScope;
self.addEventListener('message', async (event: MessageEvent<IncomingMessage>) => {
const msg = event.data;
if (msg?.type !== CropGifMessageType.CROP_GIF_START) return;
const {gif, x, y, width, height, imageRotation = 0, resizeWidth = null, resizeHeight = null} = msg;
try {
await ensureLibfluxcoreReady();
const result = cropAndRotateGif(gif, x, y, width, height, imageRotation, resizeWidth, resizeHeight);
const transferables: Array<Transferable> = [];
if (result?.buffer) {
transferables.push(result.buffer);
}
const response: CropGifCompleteMessage = {
type: CropGifMessageType.CROP_GIF_COMPLETE,
result,
};
self.postMessage(response, transferables);
} catch (err) {
console.error('[GIF Worker] Error:', err);
const response: CropGifErrorMessage = {
type: CropGifMessageType.CROP_GIF_ERROR,
error: err instanceof Error ? err.message : String(err),
};
self.postMessage(response);
}
});