feat(profile): ship profile timezone to everyone (#3034)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-29 16:28:40 +02:00
1 parent b30ea361d3
commit d433a039b5
28 files changed
+71 -971

No files matched your search

@@ -2,11 +2,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
import type {ProfileTimezoneAssignmentResponse} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import type {DomainMigrationAssignmentResponse} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
type ExperimentAssignmentsResponse,
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
readProfileTimezoneAssignment,
readDomainMigrationAssignment,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
@@ -26,14 +26,14 @@ vi.mock('@app/features/platform/transport/RestTransport', () => ({
const {http} = await import('@app/features/platform/transport/RestTransport');
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
const CANARY_ASSIGNMENT: ProfileTimezoneAssignmentResponse = {
const CANARY_ASSIGNMENT: DomainMigrationAssignmentResponse = {
enabled: true,
};
const CANARY_ENVELOPE: ExperimentAssignmentsResponse = {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
assignments: {profile_timezone: CANARY_ASSIGNMENT},
assignments: {domain_migration: CANARY_ASSIGNMENT},
};
let visibility: DocumentVisibilityState = 'visible';
@@ -68,8 +68,8 @@ function subscribe(listener: () => void): () => void {
return unsubscribe;
}
function timezoneEnabled(): boolean {
return readProfileTimezoneAssignment(ExperimentAssignments.response).enabled;
function migrationEnabled(): boolean {
return readDomainMigrationAssignment(ExperimentAssignments.response).enabled;
}
function deferredReply(): {resolve: (response: RestResponse<unknown>) => void} {
@@ -111,7 +111,7 @@ describe('ExperimentAssignments cold start', () => {
ExperimentAssignments.start();
await settle();
expect(ExperimentAssignments.response).toBe(INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE);
expect(ExperimentAssignments.response.assignments.profile_timezone).toBeUndefined();
expect(ExperimentAssignments.response.assignments.domain_migration).toBeUndefined();
});
it('keeps the inert envelope while unauthenticated and retries later', async () => {
@@ -129,7 +129,7 @@ describe('ExperimentAssignments response handling', () => {
it('adopts a valid envelope', async () => {
await adopt(CANARY_ENVELOPE);
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
expect(readProfileTimezoneAssignment(ExperimentAssignments.response)).toEqual(CANARY_ASSIGNMENT);
expect(readDomainMigrationAssignment(ExperimentAssignments.response)).toEqual(CANARY_ASSIGNMENT);
});
it('requests the shared experiment endpoint', async () => {
@@ -142,7 +142,7 @@ describe('ExperimentAssignments response handling', () => {
vi.mocked(http.get).mockResolvedValue(
reply(200, {
...CANARY_ENVELOPE,
assignments: {profile_timezone: {enabled: 'yes'}},
assignments: {domain_migration: {enabled: 'yes'}},
}),
);
await vi.advanceTimersByTimeAsync(400_000);
@@ -157,9 +157,9 @@ describe('ExperimentAssignments response handling', () => {
expect(ExperimentAssignments.response).toEqual(CANARY_ENVELOPE);
});
it('accepts an envelope that carries no profile timezone assignment', async () => {
it('accepts an envelope that carries no domain migration assignment', async () => {
await adopt({poll_interval_seconds: 600, poll_jitter_percent: 0, assignments: {}});
expect(ExperimentAssignments.response.assignments.profile_timezone).toBeUndefined();
expect(ExperimentAssignments.response.assignments.domain_migration).toBeUndefined();
expect(lastScheduledDelayMs()).toBe(600_000);
});
@@ -416,14 +416,14 @@ describe('ExperimentAssignments lifecycle', () => {
describe('ExperimentAssignments subscribe', () => {
it('fires once per envelope change and stops after unsubscribe', async () => {
const seen: Array<boolean> = [];
const unsubscribe = subscribe(() => seen.push(timezoneEnabled()));
const unsubscribe = subscribe(() => seen.push(migrationEnabled()));
await adopt(CANARY_ENVELOPE);
expect(seen).toEqual([true]);
vi.mocked(http.get).mockResolvedValue(reply(304, undefined));
await vi.advanceTimersByTimeAsync(400_000);
expect(seen).toEqual([true]);
vi.mocked(http.get).mockResolvedValue(
reply(200, {...CANARY_ENVELOPE, assignments: {profile_timezone: {enabled: false}}}, {etag: 'W/"v8"'}),
reply(200, {...CANARY_ENVELOPE, assignments: {domain_migration: {enabled: false}}}, {etag: 'W/"v8"'}),
);
await vi.advanceTimersByTimeAsync(400_000);
expect(seen).toEqual([true, false]);
@@ -53,7 +53,6 @@ import {ProfileTypeSelector} from '@app/features/user/components/modals/tabs/my_
import {TimezoneProfileSettings} from '@app/features/user/components/modals/tabs/my_profile_tab/TimezoneProfileSettings';
import {ProfilePreview} from '@app/features/user/components/profile/ProfilePreview';
import type {Profile} from '@app/features/user/models/Profile';
import ProfileTimezoneRollout from '@app/features/user/state/ProfileTimezoneRollout';
import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {setMeaningfulFormValue} from '@app/lib/forms/MeaningfulFormValue';
@@ -411,7 +410,6 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
);
const showPremiumFeatures = shouldShowPremiumFeatures();
const hasPremium = useMemo(() => showPremiumFeatures && (user?.isPremium() ?? false), [showPremiumFeatures, user]);
const hasProfileTimezoneAccess = ProfileTimezoneRollout.enabled;
const hasPerGuildProfiles = useMemo(
() =>
isLimitToggleEnabled(
@@ -537,10 +535,8 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
};
assignProfileAssetUploadPatch(updateData, 'avatar', avatarAsset);
assignProfileAssetUploadPatch(updateData, 'banner', bannerAsset);
if (hasProfileTimezoneAccess) {
updateData.timezone = data.timezone;
updateData.timezone_privacy_flags = data.timezone_privacy_flags;
}
updateData.timezone = data.timezone;
updateData.timezone_privacy_flags = data.timezone_privacy_flags;
if (data.premium_badge_hidden !== undefined) {
updateData.premium_badge_hidden = data.premium_badge_hidden;
}
@@ -591,7 +587,6 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
commitProfileFormValues,
isPerGuildProfile,
isProfileCustomizationLocked,
hasProfileTimezoneAccess,
selectedGuildId,
user,
activeProfileData,
@@ -701,12 +696,10 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
!bannerAsset.hasCleared &&
(bannerAsset.hasAsset || (!bannerAsset.isDirty && Boolean(profileRemoteValues?.banner.hasCustomAsset)));
const profileFallbackDisplayName = NicknameUtils.getDisplayName(user);
const watchedTimezone = hasProfileTimezoneAccess ? (form.watch('timezone') ?? null) : null;
const watchedTimezonePrivacyFlags = hasProfileTimezoneAccess
? (form.watch('timezone_privacy_flags') ?? ProfileFieldPrivacyFlags.EVERYONE)
: ProfileFieldPrivacyFlags.EVERYONE;
const watchedTimezone = form.watch('timezone') ?? null;
const watchedTimezonePrivacyFlags = form.watch('timezone_privacy_flags') ?? ProfileFieldPrivacyFlags.EVERYONE;
const previewTimezoneOffset =
hasProfileTimezoneAccess && !isPerGuildProfile && watchedTimezone !== null && watchedTimezonePrivacyFlags !== 0
!isPerGuildProfile && watchedTimezone !== null && watchedTimezonePrivacyFlags !== 0
? getCurrentTimeZoneOffsetMinutes(watchedTimezone)
: null;
return (
@@ -756,7 +749,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
>
{isPerGuildProfile
? i18n._(VERIFY_YOUR_EMAIL_BEFORE_CHANGING_YOUR_COMMUNITY_NICKNAME_DESCRIPTOR)
: hasProfileTimezoneAccess && showPremiumFeatures
: showPremiumFeatures
? i18n._(VERIFY_YOUR_EMAIL_BEFORE_CHANGING_YOUR_USERNAME_DISPLAY_DESCRIPTOR, {
premiumProductName: PREMIUM_PRODUCT_NAME,
})
@@ -832,7 +825,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
disabled={isPronounsDisabled}
/>
</div>
{!isPerGuildProfile && hasProfileTimezoneAccess && (
{!isPerGuildProfile && (
<TimezoneProfileSettings
timezone={watchedTimezone}
timezonePrivacyFlags={watchedTimezonePrivacyFlags}
@@ -1,22 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {readProfileTimezoneAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
const logger = new Logger('ProfileTimezoneRollout');
class ProfileTimezoneRolloutSelector {
get enabled(): boolean {
try {
return readProfileTimezoneAssignment(ExperimentAssignments.response).enabled;
} catch (err) {
logger.warn('Failed to resolve profile timezone assignment:', err);
return false;
}
}
}
export const ProfileTimezoneRollout = new ProfileTimezoneRolloutSelector();
export default ProfileTimezoneRollout;
@@ -2,7 +2,6 @@
import {Profile} from '@app/features/user/models/Profile';
import type {User} from '@app/features/user/models/User';
import ProfileTimezoneRollout from '@app/features/user/state/ProfileTimezoneRollout';
import {ProfileFieldPrivacyFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
import {getCurrentTimeZoneOffsetMinutes} from '@fluxer/date_utils/src/TimeZoneUtils';
@@ -97,7 +96,7 @@ export function createMockProfile(
timezone_offset:
options?.previewTimezoneOffset !== undefined
? options.previewTimezoneOffset
: ProfileTimezoneRollout.enabled && (user.timezonePrivacyFlags ?? ProfileFieldPrivacyFlags.EVERYONE) !== 0
: (user.timezonePrivacyFlags ?? ProfileFieldPrivacyFlags.EVERYONE) !== 0
? getCurrentTimeZoneOffsetMinutes(user.timezone)
: null,
premium_type: visiblePremiumData.premiumType ?? undefined,