mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-06 23:30: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
15
src/app/hooks/useAlive.ts
Normal file
15
src/app/hooks/useAlive.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export const useAlive = (): (() => boolean) => {
|
||||
const aliveRef = useRef<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true;
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const alive = useCallback(() => aliveRef.current, []);
|
||||
return alive;
|
||||
};
|
||||
70
src/app/hooks/useAsyncCallback.ts
Normal file
70
src/app/hooks/useAsyncCallback.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { useAlive } from './useAlive';
|
||||
|
||||
export enum AsyncStatus {
|
||||
Idle = 'idle',
|
||||
Loading = 'loading',
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export type AsyncIdle = {
|
||||
status: AsyncStatus.Idle;
|
||||
};
|
||||
|
||||
export type AsyncLoading = {
|
||||
status: AsyncStatus.Loading;
|
||||
};
|
||||
|
||||
export type AsyncSuccess<T> = {
|
||||
status: AsyncStatus.Success;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type AsyncError = {
|
||||
status: AsyncStatus.Error;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export type AsyncState<T> = AsyncIdle | AsyncLoading | AsyncSuccess<T> | AsyncError;
|
||||
|
||||
export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
|
||||
|
||||
export const useAsyncCallback = <TArgs extends unknown[], TData>(
|
||||
asyncCallback: AsyncCallback<TArgs, TData>
|
||||
): [AsyncState<TData>, AsyncCallback<TArgs, TData>] => {
|
||||
const [state, setState] = useState<AsyncState<TData>>({
|
||||
status: AsyncStatus.Idle,
|
||||
});
|
||||
const alive = useAlive();
|
||||
|
||||
const callback: AsyncCallback<TArgs, TData> = useCallback(
|
||||
async (...args) => {
|
||||
setState({
|
||||
status: AsyncStatus.Loading,
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await asyncCallback(...args);
|
||||
if (alive()) {
|
||||
setState({
|
||||
status: AsyncStatus.Success,
|
||||
data,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (alive()) {
|
||||
setState({
|
||||
status: AsyncStatus.Error,
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[asyncCallback, alive]
|
||||
);
|
||||
|
||||
return [state, callback];
|
||||
};
|
||||
81
src/app/hooks/useAsyncSearch.ts
Normal file
81
src/app/hooks/useAsyncSearch.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
MatchHandler,
|
||||
AsyncSearch,
|
||||
AsyncSearchHandler,
|
||||
AsyncSearchOption,
|
||||
MatchQueryOption,
|
||||
NormalizeOption,
|
||||
normalize,
|
||||
matchQuery,
|
||||
ResultHandler,
|
||||
} from '../utils/AsyncSearch';
|
||||
|
||||
export type UseAsyncSearchOptions = AsyncSearchOption & {
|
||||
matchOptions?: MatchQueryOption;
|
||||
normalizeOptions?: NormalizeOption;
|
||||
};
|
||||
|
||||
export type SearchItemStrGetter<TSearchItem extends object | string | number> = (
|
||||
searchItem: TSearchItem
|
||||
) => string | string[];
|
||||
|
||||
export type UseAsyncSearchResult<TSearchItem extends object | string | number> = {
|
||||
query: string;
|
||||
items: TSearchItem[];
|
||||
};
|
||||
|
||||
export const useAsyncSearch = <TSearchItem extends object | string | number>(
|
||||
list: TSearchItem[],
|
||||
getItemStr: SearchItemStrGetter<TSearchItem>,
|
||||
options?: UseAsyncSearchOptions
|
||||
): [UseAsyncSearchResult<TSearchItem> | undefined, AsyncSearchHandler] => {
|
||||
const [result, setResult] = useState<UseAsyncSearchResult<TSearchItem>>();
|
||||
|
||||
const [searchCallback, terminateSearch] = useMemo(() => {
|
||||
setResult(undefined);
|
||||
|
||||
const handleMatch: MatchHandler<TSearchItem> = (item, query) => {
|
||||
const itemStr = getItemStr(item);
|
||||
if (Array.isArray(itemStr))
|
||||
return !!itemStr.find((i) =>
|
||||
matchQuery(normalize(i, options?.normalizeOptions), query, options?.matchOptions)
|
||||
);
|
||||
return matchQuery(
|
||||
normalize(itemStr, options?.normalizeOptions),
|
||||
query,
|
||||
options?.matchOptions
|
||||
);
|
||||
};
|
||||
|
||||
const handleResult: ResultHandler<TSearchItem> = (results, query) =>
|
||||
setResult({
|
||||
query,
|
||||
items: results,
|
||||
});
|
||||
|
||||
return AsyncSearch(list, handleMatch, handleResult, options);
|
||||
}, [list, options, getItemStr]);
|
||||
|
||||
const searchHandler: AsyncSearchHandler = useCallback(
|
||||
(query) => {
|
||||
const normalizedQuery = normalize(query, options?.normalizeOptions);
|
||||
if (!normalizedQuery) {
|
||||
setResult(undefined);
|
||||
return;
|
||||
}
|
||||
searchCallback(normalizedQuery);
|
||||
},
|
||||
[searchCallback, options?.normalizeOptions]
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// terminate any ongoing search request on unmount.
|
||||
terminateSearch();
|
||||
},
|
||||
[terminateSearch]
|
||||
);
|
||||
|
||||
return [result, searchHandler];
|
||||
};
|
||||
34
src/app/hooks/useDebounce.ts
Normal file
34
src/app/hooks/useDebounce.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
|
||||
export interface DebounceOptions {
|
||||
wait?: number;
|
||||
immediate?: boolean;
|
||||
}
|
||||
export type DebounceCallback<T extends unknown[]> = (...args: T) => void;
|
||||
|
||||
export function useDebounce<T extends unknown[]>(
|
||||
callback: DebounceCallback<T>,
|
||||
options?: DebounceOptions
|
||||
): DebounceCallback<T> {
|
||||
const timeoutIdRef = useRef<number>();
|
||||
const { wait, immediate } = options ?? {};
|
||||
|
||||
const debounceCallback = useCallback(
|
||||
(...cbArgs: T) => {
|
||||
if (timeoutIdRef.current) {
|
||||
clearTimeout(timeoutIdRef.current);
|
||||
timeoutIdRef.current = undefined;
|
||||
} else if (immediate) {
|
||||
callback(...cbArgs);
|
||||
}
|
||||
|
||||
timeoutIdRef.current = window.setTimeout(() => {
|
||||
callback(...cbArgs);
|
||||
timeoutIdRef.current = undefined;
|
||||
}, wait);
|
||||
},
|
||||
[callback, wait, immediate]
|
||||
);
|
||||
|
||||
return debounceCallback;
|
||||
}
|
||||
66
src/app/hooks/useFileDrop.ts
Normal file
66
src/app/hooks/useFileDrop.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useCallback, DragEventHandler, RefObject, useState, useEffect, useRef } from 'react';
|
||||
import { getDataTransferFiles } from '../utils/dom';
|
||||
|
||||
export const useFileDropHandler = (onDrop: (file: File[]) => void): DragEventHandler =>
|
||||
useCallback(
|
||||
(evt) => {
|
||||
const files = getDataTransferFiles(evt.dataTransfer);
|
||||
if (files) onDrop(files);
|
||||
},
|
||||
[onDrop]
|
||||
);
|
||||
|
||||
export const useFileDropZone = (
|
||||
zoneRef: RefObject<HTMLElement>,
|
||||
onDrop: (file: File[]) => void
|
||||
): boolean => {
|
||||
const dragStateRef = useRef<'start' | 'leave' | 'over'>();
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const target = zoneRef.current;
|
||||
const handleDrop = (evt: DragEvent) => {
|
||||
evt.preventDefault();
|
||||
dragStateRef.current = undefined;
|
||||
setActive(false);
|
||||
if (!evt.dataTransfer) return;
|
||||
const files = getDataTransferFiles(evt.dataTransfer);
|
||||
if (files) onDrop(files);
|
||||
};
|
||||
|
||||
target?.addEventListener('drop', handleDrop);
|
||||
return () => {
|
||||
target?.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, [zoneRef, onDrop]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = zoneRef.current;
|
||||
const handleDragEnter = (evt: DragEvent) => {
|
||||
if (evt.dataTransfer?.types.includes('Files')) {
|
||||
dragStateRef.current = 'start';
|
||||
setActive(true);
|
||||
}
|
||||
};
|
||||
const handleDragLeave = () => {
|
||||
if (dragStateRef.current !== 'over') return;
|
||||
dragStateRef.current = 'leave';
|
||||
setActive(false);
|
||||
};
|
||||
const handleDragOver = (evt: DragEvent) => {
|
||||
evt.preventDefault();
|
||||
dragStateRef.current = 'over';
|
||||
};
|
||||
|
||||
target?.addEventListener('dragenter', handleDragEnter);
|
||||
target?.addEventListener('dragleave', handleDragLeave);
|
||||
target?.addEventListener('dragover', handleDragOver);
|
||||
return () => {
|
||||
target?.removeEventListener('dragenter', handleDragEnter);
|
||||
target?.removeEventListener('dragleave', handleDragLeave);
|
||||
target?.removeEventListener('dragover', handleDragOver);
|
||||
};
|
||||
}, [zoneRef]);
|
||||
|
||||
return active;
|
||||
};
|
||||
11
src/app/hooks/useFilePasteHandler.ts
Normal file
11
src/app/hooks/useFilePasteHandler.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useCallback, ClipboardEventHandler } from 'react';
|
||||
import { getDataTransferFiles } from '../utils/dom';
|
||||
|
||||
export const useFilePasteHandler = (onPaste: (file: File[]) => void): ClipboardEventHandler =>
|
||||
useCallback(
|
||||
(evt) => {
|
||||
const files = getDataTransferFiles(evt.clipboardData);
|
||||
if (files) onPaste(files);
|
||||
},
|
||||
[onPaste]
|
||||
);
|
||||
15
src/app/hooks/useFilePicker.ts
Normal file
15
src/app/hooks/useFilePicker.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useCallback } from 'react';
|
||||
import { selectFile } from '../utils/dom';
|
||||
|
||||
export const useFilePicker = <M extends boolean | undefined = undefined>(
|
||||
onSelect: (file: M extends true ? File[] : File) => void,
|
||||
multiple?: M
|
||||
) =>
|
||||
useCallback(
|
||||
async (accept: string) => {
|
||||
const file = await selectFile(accept, multiple);
|
||||
if (!file) return;
|
||||
onSelect(file);
|
||||
},
|
||||
[multiple, onSelect]
|
||||
);
|
||||
9
src/app/hooks/useForceUpdate.ts
Normal file
9
src/app/hooks/useForceUpdate.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { useReducer } from 'react';
|
||||
|
||||
const reducer = (prevCount: number): number => prevCount + 1;
|
||||
|
||||
export const useForceUpdate = (): [number, () => void] => {
|
||||
const [state, dispatch] = useReducer<typeof reducer>(reducer, 0);
|
||||
|
||||
return [state, dispatch];
|
||||
};
|
||||
48
src/app/hooks/useImagePacks.ts
Normal file
48
src/app/hooks/useImagePacks.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { ClientEvent, MatrixClient, MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { getRelevantPacks, ImagePack, PackUsage } from '../plugins/custom-emoji';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { useForceUpdate } from './useForceUpdate';
|
||||
|
||||
export const useRelevantImagePacks = (
|
||||
mx: MatrixClient,
|
||||
usage: PackUsage,
|
||||
rooms: Room[]
|
||||
): ImagePack[] => {
|
||||
const [forceCount, forceUpdate] = useForceUpdate();
|
||||
|
||||
const relevantPacks = useMemo(
|
||||
() => getRelevantPacks(mx, rooms).filter((pack) => pack.getImagesFor(usage).length > 0),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[mx, usage, rooms, forceCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdate = (event: MatrixEvent) => {
|
||||
if (
|
||||
event.getType() === AccountDataEvent.PoniesEmoteRooms ||
|
||||
event.getType() === AccountDataEvent.PoniesUserEmotes
|
||||
) {
|
||||
forceUpdate();
|
||||
}
|
||||
const eventRoomId = event.getRoomId();
|
||||
if (
|
||||
eventRoomId &&
|
||||
event.getType() === StateEvent.PoniesRoomEmotes &&
|
||||
rooms.find((room) => room.roomId === eventRoomId)
|
||||
) {
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(ClientEvent.AccountData, handleUpdate);
|
||||
mx.on(RoomStateEvent.Events, handleUpdate);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.AccountData, handleUpdate);
|
||||
mx.removeListener(RoomStateEvent.Events, handleUpdate);
|
||||
};
|
||||
}, [mx, rooms, forceUpdate]);
|
||||
|
||||
return relevantPacks;
|
||||
};
|
||||
10
src/app/hooks/useKeyDown.ts
Normal file
10
src/app/hooks/useKeyDown.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useEffect } from 'react';
|
||||
|
||||
export const useKeyDown = (target: Window, callback: (evt: KeyboardEvent) => void) => {
|
||||
useEffect(() => {
|
||||
target.addEventListener('keydown', callback);
|
||||
return () => {
|
||||
target.removeEventListener('keydown', callback);
|
||||
};
|
||||
}, [target, callback]);
|
||||
};
|
||||
12
src/app/hooks/useMatrixClient.ts
Normal file
12
src/app/hooks/useMatrixClient.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
|
||||
const MatrixClientContext = createContext<MatrixClient | null>(null);
|
||||
|
||||
export const MatrixClientProvider = MatrixClientContext.Provider;
|
||||
|
||||
export function useMatrixClient(): MatrixClient {
|
||||
const mx = useContext(MatrixClientContext);
|
||||
if (!mx) throw new Error('MatrixClient not initialized!');
|
||||
return mx;
|
||||
}
|
||||
86
src/app/hooks/usePowerLevels.ts
Normal file
86
src/app/hooks/usePowerLevels.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { Room } from 'matrix-js-sdk';
|
||||
import { useCallback } from 'react';
|
||||
import { useStateEvent } from './useStateEvent';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
|
||||
enum DefaultPowerLevels {
|
||||
usersDefault = 0,
|
||||
stateDefault = 50,
|
||||
eventsDefault = 0,
|
||||
invite = 0,
|
||||
redact = 50,
|
||||
kick = 50,
|
||||
ban = 50,
|
||||
historical = 0,
|
||||
}
|
||||
|
||||
interface IPowerLevels {
|
||||
users_default?: number;
|
||||
state_default?: number;
|
||||
events_default?: number;
|
||||
historical?: number;
|
||||
invite?: number;
|
||||
redact?: number;
|
||||
kick?: number;
|
||||
ban?: number;
|
||||
|
||||
events?: Record<string, number>;
|
||||
users?: Record<string, number>;
|
||||
notifications?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function usePowerLevels(room: Room) {
|
||||
const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
|
||||
const powerLevels: IPowerLevels = powerLevelsEvent?.getContent() ?? DefaultPowerLevels;
|
||||
|
||||
const getPowerLevel = useCallback(
|
||||
(userId: string) => {
|
||||
const { users_default: usersDefault, users } = powerLevels;
|
||||
if (users && typeof users[userId] === 'number') {
|
||||
return users[userId];
|
||||
}
|
||||
return usersDefault ?? DefaultPowerLevels.usersDefault;
|
||||
},
|
||||
[powerLevels]
|
||||
);
|
||||
|
||||
const canSendEvent = useCallback(
|
||||
(eventType: string | undefined, powerLevel: number) => {
|
||||
const { events, events_default: eventsDefault } = powerLevels;
|
||||
if (events && eventType && typeof events[eventType] === 'string') {
|
||||
return powerLevel >= events[eventType];
|
||||
}
|
||||
return powerLevel >= (eventsDefault ?? DefaultPowerLevels.eventsDefault);
|
||||
},
|
||||
[powerLevels]
|
||||
);
|
||||
|
||||
const canSendStateEvent = useCallback(
|
||||
(eventType: string | undefined, powerLevel: number) => {
|
||||
const { events, state_default: stateDefault } = powerLevels;
|
||||
if (events && eventType && typeof events[eventType] === 'number') {
|
||||
return powerLevel >= events[eventType];
|
||||
}
|
||||
return powerLevel >= (stateDefault ?? DefaultPowerLevels.stateDefault);
|
||||
},
|
||||
[powerLevels]
|
||||
);
|
||||
|
||||
const canDoAction = useCallback(
|
||||
(action: 'invite' | 'redact' | 'kick' | 'ban' | 'historical', powerLevel: number) => {
|
||||
const requiredPL = powerLevels[action];
|
||||
if (typeof requiredPL === 'number') {
|
||||
return powerLevel >= requiredPL;
|
||||
}
|
||||
return powerLevel >= DefaultPowerLevels[action];
|
||||
},
|
||||
[powerLevels]
|
||||
);
|
||||
|
||||
return {
|
||||
getPowerLevel,
|
||||
canSendEvent,
|
||||
canSendStateEvent,
|
||||
canDoAction,
|
||||
};
|
||||
}
|
||||
23
src/app/hooks/useRecentEmoji.ts
Normal file
23
src/app/hooks/useRecentEmoji.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||
import { getRecentEmojis } from '../plugins/recent-emoji';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import { IEmoji } from '../plugins/emoji';
|
||||
|
||||
export const useRecentEmoji = (mx: MatrixClient, limit?: number): IEmoji[] => {
|
||||
const [recentEmoji, setRecentEmoji] = useState(() => getRecentEmojis(mx, limit));
|
||||
|
||||
useEffect(() => {
|
||||
const handleAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() !== AccountDataEvent.ElementRecentEmoji) return;
|
||||
setRecentEmoji(getRecentEmojis(mx, limit));
|
||||
};
|
||||
|
||||
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||
};
|
||||
}, [mx, limit]);
|
||||
|
||||
return recentEmoji;
|
||||
};
|
||||
24
src/app/hooks/useResizeObserver.ts
Normal file
24
src/app/hooks/useResizeObserver.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
export type OnResizeCallback = (entries: ResizeObserverEntry[]) => void;
|
||||
|
||||
export const getResizeObserverEntry = (
|
||||
target: Element,
|
||||
entries: ResizeObserverEntry[]
|
||||
): ResizeObserverEntry | undefined => entries.find((entry) => entry.target === target);
|
||||
|
||||
export const useResizeObserver = (
|
||||
element: Element | null,
|
||||
onResizeCallback: OnResizeCallback
|
||||
): ResizeObserver => {
|
||||
const resizeObserver = useMemo(() => new ResizeObserver(onResizeCallback), [onResizeCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (element) resizeObserver.observe(element);
|
||||
return () => {
|
||||
if (element) resizeObserver.unobserve(element);
|
||||
};
|
||||
}, [resizeObserver, element]);
|
||||
|
||||
return resizeObserver;
|
||||
};
|
||||
34
src/app/hooks/useRoomMembers.ts
Normal file
34
src/app/hooks/useRoomMembers.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAlive } from './useAlive';
|
||||
|
||||
export const useRoomMembers = (mx: MatrixClient, roomId: string): RoomMember[] => {
|
||||
const [members, setMembers] = useState<RoomMember[]>([]);
|
||||
const alive = useAlive();
|
||||
|
||||
useEffect(() => {
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
const updateMemberList = (event?: MatrixEvent) => {
|
||||
if (!room || !alive || (event && event.getRoomId() !== roomId)) return;
|
||||
setMembers(room.getMembers());
|
||||
};
|
||||
|
||||
if (room) {
|
||||
updateMemberList();
|
||||
room.loadMembersIfNeeded().then(() => {
|
||||
if (!alive) return;
|
||||
updateMemberList();
|
||||
});
|
||||
}
|
||||
|
||||
mx.on(RoomMemberEvent.Membership, updateMemberList);
|
||||
mx.on(RoomMemberEvent.PowerLevel, updateMemberList);
|
||||
return () => {
|
||||
mx.removeListener(RoomMemberEvent.Membership, updateMemberList);
|
||||
mx.removeListener(RoomMemberEvent.PowerLevel, updateMemberList);
|
||||
};
|
||||
}, [mx, roomId, alive]);
|
||||
|
||||
return members;
|
||||
};
|
||||
32
src/app/hooks/useStateEvent.ts
Normal file
32
src/app/hooks/useStateEvent.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Room } from 'matrix-js-sdk';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useStateEventCallback } from './useStateEventCallback';
|
||||
import { useForceUpdate } from './useForceUpdate';
|
||||
import { getStateEvent } from '../utils/room';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
|
||||
export const useStateEvent = (room: Room, eventType: StateEvent, stateKey = '') => {
|
||||
const [updateCount, forceUpdate] = useForceUpdate();
|
||||
|
||||
useStateEventCallback(
|
||||
room.client,
|
||||
useCallback(
|
||||
(event) => {
|
||||
if (
|
||||
event.getRoomId() === room.roomId &&
|
||||
event.getType() === eventType &&
|
||||
event.getStateKey() === stateKey
|
||||
) {
|
||||
forceUpdate();
|
||||
}
|
||||
},
|
||||
[room, eventType, stateKey, forceUpdate]
|
||||
)
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => getStateEvent(room, eventType, stateKey),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[room, eventType, stateKey, updateCount]
|
||||
);
|
||||
};
|
||||
17
src/app/hooks/useStateEventCallback.ts
Normal file
17
src/app/hooks/useStateEventCallback.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { MatrixClient, MatrixEvent, RoomState, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export type StateEventCallback = (
|
||||
event: MatrixEvent,
|
||||
state: RoomState,
|
||||
lastStateEvent: MatrixEvent | null
|
||||
) => void;
|
||||
|
||||
export const useStateEventCallback = (mx: MatrixClient, onStateEvent: StateEventCallback) => {
|
||||
useEffect(() => {
|
||||
mx.on(RoomStateEvent.Events, onStateEvent);
|
||||
return () => {
|
||||
mx.removeListener(RoomStateEvent.Events, onStateEvent);
|
||||
};
|
||||
}, [mx, onStateEvent]);
|
||||
};
|
||||
28
src/app/hooks/useStateEvents.ts
Normal file
28
src/app/hooks/useStateEvents.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { useForceUpdate } from './useForceUpdate';
|
||||
import { useStateEventCallback } from './useStateEventCallback';
|
||||
import { getStateEvents } from '../utils/room';
|
||||
|
||||
export const useStateEvents = (room: Room, eventType: StateEvent) => {
|
||||
const [updateCount, forceUpdate] = useForceUpdate();
|
||||
|
||||
useStateEventCallback(
|
||||
room.client,
|
||||
useCallback(
|
||||
(event) => {
|
||||
if (event.getRoomId() === room.roomId && event.getType() === eventType) {
|
||||
forceUpdate();
|
||||
}
|
||||
},
|
||||
[room, eventType, forceUpdate]
|
||||
)
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => getStateEvents(room, eventType),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[room, eventType, updateCount]
|
||||
);
|
||||
};
|
||||
41
src/app/hooks/useThrottle.ts
Normal file
41
src/app/hooks/useThrottle.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
|
||||
export interface ThrottleOptions {
|
||||
wait?: number;
|
||||
immediate?: boolean;
|
||||
}
|
||||
|
||||
export type ThrottleCallback<T extends unknown[]> = (...args: T) => void;
|
||||
|
||||
export function useThrottle<T extends unknown[]>(
|
||||
callback: ThrottleCallback<T>,
|
||||
options?: ThrottleOptions
|
||||
): ThrottleCallback<T> {
|
||||
const timeoutIdRef = useRef<number>();
|
||||
const argsRef = useRef<T>();
|
||||
const { wait, immediate } = options ?? {};
|
||||
|
||||
const debounceCallback = useCallback(
|
||||
(...cbArgs: T) => {
|
||||
argsRef.current = cbArgs;
|
||||
|
||||
if (timeoutIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (immediate) {
|
||||
callback(...cbArgs);
|
||||
}
|
||||
|
||||
timeoutIdRef.current = window.setTimeout(() => {
|
||||
if (argsRef.current) {
|
||||
callback(...argsRef.current);
|
||||
}
|
||||
argsRef.current = undefined;
|
||||
timeoutIdRef.current = undefined;
|
||||
}, wait);
|
||||
},
|
||||
[callback, wait, immediate]
|
||||
);
|
||||
|
||||
return debounceCallback;
|
||||
}
|
||||
42
src/app/hooks/useTypingStatusUpdater.ts
Normal file
42
src/app/hooks/useTypingStatusUpdater.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { useMemo, useRef } from 'react';
|
||||
|
||||
type TypingStatusUpdater = (typing: boolean) => void;
|
||||
|
||||
const TYPING_TIMEOUT_MS = 5000; // 5 seconds
|
||||
|
||||
export const useTypingStatusUpdater = (mx: MatrixClient, roomId: string): TypingStatusUpdater => {
|
||||
const statusSentTsRef = useRef<number>(0);
|
||||
|
||||
const sendTypingStatus: TypingStatusUpdater = useMemo(() => {
|
||||
statusSentTsRef.current = 0;
|
||||
return (typing) => {
|
||||
if (typing) {
|
||||
if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
mx.sendTyping(roomId, true, TYPING_TIMEOUT_MS);
|
||||
const sentTs = Date.now();
|
||||
statusSentTsRef.current = sentTs;
|
||||
|
||||
// Don't believe server will timeout typing status;
|
||||
// Clear typing status after timeout if already not;
|
||||
setTimeout(() => {
|
||||
if (statusSentTsRef.current === sentTs) {
|
||||
mx.sendTyping(roomId, false, TYPING_TIMEOUT_MS);
|
||||
statusSentTsRef.current = 0;
|
||||
}
|
||||
}, TYPING_TIMEOUT_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - statusSentTsRef.current < TYPING_TIMEOUT_MS) {
|
||||
mx.sendTyping(roomId, false, TYPING_TIMEOUT_MS);
|
||||
}
|
||||
statusSentTsRef.current = 0;
|
||||
};
|
||||
}, [mx, roomId]);
|
||||
|
||||
return sendTypingStatus;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue