Compare commits

...
56 changed files with 509 additions and 371 deletions

No files matched your search

+1 -1
View File
@@ -31,5 +31,5 @@
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-1920w.png" alt="Fluxer app showcase" width="900">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
</p>
@@ -103,6 +103,7 @@ pub fn instance_config_page(
instance_config.self_hosted,
))
(sso_config_section(base, csrf_token, &instance_config.sso))
(deferred_phone_gate_form(base, csrf_token, &instance_config.policy))
},
))
@if instance_config.self_hosted {
@@ -191,7 +192,6 @@ fn policy_config_section(base: &str, csrf_token: &str, policy: &InstancePolicyRe
(single_community_form(base, csrf_token, policy))
(direct_messages_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy))
(deferred_phone_gate_form(base, csrf_token, policy))
(services_form(base, csrf_token, policy))
}
},
+3 -50
View File
@@ -53,12 +53,6 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfg-if"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
name = "cfg-if"
version = "1.0.4"
@@ -111,7 +105,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if 1.0.4",
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
@@ -123,7 +117,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if 1.0.4",
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
@@ -203,7 +197,6 @@ version = "0.1.0"
dependencies = [
"proptest",
"wasm-bindgen",
"wee_alloc",
"zstd",
]
@@ -225,12 +218,6 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memory_units"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3"
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -530,7 +517,7 @@ version = "0.2.123"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
dependencies = [
"cfg-if 1.0.4",
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
@@ -603,40 +590,6 @@ dependencies = [
"semver",
]
[[package]]
name = "wee_alloc"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e"
dependencies = [
"cfg-if 0.1.10",
"libc",
"memory_units",
"winapi",
]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
-1
View File
@@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "=0.2.123"
wee_alloc = {version = "0.4.5", default-features = false}
zstd = {version = "0.13.3", default-features = false, features = ["no_asm", "thin", "wasm"]}
[dev-dependencies]
-4
View File
@@ -9,10 +9,6 @@ use formats::is_animated_image_bytes;
use rgba::{TransformRequest, crop_rotate_rgba_alloc};
use wasm_bindgen::prelude::*;
#[cfg(target_arch = "wasm32")]
#[global_allocator]
static WASM_ALLOCATOR: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn crop_rotate_rgba_raw(
-11
View File
@@ -16,11 +16,6 @@
background: transparent;
}
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
:local(.appContainer) {
pointer-events: none;
}
:global(html.is-standalone:not(.auth-page)) :local(.appContainer) {
padding-top: env(safe-area-inset-top);
}
@@ -86,12 +81,6 @@
--skip-link-titlebar-offset: var(--native-titlebar-height);
--skip-link-traffic-lights-offset: 4.5rem;
}
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
:local(.overlayScope)
> :not([data-overlay-pass-through]) {
pointer-events: none;
}
.quickSwitcherPortal {
position: fixed;
inset: 0;
+2 -14
View File
@@ -45,15 +45,9 @@ import {getActivePortalHost, setActivePortalHost} from '@app/features/ui/overlay
import MobileLayout from '@app/features/ui/state/MobileLayout';
import Modal from '@app/features/ui/state/Modal';
import Popout from '@app/features/ui/state/Popout';
import {
getDesktopWindowBehaviorSettings,
setDesktopWindowBehaviorSettings,
} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
import {getDesktopWindowBehaviorSettings} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
import {attachExternalLinkInterceptor, isDesktop} from '@app/features/ui/utils/NativeUtils';
import {
FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS,
UNFOCUSED_FULLY_INTERACTIVE_CLASS,
} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import {UNFOCUSED_FULLY_INTERACTIVE_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import {IncomingCallManager} from '@app/features/voice/components/IncomingCallManager';
import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot';
@@ -86,7 +80,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
const {i18n} = useLingui();
const reducedMotion = Accessibility.useReducedMotion;
const stayInteractiveWhenUnfocused = Accessibility.stayInteractiveWhenUnfocused;
const firstClickPassThroughWhenUnfocused = Accessibility.firstClickPassThroughWhenUnfocused;
const {platform, isNative} = useNativePlatform();
const useSystemTitleBar = useNativeTitleBar();
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
@@ -163,11 +156,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
useDocumentClassToggle('reduced-motion', reducedMotion);
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
useDocumentClassToggle(FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS, firstClickPassThroughWhenUnfocused);
useEffect(() => {
if (!isNative) return;
void setDesktopWindowBehaviorSettings({firstClickPassThroughWhenUnfocused});
}, [isNative, firstClickPassThroughWhenUnfocused]);
useDesktopAllowTransparency(isNative);
useWindowEventListeners({preventDocumentScroll: !isNative});
useRemScaleTracking();
@@ -600,7 +600,6 @@ export interface AccessibilitySettings {
hdrDisplayMode: HdrDisplayMode;
preserveEditDraft: boolean;
stayInteractiveWhenUnfocused: boolean;
firstClickPassThroughWhenUnfocused: boolean;
scrollToBottomOnMessageSend: boolean;
sequentialFileSend: boolean;
showNeko: boolean;
@@ -718,7 +717,6 @@ class Accessibility {
hdrDisplayMode = HdrDisplayMode.FULL;
preserveEditDraft = false;
stayInteractiveWhenUnfocused = false;
firstClickPassThroughWhenUnfocused = false;
scrollToBottomOnMessageSend = true;
sequentialFileSend = false;
showNeko = false;
@@ -829,7 +827,6 @@ class Accessibility {
'hdrDisplayMode',
'preserveEditDraft',
'stayInteractiveWhenUnfocused',
'firstClickPassThroughWhenUnfocused',
'scrollToBottomOnMessageSend',
'sequentialFileSend',
],
@@ -890,7 +887,6 @@ class Accessibility {
hdrDisplayMode: HDR_TO_PROTO[s.hdrDisplayMode],
preserveEditDraft: s.preserveEditDraft,
stayInteractiveWhenUnfocused: s.stayInteractiveWhenUnfocused,
firstClickPassThroughWhenUnfocused: s.firstClickPassThroughWhenUnfocused,
scrollToBottomOnMessageSend: s.scrollToBottomOnMessageSend,
sequentialFileSend: s.sequentialFileSend,
}),
@@ -980,8 +976,6 @@ class Accessibility {
if (m.preserveEditDraft !== undefined) s.preserveEditDraft = m.preserveEditDraft;
if (m.stayInteractiveWhenUnfocused !== undefined)
s.stayInteractiveWhenUnfocused = m.stayInteractiveWhenUnfocused;
if (m.firstClickPassThroughWhenUnfocused !== undefined)
s.firstClickPassThroughWhenUnfocused = m.firstClickPassThroughWhenUnfocused;
if (m.scrollToBottomOnMessageSend !== undefined) s.scrollToBottomOnMessageSend = m.scrollToBottomOnMessageSend;
if (m.sequentialFileSend !== undefined) s.sequentialFileSend = m.sequentialFileSend;
},
@@ -1316,8 +1310,6 @@ class Accessibility {
if (validated.preserveEditDraft !== undefined) this.preserveEditDraft = validated.preserveEditDraft;
if (validated.stayInteractiveWhenUnfocused !== undefined)
this.stayInteractiveWhenUnfocused = validated.stayInteractiveWhenUnfocused;
if (validated.firstClickPassThroughWhenUnfocused !== undefined)
this.firstClickPassThroughWhenUnfocused = validated.firstClickPassThroughWhenUnfocused;
if (validated.scrollToBottomOnMessageSend !== undefined)
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
@@ -1418,8 +1410,6 @@ class Accessibility {
hdrDisplayMode: data.hdrDisplayMode ?? this.hdrDisplayMode,
preserveEditDraft: data.preserveEditDraft ?? this.preserveEditDraft,
stayInteractiveWhenUnfocused: data.stayInteractiveWhenUnfocused ?? this.stayInteractiveWhenUnfocused,
firstClickPassThroughWhenUnfocused:
data.firstClickPassThroughWhenUnfocused ?? this.firstClickPassThroughWhenUnfocused,
scrollToBottomOnMessageSend: data.scrollToBottomOnMessageSend ?? this.scrollToBottomOnMessageSend,
sequentialFileSend: data.sequentialFileSend ?? this.sequentialFileSend,
showNeko: data.showNeko ?? this.showNeko,
@@ -152,6 +152,38 @@ describe('useRovingFocusList', () => {
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(
@@ -72,7 +72,9 @@ const applyTabIndices = (focusable: Array<HTMLElement>, activeIndex: number): vo
for (let i = 0; i < focusable.length; i++) {
const element = focusable[i];
const desired = i === clamped ? '0' : '-1';
element.setAttribute(ROVING_MANAGED_ATTR, '');
if (!element.hasAttribute(ROVING_MANAGED_ATTR)) {
element.setAttribute(ROVING_MANAGED_ATTR, '');
}
if (element.getAttribute('tabindex') !== desired) {
element.setAttribute('tabindex', desired);
}
@@ -16,9 +16,8 @@ import {
EDIT_MESSAGE_DESCRIPTOR,
EMOJIS_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import Keybind from '@app/features/input/state/InputKeybind';
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import {doesEventMatchShortcut, useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import {useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import {applyMarkdownSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
@@ -155,16 +154,6 @@ export const EditingMessageInput = observer(
if (event.defaultPrevented) {
return;
}
const composer = composerRef.current;
const selection = composer == null ? null : composer.getSelection();
const hasSelectionRange = selection != null && selection.start !== selection.end;
const inboxCombo = Keybind.getByAction('chat_toggle_inbox').combo;
if (doesEventMatchShortcut(event, inboxCombo) && !hasSelectionRange && actualContent.trim().length === 0) {
event.preventDefault();
event.stopPropagation();
ComponentDispatch.dispatch('INBOX_OPEN');
return;
}
if (event.key === 'Escape' && !event.shiftKey && !event.defaultPrevented && !event.nativeEvent.isComposing) {
event.preventDefault();
event.stopPropagation();
@@ -52,8 +52,7 @@ import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
import GuildGuilds from '@app/features/guild/state/Guilds';
import {CANCEL_DESCRIPTOR, CONTINUE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import Keybind from '@app/features/input/state/InputKeybind';
import type {ComposerHandle, ComposerSelectionRange} from '@app/features/lexical/composer/ComposerHandle';
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import {insertComposerEmoji} from '@app/features/lexical/composer/ComposerInsertion';
import {LexicalComposerInput} from '@app/features/lexical/composer/LexicalComposerInput';
import {
@@ -69,7 +68,7 @@ import {showAttachmentPermissionDeniedModal} from '@app/features/messaging/compo
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
import {TooManyAttachmentsModal} from '@app/features/messaging/components/alerts/TooManyAttachmentsModal';
import {useTextareaAttachments} from '@app/features/messaging/hooks/useCloudUpload';
import {doesEventMatchShortcut, useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import {useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import {type SendMessageFunction, useMessageSubmission} from '@app/features/messaging/hooks/useMessageSubmission';
import {useTextareaDraftAndTyping} from '@app/features/messaging/hooks/useTextareaDraftAndTyping';
import {useTextareaEditing} from '@app/features/messaging/hooks/useTextareaEditing';
@@ -848,22 +847,9 @@ export const LexicalChannelTextareaContent = observer(
}, [channel.guildId]);
const handleEditorKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
const handle = handleRef.current;
let selection: ComposerSelectionRange | null = null;
if (handle !== null) {
selection = handle.getSelection();
}
const hasSelectionRange = selection ? selection.start !== selection.end : false;
const inboxCombo = Keybind.getByAction('chat_toggle_inbox').combo;
if (doesEventMatchShortcut(event, inboxCombo) && !hasSelectionRange && value.trim().length === 0) {
event.preventDefault();
event.stopPropagation();
ComponentDispatch.dispatch('INBOX_OPEN');
return;
}
handleEscapeKey(event);
},
[handleEscapeKey, value],
[handleEscapeKey],
);
const handleSubmit = useCallback(() => {
if (!canSubmit) {
@@ -67,7 +67,6 @@ import {Popout} from '@app/features/ui/popover/PopoverPopout';
import ContextMenu from '@app/features/ui/state/ContextMenu';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {canUseWindowFocusedActivationClick} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {MessageStates} from '@fluxer/constants/src/ChannelConstants';
@@ -187,14 +186,6 @@ const useShiftKey = (enabled: boolean) => {
}, [enabled]);
return useSyncExternalStore(subscribe, getSnapshot, shiftKeyManager.getServerSnapshot);
};
const suppressBlockedActivationClick = (event: React.SyntheticEvent): boolean => {
if (canUseWindowFocusedActivationClick()) {
return false;
}
event.preventDefault();
event.stopPropagation();
return true;
};
interface MessageActionBarButtonProps {
label: string;
@@ -211,9 +202,6 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
({label, icon, onClick, onPointerDownCapture, danger, isActive, hidden, dataAction}, ref) => {
const handleClick = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
if (suppressBlockedActivationClick(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
onClick?.(event);
@@ -222,9 +210,6 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
);
const handlePointerDownCapture = useCallback(
(event: React.PointerEvent) => {
if (suppressBlockedActivationClick(event)) {
return;
}
onPointerDownCapture?.(event);
},
[onPointerDownCapture],
@@ -275,9 +260,6 @@ const QuickReactionButton = observer(
const {url: displayUrl} = getEmojiDisplayData(emoji);
const handleClick = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
if (suppressBlockedActivationClick(event)) {
return;
}
event.preventDefault();
event.stopPropagation();
EmojiPickerCommands.trackEmojiUsage(emoji);
@@ -700,7 +682,6 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
uniqueId={`emoji_picker-actionbar-${message.id}`}
shouldAutoUpdate={false}
animationType="none"
shouldOpenOnClick={() => canUseWindowFocusedActivationClick()}
onOpen={handleEmojiPickerOpen}
onClose={handleEmojiPickerClose}
data-flx="channel.message-action-bar.message-action-bar-core.popout"
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {trackedRecipientNameKey} from '@app/features/channel/state/ChannelDisplayNameTracking';
import {onLocaleChange} from '@app/features/i18n/utils/LocaleChangeListener';
import {deferUntilModulesLoaded} from '@app/features/platform/utils/DeferUntilModulesLoaded';
import {noteText} from '@app/features/theme/fonts/ScriptFontLoader';
@@ -48,12 +49,8 @@ class ChannelDisplayName {
deferUntilModulesLoaded(() => {
reaction(
() => {
if (!Users) return [];
return Users.usersList.map((user) => ({
id: user.id,
username: user.username,
globalName: user.globalName,
}));
if (!Users) return '';
return trackedRecipientNameKey(this.channelSnapshots, Users.users);
},
() => this.recomputeAll(),
);
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type TrackedRecipientSnapshot,
type TrackedRecipientUser,
trackedRecipientNameKey,
} from '@app/features/channel/state/ChannelDisplayNameTracking';
import {autorun, observable, runInAction} from 'mobx';
import {describe, expect, it} from 'vitest';
const snapshots = (entries: Record<string, ReadonlyArray<string>>): ReadonlyMap<string, TrackedRecipientSnapshot> =>
new Map(Object.entries(entries).map(([id, recipientIds]) => [id, {recipientIds}]));
const userTable = (count: number): Record<string, TrackedRecipientUser> => {
const users: Record<string, TrackedRecipientUser> = {};
for (let i = 0; i < count; i++) {
users[`u${i}`] = {username: `user${i}`, globalName: null};
}
return users;
};
/** Wraps a user table so we can count exactly which keys get read. */
const countingUsers = (
users: Record<string, TrackedRecipientUser | undefined>,
): {proxy: Record<string, TrackedRecipientUser | undefined>; reads: Array<string>} => {
const reads: Array<string> = [];
const proxy = new Proxy(users, {
get(target, key, receiver) {
if (typeof key === 'string') reads.push(key);
return Reflect.get(target, key, receiver);
},
});
return {proxy, reads};
};
describe('trackedRecipientNameKey', () => {
it('reads only the recipients of tracked group DMs, not the whole user cache', () => {
const {proxy, reads} = countingUsers(userTable(10_000));
trackedRecipientNameKey(snapshots({c1: ['u1', 'u2'], c2: ['u3']}), proxy);
// The cost must be a function of tracked recipients, never of cache size.
expect(reads).toEqual(['u1', 'u2', 'u3']);
});
it('cost is independent of how large the user cache grows', () => {
const small = countingUsers(userTable(10));
const huge = countingUsers(userTable(100_000));
const tracked = snapshots({c1: ['u1', 'u2']});
trackedRecipientNameKey(tracked, small.proxy);
trackedRecipientNameKey(tracked, huge.proxy);
expect(small.reads.length).toBe(2);
expect(huge.reads.length).toBe(2);
});
it('changes when a tracked recipient is renamed', () => {
const users = userTable(3);
const tracked = snapshots({c1: ['u1']});
const before = trackedRecipientNameKey(tracked, users);
users.u1 = {username: 'renamed', globalName: null};
expect(trackedRecipientNameKey(tracked, users)).not.toBe(before);
});
it('does not change when an untracked user is renamed', () => {
const users = userTable(3);
const tracked = snapshots({c1: ['u1']});
const before = trackedRecipientNameKey(tracked, users);
users.u2 = {username: 'unrelated-change', globalName: 'also changed'};
expect(trackedRecipientNameKey(tracked, users)).toBe(before);
});
it('distinguishes a missing recipient from one that arrives later', () => {
const users: Record<string, TrackedRecipientUser | undefined> = {};
const tracked = snapshots({c1: ['u1']});
const before = trackedRecipientNameKey(tracked, users);
users.u1 = {username: 'arrived', globalName: null};
expect(trackedRecipientNameKey(tracked, users)).not.toBe(before);
});
it('is not confused by usernames containing the separator characters', () => {
const tracked = snapshots({c1: ['a', 'b']});
const collidingLeft: Record<string, TrackedRecipientUser> = {
a: {username: 'x", "y', globalName: null},
b: {username: '', globalName: null},
};
const collidingRight: Record<string, TrackedRecipientUser> = {
a: {username: '', globalName: null},
b: {username: 'x", "y', globalName: null},
};
expect(trackedRecipientNameKey(tracked, collidingLeft)).not.toBe(trackedRecipientNameKey(tracked, collidingRight));
});
it('drives a MobX reaction on tracked renames but stays quiet on untracked ones', () => {
const users = observable.object<Record<string, TrackedRecipientUser | undefined>>({
u1: {username: 'one', globalName: null},
u2: {username: 'two', globalName: null},
});
const tracked = snapshots({c1: ['u1']});
let runs = 0;
const dispose = autorun(() => {
trackedRecipientNameKey(tracked, users);
runs++;
});
expect(runs).toBe(1);
// An untracked user changing must not wake the reaction.
runInAction(() => {
users.u2 = {username: 'two-renamed', globalName: null};
});
expect(runs).toBe(1);
// A tracked user changing must.
runInAction(() => {
users.u1 = {username: 'one-renamed', globalName: null};
});
expect(runs).toBe(2);
dispose();
});
});
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface TrackedRecipientSnapshot {
readonly recipientIds: ReadonlyArray<string>;
}
export interface TrackedRecipientUser {
readonly username: string;
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>,
): string => {
const parts: Array<[string, string, string]> = [];
for (const snapshot of snapshots.values()) {
for (const recipientId of snapshot.recipientIds) {
const user = users[recipientId];
parts.push([recipientId, user?.username ?? '', user?.globalName ?? '']);
}
}
return JSON.stringify(parts);
};
@@ -662,6 +662,8 @@ const getDefaultKeybinds = (
action: 'chat_toggle_inbox',
label: i18n._(TOGGLE_THE_INBOX_DESCRIPTOR),
combo: {key: 'i', ctrlOrMeta: true},
ignoreWhileTyping: true,
editableFocusBehavior: 'allow_when_empty',
section: 'chat',
},
{
@@ -108,7 +108,8 @@ export const doesEventMatchShortcut = (event: ShortcutKeyEvent, target: Partial<
};
const shouldPreserveConflictingAction = (action: KeybindCommand, options: MarkdownKeybindScopeOptions): boolean => {
if (!options.preserveEditableFocusActions) return false;
return Keybind.getDefaultByAction(action)?.editableFocusBehavior === 'allow';
const behavior = Keybind.getDefaultByAction(action)?.editableFocusBehavior;
return behavior === 'allow' || behavior === 'allow_when_empty';
};
const getConflictingKeybindActions = (options: MarkdownKeybindScopeOptions = {}): Set<KeybindCommand> => {
const actions = new Set<KeybindCommand>();
@@ -76,7 +76,6 @@ export interface DesktopWindowBehaviorSettings {
activeSmoothScrolling: boolean;
middleClickAutoscroll: boolean;
activeMiddleClickAutoscroll: boolean;
firstClickPassThroughWhenUnfocused: boolean;
}
export interface ThemeLocalFileReference {
@@ -3,7 +3,6 @@
export const WINDOW_FOCUSED_CLASS = 'window-focused';
export const WINDOW_FOCUS_ACTIVATION_GUARD_CLASS = 'window-focus-activation-guard';
export const UNFOCUSED_FULLY_INTERACTIVE_CLASS = 'unfocused-fully-interactive';
export const FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS = 'first-click-passthrough-when-unfocused';
export const WINDOW_HOVER_CONTROLS_CHANGE_EVENT = 'fluxer-window-hover-controls-change';
const DEFAULT_GUARD_TIMEOUT_MS = 250;
const DEFAULT_RELEASE_CLEAR_DELAY_MS = 50;
@@ -32,16 +31,6 @@ export function isUnfocusedFullyInteractive(root: HTMLElement = document.documen
return root.classList.contains(UNFOCUSED_FULLY_INTERACTIVE_CLASS);
}
export function shouldBlockActivationClick(root: HTMLElement = document.documentElement): boolean {
return (
isWindowFocusActivationGuardActive(root) && !root.classList.contains(FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS)
);
}
export function canUseWindowFocusedActivationClick(root: HTMLElement = document.documentElement): boolean {
return !shouldBlockActivationClick(root);
}
export function canUseWindowFocusedHoverControls(root: HTMLElement = document.documentElement): boolean {
if (isUnfocusedFullyInteractive(root)) {
return !isWindowFocusActivationGuardActive(root);
@@ -132,20 +121,12 @@ export function createWindowFocusInteractionGuard({
clearActivationGuard();
}, releaseClearDelayMs);
};
const suppressActivationEvent = (event: Event) => {
if (!shouldBlockActivationClick(root)) return;
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
};
const handlePointerStart: EventListener = (event) => {
const handlePointerStart: EventListener = () => {
if (wasBlurred || !focused || isWindowFocusActivationGuardActive(root)) {
beginActivationGuard(true);
}
suppressActivationEvent(event);
};
const handlePointerEnd: EventListener = (event) => {
suppressActivationEvent(event);
const handlePointerEnd: EventListener = () => {
scheduleClearAfterActivationClick();
};
windowTarget.addEventListener('pointerdown', handlePointerStart, true);
@@ -3,6 +3,7 @@
import {
MiddleClickAutoscrollControl,
SmoothScrollingControl,
StayInteractiveUnfocusedControl,
TextSelectionControl,
VideoSeekThumbnailsControl,
} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedAccessibilityControls';
@@ -34,10 +35,8 @@ import {
} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedChatControls';
import {DeveloperModeControl} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedClientDeveloperControls';
import {
FirstClickPassThroughControl,
HardwareAccelerationControl,
NativeTitleBarControl,
StayInteractiveUnfocusedControl,
} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedDesktopControls';
import {UnreadBadgeCustomizationControl} from '@app/features/user/components/modals/tabs/advanced_settings_tab/AdvancedExperimentalControls';
import {
@@ -90,8 +89,7 @@ export const DIRECT_CONTROL_ITEM_IDS = new Set([
'voice-video-screen-share-encoder-controls',
'advanced-unread-badge-customization',
'client-developer-mode',
'advanced-stay-interactive-unfocused',
'first-click-pass-through',
'accessibility-stay-interactive-unfocused',
'advanced-native-title-bar',
'advanced-hardware-acceleration',
]);
@@ -126,8 +124,7 @@ export const COMPACT_SWITCH_CONTROL_ITEM_IDS = new Set([
'voice-video-openh264-codec',
'advanced-unread-badge-customization',
'client-developer-mode',
'advanced-stay-interactive-unfocused',
'first-click-pass-through',
'accessibility-stay-interactive-unfocused',
'advanced-native-title-bar',
'advanced-hardware-acceleration',
]);
@@ -298,14 +295,10 @@ export const AdvancedSettingControl = observer(({item}: {item: SearchableSetting
return (
<DeveloperModeControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.developer-mode-control" />
);
case 'advanced-stay-interactive-unfocused':
case 'accessibility-stay-interactive-unfocused':
return (
<StayInteractiveUnfocusedControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.stay-interactive-unfocused-control" />
);
case 'first-click-pass-through':
return (
<FirstClickPassThroughControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.first-click-pass-through-control" />
);
case 'advanced-native-title-bar':
return (
<NativeTitleBarControl data-flx="user.advanced-setting-direct-controls.advanced-setting-control.native-title-bar-control" />
@@ -32,6 +32,14 @@ const CONTENT_ANIMATIONS_STAY_IN_ANIMATION_TAB_DESCRIPTOR = msg({
message: 'Animated emojis, GIFs and stickers stay under your control in the Animation tab.',
comment: 'Extra hint shown beneath the reduce motion toggle while reduced motion is active.',
});
const STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR = msg({
message: 'Stay fully interactive when unfocused',
comment: 'Label in the motion tab.',
});
const KEEP_ANIMATIONS_GIF_PLAYBACK_HOVER_EFFECTS_AND_TOOLTIPS_RUNNING_DESCRIPTOR = msg({
message: 'Keep animations, GIF playback, hover effects, and tooltips running while the window is unfocused.',
comment: 'Description text in the motion tab.',
});
export const MotionTabContent: React.FC = observer(() => {
const {i18n} = useLingui();
const syncReducedMotionWithSystem = Accessibility.syncReducedMotionWithSystem;
@@ -60,6 +68,13 @@ export const MotionTabContent: React.FC = observer(() => {
onChange={(value) => AccessibilityCommands.update({reducedMotionOverride: value})}
data-flx="user.accessibility-tab.motion-tab.motion-tab-content.switch.update--2"
/>
<Switch
label={i18n._(STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR)}
description={i18n._(KEEP_ANIMATIONS_GIF_PLAYBACK_HOVER_EFFECTS_AND_TOOLTIPS_RUNNING_DESCRIPTOR)}
value={Accessibility.stayInteractiveWhenUnfocused}
onChange={(value) => AccessibilityCommands.update({stayInteractiveWhenUnfocused: value})}
data-flx="user.accessibility-tab.motion-tab.motion-tab-content.switch.update--3"
/>
</>
);
});
@@ -32,6 +32,10 @@ const SMOOTH_SCROLLING_DESCRIPTOR = msg({
message: 'Smooth scrolling',
comment: 'Short label for an advanced desktop scrolling toggle.',
});
const STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR = msg({
message: 'Stay fully interactive when unfocused',
comment: 'Short label for an advanced app interaction toggle.',
});
const USE_AUTOSCROLLING_DESCRIPTOR = msg({
message: 'Use autoscrolling',
comment: 'Short label for an advanced desktop scrolling toggle.',
@@ -135,6 +139,19 @@ export const TextSelectionControl = observer(() => {
);
});
export const StayInteractiveUnfocusedControl = observer(() => {
const {i18n} = useLingui();
return (
<Switch
ariaLabel={i18n._(STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR)}
value={Accessibility.stayInteractiveWhenUnfocused}
onChange={(value) => AccessibilityCommands.update({stayInteractiveWhenUnfocused: value})}
compact
data-flx="user.advanced-settings-tab.switch.stay-interactive-unfocused"
/>
);
});
export const VideoSeekThumbnailsControl = observer(() => {
const {i18n} = useLingui();
return (
@@ -1,7 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as AccessibilityCommands from '@app/features/accessibility/commands/AccessibilityCommands';
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {
@@ -25,14 +23,6 @@ import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useLayoutEffect, useState} from 'react';
const STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR = msg({
message: 'Stay fully interactive when unfocused',
comment: 'Short label for an advanced desktop preference.',
});
const FIRST_CLICK_PASS_THROUGH_WHEN_UNFOCUSED_DESCRIPTOR = msg({
message: 'First click pass-through when unfocused',
comment: 'Short label for an advanced desktop preference.',
});
const USE_NATIVE_TITLE_BAR_DESCRIPTOR = msg({
message: 'Use native title bar',
comment: 'Short label for an advanced desktop preference.',
@@ -128,32 +118,6 @@ function useDesktopTroubleshootingSettings() {
};
}
export const StayInteractiveUnfocusedControl = observer(() => {
const {i18n} = useLingui();
return (
<Switch
ariaLabel={i18n._(STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR)}
value={Accessibility.stayInteractiveWhenUnfocused}
onChange={(value) => AccessibilityCommands.update({stayInteractiveWhenUnfocused: value})}
compact
data-flx="user.advanced-settings-tab.switch.stay-interactive-unfocused"
/>
);
});
export const FirstClickPassThroughControl = observer(() => {
const {i18n} = useLingui();
return (
<Switch
ariaLabel={i18n._(FIRST_CLICK_PASS_THROUGH_WHEN_UNFOCUSED_DESCRIPTOR)}
value={Accessibility.firstClickPassThroughWhenUnfocused}
onChange={(value) => AccessibilityCommands.update({firstClickPassThroughWhenUnfocused: value})}
compact
data-flx="user.advanced-settings-tab.switch.first-click-pass-through"
/>
);
});
export const NativeTitleBarControl = observer(() => {
const {i18n} = useLingui();
const {desktopWindowBehavior, desktopWindowBehaviorBusy, updateDesktopWindowBehavior} =
@@ -342,6 +342,43 @@ const CONTROL_MIDDLE_CLICK_AUTOSCROLLING_DESCRIPTOR = msg({
message: 'Control Chromium middle-click autoscrolling in the desktop app',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
});
const STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR = msg({
message: 'Stay fully interactive when unfocused',
comment: 'Settings search entry label. Names the settings search entry in the settings UI.',
});
const UNFOCUSED_DESCRIPTOR = msg({
message: 'Unfocused',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const WINDOW_FOCUS_DESCRIPTOR = msg({
message: 'Window focus',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const ANIMATIONS_WHILE_UNFOCUSED_DESCRIPTOR = msg({
message: 'Animations while unfocused',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const ANIMATION_PLAYBACK_DESCRIPTOR = msg({
message: 'Animation playback',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const HOVER_EFFECTS_DESCRIPTOR = msg({
message: 'Hover effects',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const TOOLTIPS_DESCRIPTOR = msg({
message: 'Tooltips',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const POWER_SAVING_DESCRIPTOR = msg({
message: 'Power saving',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const KEEP_ANIMATIONS_GIF_PLAYBACK_HOVER_EFFECTS_AND_TOOLTIPS_DESCRIPTOR = msg({
message: 'Keep animations, GIF playback, hover effects, and tooltips running while the window is unfocused.',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
});
export const accessibilityIndex: Array<SearchableSettingDescriptor> = [
{
id: 'accessibility-confirm-calls',
@@ -486,6 +523,24 @@ export const accessibilityIndex: Array<SearchableSettingDescriptor> = [
],
description: DISPLAY_A_VISIBLE_FOCUS_INDICATOR_AROUND_THE_MESSAGE_DESCRIPTOR,
},
{
id: 'accessibility-stay-interactive-unfocused',
tabType: 'accessibility',
sectionId: 'motion',
label: STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR,
keywords: [
UNFOCUSED_DESCRIPTOR,
WINDOW_FOCUS_DESCRIPTOR,
ANIMATIONS_WHILE_UNFOCUSED_DESCRIPTOR,
ANIMATION_PLAYBACK_DESCRIPTOR,
HOVER_EFFECTS_DESCRIPTOR,
TOOLTIPS_DESCRIPTOR,
POWER_SAVING_DESCRIPTOR,
],
description: KEEP_ANIMATIONS_GIF_PLAYBACK_HOVER_EFFECTS_AND_TOOLTIPS_DESCRIPTOR,
audience: 'advanced',
tags: ['accessibility'],
},
{
id: 'accessibility-sync-reduced-motion',
tabType: 'accessibility',
@@ -7,66 +7,6 @@ import type {SearchableSettingDescriptor} from '@app/features/user/components/se
import {BACKGROUND_DESCRIPTOR} from '@app/features/user/components/settings_utils/search_index/SharedDescriptors';
import {msg} from '@lingui/core/macro';
const FIRST_CLICK_PASS_THROUGH_WHEN_UNFOCUSED_DESCRIPTOR = msg({
message: 'First click pass-through when unfocused',
comment: 'Settings search entry label. Names the settings search entry in the settings UI.',
});
const FIRST_CLICK_DESCRIPTOR = msg({
message: 'First click',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const CLICK_THROUGH_DESCRIPTOR = msg({
message: 'Click through',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const UNFOCUSED_DESCRIPTOR = msg({
message: 'Unfocused',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const WINDOW_FOCUS_DESCRIPTOR = msg({
message: 'Window focus',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const FOCUS_CLICK_DESCRIPTOR = msg({
message: 'Focus click',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const ACTIVATION_CLICK_DESCRIPTOR = msg({
message: 'Activation click',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const LET_THE_CLICK_THAT_FOCUSES_ALSO_ACTIVATE_THE_DESCRIPTOR = msg({
message: 'Let focus clicks activate controls.',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
});
const STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR = msg({
message: 'Stay fully interactive when unfocused',
comment: 'Settings search entry label. Names the settings search entry in the settings UI.',
});
const ANIMATIONS_WHILE_UNFOCUSED_DESCRIPTOR = msg({
message: 'Animations while unfocused',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const ANIMATION_PLAYBACK_DESCRIPTOR = msg({
message: 'Animation playback',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const HOVER_EFFECTS_DESCRIPTOR = msg({
message: 'Hover effects',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const TOOLTIPS_DESCRIPTOR = msg({
message: 'Tooltips',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const POWER_SAVING_DESCRIPTOR = msg({
message: 'Power saving',
comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.',
});
const KEEP_ANIMATIONS_GIF_PLAYBACK_HOVER_EFFECTS_AND_TOOLTIPS_DESCRIPTOR = msg({
message: 'Keep animations, GIFs, hover, and tooltips running unfocused',
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
});
const HARDWARE_ACCELERATION_DESCRIPTOR = msg({
message: 'Hardware acceleration',
comment: 'Settings search entry label. Names the settings search entry in the settings UI.',
@@ -280,40 +220,6 @@ const KEEP_RUNNING_WHEN_THE_WINDOW_IS_CLOSED_DESCRIPTOR = msg({
comment: 'Settings search entry description. One-line summary of what the settings search entry controls.',
});
export const desktopSettingsIndex: Array<SearchableSettingDescriptor> = [
{
id: 'first-click-pass-through',
tabType: 'desktop_settings',
label: FIRST_CLICK_PASS_THROUGH_WHEN_UNFOCUSED_DESCRIPTOR,
keywords: [
FIRST_CLICK_DESCRIPTOR,
CLICK_THROUGH_DESCRIPTOR,
UNFOCUSED_DESCRIPTOR,
WINDOW_FOCUS_DESCRIPTOR,
FOCUS_CLICK_DESCRIPTOR,
ACTIVATION_CLICK_DESCRIPTOR,
],
description: LET_THE_CLICK_THAT_FOCUSES_ALSO_ACTIVATE_THE_DESCRIPTOR,
audience: 'advanced',
tags: ['desktop'],
isVisible: isDesktop,
},
{
id: 'advanced-stay-interactive-unfocused',
tabType: 'desktop_settings',
label: STAY_FULLY_INTERACTIVE_WHEN_UNFOCUSED_DESCRIPTOR,
keywords: [
UNFOCUSED_DESCRIPTOR,
WINDOW_FOCUS_DESCRIPTOR,
ANIMATIONS_WHILE_UNFOCUSED_DESCRIPTOR,
ANIMATION_PLAYBACK_DESCRIPTOR,
HOVER_EFFECTS_DESCRIPTOR,
TOOLTIPS_DESCRIPTOR,
POWER_SAVING_DESCRIPTOR,
],
description: KEEP_ANIMATIONS_GIF_PLAYBACK_HOVER_EFFECTS_AND_TOOLTIPS_DESCRIPTOR,
tags: ['desktop'],
isVisible: isDesktop,
},
{
id: 'advanced-hardware-acceleration',
tabType: 'desktop_settings',
@@ -24,10 +24,6 @@
contain: layout style;
}
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused)) .container {
pointer-events: none;
}
.container:active {
cursor: grabbing;
}
-1
View File
@@ -93,7 +93,6 @@ export interface DesktopWindowBehaviorSettings {
activeSmoothScrolling: boolean;
middleClickAutoscroll: boolean;
activeMiddleClickAutoscroll: boolean;
firstClickPassThroughWhenUnfocused: boolean;
}
export interface ThemeLocalFileReference {
+74 -2
View File
@@ -2,10 +2,12 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle;
const COLD_START_RETRY_COOLDOWN: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DiscoveryResponse {
#[serde(flatten)]
@@ -14,15 +16,47 @@ pub struct DiscoveryResponse {
pub struct DiscoveryCache {
cached: RwLock<Option<DiscoveryResponse>>,
cold_start_attempt: Mutex<Option<Instant>>,
}
impl DiscoveryCache {
pub fn new() -> Self {
Self {
cached: RwLock::new(None),
cold_start_attempt: Mutex::new(None),
}
}
/// Discovery for a request that has no cached value yet.
///
/// Steady-state freshness is owned by `start_background_refresh`, which backs off when the
/// upstream is unhealthy. Request handlers must never block on the network while a cached
/// value exists, and must not retry a failing upstream once per request: the upstream is
/// reached through this proxy's own public hostname, so a stall here feeds back into the
/// front door that serves it.
pub async fn get_or_cold_start(
&self,
client: &reqwest::Client,
upstream_url: &str,
) -> Option<DiscoveryResponse> {
if let Some(cached) = self.get().await {
return Some(cached);
}
{
let mut last_attempt = self.cold_start_attempt.lock().await;
if let Some(at) = *last_attempt
&& at.elapsed() < COLD_START_RETRY_COOLDOWN
{
return None;
}
*last_attempt = Some(Instant::now());
}
if let Err(err) = self.refresh(client, upstream_url).await {
tracing::warn!(%err, url = upstream_url, "cold-start discovery fetch failed");
}
self.get().await
}
pub async fn refresh(
&self,
client: &reqwest::Client,
@@ -114,6 +148,44 @@ mod tests {
assert!(cache.get().await.is_none());
}
#[tokio::test]
async fn cached_discovery_never_touches_the_network() {
let cache = DiscoveryCache::new();
let json = r#"{"api_code_version":"v1"}"#;
*cache.cached.write().await = Some(serde_json::from_str(json).unwrap());
// An unroutable upstream: if this were contacted the call would stall or error.
let client = reqwest::Client::new();
let got = cache
.get_or_cold_start(&client, "http://127.0.0.1:1/discovery")
.await;
assert!(got.is_some());
assert!(cache.cold_start_attempt.lock().await.is_none());
}
#[tokio::test]
async fn cold_start_failure_is_not_retried_on_every_request() {
let cache = DiscoveryCache::new();
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(50))
.build()
.unwrap();
assert!(
cache
.get_or_cold_start(&client, "http://127.0.0.1:1/discovery")
.await
.is_none()
);
let first = *cache.cold_start_attempt.lock().await;
assert!(first.is_some());
assert!(
cache
.get_or_cold_start(&client, "http://127.0.0.1:1/discovery")
.await
.is_none()
);
assert_eq!(*cache.cold_start_attempt.lock().await, first);
}
#[test]
fn discovery_response_deserializes_from_json() {
let json = r#"{"api_code_version":"v1","name":"test"}"#;
+2 -10
View File
@@ -150,18 +150,10 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s
}
async fn refresh_discovery_for_spa(state: &AppState) -> Option<DiscoveryResponse> {
if let Err(err) = state
state
.discovery_cache
.refresh(&state.http_client, &state.config.discovery_upstream_url)
.get_or_cold_start(&state.http_client, &state.config.discovery_upstream_url)
.await
{
tracing::warn!(
%err,
url = %state.config.discovery_upstream_url,
"failed to refresh discovery before serving SPA; using cached discovery"
);
}
state.discovery_cache.get().await
}
async fn resolve_invite_meta(
@@ -31,7 +31,6 @@ interface PersistedDesktopWindowBehaviorSettings {
allowTransparency?: boolean;
smoothScrolling?: boolean;
middleClickAutoscroll?: boolean;
firstClickPassThroughWhenUnfocused?: boolean;
}
interface PersistedDesktopTroubleshootingSettings {
@@ -62,7 +61,6 @@ function getDefaultDesktopWindowBehaviorSettings(): DesktopWindowBehaviorSetting
activeSmoothScrolling: true,
middleClickAutoscroll: false,
activeMiddleClickAutoscroll: false,
firstClickPassThroughWhenUnfocused: false,
};
}
@@ -95,9 +93,6 @@ function sanitizePersistedDesktopWindowBehaviorSettings(
if (typeof value.middleClickAutoscroll === 'boolean') {
settings.middleClickAutoscroll = value.middleClickAutoscroll;
}
if (typeof value.firstClickPassThroughWhenUnfocused === 'boolean') {
settings.firstClickPassThroughWhenUnfocused = value.firstClickPassThroughWhenUnfocused;
}
const minimizeToTrayV2 = value[MINIMIZE_TO_TRAY_STORAGE_KEY_V2];
if (typeof minimizeToTrayV2 === 'boolean') {
settings.minimizeToTrayV2 = minimizeToTrayV2;
@@ -238,10 +233,6 @@ function normalizeDesktopWindowBehaviorSettings(
: typeof normalizedSettings?.middleClickAutoscroll === 'boolean'
? normalizedSettings.middleClickAutoscroll
: defaults.middleClickAutoscroll,
firstClickPassThroughWhenUnfocused:
typeof normalizedSettings?.firstClickPassThroughWhenUnfocused === 'boolean'
? normalizedSettings.firstClickPassThroughWhenUnfocused
: defaults.firstClickPassThroughWhenUnfocused,
};
if (!normalized.showTrayIcon) {
normalized.minimizeToTray = false;
@@ -260,7 +251,6 @@ function serializeDesktopWindowBehaviorSettings(
allowTransparency: settings.allowTransparency,
smoothScrolling: settings.smoothScrolling,
middleClickAutoscroll: settings.middleClickAutoscroll,
firstClickPassThroughWhenUnfocused: settings.firstClickPassThroughWhenUnfocused,
[MINIMIZE_TO_TRAY_STORAGE_KEY_V2]: settings.minimizeToTray,
[CLOSE_TO_TRAY_STORAGE_KEY_V2]: settings.closeToTray,
};
-1
View File
@@ -120,7 +120,6 @@ export interface DesktopWindowBehaviorSettings {
activeSmoothScrolling: boolean;
middleClickAutoscroll: boolean;
activeMiddleClickAutoscroll: boolean;
firstClickPassThroughWhenUnfocused: boolean;
}
export interface ThemeLocalFileReference {
@@ -322,7 +322,6 @@ function formatWindowBehavior(settings: DesktopWindowBehaviorSettings): string {
`activeSmoothScrolling=${settings.activeSmoothScrolling}`,
`middleClickAutoscroll=${settings.middleClickAutoscroll}`,
`activeMiddleClickAutoscroll=${settings.activeMiddleClickAutoscroll}`,
`firstClickPassThroughWhenUnfocused=${settings.firstClickPassThroughWhenUnfocused}`,
].join(', ');
}
-5
View File
@@ -62,7 +62,6 @@ import {
import {
clearSavedWindowBounds,
closeThemeStudioPopoutWindow,
desktopFirstClickPassThroughPendingRestart,
desktopTransparencyPendingRestart,
desktopUseNativeTitleBarPendingRestart,
focusThemeStudioPopoutWindow,
@@ -123,9 +122,6 @@ function normalizeDesktopWindowBehaviorUpdate(value: unknown): Partial<DesktopWi
if (typeof value.middleClickAutoscroll === 'boolean') {
update.middleClickAutoscroll = value.middleClickAutoscroll;
}
if (typeof value.firstClickPassThroughWhenUnfocused === 'boolean') {
update.firstClickPassThroughWhenUnfocused = value.firstClickPassThroughWhenUnfocused;
}
return update;
}
@@ -255,7 +251,6 @@ export function registerIpcHandlers(): void {
ipcMain.handle('desktop-window-behavior-pending-restart', (): boolean => {
return (
desktopTrayChangePendingRestart() ||
desktopFirstClickPassThroughPendingRestart() ||
desktopUseNativeTitleBarPendingRestart() ||
desktopTransparencyPendingRestart()
);
+1 -9
View File
@@ -145,7 +145,6 @@ interface CreateWindowOptions {
let mainWindow: BrowserWindow | null = null;
let windowStateFile: string;
let isQuitting = false;
let initialAcceptFirstMouseOnFocus: boolean | null = null;
let initialUseNativeTitleBar: boolean | null = null;
let initialAllowTransparency: boolean | null = null;
let themeStudioPopoutWindow: BrowserWindow | null = null;
@@ -331,12 +330,6 @@ export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
export function desktopFirstClickPassThroughPendingRestart(): boolean {
if (process.platform !== 'darwin') return false;
if (initialAcceptFirstMouseOnFocus === null) return false;
return getDesktopWindowBehaviorSettings().firstClickPassThroughWhenUnfocused !== initialAcceptFirstMouseOnFocus;
}
export function getActiveUseNativeTitleBar(): boolean {
return initialUseNativeTitleBar ?? false;
}
@@ -730,8 +723,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
const desktopWindowBehavior = getDesktopWindowBehaviorSettings();
const allowTransparency = desktopWindowBehavior.allowTransparency;
const useNativeTitleBar = getEffectiveUseNativeTitleBar(desktopWindowBehavior);
const acceptFirstMouseOnFocus = isMac && desktopWindowBehavior.firstClickPassThroughWhenUnfocused;
initialAcceptFirstMouseOnFocus = acceptFirstMouseOnFocus;
const acceptFirstMouseOnFocus = isMac;
initialUseNativeTitleBar = useNativeTitleBar;
initialAllowTransparency = allowTransparency;
const appUrl = getAppUrl();
+26 -2
View File
@@ -84,11 +84,14 @@ code_change(_OldVsn, State, _Extra) ->
-spec handle_call(term(), gen_server:from(), state()) ->
{reply, term(), state()} | {stop, normal, ok, state()}.
handle_call({session_connect, Request}, {Pid, _}, State) when is_map(Request), is_pid(Pid) ->
{reply, Reply, NewState} = presence_session:handle_session_connect(Request, Pid, State),
SessionPid = session_connect_pid(Request, Pid),
{reply, Reply, NewState} = presence_session:handle_session_connect(
Request, SessionPid, State
),
FinalState = presence_broadcast:publish_global_presence(
maps:get(sessions, NewState), NewState
),
presence_broadcast:send_cached_presences_to_session(Pid, FinalState),
presence_broadcast:send_cached_presences_to_session(SessionPid, FinalState),
{reply, Reply, FinalState};
handle_call(get_current_visible_presence, _From, State) ->
{reply, presence_broadcast:current_visible_presence(State), State};
@@ -141,6 +144,12 @@ handle_cast({sync_group_dm_recipients, RecipientsByChannel}, State) when
handle_cast(Msg, State) ->
handle_cast_guild(Msg, State).
-spec session_connect_pid(map(), pid()) -> pid().
session_connect_pid(#{session_pid := Pid}, _CallerPid) when is_pid(Pid) ->
Pid;
session_connect_pid(_Request, CallerPid) ->
CallerPid.
-spec handle_sync_friends_cast([term()], [term()], state()) -> {noreply, state()}.
handle_sync_friends_cast(FriendIds, FlushedIds, State) ->
{noreply,
@@ -280,6 +289,14 @@ normalize_start_link(ignore) ->
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
session_connect_pid_prefers_request_session_pid_test() ->
Caller = self(),
Other = spawn(fun idle_session_proc/0),
?assertEqual(Other, session_connect_pid(#{session_pid => Other}, Caller)),
?assertEqual(Caller, session_connect_pid(#{}, Caller)),
?assertEqual(Caller, session_connect_pid(#{session_pid => undefined}, Caller)),
Other ! stop.
presence_rejoin_notifies_all_sessions_test() ->
Parent = self(),
Session1 = spawn(fun() -> rejoin_check_receiver(Parent, one) end),
@@ -349,6 +366,13 @@ test_session_entry(Pid) ->
socket_pid => undefined
}.
-spec idle_session_proc() -> ok.
idle_session_proc() ->
receive
stop -> ok
after 1000 -> ok
end.
-spec rejoin_check_receiver(pid(), atom()) -> term().
rejoin_check_receiver(Parent, Tag) ->
receive
@@ -414,21 +414,22 @@ handle_resume_offline_timeout(_Msg, State) ->
ok.
notify_presence_on_resume(#{presence_pid := undefined}, _Sid, _St, _Afk, _Mob) ->
ok;
notify_presence_on_resume(#{presence_pid := Pid}, SessionId, Status, Afk, Mobile) ->
spawn(fun() -> notify_presence_on_resume_worker(Pid, SessionId, Status, Afk, Mobile) end),
notify_presence_on_resume(#{presence_pid := Pid} = State, SessionId, Status, Afk, Mobile) ->
Request = #{
session_id => SessionId,
session_pid => self(),
socket_pid => maps:get(socket_pid, State, undefined),
status => Status,
afk => Afk,
mobile => Mobile
},
spawn(fun() -> notify_presence_on_resume_worker(Pid, Request) end),
ok.
-spec notify_presence_on_resume_worker(pid(), session_id(), status(), boolean(), boolean()) ->
ok.
notify_presence_on_resume_worker(Pid, SessionId, Status, Afk, Mobile) ->
-spec notify_presence_on_resume_worker(pid(), map()) -> ok.
notify_presence_on_resume_worker(Pid, Request) ->
try
gen_server:call(
Pid,
{session_connect, #{
session_id => SessionId, status => Status, afk => Afk, mobile => Mobile
}},
10000
),
gen_server:call(Pid, {session_connect, Request}, 10000),
ok
catch
error:_Reason -> ok;
@@ -363,6 +363,31 @@ resume_success_state(State0) ->
{reply, {ok, _Missed, 1}, State1} = session_lifecycle:handle_resume(0, self(), State0),
State1.
handle_resume_registers_the_session_process_with_presence_test() ->
SessionPid = self(),
Presence = spawn(fun() -> fake_presence_loop(SessionPid) end),
State0 = resume_test_state(#{presence_pid => Presence, seq => 1}),
{reply, {ok, [], 1}, _State1} = session_lifecycle:handle_resume(1, self(), State0),
receive
{presence_session_connect, Request} ->
?assertEqual(SessionPid, maps:get(session_pid, Request)),
?assertEqual(<<"session-resume-test">>, maps:get(session_id, Request))
after 2000 ->
?assert(false, presence_session_connect_not_received)
end.
fake_presence_loop(Parent) ->
receive
{'$gen_call', From, {session_connect, Request}} ->
Parent ! {presence_session_connect, Request},
gen_server:reply(From, {ok, []}),
fake_presence_loop(Parent);
_Other ->
fake_presence_loop(Parent)
after 5000 ->
ok
end.
resume_test_state(Overrides) ->
maps:merge(
#{
+57 -2
View File
@@ -117,7 +117,10 @@ pub async fn run(cfg: Config) -> anyhow::Result<()> {
bunny_ip_gate::gate_middleware,
));
}
router = router.layer(middleware::from_fn(add_security_header_middleware));
router = router.layer(middleware::from_fn_with_state(
state.cfg.mode,
add_security_header_middleware,
));
let app = router.with_state(state);
let listener = TcpListener::bind(addr).await?;
info!(%addr, "media proxy listening");
@@ -176,11 +179,16 @@ async fn add_version_header(request: Request<Body>, next: middleware::Next) -> R
}
async fn add_security_header_middleware(
State(mode): State<DeploymentMode>,
request: Request<Body>,
next: middleware::Next,
) -> Response {
let mut response = next.run(request).await;
http_headers::add_security_headers(response.headers_mut());
let headers = response.headers_mut();
http_headers::add_security_headers(headers);
if mode == DeploymentMode::Static {
headers.remove("X-Robots-Tag");
}
response
}
@@ -3074,6 +3082,53 @@ mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
async fn robots_header_for(mode: DeploymentMode) -> Option<String> {
use axum::{body::Body, http::Request, routing::get};
let router = Router::new()
.route(
"/probe",
get(|| async {
let mut response = Response::new(Body::empty());
http_headers::add_media_headers(response.headers_mut(), 0, "text/plain", None);
response
}),
)
.layer(middleware::from_fn_with_state(
mode,
add_security_header_middleware,
));
let response = tower::ServiceExt::oneshot(
router,
Request::builder()
.uri("/probe")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response
.headers()
.get("X-Robots-Tag")
.map(|v| v.to_str().unwrap().to_owned())
}
#[tokio::test]
async fn static_mode_does_not_set_robots_tag() {
assert_eq!(robots_header_for(DeploymentMode::Static).await, None);
}
#[tokio::test]
async fn media_and_upload_modes_still_set_robots_tag() {
assert_eq!(
robots_header_for(DeploymentMode::Mp).await.as_deref(),
Some(http_headers::ROBOTS)
);
assert_eq!(
robots_header_for(DeploymentMode::Upload).await.as_deref(),
Some(http_headers::ROBOTS)
);
}
fn upload_relay_test_config(
storage_root: &std::path::Path,
spool_dir: &std::path::Path,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 KiB

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 KiB