mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +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,
|
||||
} from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine';
|
||||
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 {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer';
|
||||
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 animatedImageRef = useRef<HTMLImageElement>(null);
|
||||
const freezeCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({
|
||||
disabled: !isMobile,
|
||||
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
|
||||
const {
|
||||
ref: visibilityRef,
|
||||
loadMedia: shouldLoadMedia,
|
||||
animate: shouldAnimate,
|
||||
} = useGifViewportGate<HTMLDivElement>({
|
||||
element: containerElement,
|
||||
rememberKey: proxyURL,
|
||||
shouldBlur,
|
||||
});
|
||||
const setContainerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -1018,8 +1025,6 @@ export const EmbedGif: FC<
|
||||
Math.round(displayHeight * 2),
|
||||
);
|
||||
const animatedImageSourceRef = useRef(optimizedAnimatedURL);
|
||||
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
|
||||
const shouldLoadMedia = isNearViewport && !shouldBlur;
|
||||
const {
|
||||
loaded,
|
||||
error,
|
||||
@@ -1049,7 +1054,7 @@ export const EmbedGif: FC<
|
||||
status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle',
|
||||
});
|
||||
const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed());
|
||||
const shouldUseDecoder = shouldLoadMedia && hasStartedAnimating;
|
||||
const shouldUseDecoder = shouldAnimate && hasStartedAnimating;
|
||||
const decoderPlaying =
|
||||
shouldUseDecoder && decoderRequested && animatedMediaPlaybackAllowed && (gifAutoPlay || isHoveredState);
|
||||
const decoderState = useAnimatedImageDecoder({
|
||||
@@ -1061,7 +1066,7 @@ export const EmbedGif: FC<
|
||||
const useDecoder =
|
||||
shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error;
|
||||
const decoderCanvasVisible = useDecoder && decoderState.loaded;
|
||||
const shouldRenderFreezeFrame = !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
|
||||
const shouldRenderFreezeFrame = shouldAnimate && !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
|
||||
const setAnimatedImageRef = useCallback(
|
||||
(node: HTMLImageElement | null) => {
|
||||
animatedImageRef.current = node;
|
||||
@@ -1406,7 +1411,7 @@ export const EmbedGif: FC<
|
||||
...(aspectRatio ? {aspectRatio} : {}),
|
||||
} as React.CSSProperties;
|
||||
const shouldUseAnimatedImage =
|
||||
shouldLoadMedia &&
|
||||
shouldAnimate &&
|
||||
animatedMediaPlaybackAllowed &&
|
||||
!decoderCanvasVisible &&
|
||||
(gifAutoPlay || (hasStartedAnimating && isHoveredState));
|
||||
|
||||
@@ -356,9 +356,9 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
() => {
|
||||
if (!active) return;
|
||||
setLoadedPosterSrc(posterSrc);
|
||||
const image = ImageCacheUtils.getImage(posterSrc);
|
||||
if (active && image && image.naturalWidth > 0 && image.naturalHeight > 0) {
|
||||
setPosterNaturalDimensions({src: posterSrc, width: image.naturalWidth, height: image.naturalHeight});
|
||||
const posterSize = ImageCacheUtils.getImageSize(posterSrc);
|
||||
if (active && posterSize) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = (
|
||||
snapshots: ReadonlyMap<string, TrackedRecipientSnapshot>,
|
||||
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 {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
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 sortDMs = (a: Channel, b: Channel) => {
|
||||
@@ -30,12 +30,37 @@ const sortDMs = (a: Channel, b: Channel) => {
|
||||
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 {
|
||||
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>();
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
makeAutoObservable<this, 'channelsByGuildId' | 'privateChannelList'>(
|
||||
this,
|
||||
{channelsByGuildId: observable.shallow, privateChannelList: observable.ref},
|
||||
{autoBind: true},
|
||||
);
|
||||
}
|
||||
|
||||
get channels(): ReadonlyArray<Channel> {
|
||||
@@ -51,31 +76,14 @@ class Channels {
|
||||
readonly dms: ReadonlyArray<Channel>;
|
||||
readonly privateChannels: ReadonlyArray<Channel>;
|
||||
} {
|
||||
const byGuild = new Map<string, Array<Channel>>();
|
||||
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};
|
||||
return {byGuild: this.channelsByGuildId, dms: this.dmChannels, privateChannels: this.privateChannelList};
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -83,11 +91,11 @@ class Channels {
|
||||
}
|
||||
|
||||
getGuildChannels(guildId: string): ReadonlyArray<Channel> {
|
||||
return this.channelGroups.byGuild.get(guildId) ?? EMPTY_CHANNELS;
|
||||
return this.channelsByGuildId.get(guildId) ?? EMPTY_CHANNELS;
|
||||
}
|
||||
|
||||
getPrivateChannels(): ReadonlyArray<Channel> {
|
||||
return this.channelGroups.privateChannels;
|
||||
return this.privateChannelList;
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -100,8 +108,7 @@ class Channels {
|
||||
return;
|
||||
}
|
||||
this.optimisticChannelBackups.set(channelId, channel);
|
||||
this.channelsById.delete(channelId);
|
||||
ChannelDisplayName.removeChannel(channelId);
|
||||
this.deleteChannelRecord(channelId);
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -122,7 +129,16 @@ class Channels {
|
||||
@action
|
||||
private removeChannel(channelId: string): void {
|
||||
this.clearOptimisticallyRemovedChannel(channelId);
|
||||
this.deleteChannelRecord(channelId);
|
||||
}
|
||||
|
||||
@action
|
||||
private deleteChannelRecord(channelId: string): void {
|
||||
const channel = this.channelsById.get(channelId);
|
||||
this.channelsById.delete(channelId);
|
||||
if (channel) {
|
||||
this.removeChannelFromIndex(channel);
|
||||
}
|
||||
ChannelDisplayName.removeChannel(channelId);
|
||||
}
|
||||
|
||||
@@ -134,12 +150,95 @@ class Channels {
|
||||
return;
|
||||
}
|
||||
this.channelsById.set(record.id, record);
|
||||
this.indexChannel(existing, 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
|
||||
handleConnectionOpen({channels}: {channels: ReadonlyArray<WireChannel>}): void {
|
||||
this.channelsById.clear();
|
||||
this.channelsByGuildId.clear();
|
||||
this.privateChannelList = EMPTY_CHANNELS;
|
||||
ChannelDisplayName.clear();
|
||||
const allRecipients = channels
|
||||
.filter((channel) => channel.recipients && channel.recipients.length > 0)
|
||||
@@ -177,7 +276,7 @@ class Channels {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (!syncedChannelIds.has(channel.id)) {
|
||||
this.removeChannel(channel.id);
|
||||
@@ -190,8 +289,8 @@ class Channels {
|
||||
|
||||
@action
|
||||
handleGuildDelete({guildId}: {guildId: string}): void {
|
||||
const guildChannels = this.channelGroups.byGuild.get(guildId);
|
||||
if (!guildChannels || guildChannels.length === 0) return;
|
||||
const guildChannels = this.getGuildChannels(guildId);
|
||||
if (guildChannels.length === 0) return;
|
||||
const ids: Array<string> = [];
|
||||
for (const channel of guildChannels) ids.push(channel.id);
|
||||
for (const id of ids) {
|
||||
@@ -269,8 +368,7 @@ class Channels {
|
||||
return;
|
||||
}
|
||||
if (user.id === Authentication.currentUserId) {
|
||||
this.channelsById.delete(channelId);
|
||||
ChannelDisplayName.removeChannel(channelId);
|
||||
this.deleteChannelRecord(channelId);
|
||||
const history = RouterUtils.getHistory();
|
||||
const currentPath = history?.location.pathname ?? '';
|
||||
const expectedPath = Routes.dmChannel(channelId);
|
||||
@@ -332,8 +430,8 @@ class Channels {
|
||||
|
||||
@action
|
||||
handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void {
|
||||
const guildChannels = this.channelGroups.byGuild.get(guildId);
|
||||
if (!guildChannels || guildChannels.length === 0) return;
|
||||
const guildChannels = this.getGuildChannels(guildId);
|
||||
if (guildChannels.length === 0) return;
|
||||
const snapshot = Array.from(guildChannels);
|
||||
for (const channel of snapshot) {
|
||||
if (!(roleId in channel.permissionOverwrites)) {
|
||||
|
||||
@@ -43,6 +43,7 @@ interface BatchRequest {
|
||||
|
||||
const PARALLEL_FRAME_THRESHOLD = 8;
|
||||
const PARALLEL_PIXEL_THRESHOLD = 1_000_000;
|
||||
const WORKER_IDLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
export class CropPipelineError extends Error {
|
||||
readonly code: CropWorkerErrorCode;
|
||||
@@ -70,6 +71,8 @@ export class AnimatedImageCropWorkerManager {
|
||||
private readonly pendingBatchRequests = new Set<BatchRequest>();
|
||||
private readonly maxWorkers: number;
|
||||
private terminated = false;
|
||||
private activeJobCount = 0;
|
||||
private idleTerminationTimeout: NodeJS.Timeout | null = null;
|
||||
private readonly workerTimeout: number = 30000;
|
||||
|
||||
private constructor() {
|
||||
@@ -197,12 +200,27 @@ export class AnimatedImageCropWorkerManager {
|
||||
if (this.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();
|
||||
const workerState = this.findAvailableWorker();
|
||||
if (!workerState) {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
this.runLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject);
|
||||
this.dispatchLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject);
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
@@ -316,25 +334,30 @@ export class AnimatedImageCropWorkerManager {
|
||||
frames: Array<NativeFrame>,
|
||||
options: CropOptionsEx,
|
||||
): Promise<Array<NativeFrame>> {
|
||||
this.ensureWorkersInitialized();
|
||||
const availableWorkers = this.workers.filter((worker) => !worker.busy);
|
||||
if (availableWorkers.length <= 1) {
|
||||
return frames.map((frame) => cropDecodedFrame(frame, options));
|
||||
this.beginJob();
|
||||
try {
|
||||
this.ensureWorkersInitialized();
|
||||
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(
|
||||
@@ -417,6 +440,54 @@ export class AnimatedImageCropWorkerManager {
|
||||
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 {
|
||||
return this.workers.filter((w) => w.busy).length;
|
||||
}
|
||||
@@ -430,6 +501,7 @@ export class AnimatedImageCropWorkerManager {
|
||||
return;
|
||||
}
|
||||
this.terminated = true;
|
||||
this.clearIdleTermination();
|
||||
for (const workerState of this.workers) {
|
||||
if (workerState.currentRequest) {
|
||||
clearTimeout(workerState.currentRequest.timeout);
|
||||
@@ -453,6 +525,7 @@ export class AnimatedImageCropWorkerManager {
|
||||
}
|
||||
this.terminated = false;
|
||||
this.ensureWorkersInitialized();
|
||||
this.scheduleIdleTermination();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {type RefObject, useEffect, useRef} from 'react';
|
||||
import {type RefObject, useEffect} from 'react';
|
||||
|
||||
interface MessageListKeyboardNavigationOptions {
|
||||
containerRef?: RefObject<ScrollerHandle | HTMLElement | null>;
|
||||
@@ -77,15 +77,15 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
onEscape,
|
||||
allowWhenInactive = false,
|
||||
} = options;
|
||||
const messageNodesCache = useRef<MessageNodesSnapshot>(EMPTY_MESSAGE_NODES_SNAPSHOT);
|
||||
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
|
||||
useEffect(() => {
|
||||
if (!keyboardModeEnabled) return;
|
||||
let messageNodesCache: MessageNodesSnapshot = EMPTY_MESSAGE_NODES_SNAPSHOT;
|
||||
let observedRoot: ParentNode | null = null;
|
||||
let observer: MutationObserver | null = null;
|
||||
const invalidateMessageNodesCache = () => {
|
||||
messageNodesCache.current = {
|
||||
...messageNodesCache.current,
|
||||
messageNodesCache = {
|
||||
...messageNodesCache,
|
||||
nodes: [],
|
||||
indexById: new Map(),
|
||||
ts: 0,
|
||||
@@ -108,14 +108,14 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
};
|
||||
const getMessageElementsSnapshot = (): MessageNodesSnapshot => {
|
||||
const now = Date.now();
|
||||
const cache = messageNodesCache.current;
|
||||
const cache = messageNodesCache;
|
||||
const container = getScrollerNode(containerRef?.current ?? null);
|
||||
if (!container && containerRef) {
|
||||
messageNodesCache.current = {
|
||||
messageNodesCache = {
|
||||
...EMPTY_MESSAGE_NODES_SNAPSHOT,
|
||||
ts: now,
|
||||
};
|
||||
return messageNodesCache.current;
|
||||
return messageNodesCache;
|
||||
}
|
||||
const root = container ?? document;
|
||||
const selector = getMessageSelector(channelId);
|
||||
@@ -131,14 +131,14 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
indexById.set(messageId, i);
|
||||
}
|
||||
}
|
||||
messageNodesCache.current = {
|
||||
messageNodesCache = {
|
||||
nodes,
|
||||
indexById,
|
||||
root,
|
||||
selector,
|
||||
ts: now,
|
||||
};
|
||||
return messageNodesCache.current;
|
||||
return messageNodesCache;
|
||||
};
|
||||
const focusNode = (node: HTMLElement, messageId: string) => {
|
||||
if (onFocusMessage) {
|
||||
@@ -220,6 +220,9 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
observedRoot = null;
|
||||
messageNodesCache = EMPTY_MESSAGE_NODES_SNAPSHOT;
|
||||
};
|
||||
}, [
|
||||
keyboardModeEnabled,
|
||||
|
||||
@@ -39,6 +39,7 @@ type Listener = () => void;
|
||||
|
||||
const EMPTY_REACTIONS: ReadonlyArray<MessageReaction> = Object.freeze([]);
|
||||
const EMPTY_USERS: ReadonlyArray<User> = Object.freeze([]);
|
||||
const MAX_TRACKED_REACTION_REQUEST_KEYS = 512;
|
||||
const createEmptyReactorEntry = (requestSerial = 0): ReactorEntry => ({
|
||||
snapshot: createReactionUsersSnapshot(requestSerial),
|
||||
});
|
||||
@@ -68,6 +69,7 @@ export class MessageReactionsManager {
|
||||
private reactors: Map<string, ReactorEntry> = new Map();
|
||||
private _keysByMessage: Map<string, Set<string>> = 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 reactionListeners: Map<string, Set<Listener>> = new Map();
|
||||
private transactionDepth = 0;
|
||||
@@ -247,7 +249,9 @@ export class MessageReactionsManager {
|
||||
handleFetchPending(messageId: string, emoji: ReactionEmoji): number {
|
||||
const entry = this.getOrCreateReactorEntry(messageId, emoji);
|
||||
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(
|
||||
@@ -261,7 +265,9 @@ export class MessageReactionsManager {
|
||||
nextAfter?: string | null,
|
||||
): void {
|
||||
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);
|
||||
Users.cacheUsers(users.slice());
|
||||
this.commitReactorEvent(messageId, emoji, entry, {
|
||||
@@ -288,7 +294,9 @@ export class MessageReactionsManager {
|
||||
nextAfter?: string | null,
|
||||
): void {
|
||||
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);
|
||||
Users.cacheUsers(users.slice());
|
||||
this.commitReactorEvent(messageId, emoji, entry, {
|
||||
@@ -306,7 +314,9 @@ export class MessageReactionsManager {
|
||||
|
||||
handleFetchError(messageId: string, emoji: ReactionEmoji, requestId?: number): void {
|
||||
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), {
|
||||
type: 'fetch.error',
|
||||
requestId,
|
||||
@@ -370,10 +380,37 @@ export class MessageReactionsManager {
|
||||
}
|
||||
|
||||
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 nextRequestId = Math.max(this.retiredReactionRequests.get(key) ?? 0, requestSerial);
|
||||
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 {
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
import {LRUCache} from 'lru-cache';
|
||||
|
||||
interface ImageCacheEntry {
|
||||
image: HTMLImageElement;
|
||||
src: string;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
pin: HTMLImageElement;
|
||||
}
|
||||
|
||||
export interface CachedImageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface PendingImageSubscriber {
|
||||
@@ -31,9 +38,7 @@ const MAX_IMAGE_SOURCE_LENGTH = 16 * 1024;
|
||||
const IMAGE_LOAD_TIMEOUT_MS = 30_000;
|
||||
const IMAGE_LOAD_ACTIVATION_TIMEOUT_MS = 10_000;
|
||||
|
||||
const getImageByteSize = (image: HTMLImageElement): number | null => {
|
||||
const width = image.naturalWidth;
|
||||
const height = image.naturalHeight;
|
||||
const getDimensionByteSize = (width: number, height: number): number | null => {
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -42,12 +47,23 @@ const getImageByteSize = (image: HTMLImageElement): number | null => {
|
||||
return byteSize;
|
||||
};
|
||||
|
||||
const getImageByteSize = (image: HTMLImageElement): number | null =>
|
||||
getDimensionByteSize(image.naturalWidth, image.naturalHeight);
|
||||
|
||||
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;
|
||||
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>({
|
||||
max: MAX_CACHE_ENTRIES,
|
||||
maxSize: MAX_CACHE_BYTES,
|
||||
@@ -91,7 +107,7 @@ const isCached = (src: string | null): boolean => {
|
||||
if (!acceptsImageSource(src)) return false;
|
||||
const entry = imageCache.get(src);
|
||||
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);
|
||||
return false;
|
||||
};
|
||||
@@ -100,12 +116,13 @@ export function hasImage(src: string | null): boolean {
|
||||
return isCached(src);
|
||||
}
|
||||
|
||||
export function getImage(src: string | null): HTMLImageElement | undefined {
|
||||
export function getImageSize(src: string | null): CachedImageSize | undefined {
|
||||
if (!acceptsImageSource(src)) return undefined;
|
||||
const entry = imageCache.get(src);
|
||||
if (!entry) return undefined;
|
||||
const image = entry.image;
|
||||
if (entry.src === src && imageHasSource(image, src) && isLoadedImage(image)) return image;
|
||||
if (entry.src === src && entry.naturalWidth > 0 && entry.naturalHeight > 0) {
|
||||
return {width: entry.naturalWidth, height: entry.naturalHeight};
|
||||
}
|
||||
imageCache.delete(src);
|
||||
return undefined;
|
||||
}
|
||||
@@ -118,7 +135,14 @@ export function rememberImage(src: string | null, image: HTMLImageElement): void
|
||||
}
|
||||
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;
|
||||
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 moduleReady = false;
|
||||
let moduleExports: InitOutput | null = null;
|
||||
let activeZstdStreamHandles = 0;
|
||||
let pendingModuleOperations = 0;
|
||||
const liveZstdStreamDecoders = new Set<number>();
|
||||
const liveZstdStreamEncoders = new Set<number>();
|
||||
|
||||
export interface RgbaTransformResult {
|
||||
rgba: Uint8Array;
|
||||
@@ -58,6 +60,28 @@ async function loadModule(): Promise<void> {
|
||||
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 {
|
||||
return moduleReady;
|
||||
}
|
||||
@@ -67,7 +91,7 @@ export async function ensureLibfluxcoreReady(): Promise<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;
|
||||
wasm.__resetLibfluxcoreWasmForMemoryPressure();
|
||||
modulePromise = null;
|
||||
@@ -76,13 +100,7 @@ export function releaseLibfluxcoreMemoryIfIdle(): void {
|
||||
}
|
||||
|
||||
export async function detectAnimatedImage(data: Uint8Array): Promise<boolean> {
|
||||
await loadModule();
|
||||
try {
|
||||
const result = wasm.is_animated_image(data);
|
||||
return Boolean(result);
|
||||
} finally {
|
||||
releaseLibfluxcoreMemoryIfIdle();
|
||||
}
|
||||
return withPinnedLibfluxcoreModule(() => Boolean(wasm.is_animated_image(data)));
|
||||
}
|
||||
|
||||
export function cropAndRotateGif(
|
||||
@@ -210,70 +228,70 @@ export async function cropAndRotateApng(
|
||||
resizeWidth: number | null,
|
||||
resizeHeight: number | null,
|
||||
): Promise<Uint8Array> {
|
||||
await loadModule();
|
||||
try {
|
||||
return withPinnedLibfluxcoreModule(() => {
|
||||
const result = wasm.crop_and_rotate_apng(apng, x, y, width, height, rotation, resizeWidth, resizeHeight);
|
||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||
} finally {
|
||||
releaseLibfluxcoreMemoryIfIdle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function decompressZstdFrame(input: Uint8Array): Promise<Uint8Array | null> {
|
||||
await loadModule();
|
||||
try {
|
||||
return withPinnedLibfluxcoreModule(() => {
|
||||
const result = wasm.decompress_zstd_frame(input);
|
||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||
} finally {
|
||||
releaseLibfluxcoreMemoryIfIdle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createZstdStreamDecoder(): Promise<number> {
|
||||
await loadModule();
|
||||
const decoder = wasm.create_zstd_stream_decoder();
|
||||
activeZstdStreamHandles++;
|
||||
return decoder;
|
||||
return withPinnedLibfluxcoreModule(() => {
|
||||
const decoder = wasm.create_zstd_stream_decoder();
|
||||
liveZstdStreamDecoders.add(decoder);
|
||||
return decoder;
|
||||
});
|
||||
}
|
||||
|
||||
export async function decompressZstdStreamChunk(decoder: number, input: Uint8Array): Promise<Uint8Array> {
|
||||
await loadModule();
|
||||
const result = wasm.decompress_zstd_stream_chunk(decoder, input);
|
||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||
return withPinnedLibfluxcoreModule(() => {
|
||||
const result = wasm.decompress_zstd_stream_chunk(
|
||||
requireLiveZstdStreamHandle(decoder, liveZstdStreamDecoders),
|
||||
input,
|
||||
);
|
||||
return result instanceof Uint8Array ? result : new Uint8Array(result);
|
||||
});
|
||||
}
|
||||
|
||||
export function freeZstdStreamDecoder(decoder: number): void {
|
||||
if (!liveZstdStreamDecoders.delete(decoder)) return;
|
||||
try {
|
||||
wasm.free_zstd_stream_decoder(decoder);
|
||||
} finally {
|
||||
activeZstdStreamHandles = Math.max(0, activeZstdStreamHandles - 1);
|
||||
releaseLibfluxcoreMemoryIfIdle();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createZstdStreamEncoder(level: number): Promise<number> {
|
||||
await loadModule();
|
||||
const encoder = wasm.create_zstd_stream_encoder(level);
|
||||
activeZstdStreamHandles++;
|
||||
return encoder;
|
||||
return withPinnedLibfluxcoreModule(() => {
|
||||
const encoder = wasm.create_zstd_stream_encoder(level);
|
||||
liveZstdStreamEncoders.add(encoder);
|
||||
return encoder;
|
||||
});
|
||||
}
|
||||
|
||||
export function createZstdStreamEncoderSync(level: number): number {
|
||||
const encoder = wasm.create_zstd_stream_encoder(level);
|
||||
activeZstdStreamHandles++;
|
||||
liveZstdStreamEncoders.add(encoder);
|
||||
return encoder;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function freeZstdStreamEncoder(encoder: number): void {
|
||||
if (!liveZstdStreamEncoders.delete(encoder)) return;
|
||||
try {
|
||||
wasm.free_zstd_stream_encoder(encoder);
|
||||
} finally {
|
||||
activeZstdStreamHandles = Math.max(0, activeZstdStreamHandles - 1);
|
||||
releaseLibfluxcoreMemoryIfIdle();
|
||||
}
|
||||
}
|
||||
@@ -648,6 +648,7 @@ export class User {
|
||||
this._premiumType === other._premiumType &&
|
||||
datesEqual(this._premiumSince, other._premiumSince) &&
|
||||
datesEqual(this._premiumUntil, other._premiumUntil) &&
|
||||
datesEqual(this._premiumGraceEndsAt, other._premiumGraceEndsAt) &&
|
||||
this._premiumWillCancel === other._premiumWillCancel &&
|
||||
this._premiumBillingCycle === other._premiumBillingCycle &&
|
||||
this._premiumLifetimeSequence === other._premiumLifetimeSequence &&
|
||||
|
||||
@@ -55,6 +55,7 @@ function isPublicOnlyCurrentUserPayload(user: WireUser): boolean {
|
||||
|
||||
class Users {
|
||||
users: Record<string, User> = {};
|
||||
userCount = 0;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -98,6 +99,7 @@ class Users {
|
||||
this.users = {
|
||||
[currentUser.id]: userRecord,
|
||||
};
|
||||
this.userCount = 1;
|
||||
if (!userRecord.isClaimed()) {
|
||||
setTimeout(async () => {
|
||||
openClaimAccountModal();
|
||||
@@ -121,7 +123,18 @@ class Users {
|
||||
) {
|
||||
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(
|
||||
@@ -137,14 +150,14 @@ class Users {
|
||||
if (user.id === this.currentUserId && existingUser && isPublicOnlyCurrentUserPayload(user)) {
|
||||
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 {
|
||||
return reaction(
|
||||
() => Object.keys(this.users).length,
|
||||
() => this.userCount,
|
||||
() => callback(),
|
||||
{fireImmediately: true},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user