mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-12 02:00:28 +03:00
Refactor state & Custom editor (#1190)
* Fix eslint * Enable ts strict mode * install folds, jotai & immer * Enable immer map/set * change cross-signing alert anim to 30 iteration * Add function to access matrix client * Add new types * Add disposable util * Add room utils * Add mDirect list atom * Add invite list atom * add room list atom * add utils for jotai atoms * Add room id to parents atom * Add mute list atom * Add room to unread atom * Use hook to bind atoms with sdk * Add settings atom * Add settings hook * Extract set settings hook * Add Sidebar components * WIP * Add bind atoms hook * Fix init muted room list atom * add navigation atoms * Add custom editor * Fix hotkeys * Update folds * Add editor output function * Add matrix client context * Add tooltip to editor toolbar items * WIP - Add editor to room input * Refocus editor on toolbar item click * Add Mentions - WIP * update folds * update mention focus outline * rename emoji element type * Add auto complete menu * add autocomplete query functions * add index file for editor * fix bug in getPrevWord function * Show room mention autocomplete * Add async search function * add use async search hook * use async search in room mention autocomplete * remove folds prefer font for now * allow number array in async search * reset search with empty query * Autocomplete unknown room mention * Autocomplete first room mention on tab * fix roomAliasFromQueryText * change mention color to primary * add isAlive hook * add getMxIdLocalPart to mx utils * fix getRoomAvatarUrl size * fix types * add room members hook * fix bug in room mention * add user mention autocomplete * Fix async search giving prev result after no match * update folds * add twemoji font * add use state provider hook * add prevent scroll with arrow key util * add ts to custom-emoji and emoji files * add types * add hook for emoji group labels * add hook for emoji group icons * add emoji board with basic emoji * add emojiboard in room input * select multiple emoji with shift press * display custom emoji in emojiboard * Add emoji preview * focus element on hover * update folds * position emojiboard properly * convert recent-emoji.js to ts * add use recent emoji hook * add io.element.recent_emoji to account data evt * Render recent emoji in emoji board * show custom emoji from parent spaces * show room emoji * improve emoji sidebar * update folds * fix pack avatar and name fallback in emoji board * add stickers to emoji board * fix bug in emoji preview * Add sticker icon in room input * add debounce hook * add search in emoji board * Optimize emoji board * fix emoji board sidebar divider * sync emojiboard sidebar with scroll & update ui * Add use throttle hook * support custom emoji in editor * remove duplicate emoji selection function * fix emoji and mention spacing * add emoticon autocomplete in editor * fix string * makes emoji size relative to font size in editor * add option to render link element * add spoiler in editor * fix sticker in emoji board search using wrong type * render custom placeholder * update hotkey for block quote and block code * add terminate search function in async search * add getImageInfo to matrix utils * send stickers * add resize observer hook * move emoji board component hooks in hooks dir * prevent editor expand hides room timeline * send typing notifications * improve emoji style and performance * fix imports * add on paste param to editor * add selectFile utils * add file picker hook * add file paste handler hook * add file drop handler * update folds * Add file upload card * add bytes to size util * add blurHash util * add await to js lib * add browser-encrypt-attachment types * add list atom * convert mimetype file to ts * add matrix types * add matrix file util * add file related dom utils * add common utils * add upload atom * add room input draft atom * add upload card renderer component * add upload board component * add support for file upload in editor * send files with message / enter * fix circular deps * store editor toolbar state in local store * move msg content util to separate file * store msg draft on room switch * fix following member not updating on msg sent * add theme for folds component * fix system default theme * Add reply support in editor * prevent initMatrix to init multiple time * add state event hooks * add async callback hook * Show tombstone info for tombstone room * fix room tombstone component border * add power level hook * Add room input placeholder component * Show input placeholder for muted member
This commit is contained in:
parent
2055d7a07f
commit
0b06bed1db
128 changed files with 8799 additions and 409 deletions
102
src/app/utils/AsyncSearch.ts
Normal file
102
src/app/utils/AsyncSearch.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
export type NormalizeOption = {
|
||||
caseSensitive?: boolean;
|
||||
normalizeUnicode?: boolean;
|
||||
ignoreWhitespace?: boolean;
|
||||
};
|
||||
|
||||
export type MatchQueryOption = {
|
||||
contain?: boolean;
|
||||
};
|
||||
|
||||
export type AsyncSearchOption = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type MatchHandler<TSearchItem extends object | string | number> = (
|
||||
item: TSearchItem,
|
||||
query: string
|
||||
) => boolean;
|
||||
export type ResultHandler<TSearchItem extends object | string | number> = (
|
||||
results: TSearchItem[],
|
||||
query: string
|
||||
) => void;
|
||||
|
||||
export type AsyncSearchHandler = (query: string) => void;
|
||||
export type TerminateAsyncSearch = () => void;
|
||||
|
||||
export const normalize = (str: string, options?: NormalizeOption) => {
|
||||
let nStr = str.normalize(options?.normalizeUnicode ?? true ? 'NFKC' : 'NFC');
|
||||
if (!options?.caseSensitive) nStr = nStr.toLocaleLowerCase();
|
||||
if (options?.ignoreWhitespace ?? true) nStr = nStr.replace(/\s/g, '');
|
||||
return nStr;
|
||||
};
|
||||
|
||||
export const matchQuery = (item: string, query: string, options?: MatchQueryOption): boolean => {
|
||||
if (options?.contain) return item.indexOf(query) !== -1;
|
||||
return item.startsWith(query);
|
||||
};
|
||||
|
||||
export const AsyncSearch = <TSearchItem extends object | string | number>(
|
||||
list: TSearchItem[],
|
||||
match: MatchHandler<TSearchItem>,
|
||||
onResult: ResultHandler<TSearchItem>,
|
||||
options?: AsyncSearchOption
|
||||
): [AsyncSearchHandler, TerminateAsyncSearch] => {
|
||||
let resultList: TSearchItem[] = [];
|
||||
|
||||
let searchIndex = 0;
|
||||
let sessionStartTimestamp = 0;
|
||||
let sessionScheduleId: number | undefined;
|
||||
|
||||
const terminateSearch: TerminateAsyncSearch = () => {
|
||||
resultList = [];
|
||||
searchIndex = 0;
|
||||
sessionStartTimestamp = 0;
|
||||
if (sessionScheduleId) clearTimeout(sessionScheduleId);
|
||||
sessionScheduleId = undefined;
|
||||
};
|
||||
|
||||
const find = (query: string, sessionTimestamp: number) => {
|
||||
const findingCount = resultList.length;
|
||||
sessionScheduleId = undefined;
|
||||
// return if find session got reset
|
||||
if (sessionTimestamp !== sessionStartTimestamp) return;
|
||||
|
||||
sessionStartTimestamp = window.performance.now();
|
||||
for (; searchIndex < list.length; searchIndex += 1) {
|
||||
if (match(list[searchIndex], query)) {
|
||||
resultList.push(list[searchIndex]);
|
||||
if (typeof options?.limit === 'number' && resultList.length >= options.limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const matchFinishTime = window.performance.now();
|
||||
if (matchFinishTime - sessionStartTimestamp > 8) {
|
||||
const currentFindingCount = resultList.length;
|
||||
const thisSessionTimestamp = sessionStartTimestamp;
|
||||
if (findingCount !== currentFindingCount) onResult(resultList, query);
|
||||
|
||||
searchIndex += 1;
|
||||
sessionScheduleId = window.setTimeout(() => find(query, thisSessionTimestamp), 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (findingCount !== resultList.length || findingCount === 0) {
|
||||
onResult(resultList, query);
|
||||
}
|
||||
terminateSearch();
|
||||
};
|
||||
|
||||
const search: AsyncSearchHandler = (query: string) => {
|
||||
terminateSearch();
|
||||
if (query === '') {
|
||||
onResult(resultList, query);
|
||||
return;
|
||||
}
|
||||
find(query, sessionStartTimestamp);
|
||||
};
|
||||
|
||||
return [search, terminateSearch];
|
||||
};
|
||||
19
src/app/utils/blurHash.ts
Normal file
19
src/app/utils/blurHash.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { encode } from 'blurhash';
|
||||
|
||||
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
|
||||
|
||||
export const encodeBlurHash = (
|
||||
img: HTMLImageElement | HTMLVideoElement,
|
||||
width?: number,
|
||||
height?: number
|
||||
): string | undefined => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width || img.width;
|
||||
canvas.height = height || img.height;
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) return undefined;
|
||||
context.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
const data = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return encode(data.data, data.width, data.height, 4, 4);
|
||||
};
|
||||
32
src/app/utils/common.ts
Normal file
32
src/app/utils/common.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { IconName, IconSrc } from 'folds';
|
||||
|
||||
export const bytesToSize = (bytes: number): string => {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return '0KB';
|
||||
|
||||
let sizeIndex = Math.floor(Math.log(bytes) / Math.log(1000));
|
||||
|
||||
if (sizeIndex === 0) sizeIndex = 1;
|
||||
|
||||
return `${(bytes / 1000 ** sizeIndex).toFixed(1)} ${sizes[sizeIndex]}`;
|
||||
};
|
||||
|
||||
export const getFileTypeIcon = (icons: Record<IconName, IconSrc>, fileType: string): IconSrc => {
|
||||
const type = fileType.toLowerCase();
|
||||
if (type.startsWith('audio')) {
|
||||
return icons.Play;
|
||||
}
|
||||
if (type.startsWith('video')) {
|
||||
return icons.Vlc;
|
||||
}
|
||||
if (type.startsWith('image')) {
|
||||
return icons.Photo;
|
||||
}
|
||||
return icons.File;
|
||||
};
|
||||
|
||||
export const fulfilledPromiseSettledResult = <T>(prs: PromiseSettledResult<T>[]): T[] =>
|
||||
prs.reduce<T[]>((values, pr) => {
|
||||
if (pr.status === 'fulfilled') values.push(pr.value);
|
||||
return values;
|
||||
}, []);
|
||||
8
src/app/utils/disposable.ts
Normal file
8
src/app/utils/disposable.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export type DisposeCallback<Q extends unknown[] = [], R = void> = (...args: Q) => R;
|
||||
export type DisposableContext<P extends unknown[] = [], Q extends unknown[] = [], R = void> = (
|
||||
...args: P
|
||||
) => DisposeCallback<Q, R>;
|
||||
|
||||
export const disposable = <P extends unknown[], Q extends unknown[] = [], R = void>(
|
||||
context: DisposableContext<P, Q, R>
|
||||
) => context;
|
||||
133
src/app/utils/dom.ts
Normal file
133
src/app/utils/dom.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
export const targetFromEvent = (evt: Event, selector: string): Element | undefined => {
|
||||
const targets = evt.composedPath() as Element[];
|
||||
return targets.find((target) => target.matches?.(selector));
|
||||
};
|
||||
|
||||
export const editableActiveElement = (): boolean =>
|
||||
!!document.activeElement &&
|
||||
/^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase());
|
||||
|
||||
export const inVisibleScrollArea = (
|
||||
scrollElement: HTMLElement,
|
||||
childElement: HTMLElement
|
||||
): boolean => {
|
||||
const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop;
|
||||
const scrollBottom = scrollTop + scrollElement.offsetHeight;
|
||||
|
||||
const childTop = childElement.offsetTop;
|
||||
const childBottom = childTop + childElement.clientHeight;
|
||||
|
||||
if (childTop >= scrollTop && childTop < scrollBottom) return true;
|
||||
if (childTop < scrollTop && childBottom > scrollTop) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export type FilesOrFile<T extends boolean | undefined = undefined> = T extends true ? File[] : File;
|
||||
|
||||
export const selectFile = <M extends boolean | undefined = undefined>(
|
||||
accept: string,
|
||||
multiple?: M
|
||||
): Promise<FilesOrFile<M> | undefined> =>
|
||||
new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
if (accept) input.accept = accept;
|
||||
if (multiple) input.multiple = true;
|
||||
|
||||
const changeHandler = () => {
|
||||
const fileList = input.files;
|
||||
if (!fileList) {
|
||||
resolve(undefined);
|
||||
} else {
|
||||
const files: File[] = [...fileList].filter((file) => file);
|
||||
resolve((multiple ? files : files[0]) as FilesOrFile<M>);
|
||||
}
|
||||
input.removeEventListener('change', changeHandler);
|
||||
};
|
||||
|
||||
input.addEventListener('change', changeHandler);
|
||||
input.click();
|
||||
});
|
||||
|
||||
export const getDataTransferFiles = (dataTransfer: DataTransfer): File[] | undefined => {
|
||||
const fileList = dataTransfer.files;
|
||||
const files = [...fileList].filter((file) => file);
|
||||
if (files.length === 0) return undefined;
|
||||
return files;
|
||||
};
|
||||
|
||||
export const getImageUrlBlob = async (url: string) => {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
return blob;
|
||||
};
|
||||
|
||||
export const getImageFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob);
|
||||
|
||||
export const getVideoFileUrl = (fileOrBlob: File | Blob) => URL.createObjectURL(fileOrBlob);
|
||||
|
||||
export const loadImageElement = (url: string): Promise<HTMLImageElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const img = document.createElement('img');
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
export const loadVideoElement = (url: string): Promise<HTMLVideoElement> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.playsInline = true;
|
||||
video.muted = true;
|
||||
|
||||
video.onloadeddata = () => {
|
||||
resolve(video);
|
||||
video.pause();
|
||||
};
|
||||
video.onerror = (e) => {
|
||||
reject(e);
|
||||
};
|
||||
|
||||
video.src = url;
|
||||
video.load();
|
||||
video.play();
|
||||
});
|
||||
|
||||
export const getThumbnailDimensions = (width: number, height: number): [number, number] => {
|
||||
const MAX_WIDTH = 400;
|
||||
const MAX_HEIGHT = 300;
|
||||
let targetWidth = width;
|
||||
let targetHeight = height;
|
||||
if (targetHeight > MAX_HEIGHT) {
|
||||
targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
|
||||
targetHeight = MAX_HEIGHT;
|
||||
}
|
||||
if (targetWidth > MAX_WIDTH) {
|
||||
targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
|
||||
targetWidth = MAX_WIDTH;
|
||||
}
|
||||
return [targetWidth, targetHeight];
|
||||
};
|
||||
|
||||
export const getThumbnail = (
|
||||
img: HTMLImageElement | SVGImageElement | HTMLVideoElement,
|
||||
width: number,
|
||||
height: number,
|
||||
thumbnailMimeType?: string
|
||||
): Promise<Blob | undefined> =>
|
||||
new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
context.drawImage(img, 0, 0, width, height);
|
||||
|
||||
canvas.toBlob((thumbnail) => {
|
||||
resolve(thumbnail ?? undefined);
|
||||
}, thumbnailMimeType ?? 'image/jpeg');
|
||||
});
|
||||
6
src/app/utils/key-symbol.ts
Normal file
6
src/app/utils/key-symbol.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export enum KeySymbol {
|
||||
Command = '⌘',
|
||||
Shift = '⇧',
|
||||
Option = '⌥',
|
||||
Control = '⌃',
|
||||
}
|
||||
25
src/app/utils/keyboard.ts
Normal file
25
src/app/utils/keyboard.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import isHotkey from 'is-hotkey';
|
||||
import { KeyboardEventHandler } from 'react';
|
||||
|
||||
export interface KeyboardEventLike {
|
||||
key: string;
|
||||
which: number;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
preventDefault(): void;
|
||||
}
|
||||
|
||||
export const onTabPress = (evt: KeyboardEventLike, callback: () => void) => {
|
||||
if (isHotkey('tab', evt)) {
|
||||
evt.preventDefault();
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
export const preventScrollWithArrowKey: KeyboardEventHandler = (evt) => {
|
||||
if (isHotkey(['arrowup', 'arrowright', 'arrowdown', 'arrowleft'], evt)) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
};
|
||||
118
src/app/utils/matrix.ts
Normal file
118
src/app/utils/matrix.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { EncryptedAttachmentInfo, encryptAttachment } from 'browser-encrypt-attachment';
|
||||
import { MatrixClient, MatrixError, UploadProgress, UploadResponse } from 'matrix-js-sdk';
|
||||
import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common';
|
||||
|
||||
export const matchMxId = (id: string): RegExpMatchArray | null =>
|
||||
id.match(/^([@!$+#])(\S+):(\S+)$/);
|
||||
|
||||
export const validMxId = (id: string): boolean => !!matchMxId(id);
|
||||
|
||||
export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3];
|
||||
|
||||
export const getMxIdLocalPart = (userId: string): string | undefined => matchMxId(userId)?.[2];
|
||||
|
||||
export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@');
|
||||
|
||||
export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => {
|
||||
const info: IImageInfo = {};
|
||||
info.w = img.width;
|
||||
info.h = img.height;
|
||||
info.mimetype = fileOrBlob.type;
|
||||
info.size = fileOrBlob.size;
|
||||
return info;
|
||||
};
|
||||
|
||||
export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => {
|
||||
const info: IVideoInfo = {};
|
||||
info.duration = Number.isNaN(video.duration) ? undefined : video.duration;
|
||||
info.w = video.videoWidth;
|
||||
info.h = video.videoHeight;
|
||||
info.mimetype = fileOrBlob.type;
|
||||
info.size = fileOrBlob.size;
|
||||
return info;
|
||||
};
|
||||
|
||||
export const getThumbnailContent = (thumbnailInfo: {
|
||||
thumbnail: File | Blob;
|
||||
encInfo: EncryptedAttachmentInfo | undefined;
|
||||
mxc: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}): IThumbnailContent => {
|
||||
const { thumbnail, encInfo, mxc, width, height } = thumbnailInfo;
|
||||
|
||||
const content: IThumbnailContent = {
|
||||
thumbnail_info: {
|
||||
mimetype: thumbnail.type,
|
||||
size: thumbnail.size,
|
||||
w: width,
|
||||
h: height,
|
||||
},
|
||||
};
|
||||
if (encInfo) {
|
||||
content.thumbnail_file = {
|
||||
...encInfo,
|
||||
url: mxc,
|
||||
};
|
||||
} else {
|
||||
content.thumbnail_url = mxc;
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
export const encryptFile = async (
|
||||
file: File | Blob
|
||||
): Promise<{
|
||||
encInfo: EncryptedAttachmentInfo;
|
||||
file: File;
|
||||
originalFile: File | Blob;
|
||||
}> => {
|
||||
const dataBuffer = await file.arrayBuffer();
|
||||
const encryptedAttachment = await encryptAttachment(dataBuffer);
|
||||
const encFile = new File([encryptedAttachment.data], file.name, {
|
||||
type: file.type,
|
||||
});
|
||||
return {
|
||||
encInfo: encryptedAttachment.info,
|
||||
file: encFile,
|
||||
originalFile: file,
|
||||
};
|
||||
};
|
||||
|
||||
export type TUploadContent = File | Blob;
|
||||
|
||||
export type ContentUploadOptions = {
|
||||
name?: string;
|
||||
fileType?: string;
|
||||
hideFilename?: boolean;
|
||||
onPromise?: (promise: Promise<UploadResponse>) => void;
|
||||
onProgress?: (progress: UploadProgress) => void;
|
||||
onSuccess: (mxc: string) => void;
|
||||
onError: (error: MatrixError) => void;
|
||||
};
|
||||
|
||||
export const uploadContent = async (
|
||||
mx: MatrixClient,
|
||||
file: TUploadContent,
|
||||
options: ContentUploadOptions
|
||||
) => {
|
||||
const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options;
|
||||
|
||||
const uploadPromise = mx.uploadContent(file, {
|
||||
name,
|
||||
type: fileType,
|
||||
includeFilename: !hideFilename,
|
||||
progressHandler: onProgress,
|
||||
});
|
||||
onPromise?.(uploadPromise);
|
||||
try {
|
||||
const data = await uploadPromise;
|
||||
const mxc = data.content_uri;
|
||||
if (mxc) onSuccess(mxc);
|
||||
else onError(new MatrixError(data));
|
||||
} catch (e: any) {
|
||||
const error = typeof e?.message === 'string' ? e.message : undefined;
|
||||
const errcode = typeof e?.name === 'string' ? e.message : undefined;
|
||||
onError(new MatrixError({ error, errcode }));
|
||||
}
|
||||
};
|
||||
47
src/app/utils/mimeTypes.ts
Normal file
47
src/app/utils/mimeTypes.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// https://github.com/matrix-org/matrix-react-sdk/blob/cd15e08fc285da42134817cce50de8011809cd53/src/utils/blobs.ts
|
||||
export const ALLOWED_BLOB_MIMETYPES = [
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/png',
|
||||
'image/apng',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/ogg',
|
||||
'video/quicktime',
|
||||
|
||||
'audio/mp4',
|
||||
'audio/webm',
|
||||
'audio/aac',
|
||||
'audio/mpeg',
|
||||
'audio/ogg',
|
||||
'audio/wave',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/x-pn-wav',
|
||||
'audio/flac',
|
||||
'audio/x-flac',
|
||||
];
|
||||
|
||||
export const getBlobSafeMimeType = (mimeType: string) => {
|
||||
if (typeof mimeType !== 'string') return 'application/octet-stream';
|
||||
const [type] = mimeType.split(';');
|
||||
if (!ALLOWED_BLOB_MIMETYPES.includes(type)) {
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
// Required for Chromium browsers
|
||||
if (type === 'video/quicktime') {
|
||||
return 'video/mp4';
|
||||
}
|
||||
return type;
|
||||
};
|
||||
|
||||
export const safeFile = (f: File) => {
|
||||
const safeType = getBlobSafeMimeType(f.type);
|
||||
if (safeType !== f.type) {
|
||||
return new File([f], f.name, { type: safeType });
|
||||
}
|
||||
return f;
|
||||
};
|
||||
265
src/app/utils/room.ts
Normal file
265
src/app/utils/room.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { IconName, IconSrc } from 'folds';
|
||||
|
||||
import {
|
||||
IPushRule,
|
||||
IPushRules,
|
||||
JoinRule,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
NotificationCountType,
|
||||
Room,
|
||||
} from 'matrix-js-sdk';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import {
|
||||
NotificationType,
|
||||
RoomToParents,
|
||||
RoomType,
|
||||
StateEvent,
|
||||
UnreadInfo,
|
||||
} from '../../types/matrix/room';
|
||||
|
||||
export const getStateEvent = (
|
||||
room: Room,
|
||||
eventType: StateEvent,
|
||||
stateKey = ''
|
||||
): MatrixEvent | undefined => room.currentState.getStateEvents(eventType, stateKey) ?? undefined;
|
||||
|
||||
export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[] =>
|
||||
room.currentState.getStateEvents(eventType);
|
||||
|
||||
export const getAccountData = (
|
||||
mx: MatrixClient,
|
||||
eventType: AccountDataEvent
|
||||
): MatrixEvent | undefined => mx.getAccountData(eventType);
|
||||
|
||||
export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => {
|
||||
const roomIds = new Set<string>();
|
||||
const userIdToDirects = mDirectEvent?.getContent();
|
||||
|
||||
if (userIdToDirects === undefined) return roomIds;
|
||||
|
||||
Object.keys(userIdToDirects).forEach((userId) => {
|
||||
const directs = userIdToDirects[userId];
|
||||
if (Array.isArray(directs)) {
|
||||
directs.forEach((id) => {
|
||||
if (typeof id === 'string') roomIds.add(id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return roomIds;
|
||||
};
|
||||
|
||||
export const isDirectInvite = (room: Room | null, myUserId: string | null): boolean => {
|
||||
if (!room || !myUserId) return false;
|
||||
const me = room.getMember(myUserId);
|
||||
const memberEvent = me?.events?.member;
|
||||
const content = memberEvent?.getContent();
|
||||
return content?.is_direct === true;
|
||||
};
|
||||
|
||||
export const isSpace = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return false;
|
||||
return event.getContent().type === RoomType.Space;
|
||||
};
|
||||
|
||||
export const isRoom = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return false;
|
||||
return event.getContent().type === undefined;
|
||||
};
|
||||
|
||||
export const isUnsupportedRoom = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
if (!event) return true; // Consider room unsupported if m.room.create event doesn't exist
|
||||
return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space;
|
||||
};
|
||||
|
||||
export function isValidChild(mEvent: MatrixEvent): boolean {
|
||||
return mEvent.getType() === StateEvent.SpaceChild && Object.keys(mEvent.getContent()).length > 0;
|
||||
}
|
||||
|
||||
export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set<string> => {
|
||||
const allParents = new Set<string>();
|
||||
|
||||
const addAllParentIds = (rId: string) => {
|
||||
if (allParents.has(rId)) return;
|
||||
allParents.add(rId);
|
||||
|
||||
const parents = roomToParents.get(rId);
|
||||
parents?.forEach((id) => addAllParentIds(id));
|
||||
};
|
||||
addAllParentIds(roomId);
|
||||
allParents.delete(roomId);
|
||||
return allParents;
|
||||
};
|
||||
|
||||
export const getSpaceChildren = (room: Room) =>
|
||||
getStateEvents(room, StateEvent.SpaceChild).reduce<string[]>((filtered, mEvent) => {
|
||||
const stateKey = mEvent.getStateKey();
|
||||
if (isValidChild(mEvent) && stateKey) {
|
||||
filtered.push(stateKey);
|
||||
}
|
||||
return filtered;
|
||||
}, []);
|
||||
|
||||
export const mapParentWithChildren = (
|
||||
roomToParents: RoomToParents,
|
||||
roomId: string,
|
||||
children: string[]
|
||||
) => {
|
||||
const allParents = getAllParents(roomToParents, roomId);
|
||||
children.forEach((childId) => {
|
||||
if (allParents.has(childId)) {
|
||||
// Space cycle detected.
|
||||
return;
|
||||
}
|
||||
const parents = roomToParents.get(childId) ?? new Set<string>();
|
||||
parents.add(roomId);
|
||||
roomToParents.set(childId, parents);
|
||||
});
|
||||
};
|
||||
|
||||
export const getRoomToParents = (mx: MatrixClient): RoomToParents => {
|
||||
const map: RoomToParents = new Map();
|
||||
mx.getRooms()
|
||||
.filter((room) => isSpace(room))
|
||||
.forEach((room) => mapParentWithChildren(map, room.roomId, getSpaceChildren(room)));
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
export const isMutedRule = (rule: IPushRule) =>
|
||||
rule.actions[0] === 'dont_notify' && rule.conditions?.[0]?.kind === 'event_match';
|
||||
|
||||
export const findMutedRule = (overrideRules: IPushRule[], roomId: string) =>
|
||||
overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule));
|
||||
|
||||
export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => {
|
||||
let roomPushRule: IPushRule | undefined;
|
||||
try {
|
||||
roomPushRule = mx.getRoomPushRule('global', roomId);
|
||||
} catch {
|
||||
roomPushRule = undefined;
|
||||
}
|
||||
|
||||
if (!roomPushRule) {
|
||||
const overrideRules = mx.getAccountData('m.push_rules')?.getContent<IPushRules>()
|
||||
?.global?.override;
|
||||
if (!overrideRules) return NotificationType.Default;
|
||||
|
||||
return findMutedRule(overrideRules, roomId) ? NotificationType.Mute : NotificationType.Default;
|
||||
}
|
||||
|
||||
if (roomPushRule.actions[0] === 'notify') return NotificationType.AllMessages;
|
||||
return NotificationType.MentionsAndKeywords;
|
||||
};
|
||||
|
||||
export const isNotificationEvent = (mEvent: MatrixEvent) => {
|
||||
const eType = mEvent.getType();
|
||||
if (
|
||||
['m.room.create', 'm.room.message', 'm.room.encrypted', 'm.room.member', 'm.sticker'].find(
|
||||
(type) => type === eType
|
||||
)
|
||||
)
|
||||
return false;
|
||||
if (eType === 'm.room.member') return false;
|
||||
|
||||
if (mEvent.isRedacted()) return false;
|
||||
if (mEvent.getRelation()?.rel_type === 'm.replace') return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
|
||||
const userId = mx.getUserId();
|
||||
if (!userId) return false;
|
||||
const readUpToId = room.getEventReadUpTo(userId);
|
||||
const liveEvents = room.getLiveTimeline().getEvents();
|
||||
|
||||
if (liveEvents[liveEvents.length - 1]?.getSender() === userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
|
||||
const event = liveEvents[i];
|
||||
if (!event) return false;
|
||||
if (event.getId() === readUpToId) return false;
|
||||
if (isNotificationEvent(event)) return true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getUnreadInfo = (room: Room): UnreadInfo => {
|
||||
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
|
||||
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
highlight,
|
||||
total: highlight > total ? highlight : total,
|
||||
};
|
||||
};
|
||||
|
||||
export const getUnreadInfos = (mx: MatrixClient): UnreadInfo[] => {
|
||||
const unreadInfos = mx.getRooms().reduce<UnreadInfo[]>((unread, room) => {
|
||||
if (room.isSpaceRoom()) return unread;
|
||||
if (room.getMyMembership() !== 'join') return unread;
|
||||
if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread;
|
||||
|
||||
if (roomHaveUnread(mx, room)) {
|
||||
unread.push(getUnreadInfo(room));
|
||||
}
|
||||
|
||||
return unread;
|
||||
}, []);
|
||||
return unreadInfos;
|
||||
};
|
||||
|
||||
export const joinRuleToIconSrc = (
|
||||
icons: Record<IconName, IconSrc>,
|
||||
joinRule: JoinRule,
|
||||
space: boolean
|
||||
): IconSrc | undefined => {
|
||||
if (joinRule === JoinRule.Restricted) {
|
||||
return space ? icons.Space : icons.Hash;
|
||||
}
|
||||
if (joinRule === JoinRule.Knock) {
|
||||
return space ? icons.SpaceLock : icons.HashLock;
|
||||
}
|
||||
if (joinRule === JoinRule.Invite) {
|
||||
return space ? icons.SpaceLock : icons.HashLock;
|
||||
}
|
||||
if (joinRule === JoinRule.Public) {
|
||||
return space ? icons.SpaceGlobe : icons.HashGlobe;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefined => {
|
||||
const url =
|
||||
room.getAvatarFallbackMember()?.getAvatarUrl(mx.baseUrl, 32, 32, 'crop', undefined, false) ??
|
||||
undefined;
|
||||
if (url) return url;
|
||||
return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined;
|
||||
};
|
||||
|
||||
export const parseReplyBody = (userId: string, body: string) =>
|
||||
`> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`;
|
||||
|
||||
export const parseReplyFormattedBody = (
|
||||
roomId: string,
|
||||
userId: string,
|
||||
eventId: string,
|
||||
formattedBody: string
|
||||
): string => {
|
||||
const replyToLink = `<a href="https://matrix.to/#/${encodeURIComponent(
|
||||
roomId
|
||||
)}/${encodeURIComponent(eventId)}">In reply to</a>`;
|
||||
const userLink = `<a href="https://matrix.to/#/${encodeURIComponent(userId)}">${userId}</a>`;
|
||||
|
||||
return `<mx-reply><blockquote>${replyToLink}${userLink}<br />${formattedBody}</blockquote></mx-reply>`;
|
||||
};
|
||||
10
src/app/utils/sanitize.ts
Normal file
10
src/app/utils/sanitize.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export const sanitizeText = (body: string) => {
|
||||
const tagsToReplace: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag);
|
||||
};
|
||||
5
src/app/utils/user-agent.ts
Normal file
5
src/app/utils/user-agent.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
export const ua = () => UAParser(window.navigator.userAgent);
|
||||
|
||||
export const isMacOS = () => ua().os.name === 'Mac OS';
|
||||
Loading…
Add table
Add a link
Reference in a new issue