@@ -469,7 +468,7 @@ export const PlutoniumPage = observer(function PlutoniumPage() {
)}

- {PREMIUM_PRODUCT_FULL_NAME} + {getPremiumProductFullName()}

{renderHeroBody()} {showDonationHint && ( @@ -599,7 +598,7 @@ export const PlutoniumPage = observer(function PlutoniumPage() {

{canSubscribe ? i18n._(CLOSING_BODY_DESCRIPTOR) - : i18n._(CLOSING_MEMBER_BODY_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + : i18n._(CLOSING_MEMBER_BODY_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}

{ const {i18n} = useLingui(); const rows = buildRows(i18n.locale, (descriptor) => i18n._(descriptor)); - const premiumLabel = PREMIUM_PRODUCT_NAME; + const premiumLabel = getPremiumProductName(); const renderLabel = (row: PerkRow) => ( <> {i18n._(row.label)} @@ -220,7 +220,7 @@ export const PlutoniumPageComparison = observer( className={styles.titleHeading} data-flx="premium.plutonium-page.comparison.title" > - {i18n._(COMPARE_TITLE_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {i18n._(COMPARE_TITLE_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
    {rows.map((row) => ( @@ -249,7 +249,7 @@ export const PlutoniumPageComparison = observer(
    diff --git a/fluxer_app/src/features/premium/components/plutonium_page/PlutoniumPageLayout.tsx b/fluxer_app/src/features/premium/components/plutonium_page/PlutoniumPageLayout.tsx index 7cd33a612..93b11f729 100644 --- a/fluxer_app/src/features/premium/components/plutonium_page/PlutoniumPageLayout.tsx +++ b/fluxer_app/src/features/premium/components/plutonium_page/PlutoniumPageLayout.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Routes} from '@app/app/Routes'; +import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import * as RouterUtils from '@app/features/navigation/utils/RouterUtils'; import {PlutoniumPage} from '@app/features/premium/components/plutonium_page/PlutoniumPage'; import styles from '@app/features/premium/components/plutonium_page/PlutoniumPageLayout.module.css'; @@ -8,7 +9,6 @@ import PlutoniumPageRollout from '@app/features/premium/state/PlutoniumPageRollo import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import {observer} from 'mobx-react-lite'; import {useEffect} from 'react'; diff --git a/fluxer_app/src/features/premium/components/plutonium_page/PlutoniumPageLinkCard.tsx b/fluxer_app/src/features/premium/components/plutonium_page/PlutoniumPageLinkCard.tsx index 542889ce7..fd23aefd3 100644 --- a/fluxer_app/src/features/premium/components/plutonium_page/PlutoniumPageLinkCard.tsx +++ b/fluxer_app/src/features/premium/components/plutonium_page/PlutoniumPageLinkCard.tsx @@ -1,8 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_FULL_NAME} from '@app/features/app/config/I18nDisplayConstants'; import * as PlutoniumPageCommands from '@app/features/premium/commands/PlutoniumPageCommands'; import styles from '@app/features/premium/components/plutonium_page/PlutoniumPageLinkCard.module.css'; +import {getPremiumProductFullName} from '@app/features/premium/utils/PremiumUtils'; import {Button} from '@app/features/ui/button/Button'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; @@ -24,7 +24,7 @@ export function PlutoniumPageLinkCard() {
    ); diff --git a/fluxer_app/src/features/premium/state/DismissedUpsell.ts b/fluxer_app/src/features/premium/state/DismissedUpsell.ts index 33fae6e9f..1e2e61787 100644 --- a/fluxer_app/src/features/premium/state/DismissedUpsell.ts +++ b/fluxer_app/src/features/premium/state/DismissedUpsell.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {initializeStore} from '@app/features/platform/utils/StoreInitialization'; import {makeSyncedField} from '@app/features/user/state/SyncedField'; import {DismissedUpsellsSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb'; import {makeAutoObservable} from 'mobx'; @@ -9,7 +10,7 @@ class DismissedUpsell { constructor() { makeAutoObservable(this, {}, {autoBind: true}); - this.initPersistence(); + initializeStore(this, () => this.initPersistence()); } private async initPersistence(): Promise { diff --git a/fluxer_app/src/features/premium/state/PremiumState.ts b/fluxer_app/src/features/premium/state/PremiumState.ts index 0763f8571..7844e97dc 100644 --- a/fluxer_app/src/features/premium/state/PremiumState.ts +++ b/fluxer_app/src/features/premium/state/PremiumState.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork'; import type {PremiumStateResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas'; import {makeAutoObservable} from 'mobx'; @@ -37,4 +38,8 @@ class PremiumState { } } -export default new PremiumState(); +const premiumState = new PremiumState(); + +AccountScopedWork.registerCancellation(() => premiumState.clear()); + +export default premiumState; diff --git a/fluxer_app/src/features/premium/utils/PremiumUtils.test.ts b/fluxer_app/src/features/premium/utils/PremiumUtils.test.ts new file mode 100644 index 000000000..791608ab1 --- /dev/null +++ b/fluxer_app/src/features/premium/utils/PremiumUtils.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import DesktopRuntimeTransactions from '@app/features/app/state/DesktopRuntimeTransaction'; +import {runtimeSnapshotFromDiscovery} from '@app/features/app/state/InstanceSnapshotStore'; +import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; +import { + BOOTSTRAP_APP_PUBLIC, + instanceDiscoveryFixture, +} from '@app/features/platform/state/__fixtures__/RuntimeBootstrapFixture'; +import {getPremiumProductFullName, getPremiumProductName} from '@app/features/premium/utils/PremiumUtils'; +import {parseInstanceDiscoveryDocument} from '@fluxer/instance_bootstrap/src/Discovery'; +import type {MessageDescriptor} from '@lingui/core'; +import {afterEach, describe, expect, test, vi} from 'vitest'; + +const desktopRuntime = vi.hoisted(() => ({required: false})); + +vi.mock('@lingui/core/macro', () => ({msg: (descriptor: MessageDescriptor) => descriptor})); +vi.mock('@app/features/app/state/DesktopRuntimeTransaction', async (importOriginal) => { + const actual = await importOriginal(); + return {...actual, requiresDesktopRuntimeTransaction: () => desktopRuntime.required}; +}); + +afterEach(() => { + desktopRuntime.required = false; + vi.restoreAllMocks(); +}); + +function applyBranding(branding: Partial): void { + const discovery = instanceDiscoveryFixture('https://chat.example.test/api'); + RuntimeConfig.applySnapshot( + runtimeSnapshotFromDiscovery( + parseInstanceDiscoveryDocument({ + ...discovery, + app_public: {...BOOTSTRAP_APP_PUBLIC, branding: {...BOOTSTRAP_APP_PUBLIC.branding, ...branding}}, + }), + ), + ); +} + +describe('premium product name', () => { + test('reads the active instance branding', () => { + applyBranding({product_name: 'Example Chat', premium_product_name: 'Gold'}); + expect(getPremiumProductName()).toBe('Gold'); + expect(getPremiumProductFullName()).toBe('Example Chat Gold'); + }); + + test('follows a switch to another instance', () => { + applyBranding({product_name: 'Example Chat', premium_product_name: 'Gold'}); + applyBranding({product_name: 'Fluxer', premium_product_name: 'Plutonium'}); + expect(getPremiumProductName()).toBe('Plutonium'); + expect(getPremiumProductFullName()).toBe('Fluxer Plutonium'); + }); + + test('falls back to the default names when no runtime is active', async () => { + applyBranding({product_name: 'Example Chat', premium_product_name: 'Gold'}); + vi.spyOn(DesktopRuntimeTransactions, 'deactivate').mockResolvedValue(undefined); + desktopRuntime.required = true; + await RuntimeConfig.deactivate(); + expect(getPremiumProductName()).toBe('Plutonium'); + expect(getPremiumProductFullName()).toBe('Fluxer Plutonium'); + }); + + test('falls back to the default premium name when the instance sends a blank one', () => { + applyBranding({premium_product_name: ' '}); + expect(getPremiumProductName()).toBe('Plutonium'); + }); +}); diff --git a/fluxer_app/src/features/premium/utils/PremiumUtils.ts b/fluxer_app/src/features/premium/utils/PremiumUtils.ts index cdfc95b37..a4b98c1fc 100644 --- a/fluxer_app/src/features/premium/utils/PremiumUtils.ts +++ b/fluxer_app/src/features/premium/utils/PremiumUtils.ts @@ -1,11 +1,23 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {APP_STORE_NAME, GOOGLE_PLAY_NAME} from '@app/features/app/config/I18nDisplayConstants'; +import {DEFAULT_PREMIUM_PRODUCT_NAME, PRODUCT_NAME} from '@app/features/app/config/ProductConstants'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions'; import type {PremiumStateResponse, PriceIdsResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas'; import type {PremiumStoreSubscriptionState} from '@fluxer/schema/src/domains/premium/StoreBillingSchemas'; +export function getPremiumProductName(): string { + return ( + RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.premium_product_name?.trim() || DEFAULT_PREMIUM_PRODUCT_NAME + ); +} + +export function getPremiumProductFullName(): string { + const productName = RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.product_name?.trim() || PRODUCT_NAME; + return `${productName} ${getPremiumProductName()}`; +} + export function shouldShowPremiumFeatures(): boolean { if (DeveloperOptions.selfHostedModeOverride) { return false; diff --git a/fluxer_app/src/features/presence/events/PresenceUpdateBulk.ts b/fluxer_app/src/features/presence/events/PresenceUpdateBulk.ts index f14914402..ae048ccc8 100644 --- a/fluxer_app/src/features/presence/events/PresenceUpdateBulk.ts +++ b/fluxer_app/src/features/presence/events/PresenceUpdateBulk.ts @@ -4,7 +4,7 @@ import type {GatewayHandlerContext} from '@app/features/gateway/events/EventRout import type {PresenceRecord} from '@app/features/gateway/types/GatewayPresenceTypes'; import Presence from '@app/features/presence/state/Presence'; -interface PresenceUpdateBulkPayload { +export interface PresenceUpdateBulkPayload { presences: Array; guild_id?: string; } diff --git a/fluxer_app/src/features/presence/state/MemberPresenceSubscription.ts b/fluxer_app/src/features/presence/state/MemberPresenceSubscription.ts index 0bb882eed..5a3425be8 100644 --- a/fluxer_app/src/features/presence/state/MemberPresenceSubscription.ts +++ b/fluxer_app/src/features/presence/state/MemberPresenceSubscription.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import GatewayConnection from '@app/features/gateway/transport/GatewayConnection'; +import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork'; import {makeAutoObservable, runInAction} from 'mobx'; const MEMBER_SUBSCRIPTION_MAX_SIZE = 100; @@ -255,4 +256,8 @@ class MemberPresenceSubscription { } } -export default new MemberPresenceSubscription(); +const memberPresenceSubscription = new MemberPresenceSubscription(); + +AccountScopedWork.registerCancellation(() => memberPresenceSubscription.clearAll()); + +export default memberPresenceSubscription; diff --git a/fluxer_app/src/features/presence/state/Presence.ts b/fluxer_app/src/features/presence/state/Presence.ts index f7658c219..c229bef50 100644 --- a/fluxer_app/src/features/presence/state/Presence.ts +++ b/fluxer_app/src/features/presence/state/Presence.ts @@ -2,6 +2,7 @@ import Authentication from '@app/features/auth/state/Authentication'; import Channels from '@app/features/channel/state/Channels'; +import type {SnapshotPresenceRow} from '@app/features/gateway/snapshot/SnapshotEntities'; import type {GuildReadyData} from '@app/features/gateway/types/GatewayGuildTypes'; import type {Presence as WirePresence} from '@app/features/gateway/types/GatewayPresenceTypes'; import Guilds from '@app/features/guild/state/Guilds'; @@ -260,13 +261,7 @@ class Presence { TransientPresence.clear(); const localStatus = LocalPresence.getStatus(); const localCustomStatus = LocalPresence.customStatus; - this.presences.clear(); - this.remotePresenceCountsByGuild.clear(); - this.remotePresenceCountVersionByGuild.clear(); - this.statuses.clear(); - this.customStatuses.clear(); - this.mobilePresenceUserIds.clear(); - this.bumpPresenceVersion(); + this.clearAllPresenceState(); this.statuses.set(user.id, localStatus); this.customStatuses.set(user.id, localCustomStatus); const userGuildIds = new Map>(); @@ -292,13 +287,7 @@ class Presence { this.resyncExternalStatusListeners(); } - handleSessionInvalidated(): void { - TransientPresence.clear(); - const previousUserIds = new Set([ - ...Array.from(this.presences.keys()), - ...Array.from(this.statuses.keys()), - ...Array.from(this.customStatuses.keys()), - ]); + private clearAllPresenceState(): void { this.presences.clear(); this.remotePresenceCountsByGuild.clear(); this.remotePresenceCountVersionByGuild.clear(); @@ -306,13 +295,69 @@ class Presence { this.customStatuses.clear(); this.mobilePresenceUserIds.clear(); this.bumpPresenceVersion(); + } + + private resetPresencesToOffline(): void { + TransientPresence.clear(); + const previousUserIds = new Set([ + ...Array.from(this.presences.keys()), + ...Array.from(this.statuses.keys()), + ...Array.from(this.customStatuses.keys()), + ]); + this.clearAllPresenceState(); for (const userId of previousUserIds) { this.notifyStatusListeners(userId, StatusTypes.OFFLINE, false); queueMicrotask(() => CustomStatusEmitter.emitPresenceChange(userId)); } + } + + handleSessionInvalidated(): void { + this.resetPresencesToOffline(); this.resyncExternalStatusListeners(); } + hydrateFromSnapshot(presences: ReadonlyMap, selfUserId: string): void { + const localStatus = LocalPresence.getStatus(); + const localCustomStatus = LocalPresence.customStatus; + this.resetPresencesToOffline(); + this.statuses.set(selfUserId, localStatus); + this.customStatuses.set(selfUserId, localCustomStatus); + this.suppressVersionBump = true; + try { + for (const [userId, row] of presences) { + if (userId !== selfUserId) { + this.seedSnapshotPresence(userId, row); + } + } + } finally { + this.suppressVersionBump = false; + } + this.bumpPresenceVersion(); + this.resyncExternalStatusListeners(); + } + + private seedSnapshotPresence(userId: string, row: SnapshotPresenceRow): void { + const normalizedStatus = normalizeStatus(row.status); + const customStatus = fromGatewayCustomStatus(row.customStatus); + const guildIds = new Set(row.guildIds); + if (guildIds.size === 0) { + guildIds.add(ME); + } + const flattened: FlattenedPresence = { + status: normalizedStatus, + timestamp: Date.now(), + afk: row.afk, + mobile: row.mobile, + guildIds, + customStatus, + }; + this.presences.set(userId, flattened); + this.addPresenceCounts(flattened); + this.customStatuses.set(userId, customStatus); + this.updateStatusFromPresence(userId, flattened); + queueMicrotask(() => CustomStatusEmitter.emitPresenceChange(userId)); + } + handleGuildCreate(guild: GuildReadyData): void { if (guild.unavailable) { return; diff --git a/fluxer_app/src/features/read_state/state/ReadStates.ts b/fluxer_app/src/features/read_state/state/ReadStates.ts index 350d63ec5..3d98df221 100644 --- a/fluxer_app/src/features/read_state/state/ReadStates.ts +++ b/fluxer_app/src/features/read_state/state/ReadStates.ts @@ -2,6 +2,7 @@ import {Endpoints} from '@app/features/app/constants/Endpoints'; import Channels from '@app/features/channel/state/Channels'; +import type {SnapshotReadStateRow} from '@app/features/gateway/snapshot/SnapshotEntities'; import Messages from '@app/features/messaging/state/MessagingMessages'; import AutoAck from '@app/features/notification/state/NotificationAutoAck'; import {http} from '@app/features/platform/transport/RestTransport'; @@ -344,6 +345,16 @@ class ReadStates { return Array.from(this.states.keys()); } + captureMentionCounts(): Map { + const counts = new Map(); + for (const [channelId, state] of this.states) { + if (state.mentionCount > 0) { + counts.set(channelId, state.mentionCount); + } + } + return counts; + } + clearStickyUnread(channelId: string): void { const state = this.getIfExists(channelId); if (state != null) { @@ -390,6 +401,7 @@ class ReadStates { channels: Array; }): void { this.suppressVersionBumps(() => { + const preservedAcks = this.capturePendingAcks(); this.reset(); const readStates = this.decodeReadStateBundle(action.readStateProto, action.readState); const channelsWithReadState = new Set(); @@ -412,6 +424,27 @@ class ReadStates { } this.clearUnreadStateIfRead(state); } + this.restorePendingAcks(preservedAcks); + this.notifyChange(undefined, {global: true}); + }); + } + + hydrateFromSnapshot(readStates: ReadonlyMap): void { + this.suppressVersionBumps(() => { + const preservedAcks = this.capturePendingAcks(); + this.reset(); + for (const [channelId, row] of readStates) { + const state = this.get(channelId); + this.setMentionCount(state, row.mentionCount); + state.ackMessageId = row.ackMessageId; + state.acknowledgedPinTimestamp = row.ackPinTimestamp; + state.serverVersion = row.serverVersion; + state.lastMessageId = row.lastMessageId; + state.storedGuildId = row.guildId; + state.lastPinTimestamp = Channels.getChannel(channelId)?.lastPinTimestamp?.getTime() ?? 0; + this.clearUnreadStateIfRead(state); + } + this.restorePendingAcks(preservedAcks); this.notifyChange(undefined, {global: true}); }); } @@ -837,6 +870,26 @@ class ReadStates { await this.flushDueAcks(true); } + private capturePendingAcks(): Array { + return Array.from(this.pendingAcks.values(), (pending) => ({...pending})); + } + + private restorePendingAcks(preserved: ReadonlyArray): void { + const currentUserId = Users.getCurrentUser()?.id ?? null; + for (const pending of preserved) { + if (pending.userId !== currentUserId) continue; + const state = this.getIfExists(pending.channelId); + if (state == null) continue; + if (compareMessageIds(pending.messageId, state.ackMessageId) <= 0) continue; + this.pendingAcks.set(pending.channelId as ChannelId, {...pending, deadline: Date.now()}); + } + if (this.pendingAcks.size === 0) { + return; + } + this.scheduleAckFlush(); + void this.flushDueAcks(true); + } + private applyAck(state: ReadStateEntry, options: AckOptions): AppliedAck { const { messageId, @@ -906,6 +959,7 @@ class ReadStates { messageId: existingIsNewer ? existing.messageId : messageId, deadline: existing == null ? deadline : Math.min(existing.deadline, deadline), attempt: existing?.attempt ?? 0, + userId: Users.getCurrentUser()?.id ?? null, }; this.pendingAcks.set(channelId as ChannelId, pending); const state = this.getIfExists(channelId); diff --git a/fluxer_app/src/features/read_state/state/ReadStatesAckQueue.test.ts b/fluxer_app/src/features/read_state/state/ReadStatesAckQueue.test.ts new file mode 100644 index 000000000..2d0d7f9a9 --- /dev/null +++ b/fluxer_app/src/features/read_state/state/ReadStatesAckQueue.test.ts @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +interface AckRequestBody { + read_states: Array<{channel_id: string; message_id: string}>; +} + +const makeChannel = (id: string) => ({ + id, + type: 0, + guildId: 'guild-1', + isPrivate: () => false, + getGuildId: () => 'guild-1', +}); + +vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}})); +vi.mock('@app/features/channel/state/Channels', () => ({ + default: {getChannel: (id: string) => makeChannel(id), getBasicChannel: (id: string) => makeChannel(id)}, +})); +vi.mock('@app/features/messaging/state/MessagingMessages', () => ({ + default: { + getMessages: () => ({ + hasMoreBefore: false, + length: 0, + jumpDestinationId: null, + hasNewestMessages: () => true, + has: () => false, + last: () => undefined, + forEachBuffered: () => undefined, + }), + }, +})); +let currentUserId = 'me'; +vi.mock('@app/features/user/state/Users', () => ({default: {getCurrentUser: () => ({id: currentUserId})}})); +vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}})); +vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => undefined}})); +vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}})); +vi.mock('@app/features/user/state/UserGuildSettings', () => ({ + default: { + isEveryoneMentionSuppressed: () => false, + isRoleMentionSuppressed: () => false, + isGuildOrChannelMuted: () => false, + }, +})); +vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => false}})); +vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({ + default: {isAutomaticAckEnabled: () => false, disableForChannel: () => undefined}, +})); +vi.mock('@app/features/platform/transport/RestTransport', () => ({ + http: {post: vi.fn(async () => ({body: {read_states: []}})), get: vi.fn()}, +})); + +const {default: ReadStates} = await import('@app/features/read_state/state/ReadStates'); +const {Endpoints} = await import('@app/features/app/constants/Endpoints'); +const {http} = await import('@app/features/platform/transport/RestTransport'); +const post = vi.mocked(http.post); + +const ID = { + ack: '1519773906704011264', + newer: '1519773906708205568', +}; + +let nextChannelId = 0; + +function seedReadChannel() { + const channelId = `ack-channel-${++nextChannelId}`; + const state = ReadStates.get(channelId); + state.ackMessageId = ID.ack; + state.lastMessageId = ID.ack; + state.unreadCount = 0; + state.oldestUnreadMessageId = null; + return {channelId, state}; +} + +function ackedMessageIdsFor(channelId: string): Array { + return post.mock.calls + .filter(([url]) => url === Endpoints.READ_STATES_ACK) + .flatMap(([, options]) => (options?.body as AckRequestBody | undefined)?.read_states ?? []) + .filter((entry) => entry.channel_id === channelId) + .map((entry) => entry.message_id); +} + +function hydrateReadChannelSnapshot(channelId: string): void { + ReadStates.hydrateFromSnapshot( + new Map([ + [ + channelId, + { + ackMessageId: ID.ack, + ackPinTimestamp: 0, + mentionCount: 0, + serverVersion: null, + readStateKnown: true, + lastMessageId: ID.ack, + guildId: 'guild-1', + }, + ], + ]), + ); +} + +describe('ReadStates ack queue across gateway ready', () => { + beforeEach(() => { + currentUserId = 'me'; + post.mockClear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + }); + + it('flushes a queued ack before gateway ready reseeds the store', async () => { + const {channelId} = seedReadChannel(); + ReadStates.handleChannelAck({channelId, messageId: ID.newer}); + ReadStates.handleGatewayReady({readState: [{id: channelId, last_message_id: ID.ack}], channels: []}); + expect(ackedMessageIdsFor(channelId)).toEqual([ID.newer]); + await vi.advanceTimersByTimeAsync(0); + }); + + it('keeps a queued ack across gateway ready even while an earlier ack is still in flight', async () => { + const {channelId: stalledChannelId} = seedReadChannel(); + const {channelId} = seedReadChannel(); + const stalledPost: {release: () => void} = {release: () => undefined}; + post.mockImplementationOnce( + async () => + await new Promise((resolve) => { + stalledPost.release = () => resolve({body: {}} as never); + }), + ); + ReadStates.handleChannelAck({channelId: stalledChannelId, messageId: ID.newer}); + await vi.advanceTimersByTimeAsync(3000); + + ReadStates.handleChannelAck({channelId, messageId: ID.newer}); + ReadStates.handleGatewayReady({readState: [{id: channelId, last_message_id: ID.ack}], channels: []}); + stalledPost.release(); + await vi.advanceTimersByTimeAsync(5000); + + expect(ackedMessageIdsFor(channelId)).toContain(ID.newer); + }); + + it('does not carry a queued ack across an account switch', async () => { + const {channelId} = seedReadChannel(); + ReadStates.handleChannelAck({channelId, messageId: ID.newer}); + post.mockClear(); + + currentUserId = 'someone-else'; + ReadStates.handleGatewayReady({readState: [{id: channelId, last_message_id: ID.ack}], channels: []}); + await vi.advanceTimersByTimeAsync(5000); + + expect(ackedMessageIdsFor(channelId)).toEqual([]); + }); + + it('flushes a queued ack before a snapshot hydrate reseeds the store', async () => { + const {channelId} = seedReadChannel(); + ReadStates.handleChannelAck({channelId, messageId: ID.newer}); + hydrateReadChannelSnapshot(channelId); + expect(ackedMessageIdsFor(channelId)).toEqual([ID.newer]); + await vi.advanceTimersByTimeAsync(0); + }); + + it('does not carry a queued ack across an account switch snapshot hydrate', async () => { + const {channelId} = seedReadChannel(); + ReadStates.handleChannelAck({channelId, messageId: ID.newer}); + post.mockClear(); + + currentUserId = 'someone-else'; + hydrateReadChannelSnapshot(channelId); + await vi.advanceTimersByTimeAsync(5000); + + expect(ackedMessageIdsFor(channelId)).toEqual([]); + }); + + it('drops a queued ack the server already covers', async () => { + const {channelId} = seedReadChannel(); + ReadStates.handleChannelAck({channelId, messageId: ID.newer}); + post.mockClear(); + + ReadStates.handleGatewayReady({readState: [{id: channelId, last_message_id: ID.newer}], channels: []}); + await vi.advanceTimersByTimeAsync(5000); + + expect(ackedMessageIdsFor(channelId)).toEqual([]); + }); +}); diff --git a/fluxer_app/src/features/read_state/state/read_states/shared.ts b/fluxer_app/src/features/read_state/state/read_states/shared.ts index 5c4cc0db5..1efb23dee 100644 --- a/fluxer_app/src/features/read_state/state/read_states/shared.ts +++ b/fluxer_app/src/features/read_state/state/read_states/shared.ts @@ -60,6 +60,7 @@ export interface PendingAck { messageId: string; deadline: number; attempt: number; + userId: string | null; } export interface ArchivedReadState { diff --git a/fluxer_app/src/features/relationship/state/Relationships.ts b/fluxer_app/src/features/relationship/state/Relationships.ts index 0e58a8d98..f0e4b176e 100644 --- a/fluxer_app/src/features/relationship/state/Relationships.ts +++ b/fluxer_app/src/features/relationship/state/Relationships.ts @@ -19,6 +19,10 @@ class Relationships { this.relationships = newRelationships; } + hydrateFromSnapshot(relationships: ReadonlyArray): void { + this.loadRelationships(relationships); + } + updateRelationship(relationship: RelationshipWire): void { const existingRelationship = this.relationships[relationship.id]; if (existingRelationship) { diff --git a/fluxer_app/src/features/search/commands/QuickSwitcherCommands.tsx b/fluxer_app/src/features/search/commands/QuickSwitcherCommands.tsx index 43e971628..71e044e02 100644 --- a/fluxer_app/src/features/search/commands/QuickSwitcherCommands.tsx +++ b/fluxer_app/src/features/search/commands/QuickSwitcherCommands.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Routes} from '@app/app/Routes'; +import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import * as PrivateChannelCommands from '@app/features/channel/commands/PrivateChannelCommands'; import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils'; import {goToMessage, parseMessagePath} from '@app/features/messaging/utils/MessageNavigator'; @@ -11,7 +12,6 @@ import QuickSwitcher from '@app/features/search/state/QuickSwitcher'; import type {QuickSwitcherExecutableResult} from '@app/features/search/state/QuickSwitcherTypes'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import {FAVORITES_GUILD_ID, ME} from '@fluxer/constants/src/AppConstants'; import {QuickSwitcherResultTypes} from '@fluxer/constants/src/QuickSwitcherConstants'; @@ -92,13 +92,16 @@ export async function switchTo(result: QuickSwitcherExecutableResult): Promise ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); break; } diff --git a/fluxer_app/src/features/search/state/QuickSwitcher.tsx b/fluxer_app/src/features/search/state/QuickSwitcher.tsx index 11f8f9368..57076574e 100644 --- a/fluxer_app/src/features/search/state/QuickSwitcher.tsx +++ b/fluxer_app/src/features/search/state/QuickSwitcher.tsx @@ -7,6 +7,7 @@ import GuildMembers from '@app/features/member/state/GuildMembers'; import MemberSearch, {type SearchContext, type TransformedMember} from '@app/features/member/state/MemberSearch'; import SelectedGuild from '@app/features/navigation/state/SelectedGuild'; import {parseChannelUrl} from '@app/features/navigation/utils/DeepLinkUtils'; +import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork'; import {Logger} from '@app/features/platform/utils/AppLogger'; import {loadLazyModule} from '@app/features/platform/utils/LazyModuleLoader'; import {buildCandidateSets} from '@app/features/search/state/QuickSwitcherCandidateBuilder'; @@ -504,4 +505,8 @@ class QuickSwitcher { } } -export default new QuickSwitcher(); +const quickSwitcher = new QuickSwitcher(); + +AccountScopedWork.registerCancellation(() => quickSwitcher.hide()); + +export default quickSwitcher; diff --git a/fluxer_app/src/features/search/state/ReverseImageSearch.ts b/fluxer_app/src/features/search/state/ReverseImageSearch.ts index 4adfaaea4..094bcf02c 100644 --- a/fluxer_app/src/features/search/state/ReverseImageSearch.ts +++ b/fluxer_app/src/features/search/state/ReverseImageSearch.ts @@ -2,6 +2,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger'; import {makePersistent} from '@app/features/platform/utils/MobXPersistence'; +import {initializeStore} from '@app/features/platform/utils/StoreInitialization'; import UserSettings from '@app/features/user/state/UserSettings'; import {create} from '@bufbuild/protobuf'; import {SearchEngineSettingsSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb'; @@ -68,7 +69,7 @@ class ReverseImageSearch { constructor() { makeAutoObservable(this, {}, {autoBind: true}); - void makePersistent(this, 'ReverseImageSearch', ['engines'], {version: 2}); + initializeStore(this, () => makePersistent(this, 'ReverseImageSearch', ['engines'], {version: 2})); } get enabledEngines(): ReadonlyArray { diff --git a/fluxer_app/src/features/search/state/SearchEngine.ts b/fluxer_app/src/features/search/state/SearchEngine.ts index 0e39183a0..278cb6a98 100644 --- a/fluxer_app/src/features/search/state/SearchEngine.ts +++ b/fluxer_app/src/features/search/state/SearchEngine.ts @@ -2,6 +2,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger'; import {makePersistent} from '@app/features/platform/utils/MobXPersistence'; +import {initializeStore} from '@app/features/platform/utils/StoreInitialization'; import UserSettings from '@app/features/user/state/UserSettings'; import {create} from '@bufbuild/protobuf'; import {SearchEngineSettingsSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb'; @@ -59,7 +60,7 @@ class SearchEngines { constructor() { makeAutoObservable(this, {}, {autoBind: true}); - void makePersistent(this, 'SearchEngine', ['engines'], {version: 2}); + initializeStore(this, () => makePersistent(this, 'SearchEngine', ['engines'], {version: 2})); } get enabledEngines(): ReadonlyArray { diff --git a/fluxer_app/src/features/search/state/SearchHistory.ts b/fluxer_app/src/features/search/state/SearchHistory.ts index 1db1b2b7f..3a1fa35e8 100644 --- a/fluxer_app/src/features/search/state/SearchHistory.ts +++ b/fluxer_app/src/features/search/state/SearchHistory.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {makePersistent} from '@app/features/platform/utils/MobXPersistence'; +import {initializeStore} from '@app/features/platform/utils/StoreInitialization'; import type {SearchHints} from '@app/features/search/utils/SearchQueryParser'; import {makeAutoObservable} from 'mobx'; @@ -18,7 +19,7 @@ class SearchHistory { constructor() { makeAutoObservable(this, {}, {autoBind: true}); - void makePersistent(this, 'SearchHistory', ['entriesByChannel']); + initializeStore(this, () => makePersistent(this, 'SearchHistory', ['entriesByChannel'])); } private getEntries(channelId?: string): Array { diff --git a/fluxer_app/src/features/slowmode/state/Slowmode.ts b/fluxer_app/src/features/slowmode/state/Slowmode.ts index 17212c393..82a7edd2e 100644 --- a/fluxer_app/src/features/slowmode/state/Slowmode.ts +++ b/fluxer_app/src/features/slowmode/state/Slowmode.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {makePersistent} from '@app/features/platform/utils/MobXPersistence'; +import {initializeStore} from '@app/features/platform/utils/StoreInitialization'; import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants'; import {makeAutoObservable} from 'mobx'; @@ -43,7 +44,7 @@ class Slowmode { constructor() { makeAutoObservable(this, {nextPruneAt: false}, {autoBind: true}); - void this.initPersistence(); + initializeStore(this, () => this.initPersistence()); } private async initPersistence(): Promise { diff --git a/fluxer_app/src/features/streamer_mode/state/StreamerMode.ts b/fluxer_app/src/features/streamer_mode/state/StreamerMode.ts index 09f061aee..486896500 100644 --- a/fluxer_app/src/features/streamer_mode/state/StreamerMode.ts +++ b/fluxer_app/src/features/streamer_mode/state/StreamerMode.ts @@ -2,6 +2,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger'; import {makePersistent, stopPersistent} from '@app/features/platform/utils/MobXPersistence'; +import {initializeStore} from '@app/features/platform/utils/StoreInitialization'; import {getElectronAPI} from '@app/features/ui/utils/NativeUtils'; import {makeAutoObservable, reaction, runInAction} from 'mobx'; @@ -34,7 +35,7 @@ class StreamerModeState { }, {autoBind: true}, ); - void this.initPersistence(); + initializeStore(this, () => this.initPersistence()); if (typeof window !== 'undefined') { this.autoPollingDisposer = reaction( () => this.autoEnable, diff --git a/fluxer_app/src/features/theme/commands/ThemeCommands.tsx b/fluxer_app/src/features/theme/commands/ThemeCommands.tsx index 065216ed5..94d854025 100644 --- a/fluxer_app/src/features/theme/commands/ThemeCommands.tsx +++ b/fluxer_app/src/features/theme/commands/ThemeCommands.tsx @@ -2,6 +2,7 @@ import * as AccessibilityCommands from '@app/features/accessibility/commands/AccessibilityCommands'; import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands'; +import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore'; import {SOMETHING_WENT_WRONG_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {Logger} from '@app/features/platform/utils/AppLogger'; import {ThemeAcceptModal} from '@app/features/theme/components/modals/ThemeAcceptModal'; @@ -40,7 +41,7 @@ export function applyTheme(css: string, i18n: I18n): void { } } -export function openAcceptModal(themeId: string | undefined, i18n: I18n): void { +export function openAcceptModal(themeId: string | undefined, i18n: I18n, runtimeSnapshot: RuntimeConfigSnapshot): void { if (!themeId) { showGenericErrorModal({ title: () => i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR), @@ -51,7 +52,11 @@ export function openAcceptModal(themeId: string | undefined, i18n: I18n): void { } ModalCommands.pushWithKey( modal(() => ( - + )), `theme-accept-${themeId}`, ); diff --git a/fluxer_app/src/features/theme/components/modals/BackgroundImageGalleryModal.tsx b/fluxer_app/src/features/theme/components/modals/BackgroundImageGalleryModal.tsx index e23086e83..a060424be 100644 --- a/fluxer_app/src/features/theme/components/modals/BackgroundImageGalleryModal.tsx +++ b/fluxer_app/src/features/theme/components/modals/BackgroundImageGalleryModal.tsx @@ -3,7 +3,7 @@ import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; import * as Modal from '@app/features/app/components/dialogs/Modal'; -import {BACKGROUND_MEDIA_MAX_SIZE_BYTES, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; +import {BACKGROUND_MEDIA_MAX_SIZE_BYTES} from '@app/features/app/config/I18nDisplayConstants'; import {useAnimatedMediaVideoPlayback} from '@app/features/app/hooks/useAnimatedMediaPlayback'; import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; @@ -21,7 +21,7 @@ import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils'; import {formatFileSize} from '@app/features/messaging/utils/FileUtils'; import {Logger} from '@app/features/platform/utils/AppLogger'; import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; -import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; +import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import styles from '@app/features/theme/components/modals/BackgroundImageGalleryModal.module.css'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import * as BackgroundImageDB from '@app/features/theme/utils/BackgroundImageDB'; @@ -996,7 +996,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => { data-flx="theme.background-image-gallery-modal.premium-icon" /> - {i18n._(UNLOCK_MORE_BACKGROUNDS_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {i18n._(UNLOCK_MORE_BACKGROUNDS_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}

    @@ -1011,7 +1011,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => { onClick={() => PremiumModalCommands.open()} data-flx="theme.background-image-gallery-modal.button.open" > - {i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} )} diff --git a/fluxer_app/src/features/theme/components/modals/ThemeAcceptModal.tsx b/fluxer_app/src/features/theme/components/modals/ThemeAcceptModal.tsx index 4db574d0f..0c4a45ee7 100644 --- a/fluxer_app/src/features/theme/components/modals/ThemeAcceptModal.tsx +++ b/fluxer_app/src/features/theme/components/modals/ThemeAcceptModal.tsx @@ -3,7 +3,7 @@ import * as AccessibilityCommands from '@app/features/accessibility/commands/AccessibilityCommands'; import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands'; import * as Modal from '@app/features/app/components/dialogs/Modal'; -import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; +import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore'; import {useArboriumHighlightedHtml} from '@app/features/code_highlighting/utils/ArboriumHighlighting'; import {COPY_CODE_DESCRIPTOR, SOMETHING_WENT_WRONG_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {Logger} from '@app/features/platform/utils/AppLogger'; @@ -39,30 +39,24 @@ const logger = new Logger('ThemeAcceptModal'); interface ThemeAcceptModalProps { themeId: string; + runtimeSnapshot: RuntimeConfigSnapshot; } -export const ThemeAcceptModal = observer(function ThemeAcceptModal({themeId}: ThemeAcceptModalProps) { +export const ThemeAcceptModal = observer(function ThemeAcceptModal({themeId, runtimeSnapshot}: ThemeAcceptModalProps) { const {i18n} = useLingui(); const themeSettingsPath = formatUserSettingsPath(i18n, 'appearance', 'theme'); const [isApplying, setIsApplying] = useState(false); const [css, setCss] = useState(null); const [fetchStatus, setFetchStatus] = useState<'idle' | 'loading' | 'error' | 'ready'>('idle'); const [fetchError, setFetchError] = useState(null); - const mediaEndpoint = RuntimeConfig.mediaEndpoint; const highlightedCss = useArboriumHighlightedHtml('css', css); useEffect(() => { - if (!mediaEndpoint) { - setCss(null); - setFetchStatus('idle'); - setFetchError(null); - return; - } let cancelled = false; const fetchTheme = async () => { setFetchStatus('loading'); setFetchError(null); try { - const themeUrl = buildThemeCssProxyUrl(mediaEndpoint, themeId); + const themeUrl = buildThemeCssProxyUrl(runtimeSnapshot, themeId); if (!themeUrl) { throw new Error('Media endpoint not configured'); } @@ -86,7 +80,7 @@ export const ThemeAcceptModal = observer(function ThemeAcceptModal({themeId}: Th return () => { cancelled = true; }; - }, [mediaEndpoint, themeId]); + }, [runtimeSnapshot, themeId]); const handleDismiss = () => { ModalCommands.pop(); }; diff --git a/fluxer_app/src/features/theme/components/pages/ThemeLoginPage.tsx b/fluxer_app/src/features/theme/components/pages/ThemeLoginPage.tsx index 48b7bfb84..a4b3af213 100644 --- a/fluxer_app/src/features/theme/components/pages/ThemeLoginPage.tsx +++ b/fluxer_app/src/features/theme/components/pages/ThemeLoginPage.tsx @@ -1,28 +1,23 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Routes} from '@app/app/Routes'; -import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; +import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore'; import {AuthErrorState} from '@app/features/auth/flow/AuthErrorState'; import {AuthLoadingState} from '@app/features/auth/flow/AuthLoadingState'; import {AuthLoginLayout} from '@app/features/auth/flow/AuthLoginLayout'; import {AuthPageHeader} from '@app/features/auth/flow/AuthPageHeader'; import sharedStyles from '@app/features/auth/flow/AuthPageStyles.module.css'; import {AuthRouterLink} from '@app/features/auth/flow/AuthRouterLink'; -import { - isApprovalFlowMode, - isHandoffRequest, - useDesktopHandoffFlow, -} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow'; +import {AuthRuntimeTargetGate} from '@app/features/auth/flow/AuthRuntimeTargetGate'; +import {AuthRuntimeTargetResetAction} from '@app/features/auth/flow/AuthRuntimeTargetResetAction'; +import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow'; import {DesktopDeepLinkPrompt} from '@app/features/auth/flow/DesktopDeepLinkPrompt'; -import {ConnectedHandoffApprovalFlow} from '@app/features/auth/flow/HandoffApprovalFlow'; -import MfaScreen from '@app/features/auth/flow/MfaScreen'; -import AccountManager from '@app/features/auth/state/AccountManager'; -import Authentication from '@app/features/auth/state/Authentication'; -import type {LoginSuccessPayload} from '@app/features/auth/state/AuthFlow'; -import {safeRedirectTarget, safeRedirectTargetOrFallback} from '@app/features/auth/utils/SafeRedirect'; +import {DesktopHandoffMfaStep} from '@app/features/auth/flow/DesktopHandoffMfaStep'; +import Authentication, {LoginState} from '@app/features/auth/state/Authentication'; +import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget'; +import {safeRedirectTarget} from '@app/features/auth/utils/SafeRedirect'; import {REGISTER_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils'; -import * as RouterUtils from '@app/features/navigation/utils/RouterUtils'; import {useLocation, useParams} from '@app/features/platform/components/router/RouterReact'; import * as ThemeCommands from '@app/features/theme/commands/ThemeCommands'; import {useThemeExists} from '@app/features/theme/hooks/useThemeExists'; @@ -34,6 +29,8 @@ import {PaletteIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import {useCallback, useMemo} from 'react'; +const THEME_LOGIN_PAGE_STEP_ORDER: ReadonlyArray = [LoginState.DEFAULT, LoginState.MFA]; + const YOU_VE_GOT_CSS_DESCRIPTOR = msg({ message: "You've got CSS!", comment: 'Short label in the theme login page. Keep it concise. Keep the tone plain and specific.', @@ -46,30 +43,31 @@ const APPLY_THEME_DESCRIPTOR = msg({ message: 'Apply theme', comment: 'Button or menu action label in the theme login page. Keep it concise. Keep the tone plain and specific.', }); -const THEME_LOGIN_PAGE_STEP_ORDER = ['default', 'mfa'] as const; -const ThemeLoginPage = observer(function ThemeLoginPage() { +interface ThemeLoginPageProps { + readonly themeId: string; + readonly onLoginComplete: () => void; +} + +const ThemeLoginPage = observer(function ThemeLoginPage({themeId, onLoginComplete}: ThemeLoginPageProps) { const {i18n} = useLingui(); - const {themeId} = useParams() as {themeId: string}; + const runtimeTarget = useAuthRuntimeTarget(); const location = useLocation(); const params = useMemo(() => new URLSearchParams(location.search), [location.search]); - const rawRedirect = params['get']('redirect_to'); - const safeRedirect = safeRedirectTarget(rawRedirect); + const safeRedirect = safeRedirectTarget(params.get('redirect_to')); const isHandoff = isHandoffRequest(params); - const registerSearch = safeRedirect ? {redirect_to: safeRedirect} : undefined; + const registerSearch = safeRedirect == null ? undefined : {redirect_to: safeRedirect}; const redirectPath = useMemo(() => { - if (!safeRedirect) { + if (safeRedirect == null) { return Routes.theme(themeId); } return setPathQueryParams(Routes.theme(themeId), {redirect_to: safeRedirect}); }, [themeId, safeRedirect]); - const handleLoginComplete = useCallback(() => { - if (!themeId) return; - ThemeCommands.openAcceptModal(themeId, i18n); - }, [themeId, i18n]); return ( } + forgotPasswordAction={null} showTitle={false} + title={null} + onBackActionChange={null} + completeLoginRedirectPath={null} + forceCredentials={false} + startWithAddAccount={false} + runtimeTarget={runtimeTarget} + showInstanceSelector={null} + ssoRedirectPath={null} + suppressInlineBackButtons={false} + initialIdentifier={null} registerLink={ } - onLoginComplete={handleLoginComplete} + onLoginComplete={onLoginComplete} data-flx="theme.theme-login-page.auth-login-layout" /> ); }); -const ThemeLoginPageMFA = observer(function ThemeLoginPageMFA() { - const {i18n} = useLingui(); - const {themeId} = useParams() as {themeId: string}; - const location = useLocation(); - const params = useMemo(() => new URLSearchParams(location.search), [location.search]); - const isHandoff = isHandoffRequest(params); - const rawRedirect = params['get']('redirect_to'); - const redirectTo = isHandoff ? undefined : safeRedirectTargetOrFallback(rawRedirect, Routes.theme(themeId)); - const mfaTicket = Authentication.currentMfaTicket; - const mfaMethods = Authentication.availableMfaMethods; - const hasStoredAccounts = AccountManager.orderedAccounts.length > 0; - const handoff = useDesktopHandoffFlow({ - enabled: isHandoff, - hasStoredAccounts, - initialMode: 'idle', - }); - const handleMfaSuccess = useCallback( - async ({token, userId}: LoginSuccessPayload) => { - if (isHandoff) { - await AccountManager.refreshStoredAccount(userId, token); - await handoff.start({token, userId}); - return; - } - await AuthenticationCommands.completeLogin({token, userId}); - ThemeCommands.openAcceptModal(themeId, i18n); - AuthenticationCommands.clearMfaTicket(); - RouterUtils.replaceWith(redirectTo || '/'); - }, - [handoff, isHandoff, redirectTo, themeId, i18n], - ); - const handleCancel = useCallback(() => { - AuthenticationCommands.clearMfaTicket(); - }, []); - if (!mfaTicket || !mfaMethods) { - return null; - } - if (isHandoff && isApprovalFlowMode(handoff.mode)) { - return ( - - ); - } - return ( - - ); -}); -const ThemeLoginPageContainer = observer(() => { +interface ThemeLoginPageContentProps { + readonly runtimeSnapshot: RuntimeConfigSnapshot; +} + +const ThemeLoginPageContent = observer(function ThemeLoginPageContent({runtimeSnapshot}: ThemeLoginPageContentProps) { const {i18n} = useLingui(); + const runtimeTarget = useAuthRuntimeTarget(); const loginState = Authentication.loginState; const {themeId} = useParams() as {themeId: string}; + const handleLoginComplete = useCallback(() => { + ThemeCommands.openAcceptModal(themeId, i18n, runtimeSnapshot); + runtimeTarget.reset(); + }, [i18n, runtimeSnapshot, runtimeTarget, themeId]); useFluxerDocumentTitle(i18n._(APPLY_THEME_DESCRIPTOR)); - const themeStatus = useThemeExists(themeId); + const themeStatus = useThemeExists(themeId, runtimeSnapshot); if (themeStatus === 'loading') { return ; } @@ -175,12 +140,15 @@ const ThemeLoginPageContainer = observer(() => { Theme not found} text={This theme may have been removed or the link is invalid.} + action={ + + } data-flx="theme.theme-login-page.theme-login-page-container.auth-error-state" /> ); } switch (loginState) { - case 'default': + case LoginState.DEFAULT: return ( { ariaLabel={i18n._(APPLY_THEME_DESCRIPTOR)} data-flx="theme.theme-login-page.container-carousel" > - + ); - case 'mfa': + case LoginState.MFA: return ( { ariaLabel={i18n._(APPLY_THEME_DESCRIPTOR)} data-flx="theme.theme-login-page.container-carousel" > - + ); default: @@ -209,4 +185,15 @@ const ThemeLoginPageContainer = observer(() => { } }); +const ThemeLoginPageContainer = observer(() => ( + + {(runtimeSnapshot) => ( + + )} + +)); + export default ThemeLoginPageContainer; diff --git a/fluxer_app/src/features/theme/components/pages/ThemeRegisterPage.tsx b/fluxer_app/src/features/theme/components/pages/ThemeRegisterPage.tsx index 8c0b462cd..a49721bb8 100644 --- a/fluxer_app/src/features/theme/components/pages/ThemeRegisterPage.tsx +++ b/fluxer_app/src/features/theme/components/pages/ThemeRegisterPage.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Routes} from '@app/app/Routes'; +import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore'; import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; import {AuthBottomLink} from '@app/features/auth/flow/AuthBottomLink'; import {AuthErrorState} from '@app/features/auth/flow/AuthErrorState'; @@ -8,8 +9,13 @@ import {AuthLoadingState} from '@app/features/auth/flow/AuthLoadingState'; import {AuthMinimalRegisterFormCore} from '@app/features/auth/flow/AuthMinimalRegisterFormCore'; import {AuthPageHeader} from '@app/features/auth/flow/AuthPageHeader'; import sharedStyles from '@app/features/auth/flow/AuthPageStyles.module.css'; -import {AuthSsoPanel, isRuntimeSsoEnforced} from '@app/features/auth/flow/AuthSsoPanel'; +import {AuthRuntimeTargetGate} from '@app/features/auth/flow/AuthRuntimeTargetGate'; +import {AuthRuntimeTargetResetAction} from '@app/features/auth/flow/AuthRuntimeTargetResetAction'; +import {AuthSsoPanel, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel'; import {DesktopDeepLinkPrompt} from '@app/features/auth/flow/DesktopDeepLinkPrompt'; +import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation'; +import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext'; +import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget'; import {safeRedirectTarget} from '@app/features/auth/utils/SafeRedirect'; import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils'; import {useLocation, useParams} from '@app/features/platform/components/router/RouterReact'; @@ -34,8 +40,15 @@ const SHARED_THEME_DESCRIPTOR = msg({ message: 'Shared theme', comment: 'Button or menu action label in the theme register page. Keep it concise.', }); -const ThemeRegisterPage = observer(function ThemeRegisterPage() { +interface ThemeRegisterPageContentProps { + readonly runtimeSnapshot: RuntimeConfigSnapshot; +} + +const ThemeRegisterPageContent = observer(function ThemeRegisterPageContent({ + runtimeSnapshot, +}: ThemeRegisterPageContentProps) { const {i18n} = useLingui(); + const runtimeTarget = useAuthRuntimeTarget(); const {themeId} = useParams() as {themeId: string}; const location = useLocation(); const params = useMemo(() => new URLSearchParams(location.search), [location.search]); @@ -47,20 +60,22 @@ const ThemeRegisterPage = observer(function ThemeRegisterPage() { const loginPath = safeRedirect ? setPathQueryParams(Routes.themeLogin(themeId), {redirect_to: safeRedirect}) : Routes.themeLogin(themeId); - const themeStatus = useThemeExists(themeId); + const themeStatus = useThemeExists(themeId, runtimeSnapshot); const handleRegisterComplete = useCallback( async (response: AuthenticationCommands.TokenResponse) => { const userData = AuthenticationCommands.authResponseUserToUserData(response.user); await AuthenticationCommands.completeLogin({ token: response.token, userId: response.user_id, + runtimeSnapshot, ...(userData ? {userData} : {}), }); - ThemeCommands.openAcceptModal(themeId, i18n); + ThemeCommands.openAcceptModal(themeId, i18n, runtimeSnapshot); }, - [themeId, i18n], + [i18n, runtimeSnapshot, themeId], ); useFluxerDocumentTitle(i18n._(APPLY_THEME_DESCRIPTOR)); + useAuthPresentation({variant: AuthCardVariant.STANDARD}); if (themeStatus === 'loading') { return ; } @@ -69,11 +84,13 @@ const ThemeRegisterPage = observer(function ThemeRegisterPage() { Theme not found} text={This theme may have been removed or the link is invalid.} + action={} data-flx="theme.theme-register-page.auth-error-state" /> ); } - if (isRuntimeSsoEnforced()) { + const runtimeSso = resolveAuthPanelSso(runtimeSnapshot); + if (runtimeSso?.enabled === true && runtimeSso.enforced === true) { return (

    @@ -129,6 +147,8 @@ const ThemeRegisterPage = observer(function ThemeRegisterPage() { submitLabel={Create account} redirectPath={themePath} onRegister={handleRegisterComplete} + runtimeSnapshot={runtimeSnapshot} + onAuthenticated={runtimeTarget.reset} extraContent={

    Once your account is created, we'll take you back to the theme so you can apply it. @@ -141,4 +161,15 @@ const ThemeRegisterPage = observer(function ThemeRegisterPage() { ); }); +const ThemeRegisterPage = observer(() => ( + + {(runtimeSnapshot) => ( + + )} + +)); + export default ThemeRegisterPage; diff --git a/fluxer_app/src/features/theme/hooks/useThemeCssVariables.test.tsx b/fluxer_app/src/features/theme/hooks/useThemeCssVariables.test.tsx new file mode 100644 index 000000000..4e7bed3a3 --- /dev/null +++ b/fluxer_app/src/features/theme/hooks/useThemeCssVariables.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {HdrDisplayMode} from '@app/features/accessibility/state/Accessibility'; +import {useThemeCssVariables} from '@app/features/theme/hooks/useThemeCssVariables'; +import {act, useLayoutEffect} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, expect, test} from 'vitest'; + +interface RootStyleSample { + readonly themeClasses: ReadonlyArray; + readonly fontSize: string; + readonly chatPadding: string; + readonly groupSpacing: string; +} + +let root: Root | null = null; +let container: HTMLElement | null = null; + +function sampleRootStyle(): RootStyleSample { + const htmlNode = document.documentElement; + return { + themeClasses: Array.from(htmlNode.classList).filter((name) => name.startsWith('theme-')), + fontSize: htmlNode.style.getPropertyValue('--font-size'), + chatPadding: htmlNode.style.getPropertyValue('--chat-horizontal-padding'), + groupSpacing: htmlNode.style.getPropertyValue('--message-group-spacing'), + }; +} + +function MeasuringChild({revision, samples}: {revision: string; samples: Array}) { + useLayoutEffect(() => { + samples.push(sampleRootStyle()); + }, [revision, samples]); + return null; +} + +function ThemedApp({ + theme, + groupSpacing, + saturation, + samples, +}: { + theme: string; + groupSpacing: number; + saturation: number; + samples: Array; +}) { + useThemeCssVariables({ + effectiveTheme: theme, + saturationFactor: saturation, + alwaysUnderlineLinks: false, + dimStrikethroughText: false, + enableTextSelection: true, + fontSize: 16, + messageGutter: 16, + messageGroupSpacing: groupSpacing, + hdrDisplayMode: HdrDisplayMode.FULL, + }); + return ; +} + +beforeEach(() => { + (globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +test('a settings change never exposes a frame without the theme class or layout variables', () => { + const samples: Array = []; + act(() => root?.render()); + samples.length = 0; + act(() => root?.render()); + act(() => root?.render()); + act(() => root?.render()); + expect(samples).toHaveLength(3); + expect(samples.map((sample) => sample.themeClasses)).toEqual([['theme-dark'], ['theme-dark'], ['theme-dark']]); + expect(samples.map((sample) => sample.chatPadding)).toEqual(['1rem', '1rem', '1rem']); + expect(samples.map((sample) => sample.fontSize)).toEqual(['1rem', '1rem', '1rem']); + expect(samples.map((sample) => sample.groupSpacing)).toEqual(['1rem', '1rem', '0.5rem']); + expect(sampleRootStyle()).toEqual({ + themeClasses: ['theme-coal'], + fontSize: '1rem', + chatPadding: '1rem', + groupSpacing: '0.5rem', + }); +}); + +test('unmounting removes the theme class and every variable it set', () => { + act(() => root?.render()); + act(() => root?.unmount()); + root = createRoot(container as HTMLElement); + expect(sampleRootStyle()).toEqual({themeClasses: [], fontSize: '', chatPadding: '', groupSpacing: ''}); + expect(document.documentElement.style.getPropertyValue('--saturation-factor')).toBe(''); +}); diff --git a/fluxer_app/src/features/theme/hooks/useThemeCssVariables.ts b/fluxer_app/src/features/theme/hooks/useThemeCssVariables.ts index df2820ec0..ee6ddb928 100644 --- a/fluxer_app/src/features/theme/hooks/useThemeCssVariables.ts +++ b/fluxer_app/src/features/theme/hooks/useThemeCssVariables.ts @@ -52,17 +52,6 @@ export function useThemeCssVariables({ } else { htmlNode.style.removeProperty('--markup-strikethrough-color'); } - return () => { - htmlNode.classList.remove(themeClass); - htmlNode.style.removeProperty('--saturation-factor'); - htmlNode.style.removeProperty('--link-decoration'); - htmlNode.style.removeProperty('--markup-strikethrough-color'); - htmlNode.style.removeProperty('--user-select'); - htmlNode.style.removeProperty('--font-size'); - htmlNode.style.removeProperty('--chat-horizontal-padding'); - htmlNode.style.removeProperty('--message-group-spacing'); - htmlNode.style.removeProperty('dynamic-range-limit'); - }; }, [ effectiveTheme, saturationFactor, @@ -74,4 +63,22 @@ export function useThemeCssVariables({ messageGroupSpacing, hdrDisplayMode, ]); + useLayoutEffect(() => { + const htmlNode = document.documentElement; + return () => { + for (const existingClass of Array.from(htmlNode.classList)) { + if (existingClass.startsWith('theme-')) { + htmlNode.classList.remove(existingClass); + } + } + htmlNode.style.removeProperty('--saturation-factor'); + htmlNode.style.removeProperty('--link-decoration'); + htmlNode.style.removeProperty('--markup-strikethrough-color'); + htmlNode.style.removeProperty('--user-select'); + htmlNode.style.removeProperty('--font-size'); + htmlNode.style.removeProperty('--chat-horizontal-padding'); + htmlNode.style.removeProperty('--message-group-spacing'); + htmlNode.style.removeProperty('dynamic-range-limit'); + }; + }, []); } diff --git a/fluxer_app/src/features/theme/hooks/useThemeExists.ts b/fluxer_app/src/features/theme/hooks/useThemeExists.ts index d642e9e42..044960fa8 100644 --- a/fluxer_app/src/features/theme/hooks/useThemeExists.ts +++ b/fluxer_app/src/features/theme/hooks/useThemeExists.ts @@ -1,30 +1,23 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; -import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils'; +import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore'; import {Logger} from '@app/features/platform/utils/AppLogger'; +import {buildThemeCssProxyUrl} from '@app/features/theme/utils/ThemeUtils'; import {useEffect, useState} from 'react'; const logger = new Logger('useThemeExists'); export type ThemeExistsStatus = 'loading' | 'ready' | 'error'; -const buildThemeUrl = (endpoint: string, themeId: string): string => { - const base = endpoint.replace(/\/$/, ''); - return `${base}/themes/${themeId}.css`; -}; -export const useThemeExists = (themeId: string | null | undefined): ThemeExistsStatus => { +export const useThemeExists = (themeId: string, runtimeSnapshot: RuntimeConfigSnapshot): ThemeExistsStatus => { const [status, setStatus] = useState('loading'); - const mediaEndpoint = RuntimeConfig.mediaEndpoint; useEffect(() => { - if (!mediaEndpoint || !themeId) { - setStatus('loading'); - return; - } let cancelled = false; const checkThemeExists = async () => { try { - const response = await fetch(buildMediaProxyURL(buildThemeUrl(mediaEndpoint, themeId)), {method: 'HEAD'}); + const themeUrl = buildThemeCssProxyUrl(runtimeSnapshot, themeId); + if (themeUrl == null) throw new Error('Media endpoint not configured'); + const response = await fetch(themeUrl, {method: 'HEAD'}); if (!response.ok) throw new Error('Theme not found'); if (cancelled) return; setStatus('ready'); @@ -39,6 +32,6 @@ export const useThemeExists = (themeId: string | null | undefined): ThemeExistsS return () => { cancelled = true; }; - }, [mediaEndpoint, themeId]); + }, [runtimeSnapshot, themeId]); return status; }; diff --git a/fluxer_app/src/features/theme/state/Theme.ts b/fluxer_app/src/features/theme/state/Theme.ts index d531f59d3..9f7005352 100644 --- a/fluxer_app/src/features/theme/state/Theme.ts +++ b/fluxer_app/src/features/theme/state/Theme.ts @@ -2,6 +2,7 @@ import AppStorage from '@app/features/platform/state/PersistentStorage'; import {makePersistent} from '@app/features/platform/utils/MobXPersistence'; +import {initializeStore} from '@app/features/platform/utils/StoreInitialization'; import {getElectronAPI} from '@app/features/ui/utils/NativeUtils'; import type {LinuxAppearanceSnapshot} from '@app/types/electron.d'; import type {ThemeType} from '@fluxer/constants/src/UserConstants'; @@ -65,7 +66,7 @@ class Theme { makeAutoObservable(this, {}, {autoBind: true}); this.initSystemThemeDetection(); this.initLinuxPortalThemeDetection(); - this.initPersistence(); + initializeStore(this, () => this.initPersistence()); } get isHydrated(): boolean { diff --git a/fluxer_app/src/features/theme/state/ThemeLibrary.ts b/fluxer_app/src/features/theme/state/ThemeLibrary.ts index b5803ad06..dc6c97cf0 100644 --- a/fluxer_app/src/features/theme/state/ThemeLibrary.ts +++ b/fluxer_app/src/features/theme/state/ThemeLibrary.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Logger} from '@app/features/platform/utils/AppLogger'; +import {randomUuid} from '@app/features/platform/utils/RandomUuid'; import { assetToExportDataUrl, parseThemeMetadata, @@ -84,11 +85,7 @@ export interface ThemeDirectoryCssFile { } function createThemeLibraryId(prefix: string): string { - const cryptoApi = globalThis.crypto; - if (cryptoApi?.randomUUID) { - return `${prefix}-${cryptoApi.randomUUID()}`; - } - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + return `${prefix}-${randomUuid()}`; } function sanitizeFileName(name: string): string { diff --git a/fluxer_app/src/features/theme/utils/ThemeLibraryDb.ts b/fluxer_app/src/features/theme/utils/ThemeLibraryDb.ts index 73a92c7cc..fe6658bec 100644 --- a/fluxer_app/src/features/theme/utils/ThemeLibraryDb.ts +++ b/fluxer_app/src/features/theme/utils/ThemeLibraryDb.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {AppStorageKey} from '@app/features/platform/state/AppStorageKeys'; +import AppStorage from '@app/features/platform/state/PersistentStorage'; import {getProtectedIndexedDB} from '@app/features/platform/state/ProtectedWebStorage'; import type { ThemeLibraryAsset, @@ -158,15 +160,76 @@ async function withReadwriteDb(name: ThemeLibraryTableName, operation: (store return result; } +const DURABLE_THEMES_KEY = AppStorageKey.THEME_LIBRARY_THEMES; +const DURABLE_LOCAL_FILES_KEY = AppStorageKey.THEME_LIBRARY_LOCAL_FILES; +const DURABLE_ENABLED_IDS_KEY = AppStorageKey.THEME_LIBRARY_ENABLED_IDS; +const DURABLE_MIGRATION_KEY = AppStorageKey.THEME_LIBRARY_MIGRATED; + +function readDurableList(key: string, guard: (value: unknown) => value is T): Array { + const raw = AppStorage.getItem(key); + if (raw == null) return []; + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(guard) : []; + } catch { + return []; + } +} + +function writeDurableList(key: string, value: ReadonlyArray): void { + AppStorage.setItem(key, JSON.stringify([...value])); +} + +function readDurableStringArray(value: string): Array { + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []; + } catch { + return []; + } +} + +let themeLibraryMigrationPromise: Promise | null = null; + +async function ensureThemeLibraryMigrated(): Promise { + if (themeLibraryMigrationPromise == null) { + themeLibraryMigrationPromise = migrateThemeLibraryToDurableStore(); + } + return themeLibraryMigrationPromise; +} + +async function migrateThemeLibraryToDurableStore(): Promise { + if (AppStorage.getItem(DURABLE_MIGRATION_KEY) === '1') return; + try { + const [legacyThemes, legacyLocalFiles, legacyEnabledIds] = await Promise.all([ + withReadonlyDb(THEMES_STORE, (store) => requestToPromise(store.getAll())).then((result) => + result.filter(isThemeLibraryTheme), + ), + withReadonlyDb(LOCAL_FILES_STORE, (store) => requestToPromise(store.getAll())).then((result) => + result.filter(isThemeLibraryLocalFileReference), + ), + withReadonlyDb(META_STORE, (store) => requestToPromise(store.get(ENABLED_THEME_IDS_KEY))).then((result) => + Array.isArray(result) ? result.filter((value): value is string => typeof value === 'string') : [], + ), + ]); + if (legacyThemes.length > 0) writeDurableList(DURABLE_THEMES_KEY, legacyThemes); + if (legacyLocalFiles.length > 0) writeDurableList(DURABLE_LOCAL_FILES_KEY, legacyLocalFiles); + if (legacyEnabledIds.length > 0) { + AppStorage.setItem(DURABLE_ENABLED_IDS_KEY, JSON.stringify(legacyEnabledIds)); + } + } catch {} + AppStorage.setItem(DURABLE_MIGRATION_KEY, '1'); +} + export async function listThemeLibraryThemes(): Promise> { - const result = await withReadonlyDb(THEMES_STORE, (store) => requestToPromise(store.getAll())); - return result.filter(isThemeLibraryTheme); + await ensureThemeLibraryMigrated(); + return readDurableList(DURABLE_THEMES_KEY, isThemeLibraryTheme); } export async function saveThemeLibraryTheme(theme: ThemeLibraryTheme): Promise { - await withReadwriteDb(THEMES_STORE, (store) => { - store.put(theme); - }); + await ensureThemeLibraryMigrated(); + const themes = readDurableList(DURABLE_THEMES_KEY, isThemeLibraryTheme); + writeDurableList(DURABLE_THEMES_KEY, [...themes.filter((existing) => existing.id !== theme.id), theme]); } export async function updateThemeLibraryThemes( @@ -195,15 +258,16 @@ export async function updateThemeLibraryThemes( } export async function deleteThemeLibraryTheme(id: string): Promise { - await withReadwriteDb(THEMES_STORE, (store) => { - store.delete(id); - }); + await ensureThemeLibraryMigrated(); + const themes = readDurableList(DURABLE_THEMES_KEY, isThemeLibraryTheme); + writeDurableList( + DURABLE_THEMES_KEY, + themes.filter((existing) => existing.id !== id), + ); } export async function clearThemeLibraryThemes(): Promise { - await withReadwriteDb(THEMES_STORE, (store) => { - store.clear(); - }); + AppStorage.removeItem(DURABLE_THEMES_KEY); } export async function listThemeLibraryAssets(): Promise> { @@ -230,41 +294,40 @@ export async function clearThemeLibraryAssets(): Promise { } export async function listThemeLibraryLocalFiles(): Promise> { - const result = await withReadonlyDb(LOCAL_FILES_STORE, (store) => requestToPromise(store.getAll())); - return result.filter(isThemeLibraryLocalFileReference); + await ensureThemeLibraryMigrated(); + return readDurableList(DURABLE_LOCAL_FILES_KEY, isThemeLibraryLocalFileReference); } export async function saveThemeLibraryLocalFile(file: ThemeLibraryLocalFileReference): Promise { - await withReadwriteDb(LOCAL_FILES_STORE, (store) => { - store.put(file); - }); + await ensureThemeLibraryMigrated(); + const files = readDurableList(DURABLE_LOCAL_FILES_KEY, isThemeLibraryLocalFileReference); + writeDurableList(DURABLE_LOCAL_FILES_KEY, [...files.filter((existing) => existing.id !== file.id), file]); } export async function deleteThemeLibraryLocalFile(id: string): Promise { - await withReadwriteDb(LOCAL_FILES_STORE, (store) => { - store.delete(id); - }); + await ensureThemeLibraryMigrated(); + const files = readDurableList(DURABLE_LOCAL_FILES_KEY, isThemeLibraryLocalFileReference); + writeDurableList( + DURABLE_LOCAL_FILES_KEY, + files.filter((existing) => existing.id !== id), + ); } export async function clearThemeLibraryLocalFiles(): Promise { - await withReadwriteDb(LOCAL_FILES_STORE, (store) => { - store.clear(); - }); + AppStorage.removeItem(DURABLE_LOCAL_FILES_KEY); } export async function getEnabledThemeIds(): Promise> { - const result = await withReadonlyDb(META_STORE, (store) => requestToPromise(store.get(ENABLED_THEME_IDS_KEY))); - return Array.isArray(result) ? result.filter((value): value is string => typeof value === 'string') : []; + await ensureThemeLibraryMigrated(); + const raw = AppStorage.getItem(DURABLE_ENABLED_IDS_KEY); + return raw == null ? [] : readDurableStringArray(raw); } export async function setEnabledThemeIds(themeIds: ReadonlyArray): Promise { - await withReadwriteDb(META_STORE, (store) => { - store.put([...themeIds], ENABLED_THEME_IDS_KEY); - }); + await ensureThemeLibraryMigrated(); + AppStorage.setItem(DURABLE_ENABLED_IDS_KEY, JSON.stringify([...themeIds])); } export async function clearThemeLibraryMeta(): Promise { - await withReadwriteDb(META_STORE, (store) => { - store.clear(); - }); + AppStorage.removeItem(DURABLE_ENABLED_IDS_KEY); } diff --git a/fluxer_app/src/features/theme/utils/ThemeUtils.ts b/fluxer_app/src/features/theme/utils/ThemeUtils.ts index a067c8c64..653426d94 100644 --- a/fluxer_app/src/features/theme/utils/ThemeUtils.ts +++ b/fluxer_app/src/features/theme/utils/ThemeUtils.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {type RuntimeConfigSnapshot, runtimeInstanceKey} from '@app/features/app/state/InstanceSnapshotStore'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import * as CodeLinkUtils from '@app/features/messaging/utils/CodeLinkUtils'; -import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils'; +import {buildMediaProxyURLForInstance} from '@app/features/messaging/utils/MediaProxyUtils'; const OFFICIAL_THEME_URL_BASES = Object.freeze([ 'https://fluxer.app/theme', @@ -46,8 +47,12 @@ function buildThemeCssUrl(endpoint: string | null | undefined, themeId: string): return `${base}/themes/${themeId}.css`; } -export function buildThemeCssProxyUrl(endpoint: string | null | undefined, themeId: string): string | null { - const rawUrl = buildThemeCssUrl(endpoint, themeId); +export function buildThemeCssProxyUrl(snapshot: RuntimeConfigSnapshot, themeId: string): string | null { + const rawUrl = buildThemeCssUrl(snapshot.mediaEndpoint, themeId); if (!rawUrl) return null; - return buildMediaProxyURL(rawUrl); + const instanceKey = runtimeInstanceKey(snapshot); + if (instanceKey == null) { + throw new Error(`Theme runtime has no usable instance key (apiEndpoint: "${snapshot.apiEndpoint}")`); + } + return buildMediaProxyURLForInstance(rawUrl, instanceKey); } diff --git a/fluxer_app/src/features/theme_studio/ThemeStudioStandaloneApp.tsx b/fluxer_app/src/features/theme_studio/ThemeStudioStandaloneApp.tsx index 445eba788..0459d666b 100644 --- a/fluxer_app/src/features/theme_studio/ThemeStudioStandaloneApp.tsx +++ b/fluxer_app/src/features/theme_studio/ThemeStudioStandaloneApp.tsx @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {ModalStack} from '@app/features/app/components/dialogs/ModalStack'; -import nativeTitlebarStyles from '@app/features/app/components/layout/NativeTitlebar.module.css'; import {NativeWindowControls} from '@app/features/app/components/layout/NativeWindowControls'; import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform'; import {usePlatformClasses} from '@app/features/app/hooks/usePlatformClasses'; @@ -22,6 +21,7 @@ import {Toasts} from '@app/features/ui/toast/Toasts'; import {getElectronAPI} from '@app/features/ui/utils/NativeUtils'; import {useFluxerDocumentTitle} from '@app/features/window/hooks/useFluxerDocumentTitle'; import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar'; +import {NATIVE_TITLEBAR_CLASS} from '@fluxer/desktop_ipc/src/NativeTitlebarShell'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {IconContext, PushPinIcon, PushPinSlashIcon} from '@phosphor-icons/react'; @@ -111,7 +111,7 @@ export const ThemeStudioStandaloneApp: React.FC = observer(() => {

    )} = observer( const handleOpenSettings = useCallback( (tab: SettingsTab, subtab?: SettingsSubtab) => { ModalCommands.push( - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); onClose(); }, diff --git a/fluxer_app/src/features/ui/action_menu/items/CategoryMenuData.tsx b/fluxer_app/src/features/ui/action_menu/items/CategoryMenuData.tsx index cb8f4b5ab..1ec359a9e 100644 --- a/fluxer_app/src/features/ui/action_menu/items/CategoryMenuData.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/CategoryMenuData.tsx @@ -2,8 +2,8 @@ import {showChannelDeleteFailedModal} from '@app/features/app/components/alerts/ChannelDeleteFailedModal'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; +import {ChannelSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import * as ChannelCommands from '@app/features/channel/commands/ChannelCommands'; -import {ChannelSettingsModal} from '@app/features/channel/components/modals/ChannelSettingsModal'; import type {Channel} from '@app/features/channel/models/Channel'; import Channels from '@app/features/channel/state/Channels'; import {DELETE_CATEGORY_DESCRIPTOR} from '@app/features/channel/utils/ChannelMessageDescriptors'; @@ -192,12 +192,15 @@ export function useCategoryMenuData(category: Channel, options: CategoryMenuData handleEditCategory: () => { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'channel-settings', + ), ); }, handleDeleteCategory: () => { diff --git a/fluxer_app/src/features/ui/action_menu/items/CategoryMenuItems.tsx b/fluxer_app/src/features/ui/action_menu/items/CategoryMenuItems.tsx index b68b1a944..bb743be8b 100644 --- a/fluxer_app/src/features/ui/action_menu/items/CategoryMenuItems.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/CategoryMenuItems.tsx @@ -2,9 +2,9 @@ import {showChannelDeleteFailedModal} from '@app/features/app/components/alerts/ChannelDeleteFailedModal'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; +import {ChannelSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import * as ChannelCommands from '@app/features/channel/commands/ChannelCommands'; import {getMuteDurationOptions} from '@app/features/channel/components/MuteOptions'; -import {ChannelSettingsModal} from '@app/features/channel/components/modals/ChannelSettingsModal'; import type {Channel} from '@app/features/channel/models/Channel'; import Channels from '@app/features/channel/state/Channels'; import {DELETE_CATEGORY_DESCRIPTOR} from '@app/features/channel/utils/ChannelMessageDescriptors'; @@ -308,12 +308,15 @@ export const EditCategoryMenuItem: React.FC = observer(({ }); const handleEditCategory = useCallback(() => { ModalCommands.push( - modal(() => ( - - )), + modal( + () => ( + + ), + 'channel-settings', + ), ); onClose(); }, [category.id, onClose]); diff --git a/fluxer_app/src/features/ui/action_menu/items/ChannelMenuData.tsx b/fluxer_app/src/features/ui/action_menu/items/ChannelMenuData.tsx index 36e7e425a..78031b113 100644 --- a/fluxer_app/src/features/ui/action_menu/items/ChannelMenuData.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/ChannelMenuData.tsx @@ -4,13 +4,13 @@ import Accessibility from '@app/features/accessibility/state/Accessibility'; import {showChannelDeleteFailedModal} from '@app/features/app/components/alerts/ChannelDeleteFailedModal'; import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; +import {ChannelSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import Authentication from '@app/features/auth/state/Authentication'; import * as ChannelCommands from '@app/features/channel/commands/ChannelCommands'; import * as LinkChannelCommands from '@app/features/channel/commands/LinkChannelCommands'; import * as PrivateChannelCommands from '@app/features/channel/commands/PrivateChannelCommands'; import {ChannelDuplicateModal} from '@app/features/channel/components/modals/ChannelDuplicateModal'; import {ChannelFollowModal} from '@app/features/channel/components/modals/ChannelFollowModal'; -import {ChannelSettingsModal} from '@app/features/channel/components/modals/ChannelSettingsModal'; import {EditGroupModal} from '@app/features/channel/components/modals/EditGroupModal'; import {GroupInvitesModal} from '@app/features/channel/components/modals/GroupInvitesModal'; import {useDeleteMyMessagesInChannel} from '@app/features/channel/hooks/useDeleteMyMessagesInChannel'; @@ -404,12 +404,15 @@ export function useChannelMenuData( handleChannelSettings: () => { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'channel-settings', + ), ); }, handleDeleteChannel: () => { diff --git a/fluxer_app/src/features/ui/action_menu/items/ChannelMenuItems.tsx b/fluxer_app/src/features/ui/action_menu/items/ChannelMenuItems.tsx index f68e9edf4..a34e2efd6 100644 --- a/fluxer_app/src/features/ui/action_menu/items/ChannelMenuItems.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/ChannelMenuItems.tsx @@ -3,9 +3,9 @@ import Accessibility from '@app/features/accessibility/state/Accessibility'; import {showChannelDeleteFailedModal} from '@app/features/app/components/alerts/ChannelDeleteFailedModal'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; +import {ChannelSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import * as ChannelCommands from '@app/features/channel/commands/ChannelCommands'; import {createMuteConfig, getMuteDurationOptions} from '@app/features/channel/components/MuteOptions'; -import {ChannelSettingsModal} from '@app/features/channel/components/modals/ChannelSettingsModal'; import type {Channel} from '@app/features/channel/models/Channel'; import { DELETE_CHANNEL_DESCRIPTOR, @@ -403,12 +403,15 @@ export const EditChannelMenuItem: React.FC = observer(({ch Permission.can(Permissions.UPDATE_RTC_REGION, {channelId: channel.id, guildId: channel.guildId}); const handleEditChannel = useCallback(() => { ModalCommands.push( - modal(() => ( - - )), + modal( + () => ( + + ), + 'channel-settings', + ), ); onClose(); }, [channel.id, onClose]); diff --git a/fluxer_app/src/features/ui/action_menu/items/GuildMenuData.tsx b/fluxer_app/src/features/ui/action_menu/items/GuildMenuData.tsx index 2327276aa..7cfaf0c20 100644 --- a/fluxer_app/src/features/ui/action_menu/items/GuildMenuData.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/GuildMenuData.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Routes} from '@app/app/Routes'; +import {GuildSettingsModal, UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import Authentication from '@app/features/auth/state/Authentication'; import {CategoryCreateModal} from '@app/features/channel/components/modals/CategoryCreateModal'; import {ChannelCreateModal} from '@app/features/channel/components/modals/ChannelCreateModal'; @@ -10,7 +11,6 @@ import {GuildDebugModal} from '@app/features/devtools/components/debug/GuildDebu import {hasForumUnread} from '@app/features/forum/state/ForumReadState'; import {GuildNotificationSettingsModal} from '@app/features/guild/components/modals/GuildNotificationSettingsModal'; import {GuildPrivacySettingsModal} from '@app/features/guild/components/modals/GuildPrivacySettingsModal'; -import {GuildSettingsModal} from '@app/features/guild/components/modals/GuildSettingsModal'; import {useDeleteMyMessagesInGuild} from '@app/features/guild/hooks/useDeleteMyMessagesInGuild'; import {useLeaveGuild} from '@app/features/guild/hooks/useLeaveGuild'; import type {Guild} from '@app/features/guild/models/Guild'; @@ -64,7 +64,6 @@ import type { MenuSubmenuItemType, } from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet'; import * as UserGuildSettingsCommands from '@app/features/user/commands/UserGuildSettingsCommands'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import { GUILD_SETTINGS_LABEL_DESCRIPTOR, getGuildSettingsTabs, @@ -212,12 +211,15 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions) handleCommunitySettings: () => { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'guild-settings', + ), ); }, handleCreateChannel: () => { @@ -267,13 +269,16 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions) handleEditCommunityProfile: () => { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }, handleLeaveCommunity: () => { @@ -371,13 +376,16 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions) } ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'guild-settings', + ), ); }, })); diff --git a/fluxer_app/src/features/ui/action_menu/items/GuildMenuItems.tsx b/fluxer_app/src/features/ui/action_menu/items/GuildMenuItems.tsx index ab81e73cd..d3645169b 100644 --- a/fluxer_app/src/features/ui/action_menu/items/GuildMenuItems.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/GuildMenuItems.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {GuildSettingsModal, UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import {EVERYONE_MENTION, HERE_MENTION} from '@app/features/app/config/I18nDisplayConstants'; import Authentication from '@app/features/auth/state/Authentication'; import {getMuteDurationOptions} from '@app/features/channel/components/MuteOptions'; @@ -9,7 +10,6 @@ import Channels from '@app/features/channel/state/Channels'; import {hasForumUnread} from '@app/features/forum/state/ForumReadState'; import {GuildNotificationSettingsModal} from '@app/features/guild/components/modals/GuildNotificationSettingsModal'; import {GuildPrivacySettingsModal} from '@app/features/guild/components/modals/GuildPrivacySettingsModal'; -import {GuildSettingsModal} from '@app/features/guild/components/modals/GuildSettingsModal'; import {useLeaveGuild} from '@app/features/guild/hooks/useLeaveGuild'; import type {Guild} from '@app/features/guild/models/Guild'; import {isStockCommunityGuild} from '@app/features/guild/utils/GuildCommunityUtils'; @@ -55,7 +55,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands'; import * as UserGuildSettingsCommands from '@app/features/user/commands/UserGuildSettingsCommands'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import { GUILD_SETTINGS_LABEL_DESCRIPTOR, type GuildSettingsTab, @@ -374,13 +373,16 @@ export const CommunitySettingsMenuItem: React.FC = observer( const handleOpenSettings = useCallback( (tab: GuildSettingsTab) => { ModalCommands.push( - modal(() => ( - - )), + modal( + () => ( + + ), + 'guild-settings', + ), ); onClose(); }, @@ -452,13 +454,16 @@ export const EditCommunityProfileMenuItem: React.FC = observ const currentUser = Users.getCurrentUser(); const handleEditProfile = useCallback(() => { ModalCommands.push( - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); onClose(); }, [guild.id, onClose]); diff --git a/fluxer_app/src/features/ui/action_menu/items/MediaMenuData.tsx b/fluxer_app/src/features/ui/action_menu/items/MediaMenuData.tsx index 5ad924414..8e11a75cd 100644 --- a/fluxer_app/src/features/ui/action_menu/items/MediaMenuData.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/MediaMenuData.tsx @@ -20,6 +20,10 @@ import { import {EditAltTextModal} from '@app/features/messaging/components/modals/EditAltTextModal'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher'; +import { + unwrapDesktopLocalResourceURL, + wrapDesktopLocalResourceURL, +} from '@app/features/messaging/utils/DesktopResourceUrl'; import {createDownloadHandler} from '@app/features/messaging/utils/FileDownloadUtils'; import {buildMediaProxyURL, stripMediaProxyParams} from '@app/features/messaging/utils/MediaProxyUtils'; import Permission from '@app/features/permissions/state/Permission'; @@ -437,7 +441,11 @@ export async function copyMediaToClipboard({ ToastCommands.createToast({type: 'success', children: i18n._(LINK_COPIED_TO_CLIPBOARD_DESCRIPTOR)}); return; } - const baseProxyURL = proxyURL ? await AttachmentUrlRefresher.refresh(stripMediaProxyParams(proxyURL)) : null; + const baseProxyURL = proxyURL + ? wrapDesktopLocalResourceURL( + await AttachmentUrlRefresher.refresh(unwrapDesktopLocalResourceURL(stripMediaProxyParams(proxyURL))), + ) + : null; const clipboardFileMediaType = getClipboardFileMediaType(type); if (clipboardFileMediaType) { const electronApi = getElectronAPI(); @@ -454,7 +462,7 @@ export async function copyMediaToClipboard({ timeout: 0, }); const result = await electronApi.clipboardWriteFile({ - url: baseProxyURL || freshSrc, + url: unwrapDesktopLocalResourceURL(baseProxyURL || freshSrc), suggestedName: defaultName, mediaType: clipboardFileMediaType, }); diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx index 85778d890..e07006cd8 100644 --- a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuData.tsx @@ -3,6 +3,7 @@ import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal'; import {showVoiceMemberModerationFailedModal} from '@app/features/app/components/alerts/VoiceMemberModerationFailedModal'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; +import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import Authentication from '@app/features/auth/state/Authentication'; import * as PrivateChannelCommands from '@app/features/channel/commands/PrivateChannelCommands'; @@ -132,7 +133,6 @@ import type { import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting'; import * as UserProfileCommands from '@app/features/user/commands/UserProfileCommands'; import {ChangeNicknameModal} from '@app/features/user/components/modals/ChangeNicknameModal'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import type {User} from '@app/features/user/models/User'; import UserSettings from '@app/features/user/state/UserSettings'; import Users from '@app/features/user/state/Users'; @@ -838,12 +838,15 @@ export function useVoiceParticipantMenuData(options: VoiceParticipantMenuDataOpt onClick: () => { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }, }); diff --git a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuItems.tsx b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuItems.tsx index 891518eb8..d34695b32 100644 --- a/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuItems.tsx +++ b/fluxer_app/src/features/ui/action_menu/items/VoiceParticipantMenuItems.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import * as VoiceStateCommands from '@app/features/devtools/commands/VoiceStateCommands'; import type {VoiceState} from '@app/features/gateway/types/GatewayVoiceTypes'; import {TURN_OFF_CAMERA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; @@ -32,7 +33,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import * as SoundCommands from '@app/features/ui/commands/SoundCommands'; import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import Users from '@app/features/user/state/Users'; import * as VoiceCallLayoutCommands from '@app/features/voice/commands/VoiceCallLayoutCommands'; import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; @@ -197,12 +197,15 @@ export const VoiceVideoSettingsMenuItem: React.FC { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }, [onClose]); return ( diff --git a/fluxer_app/src/features/ui/button/Button.tsx b/fluxer_app/src/features/ui/button/Button.tsx index c0d2f2196..3fed0099a 100644 --- a/fluxer_app/src/features/ui/button/Button.tsx +++ b/fluxer_app/src/features/ui/button/Button.tsx @@ -7,7 +7,16 @@ import {Button as BaseButton} from '@base-ui/react/button'; import {clsx} from 'clsx'; import React from 'react'; -export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'inverted' | 'inverted-outline' | 'ghost'; +export const ButtonVariant = Object.freeze({ + PRIMARY: 'primary', + SECONDARY: 'secondary', + DANGER: 'danger', + INVERTED: 'inverted', + INVERTED_OUTLINE: 'inverted-outline', + GHOST: 'ghost', +} as const); + +export type ButtonVariant = (typeof ButtonVariant)[keyof typeof ButtonVariant]; interface BaseButtonProps extends Omit, 'onClick' | 'type' | 'disabled' | 'className' | 'title'> { diff --git a/fluxer_app/src/features/ui/character_counter/CharacterCounter.tsx b/fluxer_app/src/features/ui/character_counter/CharacterCounter.tsx index e179bef2d..9a782754c 100644 --- a/fluxer_app/src/features/ui/character_counter/CharacterCounter.tsx +++ b/fluxer_app/src/features/ui/character_counter/CharacterCounter.tsx @@ -1,8 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache'; -import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; +import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {CharacterCountAnnouncer} from '@app/features/ui/character_counter/CharacterCountAnnouncer'; import styles from '@app/features/ui/character_counter/CharacterCounter.module.css'; import { @@ -37,7 +36,7 @@ export const CharacterCounter = observer( const tooltipText = needsPremium ? i18n._(CHARACTERS_LEFT_GET_TO_WRITE_UP_TO_CHARACTERS_DESCRIPTOR, { remaining, - premiumProductName: PREMIUM_PRODUCT_NAME, + premiumProductName: getPremiumProductName(), premiumMaxLength, }) : isOverLimit diff --git a/fluxer_app/src/features/ui/commands/ModalCommands.ts b/fluxer_app/src/features/ui/commands/ModalCommands.ts index 4e0807a46..12fa93365 100644 --- a/fluxer_app/src/features/ui/commands/ModalCommands.ts +++ b/fluxer_app/src/features/ui/commands/ModalCommands.ts @@ -1,14 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {ChannelSettingsModal} from '@app/features/channel/components/modals/ChannelSettingsModal'; -import {GuildSettingsModal} from '@app/features/guild/components/modals/GuildSettingsModal'; import {Logger} from '@app/features/platform/utils/AppLogger'; import {closeBottomSheetThen} from '@app/features/ui/bottom_sheet/BottomSheetTransitionUtils'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import {getActivePortalHost} from '@app/features/ui/overlay/PortalHostContext'; import Modal from '@app/features/ui/state/Modal'; -import type {ModalRender} from '@app/features/ui/state/ModalRender'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; +import type {ModalRender, ModalType} from '@app/features/ui/state/ModalRender'; import type React from 'react'; const logger = new Logger('Modal'); @@ -16,9 +13,14 @@ const logger = new Logger('Modal'); let modalUniqueIdCounter = 0; const generateModalKey = (): string => `modal${++modalUniqueIdCounter}`; -const BACKGROUND_MODAL_TYPES = [UserSettingsModal, GuildSettingsModal, ChannelSettingsModal] as const; -const isBackgroundModal = (element: React.ReactElement): boolean => { - return BACKGROUND_MODAL_TYPES.some((type) => element.type === type); +const BACKGROUND_MODAL_TYPES: ReadonlySet = new Set(['user-settings', 'guild-settings', 'channel-settings']); +const isBackgroundModal = (modalType: ModalType | undefined): boolean => { + return modalType != null && BACKGROUND_MODAL_TYPES.has(modalType); +}; +const isDuplicateModal = (modalType: ModalType | undefined): boolean => { + if (modalType == null || !Modal.hasModalOfModalType(modalType)) return false; + logger.debug(`Skipping duplicate modal of type: ${modalType}`); + return true; }; const getCommandOwnerDocument = (): Document => getActivePortalHost()?.ownerDocument ?? document; const getPushOptions = (isBackground: boolean) => { @@ -32,47 +34,44 @@ const getPushOptions = (isBackground: boolean) => { }; }; -export function modal(render: () => React.ReactElement): ModalRender { - return render as ModalRender; +interface PushToPortalHostOptions { + readonly modal: ModalRender; + readonly portalHost: HTMLElement; +} + +interface ExplicitPortalHostPush { + readonly portalHost: HTMLElement; +} + +function pushModal(modal: ModalRender, explicitPortalHost?: ExplicitPortalHostPush): void { + ContextMenuCommands.close(); + if (isDuplicateModal(modal.modalType)) return; + const isBackground = isBackgroundModal(modal.modalType); + const key = generateModalKey(); + logger.debug(`Pushing modal: ${key} (background=${isBackground})`); + const options = + explicitPortalHost == null + ? getPushOptions(isBackground) + : {isBackground, portalHost: explicitPortalHost.portalHost}; + Modal.push(modal, key, options); +} + +export function modal(render: () => React.ReactElement, modalType?: ModalType): ModalRender { + return Object.assign(render, {modalType}); } export function push(modal: ModalRender): void { - ContextMenuCommands.close(); - const renderedModal = modal(); - const isBackground = isBackgroundModal(renderedModal); - if (renderedModal.type === UserSettingsModal && Modal.hasModalOfType(UserSettingsModal)) { - logger.debug('Skipping duplicate UserSettingsModal'); - return; - } - if (renderedModal.type === GuildSettingsModal && Modal.hasModalOfType(GuildSettingsModal)) { - logger.debug('Skipping duplicate GuildSettingsModal'); - return; - } - if (renderedModal.type === ChannelSettingsModal && Modal.hasModalOfType(ChannelSettingsModal)) { - logger.debug('Skipping duplicate ChannelSettingsModal'); - return; - } - const key = generateModalKey(); - logger.debug(`Pushing modal: ${key} (background=${isBackground})`); - Modal.push(modal, key, getPushOptions(isBackground)); + pushModal(modal); +} + +export function pushToPortalHost({modal, portalHost}: PushToPortalHostOptions): void { + pushModal(modal, {portalHost}); } export function pushWithKey(modal: ModalRender, key: string): void { ContextMenuCommands.close(); - const renderedModal = modal(); - const isBackground = isBackgroundModal(renderedModal); - if (renderedModal.type === UserSettingsModal && Modal.hasModalOfType(UserSettingsModal)) { - logger.debug('Skipping duplicate UserSettingsModal'); - return; - } - if (renderedModal.type === GuildSettingsModal && Modal.hasModalOfType(GuildSettingsModal)) { - logger.debug('Skipping duplicate GuildSettingsModal'); - return; - } - if (renderedModal.type === ChannelSettingsModal && Modal.hasModalOfType(ChannelSettingsModal)) { - logger.debug('Skipping duplicate ChannelSettingsModal'); - return; - } + if (isDuplicateModal(modal.modalType)) return; + const isBackground = isBackgroundModal(modal.modalType); if (Modal.hasModal(key)) { logger.debug(`Updating existing modal with key: ${key}`); Modal.update(key, () => modal, isBackground ? getPushOptions(true) : {isBackground}); @@ -119,6 +118,11 @@ export function popByType(component: React.ComponentType): void { Modal.popByType(component, getCommandOwnerDocument()); } +export function popByModalType(modalType: ModalType): void { + logger.debug(`Popping modal by modal type: ${modalType}`); + Modal.popByModalType(modalType, getCommandOwnerDocument()); +} + export function popAllByType(component: React.ComponentType): void { logger.debug(`Popping all modals by type: ${component.displayName ?? component.name ?? 'unknown'}`); const ownerDocument = getCommandOwnerDocument(); diff --git a/fluxer_app/src/features/ui/components/Spinner.tsx b/fluxer_app/src/features/ui/components/Spinner.tsx index 82c906da8..ecb5a16a4 100644 --- a/fluxer_app/src/features/ui/components/Spinner.tsx +++ b/fluxer_app/src/features/ui/components/Spinner.tsx @@ -5,9 +5,17 @@ import {Trans} from '@lingui/react/macro'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; +export const SpinnerSize = Object.freeze({ + SMALL: 'small', + MEDIUM: 'medium', + LARGE: 'large', +} as const); + +export type SpinnerSize = (typeof SpinnerSize)[keyof typeof SpinnerSize]; + interface SpinnerProps { className?: string; - size?: 'small' | 'medium' | 'large'; + size?: SpinnerSize; } export const Spinner = observer(function Spinner({className, size = 'medium'}: SpinnerProps) { diff --git a/fluxer_app/src/features/ui/components/form/ColorPickerField.tsx b/fluxer_app/src/features/ui/components/form/ColorPickerField.tsx index 03cd1a407..f02c5247d 100644 --- a/fluxer_app/src/features/ui/components/form/ColorPickerField.tsx +++ b/fluxer_app/src/features/ui/components/form/ColorPickerField.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {ColorPickerPopover} from '@app/features/app/components/floating/ColorPickerPopover'; +import type {ColorPickerPopover as ColorPickerPopoverComponent} from '@app/features/app/components/floating/ColorPickerPopover'; +import {createNamedLoadableComponent} from '@app/features/platform/components/loadable/LoadableComponent'; import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/features/platform/utils/PasswordManagerAutocomplete'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import styles from '@app/features/ui/components/form/ColorPickerField.module.css'; @@ -10,7 +11,6 @@ import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {EyedropperIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; -import Color from 'colorjs.io'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useRef, useState} from 'react'; @@ -93,21 +93,52 @@ function parseColor(input: string): {hex: string; num: number} | null { return null; } +function toLinearChannel(byte: number): number { + const channel = byte / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(num: number): number { + return ( + 0.2126 * toLinearChannel((num >>> 16) & 0xff) + + 0.7152 * toLinearChannel((num >>> 8) & 0xff) + + 0.0722 * toLinearChannel(num & 0xff) + ); +} + function bestIconColorFor(bgColorCss: string): 'black' | 'white' { if (bgColorCss === 'var(--text-chat)') { const isLightTheme = document.documentElement.classList.contains('theme-light'); return isLightTheme ? 'white' : 'black'; } - try { - const bgColor = new Color(bgColorCss); - const contrastWithWhite = Math.abs(bgColor.contrast('#FFFFFF', 'WCAG21')); - const contrastWithBlack = Math.abs(bgColor.contrast('#000000', 'WCAG21')); - return contrastWithWhite >= contrastWithBlack ? 'white' : 'black'; - } catch { + const parsed = parseColor(bgColorCss); + if (!parsed) { return 'white'; } + const luminance = relativeLuminance(parsed.num); + const contrastWithWhite = 1.05 / (luminance + 0.05); + const contrastWithBlack = (luminance + 0.05) / 0.05; + return contrastWithWhite >= contrastWithBlack ? 'white' : 'black'; } +type ColorPickerPopoverProps = React.ComponentProps; + +function ColorPickerPopoverLoading() { + return ( +
    + ); +} + +const ColorPickerPopover = createNamedLoadableComponent({ + displayName: 'ColorPickerPopover', + LoadingComponent: ColorPickerPopoverLoading, + load: async () => (await import('@app/features/app/components/floating/ColorPickerPopover')).ColorPickerPopover, +}); + interface ColorPickerFieldProps { label?: string; description?: string; diff --git a/fluxer_app/src/features/ui/components/form/DateTimePickerField.tsx b/fluxer_app/src/features/ui/components/form/DateTimePickerField.tsx index 61253a3fc..3629d9900 100644 --- a/fluxer_app/src/features/ui/components/form/DateTimePickerField.tsx +++ b/fluxer_app/src/features/ui/components/form/DateTimePickerField.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {getCachedDateTimeFormat} from '@app/features/i18n/utils/IntlCache'; +import {createNamedLoadableComponent} from '@app/features/platform/components/loadable/LoadableComponent'; import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/features/platform/utils/PasswordManagerAutocomplete'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import styles from '@app/features/ui/components/form/DateTimePickerField.module.css'; @@ -13,7 +14,7 @@ import {clsx} from 'clsx'; import type React from 'react'; import {useCallback, useMemo, useState} from 'react'; import {Button, Dialog, DialogTrigger, Popover} from 'react-aria-components'; -import {DayPicker} from 'react-day-picker'; +import type {DayPicker as DayPickerComponent} from 'react-day-picker'; const SELECT_A_DATE_AND_TIME_DESCRIPTOR = msg({ message: 'Select a date and time', @@ -36,6 +37,25 @@ const TIME_DESCRIPTOR = msg({ comment: 'Form field label for a time value.', }); +type DayPickerProps = React.ComponentProps; + +function DayPickerLoading() { + return ( +
    + ); +} + +const DayPicker = createNamedLoadableComponent({ + displayName: 'DayPicker', + LoadingComponent: DayPickerLoading, + load: async () => (await import('react-day-picker')).DayPicker, +}); + interface DateTimePickerFieldProps { label?: string; description?: string; diff --git a/fluxer_app/src/features/ui/components/form/FormCombobox.module.css b/fluxer_app/src/features/ui/components/form/FormCombobox.module.css index d3d7d0c9a..d7f443002 100644 --- a/fluxer_app/src/features/ui/components/form/FormCombobox.module.css +++ b/fluxer_app/src/features/ui/components/form/FormCombobox.module.css @@ -146,6 +146,10 @@ caret-color: transparent; } +.inputWithValueOverlay::selection { + background: transparent; +} + .inputWithValueOverlay::placeholder { color: transparent; } diff --git a/fluxer_app/src/features/ui/components/form/FormCombobox.tsx b/fluxer_app/src/features/ui/components/form/FormCombobox.tsx index 0f5094518..fd1a42082 100644 --- a/fluxer_app/src/features/ui/components/form/FormCombobox.tsx +++ b/fluxer_app/src/features/ui/components/form/FormCombobox.tsx @@ -179,6 +179,7 @@ export const Combobox = observer(function Combobox< const inputRef = useRef(null); const controlRef = useRef(null); const [open, setOpen] = useState(false); + const [isTyping, setIsTyping] = useState(false); const selectedOptions = useMemo(() => { if (isMulti) { if (!Array.isArray(value)) return []; @@ -248,9 +249,20 @@ export const Combobox = observer(function Combobox< }, [autoSelectExactMatch, autoSelectValueFromInput, isMulti, onChange, resolveInputValue], ); + const selectSelectedLabel = useCallback(() => { + if (!isSearchable || isMulti || isTyping) return; + inputRef.current?.select(); + }, [isMulti, isSearchable, isTyping]); const handleFocus = useCallback(() => { if (openMenuOnFocus && !disabled) setOpen(true); - }, [disabled, openMenuOnFocus]); + selectSelectedLabel(); + }, [disabled, openMenuOnFocus, selectSelectedLabel]); + const handleInputValueChange = useCallback( + (_inputValue: string, eventDetails: BaseCombobox.Root.ChangeEventDetails) => { + if (eventDetails.reason === 'input-change') setIsTyping(true); + }, + [], + ); const handleInputPointerDown = useCallback( (event: React.PointerEvent) => { if (isSearchable || disabled) return; @@ -266,6 +278,7 @@ export const Combobox = observer(function Combobox< eventDetails.cancel(); return; } + if (!nextOpen) setIsTyping(false); setOpen(nextOpen); }, [closeMenuOnSelect], @@ -312,7 +325,8 @@ export const Combobox = observer(function Combobox< if (renderValue) return renderValue(selectedOption as IsMulti extends true ? Array : O | null); return null; }, [i18n.locale, isMulti, renderValue, selectedOption, selectedOptionArray]); - const shouldShowValueOverlay = Boolean(renderedValue) && (!open || !isSearchable || Boolean(renderValue)); + const shouldShowValueOverlay = + Boolean(renderedValue) && (!open || !isSearchable || (Boolean(renderValue) && !isTyping)); const hasSelectedValue = isMulti ? selectedOptionArray.length > 0 : selectedOption != null; const emptyMessage = isLoading ? i18n._(LOADING_DESCRIPTOR) : i18n._(NO_RESULTS_FOUND_DESCRIPTOR); return ( @@ -334,6 +348,7 @@ export const Combobox = observer(function Combobox< onValueChange={handleValueChange} open={open} onOpenChange={handleOpenChange} + onInputValueChange={handleInputValueChange} multiple={isMulti} disabled={disabled} filter={isSearchable ? comboboxFilter : null} @@ -381,6 +396,7 @@ export const Combobox = observer(function Combobox< tabIndex={tabIndex} onBlur={handleBlur} onFocus={handleFocus} + onClick={selectSelectedLabel} onPointerDown={handleInputPointerDown} aria-label={ariaLabel} {...PASSWORD_MANAGER_IGNORE_ATTRIBUTES} diff --git a/fluxer_app/src/features/ui/components/form/FormInput.tsx b/fluxer_app/src/features/ui/components/form/FormInput.tsx index 1f5cdcf31..b8a6b3e83 100644 --- a/fluxer_app/src/features/ui/components/form/FormInput.tsx +++ b/fluxer_app/src/features/ui/components/form/FormInput.tsx @@ -36,7 +36,7 @@ const omitStyle = (obj: T): Omit => { type FieldSetProps = Omit, 'label'> & { children: React.ReactNode; - error?: string; + error?: string | null; errorId?: string; footer?: React.ReactNode; label?: React.ReactNode; @@ -86,7 +86,7 @@ export interface RenderInputArgs { } type InputProps = React.InputHTMLAttributes & { - error?: string; + error?: string | null; footer?: React.ReactNode; label?: React.ReactNode; labelRight?: React.ReactNode; @@ -186,7 +186,7 @@ export const Input = React.forwardRef( type: inputType, 'aria-label': props['aria-label'] ?? placeholderAriaLabel, 'aria-describedby': describedBy, - 'aria-invalid': ariaInvalid || undefined, + 'aria-invalid': ariaInvalid || props['aria-invalid'], }), [ props, @@ -330,6 +330,8 @@ export const Input = React.forwardRef( Input.displayName = 'Input'; +export const TextInput = Input; + const BaseTextarea = React.forwardRef(({className, ...rest}, ref) => ( & { - error?: string; + error?: string | null; footer?: React.ReactNode; label: React.ReactNode; minRows?: number; @@ -410,7 +412,7 @@ export const Textarea = React.forwardRef( id: textareaId, 'aria-label': sanitizedProps['aria-label'] ?? placeholderAriaLabel, 'aria-describedby': describedBy, - 'aria-invalid': !!error, + 'aria-invalid': !!error || (sanitizedProps['aria-invalid'] ?? false), maxRows, minRows, maxLength, diff --git a/fluxer_app/src/features/ui/components/icons/FluxerIcon.tsx b/fluxer_app/src/features/ui/components/icons/FluxerIcon.tsx index f5abd6562..d56e5b1d5 100644 --- a/fluxer_app/src/features/ui/components/icons/FluxerIcon.tsx +++ b/fluxer_app/src/features/ui/components/icons/FluxerIcon.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {resolveAppShellBranding} from '@app/features/app/state/AppShellBranding'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import {getDataFlx, getImageSizingProps} from '@app/features/ui/components/icons/BrandImageUtils'; import {APPLICATION_ICON_DESCRIPTOR, FluxerIconMark} from '@app/features/ui/components/icons/FluxerIconMark'; @@ -9,12 +10,13 @@ import type React from 'react'; export const FluxerIcon = observer((props: React.SVGProps) => { const {i18n} = useLingui(); - const ariaLabel = i18n._(APPLICATION_ICON_DESCRIPTOR, {productName: RuntimeConfig.productName}); - if (RuntimeConfig.iconUrl) { + const branding = resolveAppShellBranding(RuntimeConfig.getSnapshotOrNull()); + const ariaLabel = i18n._(APPLICATION_ICON_DESCRIPTOR, {productName: branding.productName}); + if (branding.iconUrl !== null) { return ( {ariaLabel} diff --git a/fluxer_app/src/features/ui/components/icons/FluxerLogo.tsx b/fluxer_app/src/features/ui/components/icons/FluxerLogo.tsx index b31eed19d..da2f0d483 100644 --- a/fluxer_app/src/features/ui/components/icons/FluxerLogo.tsx +++ b/fluxer_app/src/features/ui/components/icons/FluxerLogo.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {resolveAppShellBranding} from '@app/features/app/state/AppShellBranding'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import {type BrandSvgProps, getDataFlx, getImageSizingProps} from '@app/features/ui/components/icons/BrandImageUtils'; import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react'; @@ -14,12 +15,13 @@ const APPLICATION_LOGO_DESCRIPTOR = msg({ export const FluxerLogo = observer((props: BrandSvgProps) => { const {i18n} = useLingui(); - const ariaLabel = i18n._(APPLICATION_LOGO_DESCRIPTOR, {productName: RuntimeConfig.productName}); - if (RuntimeConfig.logoUrl) { + const branding = resolveAppShellBranding(RuntimeConfig.getSnapshotOrNull()); + const ariaLabel = i18n._(APPLICATION_LOGO_DESCRIPTOR, {productName: branding.productName}); + if (branding.logoUrl !== null) { return ( {ariaLabel} diff --git a/fluxer_app/src/features/ui/components/icons/FluxerWordmark.tsx b/fluxer_app/src/features/ui/components/icons/FluxerWordmark.tsx index 99ba3c29e..e8b91980b 100644 --- a/fluxer_app/src/features/ui/components/icons/FluxerWordmark.tsx +++ b/fluxer_app/src/features/ui/components/icons/FluxerWordmark.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import RuntimeConfig, {DEFAULT_APP_PUBLIC_CONFIG} from '@app/features/app/state/RuntimeConfig'; +import {DEFAULT_APP_SHELL_BRANDING, resolveAppShellBranding} from '@app/features/app/state/AppShellBranding'; +import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import {type BrandSvgProps, getDataFlx, getImageSizingProps} from '@app/features/ui/components/icons/BrandImageUtils'; import FluxerWordmarkMonochromeAsset from '@app/media/images/fluxer-logo-wordmark-monochrome.svg?react'; import FluxerWordmarkAsset from '@app/media/images/fluxer-wordmark.svg?react'; @@ -20,19 +21,20 @@ interface FluxerWordmarkProps extends BrandSvgProps { export const FluxerWordmark = observer(({variant = 'default', ...props}: FluxerWordmarkProps) => { const {i18n} = useLingui(); - const productName = RuntimeConfig.productName; + const branding = resolveAppShellBranding(RuntimeConfig.getSnapshotOrNull()); + const productName = branding.productName; const ariaLabel = i18n._(APPLICATION_WORDMARK_DESCRIPTOR, {productName}); - if (RuntimeConfig.wordmarkUrl) { + if (branding.wordmarkUrl !== null) { return ( {ariaLabel} ); } - if (productName !== DEFAULT_APP_PUBLIC_CONFIG.branding.product_name) { + if (productName !== DEFAULT_APP_SHELL_BRANDING.productName) { const style: React.CSSProperties = { ...(props.style as React.CSSProperties | undefined), alignItems: 'center', diff --git a/fluxer_app/src/features/ui/hooks/usePrefersReducedMotion.ts b/fluxer_app/src/features/ui/hooks/usePrefersReducedMotion.ts new file mode 100644 index 000000000..f4f23dc8f --- /dev/null +++ b/fluxer_app/src/features/ui/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Accessibility from '@app/features/accessibility/state/Accessibility'; +import {reaction} from 'mobx'; +import {useSyncExternalStore} from 'react'; + +const listeners = new Set<() => void>(); +let disposeReaction: (() => void) | null = null; + +function getSnapshot(): boolean { + return Accessibility.useReducedMotion; +} + +function notifyListeners(): void { + for (const listener of Array.from(listeners)) { + listener(); + } +} + +function subscribe(onStoreChange: () => void): () => void { + listeners.add(onStoreChange); + disposeReaction ??= reaction(getSnapshot, notifyListeners); + return () => { + listeners.delete(onStoreChange); + if (listeners.size > 0) return; + disposeReaction?.(); + disposeReaction = null; + }; +} + +export function usePrefersReducedMotion(): boolean { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/fluxer_app/src/features/ui/plutonium_link/PlutoniumLink.tsx b/fluxer_app/src/features/ui/plutonium_link/PlutoniumLink.tsx index c60c243d5..e34eb8356 100644 --- a/fluxer_app/src/features/ui/plutonium_link/PlutoniumLink.tsx +++ b/fluxer_app/src/features/ui/plutonium_link/PlutoniumLink.tsx @@ -1,8 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; -import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; +import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import styles from '@app/features/ui/plutonium_link/PlutoniumLink.module.css'; export const PlutoniumLink: React.FC = () => { @@ -18,7 +17,7 @@ export const PlutoniumLink: React.FC = () => { className={styles.link} data-flx="ui.plutonium-link.plutonium-link.link.open.button" > - {PREMIUM_PRODUCT_NAME} + {getPremiumProductName()} ); }; diff --git a/fluxer_app/src/features/ui/plutonium_upsell/PlutoniumUpsell.tsx b/fluxer_app/src/features/ui/plutonium_upsell/PlutoniumUpsell.tsx index d233e8c42..8c8a24af0 100644 --- a/fluxer_app/src/features/ui/plutonium_upsell/PlutoniumUpsell.tsx +++ b/fluxer_app/src/features/ui/plutonium_upsell/PlutoniumUpsell.tsx @@ -1,9 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {GET_PREMIUM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; -import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; +import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {Button} from '@app/features/ui/button/Button'; import styles from '@app/features/ui/plutonium_upsell/PlutoniumUpsell.module.css'; @@ -52,10 +51,10 @@ export const PlutoniumUpsell: React.FC = ({ superCompact={true} fitContent={true} onClick={onButtonClick ?? (() => PremiumModalCommands.open())} - aria-label={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + aria-label={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} data-flx="ui.plutonium-upsell.plutonium-upsell.button" > - {buttonText ?? i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {buttonText ?? i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} {dismissible && onDismiss && (

    {permissionStatus === 'denied' ? ( - - Allow {PRODUCT_NAME} to access your camera in {MACOS_SYSTEM_SETTINGS_NAME} →{' '} - {MACOS_PRIVACY_AND_SECURITY_SETTINGS_NAME} → {MACOS_CAMERA_PERMISSION_NAME}. + + Allow {PRODUCT_NAME} to access your camera in System Settings → Privacy & Security → Camera. ) : permissionStatus === 'granted' ? ( Connect a camera and try again. @@ -318,7 +311,7 @@ export const VideoTab: React.FC = observer( data-flx="user.video-tab.premium-icon" /> - {i18n._(UNLOCK_HD_SCREEN_SHARE_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {i18n._(UNLOCK_HD_SCREEN_SHARE_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}

    @@ -333,7 +326,7 @@ export const VideoTab: React.FC = observer( onClick={() => PremiumModalCommands.open()} data-flx="user.video-tab.button.open" > - {i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} )} @@ -355,7 +348,7 @@ export const VideoTab: React.FC = observer( className={styles.frameRateIcon} data-flx="user.video-tab.frame-rate-icon" /> - {i18n._(HIGH_FRAME_RATE_REQUIRES_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {i18n._(HIGH_FRAME_RATE_REQUIRES_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} )} {!hasHigherQuality && !showPremium && ( @@ -385,7 +378,7 @@ export const VideoTab: React.FC = observer( /> {i18n._(SCREEN_SHARE_TIER_LIMITED_DESCRIPTOR, { frameRate: screenShareState.frameRate, - premiumProductName: PREMIUM_PRODUCT_NAME, + premiumProductName: getPremiumProductName(), resolution: getScreenShareResolutionLabel(i18n, screenShareState.resolution), savedFrameRate: screenShareState.saved.frameRate, savedResolution: getScreenShareResolutionLabel(i18n, screenShareState.saved.resolution), diff --git a/fluxer_app/src/features/user/components/modals/tabs/UserVoiceTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/UserVoiceTab.tsx index 27c80fdec..3a90f8d0c 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/UserVoiceTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/UserVoiceTab.tsx @@ -2,18 +2,12 @@ import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; import {SettingsTabSection} from '@app/features/app/components/dialogs/shared/SettingsTabLayout'; -import { - DESKTOP_DOWNLOAD_URL, - MACOS_INPUT_MONITORING_PERMISSION_NAME, - MACOS_MICROPHONE_PERMISSION_NAME, - MACOS_PRIVACY_AND_SECURITY_SETTINGS_NAME, - MACOS_SYSTEM_SETTINGS_NAME, - PRODUCT_NAME, -} from '@app/features/app/config/I18nDisplayConstants'; +import {DESKTOP_DOWNLOAD_URL, PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache'; import {KeybindRecorder} from '@app/features/input/components/KeybindRecorder'; import Keybind, {getDefaultKeybind} from '@app/features/input/state/InputKeybind'; import {openMacPermissionsModal} from '@app/features/permissions/system/commands/MacPermissionsModalCommands'; +import {macPermissionNameDescriptor} from '@app/features/permissions/system/components/useMacPermissionControl'; import NativePermission from '@app/features/permissions/system/state/NativePermission'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; @@ -135,8 +129,7 @@ const PUSH_TO_TALK_BROWSER_LIMITED_DESCRIPTION_DESCRIPTOR = msg({ comment: 'Voice settings warning shown when push-to-talk is enabled in a browser.', }); const PUSH_TO_TALK_PERMISSION_LIMITED_DESCRIPTION_DESCRIPTOR = msg({ - message: - 'Push-to-talk only works while {productName} is focused until {permissionName} is enabled. After changing the permission, fully quit and restart {productName} so system-wide hotkeys can use it.', + message: 'Push-to-talk only works while {productName} is focused until {permissionName} is enabled.', comment: 'Voice settings warning shown when macOS Input Monitoring is required for system-wide push-to-talk. {productName} is the app name and {permissionName} is the macOS permission name.', }); @@ -344,13 +337,13 @@ export const VoiceTab: React.FC = observer(({voiceSettings, autoR return; } if (enabled && isNativeMac && !inputMonitoringGranted) { - openMacPermissionsModal({focus: 'input-monitoring'}); + openMacPermissionsModal('input-monitoring'); } Keybind.setTransmitMode(mode); MediaEngine.handlePushToTalkModeChange(); }; const handleOpenInputMonitoringModal = () => { - openMacPermissionsModal({focus: 'input-monitoring'}); + openMacPermissionsModal('input-monitoring'); }; const pttAvailable = voiceProcessingMode !== 'studio'; const showPttDetails = pttAvailable && isPushToTalk; @@ -434,7 +427,7 @@ export const VoiceTab: React.FC = observer(({voiceSettings, autoR data-flx="user.voice-tab.render-ptt-controls.button.open-input-monitoring-modal" > {i18n._(ENABLE_PLATFORM_PERMISSION_DESCRIPTOR, { - permissionName: MACOS_INPUT_MONITORING_PERMISSION_NAME, + permissionName: i18n._(macPermissionNameDescriptor('input-monitoring')), })} ) @@ -445,7 +438,7 @@ export const VoiceTab: React.FC = observer(({voiceSettings, autoR ? i18n._(PUSH_TO_TALK_BROWSER_LIMITED_DESCRIPTION_DESCRIPTOR) : i18n._(PUSH_TO_TALK_PERMISSION_LIMITED_DESCRIPTION_DESCRIPTOR, { productName: PRODUCT_NAME, - permissionName: MACOS_INPUT_MONITORING_PERMISSION_NAME, + permissionName: i18n._(macPermissionNameDescriptor('input-monitoring')), })} )} @@ -550,9 +543,9 @@ export const VoiceTab: React.FC = observer(({voiceSettings, autoR

    {permissionStatus === 'denied' ? ( isNativeDesktop ? ( - - Allow {PRODUCT_NAME} to access your microphone in {MACOS_SYSTEM_SETTINGS_NAME} →{' '} - {MACOS_PRIVACY_AND_SECURITY_SETTINGS_NAME} → {MACOS_MICROPHONE_PERMISSION_NAME}. + + Allow {PRODUCT_NAME} to access your microphone in System Settings → Privacy & Security → + Microphone. ) : ( diff --git a/fluxer_app/src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedDesktopControls.tsx b/fluxer_app/src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedDesktopControls.tsx index 0e11e0b24..7f91cd8c8 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedDesktopControls.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedDesktopControls.tsx @@ -8,6 +8,7 @@ import { setDesktopDisableHardwareAcceleration, } from '@app/features/devtools/utils/DesktopTroubleshootingUtils'; import {CANCEL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; +import SourceMaps from '@app/features/platform/state/SourceMaps'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import {Switch} from '@app/features/ui/components/form/FormSwitch'; @@ -25,6 +26,27 @@ import {Trans, useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; import {useCallback, useLayoutEffect, useState} from 'react'; +const DOWNLOAD_SOURCE_MAPS_DESCRIPTOR = msg({ + message: 'Download source maps', + comment: 'Short label for a desktop setting that downloads debugging symbols.', +}); + +export const SourceMapsControl = observer(() => { + const {i18n} = useLingui(); + return ( + { + void SourceMaps.setEnabled(value); + }} + disabled={SourceMaps.downloading} + compact + data-flx="user.desktop-settings-tab.switch.source-maps" + /> + ); +}); + const USE_NATIVE_TITLE_BAR_DESCRIPTOR = msg({ message: 'Use native title bar', comment: 'Short label for an advanced desktop preference.', diff --git a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/AppearanceTabInline.tsx b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/AppearanceTabInline.tsx index 6e031745a..7833b164c 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/AppearanceTabInline.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/AppearanceTabInline.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {SettingsSection} from '@app/features/app/components/dialogs/shared/SettingsSection'; -import {APP_ZOOM_LEVEL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; +import {APP_ZOOM_LEVEL_DESCRIPTOR, THEME_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {ActiveNowTabContent} from '@app/features/user/components/modals/tabs/appearance_tab/ActiveNowTab'; import { HdrTabContent, @@ -20,10 +20,6 @@ import {useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; import type React from 'react'; -const THEME_DESCRIPTOR = msg({ - message: 'Theme', - comment: 'Short label in the inline. Keep it concise.', -}); const HIGH_DYNAMIC_RANGE_DESCRIPTOR = msg({ message: 'High dynamic range', comment: 'Short label in the inline. Keep it concise.', diff --git a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent.tsx b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent.tsx index 359fe084b..630a9ac42 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/appearance_tab/theme/ThemeTabContent.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {THEME_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import * as ThemePreferenceCommands from '@app/features/theme/commands/ThemePreferenceCommands'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; @@ -63,10 +64,6 @@ const SYSTEM_LIGHT_THEME_AUTOMATICALLY_SYNC_WITH_YOUR_SYSTEM_DESCRIPTOR = msg({ message: "System: light theme (automatically sync with your system's dark/light preference)", comment: 'Description text in the theme tab content.', }); -const THEME_DESCRIPTOR = msg({ - message: 'Theme', - comment: 'Short label in the theme tab content. Keep it concise.', -}); const SYNC_THEME_ACROSS_DEVICES_DESCRIPTOR = msg({ message: 'Sync theme across devices', comment: 'Label in the theme tab content.', diff --git a/fluxer_app/src/features/user/components/modals/tabs/applications_tab/ApplicationDetail.tsx b/fluxer_app/src/features/user/components/modals/tabs/applications_tab/ApplicationDetail.tsx index 24046ca2b..2791705dc 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/applications_tab/ApplicationDetail.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/applications_tab/ApplicationDetail.tsx @@ -4,6 +4,7 @@ import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; import {StatusSlate} from '@app/features/app/components/dialogs/shared/StatusSlate'; import {Endpoints} from '@app/features/app/constants/Endpoints'; import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit'; +import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import {useSudo} from '@app/features/auth/hooks/useSudo'; import type {DeveloperApplication} from '@app/features/devtools/models/DeveloperApplication'; import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; @@ -505,7 +506,7 @@ export const ApplicationDetail: React.FC = observer( ); const builderUrl = useMemo(() => { if (!application) return ''; - const authorizeUrl = new URL(Endpoints.OAUTH_AUTHORIZE, window.location.origin); + const authorizeUrl = new URL(`${RuntimeConfig.webAppBaseUrl}${Endpoints.OAUTH_AUTHORIZE}`); authorizeUrl.searchParams.set('client_id', application.id); if (builderScopeList.length > 0) { authorizeUrl.searchParams.set('scope', builderScopeList.join(' ')); diff --git a/fluxer_app/src/features/user/components/modals/tabs/component_gallery_tab/ComponentGalleryTabButtonsTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/component_gallery_tab/ComponentGalleryTabButtonsTab.tsx index c2b1f0a60..537cc028c 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/component_gallery_tab/ComponentGalleryTabButtonsTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/component_gallery_tab/ComponentGalleryTabButtonsTab.tsx @@ -5,7 +5,7 @@ import { SettingsTabContent, SettingsTabSection, } from '@app/features/app/components/dialogs/shared/SettingsTabLayout'; -import {PLAY_DESCRIPTOR, SETTINGS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; +import {MORE_DESCRIPTOR, PLAY_DESCRIPTOR, SETTINGS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {Button} from '@app/features/ui/button/Button'; import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; @@ -122,10 +122,6 @@ const DELETED_DESCRIPTOR = msg({ message: 'Deleted.', comment: 'Button or menu action label in the buttons tab. Keep it concise. Keep the tone plain and specific.', }); -const MORE_DESCRIPTOR = msg({ - message: 'More', - comment: 'Short label in the buttons tab. Keep it concise.', -}); const OPEN_MENU_ICON_DESCRIPTOR = msg({ message: 'Open menu icon', comment: 'Button or menu action label in the buttons tab. Keep it concise.', diff --git a/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.tsx b/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.tsx index 44fef23b3..6cc35d051 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/components/EntranceSoundSection.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_NAME, PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; +import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter'; import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils'; import Guilds from '@app/features/guild/state/Guilds'; @@ -17,7 +17,7 @@ import { parseEntranceSoundScopeId, } from '@app/features/notification/utils/EntranceSoundScopes'; import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; -import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; +import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {Button} from '@app/features/ui/button/Button'; import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; @@ -441,7 +441,7 @@ export const EntranceSoundSection: React.FC = observer(() => { /> {i18n._(CUSTOM_ENTRANCE_SOUNDS_PREMIUM_DESCRIPTOR, { - premiumProductName: PREMIUM_PRODUCT_NAME, + premiumProductName: getPremiumProductName(), })} @@ -460,7 +460,7 @@ export const EntranceSoundSection: React.FC = observer(() => { onClick={() => PremiumModalCommands.open()} data-flx="user.entrance-sound-section.button.open" > - {i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} ) : ( diff --git a/fluxer_app/src/features/user/components/modals/tabs/hooks/useMicTest.tsx b/fluxer_app/src/features/user/components/modals/tabs/hooks/useMicTest.tsx index b11d41fec..82bbb758f 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/hooks/useMicTest.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/hooks/useMicTest.tsx @@ -171,7 +171,6 @@ export const useMicTest = (settings: MicTestSettings) => { case 'unsupported-platform': break; case 'denied': - case 'declined': MediaPermission.markMicrophoneExplicitlyDenied(); handleMediaPermissionBlocked('microphone'); return; diff --git a/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/CustomKeybindRow.tsx b/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/CustomKeybindRow.tsx index ec777a9df..104c79445 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/CustomKeybindRow.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/CustomKeybindRow.tsx @@ -7,6 +7,7 @@ import Keybind, { type KeybindCommand, type KeyCombo, } from '@app/features/input/state/InputKeybind'; +import {resolveKeybindCommand} from '@app/features/input/state/input_keybind/KeybindCommands'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {CheckboxItem} from '@app/features/ui/action_menu/ContextMenu'; import {MoreOptionsVerticalIcon} from '@app/features/ui/action_menu/ContextMenuIcons'; @@ -56,7 +57,8 @@ export const CustomKeybindRow = observer( conflictLabel: string | null; }) => { const {i18n} = useLingui(); - const options = useAssignableActionOptions(entry.action); + const entryAction = resolveKeybindCommand(entry.action); + const options = useAssignableActionOptions(entryAction); const handleAction = (action: KeybindCommand | null) => { Keybind.setCustomKeybindAction(entry.id, action); }; @@ -72,7 +74,7 @@ export const CustomKeybindRow = observer( const openRowMenu = (event: React.MouseEvent) => { ContextMenuCommands.openFromEvent(event, ({onClose}) => ( <> - {entry.action && Keybind.isActionGlobalCapable(entry.action) && !GlobalShortcuts.isPortalBackend ? ( + {entryAction && Keybind.isActionGlobalCapable(entryAction) && !GlobalShortcuts.isPortalBackend ? ( Action Shortcut} - action={entry.action ?? 'voice_push_to_talk'} + action={entryAction ?? 'voice_push_to_talk'} value={entry.combo} onChange={handleCombo} onClear={handleClear} @@ -149,13 +151,13 @@ export const CustomKeybindRow = observer( - {!entry.action ? ( + {entryAction === null ? (

    Choose an action.
    ) : ( )} diff --git a/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/CustomKeybindsList.tsx b/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/CustomKeybindsList.tsx index 9c0297182..525a1f2cf 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/CustomKeybindsList.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/CustomKeybindsList.tsx @@ -1,7 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; -import Keybind from '@app/features/input/state/InputKeybind'; +import Keybind, {type KeybindCommand} from '@app/features/input/state/InputKeybind'; +import {resolveKeybindCommand} from '@app/features/input/state/input_keybind/KeybindCommands'; import {isBuiltinDisableMarker} from '@app/features/input/state/KeybindResolution'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {RetryIcon} from '@app/features/ui/action_menu/ContextMenuIcons'; @@ -56,6 +57,14 @@ const SHORTCUTS_ARE_PAUSED_WHILE_RECORDING_DESCRIPTOR = msg({ message: 'Shortcuts are paused while recording.', comment: 'Short helper text in the keybinds tab.', }); +function resolveActionLabel(labelByAction: ReadonlyMap, action: string | null): string | null { + const resolved = resolveKeybindCommand(action); + if (resolved === null) { + return action; + } + return labelByAction.get(resolved) ?? resolved; +} + export const CustomKeybindsList: React.FC<{searchQuery: string}> = observer(({searchQuery}) => { const {i18n} = useLingui(); const allCustomKeybinds = Keybind.getCustomKeybinds(); @@ -70,7 +79,7 @@ export const CustomKeybindsList: React.FC<{searchQuery: string}> = observer(({se const filteredKeybinds = useMemo(() => { if (!normalized) return customKeybinds; return customKeybinds.filter((entry) => { - const label = entry.action ? (labelByAction.get(entry.action) ?? entry.action) : ''; + const label = resolveActionLabel(labelByAction, entry.action) ?? ''; if (label.toLowerCase().includes(normalized)) return true; return comboMatchesQuery(i18n, entry.combo, normalized); }); @@ -85,9 +94,7 @@ export const CustomKeybindsList: React.FC<{searchQuery: string}> = observer(({se const other = customKeybinds[j]; if (!other.enabled || !other.combo.key) continue; if (combosLooseEqual(target.combo, other.combo)) { - const otherLabel = other.action - ? (labelByAction.get(other.action) ?? other.action) - : i18n._(UNASSIGNED_2_DESCRIPTOR); + const otherLabel = resolveActionLabel(labelByAction, other.action) ?? i18n._(UNASSIGNED_2_DESCRIPTOR); result.set(target.id, otherLabel); break; } diff --git a/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/DefaultKeybindsList.tsx b/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/DefaultKeybindsList.tsx index 934ea3efb..da17a29a8 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/DefaultKeybindsList.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/keybinds_tab/DefaultKeybindsList.tsx @@ -5,6 +5,7 @@ import Keybind, { type KeybindConfig, type KeybindSection, } from '@app/features/input/state/InputKeybind'; +import {resolveKeybindCommand} from '@app/features/input/state/input_keybind/KeybindCommands'; import {isActiveCustomKeybind, isBuiltinDisableMarker} from '@app/features/input/state/KeybindResolution'; import styles from '@app/features/user/components/modals/tabs/KeybindsTab.module.css'; import {DefaultShortcutRow} from '@app/features/user/components/modals/tabs/keybinds_tab/DefaultShortcutRow'; @@ -56,9 +57,10 @@ export const DefaultKeybindsList: React.FC<{searchQuery: string}> = observer(({s const overriddenActions = new Set(); const disabledActions = new Set(); for (const entry of customKeybinds) { - if (!entry.action) continue; - if (isActiveCustomKeybind(entry)) overriddenActions.add(entry.action); - else if (isBuiltinDisableMarker(entry)) disabledActions.add(entry.action); + const action = resolveKeybindCommand(entry.action); + if (action === null) continue; + if (isActiveCustomKeybind(entry)) overriddenActions.add(action); + else if (isBuiltinDisableMarker(entry)) disabledActions.add(action); } const normalized = normalizeQuery(searchQuery); const sectionLabels: Record = { diff --git a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/AvatarUploader.tsx b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/AvatarUploader.tsx index b55f98fb1..5bf18cc4c 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/AvatarUploader.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/AvatarUploader.tsx @@ -7,7 +7,6 @@ import { ANIMATED_IMAGE_FORMATS, AVATAR_RECOMMENDED_SIZE_LABEL, IMAGE_MAX_SIZE_BYTES, - PREMIUM_PRODUCT_NAME, STATIC_IMAGE_FORMATS, } from '@app/features/app/config/I18nDisplayConstants'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; @@ -35,7 +34,7 @@ import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils'; import {formatFileSize} from '@app/features/messaging/utils/FileUtils'; import {canDecodeAnimatedAvif} from '@app/features/platform/utils/ImageDecoderInterop'; import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; -import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; +import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; @@ -134,6 +133,7 @@ export const AvatarUploader = observer( avatarMode = 'inherit', onAvatarModeChange, }: AvatarUploaderProps) => { + const PREMIUM_PRODUCT_NAME = getPremiumProductName(); const {i18n} = useLingui(); const hasAnimatedAvatarEntitlement = isLimitToggleEnabled( {feature_animated_avatar: LimitResolver.resolve({key: 'feature_animated_avatar', fallback: 0})}, @@ -186,7 +186,7 @@ export const AvatarUploader = observer( ModalCommands.push( modal(() => (

    @@ -213,7 +213,7 @@ export const AvatarUploader = observer(

    } - primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} primaryVariant="primary" secondaryText={i18n._(CANCEL_DESCRIPTOR)} onPrimary={() => { @@ -318,7 +318,7 @@ export const AvatarUploader = observer( recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL, note: i18n._(ANIMATED_AVATARS_REQUIRE_PREMIUM_NOTE_DESCRIPTOR, { animatedAvatarFormats: ANIMATED_AVATAR_FORMATS, - premiumProductName: PREMIUM_PRODUCT_NAME, + premiumProductName: getPremiumProductName(), }), }) : formatImageUploadRecommendedHint(i18n, { diff --git a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/BannerUploader.tsx b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/BannerUploader.tsx index 7174a5373..7d753ca8e 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/BannerUploader.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/BannerUploader.tsx @@ -6,7 +6,6 @@ import { BANNER_ASPECT_RATIO_LABEL, BANNER_MINIMUM_SIZE_LABEL, IMAGE_MAX_SIZE_BYTES, - PREMIUM_PRODUCT_NAME, } from '@app/features/app/config/I18nDisplayConstants'; import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter'; import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils'; @@ -29,7 +28,7 @@ import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils'; import {formatFileSize} from '@app/features/messaging/utils/FileUtils'; import {canDecodeAnimatedAvif} from '@app/features/platform/utils/ImageDecoderInterop'; import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; -import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; +import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; @@ -129,6 +128,7 @@ export const BannerUploader = observer( bannerMode = 'inherit', onBannerModeChange, }: BannerUploaderProps) => { + const PREMIUM_PRODUCT_NAME = getPremiumProductName(); const {i18n} = useLingui(); const hasPremiumBannerEntitlement = isLimitToggleEnabled( {feature_animated_banner: LimitResolver.resolve({key: 'feature_animated_banner', fallback: 0})}, @@ -161,14 +161,14 @@ export const BannerUploader = observer( ModalCommands.push( modal(() => ( Profile banners are a {PREMIUM_PRODUCT_NAME} feature. Get {PREMIUM_PRODUCT_NAME} to add a banner to your profile. } - primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} primaryVariant="primary" secondaryText={i18n._(CANCEL_DESCRIPTOR)} onPrimary={() => { @@ -370,7 +370,7 @@ export const BannerUploader = observer( {!canUploadBanner && (
    {shouldShowPremiumFeatures() - ? i18n._(PROFILE_BANNERS_REQUIRE_PREMIUM_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME}) + ? i18n._(PROFILE_BANNERS_REQUIRE_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()}) : i18n._(PROFILE_BANNERS_NOT_ENABLED_DESCRIPTOR)}
    )} diff --git a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/MyProfileTabUsernameSection.tsx b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/MyProfileTabUsernameSection.tsx index a45b93720..23c9632fb 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/MyProfileTabUsernameSection.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/MyProfileTabUsernameSection.tsx @@ -1,11 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import {LimitResolver} from '@app/features/app/utils/LimitResolverAdapter'; import {isLimitToggleEnabled} from '@app/features/app/utils/LimitUtils'; import * as PremiumModalCommands from '@app/features/premium/commands/PremiumModalCommands'; -import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; +import {getPremiumProductName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {Button} from '@app/features/ui/button/Button'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; @@ -102,7 +101,7 @@ export const UsernameSection = observer(({isClaimed, isEmailVerified, user}: Use @@ -112,7 +111,7 @@ export const UsernameSection = observer(({isClaimed, isEmailVerified, user}: Use PremiumModalCommands.open(); }} className={styles.premiumButton} - aria-label={i18n._(GET_TO_CUSTOMIZE_YOUR_TAG_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + aria-label={i18n._(GET_TO_CUSTOMIZE_YOUR_TAG_DESCRIPTOR, {premiumProductName: getPremiumProductName()})} data-flx="user.my-profile-tab.username-section.premium-button.open" > { + const PREMIUM_PRODUCT_NAME = getPremiumProductName(); const showPremium = shouldShowPremiumFeatures(); if (!showPremium) { return ( diff --git a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/PremiumBadgeSettings.tsx b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/PremiumBadgeSettings.tsx index 63f1f1bab..9336e0881 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/PremiumBadgeSettings.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/my_profile_tab/PremiumBadgeSettings.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; +import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils'; import {Switch} from '@app/features/ui/components/form/FormSwitch'; import styles from '@app/features/user/components/modals/tabs/my_profile_tab/PremiumBadgeSettings.module.css'; import * as DateUtils from '@app/features/user/utils/DateFormatting'; @@ -101,17 +101,17 @@ export const PremiumBadgeSettings = observer(

    - {i18n._(PREMIUM_BADGE_PRIVACY_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {i18n._(PREMIUM_BADGE_PRIVACY_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}

    - {i18n._(PREMIUM_BADGE_DISPLAY_DESCRIPTION_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME})} + {i18n._(PREMIUM_BADGE_DISPLAY_DESCRIPTION_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}

    onToggle('premium_badge_hidden', value)} @@ -122,13 +122,13 @@ export const PremiumBadgeSettings = observer( label={ premiumSince ? i18n._(HIDE_PURCHASE_DATE_DESCRIPTOR, { - premiumProductName: PREMIUM_PRODUCT_NAME, + premiumProductName: getPremiumProductName(), dateUtilsGetFormattedShortDatePremiumSince: DateUtils.getFormattedShortDate(premiumSince), }) - : i18n._(HIDE_PURCHASE_DATE_2_DESCRIPTOR, {premiumProductName: PREMIUM_PRODUCT_NAME}) + : i18n._(HIDE_PURCHASE_DATE_2_DESCRIPTOR, {premiumProductName: getPremiumProductName()}) } description={i18n._(REMOVE_WHEN_YOU_FIRST_BOUGHT_FROM_YOUR_BADGE_DESCRIPTOR, { - premiumProductName: PREMIUM_PRODUCT_NAME, + premiumProductName: getPremiumProductName(), })} value={premiumBadgeTimestampHidden} onChange={(value) => onToggle('premium_badge_timestamp_hidden', value)} diff --git a/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/CommunicationTab.tsx b/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/CommunicationTab.tsx index 4160e85bb..841823820 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/CommunicationTab.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/CommunicationTab.tsx @@ -10,7 +10,9 @@ import {Switch} from '@app/features/ui/components/form/FormSwitch'; import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands'; import {CompactComboboxRow} from '@app/features/user/components/modals/tabs/components/CompactComboboxRow'; import UserSettings from '@app/features/user/state/UserSettings'; +import type {UserSettingsMutationController} from '@app/features/user/UserSettingsMutationPresentation'; import {GroupDmAddPermissionFlags, IncomingCallFlags} from '@fluxer/constants/src/UserConstants'; +import type {I18n, MessageDescriptor} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; @@ -48,134 +50,132 @@ const GROUP_DM_ADD_EVERYONE_OPTION_DESCRIPTOR = msg({ message: 'Anyone can add you to group chats', comment: 'Privacy > Communication: select option allowing anyone to add the user to group chats.', }); -type CommunicationPermissionValue = 'nobody' | 'friends_only' | 'everyone' | 'custom'; -export const CommunicationTabContent: React.FC = observer(() => { + +const CommunicationPermissionValue = Object.freeze({ + NOBODY: 'nobody', + FRIENDS_ONLY: 'friends_only', + EVERYONE: 'everyone', + CUSTOM: 'custom', +} as const); + +type CommunicationPermissionValue = (typeof CommunicationPermissionValue)[keyof typeof CommunicationPermissionValue]; + +interface CommunicationPermissionSpec { + readonly flags: { + readonly NOBODY: number; + readonly FRIENDS_ONLY: number; + readonly EVERYONE: number; + readonly FRIENDS_OF_FRIENDS: number; + readonly GUILD_MEMBERS: number; + }; + readonly read: () => number; + readonly write: (flags: number) => Promise; +} + +const INCOMING_CALL_SPEC: CommunicationPermissionSpec = { + flags: IncomingCallFlags, + read: () => UserSettings.getIncomingCallFlags(), + write: (flags) => UserSettingsCommands.update({incomingCallFlags: flags}), +}; + +const GROUP_DM_ADD_SPEC: CommunicationPermissionSpec = { + flags: GroupDmAddPermissionFlags, + read: () => UserSettings.getGroupDmAddPermissionFlags(), + write: (flags) => UserSettingsCommands.update({groupDmAddPermissionFlags: flags}), +}; + +interface CommunicationTabContentProps { + readonly mutationController: UserSettingsMutationController; +} + +interface CommunicationFlagUpdate { + readonly enabled: boolean; + readonly flag: number; +} + +function resolveBaseValue(spec: CommunicationPermissionSpec, currentFlags: number): CommunicationPermissionValue { + const hasFlag = (flag: number) => (currentFlags & flag) === flag; + if (hasFlag(spec.flags.NOBODY)) return CommunicationPermissionValue.NOBODY; + if (hasFlag(spec.flags.EVERYONE)) return CommunicationPermissionValue.EVERYONE; + if (hasFlag(spec.flags.FRIENDS_ONLY)) return CommunicationPermissionValue.FRIENDS_ONLY; + return CommunicationPermissionValue.CUSTOM; +} + +function nextFlagsForBaseChange(spec: CommunicationPermissionSpec, value: CommunicationPermissionValue): number { + if (value === CommunicationPermissionValue.NOBODY) return spec.flags.NOBODY; + if (value === CommunicationPermissionValue.FRIENDS_ONLY) return spec.flags.FRIENDS_ONLY; + if (value === CommunicationPermissionValue.EVERYONE) return spec.flags.EVERYONE; + const customFlags = spec.read() & (spec.flags.FRIENDS_OF_FRIENDS | spec.flags.GUILD_MEMBERS); + return customFlags === 0 ? spec.flags.FRIENDS_OF_FRIENDS : customFlags; +} + +function nextFlagsForAdditiveToggle( + spec: CommunicationPermissionSpec, + {enabled, flag}: CommunicationFlagUpdate, +): number { + let newFlags = spec.read() & ~(spec.flags.NOBODY | spec.flags.FRIENDS_ONLY | spec.flags.EVERYONE); + if (enabled) { + newFlags |= flag; + } else { + newFlags &= ~flag; + } + return newFlags === 0 ? spec.flags.FRIENDS_ONLY : newFlags; +} + +function buildCommunicationOptions( + i18n: I18n, + descriptors: { + readonly nobody: MessageDescriptor; + readonly friendsOnly: MessageDescriptor; + readonly custom: MessageDescriptor; + readonly everyone: MessageDescriptor; + }, +): Array> { + return [ + {value: CommunicationPermissionValue.NOBODY, label: i18n._(descriptors.nobody)}, + {value: CommunicationPermissionValue.FRIENDS_ONLY, label: i18n._(descriptors.friendsOnly)}, + {value: CommunicationPermissionValue.CUSTOM, label: i18n._(descriptors.custom)}, + {value: CommunicationPermissionValue.EVERYONE, label: i18n._(descriptors.everyone)}, + ]; +} + +export const CommunicationTabContent: React.FC = observer(({mutationController}) => { const {i18n} = useLingui(); const incomingCallFlags = UserSettings.getIncomingCallFlags(); const groupDmAddPermissionFlags = UserSettings.getGroupDmAddPermissionFlags(); const hasCallFlag = (flag: number) => (incomingCallFlags & flag) === flag; const hasGroupDmAddFlag = (flag: number) => (groupDmAddPermissionFlags & flag) === flag; - const getIncomingCallBaseValue = (): CommunicationPermissionValue => { - if (hasCallFlag(IncomingCallFlags.NOBODY)) return 'nobody'; - if (hasCallFlag(IncomingCallFlags.EVERYONE)) return 'everyone'; - if (hasCallFlag(IncomingCallFlags.FRIENDS_ONLY)) return 'friends_only'; - return 'custom'; + const settleBaseChange = (spec: CommunicationPermissionSpec, value: CommunicationPermissionValue) => { + mutationController.settle(() => spec.write(nextFlagsForBaseChange(spec, value))); }; - const handleIncomingCallBaseChange = async (value: CommunicationPermissionValue) => { - let newFlags = 0; - if (value === 'nobody') { - newFlags = IncomingCallFlags.NOBODY; - } else if (value === 'friends_only') { - newFlags = IncomingCallFlags.FRIENDS_ONLY; - } else if (value === 'everyone') { - newFlags = IncomingCallFlags.EVERYONE; - } else { - newFlags = incomingCallFlags & (IncomingCallFlags.FRIENDS_OF_FRIENDS | IncomingCallFlags.GUILD_MEMBERS); - if (newFlags === 0) { - newFlags = IncomingCallFlags.FRIENDS_OF_FRIENDS; + const settleAdditiveToggle = (spec: CommunicationPermissionSpec, update: CommunicationFlagUpdate) => { + mutationController.settle(() => spec.write(nextFlagsForAdditiveToggle(spec, update))); + }; + const handleIncomingCallModifierToggle = ({enabled, flag}: CommunicationFlagUpdate) => { + mutationController.settle(() => { + let newFlags = UserSettings.getIncomingCallFlags(); + if (enabled) { + newFlags |= flag; + } else { + newFlags &= ~flag; } - } - await UserSettingsCommands.update({incomingCallFlags: newFlags}); + return UserSettingsCommands.update({incomingCallFlags: newFlags}); + }); }; - const handleIncomingCallAdditiveToggle = async (flag: number, value: boolean) => { - let newFlags = incomingCallFlags; - newFlags &= ~IncomingCallFlags.NOBODY; - newFlags &= ~IncomingCallFlags.FRIENDS_ONLY; - newFlags &= ~IncomingCallFlags.EVERYONE; - if (value) { - newFlags |= flag; - } else { - newFlags &= ~flag; - } - if (newFlags === 0) { - newFlags = IncomingCallFlags.FRIENDS_ONLY; - } - await UserSettingsCommands.update({incomingCallFlags: newFlags}); - }; - const handleIncomingCallModifierToggle = async (flag: number, value: boolean) => { - let newFlags = incomingCallFlags; - if (value) { - newFlags |= flag; - } else { - newFlags &= ~flag; - } - await UserSettingsCommands.update({incomingCallFlags: newFlags}); - }; - const getGroupDmAddBaseValue = (): CommunicationPermissionValue => { - if (hasGroupDmAddFlag(GroupDmAddPermissionFlags.NOBODY)) return 'nobody'; - if (hasGroupDmAddFlag(GroupDmAddPermissionFlags.EVERYONE)) return 'everyone'; - if (hasGroupDmAddFlag(GroupDmAddPermissionFlags.FRIENDS_ONLY)) return 'friends_only'; - return 'custom'; - }; - const handleGroupDmAddBaseChange = async (value: CommunicationPermissionValue) => { - let newFlags = 0; - if (value === 'nobody') { - newFlags = GroupDmAddPermissionFlags.NOBODY; - } else if (value === 'friends_only') { - newFlags = GroupDmAddPermissionFlags.FRIENDS_ONLY; - } else if (value === 'everyone') { - newFlags = GroupDmAddPermissionFlags.EVERYONE; - } else { - newFlags = - groupDmAddPermissionFlags & - (GroupDmAddPermissionFlags.FRIENDS_OF_FRIENDS | GroupDmAddPermissionFlags.GUILD_MEMBERS); - if (newFlags === 0) { - newFlags = GroupDmAddPermissionFlags.FRIENDS_OF_FRIENDS; - } - } - await UserSettingsCommands.update({groupDmAddPermissionFlags: newFlags}); - }; - const handleGroupDmAddAdditiveToggle = async (flag: number, value: boolean) => { - let newFlags = groupDmAddPermissionFlags; - newFlags &= ~GroupDmAddPermissionFlags.NOBODY; - newFlags &= ~GroupDmAddPermissionFlags.FRIENDS_ONLY; - newFlags &= ~GroupDmAddPermissionFlags.EVERYONE; - if (value) { - newFlags |= flag; - } else { - newFlags &= ~flag; - } - if (newFlags === 0) { - newFlags = GroupDmAddPermissionFlags.FRIENDS_ONLY; - } - await UserSettingsCommands.update({groupDmAddPermissionFlags: newFlags}); - }; - const incomingCallOptions: Array> = [ - { - value: 'nobody', - label: i18n._(INCOMING_CALL_NOBODY_OPTION_DESCRIPTOR), - }, - { - value: 'friends_only', - label: i18n._(INCOMING_CALL_FRIENDS_ONLY_OPTION_DESCRIPTOR), - }, - { - value: 'custom', - label: i18n._(INCOMING_CALL_CUSTOM_OPTION_DESCRIPTOR), - }, - { - value: 'everyone', - label: i18n._(INCOMING_CALL_EVERYONE_OPTION_DESCRIPTOR), - }, - ]; - const groupDmAddOptions: Array> = [ - { - value: 'nobody', - label: i18n._(GROUP_DM_ADD_NOBODY_OPTION_DESCRIPTOR), - }, - { - value: 'friends_only', - label: i18n._(GROUP_DM_ADD_FRIENDS_ONLY_OPTION_DESCRIPTOR), - }, - { - value: 'custom', - label: i18n._(GROUP_DM_ADD_CUSTOM_OPTION_DESCRIPTOR), - }, - { - value: 'everyone', - label: i18n._(GROUP_DM_ADD_EVERYONE_OPTION_DESCRIPTOR), - }, - ]; + const incomingCallOptions = buildCommunicationOptions(i18n, { + nobody: INCOMING_CALL_NOBODY_OPTION_DESCRIPTOR, + friendsOnly: INCOMING_CALL_FRIENDS_ONLY_OPTION_DESCRIPTOR, + custom: INCOMING_CALL_CUSTOM_OPTION_DESCRIPTOR, + everyone: INCOMING_CALL_EVERYONE_OPTION_DESCRIPTOR, + }); + const groupDmAddOptions = buildCommunicationOptions(i18n, { + nobody: GROUP_DM_ADD_NOBODY_OPTION_DESCRIPTOR, + friendsOnly: GROUP_DM_ADD_FRIENDS_ONLY_OPTION_DESCRIPTOR, + custom: GROUP_DM_ADD_CUSTOM_OPTION_DESCRIPTOR, + everyone: GROUP_DM_ADD_EVERYONE_OPTION_DESCRIPTOR, + }); + const incomingCallBaseValue = resolveBaseValue(INCOMING_CALL_SPEC, incomingCallFlags); + const groupDmAddBaseValue = resolveBaseValue(GROUP_DM_ADD_SPEC, groupDmAddPermissionFlags); return ( <> { > label={Allowed callers} - value={getIncomingCallBaseValue()} - onChange={handleIncomingCallBaseChange} + value={incomingCallBaseValue} + onChange={(value) => settleBaseChange(INCOMING_CALL_SPEC, value)} options={incomingCallOptions} isSearchable={false} controlWidth="wide" dataFlx="user.privacy-safety-tab.communication-tab.communication-tab-content.select.incoming-call-base-change" data-flx="user.privacy-safety-tab.communication-tab.communication-tab-content.compact-combobox-row.incoming-call-base-change" /> - {getIncomingCallBaseValue() === 'custom' && ( + {incomingCallBaseValue === CommunicationPermissionValue.CUSTOM && ( <> handleIncomingCallAdditiveToggle(IncomingCallFlags.FRIENDS_OF_FRIENDS, value)} + onChange={(value) => + settleAdditiveToggle(INCOMING_CALL_SPEC, {flag: IncomingCallFlags.FRIENDS_OF_FRIENDS, enabled: value}) + } data-flx="user.privacy-safety-tab.communication-tab.communication-tab-content.switch.incoming-call-additive-toggle" /> handleIncomingCallAdditiveToggle(IncomingCallFlags.GUILD_MEMBERS, value)} + onChange={(value) => + settleAdditiveToggle(INCOMING_CALL_SPEC, {flag: IncomingCallFlags.GUILD_MEMBERS, enabled: value}) + } data-flx="user.privacy-safety-tab.communication-tab.communication-tab-content.switch.incoming-call-additive-toggle--2" /> )} - {getIncomingCallBaseValue() !== 'nobody' && ( + {incomingCallBaseValue !== CommunicationPermissionValue.NOBODY && ( Silent calls from everyone} value={hasCallFlag(IncomingCallFlags.SILENT_EVERYONE)} - onChange={(value) => handleIncomingCallModifierToggle(IncomingCallFlags.SILENT_EVERYONE, value)} + onChange={(value) => + handleIncomingCallModifierToggle({flag: IncomingCallFlags.SILENT_EVERYONE, enabled: value}) + } data-flx="user.privacy-safety-tab.communication-tab.communication-tab-content.switch.incoming-call-modifier-toggle" /> )} @@ -223,26 +229,33 @@ export const CommunicationTabContent: React.FC = observer(() => { > label={Allowed invites} - value={getGroupDmAddBaseValue()} - onChange={handleGroupDmAddBaseChange} + value={groupDmAddBaseValue} + onChange={(value) => settleBaseChange(GROUP_DM_ADD_SPEC, value)} options={groupDmAddOptions} isSearchable={false} controlWidth="wide" dataFlx="user.privacy-safety-tab.communication-tab.communication-tab-content.select.group-dm-add-base-change" data-flx="user.privacy-safety-tab.communication-tab.communication-tab-content.compact-combobox-row.group-dm-add-base-change" /> - {getGroupDmAddBaseValue() === 'custom' && ( + {groupDmAddBaseValue === CommunicationPermissionValue.CUSTOM && ( <> handleGroupDmAddAdditiveToggle(GroupDmAddPermissionFlags.FRIENDS_OF_FRIENDS, value)} + onChange={(value) => + settleAdditiveToggle(GROUP_DM_ADD_SPEC, { + flag: GroupDmAddPermissionFlags.FRIENDS_OF_FRIENDS, + enabled: value, + }) + } data-flx="user.privacy-safety-tab.communication-tab.communication-tab-content.switch.group-dm-add-additive-toggle" /> handleGroupDmAddAdditiveToggle(GroupDmAddPermissionFlags.GUILD_MEMBERS, value)} + onChange={(value) => + settleAdditiveToggle(GROUP_DM_ADD_SPEC, {flag: GroupDmAddPermissionFlags.GUILD_MEMBERS, enabled: value}) + } data-flx="user.privacy-safety-tab.communication-tab.communication-tab-content.switch.group-dm-add-additive-toggle--2" /> diff --git a/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/PrivacySafetyTabInline.tsx b/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/PrivacySafetyTabInline.tsx index 1c1f1c878..b0ad33c60 100644 --- a/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/PrivacySafetyTabInline.tsx +++ b/fluxer_app/src/features/user/components/modals/tabs/privacy_safety_tab/PrivacySafetyTabInline.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {SettingsSection} from '@app/features/app/components/dialogs/shared/SettingsSection'; +import Accounts from '@app/features/auth/state/Accounts'; import {ActiveNowTabContent as ActiveNowTab} from '@app/features/user/components/modals/tabs/privacy_safety_tab/ActiveNowTab'; import {CommunicationTabContent as CommunicationTab} from '@app/features/user/components/modals/tabs/privacy_safety_tab/CommunicationTab'; import {ConnectionsTabContent as ConnectionsTab} from '@app/features/user/components/modals/tabs/privacy_safety_tab/ConnectionsTab'; @@ -9,6 +10,7 @@ import {DataExportTabContent as DataExportTab} from '@app/features/user/componen import styles from '@app/features/user/components/modals/tabs/privacy_safety_tab/PrivacySafetyTabInline.module.css'; import {ProfilePrivacyTabContent as ProfilePrivacyTab} from '@app/features/user/components/modals/tabs/privacy_safety_tab/ProfilePrivacyTab'; import {SensitiveContentTabContent as SensitiveContentTab} from '@app/features/user/components/modals/tabs/privacy_safety_tab/SensitiveContentTab'; +import {useUserSettingsMutationController} from '@app/features/user/hooks/useUserSettingsMutationController'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; @@ -44,8 +46,14 @@ const DATA_DELETION_DESCRIPTOR = msg({ }); export const PrivacyDashboardContent: React.FC = observer(() => { const {i18n} = useLingui(); + const accountKey = Accounts.currentAccountKey; + const mutationController = useUserSettingsMutationController(); return ( -
    +
    { title={i18n._(COMMUNICATION_DESCRIPTOR)} data-flx="user.privacy-safety-tab.privacy-dashboard-content.communication" > - + cdnUrl(`badges/${fileName}?v=${BADGE_ASSET_VERSION}`); - const STAFF_DESCRIPTOR = msg({ message: '{productName} Staff', comment: @@ -101,7 +100,7 @@ export const UserProfileBadges: React.FC = observer( result.push({ type: 'icon', key: 'staff', - iconUrl: badgeAssetUrl('staff.svg'), + iconUrl: staffBadgeUrl, tooltip: i18n._(STAFF_DESCRIPTOR, {productName: PRODUCT_NAME}), url: Routes.careers(), }); @@ -110,7 +109,7 @@ export const UserProfileBadges: React.FC = observer( result.push({ type: 'icon', key: 'partner', - iconUrl: badgeAssetUrl('partner.svg'), + iconUrl: partnerBadgeUrl, tooltip: i18n._(PARTNER_DESCRIPTOR, {productName: PRODUCT_NAME}), url: Routes.partners(), }); @@ -119,13 +118,13 @@ export const UserProfileBadges: React.FC = observer( result.push({ type: 'icon', key: 'bug_hunter', - iconUrl: badgeAssetUrl('bug-hunter.svg'), + iconUrl: bugHunterBadgeUrl, tooltip: i18n._(BUG_HUNTER_DESCRIPTOR, {productName: PRODUCT_NAME}), url: Routes.bugs(), }); } if (showPremium && profile?.premiumType && profile.premiumType !== UserPremiumTypes.NONE) { - let tooltipText = PREMIUM_PRODUCT_FULL_NAME; + let tooltipText = getPremiumProductFullName(); let badgeUrl: string | undefined = premiumInfoUrl ?? (selfHosted || plutoniumPageEnabled ? undefined : Routes.plutonium()); const badgeOnClick = badgeUrl ? undefined : () => PremiumModalCommands.open(); @@ -140,14 +139,14 @@ export const UserProfileBadges: React.FC = observer( } else if (profile.premiumSince) { const premiumSinceFormatted = DateUtils.getFormattedShortDate(profile.premiumSince); tooltipText = i18n._(SUBSCRIBER_SINCE_DESCRIPTOR, { - premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME, + premiumProductFullName: getPremiumProductFullName(), premiumSinceFormatted, }); } result.push({ type: 'icon', key: 'premium', - iconUrl: badgeAssetUrl('plutonium.svg'), + iconUrl: plutoniumBadgeUrl, tooltip: tooltipText, url: badgeUrl, onClick: badgeOnClick, diff --git a/fluxer_app/src/features/user/components/popouts/UserProfilePopout.tsx b/fluxer_app/src/features/user/components/popouts/UserProfilePopout.tsx index e28f64508..4458fa693 100644 --- a/fluxer_app/src/features/user/components/popouts/UserProfilePopout.tsx +++ b/fluxer_app/src/features/user/components/popouts/UserProfilePopout.tsx @@ -2,6 +2,7 @@ import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal'; import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal'; +import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import {CustomStatusDisplay} from '@app/features/app/components/shared/custom_status_display/CustomStatusDisplay'; import {useHover} from '@app/features/app/hooks/useHover'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; @@ -32,7 +33,6 @@ import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands'; import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import * as UserProfileCommands from '@app/features/user/commands/UserProfileCommands'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import {LimitedProfileNotice} from '@app/features/user/components/popouts/LimitedProfileNotice'; import {UserProfileBadges} from '@app/features/user/components/popouts/UserProfileBadges'; import {UserProfileDataWarning} from '@app/features/user/components/popouts/UserProfileDataWarning'; @@ -202,12 +202,15 @@ export const UserProfilePopout: React.FC = observer( }; const handleEditProfile = () => { ModalCommands.push( - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); requestClose(); }; diff --git a/fluxer_app/src/features/user/components/settings_utils/GuildSettingsConstants.ts b/fluxer_app/src/features/user/components/settings_utils/GuildSettingsConstants.ts index 4edfb700e..b9b77b87e 100644 --- a/fluxer_app/src/features/user/components/settings_utils/GuildSettingsConstants.ts +++ b/fluxer_app/src/features/user/components/settings_utils/GuildSettingsConstants.ts @@ -1,16 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import GuildAuditLogTab from '@app/features/guild/components/modals/guild_tabs/GuildAuditLogTab'; -import GuildBansTab from '@app/features/guild/components/modals/guild_tabs/GuildBansTab'; -import GuildDiscoveryTab from '@app/features/guild/components/modals/guild_tabs/GuildDiscoveryTab'; -import GuildEmojiTab from '@app/features/guild/components/modals/guild_tabs/GuildEmojiTab'; -import GuildInvitesTab from '@app/features/guild/components/modals/guild_tabs/GuildInvitesTab'; -import GuildModerationTab from '@app/features/guild/components/modals/guild_tabs/GuildModerationTab'; -import GuildRolesTab from '@app/features/guild/components/modals/guild_tabs/GuildRolesTab'; -import GuildStickersTab from '@app/features/guild/components/modals/guild_tabs/GuildStickersTab'; -import GuildVanityURLTab from '@app/features/guild/components/modals/guild_tabs/GuildVanityURLTab'; -import GuildWebhooksTab from '@app/features/guild/components/modals/guild_tabs/GuildWebhooksTab'; -import GuildOverviewTab from '@app/features/guild/components/modals/guild_tabs/guild_overview_tab'; import {Permissions} from '@fluxer/constants/src/ChannelConstants'; import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import type {I18n, MessageDescriptor} from '@lingui/core'; @@ -31,7 +20,6 @@ import { UserIcon, WebhooksLogoIcon, } from '@phosphor-icons/react'; -import type React from 'react'; const EMOJI_DESCRIPTOR = msg({ message: 'Emoji', @@ -120,9 +108,6 @@ export interface GuildSettingsTab { label: string; icon: Icon; iconWeight?: IconWeight; - component: React.ComponentType<{ - guildId: string; - }>; permission?: bigint | ReadonlyArray; requireFeature?: string; } @@ -133,9 +118,6 @@ interface GuildSettingsTabDescriptor { label: MessageDescriptor; icon: Icon; iconWeight?: IconWeight; - component: React.ComponentType<{ - guildId: string; - }>; permission?: bigint | ReadonlyArray; requireFeature?: string; } @@ -146,7 +128,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'guild_settings', label: OVERVIEW_DESCRIPTOR, icon: GearIcon, - component: GuildOverviewTab, permission: Permissions.MANAGE_GUILD, }, { @@ -154,7 +135,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'guild_settings', label: ROLES_DESCRIPTOR, icon: ShieldIcon, - component: GuildRolesTab, permission: Permissions.MANAGE_ROLES, }, { @@ -162,7 +142,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'guild_settings', label: MODERATION_DESCRIPTOR, icon: HammerIcon, - component: GuildModerationTab, permission: Permissions.MANAGE_GUILD, }, { @@ -170,7 +149,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'guild_settings', label: ACTIVITY_LOG_DESCRIPTOR, icon: BookOpenIcon, - component: GuildAuditLogTab, permission: Permissions.VIEW_AUDIT_LOG, }, { @@ -178,7 +156,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'expressions', label: EMOJI_DESCRIPTOR, icon: SmileyIcon, - component: GuildEmojiTab, permission: [Permissions.CREATE_EXPRESSIONS, Permissions.MANAGE_EXPRESSIONS], }, { @@ -186,7 +163,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'expressions', label: STICKERS_DESCRIPTOR, icon: StickerIcon, - component: GuildStickersTab, permission: [Permissions.CREATE_EXPRESSIONS, Permissions.MANAGE_EXPRESSIONS], }, { @@ -195,7 +171,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ label: DISCOVERY_DESCRIPTOR, icon: CompassIcon, iconWeight: 'fill', - component: GuildDiscoveryTab, permission: Permissions.MANAGE_GUILD, }, { @@ -204,7 +179,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ label: VANITY_URL_DESCRIPTOR, icon: LinkIcon, iconWeight: 'bold', - component: GuildVanityURLTab, permission: Permissions.MANAGE_GUILD, requireFeature: GuildFeatures.VANITY_URL, }, @@ -213,7 +187,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'integrations', label: WEBHOOKS_DESCRIPTOR, icon: WebhooksLogoIcon, - component: GuildWebhooksTab, permission: Permissions.MANAGE_WEBHOOKS, }, { @@ -221,7 +194,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'user_management', label: MEMBERS_DESCRIPTOR, icon: UserIcon, - component: () => null, permission: Permissions.MANAGE_GUILD, }, { @@ -229,7 +201,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'user_management', label: INVITES_DESCRIPTOR, icon: TicketIcon, - component: GuildInvitesTab, permission: Permissions.MANAGE_GUILD, }, { @@ -237,7 +208,6 @@ const GUILD_SETTINGS_TABS_DESCRIPTORS: Array = [ category: 'user_management', label: BANS_DESCRIPTOR, icon: ProhibitIcon, - component: GuildBansTab, permission: Permissions.BAN_MEMBERS, }, ]; diff --git a/fluxer_app/src/features/user/components/settings_utils/GuildSettingsTabComponents.ts b/fluxer_app/src/features/user/components/settings_utils/GuildSettingsTabComponents.ts new file mode 100644 index 000000000..694dda4f0 --- /dev/null +++ b/fluxer_app/src/features/user/components/settings_utils/GuildSettingsTabComponents.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import GuildAuditLogTab from '@app/features/guild/components/modals/guild_tabs/GuildAuditLogTab'; +import GuildBansTab from '@app/features/guild/components/modals/guild_tabs/GuildBansTab'; +import GuildDiscoveryTab from '@app/features/guild/components/modals/guild_tabs/GuildDiscoveryTab'; +import GuildEmojiTab from '@app/features/guild/components/modals/guild_tabs/GuildEmojiTab'; +import GuildInvitesTab from '@app/features/guild/components/modals/guild_tabs/GuildInvitesTab'; +import GuildModerationTab from '@app/features/guild/components/modals/guild_tabs/GuildModerationTab'; +import GuildRolesTab from '@app/features/guild/components/modals/guild_tabs/GuildRolesTab'; +import GuildStickersTab from '@app/features/guild/components/modals/guild_tabs/GuildStickersTab'; +import GuildVanityURLTab from '@app/features/guild/components/modals/guild_tabs/GuildVanityURLTab'; +import GuildWebhooksTab from '@app/features/guild/components/modals/guild_tabs/GuildWebhooksTab'; +import GuildOverviewTab from '@app/features/guild/components/modals/guild_tabs/guild_overview_tab'; +import type {GuildSettingsTabType} from '@app/features/user/components/settings_utils/GuildSettingsConstants'; +import type React from 'react'; + +type GuildSettingsTabComponent = React.ComponentType<{guildId: string}>; + +const EmptyGuildSettingsTab: GuildSettingsTabComponent = () => null; + +const GUILD_SETTINGS_TAB_COMPONENTS: Record = { + overview: GuildOverviewTab, + roles: GuildRolesTab, + moderation: GuildModerationTab, + audit_log: GuildAuditLogTab, + emoji: GuildEmojiTab, + stickers: GuildStickersTab, + discovery: GuildDiscoveryTab, + vanity_url: GuildVanityURLTab, + webhooks: GuildWebhooksTab, + members: EmptyGuildSettingsTab, + invites: GuildInvitesTab, + bans: GuildBansTab, +}; + +export function getGuildSettingsTabComponent(tabType: GuildSettingsTabType): GuildSettingsTabComponent { + return GUILD_SETTINGS_TAB_COMPONENTS[tabType]; +} diff --git a/fluxer_app/src/features/user/components/settings_utils/SettingsConstants.tsx b/fluxer_app/src/features/user/components/settings_utils/SettingsConstants.tsx index 6ef464fff..a49f4f5d4 100644 --- a/fluxer_app/src/features/user/components/settings_utils/SettingsConstants.tsx +++ b/fluxer_app/src/features/user/components/settings_utils/SettingsConstants.tsx @@ -1,7 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_FULL_NAME} from '@app/features/app/config/I18nDisplayConstants'; -import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; +import {getPremiumProductFullName, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils'; import {guessPlatform, isDesktop} from '@app/features/ui/utils/NativeUtils'; import { ADVANCED_SETTINGS_TAG_LABELS, @@ -184,7 +183,7 @@ export interface SettingsTab { iconWeight?: IconWeight; } -type SettingsTabLabel = MessageDescriptor | string; +type SettingsTabLabel = MessageDescriptor | string | (() => string); interface SettingsTabDescriptor { type: UserSettingsTabType; @@ -253,7 +252,7 @@ const ALL_TABS_DESCRIPTORS: Array = [ { type: 'plutonium', category: 'billing', - label: PREMIUM_PRODUCT_FULL_NAME, + label: getPremiumProductFullName, icon: CrownIcon, }, { @@ -347,7 +346,9 @@ export function getUserSettingsTabLabel(i18n: I18n, tabType: UserSettingsTabType const tab = ALL_TABS_DESCRIPTORS.find((candidate) => candidate.type === tabType); if (!tab) return ''; if (tab.type === 'desktop_settings') return getDesktopSettingsTabLabel(i18n); - return typeof tab.label === 'string' ? tab.label : i18n._(tab.label); + if (typeof tab.label === 'string') return tab.label; + if (typeof tab.label === 'function') return tab.label(); + return i18n._(tab.label); } export function getUserSettingsTabIconDescriptor( diff --git a/fluxer_app/src/features/user/components/settings_utils/SettingsSearchIndex.ts b/fluxer_app/src/features/user/components/settings_utils/SettingsSearchIndex.ts index 9fcd12378..14932057e 100644 --- a/fluxer_app/src/features/user/components/settings_utils/SettingsSearchIndex.ts +++ b/fluxer_app/src/features/user/components/settings_utils/SettingsSearchIndex.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import i18n from '@app/app/I18n'; -import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/ProductConstants'; import {onLocaleChange} from '@app/features/i18n/utils/LocaleChangeListener'; +import {getPremiumProductFullName, getPremiumProductName} from '@app/features/premium/utils/PremiumUtils'; import type {SettingsTab} from '@app/features/user/components/settings_utils/SettingsConstants'; import { getSettingsAudience, @@ -59,9 +59,12 @@ function translateSearchableKeyword(keyword: SearchableSettingKeyword, activeI18 if (typeof keyword === 'string') { return keyword; } + if (typeof keyword === 'function') { + return keyword(); + } return activeI18n._(keyword, { - premiumProductName: PREMIUM_PRODUCT_NAME, - premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME, + premiumProductName: getPremiumProductName(), + premiumProductFullName: getPremiumProductFullName(), }); } diff --git a/fluxer_app/src/features/user/components/settings_utils/SettingsSectionRegistry.ts b/fluxer_app/src/features/user/components/settings_utils/SettingsSectionRegistry.ts index 3c2f09154..d00f947c8 100644 --- a/fluxer_app/src/features/user/components/settings_utils/SettingsSectionRegistry.ts +++ b/fluxer_app/src/features/user/components/settings_utils/SettingsSectionRegistry.ts @@ -73,7 +73,11 @@ function toSearchableSettingItem(section: SectionDefinition, i18n: I18n): Search sectionId: section.id, sourceSectionId: section.id, label: i18n._(section.label), - keywords: section.keywords.map((keyword) => (typeof keyword === 'string' ? keyword : i18n._(keyword))), + keywords: section.keywords.map((keyword) => { + if (typeof keyword === 'string') return keyword; + if (typeof keyword === 'function') return keyword(); + return i18n._(keyword); + }), description: section.description ? i18n._(section.description) : undefined, audience: getSettingsAudience(section), tags: section.tags, diff --git a/fluxer_app/src/features/user/components/settings_utils/search_index/DesktopSettingsIndex.ts b/fluxer_app/src/features/user/components/settings_utils/search_index/DesktopSettingsIndex.ts index ce1f4c3ab..71733737d 100644 --- a/fluxer_app/src/features/user/components/settings_utils/search_index/DesktopSettingsIndex.ts +++ b/fluxer_app/src/features/user/components/settings_utils/search_index/DesktopSettingsIndex.ts @@ -7,6 +7,35 @@ 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 DOWNLOAD_SOURCE_MAPS_DESCRIPTOR = msg({ + message: 'Download source maps', + comment: 'Settings search entry label. Names the settings search entry in the settings UI.', +}); +const SOURCE_MAPS_DESCRIPTOR = msg({ + message: 'Source maps', + comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', +}); +const SOURCEMAPS_DESCRIPTOR = msg({ + message: 'Sourcemaps', + comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', +}); +const DEBUGGING_DESCRIPTOR = msg({ + message: 'Debugging', + comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', +}); +const STACK_TRACES_DESCRIPTOR = msg({ + message: 'Stack traces', + comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', +}); +const SYMBOLS_DESCRIPTOR = msg({ + message: 'Symbols', + comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', +}); +const DOWNLOAD_SOURCE_MAPS_FOR_READABLE_STACK_TRACES_DESCRIPTOR = msg({ + message: 'Download source maps so stack traces and the developer tools point at real source', + 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.', @@ -293,6 +322,22 @@ export const desktopSettingsIndex: Array = [ tags: ['desktop'], isVisible: isDesktop, }, + { + id: 'desktop-source-maps', + tabType: 'desktop_settings', + label: DOWNLOAD_SOURCE_MAPS_DESCRIPTOR, + keywords: [ + SOURCE_MAPS_DESCRIPTOR, + SOURCEMAPS_DESCRIPTOR, + DEBUGGING_DESCRIPTOR, + STACK_TRACES_DESCRIPTOR, + SYMBOLS_DESCRIPTOR, + ], + description: DOWNLOAD_SOURCE_MAPS_FOR_READABLE_STACK_TRACES_DESCRIPTOR, + audience: 'advanced', + tags: ['desktop', 'developer'], + isVisible: isDesktop, + }, { id: 'advanced-native-title-bar', tabType: 'desktop_settings', diff --git a/fluxer_app/src/features/user/components/settings_utils/search_index/SearchIndexTypes.ts b/fluxer_app/src/features/user/components/settings_utils/search_index/SearchIndexTypes.ts index 921669107..7deb4dd6f 100644 --- a/fluxer_app/src/features/user/components/settings_utils/search_index/SearchIndexTypes.ts +++ b/fluxer_app/src/features/user/components/settings_utils/search_index/SearchIndexTypes.ts @@ -4,7 +4,7 @@ import type {SettingsMetadata} from '@app/features/user/components/settings_util import type {UserSettingsTabType} from '@app/features/user/components/settings_utils/SettingsSectionRegistry'; import type {MessageDescriptor} from '@lingui/core'; -export type SearchableSettingKeyword = MessageDescriptor | string; +export type SearchableSettingKeyword = MessageDescriptor | string | (() => string); export interface SearchableSettingDescriptor extends SettingsMetadata { id: string; diff --git a/fluxer_app/src/features/user/components/settings_utils/search_index/SharedDescriptors.ts b/fluxer_app/src/features/user/components/settings_utils/search_index/SharedDescriptors.ts index 495fe3d5d..a2385ce99 100644 --- a/fluxer_app/src/features/user/components/settings_utils/search_index/SharedDescriptors.ts +++ b/fluxer_app/src/features/user/components/settings_utils/search_index/SharedDescriptors.ts @@ -1,13 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; +import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils'; import {msg} from '@lingui/core/macro'; export const BACKGROUND_DESCRIPTOR = msg({ message: 'Background', comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', }); -export const PREMIUM_PRODUCT_NAME_KEYWORD = PREMIUM_PRODUCT_NAME; +export const PREMIUM_PRODUCT_NAME_KEYWORD = getPremiumProductName; export const LOGIN_DESCRIPTOR = msg({ message: 'Login', comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', diff --git a/fluxer_app/src/features/user/components/settings_utils/section_registry/PrivacySafetySections.ts b/fluxer_app/src/features/user/components/settings_utils/section_registry/PrivacySafetySections.ts index 6758eca04..4dfa95328 100644 --- a/fluxer_app/src/features/user/components/settings_utils/section_registry/PrivacySafetySections.ts +++ b/fluxer_app/src/features/user/components/settings_utils/section_registry/PrivacySafetySections.ts @@ -1,9 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { + COMMUNITY_DESCRIPTOR, COMMUNITY_MEMBERS_DESCRIPTOR, DIRECT_MESSAGES_DESCRIPTOR, FRIENDS_OF_FRIENDS_DESCRIPTOR, + GROUP_DM_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import {ADD_FRIEND_DESCRIPTOR} from '@app/features/relationship/utils/RelationshipMessageDescriptors'; import type {SectionDefinition} from '@app/features/user/components/settings_utils/section_registry/SectionRegistryTypes'; @@ -113,10 +115,6 @@ const GROUP_CHAT_DESCRIPTOR = msg({ message: 'Group chat', comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', }); -const GROUP_DM_DESCRIPTOR = msg({ - message: 'Group DM', - comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', -}); const ADD_TO_GROUP_DESCRIPTOR = msg({ message: 'Add to group', comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', @@ -137,10 +135,6 @@ const EXTENDED_NETWORK_DESCRIPTOR = msg({ message: 'Extended network', comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', }); -const COMMUNITY_DESCRIPTOR = msg({ - message: 'Community', - comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', -}); const GUILD_MEMBERS_DESCRIPTOR = msg({ message: 'Community members', comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', diff --git a/fluxer_app/src/features/user/components/settings_utils/section_registry/SectionRegistryTypes.ts b/fluxer_app/src/features/user/components/settings_utils/section_registry/SectionRegistryTypes.ts index a84f5637f..a6ff7e9d0 100644 --- a/fluxer_app/src/features/user/components/settings_utils/section_registry/SectionRegistryTypes.ts +++ b/fluxer_app/src/features/user/components/settings_utils/section_registry/SectionRegistryTypes.ts @@ -8,7 +8,7 @@ import type { } from '@app/features/user/components/settings_utils/SettingsMetadata'; import type {MessageDescriptor} from '@lingui/core'; -export type SectionKeyword = MessageDescriptor | string; +export type SectionKeyword = MessageDescriptor | string | (() => string); export type UserSettingsTabType = | 'my_profile' diff --git a/fluxer_app/src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts b/fluxer_app/src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts index 71aba1ccc..f61dfa025 100644 --- a/fluxer_app/src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts +++ b/fluxer_app/src/features/user/components/settings_utils/section_registry/SharedDescriptors.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; +import {getPremiumProductName} from '@app/features/premium/utils/PremiumUtils'; import {msg} from '@lingui/core/macro'; export const COLORS_DESCRIPTOR = msg({ @@ -184,7 +184,7 @@ export const VOLUME_DESCRIPTOR = msg({ message: 'Volume', comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', }); -export const PREMIUM_PRODUCT_NAME_KEYWORD = PREMIUM_PRODUCT_NAME; +export const PREMIUM_PRODUCT_NAME_KEYWORD = getPremiumProductName; export const DM_DESCRIPTOR = msg({ message: 'DM', comment: 'Settings search synonym. Used to match this term when the user types it in the settings search bar.', diff --git a/fluxer_app/src/features/user/events/AuthSessionChange.ts b/fluxer_app/src/features/user/events/AuthSessionChange.ts index efc3d848d..9cb6e910f 100644 --- a/fluxer_app/src/features/user/events/AuthSessionChange.ts +++ b/fluxer_app/src/features/user/events/AuthSessionChange.ts @@ -2,15 +2,22 @@ import AuthSession from '@app/features/auth/state/AuthSession'; import type {GatewayHandlerContext} from '@app/features/gateway/events/EventRouter'; +import SessionManager from '@app/features/platform/state/AuthSession'; +import {Logger} from '@app/features/platform/utils/AppLogger'; -interface AuthSessionChangePayload { +const logger = new Logger('AuthSessionChange'); + +export interface AuthSessionChangePayload { new_token?: string; new_auth_session_id_hash?: string | null; } export function handleAuthSessionChange(data: AuthSessionChangePayload, context: GatewayHandlerContext): void { - if (data.new_token) { - context.socket?.setToken(data.new_token); + const token = data.new_token; + if (token) { + void SessionManager.setToken(token) + .then(() => context.socket?.setToken(token)) + .catch((error) => logger.error('Failed to persist an authentication session token change', error)); } if (data.new_auth_session_id_hash) { AuthSession.handleAuthSessionChange(data.new_auth_session_id_hash); diff --git a/fluxer_app/src/features/user/events/UserNoteUpdate.ts b/fluxer_app/src/features/user/events/UserNoteUpdate.ts index bdd8a5d27..c96855abe 100644 --- a/fluxer_app/src/features/user/events/UserNoteUpdate.ts +++ b/fluxer_app/src/features/user/events/UserNoteUpdate.ts @@ -3,7 +3,7 @@ import type {GatewayHandlerContext} from '@app/features/gateway/events/EventRouter'; import UserNote from '@app/features/user/state/UserNote'; -interface UserNoteUpdatePayload { +export interface UserNoteUpdatePayload { id: string; note?: string | null; } diff --git a/fluxer_app/src/features/user/events/UserSettingsUpdate.ts b/fluxer_app/src/features/user/events/UserSettingsUpdate.ts index 6a9a20d25..ab69b54db 100644 --- a/fluxer_app/src/features/user/events/UserSettingsUpdate.ts +++ b/fluxer_app/src/features/user/events/UserSettingsUpdate.ts @@ -4,7 +4,7 @@ import type {GatewayHandlerContext} from '@app/features/gateway/events/EventRout import GuildList from '@app/features/guild/state/GuildList'; import UserSettings from '@app/features/user/state/UserSettings'; -interface UserSettingsPayload { +export interface UserSettingsPayload { flags: number; status: string; theme: string; diff --git a/fluxer_app/src/features/user/events/UserUpdate.ts b/fluxer_app/src/features/user/events/UserUpdate.ts index c4159d3ea..7aedebad7 100644 --- a/fluxer_app/src/features/user/events/UserUpdate.ts +++ b/fluxer_app/src/features/user/events/UserUpdate.ts @@ -8,7 +8,7 @@ import QuickSwitcher from '@app/features/search/state/QuickSwitcher'; import Users from '@app/features/user/state/Users'; import type {User} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; -interface UserUpdatePayload { +export interface UserUpdatePayload { id: string; username: string; discriminator: string; diff --git a/fluxer_app/src/features/user/hooks/useUserSettingsMutationController.ts b/fluxer_app/src/features/user/hooks/useUserSettingsMutationController.ts new file mode 100644 index 000000000..fca7d48b7 --- /dev/null +++ b/fluxer_app/src/features/user/hooks/useUserSettingsMutationController.ts @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Accounts from '@app/features/auth/state/Accounts'; +import {resolvePortalHost, usePortalHost} from '@app/features/ui/overlay/PortalHostContext'; +import { + type UserSettingsMutation, + type UserSettingsMutationController, + UserSettingsMutationPresentationOwner, +} from '@app/features/user/UserSettingsMutationPresentation'; +import {useLingui} from '@lingui/react/macro'; +import {useCallback, useLayoutEffect, useMemo, useRef} from 'react'; + +class InactiveUserSettingsMutationControllerError extends Error { + public constructor() { + super('User settings mutation requires the current account and portal owner'); + this.name = 'InactiveUserSettingsMutationControllerError'; + } +} + +export function useUserSettingsMutationController(): UserSettingsMutationController { + const {i18n} = useLingui(); + const accountKey = Accounts.currentAccountKey; + const portalHost = resolvePortalHost(usePortalHost()); + const presentationOwnerRef = useRef(null); + useLayoutEffect(() => { + if (accountKey == null) { + return; + } + const presentationOwner = new UserSettingsMutationPresentationOwner({accountKey, i18n, portalHost}); + presentationOwnerRef.current = presentationOwner; + return () => { + if (presentationOwnerRef.current === presentationOwner) { + presentationOwnerRef.current = null; + } + presentationOwner.dispose(); + }; + }, [accountKey, i18n, portalHost]); + const requirePresentationOwner = useCallback((): UserSettingsMutationPresentationOwner => { + const presentationOwner = presentationOwnerRef.current; + if (accountKey == null || presentationOwner == null || Accounts.currentAccountKey !== accountKey) { + throw new InactiveUserSettingsMutationControllerError(); + } + return presentationOwner; + }, [accountKey]); + const settle = useCallback( + (mutation: UserSettingsMutation): void => { + requirePresentationOwner().settle(mutation); + }, + [requirePresentationOwner], + ); + return useMemo(() => ({settle}), [settle]); +} diff --git a/fluxer_app/src/features/user/hooks/useUsernameSuggestions.ts b/fluxer_app/src/features/user/hooks/useUsernameSuggestions.ts index 72c572fd0..7697f3ddc 100644 --- a/fluxer_app/src/features/user/hooks/useUsernameSuggestions.ts +++ b/fluxer_app/src/features/user/hooks/useUsernameSuggestions.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands'; +import type {InstanceHTTPTarget} from '@app/features/platform/transport/InstanceHTTP'; import {Logger} from '@app/features/platform/utils/AppLogger'; import {useCallback, useEffect, useRef, useState} from 'react'; @@ -9,32 +10,41 @@ const logger = new Logger('useUsernameSuggestions'); interface UseUsernameSuggestionsOptions { globalName: string; username: string; + target: InstanceHTTPTarget; debounceMs?: number; } -export function useUsernameSuggestions({globalName, username, debounceMs = 300}: UseUsernameSuggestionsOptions) { +export function useUsernameSuggestions({ + globalName, + username, + target, + debounceMs = 300, +}: UseUsernameSuggestionsOptions) { const [suggestions, setSuggestions] = useState>([]); const debounceTimerRef = useRef(null); const abortControllerRef = useRef(null); - const fetchSuggestions = useCallback(async (displayName: string) => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - if (!displayName || displayName.trim().length === 0) { - setSuggestions([]); - return; - } - try { - abortControllerRef.current = new AbortController(); - const fetchedSuggestions = await AuthenticationCommands.getUsernameSuggestions(displayName); - setSuggestions(fetchedSuggestions); - } catch (error) { - if (error instanceof Error && error.name !== 'AbortError') { - logger.error('Failed to fetch username suggestions', error); + const fetchSuggestions = useCallback( + async (displayName: string) => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); } - setSuggestions([]); - } - }, []); + if (!displayName || displayName.trim().length === 0) { + setSuggestions([]); + return; + } + try { + abortControllerRef.current = new AbortController(); + const fetchedSuggestions = await AuthenticationCommands.getUsernameSuggestions(displayName, target); + setSuggestions(fetchedSuggestions); + } catch (error) { + if (error instanceof Error && error.name !== 'AbortError') { + logger.error('Failed to fetch username suggestions', error); + } + setSuggestions([]); + } + }, + [target], + ); useEffect(() => { if (username && username.trim().length > 0) { setSuggestions([]); diff --git a/fluxer_app/src/features/user/state/AdvancedSettings.ts b/fluxer_app/src/features/user/state/AdvancedSettings.ts index 00480cf24..9d213136d 100644 --- a/fluxer_app/src/features/user/state/AdvancedSettings.ts +++ b/fluxer_app/src/features/user/state/AdvancedSettings.ts @@ -29,20 +29,20 @@ class AdvancedSettings { return; } AppStorage.subscribe( - (event) => { - this.unreadBadgeCustomizationEnabled = event.newValue === null ? false : readStoredBoolean(event.key ?? ''); + () => { + this.unreadBadgeCustomizationEnabled = readStoredBoolean(UNREAD_BADGE_CUSTOMIZATION_STORAGE_KEY); }, {key: UNREAD_BADGE_CUSTOMIZATION_STORAGE_KEY, source: 'external'}, ); AppStorage.subscribe( - (event) => { - this.keepAttachmentsOnEmptyMessageEdit = event.newValue === null ? false : readStoredBoolean(event.key ?? ''); + () => { + this.keepAttachmentsOnEmptyMessageEdit = readStoredBoolean(KEEP_ATTACHMENTS_ON_EMPTY_MESSAGE_EDIT_STORAGE_KEY); }, {key: KEEP_ATTACHMENTS_ON_EMPTY_MESSAGE_EDIT_STORAGE_KEY, source: 'external'}, ); AppStorage.subscribe( - (event) => { - this.expressionCloneShortcutsEnabled = event.newValue === null ? false : readStoredBoolean(event.key ?? ''); + () => { + this.expressionCloneShortcutsEnabled = readStoredBoolean(EXPRESSION_CLONE_SHORTCUTS_STORAGE_KEY); }, {key: EXPRESSION_CLONE_SHORTCUTS_STORAGE_KEY, source: 'external'}, ); diff --git a/fluxer_app/src/features/user/state/PrivacyPreferences.ts b/fluxer_app/src/features/user/state/PrivacyPreferences.ts index 9ff86806c..9f259c3db 100644 --- a/fluxer_app/src/features/user/state/PrivacyPreferences.ts +++ b/fluxer_app/src/features/user/state/PrivacyPreferences.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {initializeStore} from '@app/features/platform/utils/StoreInitialization'; import {makeSyncedField} from '@app/features/user/state/SyncedField'; import Users from '@app/features/user/state/Users'; import {PrivacyPreferencesSchema} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb'; @@ -29,7 +30,7 @@ class PrivacyPreferences { constructor() { makeAutoObservable(this, {}, {autoBind: true}); - this.initPersistence(); + initializeStore(this, () => this.initPersistence()); } private async initPersistence(): Promise { diff --git a/fluxer_app/src/features/user/state/StatusExpiry.ts b/fluxer_app/src/features/user/state/StatusExpiry.ts index d40c37f24..c2ce45230 100644 --- a/fluxer_app/src/features/user/state/StatusExpiry.ts +++ b/fluxer_app/src/features/user/state/StatusExpiry.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import AccountManager from '@app/features/auth/state/AccountManager'; +import Accounts from '@app/features/auth/state/Accounts'; import {Logger} from '@app/features/platform/utils/AppLogger'; import {deferUntilModulesLoaded} from '@app/features/platform/utils/DeferUntilModulesLoaded'; import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands'; @@ -18,7 +18,7 @@ class StatusExpiry { makeAutoObservable(this); deferUntilModulesLoaded(() => { reaction( - () => AccountManager.currentUserId, + () => Accounts.currentUserId, (userId) => { this.handleActiveUserChange(userId); }, diff --git a/fluxer_app/src/features/user/state/StatusPage.test.ts b/fluxer_app/src/features/user/state/StatusPage.test.ts new file mode 100644 index 000000000..c960f4aab --- /dev/null +++ b/fluxer_app/src/features/user/state/StatusPage.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {runInAction} from 'mobx'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const runtime = vi.hoisted(() => ({state: null as unknown as {statusPageUrl: string}})); + +vi.mock('@app/features/app/state/RuntimeConfig', async () => { + const {observable: makeObservable} = await import('mobx'); + runtime.state = makeObservable({statusPageUrl: ''}); + return {default: runtime.state}; +}); + +vi.mock('@app/features/platform/utils/AppLogger', () => ({ + Logger: class { + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = vi.fn(); + }, +})); + +const {StatusPage} = await import('@app/features/user/state/StatusPage'); + +const OFFICIAL = 'https://status.official.test'; +const SELF_HOSTED = 'https://status.selfhosted.test'; + +interface PendingSummary { + url: string; + resolve: (body: unknown) => void; +} + +const pending: Array = []; + +function incidentSummary(name: string): unknown { + return { + activeIncidents: [{id: name, name, status: 'INVESTIGATING', impact: 'MAJOROUTAGE', url: `${name}/i`}], + }; +} + +function setStatusPageUrl(url: string): void { + runInAction(() => { + runtime.state.statusPageUrl = url; + }); +} + +async function settle(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn( + (url: string) => + new Promise((resolve) => { + pending.push({ + url, + resolve: (body) => resolve({ok: true, json: () => Promise.resolve(body)}), + }); + }), + ), + ); + setStatusPageUrl(OFFICIAL); +}); + +afterEach(() => { + pending.splice(0); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('StatusPage instance switches', () => { + it('clears the previous instance incident when the next instance has no status page', async () => { + const store = new StatusPage(); + store.startPolling(); + pending.shift()?.resolve(incidentSummary('official')); + await settle(); + expect(store.incident?.name).toBe('official'); + setStatusPageUrl(''); + expect(store.incident).toBeNull(); + expect(store.scheduledMaintenance).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + store.stopPolling(); + }); + + it('drops a fetch for the previous instance that lands after the switch', async () => { + const store = new StatusPage(); + store.startPolling(); + const stale = pending.shift(); + expect(stale?.url).toBe(`${OFFICIAL}/summary.json`); + setStatusPageUrl(SELF_HOSTED); + const fresh = pending.shift(); + expect(fresh?.url).toBe(`${SELF_HOSTED}/summary.json`); + stale?.resolve(incidentSummary('official')); + await settle(); + expect(store.incident).toBeNull(); + fresh?.resolve(incidentSummary('selfhosted')); + await settle(); + expect(store.incident?.name).toBe('selfhosted'); + store.stopPolling(); + }); +}); diff --git a/fluxer_app/src/features/user/state/StatusPage.ts b/fluxer_app/src/features/user/state/StatusPage.ts index a5bb99766..1edf4f593 100644 --- a/fluxer_app/src/features/user/state/StatusPage.ts +++ b/fluxer_app/src/features/user/state/StatusPage.ts @@ -2,7 +2,7 @@ import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import {Logger} from '@app/features/platform/utils/AppLogger'; -import {makeAutoObservable, runInAction} from 'mobx'; +import {makeAutoObservable, reaction, runInAction} from 'mobx'; type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved'; type IncidentImpact = 'critical' | 'major' | 'minor' | 'maintenance'; @@ -90,7 +90,6 @@ const POLL_AFTER_MAINTENANCE_START_MS = 5 * 1000; const POLL_MIN_DELAY_MS = 10 * 1000; const POLL_RESUME_STALE_MS = 30 * 1000; const STATUS_PAGE_FETCH_TIMEOUT_MS = 10 * 1000; -const STATUS_PAGE_URL = RuntimeConfig.statusPageUrl; function statusPageFetchOptions(): RequestInit { return {cache: 'no-store', signal: AbortSignal.timeout(STATUS_PAGE_FETCH_TIMEOUT_MS)}; @@ -194,33 +193,71 @@ export class StatusPage { scheduledMaintenance: StatusPageMaintenance | null = null; pollTimerId: NodeJS.Timeout | null = null; private checkInFlight: Promise | null = null; + private checkInFlightUrl: string | null = null; private lastCheckedAt = 0; + private pollingRequested = false; private pollingStarted = false; constructor() { - makeAutoObservable( + makeAutoObservable< + StatusPage, + 'checkInFlight' | 'checkInFlightUrl' | 'lastCheckedAt' | 'pollingRequested' | 'pollingStarted' | 'pollTimerId' + >( this, { checkInFlight: false, + checkInFlightUrl: false, lastCheckedAt: false, + pollingRequested: false, pollingStarted: false, pollTimerId: false, }, {autoBind: true}, ); + reaction( + () => RuntimeConfig.statusPageUrl, + () => this.handleStatusPageUrlChange(), + ); + } + + private handleStatusPageUrlChange(): void { + this.incident = null; + this.scheduledMaintenance = null; + this.checkInFlight = null; + this.checkInFlightUrl = null; + this.lastCheckedAt = 0; + this.stopActivePolling(); + this.reconcilePolling(); } startPolling(): void { - if (!STATUS_PAGE_URL || this.pollingStarted) { - return; - } - - this.pollingStarted = true; - this.addResumeListeners(); - this.schedulePoll(); + this.pollingRequested = true; + this.reconcilePolling(); } stopPolling(): void { + this.pollingRequested = false; + this.stopActivePolling(); + } + + private reconcilePolling(): void { + if (!this.pollingRequested || !RuntimeConfig.statusPageUrl) { + this.stopActivePolling(); + return; + } + if (this.pollingStarted) { + return; + } + this.pollingStarted = true; + this.addResumeListeners(); + void this.checkIncidents(); + this.schedulePoll(); + } + + private stopActivePolling(): void { + if (!this.pollingStarted) { + return; + } this.pollingStarted = false; this.removeResumeListeners(); this.clearPollTimer(); @@ -242,24 +279,30 @@ export class StatusPage { } async checkIncidents(): Promise { - if (!STATUS_PAGE_URL) { + const statusPageUrl = RuntimeConfig.statusPageUrl; + if (!statusPageUrl) { return; } - if (this.checkInFlight) { + if (this.checkInFlight !== null && this.checkInFlightUrl === statusPageUrl) { return this.checkInFlight; } - this.checkInFlight = this.fetchIncidents(); + const pending = this.fetchIncidents(statusPageUrl); + this.checkInFlight = pending; + this.checkInFlightUrl = statusPageUrl; try { - await this.checkInFlight; + await pending; } finally { - this.lastCheckedAt = Date.now(); - this.checkInFlight = null; + if (this.checkInFlight === pending) { + this.lastCheckedAt = Date.now(); + this.checkInFlight = null; + this.checkInFlightUrl = null; + } } } - private async fetchIncidents(): Promise { + private async fetchIncidents(statusPageUrl: string): Promise { try { - const response = await fetch(`${STATUS_PAGE_URL}/summary.json`, statusPageFetchOptions()); + const response = await fetch(`${statusPageUrl}/summary.json`, statusPageFetchOptions()); if (!response.ok) { return; } @@ -267,9 +310,12 @@ export class StatusPage { const activeIncident = data.activeIncidents?.find((inc) => inc.status !== 'RESOLVED' && inc.resolved == null); let activeMaintenance = selectActiveStatusPageMaintenance(data.activeMaintenances); if (shouldFetchComponentMaintenances(data, activeMaintenance)) { - activeMaintenance = selectActiveStatusPageMaintenance(await this.fetchComponentMaintenances()); + activeMaintenance = selectActiveStatusPageMaintenance(await this.fetchComponentMaintenances(statusPageUrl)); } runInAction(() => { + if (statusPageUrl !== RuntimeConfig.statusPageUrl) { + return; + } if (activeIncident) { this.incident = { id: activeIncident.id, @@ -298,9 +344,9 @@ export class StatusPage { } } - private async fetchComponentMaintenances(): Promise> { + private async fetchComponentMaintenances(statusPageUrl: string): Promise> { try { - const response = await fetch(`${STATUS_PAGE_URL}/components.json`, statusPageFetchOptions()); + const response = await fetch(`${statusPageUrl}/components.json`, statusPageFetchOptions()); if (!response.ok) { return []; } diff --git a/fluxer_app/src/features/user/state/SyncedField.ts b/fluxer_app/src/features/user/state/SyncedField.ts index f53944f2e..0de7adf74 100644 --- a/fluxer_app/src/features/user/state/SyncedField.ts +++ b/fluxer_app/src/features/user/state/SyncedField.ts @@ -20,6 +20,34 @@ export {verifyRoundtripStability}; const logger = new Logger('SyncedField'); +interface SyncedFieldUserSettings { + isHydrated(): boolean; + getSubPreference(field: F): SyncedPreferences[F] | undefined; + setSubPreference(field: F, value: NonNullable): Promise; +} + +interface SyncedFieldSessionManager { + readonly userId: string | null; +} + +let announceUserSettings: (settings: SyncedFieldUserSettings) => void; +const userSettingsReady = new Promise((resolve) => { + announceUserSettings = resolve; +}); + +let announceSessionManager: (manager: SyncedFieldSessionManager) => void; +const sessionManagerReady = new Promise((resolve) => { + announceSessionManager = resolve; +}); + +export function setSyncedFieldUserSettings(settings: SyncedFieldUserSettings): void { + announceUserSettings(settings); +} + +export function setSyncedFieldSessionManager(manager: SyncedFieldSessionManager): void { + announceSessionManager(manager); +} + function isMessageSchema(value: unknown): value is GenMessage { return ( value != null && @@ -212,10 +240,8 @@ export async function makeSyncedField< transitionOnly(event); processCommands(); } - await Promise.resolve(); const maxEncodedBytes = config.maxEncodedBytes ?? DEFAULT_SYNCED_FIELD_MAX_ENCODED_BYTES; - const UserSettings = (await import('@app/features/user/state/UserSettings')).default; - const SessionManager = (await import('@app/features/platform/state/AuthSession')).default; + const [UserSettings, SessionManager] = await Promise.all([userSettingsReady, sessionManagerReady]); const applyDefaultsForUserChange = (userId: string): void => { try { runInAction(() => { diff --git a/fluxer_app/src/features/user/state/UserGuildSettings.ts b/fluxer_app/src/features/user/state/UserGuildSettings.ts index 2d61014e6..cd39dc875 100644 --- a/fluxer_app/src/features/user/state/UserGuildSettings.ts +++ b/fluxer_app/src/features/user/state/UserGuildSettings.ts @@ -575,6 +575,10 @@ class UserGuildSettings { this.notifyChange(); } + hydrateFromSnapshot(userGuildSettings: ReadonlyArray): void { + this.handleGatewayReady([...userGuildSettings]); + } + handleGuildSettingsUpdate(action: {guildId: string; settings: Partial}): void { this.updateGuildSettings(action.guildId, action.settings); } diff --git a/fluxer_app/src/features/user/state/UserProfile.ts b/fluxer_app/src/features/user/state/UserProfile.ts index f76be05c4..3d5922d05 100644 --- a/fluxer_app/src/features/user/state/UserProfile.ts +++ b/fluxer_app/src/features/user/state/UserProfile.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import Authentication from '@app/features/auth/state/Authentication'; +import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork'; import {Logger} from '@app/features/platform/utils/AppLogger'; import type {Profile} from '@app/features/user/models/Profile'; import {ME} from '@fluxer/constants/src/AppConstants'; @@ -148,4 +149,8 @@ class UserProfile { } } -export default new UserProfile(); +const userProfile = new UserProfile(); + +AccountScopedWork.registerCancellation(() => userProfile.handleGatewayReady()); + +export default userProfile; diff --git a/fluxer_app/src/features/user/state/UserSettings.ts b/fluxer_app/src/features/user/state/UserSettings.ts index aa92f5afa..8a24449b1 100644 --- a/fluxer_app/src/features/user/state/UserSettings.ts +++ b/fluxer_app/src/features/user/state/UserSettings.ts @@ -12,7 +12,8 @@ import { } from '@app/features/accessibility/state/MotionPreferencesMachine'; import {Endpoints} from '@app/features/app/constants/Endpoints'; import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils'; -import AppStorage from '@app/features/platform/state/PersistentStorage'; +import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork'; +import AppStorage, {flushAppStorageWrites} from '@app/features/platform/state/PersistentStorage'; import {http} from '@app/features/platform/transport/RestTransport'; import {HttpError} from '@app/features/platform/types/EndpointError'; import {Logger} from '@app/features/platform/utils/AppLogger'; @@ -24,6 +25,7 @@ import { normalizeCustomStatus, toApiCustomStatusPayload, } from '@app/features/user/state/CustomStatus'; +import {setSyncedFieldUserSettings} from '@app/features/user/state/SyncedField'; import { changedSyncedPreferenceFields, createEmptySyncedPreferences, @@ -57,6 +59,17 @@ import isPlainObject from 'lodash/isPlainObject'; import snakeCase from 'lodash/snakeCase'; import {makeAutoObservable, reaction, runInAction} from 'mobx'; +export interface UserSettingsAccountTransitionCheckpoint { + readonly accountEpoch: number; + readonly hydrated: boolean; + readonly syncedPreferences: SyncedPreferences; + readonly wireSyncedPreferences: SyncedPreferences; + readonly dirtySyncedPreferenceFields: ReadonlyArray; + readonly recentlyAckedSyncedPreferenceFields: ReadonlyArray; + readonly syncConsecutive429s: number; + readonly hadPendingFlush: boolean; +} + function restoreSettingValue(target: UserSettings, source: UserSettings, key: K): void { target[key] = source[key]; } @@ -118,6 +131,10 @@ const SYNCED_PREFERENCES_WIRE_KEY = 'UserSettings:syncedPreferencesWire'; const SYNCED_PREFERENCES_RECENT_ACK_KEY = 'UserSettings:syncedPreferencesRecentAck'; const RECENT_SYNCED_PREFERENCES_ACK_WINDOW_MS = 60_000; +function isAbortError(error: unknown): boolean { + return (error instanceof DOMException || error instanceof Error) && error.name === 'AbortError'; +} + function readDirtySyncedPreferenceFields(): Set { try { const raw = AppStorage.getJSON(SYNCED_PREFERENCES_DIRTY_FIELDS_KEY, []); @@ -308,6 +325,7 @@ class UserSettingsState { private inFlightSyncedPreferences: SyncedPreferences | null = null; private recentlyAckedSyncedPreferenceFields: Map = readPersistedRecentAck(); private accountEpoch = 0; + private syncFlushGeneration = 0; constructor() { makeAutoObservable< @@ -323,6 +341,7 @@ class UserSettingsState { | 'inFlightSyncedPreferences' | 'recentlyAckedSyncedPreferenceFields' | 'accountEpoch' + | 'syncFlushGeneration' >( this, { @@ -337,6 +356,7 @@ class UserSettingsState { inFlightSyncedPreferences: false, recentlyAckedSyncedPreferenceFields: false, accountEpoch: false, + syncFlushGeneration: false, }, {autoBind: true}, ); @@ -562,8 +582,73 @@ class UserSettingsState { this.hydrated = false; } + captureAccountTransitionCheckpoint(): UserSettingsAccountTransitionCheckpoint { + return { + accountEpoch: this.accountEpoch, + hydrated: this.hydrated, + syncedPreferences: cloneSyncedPreferences(this.syncedPreferences), + wireSyncedPreferences: cloneSyncedPreferences(this.wireSyncedPreferences), + dirtySyncedPreferenceFields: [...this.dirtySyncedPreferenceFields], + recentlyAckedSyncedPreferenceFields: [...this.recentlyAckedSyncedPreferenceFields.entries()], + syncConsecutive429s: this.syncConsecutive429s, + hadPendingFlush: + this.syncFlushTimer !== null || + this.syncFlushInFlight || + this.syncFlushPendingPromise !== null || + this.syncFlushPendingResolvers.length > 0, + }; + } + + async restoreAccountTransitionCheckpoint(checkpoint: UserSettingsAccountTransitionCheckpoint): Promise { + this.syncFlushGeneration += 1; + if (this.syncFlushTimer != null && this.syncFlushTimer !== 'microtask') { + clearTimeout(this.syncFlushTimer); + } + const resolvers = this.syncFlushPendingResolvers; + runInAction(() => { + this.accountEpoch = Math.max(this.accountEpoch, checkpoint.accountEpoch); + this.hydrated = checkpoint.hydrated; + this.syncedPreferences = cloneSyncedPreferences(checkpoint.syncedPreferences); + this.wireSyncedPreferences = cloneSyncedPreferences(checkpoint.wireSyncedPreferences); + this.dirtySyncedPreferenceFields = new Set(checkpoint.dirtySyncedPreferenceFields); + this.recentlyAckedSyncedPreferenceFields = new Map(checkpoint.recentlyAckedSyncedPreferenceFields); + this.syncConsecutive429s = checkpoint.syncConsecutive429s; + this.syncFlushTimer = null; + this.syncFlushInFlight = false; + this.inFlightSyncedPreferenceFields.clear(); + this.inFlightSyncedPreferences = null; + this.syncFlushPendingResolvers = []; + this.syncFlushPendingPromise = null; + }); + for (const resolver of resolvers) { + resolver.resolve(); + } + this.persistLocalSyncedPreferences(); + this.persistWireSyncedPreferences(); + writeDirtySyncedPreferenceFields(this.dirtySyncedPreferenceFields); + writePersistedRecentAck(this.recentlyAckedSyncedPreferenceFields); + await flushAppStorageWrites(); + if ( + checkpoint.hydrated && + (checkpoint.hadPendingFlush || + this.dirtySyncedPreferenceFields.size > 0 || + changedSyncedPreferenceFields(this.syncedPreferences, this.wireSyncedPreferences).length > 0) + ) { + void this.scheduleSyncedPreferencesFlush(); + } + LocalPresence.updatePresence(); + } + handleAccountTransition(): void { + const unsyncedFields = new Set([ + ...this.dirtySyncedPreferenceFields, + ...changedSyncedPreferenceFields(this.syncedPreferences, this.wireSyncedPreferences), + ]); + if (unsyncedFields.size > 0) { + logger.info(`Clearing ${unsyncedFields.size} unsynced synced preference field(s) left by the previous account`); + } this.accountEpoch += 1; + this.syncFlushGeneration += 1; this.hydrated = false; this.syncedPreferences = createEmptySyncedPreferences(); this.wireSyncedPreferences = createEmptySyncedPreferences(); @@ -579,6 +664,7 @@ class UserSettingsState { clearTimeout(this.syncFlushTimer); } this.syncFlushTimer = null; + this.syncFlushInFlight = false; this.syncConsecutive429s = 0; const resolvers = this.syncFlushPendingResolvers; this.syncFlushPendingResolvers = []; @@ -934,8 +1020,19 @@ class UserSettingsState { return this.syncFlushPendingPromise; } + resumeSyncedPreferencesFlush(): void { + if (!this.hydrated || this.syncFlushInFlight || this.syncFlushTimer != null) return; + if ( + this.syncFlushPendingResolvers.length > 0 || + this.dirtySyncedPreferenceFields.size > 0 || + changedSyncedPreferenceFields(this.syncedPreferences, this.wireSyncedPreferences).length > 0 + ) { + void this.scheduleSyncedPreferencesFlush(); + } + } + private async runSyncedPreferencesFlush(): Promise { - if (this.syncFlushInFlight) return; + if (this.syncFlushInFlight || AccountScopedWork.isSuspended) return; const resolvers = this.syncFlushPendingResolvers; this.syncFlushPendingResolvers = []; const pendingPromise = this.syncFlushPendingPromise; @@ -962,6 +1059,7 @@ class UserSettingsState { ); this.syncFlushInFlight = true; const epochAtFlush = this.accountEpoch; + const generationAtFlush = ++this.syncFlushGeneration; const snapshotAtFlush = cloneSyncedPreferences(this.syncedPreferences); this.inFlightSyncedPreferenceFields = new Set(fieldsInRequest); this.inFlightSyncedPreferences = cloneSyncedPreferences(snapshotAtFlush); @@ -970,8 +1068,7 @@ class UserSettingsState { const response = await http.patch(Endpoints.USER_SETTINGS, { body: {synced_preferences: encoded === '' ? null : encoded}, }); - if (this.accountEpoch !== epochAtFlush) { - this.syncFlushInFlight = false; + if (this.accountEpoch !== epochAtFlush || this.syncFlushGeneration !== generationAtFlush) { completeAll(); return; } @@ -1010,13 +1107,13 @@ class UserSettingsState { void this.scheduleSyncedPreferencesFlush(); } } catch (error) { - this.syncFlushInFlight = false; - this.inFlightSyncedPreferenceFields.clear(); - this.inFlightSyncedPreferences = null; - if (this.accountEpoch !== epochAtFlush) { + if (this.accountEpoch !== epochAtFlush || this.syncFlushGeneration !== generationAtFlush) { completeAll(); return; } + this.syncFlushInFlight = false; + this.inFlightSyncedPreferenceFields.clear(); + this.inFlightSyncedPreferences = null; if (this.isRateLimitError(error)) { this.syncConsecutive429s += 1; const retryAfterMs = this.syncRetryDelayMs(error); @@ -1034,6 +1131,17 @@ class UserSettingsState { }, retryAfterMs); return; } + if (isAbortError(error)) { + logger.debug('Synced preferences save was cancelled and will retry once account work resumes'); + if (this.syncFlushPendingPromise == null) { + this.syncFlushPendingPromise = pendingPromise ?? new Promise(() => undefined); + } + this.syncFlushPendingResolvers.unshift(...resolvers); + if (!AccountScopedWork.isSuspended) { + void this.scheduleSyncedPreferencesFlush(); + } + return; + } logger.error('Failed to save synced preferences:', error); completeAll(error); } @@ -1103,5 +1211,11 @@ class UserSettingsState { const UserSettings = new UserSettingsState(); setLocalPresenceUserSettings(UserSettings); +setSyncedFieldUserSettings(UserSettings); +AccountScopedWork.registerTransition({ + suspend: () => {}, + resume: () => {}, + released: () => UserSettings.resumeSyncedPreferencesFlush(), +}); export default UserSettings; diff --git a/fluxer_app/src/features/user/state/UserSettingsTransitionFlush.test.ts b/fluxer_app/src/features/user/state/UserSettingsTransitionFlush.test.ts new file mode 100644 index 000000000..23c63c516 --- /dev/null +++ b/fluxer_app/src/features/user/state/UserSettingsTransitionFlush.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {afterEach, describe, expect, it, vi} from 'vitest'; + +const mocks = vi.hoisted(() => ({patch: vi.fn()})); + +vi.mock('@lingui/core/macro', () => ({ + msg: (descriptor: unknown) => descriptor, + t: (descriptor: unknown) => descriptor, +})); + +vi.mock('@app/features/platform/transport/RestTransport', () => ({ + http: {get: vi.fn(), post: vi.fn(), patch: mocks.patch}, +})); + +const {AccountScopedWork, AccountScopedWorkTransitionReason, accountScopedWorkAbortError} = await import( + '@app/features/platform/state/AccountScopedWork' +); +const {default: UserSettings} = await import('@app/features/user/state/UserSettings'); + +async function settle(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function hydrate(): void { + UserSettings.updateUserSettings({ + status: 'online', + flags: 0, + locale: 'en-US', + developer_mode: false, + message_display_compact: false, + restricted_guilds: [], + bot_restricted_guilds: [], + guild_folders: [], + trusted_domains: [], + staff_dm_access_user_ids: [], + suppress_unprivileged_self_mentions_bypass_user_ids: [], + }); +} + +describe('UserSettings synced preference flush across account transitions', () => { + afterEach(() => { + UserSettings.handleAccountTransition(); + mocks.patch.mockReset(); + }); + + it('holds a flush requested during a transition until admission opens', async () => { + hydrate(); + mocks.patch.mockResolvedValue({ok: true, status: 200, body: {}}); + let saved: Promise | null = null; + await AccountScopedWork.runSuspended(AccountScopedWorkTransitionReason.ACCOUNT_SWITCH, async () => { + saved = UserSettings.setSanitizeUrls(!UserSettings.getSanitizeUrls()); + await settle(); + expect(mocks.patch).not.toHaveBeenCalled(); + }); + await settle(); + expect(mocks.patch).toHaveBeenCalledTimes(1); + await expect(saved).resolves.toBeUndefined(); + }); + + it('retries a flush aborted by an account transition instead of failing it', async () => { + hydrate(); + mocks.patch.mockRejectedValueOnce(accountScopedWorkAbortError()); + mocks.patch.mockResolvedValue({ok: true, status: 200, body: {}}); + const saved = UserSettings.setSanitizeUrls(!UserSettings.getSanitizeUrls()); + await settle(); + expect(mocks.patch).toHaveBeenCalledTimes(2); + await expect(saved).resolves.toBeUndefined(); + }); +}); diff --git a/fluxer_app/src/features/user/state/Users.ts b/fluxer_app/src/features/user/state/Users.ts index 1787ed89d..c8642c12c 100644 --- a/fluxer_app/src/features/user/state/Users.ts +++ b/fluxer_app/src/features/user/state/Users.ts @@ -1,10 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import Authentication from '@app/features/auth/state/Authentication'; +import {ResettableStates} from '@app/features/app/state/ResettableStates'; +import SessionManager from '@app/features/platform/state/AuthSession'; import {User} from '@app/features/user/models/User'; import {shouldShowDiscriminator} from '@app/features/user/utils/UserTagUtils'; import type {UserPrivate, User as WireUser} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; -import {makeAutoObservable, reaction, runInAction} from 'mobx'; +import {makeAutoObservable, observableRef, reaction, runInAction} from 'mobx'; const CURRENT_USER_PRIVATE_WIRE_KEYS = [ 'is_staff', @@ -49,24 +50,45 @@ function isPublicOnlyCurrentUserPayload(user: WireUser): boolean { return !CURRENT_USER_PRIVATE_WIRE_KEYS.some((key) => key in user); } +class CurrentUserHydrationMismatchError extends Error { + constructor(accountKey: string, userId: string) { + super(`Cannot hydrate user ${userId} outside its active account ${accountKey}`); + this.name = 'CurrentUserHydrationMismatchError'; + } +} + class Users { users: Record = {}; userCount = 0; + viewAccountKey: string | null = null; + private hydratedAccountKey: string | null = null; constructor() { - makeAutoObservable(this, {}, {autoBind: true}); + makeAutoObservable(this, {hydratedAccountKey: observableRef}, {autoBind: true}); } get currentUser(): User | null { - const currentUserId = Authentication.userId; - if (!currentUserId) { + const currentAccountKey = SessionManager.currentAccountKey; + const currentUserId = SessionManager.userId; + if (currentAccountKey === null || currentUserId === null || this.hydratedAccountKey !== currentAccountKey) { return null; } return this.users[currentUserId] ?? null; } get currentUserId(): string | null { - return Authentication.userId; + return SessionManager.userId; + } + + get isCurrentUserHydrated(): boolean { + const currentAccountKey = SessionManager.currentAccountKey; + const currentUserId = SessionManager.userId; + return ( + currentAccountKey !== null && + currentUserId !== null && + this.hydratedAccountKey === currentAccountKey && + this.users[currentUserId] !== undefined + ); } get usersList(): ReadonlyArray { @@ -74,6 +96,9 @@ class Users { } getUser(userId: string): User | undefined { + if (userId === this.currentUserId) { + return this.currentUser ?? undefined; + } return this.users[userId]; } @@ -95,20 +120,51 @@ class Users { return this.usersList; } - handleGatewayReady(currentUser: UserPrivate): void { + handleGatewayReady(accountKey: string, currentUser: UserPrivate): void { + this.assertCurrentAccountIdentity(accountKey, currentUser.id); const userRecord = new User(currentUser); this.users = { [currentUser.id]: userRecord, }; this.userCount = 1; + this.hydratedAccountKey = accountKey; + this.viewAccountKey = accountKey; if (!userRecord.isClaimed()) { setTimeout(async () => { + if (!this.isActiveUnclaimedAccount(accountKey, currentUser.id)) { + return; + } const {openClaimAccountModal} = await import('@app/features/auth/components/modals/ClaimAccountModal'); + if (!this.isActiveUnclaimedAccount(accountKey, currentUser.id)) { + return; + } openClaimAccountModal(); }, 1000); } } + hydrateFromSnapshot(accountKey: string, currentUser: UserPrivate, users: ReadonlyArray): void { + this.assertCurrentAccountIdentity(accountKey, currentUser.id); + const hydratedUsers: Record = { + [currentUser.id]: new User(currentUser), + }; + for (const user of users) { + if (user.id !== currentUser.id) { + hydratedUsers[user.id] = new User(user); + } + } + this.users = hydratedUsers; + this.userCount = Object.keys(hydratedUsers).length; + this.hydratedAccountKey = accountKey; + this.viewAccountKey = accountKey; + } + + resetAccountState(): void { + this.users = {}; + this.userCount = 0; + this.hydratedAccountKey = null; + } + handleUserUpdate( user: WireUser, options?: { @@ -163,6 +219,25 @@ class Users { {fireImmediately: true}, ); } + + private assertCurrentAccountIdentity(accountKey: string, userId: string): void { + if (SessionManager.currentAccountKey !== accountKey || SessionManager.userId !== userId) { + throw new CurrentUserHydrationMismatchError(accountKey, userId); + } + } + + private isActiveUnclaimedAccount(accountKey: string, userId: string): boolean { + const currentUser = this.currentUser; + return ( + this.hydratedAccountKey === accountKey && + SessionManager.currentAccountKey === accountKey && + currentUser?.id === userId && + !currentUser.isClaimed() + ); + } } -export default new Users(); +const users = new Users(); +ResettableStates.register(users, users.resetAccountState); + +export default users; diff --git a/fluxer_app/src/features/user/utils/AvatarUtils.ts b/fluxer_app/src/features/user/utils/AvatarUtils.ts index 2567c7a59..8d47ec0ab 100644 --- a/fluxer_app/src/features/user/utils/AvatarUtils.ts +++ b/fluxer_app/src/features/user/utils/AvatarUtils.ts @@ -8,7 +8,7 @@ import { MEDIA_PROXY_IMAGE_SIZE_LADDER, snapMediaProxyImageSize, } from '@app/features/messaging/utils/MediaProxyUtils'; -import {cdnUrl, mediaUrl, setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils'; +import {mediaUrl, setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils'; import type {User} from '@app/features/user/models/User'; import { getDefaultAvatarIndex, @@ -16,6 +16,7 @@ import { normalizeEndpoint, parseAvatarHash, } from '@app/features/user/utils/AvatarMediaUtils'; +import {getDefaultAvatarAssetURL} from '@app/features/user/utils/DefaultAvatars'; import { MEDIA_PROXY_AVATAR_SIZE_DEFAULT, MEDIA_PROXY_ICON_SIZE_DEFAULT, @@ -72,10 +73,7 @@ const getViewportSplashSize = (): MediaProxyImageSize => { return snapWideAssetImageSize(cssWidth); }; -const DEFAULT_AVATAR_ASSET_VERSION = '1'; - -export const getDefaultAvatarURLForIndex = (index: number): string => - cdnUrl(`avatars/${index}.png?v=${DEFAULT_AVATAR_ASSET_VERSION}`); +export const getDefaultAvatarURLForIndex = (index: number): string => getDefaultAvatarAssetURL(index); export function getDefaultAvatarPrimaryColor(id: string) { return getSharedDefaultAvatarPrimaryColor(id); @@ -190,6 +188,25 @@ export function getUserNotificationAvatarURL( }); } +export function getUserNotificationAvatarURLForEndpoint( + {id, avatar}: AvatarOptions, + endpoint: string, + size: MediaProxyImageSize = MEDIA_PROXY_AVATAR_SIZE_DEFAULT, +) { + if (!avatar) { + return getDefaultAvatarURLForIndex(getDefaultAvatarIndex(id)); + } + const {hash, animated} = parseMediaHashForRequest(avatar, false); + return buildPngMediaUrl({ + path: 'avatars', + id, + hash, + size: snapIconImageSize(size), + animated, + endpoint, + }); +} + export function getGuildMemberNotificationAvatarURL({ guildId, userId, diff --git a/fluxer_app/src/features/user/utils/DefaultAvatars.ts b/fluxer_app/src/features/user/utils/DefaultAvatars.ts new file mode 100644 index 000000000..38e61006a --- /dev/null +++ b/fluxer_app/src/features/user/utils/DefaultAvatars.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import avatar0 from '@app/media/images/avatars/0.png'; +import avatar1 from '@app/media/images/avatars/1.png'; +import avatar2 from '@app/media/images/avatars/2.png'; +import avatar3 from '@app/media/images/avatars/3.png'; +import avatar4 from '@app/media/images/avatars/4.png'; +import avatar5 from '@app/media/images/avatars/5.png'; + +const DEFAULT_AVATARS: ReadonlyArray = [avatar0, avatar1, avatar2, avatar3, avatar4, avatar5]; + +const DEFAULT_AVATAR_COUNT = DEFAULT_AVATARS.length; + +export function getDefaultAvatarAssetURL(index: number): string { + return DEFAULT_AVATARS[((index % DEFAULT_AVATAR_COUNT) + DEFAULT_AVATAR_COUNT) % DEFAULT_AVATAR_COUNT]; +} diff --git a/fluxer_app/src/features/voice/VoiceAccountLifecycle.ts b/fluxer_app/src/features/voice/VoiceAccountLifecycle.ts new file mode 100644 index 000000000..a7ab9600d --- /dev/null +++ b/fluxer_app/src/features/voice/VoiceAccountLifecycle.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; +import VoiceSessionRestore, {type VoiceSessionRestoreSnapshot} from '@app/features/voice/state/VoiceSessionRestore'; +import { + VoiceAccountExitReason, + type VoiceAccountExitRequest, + type VoiceAccountIdentity, +} from '@app/features/voice/VoiceAccountLifecyclePort'; + +function snapshotToRestoreAfterExit( + request: VoiceAccountExitRequest, +): Omit | null { + if (request.reason !== VoiceAccountExitReason.ACCOUNT_SWITCH) { + return null; + } + const snapshot = VoiceSessionRestore.getSnapshotForUser(request.userId); + if (snapshot === null) { + return null; + } + return { + userId: snapshot.userId, + guildId: snapshot.guildId, + channelId: snapshot.channelId, + selfVideo: snapshot.selfVideo, + selfStream: snapshot.selfStream, + }; +} + +export async function leaveVoiceChannelForAccountExit(request: VoiceAccountExitRequest): Promise { + const retained = snapshotToRestoreAfterExit(request); + await MediaEngine.disconnectFromVoiceChannel('user'); + if (retained !== null) { + VoiceSessionRestore.saveSnapshot(retained); + } +} + +export async function suspendVoiceForAccountRestriction(_account: VoiceAccountIdentity): Promise { + await MediaEngine.disconnectFromVoiceChannel('server'); +} diff --git a/fluxer_app/src/features/voice/VoiceAccountLifecyclePort.ts b/fluxer_app/src/features/voice/VoiceAccountLifecyclePort.ts new file mode 100644 index 000000000..f0e580756 --- /dev/null +++ b/fluxer_app/src/features/voice/VoiceAccountLifecyclePort.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +export const VoiceAccountExitReason = Object.freeze({ + ACCOUNT_SWITCH: 'account-switch', + LOGOUT: 'logout', + ACCOUNT_REMOVED: 'account-removed', +} as const); + +export type VoiceAccountExitReason = (typeof VoiceAccountExitReason)[keyof typeof VoiceAccountExitReason]; + +export interface VoiceAccountIdentity { + readonly accountKey: string; + readonly userId: string; +} + +export interface VoiceAccountExitRequest extends VoiceAccountIdentity { + readonly reason: VoiceAccountExitReason; +} + +export interface VoiceAccountLifecycle { + leaveVoiceChannelForAccountExit(request: VoiceAccountExitRequest): Promise; + suspendVoiceForAccountRestriction(account: VoiceAccountIdentity): Promise; +} + +function loadVoiceAccountLifecycle(): Promise { + return import('@app/features/voice/VoiceAccountLifecycle'); +} + +export const voiceAccountLifecycle: VoiceAccountLifecycle = { + async leaveVoiceChannelForAccountExit(request) { + const lifecycle = await loadVoiceAccountLifecycle(); + await lifecycle.leaveVoiceChannelForAccountExit(request); + }, + async suspendVoiceForAccountRestriction(account) { + const lifecycle = await loadVoiceAccountLifecycle(); + await lifecycle.suspendVoiceForAccountRestriction(account); + }, +}; diff --git a/fluxer_app/src/features/voice/VoiceGatewayLifecycle.ts b/fluxer_app/src/features/voice/VoiceGatewayLifecycle.ts new file mode 100644 index 000000000..319689082 --- /dev/null +++ b/fluxer_app/src/features/voice/VoiceGatewayLifecycle.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {GatewayErrorData} from '@app/features/gateway/transport/GatewaySocket'; +import type {GuildReadyData} from '@app/features/gateway/types/GatewayGuildTypes'; +import MediaEngine from '@app/features/voice/engine/MediaEngineFacade'; + +export function seedVoiceStatesFromReady(guilds: Array): void { + MediaEngine.handleGatewayReady(guilds); +} + +export function reportGatewayErrorToVoice(error: GatewayErrorData): void { + MediaEngine.handleGatewayError(error); +} + +export function teardownVoiceForFatalGatewayCrash(): void { + MediaEngine.cleanup(); +} diff --git a/fluxer_app/src/features/voice/components/VoiceSettingsMenus.tsx b/fluxer_app/src/features/voice/components/VoiceSettingsMenus.tsx index 9c56856a4..4827f7f2b 100644 --- a/fluxer_app/src/features/voice/components/VoiceSettingsMenus.tsx +++ b/fluxer_app/src/features/voice/components/VoiceSettingsMenus.tsx @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import * as VoiceStateCommands from '@app/features/devtools/commands/VoiceStateCommands'; import {CAMERA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import Keybind from '@app/features/input/state/InputKeybind'; @@ -15,7 +16,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting'; import {AudioLevelMeter} from '@app/features/user/components/modals/tabs/components/AudioLevelMeter'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import Users from '@app/features/user/state/Users'; import * as CallCommands from '@app/features/voice/commands/CallCommands'; import * as VoiceCallLayoutCommands from '@app/features/voice/commands/VoiceCallLayoutCommands'; @@ -125,13 +125,16 @@ type VoiceVideoSettingsSection = 'audio' | 'video'; export function openVoiceVideoSettings(onClose: () => void, section?: VoiceVideoSettingsSection): void { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); } @@ -875,12 +878,15 @@ export const VoiceMoreOptionsMenu: React.FC = observe onClick={() => { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }} data-flx="voice.voice-settings-menus.voice-more-options-menu.menu-item.close" diff --git a/fluxer_app/src/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.tsx b/fluxer_app/src/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.tsx index 8d364ca9d..82ea29c9b 100644 --- a/fluxer_app/src/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.tsx +++ b/fluxer_app/src/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Routes} from '@app/app/Routes'; +import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import {useCallHeaderState} from '@app/features/channel/components/channel_view/useCallHeaderState'; import type {Channel} from '@app/features/channel/models/Channel'; import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils'; @@ -29,7 +30,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import * as CallCommands from '@app/features/voice/commands/CallCommands'; import styles from '@app/features/voice/components/bottomsheets/DirectCallLobbyBottomSheet.module.css'; import {CompactVoiceCallView} from '@app/features/voice/components/CompactVoiceCallView'; @@ -192,12 +192,15 @@ export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBotto const handleOpenVoiceSettings = useCallback(() => { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }, [onClose]); const handleOpenCallView = useCallback(() => { diff --git a/fluxer_app/src/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.tsx b/fluxer_app/src/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.tsx index 9b86efa74..616ff8efb 100644 --- a/fluxer_app/src/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.tsx +++ b/fluxer_app/src/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Routes} from '@app/app/Routes'; +import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import type {Channel} from '@app/features/channel/models/Channel'; import * as VoiceStateCommands from '@app/features/devtools/commands/VoiceStateCommands'; import type {Guild} from '@app/features/guild/models/Guild'; @@ -24,7 +25,6 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import MobileLayout from '@app/features/ui/state/MobileLayout'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import styles from '@app/features/voice/components/bottomsheets/VoiceLobbyBottomSheet.module.css'; import {CameraPreviewModalInRoom} from '@app/features/voice/components/modals/CameraPreviewModal'; import { @@ -162,12 +162,15 @@ export const VoiceLobbyBottomSheet = observer(function VoiceLobbyBottomSheet({ const handleOpenVoiceSettings = () => { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }; const handleEnterCall = () => { diff --git a/fluxer_app/src/features/voice/components/bottomsheets/VoiceSettingsBottomSheets.tsx b/fluxer_app/src/features/voice/components/bottomsheets/VoiceSettingsBottomSheets.tsx index 6681b84b7..f8719dbc9 100644 --- a/fluxer_app/src/features/voice/components/bottomsheets/VoiceSettingsBottomSheets.tsx +++ b/fluxer_app/src/features/voice/components/bottomsheets/VoiceSettingsBottomSheets.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import sharedStyles from '@app/features/app/components/bottomsheets/shared.module.css'; +import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals'; import * as VoiceStateCommands from '@app/features/devtools/commands/VoiceStateCommands'; import { DeafenIcon, @@ -16,7 +17,6 @@ import {modal} from '@app/features/ui/commands/ModalCommands'; import type {MenuGroupType, MenuSheetItem} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet'; import {MenuBottomSheet} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet'; import {formatRoundedPercentage} from '@app/features/ui/utils/PercentageFormatting'; -import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal'; import * as VoiceCallLayoutCommands from '@app/features/voice/commands/VoiceCallLayoutCommands'; import * as VoiceSettingsCommands from '@app/features/voice/commands/VoiceSettingsCommands'; import {CameraPreviewModalInRoom} from '@app/features/voice/components/modals/CameraPreviewModal'; @@ -109,12 +109,15 @@ export const VoiceAudioSettingsBottomSheet: React.FC { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }; const menuGroups: Array = []; @@ -130,12 +133,15 @@ export const VoiceAudioSettingsBottomSheet: React.FC { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }, }, @@ -150,12 +156,15 @@ export const VoiceAudioSettingsBottomSheet: React.FC { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }, }, @@ -262,12 +271,15 @@ export const VoiceCameraSettingsBottomSheet: React.FC { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }; const menuGroups: Array = []; @@ -283,12 +295,15 @@ export const VoiceCameraSettingsBottomSheet: React.FC { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }, }, @@ -406,12 +421,15 @@ export const VoiceMoreOptionsBottomSheet: React.FC { ModalCommands.pushAfterBottomSheetClose( onClose, - modal(() => ( - - )), + modal( + () => ( + + ), + 'user-settings', + ), ); }, }, diff --git a/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionPrompt.tsx b/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionPrompt.tsx index d602e3782..a2cc069d1 100644 --- a/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionPrompt.tsx +++ b/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionPrompt.tsx @@ -1,15 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import { - MACOS_PRIVACY_AND_SECURITY_SETTINGS_NAME, - MACOS_SCREEN_RECORDING_PERMISSION_NAME, - MACOS_SYSTEM_SETTINGS_NAME, - PRODUCT_NAME, -} from '@app/features/app/config/I18nDisplayConstants'; -import {OPEN_SETTINGS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; + OPEN_SETTINGS_DESCRIPTOR, + RELAUNCH_TO_APPLY_DESCRIPTOR, +} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {Button} from '@app/features/ui/button/Button'; import {Spinner} from '@app/features/ui/components/Spinner'; +import {relaunchDesktopApp} from '@app/features/ui/utils/DesktopWindowBehaviorUtils'; import styles from '@app/features/voice/components/modals/ScreenSharePickerModal.module.css'; import type {ScreenSharePickerDisplayPermissionPrompt as DisplayPermissionPrompt} from '@app/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionStateMachine'; import {msg} from '@lingui/core/macro'; @@ -25,21 +24,20 @@ const CHECKING_SCREEN_RECORDING_PERMISSION_DESCRIPTOR = msg({ comment: 'Loading state in the screen-share picker while checking macOS screen recording permission.', }); const SCREEN_RECORDING_PERMISSION_PROMPT_DESCRIPTOR = msg({ - message: - 'Open {macosSystemSettingsName} → {macosPrivacyAndSecuritySettingsName} → {macosScreenRecordingPermissionName}, then allow {productName}. If {productName2} is already enabled, fully quit and restart {productName3} so macOS applies the permission.', + message: 'Open System Settings → Privacy & Security → Screen Recording, then allow {productName}.', comment: - 'Body in the screen-share picker when macOS screen recording permission is missing. Placeholders are product and macOS UI names.', + 'Body in the screen-share picker when macOS screen recording permission is missing. {productName} is the app name. Keep "System Settings", "Privacy & Security" and the permission name as macOS shows them in this language.', }); -const SCREEN_RECORDING_PERMISSION_RESTART_REQUIRED_DESCRIPTOR = msg({ - message: 'Restart {productName} to use screen sharing', +const SCREEN_RECORDING_PERMISSION_STILL_WAITING_DESCRIPTOR = msg({ + message: 'Still waiting for screen recording access', comment: - 'Heading in the screen-share picker after the user has opened macOS screen recording settings. The app must restart before the permission applies.', + 'Heading in the screen-share picker after the user has opened macOS screen recording settings and the permission is not active yet.', }); -const SCREEN_RECORDING_PERMISSION_RESTART_PROMPT_DESCRIPTOR = msg({ +const SCREEN_RECORDING_PERMISSION_RELAUNCH_PROMPT_DESCRIPTOR = msg({ message: - 'After changing {macosScreenRecordingPermissionName}, fully quit and restart {productName} so macOS applies the permission.', + 'Allow {productName} under Screen Recording in System Settings. If it is already allowed, relaunch {productName2} so macOS applies it.', comment: - 'Body in the screen-share picker after the user has opened macOS screen recording settings. macOS does not apply screen recording permission to a running app.', + 'Body in the screen-share picker after the user has opened macOS screen recording settings and the permission is not active yet. {productName} and {productName2} are the app name. Keep "Screen Recording" and "System Settings" as macOS shows them in this language.', }); export function ScreenSharePickerDisplayPermissionPrompt({ @@ -76,22 +74,17 @@ export function ScreenSharePickerDisplayPermissionPrompt({ data-flx="voice.screen-share-picker-modal.screen-recording-permission.heading" > {restartRequired - ? i18n._(SCREEN_RECORDING_PERMISSION_RESTART_REQUIRED_DESCRIPTOR, {productName: PRODUCT_NAME}) + ? i18n._(SCREEN_RECORDING_PERMISSION_STILL_WAITING_DESCRIPTOR) : i18n._(SCREEN_RECORDING_PERMISSION_REQUIRED_DESCRIPTOR)}
    {restartRequired - ? i18n._(SCREEN_RECORDING_PERMISSION_RESTART_PROMPT_DESCRIPTOR, { - macosScreenRecordingPermissionName: MACOS_SCREEN_RECORDING_PERMISSION_NAME, - productName: PRODUCT_NAME, - }) - : i18n._(SCREEN_RECORDING_PERMISSION_PROMPT_DESCRIPTOR, { - macosSystemSettingsName: MACOS_SYSTEM_SETTINGS_NAME, - macosPrivacyAndSecuritySettingsName: MACOS_PRIVACY_AND_SECURITY_SETTINGS_NAME, - macosScreenRecordingPermissionName: MACOS_SCREEN_RECORDING_PERMISSION_NAME, + ? i18n._(SCREEN_RECORDING_PERMISSION_RELAUNCH_PROMPT_DESCRIPTOR, { productName: PRODUCT_NAME, productName2: PRODUCT_NAME, - productName3: PRODUCT_NAME, + }) + : i18n._(SCREEN_RECORDING_PERMISSION_PROMPT_DESCRIPTOR, { + productName: PRODUCT_NAME, })}
    {i18n._(OPEN_SETTINGS_DESCRIPTOR)} + {restartRequired && ( + + )}
    ); diff --git a/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionStateMachine.test.ts b/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionStateMachine.test.ts new file mode 100644 index 000000000..495329dc5 --- /dev/null +++ b/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionStateMachine.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + createScreenSharePickerDisplayPermissionSnapshot, + type ScreenSharePickerDisplayPermissionEvent, + selectScreenSharePickerDisplayPermissionPrompt, + transitionScreenSharePickerDisplayPermissionSnapshot, +} from '@app/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionStateMachine'; +import {describe, expect, test} from 'vitest'; + +function promptAfter(events: ReadonlyArray): string { + let snapshot = createScreenSharePickerDisplayPermissionSnapshot(); + for (const event of events) { + snapshot = transitionScreenSharePickerDisplayPermissionSnapshot(snapshot, event); + } + return selectScreenSharePickerDisplayPermissionPrompt(snapshot); +} + +const BLOCKED_THEN_SETTINGS: ReadonlyArray = [ + {type: 'permission.check'}, + {type: 'permission.result', permission: 'denied'}, + {type: 'permission.settingsOpened'}, +]; + +describe('screen share picker display permission', () => { + test('a grant made in System Settings unblocks the picker without a restart', () => { + expect(promptAfter(BLOCKED_THEN_SETTINGS)).toBe('restart-required'); + expect(promptAfter([...BLOCKED_THEN_SETTINGS, {type: 'permission.result', permission: 'granted'}])).toBe('none'); + expect( + promptAfter([ + ...BLOCKED_THEN_SETTINGS, + {type: 'permission.check'}, + {type: 'permission.result', permission: 'granted'}, + ]), + ).toBe('none'); + }); + + test('a permission that still reads as denied after opening settings keeps offering the relaunch', () => { + expect(promptAfter([...BLOCKED_THEN_SETTINGS, {type: 'permission.result', permission: 'denied'}])).toBe( + 'restart-required', + ); + expect( + promptAfter([ + ...BLOCKED_THEN_SETTINGS, + {type: 'permission.check'}, + {type: 'permission.result', permission: 'denied'}, + ]), + ).toBe('restart-required'); + }); + + test('a permission that was never asked for blocks the picker until it is granted', () => { + expect(promptAfter([{type: 'permission.check'}, {type: 'permission.result', permission: 'not-determined'}])).toBe( + 'needs-permission', + ); + }); +}); diff --git a/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionStateMachine.ts b/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionStateMachine.ts index 82595496e..ec1100430 100644 --- a/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionStateMachine.ts +++ b/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/ScreenSharePickerDisplayPermissionStateMachine.ts @@ -57,14 +57,14 @@ export const screenSharePickerDisplayPermissionStateMachine = setup({ checking: { on: { 'permission.result': [ - { - guard: 'settingsAlreadyOpened', - target: 'restartRequired', - }, { guard: 'permissionAllowsDisplaySources', target: 'ready', }, + { + guard: 'settingsAlreadyOpened', + target: 'restartRequired', + }, {target: 'blocked'}, ], 'permission.clear': {target: 'idle', actions: 'clear'}, @@ -94,7 +94,13 @@ export const screenSharePickerDisplayPermissionStateMachine = setup({ restartRequired: { on: { 'permission.check': {target: 'checking'}, - 'permission.result': {target: 'restartRequired'}, + 'permission.result': [ + { + guard: 'permissionAllowsDisplaySources', + target: 'ready', + }, + {target: 'restartRequired'}, + ], 'permission.settingsOpened': {actions: 'markSettingsOpened'}, 'permission.clear': {target: 'idle', actions: 'clear'}, }, diff --git a/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/useScreenSharePickerDisplayPermission.ts b/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/useScreenSharePickerDisplayPermission.ts index 2fa8d2506..689647f02 100644 --- a/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/useScreenSharePickerDisplayPermission.ts +++ b/fluxer_app/src/features/voice/components/modals/screen_share_picker_modal/useScreenSharePickerDisplayPermission.ts @@ -1,10 +1,10 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import MacPermissions from '@app/features/permissions/system/state/MacPermissions'; import MediaPermission from '@app/features/permissions/system/state/MediaPermission'; import { checkNativePermission, type NativePermissionResult, - openNativePermissionSettings, } from '@app/features/permissions/system/utils/NativePermissions'; import {getElectronAPI} from '@app/features/ui/utils/NativeUtils'; import { @@ -92,9 +92,22 @@ export function useScreenSharePickerDisplayPermission({ }, [shouldCheck, transitionDisplayPermission]); const selectedPrompt = selectScreenSharePickerDisplayPermissionPrompt(snapshot); const prompt = shouldCheck && snapshot.matches('idle') ? 'checking' : selectedPrompt; + const awaitingGrant = shouldCheck && (selectedPrompt === 'needs-permission' || selectedPrompt === 'restart-required'); + useEffect(() => { + if (!awaitingGrant) return; + const recheck = () => { + void readScreenSharePickerScreenRecordingPermission('window-focus').then((permission) => { + transitionDisplayPermission({type: 'permission.result', permission}); + }); + }; + window.addEventListener('focus', recheck); + return () => window.removeEventListener('focus', recheck); + }, [awaitingGrant, transitionDisplayPermission]); const openSettings = useCallback(() => { transitionDisplayPermission({type: 'permission.settingsOpened'}); - void openNativePermissionSettings('screen'); + void MacPermissions.refreshKind('screen').then((status) => + status === 'not-determined' ? MacPermissions.request('screen') : MacPermissions.openSettings('screen'), + ); }, [transitionDisplayPermission]); return { blocksDesktopSources: shouldCheck && prompt !== 'none', diff --git a/fluxer_app/src/features/voice/components/popout/PopoutTitlebar.tsx b/fluxer_app/src/features/voice/components/popout/PopoutTitlebar.tsx index c5b5f067c..207a31d3a 100644 --- a/fluxer_app/src/features/voice/components/popout/PopoutTitlebar.tsx +++ b/fluxer_app/src/features/voice/components/popout/PopoutTitlebar.tsx @@ -1,9 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import nativeTitlebarStyles from '@app/features/app/components/layout/NativeTitlebar.module.css'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {getNativePlatformSync} from '@app/features/ui/utils/NativeUtils'; import styles from '@app/features/voice/components/popout/PopoutTitlebar.module.css'; +import {NATIVE_TITLEBAR_CLASS} from '@fluxer/desktop_ipc/src/NativeTitlebarShell'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {ArrowSquareInIcon, PushPinIcon, PushPinSlashIcon, XIcon} from '@phosphor-icons/react'; @@ -70,7 +70,7 @@ export const PopoutTitlebar: React.FC = ({