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:
Ajay Bura 2023-06-12 21:15:23 +10:00 committed by GitHub
parent 2055d7a07f
commit 0b06bed1db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
128 changed files with 8799 additions and 409 deletions

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

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

View file

@ -0,0 +1,6 @@
export enum KeySymbol {
Command = '⌘',
Shift = '⇧',
Option = '⌥',
Control = '⌃',
}

25
src/app/utils/keyboard.ts Normal file
View 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
View 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 }));
}
};

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

@ -0,0 +1,10 @@
export const sanitizeText = (body: string) => {
const tagsToReplace: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
return body.replace(/[&<>'"]/g, (tag) => tagsToReplace[tag] || tag);
};

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