mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(app): shrink stored favorite gifs and raise their budget (#2643)
This commit is contained in:
@@ -125,12 +125,13 @@ describe('User Settings synced_preferences', () => {
|
||||
});
|
||||
test('reports TOO_LARGE for a snapshot inside the encoded-length bound but over the byte cap', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const baseEntries = Math.floor((SYNCED_PREFERENCES_MAX_BYTES - 8192) / 1006);
|
||||
const build = (tailLength: number) =>
|
||||
encodeSyncedPreferences(
|
||||
create(SyncedPreferencesSchema, {
|
||||
localSpamOverrides: create(LocalUserSpamOverridesSchema, {
|
||||
spammerUserIds: [
|
||||
...Array.from({length: 260}, (_, i) => `${i}-${'x'.repeat(1000)}`),
|
||||
...Array.from({length: baseEntries}, (_, i) => `${i}-${'x'.repeat(1000)}`),
|
||||
'y'.repeat(tailLength),
|
||||
],
|
||||
}),
|
||||
@@ -138,7 +139,13 @@ describe('User Settings synced_preferences', () => {
|
||||
);
|
||||
let tail = 1;
|
||||
let encoded = build(tail);
|
||||
while (encodedSyncedPreferencesByteLength(encoded) < SYNCED_PREFERENCES_MAX_BYTES + 1 && tail < 8000) {
|
||||
while (encodedSyncedPreferencesByteLength(encoded) <= SYNCED_PREFERENCES_MAX_BYTES) {
|
||||
tail += 512;
|
||||
encoded = build(tail);
|
||||
}
|
||||
tail = Math.max(1, tail - 512);
|
||||
encoded = build(tail);
|
||||
while (encodedSyncedPreferencesByteLength(encoded) <= SYNCED_PREFERENCES_MAX_BYTES) {
|
||||
tail += 1;
|
||||
encoded = build(tail);
|
||||
}
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
padding-inline-start: 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.listItem::before {
|
||||
content: '•';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--text-chat-muted);
|
||||
}
|
||||
|
||||
.listItem strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text-chat-muted);
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import styles from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal.module.css';
|
||||
import FavoriteGif from '@app/features/expressions/state/FavoriteGif';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {formatUserSettingsPath} from '@app/features/user/components/settings_utils/SettingsConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useRef} from 'react';
|
||||
|
||||
const HOW_SHOULD_WE_SAVE_YOUR_GIF_FAVORITES_DESCRIPTOR = msg({
|
||||
message: 'How should we save your GIF favorites?',
|
||||
comment: 'Confirmation prompt in the channel and chat favorite gif first time prompt modal.',
|
||||
});
|
||||
|
||||
interface FavoriteGifFirstTimePromptModalProps {
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const FavoriteGifFirstTimePromptModal = observer(function FavoriteGifFirstTimePromptModal({
|
||||
onConfirm,
|
||||
}: FavoriteGifFirstTimePromptModalProps) {
|
||||
const {i18n} = useLingui();
|
||||
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
|
||||
const mediaSettingsPath = formatUserSettingsPath(i18n, 'chat_settings', 'media');
|
||||
const handleConfirm = () => {
|
||||
FavoriteGif.setSaveGifFavoritesAsSavedMedia(false);
|
||||
FavoriteGif.markFirstTimePromptSeen();
|
||||
ModalCommands.pop();
|
||||
onConfirm();
|
||||
};
|
||||
const handleUseSavedMedia = () => {
|
||||
FavoriteGif.setSaveGifFavoritesAsSavedMedia(true);
|
||||
FavoriteGif.markFirstTimePromptSeen();
|
||||
ModalCommands.pop();
|
||||
onConfirm();
|
||||
};
|
||||
const handleCancel = () => {
|
||||
ModalCommands.pop();
|
||||
};
|
||||
return (
|
||||
<Modal.Root
|
||||
size="small"
|
||||
centered
|
||||
initialFocusRef={initialFocusRef}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-root"
|
||||
>
|
||||
<Modal.Header
|
||||
title={i18n._(HOW_SHOULD_WE_SAVE_YOUR_GIF_FAVORITES_DESCRIPTOR)}
|
||||
onClose={handleCancel}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-header"
|
||||
/>
|
||||
<Modal.Content data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-content">
|
||||
<div className={styles.body} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.body">
|
||||
<p
|
||||
className={styles.description}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.description"
|
||||
>
|
||||
<Trans>
|
||||
You can store starred GIFs as URL-only favorites or upload them to your saved media. Pick the one that
|
||||
fits how you use them. You can change it any time in {mediaSettingsPath}.
|
||||
</Trans>
|
||||
</p>
|
||||
<ul className={styles.list} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list">
|
||||
<li
|
||||
className={styles.listItem}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list-item"
|
||||
>
|
||||
<Trans>
|
||||
<strong data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.strong">
|
||||
URL-only favorites (default)
|
||||
</strong>
|
||||
: synced across your devices, no upload, doesn't count against saved media. The original media may
|
||||
disappear if its host removes it.
|
||||
</Trans>
|
||||
</li>
|
||||
<li
|
||||
className={styles.listItem}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.list-item--2"
|
||||
>
|
||||
<Trans>
|
||||
<strong data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.strong--2">
|
||||
Saved media
|
||||
</strong>
|
||||
: uploaded, taggable, searchable, and persistent, but counts against your saved media limit.
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
<p className={styles.hint} data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.hint">
|
||||
<Trans>We'll only ask once.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Footer data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.modal-footer">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleUseSavedMedia}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.button.use-saved-media"
|
||||
>
|
||||
<Trans>Use saved media</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
ref={initialFocusRef}
|
||||
data-flx="channel.pickers.gif.favorite-gif-first-time-prompt-modal.button.confirm"
|
||||
>
|
||||
<Trans>Use URL-only (recommended)</Trans>
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {type FavoriteGifEntry, slimFavoriteGifEntry} from './FavoriteGifTypes';
|
||||
|
||||
function format(src: string, width: number) {
|
||||
return {src, proxy_src: `https://media.test/external/sig/${src}`, width, height: Math.round(width * 0.84)};
|
||||
}
|
||||
|
||||
const FAT: FavoriteGifEntry = {
|
||||
url: 'https://klipy.com/gifs/reaction',
|
||||
proxy_url: 'https://media.test/external/sig/tiny.webm',
|
||||
width: 220,
|
||||
height: 185,
|
||||
media: {
|
||||
nanowebm: format('nano.webm', 90),
|
||||
tinywebm: format('tiny.webm', 220),
|
||||
mediumwebm: format('medium.webm', 320),
|
||||
webm: format('full.webm', 498),
|
||||
gif: format('full.gif', 498),
|
||||
},
|
||||
content_type: 'video/webm',
|
||||
placeholder: 'placeholder',
|
||||
};
|
||||
|
||||
describe('slimFavoriteGifEntry', () => {
|
||||
it('drops the media map so a favorite is cheap to sync', () => {
|
||||
expect(slimFavoriteGifEntry(FAT).media).toEqual({});
|
||||
});
|
||||
|
||||
it('promotes a preview that covers a 200px tile at 2x into the top-level fields', () => {
|
||||
const slim = slimFavoriteGifEntry(FAT);
|
||||
expect(slim.proxy_url).toBe(FAT.media.webm.proxy_src);
|
||||
expect(slim.width).toBe(498);
|
||||
expect(slim.content_type).toBe('video/webm');
|
||||
});
|
||||
|
||||
it('keeps url and placeholder untouched', () => {
|
||||
const slim = slimFavoriteGifEntry(FAT);
|
||||
expect(slim.url).toBe(FAT.url);
|
||||
expect(slim.placeholder).toBe(FAT.placeholder);
|
||||
});
|
||||
|
||||
it('is idempotent so the synced roundtrip stays stable', () => {
|
||||
const once = slimFavoriteGifEntry(FAT);
|
||||
expect(slimFavoriteGifEntry(once)).toEqual(once);
|
||||
});
|
||||
|
||||
it('leaves an entry that already carries no media alone', () => {
|
||||
const urlOnly: FavoriteGifEntry = {...FAT, media: {}};
|
||||
expect(slimFavoriteGifEntry(urlOnly)).toBe(urlOnly);
|
||||
});
|
||||
|
||||
it('does not invent a preview when every format is unusable', () => {
|
||||
const broken: FavoriteGifEntry = {...FAT, media: {webm: {src: '', proxy_src: '', width: 0, height: 0}}};
|
||||
const slim = slimFavoriteGifEntry(broken);
|
||||
expect(slim.media).toEqual({});
|
||||
expect(slim.proxy_url).toBe(FAT.proxy_url);
|
||||
});
|
||||
});
|
||||
@@ -105,4 +105,24 @@ export function pickCanonicalPreviewFormat(
|
||||
return null;
|
||||
}
|
||||
|
||||
export const STORED_PREVIEW_DEVICE_PIXEL_RATIO = 2;
|
||||
|
||||
export function slimFavoriteGifEntry(entry: FavoriteGifEntry): FavoriteGifEntry {
|
||||
const best = pickBestPreviewFormat(entry.media, 'any', {
|
||||
cssWidth: PREVIEW_TILE_CSS_WIDTH,
|
||||
devicePixelRatio: STORED_PREVIEW_DEVICE_PIXEL_RATIO,
|
||||
});
|
||||
if (best == null) {
|
||||
return Object.keys(entry.media).length === 0 ? entry : {...entry, media: {}};
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
proxy_url: best.format.proxy_src,
|
||||
width: best.format.width,
|
||||
height: best.format.height,
|
||||
content_type: inferFormatContentType(best.key),
|
||||
media: {},
|
||||
};
|
||||
}
|
||||
|
||||
export {inferFormatContentType};
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import styles from '@app/features/channel/components/GifPicker.module.css';
|
||||
import {safePause, safePlay, useGifVideoPool} from '@app/features/channel/components/GifVideoPool';
|
||||
import {FavoriteGifFirstTimePromptModal} from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal';
|
||||
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
|
||||
import {PickerThumbnail} from '@app/features/channel/components/pickers/shared/PickerThumbnail';
|
||||
import {usePooledVideo} from '@app/features/channel/components/pickers/shared/usePooledVideo';
|
||||
@@ -24,7 +23,6 @@ import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {decodeThumbHashDataURL} from '@app/features/messaging/utils/ThumbHashUtils';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {modal, push} from '@app/features/ui/commands/ModalCommands';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -400,17 +398,6 @@ export const GifPickerGridItem = observer(function GifPickerGridItem({
|
||||
const handleFavoriteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isFavoritePending) return;
|
||||
if (!FavoriteGif.hasSeenFavoriteGifFirstTimePrompt && !isFavorited) {
|
||||
push(
|
||||
modal(() => (
|
||||
<FavoriteGifFirstTimePromptModal
|
||||
onConfirm={() => void performFavoriteToggle()}
|
||||
data-flx="channel.pickers.gif.gif-picker-grid-item.handle-favorite-click.favorite-gif-first-time-prompt-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void performFavoriteToggle();
|
||||
};
|
||||
const favoriteTooltipText = (() => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
FavoriteGifEntry,
|
||||
FavoriteGifMediaFormat,
|
||||
import {
|
||||
type FavoriteGifEntry,
|
||||
type FavoriteGifMediaFormat,
|
||||
slimFavoriteGifEntry,
|
||||
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
|
||||
import {makeSyncedField} from '@app/features/user/state/SyncedField';
|
||||
import {FAVORITE_GIF_MAX_ENCODED_BYTES} from '@app/features/user/state/SyncedFieldBudget';
|
||||
import type {FavoriteGifMediaFormat as FavoriteGifMediaFormatProto} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
|
||||
import {FavoriteGifSettingsSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
|
||||
import {makeAutoObservable} from 'mobx';
|
||||
@@ -52,8 +54,9 @@ class FavoriteGif {
|
||||
field: 'favoriteGifs',
|
||||
schema: FavoriteGifSettingsSchema,
|
||||
persist: ['favoriteGifs', 'saveGifFavoritesAsSavedMedia', 'hasSeenFavoriteGifFirstTimePrompt'],
|
||||
maxEncodedBytes: FAVORITE_GIF_MAX_ENCODED_BYTES,
|
||||
toMessage: (s) => ({
|
||||
entries: s.favoriteGifs.map((entry) => ({
|
||||
entries: s.favoriteGifs.map(slimFavoriteGifEntry).map((entry) => ({
|
||||
url: entry.url,
|
||||
proxyUrl: entry.proxy_url,
|
||||
width: entry.width,
|
||||
@@ -66,15 +69,17 @@ class FavoriteGif {
|
||||
seenFirstTimePrompt: s.hasSeenFavoriteGifFirstTimePrompt,
|
||||
}),
|
||||
applyMessage: (s, m) => {
|
||||
s.favoriteGifs = m.entries.map((entry) => ({
|
||||
url: entry.url,
|
||||
proxy_url: entry.proxyUrl,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
media: mediaFromProto(entry.media),
|
||||
content_type: entry.contentType,
|
||||
placeholder: entry.placeholder ? entry.placeholder : null,
|
||||
}));
|
||||
s.favoriteGifs = m.entries.map((entry) =>
|
||||
slimFavoriteGifEntry({
|
||||
url: entry.url,
|
||||
proxy_url: entry.proxyUrl,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
media: mediaFromProto(entry.media),
|
||||
content_type: entry.contentType,
|
||||
placeholder: entry.placeholder ? entry.placeholder : null,
|
||||
}),
|
||||
);
|
||||
s.saveGifFavoritesAsSavedMedia = m.saveAsSavedMedia;
|
||||
s.hasSeenFavoriteGifFirstTimePrompt = m.seenFirstTimePrompt;
|
||||
},
|
||||
@@ -110,10 +115,6 @@ class FavoriteGif {
|
||||
setSaveGifFavoritesAsSavedMedia(value: boolean): void {
|
||||
this.saveGifFavoritesAsSavedMedia = value;
|
||||
}
|
||||
|
||||
markFirstTimePromptSeen(): void {
|
||||
this.hasSeenFavoriteGifFirstTimePrompt = true;
|
||||
}
|
||||
}
|
||||
|
||||
export default new FavoriteGif();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {FavoriteGifFirstTimePromptModal} from '@app/features/channel/components/pickers/gif/FavoriteGifFirstTimePromptModal';
|
||||
import * as FavoriteGifCommands from '@app/features/expressions/commands/FavoriteGifCommands';
|
||||
import * as FavoriteMemeCommands from '@app/features/expressions/commands/FavoriteMemeCommands';
|
||||
import {AddFavoriteMemeModal} from '@app/features/expressions/components/modals/AddFavoriteMemeModal';
|
||||
@@ -106,17 +105,6 @@ export function useMediaFavorite({
|
||||
});
|
||||
}
|
||||
};
|
||||
if (!FavoriteGif.hasSeenFavoriteGifFirstTimePrompt && !isFavorited) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<FavoriteGifFirstTimePromptModal
|
||||
onConfirm={performToggle}
|
||||
data-flx="messaging.use-media-favorite.toggle-favorite.favorite-gif-first-time-prompt-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
performToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {DEFAULT_SYNCED_FIELD_MAX_ENCODED_BYTES, decideOversizePush} from '@app/features/user/state/SyncedFieldBudget';
|
||||
import {verifyRoundtripStability} from '@app/features/user/state/SyncedFieldRoundtrip';
|
||||
import {
|
||||
createSyncedFieldMachineSnapshot,
|
||||
@@ -59,6 +60,7 @@ export async function makeSyncedField<
|
||||
}
|
||||
const isEnabled = (): boolean => config.enabled?.() ?? true;
|
||||
let machine = createSyncedFieldMachineSnapshot();
|
||||
let lastPreparedBytes: number | null = null;
|
||||
let ownerUserId: string | null = null;
|
||||
let observedUserId: string | null = null;
|
||||
const suspend = (reason: SyncedFieldFailureReason, message: string, error?: unknown): void => {
|
||||
@@ -130,13 +132,20 @@ export async function makeSyncedField<
|
||||
return;
|
||||
}
|
||||
const encodedBytes = toBinary(schema, candidate).length;
|
||||
if (encodedBytes > maxEncodedBytes) {
|
||||
const decision = decideOversizePush({encodedBytes, maxEncodedBytes, lastPreparedBytes});
|
||||
lastPreparedBytes = encodedBytes;
|
||||
if (decision === 'drop') {
|
||||
logger.error(
|
||||
`${tag}: payload of ${encodedBytes} bytes exceeds per-field budget of ${maxEncodedBytes}; push dropped.`,
|
||||
);
|
||||
transitionOnly({type: 'sync.localAlreadySynced'});
|
||||
return;
|
||||
}
|
||||
if (decision === 'push-shrinks') {
|
||||
logger.warn(
|
||||
`${tag}: payload of ${encodedBytes} bytes is over the per-field budget of ${maxEncodedBytes} but smaller than the previous candidate. Pushing so removals persist.`,
|
||||
);
|
||||
}
|
||||
const stability = verifyRoundtripStability<T, M>({
|
||||
schema,
|
||||
store,
|
||||
@@ -204,7 +213,7 @@ export async function makeSyncedField<
|
||||
processCommands();
|
||||
}
|
||||
await Promise.resolve();
|
||||
const maxEncodedBytes = config.maxEncodedBytes ?? 65_536;
|
||||
const maxEncodedBytes = config.maxEncodedBytes ?? DEFAULT_SYNCED_FIELD_MAX_ENCODED_BYTES;
|
||||
const UserSettings = (await import('@app/features/user/state/UserSettings')).default;
|
||||
const SessionManager = (await import('@app/features/platform/state/AuthSession')).default;
|
||||
const applyDefaultsForUserChange = (userId: string): void => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {decideOversizePush} from './SyncedFieldBudget';
|
||||
|
||||
const MAX = 1000;
|
||||
|
||||
describe('decideOversizePush', () => {
|
||||
it('accepts a payload inside the budget', () => {
|
||||
expect(decideOversizePush({encodedBytes: 999, maxEncodedBytes: MAX, lastPreparedBytes: null})).toBe(
|
||||
'within-budget',
|
||||
);
|
||||
expect(decideOversizePush({encodedBytes: MAX, maxEncodedBytes: MAX, lastPreparedBytes: null})).toBe(
|
||||
'within-budget',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the first over-budget payload', () => {
|
||||
expect(decideOversizePush({encodedBytes: 1001, maxEncodedBytes: MAX, lastPreparedBytes: null})).toBe('drop');
|
||||
});
|
||||
|
||||
it('drops an over-budget payload that grows', () => {
|
||||
expect(decideOversizePush({encodedBytes: 1200, maxEncodedBytes: MAX, lastPreparedBytes: 1100})).toBe('drop');
|
||||
});
|
||||
|
||||
it('drops an over-budget payload that stays the same size', () => {
|
||||
expect(decideOversizePush({encodedBytes: 1100, maxEncodedBytes: MAX, lastPreparedBytes: 1100})).toBe('drop');
|
||||
});
|
||||
|
||||
it('pushes an over-budget payload that shrinks so removals persist', () => {
|
||||
expect(decideOversizePush({encodedBytes: 1050, maxEncodedBytes: MAX, lastPreparedBytes: 1100})).toBe(
|
||||
'push-shrinks',
|
||||
);
|
||||
});
|
||||
|
||||
it('lets a run of removals drain back under the budget', () => {
|
||||
let last: number | null = null;
|
||||
const decisions: Array<string> = [];
|
||||
for (const bytes of [1300, 1200, 1100, 900]) {
|
||||
decisions.push(decideOversizePush({encodedBytes: bytes, maxEncodedBytes: MAX, lastPreparedBytes: last}));
|
||||
last = bytes;
|
||||
}
|
||||
expect(decisions).toEqual(['drop', 'push-shrinks', 'push-shrinks', 'within-budget']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const DEFAULT_SYNCED_FIELD_MAX_ENCODED_BYTES = 65_536;
|
||||
|
||||
export const FAVORITE_GIF_MAX_ENCODED_BYTES = 384 * 1024;
|
||||
|
||||
export type OversizePushDecision = 'within-budget' | 'push-shrinks' | 'drop';
|
||||
|
||||
export function decideOversizePush(args: {
|
||||
encodedBytes: number;
|
||||
maxEncodedBytes: number;
|
||||
lastPreparedBytes: number | null;
|
||||
}): OversizePushDecision {
|
||||
if (args.encodedBytes <= args.maxEncodedBytes) return 'within-budget';
|
||||
if (args.lastPreparedBytes != null && args.encodedBytes < args.lastPreparedBytes) return 'push-shrinks';
|
||||
return 'drop';
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import {create} from '@bufbuild/protobuf';
|
||||
import {MAX_GROUP_DM_OTHER_RECIPIENTS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {encodeSyncedPreferences, SyncedPreferencesSchema} from '@fluxer/schema/src/domains/user/SyncedPreferencesCodec';
|
||||
import {
|
||||
encodeSyncedPreferences,
|
||||
SYNCED_PREFERENCES_MAX_ENCODED_LENGTH,
|
||||
SyncedPreferencesSchema,
|
||||
} from '@fluxer/schema/src/domains/user/SyncedPreferencesCodec';
|
||||
import {
|
||||
CreatePrivateChannelRequest,
|
||||
CustomStatusPayload,
|
||||
@@ -60,8 +64,12 @@ describe('UserSettingsUpdateRequest synced_preferences', () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
it('accepts a string at exactly the size cap', () => {
|
||||
const atCap = 'A'.repeat(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH);
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: atCap}).success).toBe(true);
|
||||
});
|
||||
it('rejects strings exceeding the size cap', () => {
|
||||
const oversized = 'A'.repeat(400000);
|
||||
const oversized = 'A'.repeat(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH + 1);
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: oversized}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export type {SyncedPreferences} from '@fluxer/schema/src/gen/fluxer/user/prefere
|
||||
export {SyncedPreferencesSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb';
|
||||
|
||||
export const EMPTY_SYNCED_PREFERENCES_ENCODED = '';
|
||||
export const SYNCED_PREFERENCES_MAX_BYTES = 256 * 1024;
|
||||
export const SYNCED_PREFERENCES_MAX_BYTES = 512 * 1024;
|
||||
export const SYNCED_PREFERENCES_MAX_ENCODED_LENGTH = Math.ceil(SYNCED_PREFERENCES_MAX_BYTES / 3) * 4;
|
||||
const BASE64_PATTERN = /^[A-Za-z0-9+/_-]*={0,2}$/;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user