mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
perf(app): cut idle renderer CPU and unbounded memory growth (#1724)
This commit is contained in:
1 parent
b4a5eb77a8
commit
5660972c71
13 files changed
+453
-345
No files matched your search
@@ -1,209 +0,0 @@
|
|||||||
// @vitest-environment happy-dom
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
|
|
||||||
import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList';
|
|
||||||
import {act} from 'react';
|
|
||||||
import {createRoot, type Root} from 'react-dom/client';
|
|
||||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
||||||
|
|
||||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
|
||||||
|
|
||||||
function RovingFocusProbe() {
|
|
||||||
const listRef = useRovingFocusList<HTMLDivElement>({
|
|
||||||
autoFocusFirst: true,
|
|
||||||
focusableSelector: '[data-roving-focus="true"]',
|
|
||||||
manageTabIndex: true,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={listRef}
|
|
||||||
role="menu"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menu"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
data-roving-focus="true"
|
|
||||||
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button"
|
|
||||||
>
|
|
||||||
First
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
data-roving-focus="true"
|
|
||||||
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button--2"
|
|
||||||
>
|
|
||||||
Second
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
role="menuitemcheckbox"
|
|
||||||
aria-checked="false"
|
|
||||||
tabIndex={0}
|
|
||||||
data-roving-focus="true"
|
|
||||||
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitemcheckbox"
|
|
||||||
>
|
|
||||||
Third
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ManualEntryRovingFocusProbe() {
|
|
||||||
const listRef = useRovingFocusList<HTMLDivElement>({
|
|
||||||
focusableSelector: '[data-roving-focus="true"]',
|
|
||||||
manageTabIndex: true,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={listRef}
|
|
||||||
role="menu"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
tabIndex={-1}
|
|
||||||
data-autofocus
|
|
||||||
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menu"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
data-roving-focus="true"
|
|
||||||
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button"
|
|
||||||
>
|
|
||||||
First
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
data-roving-focus="true"
|
|
||||||
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button--2"
|
|
||||||
>
|
|
||||||
Second
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const press = (target: Element, key: string): KeyboardEvent => {
|
|
||||||
const event = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true});
|
|
||||||
act(() => {
|
|
||||||
target.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
return event;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getItems = (container: HTMLElement): Array<HTMLElement> =>
|
|
||||||
Array.from(container.querySelectorAll<HTMLElement>('[data-roving-focus="true"]'));
|
|
||||||
|
|
||||||
describe('useRovingFocusList', () => {
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLDivElement | null = null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
act(() => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
root = null;
|
|
||||||
container?.remove();
|
|
||||||
container = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('moves focus with vertical arrow keys while keeping one tabbable item', () => {
|
|
||||||
act(() => {
|
|
||||||
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe" />);
|
|
||||||
});
|
|
||||||
const items = getItems(container!);
|
|
||||||
|
|
||||||
expect(document.activeElement).toBe(items[0]);
|
|
||||||
expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]);
|
|
||||||
|
|
||||||
const down = press(items[0], 'ArrowDown');
|
|
||||||
expect(down.defaultPrevented).toBe(true);
|
|
||||||
expect(document.activeElement).toBe(items[1]);
|
|
||||||
expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]);
|
|
||||||
|
|
||||||
press(items[1], 'ArrowDown');
|
|
||||||
expect(document.activeElement).toBe(items[2]);
|
|
||||||
expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
|
|
||||||
|
|
||||||
press(items[2], 'ArrowUp');
|
|
||||||
expect(document.activeElement).toBe(items[1]);
|
|
||||||
expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports Home and End in a managed roving list', () => {
|
|
||||||
act(() => {
|
|
||||||
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe--2" />);
|
|
||||||
});
|
|
||||||
const items = getItems(container!);
|
|
||||||
|
|
||||||
press(items[0], 'End');
|
|
||||||
expect(document.activeElement).toBe(items[2]);
|
|
||||||
expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
|
|
||||||
|
|
||||||
press(items[2], 'Home');
|
|
||||||
expect(document.activeElement).toBe(items[0]);
|
|
||||||
expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not rewrite the roving-managed marker attribute on every navigation', () => {
|
|
||||||
act(() => {
|
|
||||||
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe--3" />);
|
|
||||||
});
|
|
||||||
const items = getItems(container!);
|
|
||||||
expect(items.every((item) => item.hasAttribute('data-roving-focus-managed'))).toBe(true);
|
|
||||||
|
|
||||||
// Observe only the marker attribute, after it has already been applied once.
|
|
||||||
const observer = new MutationObserver(() => {});
|
|
||||||
observer.observe(container!, {
|
|
||||||
subtree: true,
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['data-roving-focus-managed'],
|
|
||||||
});
|
|
||||||
|
|
||||||
press(items[0], 'ArrowDown');
|
|
||||||
press(items[1], 'ArrowDown');
|
|
||||||
press(items[2], 'ArrowUp');
|
|
||||||
press(items[1], 'ArrowUp');
|
|
||||||
press(items[0], 'End');
|
|
||||||
press(items[2], 'Home');
|
|
||||||
|
|
||||||
const records = observer.takeRecords();
|
|
||||||
observer.disconnect();
|
|
||||||
|
|
||||||
// The marker is write-once and only ever read via hasAttribute, so navigating
|
|
||||||
// must not touch it again. Rewriting it invalidates style for every list item
|
|
||||||
// on every keystroke, which is what drove idle style-recalc churn.
|
|
||||||
expect(records.length).toBe(0);
|
|
||||||
expect(items.every((item) => item.hasAttribute('data-roving-focus-managed'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can start on the container with no focused item before ArrowDown enters the list', () => {
|
|
||||||
act(() => {
|
|
||||||
root?.render(
|
|
||||||
<ManualEntryRovingFocusProbe data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe" />,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const menu = container?.querySelector<HTMLElement>('[role="menu"]');
|
|
||||||
const items = getItems(container!);
|
|
||||||
expect(menu).toBeInstanceOf(HTMLElement);
|
|
||||||
expect(document.activeElement).not.toBe(items[0]);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
menu?.focus();
|
|
||||||
});
|
|
||||||
expect(document.activeElement).toBe(menu);
|
|
||||||
expect(items.map((item) => item.tabIndex)).toEqual([0, -1]);
|
|
||||||
|
|
||||||
const down = press(menu!, 'ArrowDown');
|
|
||||||
expect(down.defaultPrevented).toBe(true);
|
|
||||||
expect(document.activeElement).toBe(items[0]);
|
|
||||||
expect(items.map((item) => item.tabIndex)).toEqual([0, -1]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
reduceGIFVRenderState,
|
reduceGIFVRenderState,
|
||||||
} from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine';
|
} from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine';
|
||||||
import {GifIndicator} from '@app/features/channel/components/embeds/media/GifIndicator';
|
import {GifIndicator} from '@app/features/channel/components/embeds/media/GifIndicator';
|
||||||
|
import {useGifViewportGate} from '@app/features/channel/components/embeds/media/GifViewportGate';
|
||||||
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
|
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
|
||||||
import {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer';
|
import {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer';
|
||||||
import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes';
|
import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes';
|
||||||
@@ -985,9 +986,15 @@ export const EmbedGif: FC<
|
|||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const animatedImageRef = useRef<HTMLImageElement>(null);
|
const animatedImageRef = useRef<HTMLImageElement>(null);
|
||||||
const freezeCanvasRef = useRef<HTMLCanvasElement>(null);
|
const freezeCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({
|
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
|
||||||
disabled: !isMobile,
|
const {
|
||||||
|
ref: visibilityRef,
|
||||||
|
loadMedia: shouldLoadMedia,
|
||||||
|
animate: shouldAnimate,
|
||||||
|
} = useGifViewportGate<HTMLDivElement>({
|
||||||
|
element: containerElement,
|
||||||
rememberKey: proxyURL,
|
rememberKey: proxyURL,
|
||||||
|
shouldBlur,
|
||||||
});
|
});
|
||||||
const setContainerRef = useCallback(
|
const setContainerRef = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
@@ -1018,8 +1025,6 @@ export const EmbedGif: FC<
|
|||||||
Math.round(displayHeight * 2),
|
Math.round(displayHeight * 2),
|
||||||
);
|
);
|
||||||
const animatedImageSourceRef = useRef(optimizedAnimatedURL);
|
const animatedImageSourceRef = useRef(optimizedAnimatedURL);
|
||||||
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
|
|
||||||
const shouldLoadMedia = isNearViewport && !shouldBlur;
|
|
||||||
const {
|
const {
|
||||||
loaded,
|
loaded,
|
||||||
error,
|
error,
|
||||||
@@ -1049,7 +1054,7 @@ export const EmbedGif: FC<
|
|||||||
status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle',
|
status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle',
|
||||||
});
|
});
|
||||||
const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed());
|
const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed());
|
||||||
const shouldUseDecoder = shouldLoadMedia && hasStartedAnimating;
|
const shouldUseDecoder = shouldAnimate && hasStartedAnimating;
|
||||||
const decoderPlaying =
|
const decoderPlaying =
|
||||||
shouldUseDecoder && decoderRequested && animatedMediaPlaybackAllowed && (gifAutoPlay || isHoveredState);
|
shouldUseDecoder && decoderRequested && animatedMediaPlaybackAllowed && (gifAutoPlay || isHoveredState);
|
||||||
const decoderState = useAnimatedImageDecoder({
|
const decoderState = useAnimatedImageDecoder({
|
||||||
@@ -1061,7 +1066,7 @@ export const EmbedGif: FC<
|
|||||||
const useDecoder =
|
const useDecoder =
|
||||||
shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error;
|
shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error;
|
||||||
const decoderCanvasVisible = useDecoder && decoderState.loaded;
|
const decoderCanvasVisible = useDecoder && decoderState.loaded;
|
||||||
const shouldRenderFreezeFrame = !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
|
const shouldRenderFreezeFrame = shouldAnimate && !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
|
||||||
const setAnimatedImageRef = useCallback(
|
const setAnimatedImageRef = useCallback(
|
||||||
(node: HTMLImageElement | null) => {
|
(node: HTMLImageElement | null) => {
|
||||||
animatedImageRef.current = node;
|
animatedImageRef.current = node;
|
||||||
@@ -1406,7 +1411,7 @@ export const EmbedGif: FC<
|
|||||||
...(aspectRatio ? {aspectRatio} : {}),
|
...(aspectRatio ? {aspectRatio} : {}),
|
||||||
} as React.CSSProperties;
|
} as React.CSSProperties;
|
||||||
const shouldUseAnimatedImage =
|
const shouldUseAnimatedImage =
|
||||||
shouldLoadMedia &&
|
shouldAnimate &&
|
||||||
animatedMediaPlaybackAllowed &&
|
animatedMediaPlaybackAllowed &&
|
||||||
!decoderCanvasVisible &&
|
!decoderCanvasVisible &&
|
||||||
(gifAutoPlay || (hasStartedAnimating && isHoveredState));
|
(gifAutoPlay || (hasStartedAnimating && isHoveredState));
|
||||||
|
|||||||
@@ -356,9 +356,9 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
|||||||
() => {
|
() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setLoadedPosterSrc(posterSrc);
|
setLoadedPosterSrc(posterSrc);
|
||||||
const image = ImageCacheUtils.getImage(posterSrc);
|
const posterSize = ImageCacheUtils.getImageSize(posterSrc);
|
||||||
if (active && image && image.naturalWidth > 0 && image.naturalHeight > 0) {
|
if (active && posterSize) {
|
||||||
setPosterNaturalDimensions({src: posterSrc, width: image.naturalWidth, height: image.naturalHeight});
|
setPosterNaturalDimensions({src: posterSrc, width: posterSize.width, height: posterSize.height});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
|
||||||
|
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
|
||||||
|
const ANIMATION_ROOT_MARGIN = '600px 0px';
|
||||||
|
|
||||||
|
export interface GifViewportGate {
|
||||||
|
loadMedia: boolean;
|
||||||
|
animate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGifViewportGate({
|
||||||
|
isNearViewport,
|
||||||
|
isInViewport,
|
||||||
|
shouldBlur,
|
||||||
|
}: {
|
||||||
|
isNearViewport: boolean;
|
||||||
|
isInViewport: boolean;
|
||||||
|
shouldBlur: boolean;
|
||||||
|
}): GifViewportGate {
|
||||||
|
if (shouldBlur) return {loadMedia: false, animate: false};
|
||||||
|
return {loadMedia: isNearViewport, animate: isNearViewport && isInViewport};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGifViewportGate<T extends Element>({
|
||||||
|
element,
|
||||||
|
rememberKey,
|
||||||
|
shouldBlur,
|
||||||
|
}: {
|
||||||
|
element: Element | null;
|
||||||
|
rememberKey: string;
|
||||||
|
shouldBlur: boolean;
|
||||||
|
}): GifViewportGate & {ref: (node: T | null) => void} {
|
||||||
|
const {ref, isNearViewport} = useNearViewport<T>({rememberKey});
|
||||||
|
const [isInViewport, setIsInViewport] = useState(() => typeof IntersectionObserver === 'undefined');
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
setIsInViewport(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (element == null) {
|
||||||
|
setIsInViewport(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return observeIntersection(
|
||||||
|
element,
|
||||||
|
(entry) => {
|
||||||
|
setIsInViewport(entry.isIntersecting || entry.intersectionRatio > 0);
|
||||||
|
},
|
||||||
|
{rootMargin: ANIMATION_ROOT_MARGIN},
|
||||||
|
);
|
||||||
|
}, [element]);
|
||||||
|
return {ref, ...resolveGifViewportGate({isNearViewport, isInViewport, shouldBlur})};
|
||||||
|
}
|
||||||
@@ -9,17 +9,6 @@ export interface TrackedRecipientUser {
|
|||||||
readonly globalName?: string | null;
|
readonly globalName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the MobX tracking key for group-DM display names.
|
|
||||||
*
|
|
||||||
* Only the recipients of tracked group DMs are read, so the observing reaction
|
|
||||||
* depends on those users alone rather than on the whole (unbounded) user cache.
|
|
||||||
* Reading a recipient that is not cached yet still registers a dependency on
|
|
||||||
* that key, so a user arriving later re-triggers the reaction.
|
|
||||||
*
|
|
||||||
* Kept as a standalone function rather than a class method because
|
|
||||||
* makeAutoObservable converts methods into actions, and actions run untracked.
|
|
||||||
*/
|
|
||||||
export const trackedRecipientNameKey = (
|
export const trackedRecipientNameKey = (
|
||||||
snapshots: ReadonlyMap<string, TrackedRecipientSnapshot>,
|
snapshots: ReadonlyMap<string, TrackedRecipientSnapshot>,
|
||||||
users: Record<string, TrackedRecipientUser | undefined>,
|
users: Record<string, TrackedRecipientUser | undefined>,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/Ch
|
|||||||
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||||
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||||
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
|
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||||
import {action, makeAutoObservable} from 'mobx';
|
import {action, makeAutoObservable, observable} from 'mobx';
|
||||||
|
|
||||||
const EMPTY_CHANNELS: ReadonlyArray<Channel> = Object.freeze([]);
|
const EMPTY_CHANNELS: ReadonlyArray<Channel> = Object.freeze([]);
|
||||||
const sortDMs = (a: Channel, b: Channel) => {
|
const sortDMs = (a: Channel, b: Channel) => {
|
||||||
@@ -30,12 +30,37 @@ const sortDMs = (a: Channel, b: Channel) => {
|
|||||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPrivateChannel = (channel: Channel) =>
|
||||||
|
channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
|
||||||
|
|
||||||
|
const insertSorted = (channels: ReadonlyArray<Channel>, channel: Channel): Array<Channel> => {
|
||||||
|
let low = 0;
|
||||||
|
let high = channels.length;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = (low + high) >>> 1;
|
||||||
|
if (ChannelUtils.compareChannels(channels[middle], channel) <= 0) {
|
||||||
|
low = middle + 1;
|
||||||
|
} else {
|
||||||
|
high = middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = channels.slice();
|
||||||
|
next.splice(low, 0, channel);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
class Channels {
|
class Channels {
|
||||||
private readonly channelsById = new Map<string, Channel>();
|
private readonly channelsById = new Map<string, Channel>();
|
||||||
|
private readonly channelsByGuildId = new Map<string, ReadonlyArray<Channel>>();
|
||||||
|
private privateChannelList: ReadonlyArray<Channel> = EMPTY_CHANNELS;
|
||||||
private readonly optimisticChannelBackups = new Map<string, Channel>();
|
private readonly optimisticChannelBackups = new Map<string, Channel>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this, {}, {autoBind: true});
|
makeAutoObservable<this, 'channelsByGuildId' | 'privateChannelList'>(
|
||||||
|
this,
|
||||||
|
{channelsByGuildId: observable.shallow, privateChannelList: observable.ref},
|
||||||
|
{autoBind: true},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get channels(): ReadonlyArray<Channel> {
|
get channels(): ReadonlyArray<Channel> {
|
||||||
@@ -51,31 +76,14 @@ class Channels {
|
|||||||
readonly dms: ReadonlyArray<Channel>;
|
readonly dms: ReadonlyArray<Channel>;
|
||||||
readonly privateChannels: ReadonlyArray<Channel>;
|
readonly privateChannels: ReadonlyArray<Channel>;
|
||||||
} {
|
} {
|
||||||
const byGuild = new Map<string, Array<Channel>>();
|
return {byGuild: this.channelsByGuildId, dms: this.dmChannels, privateChannels: this.privateChannelList};
|
||||||
const dms: Array<Channel> = [];
|
|
||||||
const privateChannels: Array<Channel> = [];
|
|
||||||
for (const channel of this.channelsById.values()) {
|
|
||||||
if (channel.guildId) {
|
|
||||||
let list = byGuild.get(channel.guildId);
|
|
||||||
if (!list) {
|
|
||||||
list = [];
|
|
||||||
byGuild.set(channel.guildId, list);
|
|
||||||
}
|
|
||||||
list.push(channel);
|
|
||||||
} else if (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM) {
|
|
||||||
privateChannels.push(channel);
|
|
||||||
dms.push(channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const list of byGuild.values()) {
|
|
||||||
list.sort(ChannelUtils.compareChannels);
|
|
||||||
}
|
|
||||||
dms.sort(sortDMs);
|
|
||||||
return {byGuild, dms, privateChannels};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get dmChannels(): ReadonlyArray<Channel> {
|
get dmChannels(): ReadonlyArray<Channel> {
|
||||||
return this.channelGroups.dms;
|
if (this.privateChannelList.length === 0) {
|
||||||
|
return EMPTY_CHANNELS;
|
||||||
|
}
|
||||||
|
return this.privateChannelList.slice().sort(sortDMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
getChannel(channelId: string): Channel | undefined {
|
getChannel(channelId: string): Channel | undefined {
|
||||||
@@ -83,11 +91,11 @@ class Channels {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getGuildChannels(guildId: string): ReadonlyArray<Channel> {
|
getGuildChannels(guildId: string): ReadonlyArray<Channel> {
|
||||||
return this.channelGroups.byGuild.get(guildId) ?? EMPTY_CHANNELS;
|
return this.channelsByGuildId.get(guildId) ?? EMPTY_CHANNELS;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPrivateChannels(): ReadonlyArray<Channel> {
|
getPrivateChannels(): ReadonlyArray<Channel> {
|
||||||
return this.channelGroups.privateChannels;
|
return this.privateChannelList;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -100,8 +108,7 @@ class Channels {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.optimisticChannelBackups.set(channelId, channel);
|
this.optimisticChannelBackups.set(channelId, channel);
|
||||||
this.channelsById.delete(channelId);
|
this.deleteChannelRecord(channelId);
|
||||||
ChannelDisplayName.removeChannel(channelId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -122,7 +129,16 @@ class Channels {
|
|||||||
@action
|
@action
|
||||||
private removeChannel(channelId: string): void {
|
private removeChannel(channelId: string): void {
|
||||||
this.clearOptimisticallyRemovedChannel(channelId);
|
this.clearOptimisticallyRemovedChannel(channelId);
|
||||||
|
this.deleteChannelRecord(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
private deleteChannelRecord(channelId: string): void {
|
||||||
|
const channel = this.channelsById.get(channelId);
|
||||||
this.channelsById.delete(channelId);
|
this.channelsById.delete(channelId);
|
||||||
|
if (channel) {
|
||||||
|
this.removeChannelFromIndex(channel);
|
||||||
|
}
|
||||||
ChannelDisplayName.removeChannel(channelId);
|
ChannelDisplayName.removeChannel(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,12 +150,95 @@ class Channels {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.channelsById.set(record.id, record);
|
this.channelsById.set(record.id, record);
|
||||||
|
this.indexChannel(existing, record);
|
||||||
ChannelDisplayName.syncChannel(record);
|
ChannelDisplayName.syncChannel(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
private indexChannel(previous: Channel | undefined, next: Channel): void {
|
||||||
|
if (previous && previous.guildId === next.guildId && isPrivateChannel(previous) === isPrivateChannel(next)) {
|
||||||
|
this.replaceChannelInIndex(previous, next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previous) {
|
||||||
|
this.removeChannelFromIndex(previous);
|
||||||
|
}
|
||||||
|
this.addChannelToIndex(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
private addChannelToIndex(channel: Channel): void {
|
||||||
|
if (channel.guildId) {
|
||||||
|
const list = this.channelsByGuildId.get(channel.guildId) ?? EMPTY_CHANNELS;
|
||||||
|
this.channelsByGuildId.set(channel.guildId, insertSorted(list, channel));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPrivateChannel(channel)) {
|
||||||
|
this.privateChannelList = [...this.privateChannelList, channel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
private removeChannelFromIndex(channel: Channel): void {
|
||||||
|
if (channel.guildId) {
|
||||||
|
const list = this.channelsByGuildId.get(channel.guildId);
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = list.filter((entry) => entry.id !== channel.id);
|
||||||
|
if (next.length === list.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next.length === 0) {
|
||||||
|
this.channelsByGuildId.delete(channel.guildId);
|
||||||
|
} else {
|
||||||
|
this.channelsByGuildId.set(channel.guildId, next);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isPrivateChannel(channel)) {
|
||||||
|
const next = this.privateChannelList.filter((entry) => entry.id !== channel.id);
|
||||||
|
if (next.length !== this.privateChannelList.length) {
|
||||||
|
this.privateChannelList = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
private replaceChannelInIndex(previous: Channel, next: Channel): void {
|
||||||
|
if (next.guildId) {
|
||||||
|
const list = this.channelsByGuildId.get(next.guildId);
|
||||||
|
const index = list ? list.findIndex((entry) => entry.id === next.id) : -1;
|
||||||
|
if (!list || index === -1) {
|
||||||
|
this.addChannelToIndex(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = list.slice();
|
||||||
|
updated[index] = next;
|
||||||
|
if (ChannelUtils.compareChannels(previous, next) !== 0) {
|
||||||
|
updated.sort(ChannelUtils.compareChannels);
|
||||||
|
}
|
||||||
|
this.channelsByGuildId.set(next.guildId, updated);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isPrivateChannel(next)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const index = this.privateChannelList.findIndex((entry) => entry.id === next.id);
|
||||||
|
if (index === -1) {
|
||||||
|
this.addChannelToIndex(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = this.privateChannelList.slice();
|
||||||
|
updated[index] = next;
|
||||||
|
this.privateChannelList = updated;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleConnectionOpen({channels}: {channels: ReadonlyArray<WireChannel>}): void {
|
handleConnectionOpen({channels}: {channels: ReadonlyArray<WireChannel>}): void {
|
||||||
this.channelsById.clear();
|
this.channelsById.clear();
|
||||||
|
this.channelsByGuildId.clear();
|
||||||
|
this.privateChannelList = EMPTY_CHANNELS;
|
||||||
ChannelDisplayName.clear();
|
ChannelDisplayName.clear();
|
||||||
const allRecipients = channels
|
const allRecipients = channels
|
||||||
.filter((channel) => channel.recipients && channel.recipients.length > 0)
|
.filter((channel) => channel.recipients && channel.recipients.length > 0)
|
||||||
@@ -177,7 +276,7 @@ class Channels {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const syncedChannelIds = new Set(guild.channels.map((channel) => channel.id));
|
const syncedChannelIds = new Set(guild.channels.map((channel) => channel.id));
|
||||||
const existingGuildChannels = this.channelGroups.byGuild.get(guild.id) ?? EMPTY_CHANNELS;
|
const existingGuildChannels = this.getGuildChannels(guild.id);
|
||||||
for (const channel of existingGuildChannels) {
|
for (const channel of existingGuildChannels) {
|
||||||
if (!syncedChannelIds.has(channel.id)) {
|
if (!syncedChannelIds.has(channel.id)) {
|
||||||
this.removeChannel(channel.id);
|
this.removeChannel(channel.id);
|
||||||
@@ -190,8 +289,8 @@ class Channels {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
handleGuildDelete({guildId}: {guildId: string}): void {
|
handleGuildDelete({guildId}: {guildId: string}): void {
|
||||||
const guildChannels = this.channelGroups.byGuild.get(guildId);
|
const guildChannels = this.getGuildChannels(guildId);
|
||||||
if (!guildChannels || guildChannels.length === 0) return;
|
if (guildChannels.length === 0) return;
|
||||||
const ids: Array<string> = [];
|
const ids: Array<string> = [];
|
||||||
for (const channel of guildChannels) ids.push(channel.id);
|
for (const channel of guildChannels) ids.push(channel.id);
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
@@ -269,8 +368,7 @@ class Channels {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.id === Authentication.currentUserId) {
|
if (user.id === Authentication.currentUserId) {
|
||||||
this.channelsById.delete(channelId);
|
this.deleteChannelRecord(channelId);
|
||||||
ChannelDisplayName.removeChannel(channelId);
|
|
||||||
const history = RouterUtils.getHistory();
|
const history = RouterUtils.getHistory();
|
||||||
const currentPath = history?.location.pathname ?? '';
|
const currentPath = history?.location.pathname ?? '';
|
||||||
const expectedPath = Routes.dmChannel(channelId);
|
const expectedPath = Routes.dmChannel(channelId);
|
||||||
@@ -332,8 +430,8 @@ class Channels {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void {
|
handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void {
|
||||||
const guildChannels = this.channelGroups.byGuild.get(guildId);
|
const guildChannels = this.getGuildChannels(guildId);
|
||||||
if (!guildChannels || guildChannels.length === 0) return;
|
if (guildChannels.length === 0) return;
|
||||||
const snapshot = Array.from(guildChannels);
|
const snapshot = Array.from(guildChannels);
|
||||||
for (const channel of snapshot) {
|
for (const channel of snapshot) {
|
||||||
if (!(roleId in channel.permissionOverwrites)) {
|
if (!(roleId in channel.permissionOverwrites)) {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface BatchRequest {
|
|||||||
|
|
||||||
const PARALLEL_FRAME_THRESHOLD = 8;
|
const PARALLEL_FRAME_THRESHOLD = 8;
|
||||||
const PARALLEL_PIXEL_THRESHOLD = 1_000_000;
|
const PARALLEL_PIXEL_THRESHOLD = 1_000_000;
|
||||||
|
const WORKER_IDLE_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
export class CropPipelineError extends Error {
|
export class CropPipelineError extends Error {
|
||||||
readonly code: CropWorkerErrorCode;
|
readonly code: CropWorkerErrorCode;
|
||||||
@@ -70,6 +71,8 @@ export class AnimatedImageCropWorkerManager {
|
|||||||
private readonly pendingBatchRequests = new Set<BatchRequest>();
|
private readonly pendingBatchRequests = new Set<BatchRequest>();
|
||||||
private readonly maxWorkers: number;
|
private readonly maxWorkers: number;
|
||||||
private terminated = false;
|
private terminated = false;
|
||||||
|
private activeJobCount = 0;
|
||||||
|
private idleTerminationTimeout: NodeJS.Timeout | null = null;
|
||||||
private readonly workerTimeout: number = 30000;
|
private readonly workerTimeout: number = 30000;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
@@ -197,12 +200,27 @@ export class AnimatedImageCropWorkerManager {
|
|||||||
if (this.terminated) {
|
if (this.terminated) {
|
||||||
throw new CropPipelineError('internal', 'Worker manager has been terminated');
|
throw new CropPipelineError('internal', 'Worker manager has been terminated');
|
||||||
}
|
}
|
||||||
|
this.beginJob();
|
||||||
|
return this.dispatchLibfluxcoreWorker(imageBytes, format, options, outputFormat).finally(() => {
|
||||||
|
this.endJob();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatchLibfluxcoreWorker(
|
||||||
|
imageBytes: Uint8Array,
|
||||||
|
format: CropWorkerImageFormat,
|
||||||
|
options: CropParams,
|
||||||
|
outputFormat: CropOutputFormat | undefined,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
if (this.terminated) {
|
||||||
|
return Promise.reject(new CropPipelineError('internal', 'Worker manager has been terminated'));
|
||||||
|
}
|
||||||
this.ensureWorkersInitialized();
|
this.ensureWorkersInitialized();
|
||||||
const workerState = this.findAvailableWorker();
|
const workerState = this.findAvailableWorker();
|
||||||
if (!workerState) {
|
if (!workerState) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.runLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject);
|
this.dispatchLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject);
|
||||||
}, 50);
|
}, 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -316,25 +334,30 @@ export class AnimatedImageCropWorkerManager {
|
|||||||
frames: Array<NativeFrame>,
|
frames: Array<NativeFrame>,
|
||||||
options: CropOptionsEx,
|
options: CropOptionsEx,
|
||||||
): Promise<Array<NativeFrame>> {
|
): Promise<Array<NativeFrame>> {
|
||||||
this.ensureWorkersInitialized();
|
this.beginJob();
|
||||||
const availableWorkers = this.workers.filter((worker) => !worker.busy);
|
try {
|
||||||
if (availableWorkers.length <= 1) {
|
this.ensureWorkersInitialized();
|
||||||
return frames.map((frame) => cropDecodedFrame(frame, options));
|
const availableWorkers = this.workers.filter((worker) => !worker.busy);
|
||||||
|
if (availableWorkers.length <= 1) {
|
||||||
|
return frames.map((frame) => cropDecodedFrame(frame, options));
|
||||||
|
}
|
||||||
|
const batches = createFrameBatches(frames, availableWorkers.length);
|
||||||
|
const results = await Promise.all(
|
||||||
|
batches.map((batch, index) =>
|
||||||
|
this.processFrameBatchWithWorker(availableWorkers[index], index + 1, batch, options),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const ordered: Array<NativeFrame | undefined> = new Array(frames.length);
|
||||||
|
for (const batch of results) {
|
||||||
|
for (const item of batch) ordered[item.index] = item.frame;
|
||||||
|
}
|
||||||
|
return ordered.map((frame, index) => {
|
||||||
|
if (!frame) throw new CropPipelineError('internal', `Missing transformed frame ${index}`);
|
||||||
|
return frame;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.endJob();
|
||||||
}
|
}
|
||||||
const batches = createFrameBatches(frames, availableWorkers.length);
|
|
||||||
const results = await Promise.all(
|
|
||||||
batches.map((batch, index) =>
|
|
||||||
this.processFrameBatchWithWorker(availableWorkers[index], index + 1, batch, options),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const ordered: Array<NativeFrame | undefined> = new Array(frames.length);
|
|
||||||
for (const batch of results) {
|
|
||||||
for (const item of batch) ordered[item.index] = item.frame;
|
|
||||||
}
|
|
||||||
return ordered.map((frame, index) => {
|
|
||||||
if (!frame) throw new CropPipelineError('internal', `Missing transformed frame ${index}`);
|
|
||||||
return frame;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private processFrameBatchWithWorker(
|
private processFrameBatchWithWorker(
|
||||||
@@ -417,6 +440,54 @@ export class AnimatedImageCropWorkerManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private beginJob(): void {
|
||||||
|
this.clearIdleTermination();
|
||||||
|
this.activeJobCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private endJob(): void {
|
||||||
|
this.activeJobCount = Math.max(0, this.activeJobCount - 1);
|
||||||
|
this.scheduleIdleTermination();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPoolIdle(): boolean {
|
||||||
|
return (
|
||||||
|
this.activeJobCount === 0 &&
|
||||||
|
this.pendingBatchRequests.size === 0 &&
|
||||||
|
this.workers.every((workerState) => !workerState.busy && workerState.currentRequest === null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearIdleTermination(): void {
|
||||||
|
if (this.idleTerminationTimeout === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(this.idleTerminationTimeout);
|
||||||
|
this.idleTerminationTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleIdleTermination(): void {
|
||||||
|
this.clearIdleTermination();
|
||||||
|
if (this.terminated || this.workers.length === 0 || !this.isPoolIdle()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.idleTerminationTimeout = setTimeout(() => {
|
||||||
|
this.idleTerminationTimeout = null;
|
||||||
|
if (this.isPoolIdle()) {
|
||||||
|
this.terminateIdleWorkers();
|
||||||
|
}
|
||||||
|
}, WORKER_IDLE_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private terminateIdleWorkers(): void {
|
||||||
|
for (const workerState of this.workers) {
|
||||||
|
try {
|
||||||
|
workerState.worker.terminate();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
this.workers = [];
|
||||||
|
}
|
||||||
|
|
||||||
getActiveWorkerCount(): number {
|
getActiveWorkerCount(): number {
|
||||||
return this.workers.filter((w) => w.busy).length;
|
return this.workers.filter((w) => w.busy).length;
|
||||||
}
|
}
|
||||||
@@ -430,6 +501,7 @@ export class AnimatedImageCropWorkerManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.terminated = true;
|
this.terminated = true;
|
||||||
|
this.clearIdleTermination();
|
||||||
for (const workerState of this.workers) {
|
for (const workerState of this.workers) {
|
||||||
if (workerState.currentRequest) {
|
if (workerState.currentRequest) {
|
||||||
clearTimeout(workerState.currentRequest.timeout);
|
clearTimeout(workerState.currentRequest.timeout);
|
||||||
@@ -453,6 +525,7 @@ export class AnimatedImageCropWorkerManager {
|
|||||||
}
|
}
|
||||||
this.terminated = false;
|
this.terminated = false;
|
||||||
this.ensureWorkersInitialized();
|
this.ensureWorkersInitialized();
|
||||||
|
this.scheduleIdleTermination();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
||||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||||
import {type RefObject, useEffect, useRef} from 'react';
|
import {type RefObject, useEffect} from 'react';
|
||||||
|
|
||||||
interface MessageListKeyboardNavigationOptions {
|
interface MessageListKeyboardNavigationOptions {
|
||||||
containerRef?: RefObject<ScrollerHandle | HTMLElement | null>;
|
containerRef?: RefObject<ScrollerHandle | HTMLElement | null>;
|
||||||
@@ -77,15 +77,15 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
|||||||
onEscape,
|
onEscape,
|
||||||
allowWhenInactive = false,
|
allowWhenInactive = false,
|
||||||
} = options;
|
} = options;
|
||||||
const messageNodesCache = useRef<MessageNodesSnapshot>(EMPTY_MESSAGE_NODES_SNAPSHOT);
|
|
||||||
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
|
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!keyboardModeEnabled) return;
|
if (!keyboardModeEnabled) return;
|
||||||
|
let messageNodesCache: MessageNodesSnapshot = EMPTY_MESSAGE_NODES_SNAPSHOT;
|
||||||
let observedRoot: ParentNode | null = null;
|
let observedRoot: ParentNode | null = null;
|
||||||
let observer: MutationObserver | null = null;
|
let observer: MutationObserver | null = null;
|
||||||
const invalidateMessageNodesCache = () => {
|
const invalidateMessageNodesCache = () => {
|
||||||
messageNodesCache.current = {
|
messageNodesCache = {
|
||||||
...messageNodesCache.current,
|
...messageNodesCache,
|
||||||
nodes: [],
|
nodes: [],
|
||||||
indexById: new Map(),
|
indexById: new Map(),
|
||||||
ts: 0,
|
ts: 0,
|
||||||
@@ -108,14 +108,14 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
|||||||
};
|
};
|
||||||
const getMessageElementsSnapshot = (): MessageNodesSnapshot => {
|
const getMessageElementsSnapshot = (): MessageNodesSnapshot => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cache = messageNodesCache.current;
|
const cache = messageNodesCache;
|
||||||
const container = getScrollerNode(containerRef?.current ?? null);
|
const container = getScrollerNode(containerRef?.current ?? null);
|
||||||
if (!container && containerRef) {
|
if (!container && containerRef) {
|
||||||
messageNodesCache.current = {
|
messageNodesCache = {
|
||||||
...EMPTY_MESSAGE_NODES_SNAPSHOT,
|
...EMPTY_MESSAGE_NODES_SNAPSHOT,
|
||||||
ts: now,
|
ts: now,
|
||||||
};
|
};
|
||||||
return messageNodesCache.current;
|
return messageNodesCache;
|
||||||
}
|
}
|
||||||
const root = container ?? document;
|
const root = container ?? document;
|
||||||
const selector = getMessageSelector(channelId);
|
const selector = getMessageSelector(channelId);
|
||||||
@@ -131,14 +131,14 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
|||||||
indexById.set(messageId, i);
|
indexById.set(messageId, i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
messageNodesCache.current = {
|
messageNodesCache = {
|
||||||
nodes,
|
nodes,
|
||||||
indexById,
|
indexById,
|
||||||
root,
|
root,
|
||||||
selector,
|
selector,
|
||||||
ts: now,
|
ts: now,
|
||||||
};
|
};
|
||||||
return messageNodesCache.current;
|
return messageNodesCache;
|
||||||
};
|
};
|
||||||
const focusNode = (node: HTMLElement, messageId: string) => {
|
const focusNode = (node: HTMLElement, messageId: string) => {
|
||||||
if (onFocusMessage) {
|
if (onFocusMessage) {
|
||||||
@@ -220,6 +220,9 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown, true);
|
window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
observer?.disconnect();
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
observedRoot = null;
|
||||||
|
messageNodesCache = EMPTY_MESSAGE_NODES_SNAPSHOT;
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
keyboardModeEnabled,
|
keyboardModeEnabled,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type Listener = () => void;
|
|||||||
|
|
||||||
const EMPTY_REACTIONS: ReadonlyArray<MessageReaction> = Object.freeze([]);
|
const EMPTY_REACTIONS: ReadonlyArray<MessageReaction> = Object.freeze([]);
|
||||||
const EMPTY_USERS: ReadonlyArray<User> = Object.freeze([]);
|
const EMPTY_USERS: ReadonlyArray<User> = Object.freeze([]);
|
||||||
|
const MAX_TRACKED_REACTION_REQUEST_KEYS = 512;
|
||||||
const createEmptyReactorEntry = (requestSerial = 0): ReactorEntry => ({
|
const createEmptyReactorEntry = (requestSerial = 0): ReactorEntry => ({
|
||||||
snapshot: createReactionUsersSnapshot(requestSerial),
|
snapshot: createReactionUsersSnapshot(requestSerial),
|
||||||
});
|
});
|
||||||
@@ -68,6 +69,7 @@ export class MessageReactionsManager {
|
|||||||
private reactors: Map<string, ReactorEntry> = new Map();
|
private reactors: Map<string, ReactorEntry> = new Map();
|
||||||
private _keysByMessage: Map<string, Set<string>> = new Map();
|
private _keysByMessage: Map<string, Set<string>> = new Map();
|
||||||
private retiredReactionRequests: Map<string, number> = new Map();
|
private retiredReactionRequests: Map<string, number> = new Map();
|
||||||
|
private inFlightReactionRequests: Map<string, Set<number>> = new Map();
|
||||||
private messageListeners: Map<string, Set<Listener>> = new Map();
|
private messageListeners: Map<string, Set<Listener>> = new Map();
|
||||||
private reactionListeners: Map<string, Set<Listener>> = new Map();
|
private reactionListeners: Map<string, Set<Listener>> = new Map();
|
||||||
private transactionDepth = 0;
|
private transactionDepth = 0;
|
||||||
@@ -247,7 +249,9 @@ export class MessageReactionsManager {
|
|||||||
handleFetchPending(messageId: string, emoji: ReactionEmoji): number {
|
handleFetchPending(messageId: string, emoji: ReactionEmoji): number {
|
||||||
const entry = this.getOrCreateReactorEntry(messageId, emoji);
|
const entry = this.getOrCreateReactorEntry(messageId, emoji);
|
||||||
this.commitReactorEvent(messageId, emoji, entry, {type: 'fetch.pending'});
|
this.commitReactorEvent(messageId, emoji, entry, {type: 'fetch.pending'});
|
||||||
return entry.snapshot.context.activeRequestId ?? entry.snapshot.context.requestSerial;
|
const requestId = entry.snapshot.context.activeRequestId ?? entry.snapshot.context.requestSerial;
|
||||||
|
this.trackInFlightRequest(getReactionKey(messageId, emoji), requestId);
|
||||||
|
return requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleFetchSuccess(
|
handleFetchSuccess(
|
||||||
@@ -261,7 +265,9 @@ export class MessageReactionsManager {
|
|||||||
nextAfter?: string | null,
|
nextAfter?: string | null,
|
||||||
): void {
|
): void {
|
||||||
const key = getReactionKey(messageId, emoji);
|
const key = getReactionKey(messageId, emoji);
|
||||||
if (this.shouldIgnoreFetchResult(key, requestId)) return;
|
const ignored = this.shouldIgnoreFetchResult(key, requestId);
|
||||||
|
this.settleInFlightRequest(key, requestId);
|
||||||
|
if (ignored) return;
|
||||||
const entry = this.getOrCreateReactorEntry(messageId, emoji);
|
const entry = this.getOrCreateReactorEntry(messageId, emoji);
|
||||||
Users.cacheUsers(users.slice());
|
Users.cacheUsers(users.slice());
|
||||||
this.commitReactorEvent(messageId, emoji, entry, {
|
this.commitReactorEvent(messageId, emoji, entry, {
|
||||||
@@ -288,7 +294,9 @@ export class MessageReactionsManager {
|
|||||||
nextAfter?: string | null,
|
nextAfter?: string | null,
|
||||||
): void {
|
): void {
|
||||||
const key = getReactionKey(messageId, emoji);
|
const key = getReactionKey(messageId, emoji);
|
||||||
if (this.shouldIgnoreFetchResult(key, requestId)) return;
|
const ignored = this.shouldIgnoreFetchResult(key, requestId);
|
||||||
|
this.settleInFlightRequest(key, requestId);
|
||||||
|
if (ignored) return;
|
||||||
const entry = this.getOrCreateReactorEntry(messageId, emoji);
|
const entry = this.getOrCreateReactorEntry(messageId, emoji);
|
||||||
Users.cacheUsers(users.slice());
|
Users.cacheUsers(users.slice());
|
||||||
this.commitReactorEvent(messageId, emoji, entry, {
|
this.commitReactorEvent(messageId, emoji, entry, {
|
||||||
@@ -306,7 +314,9 @@ export class MessageReactionsManager {
|
|||||||
|
|
||||||
handleFetchError(messageId: string, emoji: ReactionEmoji, requestId?: number): void {
|
handleFetchError(messageId: string, emoji: ReactionEmoji, requestId?: number): void {
|
||||||
const key = getReactionKey(messageId, emoji);
|
const key = getReactionKey(messageId, emoji);
|
||||||
if (this.shouldIgnoreFetchResult(key, requestId)) return;
|
const ignored = this.shouldIgnoreFetchResult(key, requestId);
|
||||||
|
this.settleInFlightRequest(key, requestId);
|
||||||
|
if (ignored) return;
|
||||||
this.commitReactorEvent(messageId, emoji, this.getOrCreateReactorEntry(messageId, emoji), {
|
this.commitReactorEvent(messageId, emoji, this.getOrCreateReactorEntry(messageId, emoji), {
|
||||||
type: 'fetch.error',
|
type: 'fetch.error',
|
||||||
requestId,
|
requestId,
|
||||||
@@ -370,10 +380,37 @@ export class MessageReactionsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private retireReactionEntry(key: string, entry = this.reactors.get(key)): void {
|
private retireReactionEntry(key: string, entry = this.reactors.get(key)): void {
|
||||||
|
this.reactors.delete(key);
|
||||||
|
if (!this.inFlightReactionRequests.has(key)) {
|
||||||
|
this.retiredReactionRequests.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const requestSerial = entry?.snapshot.context.requestSerial ?? 0;
|
const requestSerial = entry?.snapshot.context.requestSerial ?? 0;
|
||||||
const nextRequestId = Math.max(this.retiredReactionRequests.get(key) ?? 0, requestSerial);
|
const nextRequestId = Math.max(this.retiredReactionRequests.get(key) ?? 0, requestSerial);
|
||||||
this.retiredReactionRequests.set(key, nextRequestId);
|
this.retiredReactionRequests.set(key, nextRequestId);
|
||||||
this.reactors.delete(key);
|
}
|
||||||
|
|
||||||
|
private trackInFlightRequest(key: string, requestId: number): void {
|
||||||
|
const requestIds = this.inFlightReactionRequests.get(key) ?? new Set<number>();
|
||||||
|
requestIds.add(requestId);
|
||||||
|
this.inFlightReactionRequests.delete(key);
|
||||||
|
this.inFlightReactionRequests.set(key, requestIds);
|
||||||
|
while (this.inFlightReactionRequests.size > MAX_TRACKED_REACTION_REQUEST_KEYS) {
|
||||||
|
const oldest = this.inFlightReactionRequests.keys().next();
|
||||||
|
if (oldest.done) break;
|
||||||
|
this.inFlightReactionRequests.delete(oldest.value);
|
||||||
|
this.retiredReactionRequests.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private settleInFlightRequest(key: string, requestId?: number): void {
|
||||||
|
if (requestId == null) return;
|
||||||
|
const requestIds = this.inFlightReactionRequests.get(key);
|
||||||
|
if (!requestIds) return;
|
||||||
|
requestIds.delete(requestId);
|
||||||
|
if (requestIds.size > 0) return;
|
||||||
|
this.inFlightReactionRequests.delete(key);
|
||||||
|
this.retiredReactionRequests.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldIgnoreFetchResult(key: string, requestId?: number): boolean {
|
private shouldIgnoreFetchResult(key: string, requestId?: number): boolean {
|
||||||
|
|||||||
@@ -3,8 +3,15 @@
|
|||||||
import {LRUCache} from 'lru-cache';
|
import {LRUCache} from 'lru-cache';
|
||||||
|
|
||||||
interface ImageCacheEntry {
|
interface ImageCacheEntry {
|
||||||
image: HTMLImageElement;
|
|
||||||
src: string;
|
src: string;
|
||||||
|
naturalWidth: number;
|
||||||
|
naturalHeight: number;
|
||||||
|
pin: HTMLImageElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CachedImageSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingImageSubscriber {
|
interface PendingImageSubscriber {
|
||||||
@@ -31,9 +38,7 @@ const MAX_IMAGE_SOURCE_LENGTH = 16 * 1024;
|
|||||||
const IMAGE_LOAD_TIMEOUT_MS = 30_000;
|
const IMAGE_LOAD_TIMEOUT_MS = 30_000;
|
||||||
const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000;
|
const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
const getImageByteSize = (image: HTMLImageElement): number | null => {
|
const getDimensionByteSize = (width: number, height: number): number | null => {
|
||||||
const width = image.naturalWidth;
|
|
||||||
const height = image.naturalHeight;
|
|
||||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -42,12 +47,23 @@ const getImageByteSize = (image: HTMLImageElement): number | null => {
|
|||||||
return byteSize;
|
return byteSize;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getImageByteSize = (image: HTMLImageElement): number | null =>
|
||||||
|
getDimensionByteSize(image.naturalWidth, image.naturalHeight);
|
||||||
|
|
||||||
const estimateImageBytes = (entry: ImageCacheEntry): number => {
|
const estimateImageBytes = (entry: ImageCacheEntry): number => {
|
||||||
const byteSize = getImageByteSize(entry.image);
|
const byteSize = getDimensionByteSize(entry.naturalWidth, entry.naturalHeight);
|
||||||
if (byteSize == null) return MAX_CACHE_ENTRY_BYTES + FALLBACK_IMAGE_BYTES;
|
if (byteSize == null) return MAX_CACHE_ENTRY_BYTES + FALLBACK_IMAGE_BYTES;
|
||||||
return byteSize;
|
return byteSize;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createImagePin = (src: string, image: HTMLImageElement): HTMLImageElement => {
|
||||||
|
if (image.parentNode == null) return image;
|
||||||
|
const pin = new Image();
|
||||||
|
pin.decoding = 'async';
|
||||||
|
pin.src = src;
|
||||||
|
return pin;
|
||||||
|
};
|
||||||
|
|
||||||
const imageCache = new LRUCache<string, ImageCacheEntry>({
|
const imageCache = new LRUCache<string, ImageCacheEntry>({
|
||||||
max: MAX_CACHE_ENTRIES,
|
max: MAX_CACHE_ENTRIES,
|
||||||
maxSize: MAX_CACHE_BYTES,
|
maxSize: MAX_CACHE_BYTES,
|
||||||
@@ -91,7 +107,7 @@ const isCached = (src: string | null): boolean => {
|
|||||||
if (!acceptsImageSource(src)) return false;
|
if (!acceptsImageSource(src)) return false;
|
||||||
const entry = imageCache.get(src);
|
const entry = imageCache.get(src);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
if (entry.src === src && imageHasSource(entry.image, src) && isLoadedImage(entry.image)) return true;
|
if (entry.src === src && entry.naturalWidth > 0 && entry.naturalHeight > 0) return true;
|
||||||
imageCache.delete(src);
|
imageCache.delete(src);
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -100,12 +116,13 @@ export function hasImage(src: string | null): boolean {
|
|||||||
return isCached(src);
|
return isCached(src);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getImage(src: string | null): HTMLImageElement | undefined {
|
export function getImageSize(src: string | null): CachedImageSize | undefined {
|
||||||
if (!acceptsImageSource(src)) return undefined;
|
if (!acceptsImageSource(src)) return undefined;
|
||||||
const entry = imageCache.get(src);
|
const entry = imageCache.get(src);
|
||||||
if (!entry) return undefined;
|
if (!entry) return undefined;
|
||||||
const image = entry.image;
|
if (entry.src === src && entry.naturalWidth > 0 && entry.naturalHeight > 0) {
|
||||||
if (entry.src === src && imageHasSource(image, src) && isLoadedImage(image)) return image;
|
return {width: entry.naturalWidth, height: entry.naturalHeight};
|
||||||
|
}
|
||||||
imageCache.delete(src);
|
imageCache.delete(src);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -118,7 +135,14 @@ export function rememberImage(src: string | null, image: HTMLImageElement): void
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isCacheableImage(image)) imageCache.set(src, {image, src});
|
if (isCacheableImage(image)) {
|
||||||
|
imageCache.set(src, {
|
||||||
|
src,
|
||||||
|
naturalWidth: image.naturalWidth,
|
||||||
|
naturalHeight: image.naturalHeight,
|
||||||
|
pin: createImagePin(src, image),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!currentPendingLoad) return;
|
if (!currentPendingLoad) return;
|
||||||
settlePendingImageLoad(src, currentPendingLoad, true, image);
|
settlePendingImageLoad(src, currentPendingLoad, true, image);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const MAX_RETAINED_LIBFLUXCORE_WASM_MEMORY_BYTES = 64 * 1024 * 1024;
|
|||||||
let modulePromise: Promise<void> | null = null;
|
let modulePromise: Promise<void> | null = null;
|
||||||
let moduleReady = false;
|
let moduleReady = false;
|
||||||
let moduleExports: InitOutput | null = null;
|
let moduleExports: InitOutput | null = null;
|
||||||
let activeZstdStreamHandles = 0;
|
let pendingModuleOperations = 0;
|
||||||
|
const liveZstdStreamDecoders = new Set<number>();
|
||||||
|
const liveZstdStreamEncoders = new Set<number>();
|
||||||
|
|
||||||
export interface RgbaTransformResult {
|
export interface RgbaTransformResult {
|
||||||
rgba: Uint8Array;
|
rgba: Uint8Array;
|
||||||
@@ -58,6 +60,28 @@ async function loadModule(): Promise<void> {
|
|||||||
await modulePromise;
|
await modulePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLibfluxcoreModulePinned(): boolean {
|
||||||
|
return pendingModuleOperations > 0 || liveZstdStreamDecoders.size > 0 || liveZstdStreamEncoders.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withPinnedLibfluxcoreModule<T>(run: () => T): Promise<T> {
|
||||||
|
pendingModuleOperations++;
|
||||||
|
try {
|
||||||
|
await loadModule();
|
||||||
|
return run();
|
||||||
|
} finally {
|
||||||
|
pendingModuleOperations--;
|
||||||
|
releaseLibfluxcoreMemoryIfIdle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLiveZstdStreamHandle(handle: number, live: Set<number>): number {
|
||||||
|
if (!live.has(handle)) {
|
||||||
|
throw new Error('libfluxcore zstd stream handle is no longer live');
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
export function isLibfluxcoreReady(): boolean {
|
export function isLibfluxcoreReady(): boolean {
|
||||||
return moduleReady;
|
return moduleReady;
|
||||||
}
|
}
|
||||||
@@ -67,7 +91,7 @@ export async function ensureLibfluxcoreReady(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function releaseLibfluxcoreMemoryIfIdle(): void {
|
export function releaseLibfluxcoreMemoryIfIdle(): void {
|
||||||
if (activeZstdStreamHandles > 0) return;
|
if (isLibfluxcoreModulePinned()) return;
|
||||||
if ((moduleExports?.memory.buffer.byteLength ?? 0) <= MAX_RETAINED_LIBFLUXCORE_WASM_MEMORY_BYTES) return;
|
if ((moduleExports?.memory.buffer.byteLength ?? 0) <= MAX_RETAINED_LIBFLUXCORE_WASM_MEMORY_BYTES) return;
|
||||||
wasm.__resetLibfluxcoreWasmForMemoryPressure();
|
wasm.__resetLibfluxcoreWasmForMemoryPressure();
|
||||||
modulePromise = null;
|
modulePromise = null;
|
||||||
@@ -76,13 +100,7 @@ export function releaseLibfluxcoreMemoryIfIdle(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function detectAnimatedImage(data: Uint8Array): Promise<boolean> {
|
export async function detectAnimatedImage(data: Uint8Array): Promise<boolean> {
|
||||||
await loadModule();
|
return withPinnedLibfluxcoreModule(() => Boolean(wasm.is_animated_image(data)));
|
||||||
try {
|
|
||||||
const result = wasm.is_animated_image(data);
|
|
||||||
return Boolean(result);
|
|
||||||
} finally {
|
|
||||||
releaseLibfluxcoreMemoryIfIdle();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cropAndRotateGif(
|
export function cropAndRotateGif(
|
||||||
@@ -210,70 +228,70 @@ export async function cropAndRotateApng(
|
|||||||
resizeWidth: number | null,
|
resizeWidth: number | null,
|
||||||
resizeHeight: number | null,
|
resizeHeight: number | null,
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
await loadModule();
|
return withPinnedLibfluxcoreModule(() => {
|
||||||
try {
|
|
||||||
const result = wasm.crop_and_rotate_apng(apng, x, y, width, height, rotation, resizeWidth, resizeHeight);
|
const result = wasm.crop_and_rotate_apng(apng, x, y, width, height, rotation, resizeWidth, resizeHeight);
|
||||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||||
} finally {
|
});
|
||||||
releaseLibfluxcoreMemoryIfIdle();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decompressZstdFrame(input: Uint8Array): Promise<Uint8Array | null> {
|
export async function decompressZstdFrame(input: Uint8Array): Promise<Uint8Array | null> {
|
||||||
await loadModule();
|
return withPinnedLibfluxcoreModule(() => {
|
||||||
try {
|
|
||||||
const result = wasm.decompress_zstd_frame(input);
|
const result = wasm.decompress_zstd_frame(input);
|
||||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||||
} finally {
|
});
|
||||||
releaseLibfluxcoreMemoryIfIdle();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createZstdStreamDecoder(): Promise<number> {
|
export async function createZstdStreamDecoder(): Promise<number> {
|
||||||
await loadModule();
|
return withPinnedLibfluxcoreModule(() => {
|
||||||
const decoder = wasm.create_zstd_stream_decoder();
|
const decoder = wasm.create_zstd_stream_decoder();
|
||||||
activeZstdStreamHandles++;
|
liveZstdStreamDecoders.add(decoder);
|
||||||
return decoder;
|
return decoder;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decompressZstdStreamChunk(decoder: number, input: Uint8Array): Promise<Uint8Array> {
|
export async function decompressZstdStreamChunk(decoder: number, input: Uint8Array): Promise<Uint8Array> {
|
||||||
await loadModule();
|
return withPinnedLibfluxcoreModule(() => {
|
||||||
const result = wasm.decompress_zstd_stream_chunk(decoder, input);
|
const result = wasm.decompress_zstd_stream_chunk(
|
||||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
requireLiveZstdStreamHandle(decoder, liveZstdStreamDecoders),
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function freeZstdStreamDecoder(decoder: number): void {
|
export function freeZstdStreamDecoder(decoder: number): void {
|
||||||
|
if (!liveZstdStreamDecoders.delete(decoder)) return;
|
||||||
try {
|
try {
|
||||||
wasm.free_zstd_stream_decoder(decoder);
|
wasm.free_zstd_stream_decoder(decoder);
|
||||||
} finally {
|
} finally {
|
||||||
activeZstdStreamHandles = Math.max(0, activeZstdStreamHandles - 1);
|
|
||||||
releaseLibfluxcoreMemoryIfIdle();
|
releaseLibfluxcoreMemoryIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createZstdStreamEncoder(level: number): Promise<number> {
|
export async function createZstdStreamEncoder(level: number): Promise<number> {
|
||||||
await loadModule();
|
return withPinnedLibfluxcoreModule(() => {
|
||||||
const encoder = wasm.create_zstd_stream_encoder(level);
|
const encoder = wasm.create_zstd_stream_encoder(level);
|
||||||
activeZstdStreamHandles++;
|
liveZstdStreamEncoders.add(encoder);
|
||||||
return encoder;
|
return encoder;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createZstdStreamEncoderSync(level: number): number {
|
export function createZstdStreamEncoderSync(level: number): number {
|
||||||
const encoder = wasm.create_zstd_stream_encoder(level);
|
const encoder = wasm.create_zstd_stream_encoder(level);
|
||||||
activeZstdStreamHandles++;
|
liveZstdStreamEncoders.add(encoder);
|
||||||
return encoder;
|
return encoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function compressZstdStreamChunk(encoder: number, input: Uint8Array): Uint8Array {
|
export function compressZstdStreamChunk(encoder: number, input: Uint8Array): Uint8Array {
|
||||||
const result = wasm.compress_zstd_stream_chunk(encoder, input);
|
const result = wasm.compress_zstd_stream_chunk(requireLiveZstdStreamHandle(encoder, liveZstdStreamEncoders), input);
|
||||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function freeZstdStreamEncoder(encoder: number): void {
|
export function freeZstdStreamEncoder(encoder: number): void {
|
||||||
|
if (!liveZstdStreamEncoders.delete(encoder)) return;
|
||||||
try {
|
try {
|
||||||
wasm.free_zstd_stream_encoder(encoder);
|
wasm.free_zstd_stream_encoder(encoder);
|
||||||
} finally {
|
} finally {
|
||||||
activeZstdStreamHandles = Math.max(0, activeZstdStreamHandles - 1);
|
|
||||||
releaseLibfluxcoreMemoryIfIdle();
|
releaseLibfluxcoreMemoryIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -648,6 +648,7 @@ export class User {
|
|||||||
this._premiumType === other._premiumType &&
|
this._premiumType === other._premiumType &&
|
||||||
datesEqual(this._premiumSince, other._premiumSince) &&
|
datesEqual(this._premiumSince, other._premiumSince) &&
|
||||||
datesEqual(this._premiumUntil, other._premiumUntil) &&
|
datesEqual(this._premiumUntil, other._premiumUntil) &&
|
||||||
|
datesEqual(this._premiumGraceEndsAt, other._premiumGraceEndsAt) &&
|
||||||
this._premiumWillCancel === other._premiumWillCancel &&
|
this._premiumWillCancel === other._premiumWillCancel &&
|
||||||
this._premiumBillingCycle === other._premiumBillingCycle &&
|
this._premiumBillingCycle === other._premiumBillingCycle &&
|
||||||
this._premiumLifetimeSequence === other._premiumLifetimeSequence &&
|
this._premiumLifetimeSequence === other._premiumLifetimeSequence &&
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function isPublicOnlyCurrentUserPayload(user: WireUser): boolean {
|
|||||||
|
|
||||||
class Users {
|
class Users {
|
||||||
users: Record<string, User> = {};
|
users: Record<string, User> = {};
|
||||||
|
userCount = 0;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this, {}, {autoBind: true});
|
makeAutoObservable(this, {}, {autoBind: true});
|
||||||
@@ -98,6 +99,7 @@ class Users {
|
|||||||
this.users = {
|
this.users = {
|
||||||
[currentUser.id]: userRecord,
|
[currentUser.id]: userRecord,
|
||||||
};
|
};
|
||||||
|
this.userCount = 1;
|
||||||
if (!userRecord.isClaimed()) {
|
if (!userRecord.isClaimed()) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
openClaimAccountModal();
|
openClaimAccountModal();
|
||||||
@@ -121,7 +123,18 @@ class Users {
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.users[user.id] = existingUser ? existingUser.withUpdates(user, options) : new User(user);
|
this.storeUser(existingUser, existingUser ? existingUser.withUpdates(user, options) : new User(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private storeUser(existingUser: User | undefined, nextUser: User): void {
|
||||||
|
if (existingUser) {
|
||||||
|
if (existingUser.equals(nextUser)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.userCount += 1;
|
||||||
|
}
|
||||||
|
this.users[nextUser.id] = nextUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheUsers(
|
cacheUsers(
|
||||||
@@ -137,14 +150,14 @@ class Users {
|
|||||||
if (user.id === this.currentUserId && existingUser && isPublicOnlyCurrentUserPayload(user)) {
|
if (user.id === this.currentUserId && existingUser && isPublicOnlyCurrentUserPayload(user)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.users[user.id] = existingUser ? existingUser.withUpdates(user) : new User(user);
|
this.storeUser(existingUser, existingUser ? existingUser.withUpdates(user) : new User(user));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(callback: () => void): () => void {
|
subscribe(callback: () => void): () => void {
|
||||||
return reaction(
|
return reaction(
|
||||||
() => Object.keys(this.users).length,
|
() => this.userCount,
|
||||||
() => callback(),
|
() => callback(),
|
||||||
{fireImmediately: true},
|
{fireImmediately: true},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user