Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bbfee4cec | ||
|
|
9e89539f0a | ||
|
|
fa71eb8682 | ||
|
|
49b7127bc7 | ||
|
|
9cb8812be0 | ||
|
|
7d82d188a0 | ||
|
|
5ce5669f17 | ||
|
|
9ea750152e | ||
|
|
e87e2fe7bf | ||
|
|
871b5116dc |
No files matched your search
@@ -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))
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -93,7 +93,6 @@ export interface DesktopWindowBehaviorSettings {
|
||||
activeSmoothScrolling: boolean;
|
||||
middleClickAutoscroll: boolean;
|
||||
activeMiddleClickAutoscroll: boolean;
|
||||
firstClickPassThroughWhenUnfocused: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeLocalFileReference {
|
||||
|
||||
@@ -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"}"#;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(', ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
#{
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 504 KiB After Width: | Height: | Size: 477 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 779 KiB After Width: | Height: | Size: 571 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 537 KiB |