mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-21 15:06:05 +09:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45289b5531 | ||
|
|
30a1271774 | ||
|
|
438f11adda |
No files matched your search
@@ -16,6 +16,7 @@ import {
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildResponse, GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
|
||||
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
@@ -49,6 +50,7 @@ export function GuildBaseController(app: HonoApp) {
|
||||
if (policy.single_community_enabled) {
|
||||
throw new SingleCommunityCannotCreateGuildsError();
|
||||
}
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const locale = ctx.get('requestLocale') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
|
||||
|
||||
@@ -36,7 +36,6 @@ import type {
|
||||
TemplateSerializedGuild,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
|
||||
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {requireEmailVerified} from '../../../auth/EmailVerificationUtils';
|
||||
import type {ChannelID, GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
||||
import {createChannelID, createGuildID, createRoleID, guildIdToRoleId} from '../../../BrandedTypes';
|
||||
import type {IChannelRepository} from '../../../channel/IChannelRepository';
|
||||
@@ -257,7 +256,6 @@ export class GuildOperationsService {
|
||||
if (user.isUnclaimedAccount()) {
|
||||
throw new UnclaimedAccountCannotCreateGuildsError();
|
||||
}
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
const currentGuildCount = await this.guildRepository.countUserGuilds(user.id);
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const maxGuilds = resolveLimitSafe(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {User} from '../../models/User';
|
||||
import {GuildOperationsService} from '../services/data/GuildOperationsService';
|
||||
|
||||
const REACHED_GUILD_COUNT = new Error('reached guild count lookup');
|
||||
|
||||
function createServiceStoppingAtGuildCount(): GuildOperationsService {
|
||||
const guildRepository = {
|
||||
countUserGuilds: (): never => {
|
||||
throw REACHED_GUILD_COUNT;
|
||||
},
|
||||
};
|
||||
const unused = null as never;
|
||||
return new GuildOperationsService(
|
||||
guildRepository as never,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
);
|
||||
}
|
||||
|
||||
function createUser(overrides: {emailVerified: boolean; isBot?: boolean}): User {
|
||||
return {
|
||||
id: 1n,
|
||||
isBot: overrides.isBot ?? false,
|
||||
emailVerified: overrides.emailVerified,
|
||||
isUnclaimedAccount: () => false,
|
||||
} as unknown as User;
|
||||
}
|
||||
|
||||
describe('guild creation email verification', () => {
|
||||
it('does not gate guild creation on email verification inside the domain operation', async () => {
|
||||
const service = createServiceStoppingAtGuildCount();
|
||||
await expect(
|
||||
service.createGuild({user: createUser({emailVerified: false}), data: {name: 'Stock Community'} as never}),
|
||||
).rejects.toBe(REACHED_GUILD_COUNT);
|
||||
});
|
||||
|
||||
it('reaches the same point for a verified user', async () => {
|
||||
const service = createServiceStoppingAtGuildCount();
|
||||
await expect(
|
||||
service.createGuild({user: createUser({emailVerified: true}), data: {name: 'Stock Community'} as never}),
|
||||
).rejects.toBe(REACHED_GUILD_COUNT);
|
||||
});
|
||||
|
||||
it('still rejects bots before any guild count lookup', async () => {
|
||||
const service = createServiceStoppingAtGuildCount();
|
||||
await expect(
|
||||
service.createGuild({
|
||||
user: createUser({emailVerified: true, isBot: true}),
|
||||
data: {name: 'Stock Community'} as never,
|
||||
}),
|
||||
).rejects.toMatchObject({code: APIErrorCodes.BOTS_CANNOT_CREATE_GUILDS});
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {startDesktopLocaleBridge} from '@app/features/platform/utils/DesktopLoca
|
||||
import {PremiumCheckoutReturnWatcher} from '@app/features/premium/components/PremiumCheckoutReturnWatcher';
|
||||
import {QUICK_SWITCHER_PORTAL_ID} from '@app/features/search/components/quick_switcher/QuickSwitcherConstants';
|
||||
import {useCustomThemeStyle} from '@app/features/theme/hooks/useCustomThemeStyle';
|
||||
import {useRemScaleTracking} from '@app/features/theme/hooks/useRemScaleTracking';
|
||||
import {useThemeCssVariables} from '@app/features/theme/hooks/useThemeCssVariables';
|
||||
import Theme from '@app/features/theme/state/Theme';
|
||||
import ThemeLibrary from '@app/features/theme/state/ThemeLibrary';
|
||||
@@ -169,6 +170,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
}, [isNative, firstClickPassThroughWhenUnfocused]);
|
||||
useDesktopAllowTransparency(isNative);
|
||||
useWindowEventListeners({preventDocumentScroll: !isNative});
|
||||
useRemScaleTracking();
|
||||
usePlatformClasses(platform, isNative);
|
||||
useThemeCssVariables({
|
||||
effectiveTheme: Theme.effectiveTheme,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
CompassIcon,
|
||||
DownloadSimpleIcon,
|
||||
type Icon,
|
||||
type IconWeight,
|
||||
PlusIcon,
|
||||
QuestionMarkIcon,
|
||||
StarIcon,
|
||||
@@ -118,7 +119,15 @@ const InlineDMPlaceholder = ({selected, unread}: {readonly selected: boolean; re
|
||||
</flx-app-guild-rail-skeleton-item>
|
||||
);
|
||||
|
||||
const RailActionButton = ({icon: Icon, selected = false}: {readonly icon: Icon; readonly selected?: boolean}) => (
|
||||
const RailActionButton = ({
|
||||
icon: Icon,
|
||||
selected = false,
|
||||
weight = 'bold',
|
||||
}: {
|
||||
readonly icon: Icon;
|
||||
readonly selected?: boolean;
|
||||
readonly weight?: IconWeight;
|
||||
}) => (
|
||||
<flx-app-guild-rail-skeleton-item
|
||||
className={flxElementClassName(styles.item)}
|
||||
data-selected={selectedAttribute(selected)}
|
||||
@@ -129,7 +138,7 @@ const RailActionButton = ({icon: Icon, selected = false}: {readonly icon: Icon;
|
||||
data-flx="app.skeleton.guild-rail-skeleton.rail-action-button.action-icon"
|
||||
>
|
||||
<Icon
|
||||
weight="bold"
|
||||
weight={weight}
|
||||
className={styles.actionIconGlyph}
|
||||
data-flx="app.skeleton.guild-rail-skeleton.rail-action-button.icon"
|
||||
/>
|
||||
@@ -578,6 +587,7 @@ export const GuildRailSkeleton: React.FC<GuildRailSkeletonProps> = ({
|
||||
{discoveryVisible && (
|
||||
<RailActionButton
|
||||
icon={CompassIcon}
|
||||
weight="fill"
|
||||
selected={isDiscoverySelected}
|
||||
data-flx="app.skeleton.guild-rail-skeleton.rail-action-button"
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
@property --skeleton-shimmer-position {
|
||||
syntax: '<percentage>';
|
||||
inherits: true;
|
||||
initial-value: -150%;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
--skeleton-opacity: var(--skeleton-opacity-default, 0.45);
|
||||
--skeleton-width: 100%;
|
||||
@@ -13,13 +7,13 @@
|
||||
background-color: var(--background-modifier-accent);
|
||||
background-image: linear-gradient(
|
||||
105deg,
|
||||
transparent 46%,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--text-primary) 7%, transparent) 50%,
|
||||
transparent 54%
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200vw 100%;
|
||||
background-position: var(--skeleton-shimmer-position) 0;
|
||||
background-attachment: fixed;
|
||||
background-size: 200% 100%;
|
||||
background-position: -50% 0;
|
||||
animation: skeleton-shimmer 3.2s linear infinite;
|
||||
opacity: var(--skeleton-opacity);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
@@ -30,35 +24,25 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:global(html):has(.skeleton) {
|
||||
animation: skeleton-shimmer 3.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
from {
|
||||
--skeleton-shimmer-position: -150%;
|
||||
background-position: -50% 0;
|
||||
}
|
||||
|
||||
to {
|
||||
--skeleton-shimmer-position: 150%;
|
||||
background-position: 150% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(html):has(.skeleton) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global(html.reduced-motion):has(.skeleton) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
:global(html.reduced-motion) .skeleton {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {clearRemScaleCache} from '@app/features/theme/layout/RemFromPx';
|
||||
import type {NativePlatform} from '@app/features/ui/utils/NativeUtils';
|
||||
import {useLayoutEffect} from 'react';
|
||||
|
||||
@@ -8,8 +9,10 @@ export function usePlatformClasses(platform: NativePlatform, isNative: boolean):
|
||||
const htmlNode = document.documentElement;
|
||||
const platformClasses = [isNative ? 'platform-native' : 'platform-web', `platform-${platform}`];
|
||||
htmlNode.classList.add(...platformClasses);
|
||||
clearRemScaleCache();
|
||||
return () => {
|
||||
htmlNode.classList.remove(...platformClasses);
|
||||
clearRemScaleCache();
|
||||
};
|
||||
}, [isNative, platform]);
|
||||
}
|
||||
@@ -95,6 +95,41 @@ function measureMemberGroupHeadingWidthPx(element: HTMLElement | null): number {
|
||||
return Math.round((right - left) / getRemScaleForDocument(element.ownerDocument));
|
||||
}
|
||||
|
||||
function createGroupHeadingRegistrar(groupHeadingWidths: Map<string, number>): (node: HTMLDivElement | null) => void {
|
||||
return (node) => {
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
const groupId = node.dataset.memberGroupId;
|
||||
if (groupId == null) {
|
||||
return;
|
||||
}
|
||||
const widthPx = measureMemberGroupHeadingWidthPx(node);
|
||||
if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) {
|
||||
groupHeadingWidths.set(groupId, widthPx);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface GroupHeadingWidthTracking {
|
||||
contentKey: string;
|
||||
groupHeadingWidths: Map<string, number>;
|
||||
registerGroupHeading: (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
function useGroupHeadingWidthTracking(contentKey: string): GroupHeadingWidthTracking {
|
||||
const trackingRef = useRef<GroupHeadingWidthTracking | null>(null);
|
||||
if (trackingRef.current == null || trackingRef.current.contentKey !== contentKey) {
|
||||
const groupHeadingWidths = trackingRef.current?.groupHeadingWidths ?? new Map<string, number>();
|
||||
trackingRef.current = {
|
||||
contentKey,
|
||||
groupHeadingWidths,
|
||||
registerGroupHeading: createGroupHeadingRegistrar(groupHeadingWidths),
|
||||
};
|
||||
}
|
||||
return trackingRef.current;
|
||||
}
|
||||
|
||||
interface MemberListGroupHeaderContentProps {
|
||||
name: string;
|
||||
count: number;
|
||||
@@ -129,13 +164,14 @@ interface GroupDMMemberListGroupProps {
|
||||
group: GroupDMMemberGroup;
|
||||
channelId: string;
|
||||
ownerId: string | null;
|
||||
onHeadingRef: (groupId: string, node: HTMLDivElement | null) => void;
|
||||
onHeadingRef: (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const GroupDMMemberListGroup = observer(({group, channelId, ownerId, onHeadingRef}: GroupDMMemberListGroupProps) => (
|
||||
<div className={styles.groupContainer} data-flx="channel.channel-members.group-dm-member-list-group.group-container">
|
||||
<div
|
||||
ref={(node) => onHeadingRef(group.id, node)}
|
||||
ref={onHeadingRef}
|
||||
data-member-group-id={group.id}
|
||||
className={styles.groupHeader}
|
||||
data-flx="channel.channel-members.group-dm-member-list-group.group-header"
|
||||
>
|
||||
@@ -196,13 +232,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
const groups = memberListState?.groups ?? [];
|
||||
const populatedGroups = useMemo(() => groups.filter((group) => group.count > 0), [groups]);
|
||||
const memberGroupCountsKey = populatedGroups.map((group) => `${group.id}:${group.count}`).join(',');
|
||||
const groupHeadingWidthsRef = useRef(new Map<string, number>());
|
||||
const registerGroupHeading = useCallback((groupId: string, node: HTMLDivElement | null) => {
|
||||
const widthPx = measureMemberGroupHeadingWidthPx(node);
|
||||
if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) {
|
||||
groupHeadingWidthsRef.current.set(groupId, widthPx);
|
||||
}
|
||||
}, []);
|
||||
const {groupHeadingWidths, registerGroupHeading} = useGroupHeadingWidthTracking(memberGroupCountsKey);
|
||||
const memberSurfaceKind =
|
||||
channel.type === ChannelTypes.GUILD_VOICE ? SkeletonMemberSurfaceKind.GUILD_VOICE : SkeletonMemberSurfaceKind.GUILD;
|
||||
const rememberedMemberGroups = getRememberedSkeletonMemberGroups(channel.id, memberSurfaceKind);
|
||||
@@ -255,7 +285,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
memberSurfaceKind,
|
||||
populatedGroups.map((group) => ({
|
||||
rowCount: group.count,
|
||||
headingWidthPx: groupHeadingWidthsRef.current.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX,
|
||||
headingWidthPx: groupHeadingWidths.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX,
|
||||
})),
|
||||
);
|
||||
},
|
||||
@@ -511,7 +541,8 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
virtualRows.push(
|
||||
<div
|
||||
key={`group-${rowIndex}-${group.id}`}
|
||||
ref={(node) => registerGroupHeading(group.id, node)}
|
||||
ref={registerGroupHeading}
|
||||
data-member-group-id={group.id}
|
||||
className={clsx(styles.virtualRow, styles.virtualGroupRow)}
|
||||
style={rowStyle}
|
||||
role="button"
|
||||
@@ -533,7 +564,8 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
virtualRows.push(
|
||||
<div
|
||||
key={`group-${rowIndex}-${group.id}`}
|
||||
ref={(node) => registerGroupHeading(group.id, node)}
|
||||
ref={registerGroupHeading}
|
||||
data-member-group-id={group.id}
|
||||
className={clsx(styles.virtualRow, styles.virtualGroupRow)}
|
||||
style={rowStyle}
|
||||
data-flx="channel.channel-members.lazy-member-list.virtual-row.group"
|
||||
@@ -633,25 +665,21 @@ const GroupDMChannelMembers = observer(function GroupDMChannelMembers({channel}:
|
||||
const allUserIds = currentUserId ? [currentUserId, ...channel.recipientIds] : channel.recipientIds;
|
||||
const users = allUserIds.map((id) => Users.getUser(id)).filter((user): user is User => user != null);
|
||||
const memberGroups = MemberListUtils.getGroupDMMemberGroups(users);
|
||||
const memberGroupCountsKey = memberGroups.map((group) => `${group.id}:${group.count}`).join(',');
|
||||
const groupHeadingWidthsRef = useRef(new Map<string, number>());
|
||||
const registerGroupHeading = useCallback((groupId: string, node: HTMLDivElement | null) => {
|
||||
const widthPx = measureMemberGroupHeadingWidthPx(node);
|
||||
if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) {
|
||||
groupHeadingWidthsRef.current.set(groupId, widthPx);
|
||||
}
|
||||
}, []);
|
||||
const memberGroupContentKey = memberGroups
|
||||
.map((group) => `${group.id}:${group.displayName}:${group.count}`)
|
||||
.join(',');
|
||||
const {groupHeadingWidths, registerGroupHeading} = useGroupHeadingWidthTracking(memberGroupContentKey);
|
||||
useSkeletonLayoutReport(() => {
|
||||
reportSkeletonMemberLayout(
|
||||
channel.id,
|
||||
SkeletonMemberSurfaceKind.GROUP_DM,
|
||||
memberGroups.map((group) => ({
|
||||
rowCount: group.count,
|
||||
headingWidthPx: groupHeadingWidthsRef.current.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX,
|
||||
headingWidthPx: groupHeadingWidths.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX,
|
||||
subtextFlags: group.users.map((user) => hasVisibleCompactMemberCustomStatus(Presence.getCustomStatus(user.id))),
|
||||
})),
|
||||
);
|
||||
}, `${channel.id}|${memberGroupCountsKey}`);
|
||||
}, `${channel.id}|${memberGroupContentKey}`);
|
||||
return (
|
||||
<OutlineFrame hideTopBorder data-flx="channel.channel-members.outline-frame">
|
||||
<MemberListContainer channelId={channel.id} data-flx="channel.channel-members.member-list-container">
|
||||
|
||||
+2
-1
@@ -129,6 +129,7 @@ export const CallParticipantsRow = observer(
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [participants.length]);
|
||||
const avatarSize = layoutMetrics.avatarSize;
|
||||
const scaledAvatarSize = avatarSize / getAppRemScale();
|
||||
const callRippleStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
@@ -175,7 +176,7 @@ export const CallParticipantsRow = observer(
|
||||
>
|
||||
<AvatarWithPresence
|
||||
user={user}
|
||||
size={avatarSize / getAppRemScale()}
|
||||
size={scaledAvatarSize}
|
||||
speaking={participantEntry?.speaking}
|
||||
muted={participantEntry?.selfMute}
|
||||
deafened={participantEntry?.selfDeaf}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {clearRemScaleCache} from '@app/features/theme/layout/RemFromPx';
|
||||
import type {ThemeLibraryAsset, ThemeLibraryLocalFileReference} from '@app/features/theme/state/ThemeLibrary';
|
||||
import {resolveThemeCssReferences} from '@app/features/theme/utils/ThemeCssUtils';
|
||||
import {useEffect} from 'react';
|
||||
@@ -32,10 +33,15 @@ export function useCustomThemeStyle({
|
||||
if (!css) {
|
||||
if (existing?.parentNode) {
|
||||
existing.parentNode.removeChild(existing);
|
||||
clearRemScaleCache();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const styleElement = existing ?? document.createElement('style');
|
||||
const applyThemeCss = (value: string): void => {
|
||||
styleElement.textContent = value;
|
||||
clearRemScaleCache();
|
||||
};
|
||||
styleElement.id = STYLE_ELEMENT_ID;
|
||||
let disposed = false;
|
||||
let objectUrls: Array<string> = [];
|
||||
@@ -48,7 +54,7 @@ export function useCustomThemeStyle({
|
||||
return;
|
||||
}
|
||||
objectUrls = resolved.objectUrls;
|
||||
styleElement.textContent = resolved.css;
|
||||
applyThemeCss(resolved.css);
|
||||
if (resolved.missingAssetReferences.length > 0 || resolved.missingLocalFileReferences.length > 0) {
|
||||
logger.warn('Custom theme CSS has unresolved file references', {
|
||||
assets: resolved.missingAssetReferences,
|
||||
@@ -58,7 +64,7 @@ export function useCustomThemeStyle({
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to resolve custom theme CSS references', error);
|
||||
styleElement.textContent = css;
|
||||
applyThemeCss(css);
|
||||
});
|
||||
if (!existing) {
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {startRemScaleTracking} from '@app/features/theme/layout/RemFromPx';
|
||||
import {useLayoutEffect} from 'react';
|
||||
|
||||
export function useRemScaleTracking(): void {
|
||||
useLayoutEffect(() => startRemScaleTracking(document), []);
|
||||
}
|
||||
@@ -1,8 +1,101 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {REM_BASE_PX, remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {
|
||||
clearRemScaleCache,
|
||||
getRemScaleForDocument,
|
||||
REM_BASE_PX,
|
||||
remFromPx,
|
||||
startRemScaleTracking,
|
||||
} from '@app/features/theme/layout/RemFromPx';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface StubDocumentHarness {
|
||||
ownerDocument: Document;
|
||||
setRootFontSize: (fontSize: string) => void;
|
||||
getComputedStyleCallCount: () => number;
|
||||
animationFrameRequestCount: () => number;
|
||||
resizeListenerCount: () => number;
|
||||
resolutionListenerCount: () => number;
|
||||
watchedResolutionQueries: () => Array<string>;
|
||||
dispatchResize: () => void;
|
||||
setDevicePixelRatio: (devicePixelRatio: number) => void;
|
||||
setInnerWidth: (innerWidth: number) => void;
|
||||
setResolutionQueryMatches: (matches: boolean) => void;
|
||||
dispatchResolutionChange: () => void;
|
||||
}
|
||||
|
||||
function createStubDocumentHarness(initialRootFontSize: string): StubDocumentHarness {
|
||||
let rootFontSize = initialRootFontSize;
|
||||
let getComputedStyleCallCount = 0;
|
||||
let animationFrameRequestCount = 0;
|
||||
let resolutionQueryMatches = true;
|
||||
const resizeListeners = new Set<() => void>();
|
||||
const resolutionQueries = new Map<string, Set<() => void>>();
|
||||
const documentElement = {} as HTMLElement;
|
||||
const ownerWindow = {
|
||||
devicePixelRatio: 1,
|
||||
innerWidth: 1280,
|
||||
getComputedStyle: () => {
|
||||
getComputedStyleCallCount += 1;
|
||||
return {fontSize: rootFontSize} as CSSStyleDeclaration;
|
||||
},
|
||||
requestAnimationFrame: () => {
|
||||
animationFrameRequestCount += 1;
|
||||
return 0;
|
||||
},
|
||||
cancelAnimationFrame: () => {},
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
if (type !== 'resize') return;
|
||||
resizeListeners.add(listener);
|
||||
},
|
||||
removeEventListener: (type: string, listener: () => void) => {
|
||||
if (type !== 'resize') return;
|
||||
resizeListeners.delete(listener);
|
||||
},
|
||||
matchMedia: (media: string) => {
|
||||
const listeners = resolutionQueries.get(media) ?? new Set<() => void>();
|
||||
resolutionQueries.set(media, listeners);
|
||||
return {
|
||||
media,
|
||||
matches: resolutionQueryMatches,
|
||||
addEventListener: (_type: string, listener: () => void) => listeners.add(listener),
|
||||
removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener),
|
||||
} as unknown as MediaQueryList;
|
||||
},
|
||||
};
|
||||
const ownerDocument = {documentElement, defaultView: ownerWindow} as unknown as Document;
|
||||
return {
|
||||
ownerDocument,
|
||||
setRootFontSize: (fontSize) => {
|
||||
rootFontSize = fontSize;
|
||||
},
|
||||
getComputedStyleCallCount: () => getComputedStyleCallCount,
|
||||
animationFrameRequestCount: () => animationFrameRequestCount,
|
||||
resizeListenerCount: () => resizeListeners.size,
|
||||
resolutionListenerCount: () =>
|
||||
[...resolutionQueries.values()].reduce((total, listeners) => total + listeners.size, 0),
|
||||
watchedResolutionQueries: () =>
|
||||
[...resolutionQueries.entries()].filter(([, listeners]) => listeners.size > 0).map(([media]) => media),
|
||||
dispatchResize: () => {
|
||||
for (const listener of [...resizeListeners]) listener();
|
||||
},
|
||||
setDevicePixelRatio: (devicePixelRatio) => {
|
||||
ownerWindow.devicePixelRatio = devicePixelRatio;
|
||||
},
|
||||
setInnerWidth: (innerWidth) => {
|
||||
ownerWindow.innerWidth = innerWidth;
|
||||
},
|
||||
setResolutionQueryMatches: (matches) => {
|
||||
resolutionQueryMatches = matches;
|
||||
},
|
||||
dispatchResolutionChange: () => {
|
||||
for (const listeners of [...resolutionQueries.values()]) {
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('remFromPx', () => {
|
||||
it('uses a 16px base', () => {
|
||||
expect(REM_BASE_PX).toBe(16);
|
||||
@@ -32,3 +125,146 @@ describe('remFromPx', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRemScaleForDocument', () => {
|
||||
it('reports the root font size relative to the rem base', () => {
|
||||
const defaultRoot = createStubDocumentHarness('16px');
|
||||
const scaledRoot = createStubDocumentHarness('20px');
|
||||
const zoomedRoot = createStubDocumentHarness('24px');
|
||||
expect(getRemScaleForDocument(defaultRoot.ownerDocument)).toBe(1);
|
||||
expect(getRemScaleForDocument(scaledRoot.ownerDocument)).toBe(1.25);
|
||||
expect(getRemScaleForDocument(zoomedRoot.ownerDocument)).toBe(1.5);
|
||||
});
|
||||
|
||||
it('reads the computed root font size once for repeated calls', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
for (let call = 0; call < 200; call += 1) {
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
}
|
||||
expect(harness.getComputedStyleCallCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('never schedules per-frame invalidation work', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
getRemScaleForDocument(harness.ownerDocument);
|
||||
getRemScaleForDocument(harness.ownerDocument);
|
||||
expect(harness.animationFrameRequestCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('re-reads after a resize that changes the device pixel ratio', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('20px');
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setDevicePixelRatio(1.25);
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
|
||||
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1.25dppx)']);
|
||||
});
|
||||
|
||||
it('re-reads after a resize that changes the viewport width', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('20px');
|
||||
harness.setInnerWidth(720);
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
|
||||
});
|
||||
|
||||
it('ignores resizes that change neither the device pixel ratio nor the viewport width', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('20px');
|
||||
for (let frame = 0; frame < 60; frame += 1) {
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
}
|
||||
expect(harness.getComputedStyleCallCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('re-reads after a browser zoom changes the device pixel ratio', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1dppx)']);
|
||||
harness.setRootFontSize('24px');
|
||||
harness.setDevicePixelRatio(1.5);
|
||||
harness.dispatchResolutionChange();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.5);
|
||||
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1.5dppx)']);
|
||||
expect(harness.resolutionListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('re-binds the resolution query from a resize when the query never matched', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
harness.setResolutionQueryMatches(false);
|
||||
startRemScaleTracking(harness.ownerDocument);
|
||||
expect(harness.resolutionListenerCount()).toBe(0);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('20px');
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setDevicePixelRatio(1.5);
|
||||
harness.setResolutionQueryMatches(true);
|
||||
harness.dispatchResize();
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
|
||||
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1.5dppx)']);
|
||||
expect(harness.resolutionListenerCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('re-reads after an explicit cache clear', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
harness.setRootFontSize('12px');
|
||||
clearRemScaleCache(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(0.75);
|
||||
expect(harness.getComputedStyleCallCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('releases every listener when tracking is disposed', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
const stopTracking = startRemScaleTracking(harness.ownerDocument);
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
|
||||
expect(harness.resizeListenerCount()).toBe(1);
|
||||
expect(harness.resolutionListenerCount()).toBe(1);
|
||||
stopTracking();
|
||||
expect(harness.resizeListenerCount()).toBe(0);
|
||||
expect(harness.resolutionListenerCount()).toBe(0);
|
||||
harness.setRootFontSize('20px');
|
||||
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
|
||||
expect(harness.resizeListenerCount()).toBe(0);
|
||||
expect(harness.resolutionListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('never registers listeners as a side effect of reading', () => {
|
||||
const harness = createStubDocumentHarness('16px');
|
||||
for (let call = 0; call < 5; call += 1) {
|
||||
getRemScaleForDocument(harness.ownerDocument);
|
||||
}
|
||||
expect(harness.resizeListenerCount()).toBe(0);
|
||||
expect(harness.resolutionListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('invalidates every document when cleared without a target', () => {
|
||||
const main = createStubDocumentHarness('16px');
|
||||
const popout = createStubDocumentHarness('16px');
|
||||
expect(getRemScaleForDocument(main.ownerDocument)).toBe(1);
|
||||
expect(getRemScaleForDocument(popout.ownerDocument)).toBe(1);
|
||||
main.setRootFontSize('20px');
|
||||
popout.setRootFontSize('20px');
|
||||
clearRemScaleCache();
|
||||
expect(getRemScaleForDocument(main.ownerDocument)).toBe(1.25);
|
||||
expect(getRemScaleForDocument(popout.ownerDocument)).toBe(1.25);
|
||||
});
|
||||
|
||||
it('falls back to the rem base when the document has no view or no usable root font size', () => {
|
||||
const detachedDocument = {documentElement: {} as HTMLElement, defaultView: null} as unknown as Document;
|
||||
const brokenRoot = createStubDocumentHarness('not-a-length');
|
||||
expect(getRemScaleForDocument(null)).toBe(1);
|
||||
expect(getRemScaleForDocument(detachedDocument)).toBe(1);
|
||||
expect(getRemScaleForDocument(brokenRoot.ownerDocument)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,16 @@ export function remFromPx(px: number): `${number}rem` {
|
||||
return `${rounded}rem`;
|
||||
}
|
||||
|
||||
export function getRemScaleForDocument(ownerDocument: Document | null): number {
|
||||
if (ownerDocument == null) {
|
||||
return 1;
|
||||
}
|
||||
interface CachedRemScale {
|
||||
scale: number;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const cachedRemScales = new WeakMap<Document, CachedRemScale>();
|
||||
const remScaleDisposers = new WeakMap<Document, () => void>();
|
||||
let remScaleGeneration = 0;
|
||||
|
||||
function readRemScaleFromDocument(ownerDocument: Document): number {
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow == null) {
|
||||
return 1;
|
||||
@@ -31,3 +37,92 @@ export function getRemScaleForDocument(ownerDocument: Document | null): number {
|
||||
}
|
||||
return rootFontSize / REM_BASE_PX;
|
||||
}
|
||||
|
||||
export function getRemScaleForDocument(ownerDocument: Document | null): number {
|
||||
if (ownerDocument == null) {
|
||||
return 1;
|
||||
}
|
||||
const cached = cachedRemScales.get(ownerDocument);
|
||||
if (cached != null && cached.generation === remScaleGeneration) {
|
||||
return cached.scale;
|
||||
}
|
||||
const scale = readRemScaleFromDocument(ownerDocument);
|
||||
cachedRemScales.set(ownerDocument, {scale, generation: remScaleGeneration});
|
||||
return scale;
|
||||
}
|
||||
|
||||
export function clearRemScaleCache(ownerDocument?: Document | null): void {
|
||||
if (ownerDocument == null) {
|
||||
remScaleGeneration += 1;
|
||||
return;
|
||||
}
|
||||
cachedRemScales.delete(ownerDocument);
|
||||
}
|
||||
|
||||
export function startRemScaleTracking(ownerDocument: Document): () => void {
|
||||
remScaleDisposers.get(ownerDocument)?.();
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow == null) {
|
||||
return () => {};
|
||||
}
|
||||
return trackRemScaleForWindow(ownerDocument, ownerWindow);
|
||||
}
|
||||
|
||||
function trackRemScaleForWindow(ownerDocument: Document, ownerWindow: Window): () => void {
|
||||
let resolutionQuery: MediaQueryList | null = null;
|
||||
let trackedDevicePixelRatio = ownerWindow.devicePixelRatio;
|
||||
let trackedInnerWidth = ownerWindow.innerWidth;
|
||||
|
||||
function invalidate(): void {
|
||||
cachedRemScales.delete(ownerDocument);
|
||||
}
|
||||
|
||||
function stopWatchingResolution(): void {
|
||||
resolutionQuery?.removeEventListener('change', invalidateForRootFontSizeInputs);
|
||||
resolutionQuery = null;
|
||||
}
|
||||
|
||||
function watchResolution(): void {
|
||||
if (typeof ownerWindow.matchMedia !== 'function') {
|
||||
return;
|
||||
}
|
||||
const nextQuery = ownerWindow.matchMedia(`(resolution: ${ownerWindow.devicePixelRatio}dppx)`);
|
||||
if (!nextQuery.matches) {
|
||||
stopWatchingResolution();
|
||||
return;
|
||||
}
|
||||
stopWatchingResolution();
|
||||
resolutionQuery = nextQuery;
|
||||
resolutionQuery.addEventListener('change', invalidateForRootFontSizeInputs);
|
||||
}
|
||||
|
||||
function invalidateForRootFontSizeInputs(): void {
|
||||
trackedDevicePixelRatio = ownerWindow.devicePixelRatio;
|
||||
trackedInnerWidth = ownerWindow.innerWidth;
|
||||
invalidate();
|
||||
watchResolution();
|
||||
}
|
||||
|
||||
function handleResize(): void {
|
||||
if (ownerWindow.devicePixelRatio === trackedDevicePixelRatio && ownerWindow.innerWidth === trackedInnerWidth) {
|
||||
return;
|
||||
}
|
||||
invalidateForRootFontSizeInputs();
|
||||
}
|
||||
|
||||
ownerWindow.addEventListener('resize', handleResize);
|
||||
watchResolution();
|
||||
invalidate();
|
||||
|
||||
const dispose = (): void => {
|
||||
if (remScaleDisposers.get(ownerDocument) !== dispose) {
|
||||
return;
|
||||
}
|
||||
ownerWindow.removeEventListener('resize', handleResize);
|
||||
stopWatchingResolution();
|
||||
remScaleDisposers.delete(ownerDocument);
|
||||
cachedRemScales.delete(ownerDocument);
|
||||
};
|
||||
remScaleDisposers.set(ownerDocument, dispose);
|
||||
return dispose;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {autorun} from 'mobx';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
async function loadTick() {
|
||||
vi.resetModules();
|
||||
const {default: Window} = await import('@app/features/window/state/Window');
|
||||
const {default: Tick} = await import('@app/features/ui/state/Tick');
|
||||
return {Tick, Window};
|
||||
}
|
||||
|
||||
describe('Tick', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps counting while the window is visible but not focused', async () => {
|
||||
const {Tick, Window} = await loadTick();
|
||||
const dispose = autorun(() => Tick.nowSecond);
|
||||
const started = Tick.nowSecond;
|
||||
Window.setFocused(false);
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(Window.visible).toBe(true);
|
||||
expect(Tick.nowSecond).toBe(started + 3);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('stops counting while the window is hidden', async () => {
|
||||
const {Tick, Window} = await loadTick();
|
||||
const dispose = autorun(() => Tick.nowSecond);
|
||||
Window.setVisible(false);
|
||||
const stopped = Tick.nowSecond;
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(Tick.nowSecond).toBe(stopped);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('resynchronises to wall clock as soon as the window becomes visible again', async () => {
|
||||
const {Tick, Window} = await loadTick();
|
||||
const dispose = autorun(() => Tick.nowSecond);
|
||||
Window.setVisible(false);
|
||||
const stopped = Tick.nowSecond;
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(Tick.nowSecond).toBe(stopped);
|
||||
Window.setVisible(true);
|
||||
expect(Tick.nowSecond).toBe(stopped + 60);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('does not run an interval while nothing observes the clock', async () => {
|
||||
const {Tick} = await loadTick();
|
||||
const idle = Tick.nowSecond;
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(Tick.nowSecond).toBe(idle);
|
||||
const dispose = autorun(() => Tick.nowSecond);
|
||||
expect(Tick.nowSecond).toBe(idle + 5);
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,9 @@ class TickRegistryImpl {
|
||||
this.stop();
|
||||
});
|
||||
reaction(
|
||||
() => Window.focused,
|
||||
(focused) => {
|
||||
if (focused) this.start();
|
||||
() => Window.visible,
|
||||
(visible) => {
|
||||
if (visible) this.start();
|
||||
else this.stop();
|
||||
},
|
||||
{fireImmediately: true},
|
||||
@@ -30,7 +30,7 @@ class TickRegistryImpl {
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
if (!this.observed || !Window.focused || this.intervalId !== null) return;
|
||||
if (!this.observed || !Window.visible || this.intervalId !== null) return;
|
||||
this.tick();
|
||||
this.intervalId = window.setInterval(() => this.tick(), 1000);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
|
||||
|
||||
const cachedRemScales = new WeakMap<Document, number>();
|
||||
const clearRemScaleCacheRafIds = new WeakMap<Document, number>();
|
||||
import {clearRemScaleCache, getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
|
||||
|
||||
export interface AppZoomPoint {
|
||||
x: number;
|
||||
@@ -24,41 +21,12 @@ function getDefaultDocument(): Document | null {
|
||||
return document;
|
||||
}
|
||||
|
||||
function scheduleRemScaleCacheClear(ownerDocument: Document): void {
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow == null || typeof ownerWindow.requestAnimationFrame !== 'function') {
|
||||
cachedRemScales.delete(ownerDocument);
|
||||
return;
|
||||
}
|
||||
if (clearRemScaleCacheRafIds.has(ownerDocument)) return;
|
||||
const rafId = ownerWindow.requestAnimationFrame(() => {
|
||||
clearRemScaleCacheRafIds.delete(ownerDocument);
|
||||
cachedRemScales.delete(ownerDocument);
|
||||
});
|
||||
clearRemScaleCacheRafIds.set(ownerDocument, rafId);
|
||||
}
|
||||
|
||||
export function clearAppZoomCache(ownerDocument: Document | null = getDefaultDocument()): void {
|
||||
if (ownerDocument == null) return;
|
||||
cachedRemScales.delete(ownerDocument);
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow == null) return;
|
||||
const remScaleRafId = clearRemScaleCacheRafIds.get(ownerDocument);
|
||||
if (remScaleRafId == null) return;
|
||||
if (typeof ownerWindow.cancelAnimationFrame === 'function') {
|
||||
ownerWindow.cancelAnimationFrame(remScaleRafId);
|
||||
}
|
||||
clearRemScaleCacheRafIds.delete(ownerDocument);
|
||||
export function clearAppZoomCache(): void {
|
||||
clearRemScaleCache();
|
||||
}
|
||||
|
||||
export function getAppRemScale(ownerDocument: Document | null = getDefaultDocument()): number {
|
||||
if (ownerDocument == null) return 1;
|
||||
const cachedRemScale = cachedRemScales.get(ownerDocument);
|
||||
if (cachedRemScale != null) return cachedRemScale;
|
||||
const remScale = getRemScaleForDocument(ownerDocument);
|
||||
cachedRemScales.set(ownerDocument, remScale);
|
||||
scheduleRemScaleCacheClear(ownerDocument);
|
||||
return remScale;
|
||||
return getRemScaleForDocument(ownerDocument);
|
||||
}
|
||||
|
||||
export function applyAppZoomToDocument(zoomPercent: number, electronApi?: AppZoomElectronApi | null): void {
|
||||
|
||||
Reference in new issue
Block a user