mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00249f9b4d | ||
|
|
7c16d424ce | ||
|
|
5f5e26a8d4 | ||
|
|
8c05497c37 | ||
|
|
d2f19660e5 | ||
|
|
d5ca5d8293 | ||
|
|
e403fb6cce | ||
|
|
e8dff6a8a1 | ||
|
|
2b656a1186 | ||
|
|
381699623e | ||
|
|
d7bbe933fa | ||
|
|
069b13c382 | ||
|
|
538b5d69bb | ||
|
|
3c8fe74660 |
No files matched your search
Generated
+4
-4
@@ -1266,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3890,9 +3890,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -4613,7 +4613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
@@ -211,7 +211,6 @@ x-fluxer-app-healthcheck: &fluxer-app-healthcheck
|
||||
timeout: ${FLUXER_HEALTHCHECK_TIMEOUT:-5s}
|
||||
retries: ${FLUXER_APP_HEALTHCHECK_RETRIES:-30}
|
||||
start_period: ${FLUXER_APP_HEALTHCHECK_START_PERIOD:-90s}
|
||||
start_interval: 1s
|
||||
|
||||
x-fluxer-svc-healthcheck: &fluxer-svc-healthcheck
|
||||
<<: *fluxer-app-healthcheck
|
||||
|
||||
Generated
+7
-3
@@ -4258,7 +4258,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.",
|
||||
"description": "Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait and joins the account to the community it was invited to and the single community, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -16526,7 +16526,8 @@
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
},
|
||||
"registration_url_id": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 128},
|
||||
"client_ip": {"nullable": true, "type": "string"}
|
||||
"client_ip": {"nullable": true, "type": "string"},
|
||||
"invite_code": {"nullable": true, "type": "string", "maxLength": 256}
|
||||
},
|
||||
"required": [
|
||||
"user_id",
|
||||
@@ -16536,7 +16537,8 @@
|
||||
"email",
|
||||
"requested_at",
|
||||
"registration_url_id",
|
||||
"client_ip"
|
||||
"client_ip",
|
||||
"invite_code"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -17409,6 +17411,7 @@
|
||||
"REACTION_EMAIL_VERIFICATION_REQUIRED",
|
||||
"REPORT_EMAIL_VERIFICATION_REQUIRED",
|
||||
"REGISTRATION_CLOSED",
|
||||
"REGISTRATION_CREDENTIALS_REQUIRED",
|
||||
"REGISTRATION_PENDING_APPROVAL",
|
||||
"REGISTRATION_REJECTED",
|
||||
"REGISTRATION_URL_INVALID",
|
||||
@@ -17704,6 +17707,7 @@
|
||||
"Email verification is required for this action.",
|
||||
"Email verification is required for this action.",
|
||||
"Registration is closed on this instance.",
|
||||
"This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.",
|
||||
"This registration is waiting for admin approval.",
|
||||
"This registration request was rejected.",
|
||||
"This registration link is invalid or has expired.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {startAttachmentDecayReconcile} from '@app/api/attachment/AttachmentDecayReconcileRun';
|
||||
import {maybeAutoJoinInvite} from '@app/api/auth/AuthRegistration';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {
|
||||
@@ -768,7 +769,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
operationId: 'update_admin_pending_registration',
|
||||
summary: 'Approve or reject a pending registration',
|
||||
description:
|
||||
'Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.',
|
||||
'Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait and joins the account to the community it was invited to and the single community, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.',
|
||||
responseSchema: PendingRegistrationListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -777,7 +778,10 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const userId = ctx.req.valid('param').user_id.toString();
|
||||
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
|
||||
await updatePendingRegistrationUser(ctx, userId, decision);
|
||||
const pendingRegistration = (await instanceConfigRepository.getPendingRegistrations()).find(
|
||||
(entry) => entry.user_id === userId,
|
||||
);
|
||||
await updatePendingRegistrationUser(ctx, userId, decision, pendingRegistration?.invite_code ?? null);
|
||||
await instanceConfigRepository.removePendingRegistration(userId);
|
||||
return ctx.json({pending_registrations: await instanceConfigRepository.getPendingRegistrations()});
|
||||
},
|
||||
@@ -875,6 +879,7 @@ async function updatePendingRegistrationUser(
|
||||
ctx: Context<HonoEnv>,
|
||||
userId: string,
|
||||
decision: 'approve' | 'reject',
|
||||
inviteCode: string | null,
|
||||
): Promise<void> {
|
||||
const userRepository = ctx.get('userRepository');
|
||||
const user = await userRepository.findUnique(createUserID(BigInt(userId)));
|
||||
@@ -896,6 +901,11 @@ async function updatePendingRegistrationUser(
|
||||
}
|
||||
await userRepository.patchUpsert(user.id, {traits: traits.size > 0 ? traits : null}, user.toRow());
|
||||
if (decision === 'approve' && wasPendingApproval) {
|
||||
await maybeAutoJoinInvite(ctx.get('inviteService'), {
|
||||
userId: user.id,
|
||||
inviteCode,
|
||||
requestCache: ctx.get('requestCache'),
|
||||
});
|
||||
await ctx.get('singleCommunityService').joinStockCommunity(user.id, ctx.get('requestCache'));
|
||||
}
|
||||
await recordAdminWrite(ctx, {
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
createUniqueUsername,
|
||||
setUserACLs,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createChannelInvite, createGuild, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
@@ -21,6 +22,18 @@ interface PendingRegistrationResponse {
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
function invitedRegistrationBody(prefix: string, inviteCode?: string): Record<string, unknown> {
|
||||
return {
|
||||
email: createUniqueEmail(prefix),
|
||||
username: createUniqueUsername(prefix),
|
||||
global_name: 'Invited person',
|
||||
password: 'invited-since-1999',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
invite_code: inviteCode,
|
||||
};
|
||||
}
|
||||
|
||||
describe('pending registration approval and the stock community', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
@@ -102,6 +115,198 @@ describe('pending registration approval and the stock community', () => {
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('joins the invited community on approval', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('invitedapproval', invite.code))
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('lists the invite code on a pending registration', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('invitedlisted', invite.code))
|
||||
.execute();
|
||||
|
||||
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
|
||||
.get('/admin/instance/pending-registrations')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(listed.pending_registrations).toEqual([
|
||||
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not join the invited community on rejection', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('invitedrejected', invite.code))
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'rejected'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('joins the auto-join community on approval of a registration without an invite', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
|
||||
Config.instance.autoJoinInviteCode = invite.code;
|
||||
try {
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('autojoinapproval'))
|
||||
.execute();
|
||||
|
||||
const listed = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
|
||||
.get('/admin/instance/pending-registrations')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(listed.pending_registrations).toEqual([
|
||||
expect.objectContaining({user_id: pending.user_id, invite_code: invite.code}),
|
||||
]);
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
} finally {
|
||||
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not join the auto-join community when the pending entry has no invite', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('noinviteapproval'))
|
||||
.execute();
|
||||
|
||||
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
|
||||
Config.instance.autoJoinInviteCode = invite.code;
|
||||
try {
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
} finally {
|
||||
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
|
||||
}
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('approves and joins the single community when the invite is gone', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild: stockGuild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invitedGuild = await createGuild(harness, owner.token, 'Invited Guild');
|
||||
const invite = await createChannelInvite(harness, owner.token, invitedGuild.system_channel_id!);
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
single_community_enabled: true,
|
||||
single_community_guild_id: stockGuild.id,
|
||||
});
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
|
||||
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body(invitedRegistrationBody('invitegone', invite.code))
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/invites/${invite.code}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${stockGuild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${invitedGuild.id}/members/${pending.user_id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('lets the review ACL alone list and decide registrations', async () => {
|
||||
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
|
||||
@@ -52,9 +52,10 @@ import {inputValidationErrorFromZodIssues} from '@app/api/Validator';
|
||||
import {AccountIdentityModes, TagStyles} from '@fluxer/constants/src/AccountIdentityConstants';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {getRegionalMinimumAge} from '@fluxer/constants/src/RegionalMinimumAge';
|
||||
import {ProfileFieldPrivacyFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ProfileFieldPrivacyFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {RegistrationClosedError} from '@fluxer/errors/src/domains/auth/RegistrationClosedError';
|
||||
import {RegistrationCredentialsRequiredError} from '@fluxer/errors/src/domains/auth/RegistrationCredentialsRequiredError';
|
||||
import {RegistrationUrlInvalidError} from '@fluxer/errors/src/domains/auth/RegistrationUrlInvalidError';
|
||||
import {ContentBlockedError} from '@fluxer/errors/src/domains/content/ContentBlockedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -163,6 +164,9 @@ async function registerAccount(
|
||||
const registrationAccess = setupTokenVerified
|
||||
? {pendingApproval: false, registrationUrl: null}
|
||||
: await resolveRegistrationAccess(instanceConfigRepository, data.registration_url_code);
|
||||
if (registrationAccess.pendingApproval && !hasSignInCredentials(data, usernameMode)) {
|
||||
throw new RegistrationCredentialsRequiredError();
|
||||
}
|
||||
const geoipResult = await lookupGeoip(clientIp);
|
||||
const countryCode = geoipResult.countryCode;
|
||||
const collectDateOfBirth = appPublicConfig.registration.collect_date_of_birth;
|
||||
@@ -253,7 +257,6 @@ async function registerAccount(
|
||||
const acceptLanguage = request.headers.get('accept-language');
|
||||
const userLocale = parseAcceptLanguage(acceptLanguage);
|
||||
const passwordHash = data.password ? await hashPassword(data.password) : null;
|
||||
const flags = config.nodeEnv === 'development' ? UserFlags.STAFF : 0n;
|
||||
const userRow: UserRow = {
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: userId,
|
||||
@@ -271,7 +274,7 @@ async function registerAccount(
|
||||
timezone_privacy_flags: ProfileFieldPrivacyFlags.EVERYONE,
|
||||
date_of_birth: dateOfBirth,
|
||||
locale: userLocale,
|
||||
flags,
|
||||
flags: 0n,
|
||||
terms_agreed_at: requiresTermsConsent ? now : null,
|
||||
privacy_agreed_at: requiresPrivacyConsent ? now : null,
|
||||
last_active_at: now,
|
||||
@@ -303,6 +306,7 @@ async function registerAccount(
|
||||
requested_at: now.toISOString(),
|
||||
registration_url_id: registrationAccess.registrationUrl?.id ?? null,
|
||||
client_ip: clientIp,
|
||||
invite_code: data.invite_code?.trim() || config.instance.autoJoinInviteCode || null,
|
||||
});
|
||||
}
|
||||
grantBootstrapAdmin = bootstrapAdminEligible && (await instanceConfigRepository.claimAdminBootstrap(userId));
|
||||
@@ -405,6 +409,11 @@ function assertUsernameModeRegistration(data: RegisterRequest): void {
|
||||
}
|
||||
}
|
||||
|
||||
function hasSignInCredentials(data: RegisterRequest, usernameMode: boolean): boolean {
|
||||
if (!data.password) return false;
|
||||
return usernameMode || Boolean(data.email);
|
||||
}
|
||||
|
||||
function requestedUsernameFromOlderApp(config: APIConfig, email: string | null | undefined): string | undefined {
|
||||
if (email == null) return undefined;
|
||||
const localPart = getLocalPartAtInstance(config, email.trim());
|
||||
@@ -495,7 +504,7 @@ async function resolveRegistrationAccess(
|
||||
};
|
||||
}
|
||||
|
||||
async function maybeAutoJoinInvite(
|
||||
export async function maybeAutoJoinInvite(
|
||||
inviteService: InviteService,
|
||||
params: {
|
||||
userId: UserID;
|
||||
@@ -513,7 +522,7 @@ async function maybeAutoJoinInvite(
|
||||
requestCache,
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite on registration');
|
||||
Logger.warn({inviteCode: normalizedInviteCode, error}, 'Failed to auto-join invite');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {createHash, randomBytes} from 'node:crypto';
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {maybeAutoJoinInvite} from '@app/api/auth/AuthRegistration';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {assertEmailNotBlocklisted} from '@app/api/auth/EmailBlocklist';
|
||||
import {SsoIdentityRepository} from '@app/api/auth/services/SsoIdentityRepository';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
isTestSsoProvider,
|
||||
validateSsoPublicOutboundUrl,
|
||||
} from '@app/api/instance/SsoConfigValidation';
|
||||
import type {InviteService} from '@app/api/invite/InviteService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {profileSubstringBlocklistCache} from '@app/api/middleware/ProfileSubstringBlocklistCache';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
@@ -265,6 +267,7 @@ export class SsoService {
|
||||
private readonly discriminatorService: DiscriminatorService,
|
||||
private readonly kvActivityTracker: KVActivityTracker,
|
||||
private readonly singleCommunityService: SingleCommunityService,
|
||||
private readonly inviteService: InviteService,
|
||||
) {}
|
||||
|
||||
async getPublicStatus(): Promise<PublicSsoStatus> {
|
||||
@@ -406,10 +409,16 @@ export class SsoService {
|
||||
}
|
||||
await assertEmailNotBlocklisted(emailLower, 'email');
|
||||
const pendingApproval = registrationConfig.mode === 'approval';
|
||||
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval});
|
||||
const autoJoinInviteCode = this.apiContext.services.config.instance.autoJoinInviteCode?.trim() || null;
|
||||
const user = await this.provisionUserFromClaims(claims, config, {pendingApproval, autoJoinInviteCode});
|
||||
if (pendingApproval) {
|
||||
throw new RegistrationPendingApprovalError();
|
||||
}
|
||||
await maybeAutoJoinInvite(this.inviteService, {
|
||||
userId: user.id,
|
||||
inviteCode: autoJoinInviteCode,
|
||||
requestCache,
|
||||
});
|
||||
await this.singleCommunityService.joinStockCommunity(user.id, requestCache);
|
||||
return user;
|
||||
}
|
||||
@@ -468,6 +477,7 @@ export class SsoService {
|
||||
config: ResolvedSsoConfig,
|
||||
options?: {
|
||||
pendingApproval?: boolean;
|
||||
autoJoinInviteCode?: string | null;
|
||||
},
|
||||
): Promise<User> {
|
||||
const {users, snowflake, cache} = this.apiContext.services;
|
||||
@@ -535,6 +545,7 @@ export class SsoService {
|
||||
requested_at: now.toISOString(),
|
||||
registration_url_id: null,
|
||||
client_ip: null,
|
||||
invite_code: options.autoJoinInviteCode ?? null,
|
||||
});
|
||||
}
|
||||
createAttempted = true;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {ms} from 'itty-time';
|
||||
@@ -126,6 +127,19 @@ describe('Auth registration', () => {
|
||||
expect(reg.user_id.length).toBeGreaterThan(0);
|
||||
expect(Object.keys(reg).sort()).toEqual(['token', 'user', 'user_id']);
|
||||
});
|
||||
it('does not flag accounts registered on a development instance as staff', async () => {
|
||||
expect(getConfig().nodeEnv).toBe('development');
|
||||
const reg = await registerUser(harness, {
|
||||
email: createUniqueEmail('devnotstaff'),
|
||||
username: createUniqueUsername('devnotstaff'),
|
||||
global_name: 'Dev User',
|
||||
password: 'a-strong-password',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const user = await getUserRepository().findUniqueAssert(createUserID(BigInt(reg.user_id)));
|
||||
expect(user.flags & UserFlags.STAFF).toBe(0n);
|
||||
});
|
||||
it('grants wildcard admin ACL to first accepted local dev registration', async () => {
|
||||
await getInstanceConfigRepository().updateCaptchaConfig({enabled: false});
|
||||
await withBootstrapAdminConfig({selfHosted: false, testModeEnabled: false}, async () => {
|
||||
|
||||
@@ -91,4 +91,67 @@ describe('Auth registration policy', () => {
|
||||
.expect(403, APIErrorCodes.REGISTRATION_PENDING_APPROVAL)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('refuses an approval registration without sign-in credentials', async () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({global_name: 'Nobody Signs In', date_of_birth: '2000-01-01', consent: true})
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses an approval registration with an email but no password', async () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const {password: _password, ...body} = registrationBody('nopassword');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body(body)
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses an approval registration with a password but no email', async () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const {email: _email, ...body} = registrationBody('noemail');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body(body)
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the registration URL seat when an approval registration is refused', async () => {
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setRegistrationConfig({mode: 'open', admin_registration_urls_enabled: true});
|
||||
const {code} = await repository.createRegistrationUrl({
|
||||
label: 'Reviewed link',
|
||||
createdByUserId: '1',
|
||||
expiresAt: null,
|
||||
maxUses: 1,
|
||||
approvalRequired: true,
|
||||
});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({global_name: 'Seat Keeper', date_of_birth: '2000-01-01', consent: true, registration_url_code: code})
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
const registration = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body({...registrationBody('seatkeeper'), registration_url_code: code})
|
||||
.execute();
|
||||
expect(registration.registration_pending_approval).toBe(true);
|
||||
});
|
||||
|
||||
it('still accepts an unclaimed registration in open mode', async () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'open'});
|
||||
const registration = await createBuilderWithoutAuth<RegistrationTokenResponse>(harness)
|
||||
.post('/auth/register')
|
||||
.body({global_name: 'Open Unclaimed', date_of_birth: '2000-01-01', consent: true})
|
||||
.execute();
|
||||
expect(registration.token.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -428,8 +428,11 @@ describe('signups racing on registration URLs and pending approvals', () => {
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const fresh = await register('pendingafter');
|
||||
|
||||
const listed = (await readPendingRegistrations()).map((entry) => entry.user_id);
|
||||
expect(listed.toSorted()).toEqual([account.userId, fresh.json.user_id].toSorted());
|
||||
const pendingRegistrations = await readPendingRegistrations();
|
||||
expect(pendingRegistrations.map((entry) => entry.user_id).toSorted()).toEqual(
|
||||
[account.userId, fresh.json.user_id].toSorted(),
|
||||
);
|
||||
expect(pendingRegistrations.find((entry) => entry.user_id === account.userId)?.invite_code).toBeNull();
|
||||
|
||||
const decided = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
|
||||
.patch(`/admin/instance/pending-registrations/${account.userId}`)
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
setUserACLs,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {createChannelInvite, setupTestGuildWithMembers} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {PendingRegistrationListResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface SsoStartResponse {
|
||||
@@ -503,6 +506,79 @@ describe('Auth SSO flow', () => {
|
||||
.expect(200)
|
||||
.execute();
|
||||
});
|
||||
it('joins a provisioned user to the auto-join invite community', async () => {
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await enableSso(harness, admin.token);
|
||||
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
|
||||
Config.instance.autoJoinInviteCode = invite.code;
|
||||
try {
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({})
|
||||
.execute();
|
||||
const completeData = await createBuilderWithoutAuth<SsoCompleteResponse>(harness)
|
||||
.post('/auth/sso/complete')
|
||||
.body({
|
||||
code: `sso-auto-join-${Date.now()}@example.com`,
|
||||
state: startData.state,
|
||||
})
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${completeData.user_id}`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
} finally {
|
||||
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
|
||||
}
|
||||
});
|
||||
it('replays the auto-join invite when an SSO registration is approved', async () => {
|
||||
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.REGISTRATION_REVIEW,
|
||||
]);
|
||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await enableSso(harness, admin.token);
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const originalAutoJoinInviteCode = Config.instance.autoJoinInviteCode;
|
||||
Config.instance.autoJoinInviteCode = invite.code;
|
||||
try {
|
||||
const startData = await createBuilderWithoutAuth<SsoStartResponse>(harness)
|
||||
.post('/auth/sso/start')
|
||||
.body({})
|
||||
.execute();
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/sso/complete')
|
||||
.body({
|
||||
code: `sso-auto-join-approval-${Date.now()}@example.com`,
|
||||
state: startData.state,
|
||||
})
|
||||
.expect(403, 'REGISTRATION_PENDING_APPROVAL')
|
||||
.execute();
|
||||
const listed = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
|
||||
.get('/admin/instance/pending-registrations')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(listed.pending_registrations).toEqual([expect.objectContaining({invite_code: invite.code})]);
|
||||
const pendingUserId = listed.pending_registrations[0]!.user_id;
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pendingUserId}`)
|
||||
.expect(404)
|
||||
.execute();
|
||||
await createBuilder(harness, reviewer.token)
|
||||
.patch(`/admin/instance/pending-registrations/${pendingUserId}`)
|
||||
.body({status: 'approved'})
|
||||
.expect(200)
|
||||
.execute();
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/guilds/${guild.id}/members/${pendingUserId}`)
|
||||
.expect(200)
|
||||
.execute();
|
||||
} finally {
|
||||
Config.instance.autoJoinInviteCode = originalAutoJoinInviteCode;
|
||||
}
|
||||
});
|
||||
});
|
||||
describe('existing user login', () => {
|
||||
let admin: TestAccount;
|
||||
|
||||
@@ -404,6 +404,29 @@ describe('Username sign-in', () => {
|
||||
expect(user.username.toLowerCase().startsWith('sharedname')).toBe(true);
|
||||
expect(user.isUnclaimedAccount()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a credential-less approval registration on a username instance', async () => {
|
||||
await useUsernameMode();
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/register')
|
||||
.body({global_name: 'No Credentials', date_of_birth: '2000-01-01'})
|
||||
.expect(400, APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED)
|
||||
.execute();
|
||||
expect(await getInstanceConfigRepository().getPendingRegistrations()).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts an approval registration with a username and password', async () => {
|
||||
await useUsernameMode();
|
||||
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||
const registration = await createBuilderWithoutAuth<{registration_pending_approval: true; user_id: string}>(
|
||||
harness,
|
||||
)
|
||||
.post('/auth/register')
|
||||
.body({username: 'reviewedperson', password: PASSWORD, date_of_birth: '2000-01-01'})
|
||||
.execute();
|
||||
expect(registration).toEqual({registration_pending_approval: true, user_id: expect.any(String)});
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {defineTable} from '@app/api/database/CassandraTableDsl';
|
||||
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import {keyFromColumns, PostgresKvQueryExecutor} from '@app/api/database/PostgresKvQueryExecutor';
|
||||
import {dockerAvailable, startPostgresKv, stopPostgresKv} from '@app/api/test/DockerTestContainer';
|
||||
import type {IPostgresClient} from '@pkgs/postgres/src/Client';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const KV_TABLE = 'kv_keyset_scan';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
interface UserRow {
|
||||
user_id: bigint;
|
||||
name: string;
|
||||
kind: number;
|
||||
}
|
||||
|
||||
interface BucketRow {
|
||||
channel_id: bigint;
|
||||
bucket: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface NameRow {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const KeysetUsers = defineTable<UserRow, 'user_id'>({
|
||||
name: 'keyset_users',
|
||||
columns: ['user_id', 'name', 'kind'],
|
||||
primaryKey: ['user_id'],
|
||||
});
|
||||
|
||||
const KeysetBuckets = defineTable<BucketRow, 'channel_id' | 'bucket', 'channel_id'>({
|
||||
name: 'keyset_buckets',
|
||||
columns: ['channel_id', 'bucket', 'note'],
|
||||
primaryKey: ['channel_id', 'bucket'],
|
||||
partitionKey: ['channel_id'],
|
||||
});
|
||||
|
||||
const KeysetNames = defineTable<NameRow, 'name'>({
|
||||
name: 'keyset_names',
|
||||
columns: ['name', 'value'],
|
||||
primaryKey: ['name'],
|
||||
});
|
||||
|
||||
interface Statement {
|
||||
text: string;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
function recordingClient(client: IPostgresClient, statements: Array<Statement>): IPostgresClient {
|
||||
return {
|
||||
query: (async (text: string, values?: Array<unknown>, name?: string) => {
|
||||
const result = await client.query(text, values, name);
|
||||
statements.push({text, rows: result.rows.length});
|
||||
return result;
|
||||
}) as IPostgresClient['query'],
|
||||
connect: () => client.connect(),
|
||||
shutdown: () => client.shutdown(),
|
||||
transaction: (fn) => client.transaction(fn),
|
||||
kvTable: () => client.kvTable(),
|
||||
};
|
||||
}
|
||||
|
||||
function pageState(state: Row): string {
|
||||
return Buffer.from(JSON.stringify(state)).toString('base64url');
|
||||
}
|
||||
|
||||
function readPageState(state: string): Row {
|
||||
return JSON.parse(Buffer.from(state, 'base64url').toString('utf8')) as Row;
|
||||
}
|
||||
|
||||
function userId(index: number): bigint {
|
||||
return 1_000_000_000_000_000_000n + BigInt(index) * 7_919n;
|
||||
}
|
||||
|
||||
describe.skipIf(!dockerAvailable)('Postgres KV keyset scans', () => {
|
||||
let raw: IPostgresClient;
|
||||
let statements: Array<Statement>;
|
||||
let executor: PostgresKvQueryExecutor;
|
||||
let postgresContainer: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startPostgresKv({namePrefix: 'fluxer-kvkeyset', kvTable: KV_TABLE, maxConnections: 4});
|
||||
postgresContainer = started.container;
|
||||
raw = started.client;
|
||||
}, 900_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await raw.query(`DELETE FROM ${KV_TABLE}`);
|
||||
statements = [];
|
||||
executor = new PostgresKvQueryExecutor(recordingClient(raw, statements));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopPostgresKv(postgresContainer);
|
||||
});
|
||||
|
||||
async function seed(queries: ReadonlyArray<PreparedQuery>): Promise<void> {
|
||||
await executor.executeBatch(queries.map((query) => ({query: query.cql, params: query.params, meta: query.kvMeta})));
|
||||
}
|
||||
|
||||
async function seedUsers(count: number): Promise<Array<UserRow>> {
|
||||
const rows = Array.from({length: count}, (_, index) => ({
|
||||
user_id: userId((index * 37) % count),
|
||||
name: `user-${index}`,
|
||||
kind: index % 7,
|
||||
}));
|
||||
await seed(rows.map((row) => KeysetUsers.insert(row)));
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function seedBuckets(channels: number, buckets: number): Promise<void> {
|
||||
const queries: Array<PreparedQuery> = [];
|
||||
for (let channel = 0; channel < channels; channel += 1) {
|
||||
for (let bucket = 0; bucket < buckets; bucket += 1) {
|
||||
queries.push(
|
||||
KeysetBuckets.insert({
|
||||
channel_id: userId((channel * 13) % channels),
|
||||
bucket: bucket * 11 - 20,
|
||||
note: `note-${(channel + bucket) % 5}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await seed(queries);
|
||||
}
|
||||
|
||||
async function oracle(query: PreparedQuery): Promise<Array<Row>> {
|
||||
const rows = await executor.executeQuery<Row>(query);
|
||||
statements.length = 0;
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function walk(
|
||||
query: PreparedQuery,
|
||||
pageSize: number,
|
||||
start: string | null = null,
|
||||
): Promise<{pages: Array<Array<Row>>; states: Array<string>}> {
|
||||
const pages: Array<Array<Row>> = [];
|
||||
const states: Array<string> = [];
|
||||
let state = start;
|
||||
do {
|
||||
const page = await executor.executePagedQuery<Row>(query, {pageSize, pageState: state});
|
||||
pages.push(page.rows);
|
||||
state = page.pageState;
|
||||
if (state !== null) states.push(state);
|
||||
expect(pages.length).toBeLessThan(10_000);
|
||||
} while (state !== null);
|
||||
return {pages, states};
|
||||
}
|
||||
|
||||
function expectBounded(batch: number): void {
|
||||
expect(statements.length).toBeGreaterThan(0);
|
||||
for (const statement of statements) {
|
||||
expect(statement.rows).toBeLessThanOrEqual(batch);
|
||||
if (statement.text.includes('row_data FROM')) expect(statement.text).toMatch(/LIMIT \$2$/);
|
||||
}
|
||||
}
|
||||
|
||||
function byKey(columns: ReadonlyArray<string>, rows: ReadonlyArray<Row>): Array<Row> {
|
||||
return [...rows].sort((left, right) => {
|
||||
const l = keyFromColumns(columns, left);
|
||||
const r = keyFromColumns(columns, right);
|
||||
return l < r ? -1 : l > r ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
it('walks a numeric single key table in the same order without a full load', async () => {
|
||||
await seedUsers(250);
|
||||
const query = KeysetUsers.select({}).bind({});
|
||||
const expected = await oracle(query);
|
||||
expect(expected).toHaveLength(250);
|
||||
const {pages, states} = await walk(query, 40);
|
||||
expect(pages.flat()).toEqual(expected);
|
||||
expect(pages.slice(0, -1).every((page) => page.length === 40)).toBe(true);
|
||||
expect(readPageState(states[0]!)).toEqual({
|
||||
offset: 40,
|
||||
after: keyFromColumns(['user_id'], expected[39]!),
|
||||
keyed: true,
|
||||
});
|
||||
expectBounded(41);
|
||||
});
|
||||
|
||||
it('walks a composite key table by row key without reloading it per page', async () => {
|
||||
await seedBuckets(30, 7);
|
||||
const query = KeysetBuckets.select({}).bind({});
|
||||
const expected = await oracle(query);
|
||||
expect(expected).toHaveLength(210);
|
||||
const {pages} = await walk(query, 25);
|
||||
const rows = pages.flat();
|
||||
expect(byKey(['channel_id', 'bucket'], rows)).toEqual(byKey(['channel_id', 'bucket'], expected));
|
||||
expect(new Set(rows.map((row) => keyFromColumns(['channel_id', 'bucket'], row))).size).toBe(210);
|
||||
const channelRuns = rows.filter((row, index) => index === 0 || rows[index - 1]!.channel_id !== row.channel_id);
|
||||
expect(channelRuns).toHaveLength(30);
|
||||
expect(statements.filter((statement) => statement.text.includes('row_data FROM'))).toHaveLength(pages.length);
|
||||
expectBounded(26);
|
||||
});
|
||||
|
||||
it('walks a text key table in the same order', async () => {
|
||||
await seed(
|
||||
Array.from({length: 90}, (_, index) => KeysetNames.insert({name: `name-${(index * 31) % 90}-x`, value: index})),
|
||||
);
|
||||
const query = KeysetNames.select({}).bind({});
|
||||
const expected = await oracle(query);
|
||||
const {pages} = await walk(query, 16);
|
||||
expect(pages.flat()).toEqual(expected);
|
||||
expectBounded(17);
|
||||
});
|
||||
|
||||
it('fills filtered pages from bounded batches on numeric and composite tables', async () => {
|
||||
await seedUsers(300);
|
||||
await seedBuckets(20, 9);
|
||||
const users = KeysetUsers.select({where: KeysetUsers.where.eq('kind')}).bind({kind: 3});
|
||||
const expectedUsers = await oracle(users);
|
||||
const userWalk = await walk(users, 10);
|
||||
expect(userWalk.pages.flat()).toEqual(expectedUsers);
|
||||
expect(userWalk.pages.slice(0, -1).every((page) => page.length === 10)).toBe(true);
|
||||
expectBounded(11);
|
||||
const buckets = KeysetBuckets.select({where: KeysetBuckets.where.eq('note')}).bind({note: 'note-2'});
|
||||
const expectedBuckets = await oracle(buckets);
|
||||
const bucketWalk = await walk(buckets, 6);
|
||||
expect(byKey(['channel_id', 'bucket'], bucketWalk.pages.flat())).toEqual(
|
||||
byKey(['channel_id', 'bucket'], expectedBuckets),
|
||||
);
|
||||
expect(bucketWalk.pages.slice(0, -1).every((page) => page.length === 6)).toBe(true);
|
||||
expectBounded(7);
|
||||
});
|
||||
|
||||
it('skips expired rows', async () => {
|
||||
await seedUsers(60);
|
||||
await seedBuckets(6, 5);
|
||||
await raw.query(
|
||||
`UPDATE ${KV_TABLE} SET expires_at = now() - interval '1 minute' WHERE (row_data ->> 'kind')::int = 2 OR row_data ->> 'note' = 'note-1'`,
|
||||
);
|
||||
const users = KeysetUsers.select({}).bind({});
|
||||
const expectedUsers = await oracle(users);
|
||||
expect(expectedUsers.length).toBeLessThan(60);
|
||||
expect((await walk(users, 7)).pages.flat()).toEqual(expectedUsers);
|
||||
const buckets = KeysetBuckets.select({}).bind({});
|
||||
const expectedBuckets = await oracle(buckets);
|
||||
expect(expectedBuckets.length).toBeLessThan(30);
|
||||
expect(byKey(['channel_id', 'bucket'], (await walk(buckets, 7)).pages.flat())).toEqual(
|
||||
byKey(['channel_id', 'bucket'], expectedBuckets),
|
||||
);
|
||||
expectBounded(8);
|
||||
});
|
||||
|
||||
it('returns one empty page for an empty table', async () => {
|
||||
for (const query of [KeysetUsers.select({}).bind({}), KeysetBuckets.select({}).bind({})]) {
|
||||
expect(await executor.executePagedQuery(query, {pageSize: 10})).toEqual({rows: [], pageState: null});
|
||||
}
|
||||
expectBounded(1);
|
||||
});
|
||||
|
||||
it('never drops rows with non-numeric keys from a single key table', async () => {
|
||||
await seedUsers(40);
|
||||
await executor.executeQuery(KeysetUsers.insert({user_id: 'stray' as unknown as bigint, name: 'stray', kind: 0}));
|
||||
const {pages} = await walk(KeysetUsers.select({}).bind({}), 9);
|
||||
const names = pages.flat().map((row) => row.name);
|
||||
expect(names).toHaveLength(41);
|
||||
expect(new Set(names).size).toBe(41);
|
||||
expect(names).toContain('stray');
|
||||
expectBounded(10);
|
||||
});
|
||||
|
||||
it('resumes every page state shape the previous release produced', async () => {
|
||||
await seedUsers(120);
|
||||
await seedBuckets(12, 6);
|
||||
const users = KeysetUsers.select({}).bind({});
|
||||
const expectedUsers = await oracle(users);
|
||||
const userKey = keyFromColumns(['user_id'], expectedUsers[49]!);
|
||||
for (const state of [{offset: 50, after: userKey, keyed: true}, {offset: 50, after: userKey}, {offset: 50}]) {
|
||||
expect((await walk(users, 15, pageState(state))).pages.flat()).toEqual(expectedUsers.slice(50));
|
||||
}
|
||||
const buckets = KeysetBuckets.select({}).bind({});
|
||||
const expectedBuckets = await oracle(buckets);
|
||||
const bucketKey = keyFromColumns(['channel_id', 'bucket'], expectedBuckets[29]!);
|
||||
statements.length = 0;
|
||||
const resumed = await walk(buckets, 11, pageState({offset: 30, after: bucketKey}));
|
||||
expect(byKey(['channel_id', 'bucket'], resumed.pages.flat())).toEqual(
|
||||
byKey(['channel_id', 'bucket'], expectedBuckets.slice(30)),
|
||||
);
|
||||
expect(readPageState(resumed.states[0]!)).toMatchObject({byRowKey: true, since: bucketKey});
|
||||
expectBounded(12);
|
||||
});
|
||||
|
||||
it('streams limited scans in key order instead of loading the table', async () => {
|
||||
const seeded = await seedUsers(200);
|
||||
const sorted = [...seeded].sort((left, right) => (left.user_id < right.user_id ? -1 : 1));
|
||||
const first = KeysetUsers.select({limit: 20}).bind({});
|
||||
const next = KeysetUsers.select({where: KeysetUsers.where.tokenGt('user_id', 'last_user_id'), limit: 20});
|
||||
const expectedFirst = await oracle(KeysetUsers.select({}).bind({}));
|
||||
expect(await executor.executeQuery(first)).toEqual(expectedFirst.slice(0, 20));
|
||||
expectBounded(21);
|
||||
statements.length = 0;
|
||||
const rows = await executor.executeQuery(next.bind({last_user_id: sorted[99]!.user_id}));
|
||||
expect(rows).toEqual(expectedFirst.slice(100, 120));
|
||||
expect(statements.filter((statement) => statement.text.includes('row_data FROM'))).toHaveLength(1);
|
||||
expectBounded(21);
|
||||
statements.length = 0;
|
||||
expect(await executor.executeQuery(next.bind({last_user_id: sorted[195]!.user_id}))).toEqual(
|
||||
expectedFirst.slice(196),
|
||||
);
|
||||
expectBounded(21);
|
||||
});
|
||||
|
||||
it('probes for non-numeric keys through the primary key while other tables hold string keys', async () => {
|
||||
await raw.query(
|
||||
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data) SELECT 'keyset_users', k, k, '{}'::jsonb FROM generate_series(1, 20000) g, LATERAL (SELECT '{"__fluxer_type":"bigint","value":"' || g || '"}' AS k) s`,
|
||||
);
|
||||
await raw.query(
|
||||
`INSERT INTO ${KV_TABLE} (table_name, partition_key, row_key, row_data) SELECT 'keyset_strings', g::text, '"' || lpad(g::text, 8, '0') || '"', '{}'::jsonb FROM generate_series(1, 200000) g`,
|
||||
);
|
||||
await raw.query(`ANALYZE ${KV_TABLE}`);
|
||||
await executor.executePagedQuery(KeysetUsers.select({}).bind({}), {pageSize: 10});
|
||||
const probe = statements.find((statement) => statement.text.endsWith('AS found'));
|
||||
expect(probe).toBeDefined();
|
||||
const plan = await raw.query<{'QUERY PLAN': string}>(`EXPLAIN (COSTS OFF) ${probe!.text}`, ['keyset_users']);
|
||||
expect(plan.rows.map((row) => row['QUERY PLAN']).join('\n')).not.toContain('Seq Scan');
|
||||
});
|
||||
|
||||
it('keeps the sorted load when a limited scan asks for an order the key walk cannot give', async () => {
|
||||
await seedUsers(30);
|
||||
const descending = KeysetUsers.select({orderBy: {col: 'user_id', direction: 'DESC'}, limit: 5}).bind({});
|
||||
const rows = await executor.executeQuery<Row>(descending);
|
||||
const all = await oracle(KeysetUsers.select({}).bind({}));
|
||||
expect(rows).toEqual(all.slice(-5).reverse());
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,19 @@ interface PageState {
|
||||
offset: number;
|
||||
after?: string;
|
||||
keyed?: boolean;
|
||||
byRowKey?: boolean;
|
||||
since?: string;
|
||||
}
|
||||
|
||||
interface ScanWalk {
|
||||
numeric: boolean;
|
||||
after: string | null;
|
||||
since: string | null;
|
||||
}
|
||||
|
||||
interface NumericFloor {
|
||||
value: string;
|
||||
inclusive: boolean;
|
||||
}
|
||||
|
||||
interface PageEntry {
|
||||
@@ -90,6 +103,12 @@ const EXPIRED_STORED_ROW = 'kv.expires_at IS NOT NULL AND kv.expires_at <= now()
|
||||
const MERGED_ROW_DATA = `CASE WHEN ${EXPIRED_STORED_ROW} THEN EXCLUDED.row_data ELSE kv.row_data || EXCLUDED.row_data END`;
|
||||
const KEPT_EXPIRES_AT = `CASE WHEN ${EXPIRED_STORED_ROW} THEN NULL ELSE kv.expires_at END`;
|
||||
const NO_EXPIRY = 'infinity';
|
||||
const BIGINT_ROW_KEY_PREFIX = `{"${ENCODED_TYPE_KEY}":"bigint","value":"`;
|
||||
const NON_NUMERIC_ROW_KEY_RANGES = [
|
||||
`kv.row_key COLLATE "C" < '-'`,
|
||||
`kv.row_key COLLATE "C" >= ':' AND kv.row_key COLLATE "C" < '${BIGINT_ROW_KEY_PREFIX}'`,
|
||||
`kv.row_key COLLATE "C" >= '${BIGINT_ROW_KEY_PREFIX}:'`,
|
||||
];
|
||||
|
||||
export async function postgresKvPassIsFresh(
|
||||
client: IPostgresClient,
|
||||
@@ -676,15 +695,18 @@ function logError(details: Record<string, unknown>, message: string): void {
|
||||
|
||||
const fullScanLoggedAt = new Map<string, number>();
|
||||
|
||||
function logFullScan(meta: KvQueryMeta): void {
|
||||
function logFullScan(meta: KvQueryMeta, walk?: 'numeric' | 'row_key'): void {
|
||||
const shape = queryShape(meta);
|
||||
const key = `${meta.table.name}|${meta.action}|${shape.summary}`;
|
||||
const key = `${meta.table.name}|${meta.action}|${shape.summary}|${walk ?? 'load'}`;
|
||||
const now = Date.now();
|
||||
const last = fullScanLoggedAt.get(key);
|
||||
if (last !== undefined && now - last < FULL_SCAN_LOG_INTERVAL_MS) return;
|
||||
if (fullScanLoggedAt.size >= FULL_SCAN_LOG_KEY_LIMIT) fullScanLoggedAt.clear();
|
||||
fullScanLoggedAt.set(key, now);
|
||||
logWarn({table: meta.table.name, action: meta.action, where: shape.summary || 'none'}, 'Postgres KV full table scan');
|
||||
logWarn(
|
||||
{table: meta.table.name, action: meta.action, where: shape.summary || 'none', walk: walk ?? 'load'},
|
||||
'Postgres KV full table scan',
|
||||
);
|
||||
}
|
||||
|
||||
function ttlExpiresAt(meta: KvQueryMeta, params: CassandraParams): Date | typeof NO_EXPIRY | null | undefined {
|
||||
@@ -715,7 +737,13 @@ function decodePageState(pageState: string | null | undefined): PageState {
|
||||
throw new Error('Invalid Postgres KV page state');
|
||||
}
|
||||
if (typeof decoded.after !== 'string') return {offset: decoded.offset};
|
||||
return {offset: decoded.offset, after: decoded.after, keyed: decoded.keyed === true};
|
||||
return {
|
||||
offset: decoded.offset,
|
||||
after: decoded.after,
|
||||
keyed: decoded.keyed === true,
|
||||
byRowKey: decoded.byRowKey === true,
|
||||
...(typeof decoded.since === 'string' ? {since: decoded.since} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function pageableSelect(meta: KvQueryMeta, pageSize: number): boolean {
|
||||
@@ -734,22 +762,30 @@ function numericKeyValue(value: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function numericScanPlan(meta: KvQueryMeta, plan: QueryPlan): boolean {
|
||||
return plan.exact && plan.candidates.kind === 'scan' && (meta.table.primaryKey as ReadonlyArray<string>).length === 1;
|
||||
function numericRowKeyValue(key: string): string | null {
|
||||
const values = decodeRowKey(key, 1);
|
||||
return values === null ? null : numericKeyValue(values[0]);
|
||||
}
|
||||
|
||||
function numericScanKeyed(meta: KvQueryMeta, plan: QueryPlan, entries: ReadonlyArray<PageEntry>): boolean {
|
||||
if (!numericScanPlan(meta, plan)) return false;
|
||||
const column = (meta.table.primaryKey as ReadonlyArray<string>)[0]!;
|
||||
return entries.every((entry) => numericKeyValue(entry.row[column]) !== null);
|
||||
function singlePrimaryKey(meta: KvQueryMeta): boolean {
|
||||
return (meta.table.primaryKey as ReadonlyArray<string>).length === 1;
|
||||
}
|
||||
|
||||
function numericScanCursor(state: PageState): {rowKey: string; value: string} | null {
|
||||
if (state.keyed !== true || state.after === undefined) return null;
|
||||
const values = decodeRowKey(state.after, 1);
|
||||
if (values === null) return null;
|
||||
const value = numericKeyValue(values[0]);
|
||||
return value === null ? null : {rowKey: state.after, value};
|
||||
function walksInPrimaryKeyOrder(meta: KvQueryMeta): boolean {
|
||||
if (!singlePrimaryKey(meta)) return false;
|
||||
if (meta.orderBy === undefined) return true;
|
||||
return meta.orderBy.col === meta.table.primaryKey[0] && meta.orderBy.direction !== 'DESC';
|
||||
}
|
||||
|
||||
function numericFloor(meta: KvQueryMeta, params: CassandraParams): NumericFloor | null {
|
||||
const column = meta.table.primaryKey[0];
|
||||
for (const clause of whereClauses(meta)) {
|
||||
if (clause.col !== column) continue;
|
||||
if (clause.kind !== 'gt' && clause.kind !== 'tokenGt' && clause.kind !== 'gte') continue;
|
||||
const value = numericKeyValue(getParam(params, clause.param));
|
||||
if (value !== null) return {value, inclusive: clause.kind === 'gte'};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pageStart(meta: KvQueryMeta, entries: ReadonlyArray<PageEntry>, state: PageState): number {
|
||||
@@ -1018,10 +1054,10 @@ export class PostgresKvQueryExecutor {
|
||||
const meta = this.meta(query);
|
||||
if (pageableSelect(meta, options.pageSize) && (state.after !== undefined || state.offset === 0)) {
|
||||
const plan = buildCandidatePlan(meta, query.params);
|
||||
const cursor = numericScanPlan(meta, plan) ? numericScanCursor(state) : null;
|
||||
const page = cursor
|
||||
? await this.numericScanPage(meta, query.params, cursor, state.offset, options.pageSize)
|
||||
: await this.sortedPage(meta, query.params, plan, state, options.pageSize);
|
||||
const page =
|
||||
plan.candidates.kind === 'scan'
|
||||
? await this.scanPage(meta, query.params, await this.scanWalk(meta, state), state.offset, options.pageSize)
|
||||
: await this.sortedPage(meta, query.params, plan, state, options.pageSize);
|
||||
return {rows: page.rows as Array<T>, pageState: page.pageState};
|
||||
}
|
||||
const rows = await this.executeQuery<T, P>(query);
|
||||
@@ -1033,29 +1069,111 @@ export class PostgresKvQueryExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
private async numericScanPage(
|
||||
private async scanWalk(meta: KvQueryMeta, state: PageState): Promise<ScanWalk> {
|
||||
if (state.byRowKey) return {numeric: false, after: state.after ?? null, since: state.since ?? null};
|
||||
const after = state.after ?? null;
|
||||
const numericAfter = after === null || numericRowKeyValue(after) !== null;
|
||||
if (state.keyed && after !== null && numericAfter && singlePrimaryKey(meta)) {
|
||||
return {numeric: true, after, since: null};
|
||||
}
|
||||
const numeric = singlePrimaryKey(meta) && !(await this.hasNonNumericRows(meta, this.client));
|
||||
if (numeric && numericAfter) return {numeric: true, after, since: null};
|
||||
return {numeric: false, after: null, since: after};
|
||||
}
|
||||
|
||||
private async hasNonNumericRows(meta: KvQueryMeta, db: PostgresQueryable): Promise<boolean> {
|
||||
const probes = NON_NUMERIC_ROW_KEY_RANGES.map(
|
||||
(range) =>
|
||||
`(SELECT kv.row_key FROM ${this.table} kv WHERE kv.table_name = $1 AND ${range} AND (kv.expires_at IS NULL OR kv.expires_at > now()) AND ${NUMERIC_ROW_KEY} IS NULL ORDER BY kv.row_key COLLATE "C" LIMIT 1) IS NOT NULL`,
|
||||
);
|
||||
const result = await db.query<{found: boolean}>(`SELECT ${probes.join(' OR ')} AS found`, [meta.table.name]);
|
||||
return result.rows[0]?.found === true;
|
||||
}
|
||||
|
||||
private async scanPage(
|
||||
meta: KvQueryMeta,
|
||||
params: CassandraParams,
|
||||
cursor: {rowKey: string; value: string},
|
||||
walk: ScanWalk,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<{rows: Array<Row>; pageState: string | null}> {
|
||||
logFullScan(meta);
|
||||
const result = await this.client.query<StoredRow>(
|
||||
`SELECT kv.row_key, kv.row_data FROM ${this.table} kv WHERE kv.table_name = $1 AND ${NUMERIC_ROW_KEY} IS NOT NULL AND (${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C") > ($2::numeric, $3) AND (kv.expires_at IS NULL OR kv.expires_at > now()) ORDER BY ${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C" LIMIT $4`,
|
||||
[meta.table.name, cursor.value, cursor.rowKey, pageSize + 1],
|
||||
);
|
||||
const entries = this.matchingEntries(meta, result.rows.slice(0, pageSize), params);
|
||||
const last = entries[entries.length - 1];
|
||||
const page = await this.walkScan(meta, params, walk, pageSize, this.client);
|
||||
if (!page.more || page.after === null) return {rows: this.projected(meta, page.entries), pageState: null};
|
||||
const position = {offset: offset + page.entries.length, after: page.after};
|
||||
return {
|
||||
rows: this.projected(meta, entries),
|
||||
pageState:
|
||||
result.rows.length > pageSize && last
|
||||
? encodePageState({offset: offset + entries.length, after: last.key, keyed: true})
|
||||
: null,
|
||||
rows: this.projected(meta, page.entries),
|
||||
pageState: encodePageState(
|
||||
walk.numeric
|
||||
? {...position, keyed: true}
|
||||
: {...position, byRowKey: true, ...(walk.since === null ? {} : {since: walk.since})},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async walkScan(
|
||||
meta: KvQueryMeta,
|
||||
params: CassandraParams,
|
||||
walk: ScanWalk,
|
||||
want: number,
|
||||
db: PostgresQueryable,
|
||||
): Promise<{entries: Array<PageEntry>; after: string | null; more: boolean}> {
|
||||
logFullScan(meta, walk.numeric ? 'numeric' : 'row_key');
|
||||
const floor = walk.numeric ? numericFloor(meta, params) : null;
|
||||
const since = walk.since === null ? null : decodeRowKey(walk.since, meta.table.primaryKey.length);
|
||||
if (walk.since !== null && since === null) throw new Error('Invalid Postgres KV page state');
|
||||
const limit = want + 1;
|
||||
const entries: Array<PageEntry> = [];
|
||||
let after = walk.after;
|
||||
while (entries.length < want) {
|
||||
const batch = await this.scanBatch(meta, walk.numeric, floor, after, limit, db);
|
||||
for (let index = 0; index < batch.length; index += 1) {
|
||||
const stored = batch[index]!;
|
||||
after = stored.row_key;
|
||||
const entry = this.matchingEntry(meta, stored, params);
|
||||
if (entry && (since === null || compareRowToKeyValues(meta, entry.row, since) > 0)) entries.push(entry);
|
||||
if (entries.length === want) {
|
||||
return {entries, after, more: index < batch.length - 1 || batch.length === limit};
|
||||
}
|
||||
}
|
||||
if (batch.length < limit) return {entries, after, more: false};
|
||||
}
|
||||
return {entries, after, more: false};
|
||||
}
|
||||
|
||||
private async scanBatch(
|
||||
meta: KvQueryMeta,
|
||||
numeric: boolean,
|
||||
floor: NumericFloor | null,
|
||||
after: string | null,
|
||||
limit: number,
|
||||
db: PostgresQueryable,
|
||||
): Promise<Array<StoredRow>> {
|
||||
const params: Array<unknown> = [meta.table.name, limit];
|
||||
let predicate = '';
|
||||
if (numeric) {
|
||||
predicate = ` AND ${NUMERIC_ROW_KEY} IS NOT NULL`;
|
||||
if (floor) {
|
||||
params.push(floor.value);
|
||||
predicate += ` AND ${NUMERIC_ROW_KEY} ${floor.inclusive ? '>=' : '>'} $${params.length}::numeric`;
|
||||
}
|
||||
if (after !== null) {
|
||||
const value = numericRowKeyValue(after);
|
||||
if (value === null) throw new Error('Invalid Postgres KV page state');
|
||||
params.push(value, after);
|
||||
predicate += ` AND (${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C") > ($${params.length - 1}::numeric, $${params.length})`;
|
||||
}
|
||||
} else if (after !== null) {
|
||||
params.push(after);
|
||||
predicate = ` AND kv.row_key COLLATE "C" > $${params.length}`;
|
||||
}
|
||||
const order = numeric ? `${NUMERIC_ROW_KEY}, kv.row_key COLLATE "C"` : 'kv.row_key COLLATE "C"';
|
||||
const result = await db.query<StoredRow>(
|
||||
`SELECT kv.row_key, kv.row_data FROM ${this.table} kv WHERE kv.table_name = $1${predicate} AND (kv.expires_at IS NULL OR kv.expires_at > now()) ORDER BY ${order} LIMIT $2`,
|
||||
params,
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
private async sortedPage(
|
||||
meta: KvQueryMeta,
|
||||
params: CassandraParams,
|
||||
@@ -1071,12 +1189,9 @@ export class PostgresKvQueryExecutor {
|
||||
const last = page[page.length - 1];
|
||||
const nextOffset = start + page.length;
|
||||
if (nextOffset >= entries.length || !last) return {rows: this.projected(meta, page), pageState: null};
|
||||
const keyed = numericScanKeyed(meta, plan, entries);
|
||||
return {
|
||||
rows: this.projected(meta, page),
|
||||
pageState: encodePageState(
|
||||
keyed ? {offset: nextOffset, after: last.key, keyed: true} : {offset: nextOffset, after: last.key},
|
||||
),
|
||||
pageState: encodePageState({offset: nextOffset, after: last.key}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1146,17 +1261,22 @@ export class PostgresKvQueryExecutor {
|
||||
stored: ReadonlyArray<StoredRow>,
|
||||
params: CassandraParams,
|
||||
): Array<PageEntry> {
|
||||
const required = queryShape(meta).requiredColumns;
|
||||
const entries: Array<PageEntry> = [];
|
||||
for (const entry of stored) {
|
||||
const row = required ? decodeRowColumns(entry.row_data, required) : decodeRow(entry.row_data);
|
||||
if (matchesWhere(row, meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params)) {
|
||||
entries.push({key: entry.row_key, row});
|
||||
}
|
||||
const matched = this.matchingEntry(meta, entry, params);
|
||||
if (matched) entries.push(matched);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private matchingEntry(meta: KvQueryMeta, stored: StoredRow, params: CassandraParams): PageEntry | null {
|
||||
const required = queryShape(meta).requiredColumns;
|
||||
const row = required ? decodeRowColumns(stored.row_data, required) : decodeRow(stored.row_data);
|
||||
return matchesWhere(row, meta.where as ReadonlyArray<WhereExpr<Row>> | undefined, params)
|
||||
? {key: stored.row_key, row}
|
||||
: null;
|
||||
}
|
||||
|
||||
private matchingRows(meta: KvQueryMeta, stored: ReadonlyArray<StoredRow>, params: CassandraParams): Array<Row> {
|
||||
return this.matchingEntries(meta, stored, params).map((entry) => entry.row);
|
||||
}
|
||||
@@ -1185,6 +1305,16 @@ export class PostgresKvQueryExecutor {
|
||||
) {
|
||||
return this.unorderedScan(meta, meta.limit, db);
|
||||
}
|
||||
if (
|
||||
typeof meta.limit === 'number' &&
|
||||
Number.isInteger(meta.limit) &&
|
||||
plan.candidates.kind === 'scan' &&
|
||||
walksInPrimaryKeyOrder(meta) &&
|
||||
!(await this.hasNonNumericRows(meta, db))
|
||||
) {
|
||||
const walk = {numeric: true, after: null, since: null};
|
||||
return this.projected(meta, (await this.walkScan(meta, params, walk, meta.limit, db)).entries);
|
||||
}
|
||||
let rows = this.matchingRows(meta, await this.candidates(meta, plan, db), params);
|
||||
rows = sortRows(meta, rows);
|
||||
if (typeof meta.limit === 'number') rows = rows.slice(0, meta.limit);
|
||||
|
||||
@@ -55,15 +55,15 @@ describe('Postgres KV unordered scan', () => {
|
||||
expect(rows).toEqual([{user_id: '2'}, {user_id: '1'}]);
|
||||
});
|
||||
|
||||
it('keeps the sorted full scan when order is asked for', async () => {
|
||||
it('keeps the sorted full scan when the order cannot be walked by key', async () => {
|
||||
const statements: Array<{text: string; values: Array<unknown> | undefined}> = [];
|
||||
const executor = new PostgresKvQueryExecutor(recordingClient(statements));
|
||||
const rows = await executor.executeQuery({
|
||||
cql: '__ordered_probe',
|
||||
params: {},
|
||||
kvMeta: selectMeta({limit: 1}),
|
||||
kvMeta: selectMeta({limit: 1, orderBy: {col: 'user_id', direction: 'DESC'}}),
|
||||
});
|
||||
expect(statements[0]!.text).not.toContain('LIMIT');
|
||||
expect(rows).toEqual([{user_id: '1'}]);
|
||||
expect(rows).toEqual([{user_id: '2'}]);
|
||||
});
|
||||
});
|
||||
@@ -936,6 +936,7 @@ function pendingRegistration(userId: string) {
|
||||
requested_at: `2026-09-01T00:00:${userId.slice(-2)}.000Z`,
|
||||
registration_url_id: null,
|
||||
client_ip: '127.0.0.1',
|
||||
invite_code: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1093,6 +1093,7 @@ const StoredPendingRegistrationSchema = PendingRegistrationResponse.extend({
|
||||
requested_at: StoredRegistrationTimestampSchema,
|
||||
registration_url_id: StoredNullableStringSchema.pipe(StoredRegistrationUrlIdSchema.nullable()),
|
||||
client_ip: StoredNullableStringSchema,
|
||||
invite_code: StoredNullableStringSchema,
|
||||
}) satisfies z.ZodType<InstancePendingRegistration>;
|
||||
|
||||
function isRegistrationUrlUsable(registrationUrl: InstanceRegistrationUrl, now: Date): boolean {
|
||||
|
||||
@@ -521,6 +521,7 @@ class RequestServices implements RequestScopedServices {
|
||||
getDiscriminatorService(),
|
||||
getKVActivityTracker(),
|
||||
this.singleCommunityService,
|
||||
this.inviteService,
|
||||
);
|
||||
return this.cachedSsoService;
|
||||
}
|
||||
|
||||
Generated
+2
@@ -39859,6 +39859,7 @@
|
||||
"REACTION_EMAIL_VERIFICATION_REQUIRED",
|
||||
"REPORT_EMAIL_VERIFICATION_REQUIRED",
|
||||
"REGISTRATION_CLOSED",
|
||||
"REGISTRATION_CREDENTIALS_REQUIRED",
|
||||
"REGISTRATION_PENDING_APPROVAL",
|
||||
"REGISTRATION_REJECTED",
|
||||
"REGISTRATION_URL_INVALID",
|
||||
@@ -40154,6 +40155,7 @@
|
||||
"Email verification is required for this action.",
|
||||
"Email verification is required for this action.",
|
||||
"Registration is closed on this instance.",
|
||||
"This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.",
|
||||
"This registration is waiting for admin approval.",
|
||||
"This registration request was rejected.",
|
||||
"This registration link is invalid or has expired.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
|
||||
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
@@ -26,8 +26,6 @@ export interface GuildComboboxRenderersConfig<T extends GuildComboboxOption> {
|
||||
getNotice?: (option: T, disabled: boolean) => React.ReactNode;
|
||||
}
|
||||
|
||||
const ROW_INITIALS_MAX_LENGTH = 1;
|
||||
|
||||
const renderRow = <T extends GuildComboboxOption>(
|
||||
option: T,
|
||||
disabled: boolean,
|
||||
@@ -37,7 +35,6 @@ const renderRow = <T extends GuildComboboxOption>(
|
||||
) => {
|
||||
const isGlobal = !option.value;
|
||||
const iconUrl = option.iconUrl ?? null;
|
||||
const initial = truncateInitials(getInitialsFromName(option.label), ROW_INITIALS_MAX_LENGTH).toUpperCase();
|
||||
const notice = getNotice?.(option, disabled);
|
||||
return (
|
||||
<div
|
||||
@@ -58,7 +55,10 @@ const renderRow = <T extends GuildComboboxOption>(
|
||||
aria-hidden
|
||||
data-flx="app.guild-combobox-renderers.render-row.avatar-placeholder"
|
||||
>
|
||||
{initial}
|
||||
<GuildAcronym
|
||||
name={option.label}
|
||||
data-flx="app.guild-combobox-renderers.render-row.avatar-placeholder-acronym"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<span className={styles.label} data-flx="app.guild-combobox-renderers.render-row.label">
|
||||
|
||||
@@ -185,19 +185,12 @@
|
||||
background-color: transparent;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-radius 70ms ease-out,
|
||||
background-color 70ms ease-out,
|
||||
color 70ms ease-out;
|
||||
container-type: size;
|
||||
--initials-font-size: clamp(0.85rem, 45cqi, 1.35rem);
|
||||
--initials-fit-inline-size: 92cqi;
|
||||
--initials-letter-spacing: 0px;
|
||||
--initials-scale: 1;
|
||||
}
|
||||
|
||||
.guildIconSelected:not(.guildIconNoImage) {
|
||||
@@ -242,28 +235,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.guildIconInitials {
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
font-size: min(
|
||||
var(--initials-font-size),
|
||||
calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale)))
|
||||
);
|
||||
letter-spacing: var(--initials-letter-spacing);
|
||||
transform: scale(var(--initials-scale));
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.guildIcon[data-initials-length='medium'] .guildIconInitials {
|
||||
--initials-font-size: clamp(0.85rem, 38cqi, 1.11rem);
|
||||
--initials-letter-spacing: 0px;
|
||||
--initials-scale: 0.92;
|
||||
}
|
||||
|
||||
.guildIndicator {
|
||||
position: absolute;
|
||||
left: var(--guild-list-indicator-inset);
|
||||
@@ -765,10 +736,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.combinePreviewIconInitials {
|
||||
|
||||
@@ -124,15 +124,7 @@
|
||||
|
||||
.miniGuildIconWithInitials {
|
||||
background-color: var(--folder-mini-surface);
|
||||
}
|
||||
|
||||
.miniGuildInitials {
|
||||
font-size: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folderBadge {
|
||||
|
||||
@@ -23,10 +23,10 @@ import {
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import {useHover} from '@app/features/app/hooks/useHover';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import GuildFolderExpanded from '@app/features/guild/state/GuildFolderExpanded';
|
||||
import GuildReadState from '@app/features/guild/state/GuildReadState';
|
||||
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {MENTION_COUNT_ARIA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
|
||||
@@ -760,8 +760,6 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
const MINI_GUILD_INITIALS_MAX_LENGTH = 2;
|
||||
|
||||
interface MiniGuildIconProps {
|
||||
readonly guild: Guild;
|
||||
}
|
||||
@@ -782,12 +780,7 @@ const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
|
||||
className={flxElementClassName(styles.miniGuildIcon, styles.miniGuildIconWithInitials)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-icon--2"
|
||||
>
|
||||
<span
|
||||
className={styles.miniGuildInitials}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-initials"
|
||||
>
|
||||
{truncateInitials(getInitialsFromName(guild.name), MINI_GUILD_INITIALS_MAX_LENGTH)}
|
||||
</span>
|
||||
<GuildAcronym name={guild.name} data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.acronym" />
|
||||
</flx-app-mini-guild-icon>
|
||||
);
|
||||
});
|
||||
+9
-27
@@ -9,16 +9,9 @@ import type {GuildListItemDragAndDrop} from '@app/features/app/components/layout
|
||||
import {VoiceBadge, type VoiceBadgeActivity} from '@app/features/app/components/layout/sidebar_nav/VoiceBadge';
|
||||
import {DropPlacement} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {
|
||||
type GuildInitialsLength,
|
||||
getGuildIconDisplayInitials,
|
||||
getGuildInitialsFitStyle,
|
||||
getInitialsFromName,
|
||||
getInitialsLength,
|
||||
truncateInitials,
|
||||
} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {ariaCurrentPage} from '@app/features/platform/ARIAProps';
|
||||
import {Edge} from '@app/features/ui/AxisOrientation';
|
||||
import {MentionBadgeAnimated} from '@app/features/ui/components/MentionBadge';
|
||||
@@ -58,13 +51,6 @@ function resolveGuildListItemMotion(useReducedMotion: boolean): GuildListItemMot
|
||||
return STANDARD_GUILD_LIST_ITEM_MOTION;
|
||||
}
|
||||
|
||||
function resolveInitialsLength(displayInitials: string): GuildInitialsLength | null {
|
||||
if (displayInitials.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return getInitialsLength(displayInitials);
|
||||
}
|
||||
|
||||
function resolveDraggingCursor(dragAndDrop: GuildListItemDragAndDrop | null): 'grabbing' | null {
|
||||
if (dragAndDrop == null) {
|
||||
return null;
|
||||
@@ -164,7 +150,6 @@ function GuildListItemBadges({
|
||||
|
||||
function CombinePreviewIcon({guild}: {readonly guild: Guild}) {
|
||||
const url = AvatarSourceUtils.getGuildIconURL(guild, false);
|
||||
const initials = truncateInitials(getInitialsFromName(guild.name), 2);
|
||||
const backgroundStyle: React.CSSProperties = {};
|
||||
if (url.length > 0) {
|
||||
backgroundStyle.backgroundImage = `url(${url})`;
|
||||
@@ -176,7 +161,10 @@ function CombinePreviewIcon({guild}: {readonly guild: Guild}) {
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.combine-preview-icon"
|
||||
>
|
||||
{url.length === 0 && (
|
||||
<span data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.span">{initials}</span>
|
||||
<GuildAcronym
|
||||
name={guild.name}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.combine-preview-icon.acronym"
|
||||
/>
|
||||
)}
|
||||
</flx-app-combine-preview-icon>
|
||||
);
|
||||
@@ -304,8 +292,6 @@ export const GuildListItemPresentation = forwardRef<
|
||||
forwardedRef,
|
||||
) {
|
||||
const rootRef = useMergeRefs([surfaceRef, forwardedRef]);
|
||||
const displayInitials = getGuildIconDisplayInitials(getInitialsFromName(guild.name));
|
||||
const initialsLength = resolveInitialsLength(displayInitials);
|
||||
const dropIndicator = resolveDropIndicator(dragAndDrop);
|
||||
const motion = resolveGuildListItemMotion(Accessibility.useReducedMotion);
|
||||
const draggingCursor = resolveDraggingCursor(dragAndDrop);
|
||||
@@ -372,18 +358,14 @@ export const GuildListItemPresentation = forwardRef<
|
||||
to={guildIconTarget}
|
||||
from={false}
|
||||
tween={{duration: motion.iconTweenDuration, ease: 'easeOut'}}
|
||||
data-initials-length={initialsLength}
|
||||
style={guildIconStyle}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon"
|
||||
>
|
||||
{!hasPaintedIcon && (
|
||||
<span
|
||||
className={styles.guildIconInitials}
|
||||
style={getGuildInitialsFitStyle(displayInitials)}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-initials"
|
||||
>
|
||||
{displayInitials}
|
||||
</span>
|
||||
<GuildAcronym
|
||||
name={guild.name}
|
||||
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-acronym"
|
||||
/>
|
||||
)}
|
||||
</GuildListItemIcon>
|
||||
<GuildListItemBadges
|
||||
|
||||
@@ -5,6 +5,7 @@ import {SetupWelcomeGate} from '@app/features/app/components/setup/SetupWelcomeG
|
||||
import type {SetupBrandingAssetKind} from '@app/features/app/components/setup/SetupWizardClient';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
|
||||
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {ColorPickerField} from '@app/features/ui/components/form/ColorPickerField';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
@@ -751,13 +752,7 @@ export const AdminAccountStep = observer(
|
||||
<section className={styles.step} data-flx="app.self-hosted-setup-wizard-gate.admin-account-step">
|
||||
<div className={styles.accountSetupForm} data-flx="app.self-hosted-setup-wizard-gate.admin-form">
|
||||
<AuthRegisterFormCore
|
||||
fields={{
|
||||
showEmail: !usernameSignIn,
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: usernameSignIn,
|
||||
}}
|
||||
fields={credentialRegisterFields(usernameSignIn)}
|
||||
submitLabel={i18n._(CREATE_ADMIN_ACCOUNT_DESCRIPTOR)}
|
||||
redirectPath=""
|
||||
runtimeSnapshot={RuntimeConfig.getSnapshot()}
|
||||
|
||||
@@ -155,6 +155,7 @@ class Updater {
|
||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||
private unsubscribeDesktopUpdate: (() => void) | null = null;
|
||||
private desktopUpdateReported = false;
|
||||
private desktopUpdateReportedReplacesShell = false;
|
||||
private desktopUpdateRunning = false;
|
||||
private olderShellModuleUpdateReady = false;
|
||||
private olderShellUpdateDownloaded = false;
|
||||
@@ -203,6 +204,10 @@ class Updater {
|
||||
return this.desktopUpdateReported || this.olderShellModuleUpdateReady || this.olderShellUpdateAvailable;
|
||||
}
|
||||
|
||||
get desktopUpdateReplacesShell(): boolean {
|
||||
return (this.desktopUpdateReported && this.desktopUpdateReportedReplacesShell) || this.olderShellUpdateAvailable;
|
||||
}
|
||||
|
||||
private get olderShellUpdateAvailable(): boolean {
|
||||
const electronApi = getElectronAPI();
|
||||
if (electronApi == null || electronApi.desktopUpdate != null || electronApi.updaterInstall == null) {
|
||||
@@ -281,6 +286,7 @@ class Updater {
|
||||
this.unsubscribeDesktopUpdate = desktopUpdate.onStateChanged((state) => {
|
||||
runInAction(() => {
|
||||
this.desktopUpdateReported = state.available;
|
||||
this.desktopUpdateReportedReplacesShell = state.replacesShell === true;
|
||||
this.desktopUpdateRunning = state.updating === true;
|
||||
});
|
||||
});
|
||||
@@ -314,6 +320,7 @@ class Updater {
|
||||
const state = await desktopUpdate.state();
|
||||
runInAction(() => {
|
||||
this.desktopUpdateReported = state.available;
|
||||
this.desktopUpdateReportedReplacesShell = state.replacesShell === true;
|
||||
this.desktopUpdateRunning = state.updating === true;
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -669,6 +676,7 @@ class Updater {
|
||||
this.unsubscribeDesktopUpdate = null;
|
||||
}
|
||||
this.desktopUpdateReported = false;
|
||||
this.desktopUpdateReportedReplacesShell = false;
|
||||
this.desktopUpdateRunning = false;
|
||||
this.olderShellModuleUpdateReady = false;
|
||||
this.olderShellUpdateDownloaded = false;
|
||||
|
||||
@@ -769,7 +769,7 @@ export async function fetchDesktopHandoffInfo({
|
||||
return response.body;
|
||||
}
|
||||
|
||||
export interface CompleteLoginOptions {
|
||||
interface CompleteLoginOptions {
|
||||
redirectPath?: string | null;
|
||||
}
|
||||
|
||||
|
||||
+2
-7
@@ -7,6 +7,7 @@ import authStyles from '@app/features/auth/components/accounts/account_switcher_
|
||||
import pageStyles from '@app/features/auth/flow/AuthPageStyles.module.css';
|
||||
import {AuthRegisterFormCore} from '@app/features/auth/flow/AuthRegisterFormCore';
|
||||
import {AuthSsoPanel, isSsoLoginEnforced, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
|
||||
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
|
||||
import type {LoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
|
||||
import Theme from '@app/features/theme/state/Theme';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
@@ -50,13 +51,7 @@ export const AccountSwitcherRegisterStep = observer(function AccountSwitcherRegi
|
||||
data-flx="auth.accounts.account-switcher-modal.account-switcher-register-step.container"
|
||||
>
|
||||
<AuthRegisterFormCore
|
||||
fields={{
|
||||
showEmail: !usernameSignIn,
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: usernameSignIn,
|
||||
}}
|
||||
fields={credentialRegisterFields(usernameSignIn)}
|
||||
submitLabel={<Trans>Create account</Trans>}
|
||||
redirectPath=""
|
||||
offerRecoveryKit={usernameSignIn}
|
||||
|
||||
@@ -64,7 +64,7 @@ const LoginPage = observer(function LoginPage({onBackActionChange}: LoginPagePro
|
||||
title={null}
|
||||
onLoginComplete={runtimeTarget.reset}
|
||||
onBackActionChange={onBackActionChange}
|
||||
completeLoginRedirectPath={null}
|
||||
completeLoginRedirectPath={Routes.ME}
|
||||
forceCredentials={false}
|
||||
startWithAddAccount={false}
|
||||
runtimeTarget={runtimeTarget}
|
||||
|
||||
@@ -320,9 +320,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.75rem;
|
||||
overflow: hidden;
|
||||
border: 0.0625rem solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {AUTH_LOGIN_METHOD_QUERY_PARAM} from '@app/features/auth/flow/auth_login_
|
||||
import {AuthClientPermissionStep} from '@app/features/auth/flow/client_intro/AuthClientPermissionStep';
|
||||
import {AuthClientPreferencesStep} from '@app/features/auth/flow/client_intro/AuthClientPreferencesStep';
|
||||
import {useDesktopClientIntroFlow} from '@app/features/auth/flow/client_intro/useDesktopClientIntroFlow';
|
||||
import {credentialRegisterFields} from '@app/features/auth/flow/RegisterFormFields';
|
||||
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
|
||||
import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext';
|
||||
import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget';
|
||||
@@ -116,13 +117,7 @@ const RegisterPageContent = observer(function RegisterPageContent({runtimeSnapsh
|
||||
</h1>
|
||||
<div className={sharedStyles.container} data-flx="auth.register-page.register-page-content.div">
|
||||
<AuthRegisterFormCore
|
||||
fields={{
|
||||
showEmail: !usernameSignIn,
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: usernameSignIn,
|
||||
}}
|
||||
fields={credentialRegisterFields(usernameSignIn)}
|
||||
submitLabel={<Trans>Create account</Trans>}
|
||||
redirectPath={redirectTo}
|
||||
offerRecoveryKit={usernameSignIn}
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ export const OAuthCommunityStep: React.FC<OAuthCommunityStepProps> = ({
|
||||
{destinationsError}
|
||||
</div>
|
||||
)}
|
||||
{!destinationsLoading && destinationOptions.length === 0 && (
|
||||
{!destinationsLoading && !destinationsError && destinationOptions.length === 0 && (
|
||||
<div className={styles.emptyState} data-flx="auth.o-auth-authorize-page.community-step.empty-state">
|
||||
{guildSelectorEmptyState}
|
||||
</div>
|
||||
|
||||
+30
-5
@@ -10,6 +10,8 @@ import {
|
||||
} from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import {formatList} from '@app/features/i18n/utils/IntlCache';
|
||||
import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork';
|
||||
import {isAccountTransitionAbortError} from '@app/features/platform/state/AccountTransitionAbort';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
@@ -106,9 +108,14 @@ function getGroupDmLabel(channel: ChannelResponse, unknownGroupDmLabel: string,
|
||||
return unknownGroupDmLabel;
|
||||
}
|
||||
|
||||
export function useBotInviteDestinations(enabled: boolean, rawPermissions: string | null): BotInviteDestinationsResult {
|
||||
export function useBotInviteDestinations(
|
||||
enabled: boolean,
|
||||
rawPermissions: string | null,
|
||||
revalidate: boolean,
|
||||
): BotInviteDestinationsResult {
|
||||
const {i18n} = useLingui();
|
||||
const currentAccountKey = Accounts.currentAccountKey;
|
||||
const accountWorkAdmitted = !AccountScopedWork.isSuspended;
|
||||
const requestedPermissions = useMemo(() => parseRequestedBotPermissions(rawPermissions, false), [rawPermissions]);
|
||||
const i18nRef = useRef(i18n);
|
||||
i18nRef.current = i18n;
|
||||
@@ -116,17 +123,32 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin
|
||||
const [rawGuilds, setRawGuilds] = useState<ReadonlyArray<GuildSummary>>([]);
|
||||
const [rawChannels, setRawChannels] = useState<ReadonlyArray<ChannelResponse>>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const loadedAccountKeyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!revalidate) return;
|
||||
const bumpRevision = () => setRevision((value) => value + 1);
|
||||
bumpRevision();
|
||||
window.addEventListener('focus', bumpRevision);
|
||||
return () => {
|
||||
window.removeEventListener('focus', bumpRevision);
|
||||
};
|
||||
}, [revalidate]);
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
loadedAccountKeyRef.current = null;
|
||||
setStatus('idle');
|
||||
setRawGuilds([]);
|
||||
setRawChannels([]);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (loadedAccountKeyRef.current !== currentAccountKey) {
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
}
|
||||
if (!accountWorkAdmitted) return;
|
||||
let cancelled = false;
|
||||
setStatus('loading');
|
||||
setError(null);
|
||||
(async () => {
|
||||
try {
|
||||
const [guildsResp, channelsResp] = await Promise.all([
|
||||
@@ -136,10 +158,13 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin
|
||||
if (cancelled) return;
|
||||
setRawGuilds(guildsResp.body);
|
||||
setRawChannels(channelsResp.body.filter((channel) => channel.type === ChannelTypes.GROUP_DM));
|
||||
loadedAccountKeyRef.current = currentAccountKey;
|
||||
setError(null);
|
||||
setStatus('ready');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (cancelled || isAccountTransitionAbortError(err)) return;
|
||||
logger.error('Failed to fetch bot invite destinations', err);
|
||||
loadedAccountKeyRef.current = null;
|
||||
setError(failureMessage(err) ?? i18nRef.current._(FAILED_TO_LOAD_INVITE_DESTINATIONS_DESCRIPTOR));
|
||||
setRawGuilds([]);
|
||||
setRawChannels([]);
|
||||
@@ -149,7 +174,7 @@ export function useBotInviteDestinations(enabled: boolean, rawPermissions: strin
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, currentAccountKey]);
|
||||
}, [enabled, currentAccountKey, accountWorkAdmitted, revision]);
|
||||
const guilds = useMemo<ReadonlyArray<GuildWithPermissions>>(
|
||||
() => mapBotGuilds(rawGuilds, requestedPermissions, i18n._(UNKNOWN_COMMUNITY_DESCRIPTOR)),
|
||||
[rawGuilds, requestedPermissions, i18n.locale],
|
||||
|
||||
+7
-3
@@ -8,6 +8,8 @@ import {
|
||||
} from '@app/features/auth/components/pages/oauth_authorize_page/OAuthAuthorizePageShared';
|
||||
import accountStorage from '@app/features/auth/state/AccountStorage';
|
||||
import Accounts from '@app/features/auth/state/Accounts';
|
||||
import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork';
|
||||
import {isAccountTransitionAbortError} from '@app/features/platform/state/AccountTransitionAbort';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {useEffect, useState} from 'react';
|
||||
@@ -23,6 +25,7 @@ export interface PublicAppState {
|
||||
export function useOAuthPublicApp(clientId: string | null): PublicAppState {
|
||||
const currentUserId = Accounts.currentUserId;
|
||||
const currentAccountKey = Accounts.currentAccountKey;
|
||||
const accountWorkAdmitted = !AccountScopedWork.isSuspended;
|
||||
const [state, setState] = useState<PublicAppState>(() => ({
|
||||
status: clientId ? 'loading' : 'idle',
|
||||
data: null,
|
||||
@@ -33,8 +36,9 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
|
||||
setState({status: 'idle', data: null, error: null});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setState({status: 'loading', data: null, error: null});
|
||||
if (!accountWorkAdmitted) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await http.get<PublicAppData>(Endpoints.OAUTH_PUBLIC_APPLICATION(clientId));
|
||||
@@ -56,7 +60,7 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
|
||||
}
|
||||
setState({status: 'ready', data: resp.body, error: null});
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (cancelled || isAccountTransitionAbortError(err)) return;
|
||||
if (err instanceof HttpError && err.status === 401) {
|
||||
logger.warn('OAuth public app fetch returned 401', err);
|
||||
setState({status: 'session_expired', data: null, error: err});
|
||||
@@ -69,6 +73,6 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [clientId, currentAccountKey, currentUserId]);
|
||||
}, [clientId, currentAccountKey, currentUserId, accountWorkAdmitted]);
|
||||
return state;
|
||||
}
|
||||
+15
-14
@@ -185,7 +185,11 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
const initialMissingClientId = !params;
|
||||
const publicAppState = useOAuthPublicApp(params?.clientId ?? null);
|
||||
const scopeSelection = useScopeSelection(scopes);
|
||||
const destinations = useBotInviteDestinations(hasBotScope, params?.permissions ?? null);
|
||||
const destinations = useBotInviteDestinations(
|
||||
hasBotScope,
|
||||
params?.permissions ?? null,
|
||||
phase.kind === 'review' && phase.step === 'community',
|
||||
);
|
||||
const selectedDestinationTarget = parseBotInviteDestinationKey(selectedDestinationKey);
|
||||
const selectedGuildId = selectedDestinationTarget?.kind === 'guild' ? selectedDestinationTarget.id : null;
|
||||
const permissionSelection = usePermissionSelection(
|
||||
@@ -280,18 +284,15 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
});
|
||||
}, [phase.kind]);
|
||||
const currentAccountKey = Accounts.currentAccountKey;
|
||||
const destinationInitRef = useRef(false);
|
||||
useEffect(() => {
|
||||
destinationInitRef.current = false;
|
||||
setSelectedDestinationKey(null);
|
||||
}, [currentAccountKey]);
|
||||
useEffect(() => {
|
||||
if (!hasBotScope) {
|
||||
destinationInitRef.current = false;
|
||||
setSelectedDestinationKey(null);
|
||||
return;
|
||||
}
|
||||
if (destinationInitRef.current) return;
|
||||
if (destinations.options.some((option) => option.value === selectedDestinationKey)) return;
|
||||
const requestedDestinationKey = params?.channelId
|
||||
? createBotInviteDestinationKey('group_dm', params.channelId)
|
||||
: params?.guildId
|
||||
@@ -304,23 +305,23 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
? requestedDestinationKey
|
||||
: null,
|
||||
);
|
||||
destinationInitRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (destinations.status !== 'ready') return;
|
||||
const preferredDestinationKey = preferredDestinationKeyRef.current;
|
||||
if (preferredDestinationKey && destinations.options.some((option) => option.value === preferredDestinationKey)) {
|
||||
setSelectedDestinationKey(preferredDestinationKey);
|
||||
destinationInitRef.current = true;
|
||||
return;
|
||||
}
|
||||
if (destinations.options.length > 0) {
|
||||
setSelectedDestinationKey(destinations.options[0].value);
|
||||
destinationInitRef.current = true;
|
||||
return;
|
||||
}
|
||||
destinationInitRef.current = true;
|
||||
}, [hasBotScope, params?.guildId, params?.channelId, destinations.options, destinations.status]);
|
||||
setSelectedDestinationKey(destinations.options[0]?.value ?? null);
|
||||
}, [
|
||||
hasBotScope,
|
||||
params?.guildId,
|
||||
params?.channelId,
|
||||
destinations.options,
|
||||
destinations.status,
|
||||
selectedDestinationKey,
|
||||
]);
|
||||
const selectedDestination = useMemo(
|
||||
() => destinations.options.find((option) => option.value === selectedDestinationKey) ?? null,
|
||||
[destinations.options, selectedDestinationKey],
|
||||
|
||||
@@ -121,7 +121,7 @@ export interface AuthLoginLayoutProps {
|
||||
readonly registerLink: ReactElement<Record<string, unknown>>;
|
||||
readonly onLoginComplete?: ((payload: LoginSuccessPayload) => Promise<void> | void) | null;
|
||||
readonly onBackActionChange?: ((action: (() => void) | null) => void) | null;
|
||||
readonly completeLoginRedirectPath?: string | null;
|
||||
readonly completeLoginRedirectPath: string | null;
|
||||
readonly forceCredentials?: boolean;
|
||||
readonly startWithAddAccount?: boolean;
|
||||
readonly runtimeTarget: AuthRuntimeTarget;
|
||||
@@ -343,15 +343,6 @@ function resolveMethodSsoAction({
|
||||
return {isStarting, label, onStart};
|
||||
}
|
||||
|
||||
function resolveCompleteLoginOptions(
|
||||
completeLoginRedirectPath: string | null,
|
||||
): AuthenticationCommands.CompleteLoginOptions {
|
||||
if (completeLoginRedirectPath == null) {
|
||||
return {};
|
||||
}
|
||||
return {redirectPath: completeLoginRedirectPath};
|
||||
}
|
||||
|
||||
function resolveAuthBackAction({
|
||||
accountListBackAction,
|
||||
authLoginStep,
|
||||
@@ -400,7 +391,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
registerLink,
|
||||
onLoginComplete = null,
|
||||
onBackActionChange = null,
|
||||
completeLoginRedirectPath = null,
|
||||
completeLoginRedirectPath,
|
||||
forceCredentials = false,
|
||||
startWithAddAccount = false,
|
||||
runtimeTarget,
|
||||
@@ -509,7 +500,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
}
|
||||
await AuthenticationCommands.completeLogin(
|
||||
{...payload, runtimeSnapshot: requireAuthInstanceSnapshot(resolvedAuthRuntimeSnapshot)},
|
||||
resolveCompleteLoginOptions(completeLoginRedirectPath),
|
||||
{redirectPath: completeLoginRedirectPath},
|
||||
);
|
||||
if (onLoginComplete != null) {
|
||||
await onLoginComplete(payload);
|
||||
@@ -572,7 +563,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
);
|
||||
const handleStoredAccountLogin = useCallback(
|
||||
async (payload: StoredAccountLoginPayload) => {
|
||||
await AuthenticationCommands.completeLogin(payload, resolveCompleteLoginOptions(completeLoginRedirectPath));
|
||||
await AuthenticationCommands.completeLogin(payload, {redirectPath: completeLoginRedirectPath});
|
||||
if (onLoginComplete != null) {
|
||||
await onLoginComplete(payload);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
|
||||
import FormField from '@app/features/auth/flow/AuthFormField';
|
||||
import styles from '@app/features/auth/flow/AuthPageStyles.module.css';
|
||||
import {DateOfBirthField} from '@app/features/auth/flow/DateOfBirthField';
|
||||
import {type RegisterFormFields, readRegistrationUrlCode} from '@app/features/auth/flow/RegisterFormFields';
|
||||
import {
|
||||
getRegistrationLegalConsentConfig,
|
||||
RegistrationLegalConsent,
|
||||
@@ -83,17 +84,8 @@ const USERNAME_AVAILABLE_DESCRIPTOR = msg({
|
||||
const MAX_USERNAME_LENGTH = 32;
|
||||
const USERNAME_PATTERN = /^[a-zA-Z0-9_]+$/;
|
||||
|
||||
interface FieldConfig {
|
||||
showEmail?: boolean;
|
||||
showPassword?: boolean;
|
||||
showPasswordConfirmation?: boolean;
|
||||
showUsername?: boolean;
|
||||
showUsernameValidation?: boolean;
|
||||
requireUsername?: boolean;
|
||||
}
|
||||
|
||||
interface AuthRegisterFormCoreProps {
|
||||
fields?: FieldConfig;
|
||||
fields?: RegisterFormFields;
|
||||
submitLabel: React.ReactNode;
|
||||
redirectPath: string;
|
||||
runtimeSnapshot: RuntimeConfigSnapshot;
|
||||
@@ -136,10 +128,10 @@ export const AuthRegisterFormCore = observer(function AuthRegisterFormCore({
|
||||
const firstFieldName = showEmail ? 'email' : requireUsername ? 'username' : 'global_name';
|
||||
const location = useLocation();
|
||||
const draftKey = `register:${location.pathname}${location.search}`;
|
||||
const registrationUrlCode = useMemo(() => {
|
||||
const value = new URLSearchParams(location.search).get('registration_url')?.trim();
|
||||
return value || undefined;
|
||||
}, [location.search]);
|
||||
const registrationUrlCode = useMemo(
|
||||
() => readRegistrationUrlCode(new URLSearchParams(location.search)),
|
||||
[location.search],
|
||||
);
|
||||
const registrationTarget = useMemo(() => instanceTargetFromSnapshot(runtimeSnapshot), [runtimeSnapshot]);
|
||||
const isPublicRegistrationClosed = runtimeSnapshot.registration.mode === 'closed' && !registrationUrlCode;
|
||||
const collectDateOfBirth = runtimeSnapshot.appPublic.registration.collect_date_of_birth;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import {
|
||||
credentialRegisterFields,
|
||||
linkRegisterFields,
|
||||
readRegistrationUrlCode,
|
||||
} from '@app/features/auth/flow/RegisterFormFields';
|
||||
import {AccountIdentityModes} from '@fluxer/constants/src/AccountIdentityConstants';
|
||||
import type {InstanceRegistration} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function snapshot(usernameSignIn: boolean, mode: InstanceRegistration['mode']): RuntimeConfigSnapshot {
|
||||
return {
|
||||
features: {account_identity: usernameSignIn ? AccountIdentityModes.USERNAME : AccountIdentityModes.EMAIL},
|
||||
registration: {mode, admin_registration_urls_enabled: false},
|
||||
} as RuntimeConfigSnapshot;
|
||||
}
|
||||
|
||||
const EMAIL_CREDENTIALS = {
|
||||
showEmail: true,
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: false,
|
||||
};
|
||||
const USERNAME_CREDENTIALS = {
|
||||
showEmail: false,
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: true,
|
||||
};
|
||||
|
||||
describe('register form fields', () => {
|
||||
it('asks for credentials on the full register form in both sign-in modes', () => {
|
||||
expect(credentialRegisterFields(false)).toEqual(EMAIL_CREDENTIALS);
|
||||
expect(credentialRegisterFields(true)).toEqual(USERNAME_CREDENTIALS);
|
||||
});
|
||||
|
||||
it('keeps link sign-ups minimal only where the account can sign in right away', () => {
|
||||
expect(linkRegisterFields(snapshot(false, 'open'), undefined)).toEqual({showUsername: false});
|
||||
expect(linkRegisterFields(snapshot(false, 'closed'), undefined)).toEqual({showUsername: false});
|
||||
expect(linkRegisterFields(snapshot(false, 'approval'), undefined)).toEqual(EMAIL_CREDENTIALS);
|
||||
});
|
||||
|
||||
it('asks for credentials on link sign-ups through a registration URL, which can require approval', () => {
|
||||
const code = readRegistrationUrlCode(new URLSearchParams('?registration_url=%20abc%20'));
|
||||
expect(code).toBe('abc');
|
||||
for (const mode of ['open', 'approval', 'closed'] as const) {
|
||||
expect(linkRegisterFields(snapshot(false, mode), code)).toEqual(EMAIL_CREDENTIALS);
|
||||
}
|
||||
expect(readRegistrationUrlCode(new URLSearchParams('?registration_url=%20'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('always asks for a username and password on link sign-ups of a username instance', () => {
|
||||
for (const mode of ['open', 'approval', 'closed'] as const) {
|
||||
expect(linkRegisterFields(snapshot(true, mode), undefined)).toEqual(USERNAME_CREDENTIALS);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
|
||||
|
||||
export interface RegisterFormFields {
|
||||
showEmail?: boolean;
|
||||
showPassword?: boolean;
|
||||
showPasswordConfirmation?: boolean;
|
||||
showUsername?: boolean;
|
||||
showUsernameValidation?: boolean;
|
||||
requireUsername?: boolean;
|
||||
}
|
||||
|
||||
export function credentialRegisterFields(usernameSignIn: boolean): RegisterFormFields {
|
||||
return {
|
||||
showEmail: !usernameSignIn,
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: usernameSignIn,
|
||||
};
|
||||
}
|
||||
|
||||
export function readRegistrationUrlCode(params: URLSearchParams): string | undefined {
|
||||
return params.get('registration_url')?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function linkRegisterFields(
|
||||
snapshot: RuntimeConfigSnapshot,
|
||||
registrationUrlCode: string | undefined,
|
||||
): RegisterFormFields {
|
||||
const usernameSignIn = usesUsernameSignIn(snapshot.features);
|
||||
if (usernameSignIn || registrationUrlCode || snapshot.registration.mode === 'approval') {
|
||||
return credentialRegisterFields(usernameSignIn);
|
||||
}
|
||||
return {showUsername: false};
|
||||
}
|
||||
@@ -13,7 +13,12 @@ import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
snapshot: {apiEndpoint: 'https://instance.example/api', apiCodeVersion: 7, features: {}} as RuntimeConfigSnapshot,
|
||||
snapshot: {
|
||||
apiEndpoint: 'https://instance.example/api',
|
||||
apiCodeVersion: 7,
|
||||
features: {},
|
||||
registration: {mode: 'open', admin_registration_urls_enabled: false},
|
||||
} as RuntimeConfigSnapshot,
|
||||
reset: vi.fn(),
|
||||
completeLogin: vi.fn(),
|
||||
search: '',
|
||||
@@ -110,6 +115,7 @@ beforeEach(() => {
|
||||
mocks.search = '';
|
||||
mocks.loginProps = mocks.registerProps = mocks.ssoProps = mocks.carouselProps = null;
|
||||
delete (mocks.snapshot as any).sso;
|
||||
(mocks.snapshot as any).registration = {mode: 'open', admin_registration_urls_enabled: false};
|
||||
});
|
||||
describe('resource auth guards', () => {
|
||||
it('blocks redeemed resources before login, registration or enforced SSO', () => {
|
||||
@@ -207,4 +213,21 @@ describe('resource auth guards', () => {
|
||||
expect(mocks.ssoProps.redirectPath).toBe('/gift/code/register');
|
||||
expect(mocks.registerProps).toBeNull();
|
||||
});
|
||||
it('asks for an email and password on an approval instance', () => {
|
||||
(mocks.snapshot as any).registration = {mode: 'approval', admin_registration_urls_enabled: false};
|
||||
renderToStaticMarkup(
|
||||
<ResourceRegisterPage id="code" config={config()} data-flx="auth.flow.resource-auth-pages-test.code--8" />,
|
||||
);
|
||||
expect(mocks.registerProps.fields).toMatchObject({
|
||||
showEmail: true,
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
});
|
||||
});
|
||||
it('keeps the minimal form on an open email instance', () => {
|
||||
renderToStaticMarkup(
|
||||
<ResourceRegisterPage id="code" config={config()} data-flx="auth.flow.resource-auth-pages-test.code--9" />,
|
||||
);
|
||||
expect(mocks.registerProps.fields).toEqual({showUsername: false});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
|
||||
import {usesUsernameSignIn} from '@app/features/app/utils/AccountIdentityFeatures';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
@@ -16,6 +17,7 @@ import {AuthRuntimeTargetResetAction} from '@app/features/auth/flow/AuthRuntimeT
|
||||
import {AuthSsoPanel, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
|
||||
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import {DesktopDeepLinkPrompt} from '@app/features/auth/flow/DesktopDeepLinkPrompt';
|
||||
import {linkRegisterFields, readRegistrationUrlCode} from '@app/features/auth/flow/RegisterFormFields';
|
||||
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
|
||||
import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext';
|
||||
import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget';
|
||||
@@ -141,7 +143,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
|
||||
showTitle={false}
|
||||
title={null}
|
||||
onBackActionChange={null}
|
||||
completeLoginRedirectPath={null}
|
||||
completeLoginRedirectPath={Routes.ME}
|
||||
forceCredentials={false}
|
||||
startWithAddAccount={false}
|
||||
runtimeTarget={runtimeTarget}
|
||||
@@ -187,16 +189,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
|
||||
/>
|
||||
) : (
|
||||
<AuthRegisterFormCore
|
||||
fields={
|
||||
usernameSignIn
|
||||
? {
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: true,
|
||||
}
|
||||
: {showUsername: false}
|
||||
}
|
||||
fields={linkRegisterFields(runtimeSnapshot, readRegistrationUrlCode(params))}
|
||||
submitLabel={config.submitLabel}
|
||||
redirectPath={config.registerRedirectPath(id, safeRedirect)}
|
||||
offerRecoveryKit={usernameSignIn}
|
||||
|
||||
+6
-2
@@ -8,7 +8,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ArrowClockwiseIcon} from '@phosphor-icons/react';
|
||||
import {ArrowClockwiseIcon, DownloadSimpleIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
@@ -67,6 +67,10 @@ export const UpdaterIcon = observer(() => {
|
||||
? i18n._(CLICK_TO_RELOAD_AND_UPDATE_DESCRIPTOR, {version, productName: RuntimeConfig.productName})
|
||||
: i18n._(CLICK_TO_RELOAD_AND_UPDATE_2_DESCRIPTOR, {productName: RuntimeConfig.productName});
|
||||
}
|
||||
const Icon =
|
||||
affordance === 'manual' || (affordance === 'desktop' && Updater.desktopUpdateReplacesShell)
|
||||
? DownloadSimpleIcon
|
||||
: ArrowClockwiseIcon;
|
||||
return (
|
||||
<Tooltip text={tooltip} position="bottom" data-flx="channel.channel-header-components.updater-icon.tooltip">
|
||||
<FocusRing offset={-2} data-flx="channel.channel-header-components.updater-icon.focus-ring">
|
||||
@@ -79,7 +83,7 @@ export const UpdaterIcon = observer(() => {
|
||||
aria-label={tooltip}
|
||||
data-flx="channel.channel-header-components.updater-icon.button.click"
|
||||
>
|
||||
<ArrowClockwiseIcon
|
||||
<Icon
|
||||
weight="bold"
|
||||
className={affordance === 'updating' ? styles.updateIconSpinning : styles.updateIcon}
|
||||
data-flx="channel.channel-header-components.updater-icon.update-icon"
|
||||
|
||||
@@ -137,8 +137,7 @@
|
||||
justify-content: center;
|
||||
background-color: var(--background-modifier-accent);
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.acronym {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
container-type: inline-size;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 0 0 auto;
|
||||
font-size: max(0.5rem, calc(var(--guild-acronym-font-scale) * 100cqi));
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
letter-spacing: normal;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/guild/components/GuildAcronym.module.css';
|
||||
import {
|
||||
GUILD_ACRONYM_FALLBACK,
|
||||
getFittedGuildAcronymFontScale,
|
||||
getGuildAcronym,
|
||||
} from '@app/features/guild/utils/GuildAcronymUtils';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
interface GuildAcronymProps {
|
||||
readonly name: string;
|
||||
readonly className?: string;
|
||||
readonly 'data-flx'?: string;
|
||||
}
|
||||
|
||||
type GuildAcronymStyle = React.CSSProperties & {
|
||||
'--guild-acronym-font-scale': string;
|
||||
};
|
||||
|
||||
export function GuildAcronym({name, className, 'data-flx': dataFlx}: GuildAcronymProps) {
|
||||
const acronym = useMemo(() => getGuildAcronym(name) || GUILD_ACRONYM_FALLBACK, [name]);
|
||||
const style = useMemo<GuildAcronymStyle>(
|
||||
() => ({'--guild-acronym-font-scale': getFittedGuildAcronymFontScale(acronym).toFixed(4)}),
|
||||
[acronym],
|
||||
);
|
||||
return (
|
||||
<span className={clsx(styles.acronym, className)} style={style} data-flx={dataFlx ?? 'guild.guild-acronym.acronym'}>
|
||||
<span className={styles.text} data-flx="guild.guild-acronym.text">
|
||||
{acronym}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -131,35 +131,7 @@
|
||||
border-radius: 50%;
|
||||
border: 0.0625rem solid var(--background-modifier-accent);
|
||||
background: var(--background-tertiary);
|
||||
container-type: size;
|
||||
--initials-font-size: clamp(0.85rem, 45cqi, 1.35rem);
|
||||
--initials-fit-inline-size: 92cqi;
|
||||
--initials-letter-spacing: 0px;
|
||||
--initials-scale: 1;
|
||||
}
|
||||
|
||||
.iconInitials {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
font-size: min(
|
||||
var(--initials-font-size),
|
||||
calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale)))
|
||||
);
|
||||
letter-spacing: var(--initials-letter-spacing);
|
||||
transform: scale(var(--initials-scale));
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.iconPlaceholder[data-initials-length='medium'] .iconInitials {
|
||||
--initials-font-size: clamp(0.85rem, 38cqi, 1.11rem);
|
||||
--initials-letter-spacing: 0px;
|
||||
--initials-scale: 0.92;
|
||||
}
|
||||
|
||||
.iconActions {
|
||||
|
||||
+7
-30
@@ -13,18 +13,13 @@ import {openAssetSourceModal} from '@app/features/expressions/components/modals/
|
||||
import {isAnimatedFile} from '@app/features/expressions/utils/AnimatedImageUtils';
|
||||
import {formatImageUploadRecommendedHint} from '@app/features/expressions/utils/AssetUploadHintCopy';
|
||||
import {isSvgFile, readImageFileAsUploadDataUrl} from '@app/features/expressions/utils/ImageUploadFileUtils';
|
||||
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
|
||||
import styles from '@app/features/guild/components/modals/AddGuildModal.module.css';
|
||||
import {
|
||||
ANIMATED_ICONS_ARE_NOT_SUPPORTED_WHEN_CREATING_A_DESCRIPTOR,
|
||||
COMMUNITY_NAME_DESCRIPTOR,
|
||||
ICON_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR,
|
||||
} from '@app/features/guild/components/modals/add_guild_modal/shared';
|
||||
import * as StringUtils from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {
|
||||
getGuildIconDisplayInitials,
|
||||
getGuildInitialsFitStyle,
|
||||
getInitialsLength,
|
||||
} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {
|
||||
FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR,
|
||||
INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR,
|
||||
@@ -45,7 +40,7 @@ import {ph} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {EnvelopeSimpleIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo, useState} from 'react';
|
||||
import {useCallback, useState} from 'react';
|
||||
import type {UseFormReturn} from 'react-hook-form';
|
||||
|
||||
interface GuildCreateFormValues {
|
||||
@@ -72,12 +67,7 @@ export function useGuildCreateIconField({
|
||||
}: GuildCreateIconFieldOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const [previewIconUrl, setPreviewIconUrl] = useState<string | null>(null);
|
||||
const initials = useMemo(() => {
|
||||
const raw = (nameValue || '').trim();
|
||||
if (!raw) return '';
|
||||
return getGuildIconDisplayInitials(StringUtils.getInitialsFromName(raw));
|
||||
}, [nameValue]);
|
||||
const initialsLength = initials ? getInitialsLength(initials) : null;
|
||||
const iconName = (nameValue || '').trim();
|
||||
const showIconUploadErrorModal = useCallback(
|
||||
(message: string) => {
|
||||
showGenericErrorModal({
|
||||
@@ -156,7 +146,7 @@ export function useGuildCreateIconField({
|
||||
showGifOption: false,
|
||||
});
|
||||
}, [handleIconUpload, i18n, messages]);
|
||||
return {previewIconUrl, initials, initialsLength, handleOpenIconUpload, handleClearIcon};
|
||||
return {previewIconUrl, iconName, handleOpenIconUpload, handleClearIcon};
|
||||
}
|
||||
|
||||
export function renderGuildCreateAccountGate(i18n: I18n, dataFlx: string): React.ReactNode {
|
||||
@@ -224,8 +214,7 @@ export function renderGuildCreateFields({
|
||||
nameInputDataFlx,
|
||||
showIconError,
|
||||
previewIconUrl,
|
||||
initials,
|
||||
initialsLength,
|
||||
iconName,
|
||||
handleOpenIconUpload,
|
||||
handleClearIcon,
|
||||
}: GuildCreateFieldsOptions): React.ReactNode {
|
||||
@@ -244,20 +233,8 @@ export function renderGuildCreateFields({
|
||||
data-flx={`${dataFlx}.icon-image`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={styles.iconPlaceholder}
|
||||
data-initials-length={initialsLength}
|
||||
data-flx={`${dataFlx}.icon-placeholder`}
|
||||
>
|
||||
{initials ? (
|
||||
<span
|
||||
className={styles.iconInitials}
|
||||
style={getGuildInitialsFitStyle(initials)}
|
||||
data-flx={`${dataFlx}.icon-initials`}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={styles.iconPlaceholder} data-flx={`${dataFlx}.icon-placeholder`}>
|
||||
{iconName ? <GuildAcronym name={iconName} data-flx={`${dataFlx}.icon-acronym`} /> : null}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.iconActions} data-flx={`${dataFlx}.icon-actions`}>
|
||||
|
||||
@@ -11,54 +11,14 @@
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 624.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
background-color: var(--background-tertiary);
|
||||
background-image: var(--guild-icon-image, none);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
container-type: inline-size;
|
||||
--initials-font-size: clamp(0.85rem, 45cqi, 1.35rem);
|
||||
--initials-fit-inline-size: 92cqi;
|
||||
--initials-letter-spacing: 0px;
|
||||
--initials-scale: 1;
|
||||
--initials-scale-cap: 1;
|
||||
}
|
||||
|
||||
.containerNoIcon {
|
||||
background-image: none;
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.initials {
|
||||
align-self: center;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
font-size: min(
|
||||
var(--initials-font-size),
|
||||
calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale)))
|
||||
);
|
||||
letter-spacing: var(--initials-letter-spacing);
|
||||
transform: scale(calc(var(--initials-scale) * var(--initials-scale-cap)));
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.container[data-initials-length='medium'] .initials {
|
||||
--initials-font-size: clamp(0.85rem, 38cqi, 1.11rem);
|
||||
--initials-scale: 0.92;
|
||||
--initials-letter-spacing: 0px;
|
||||
}
|
||||
|
||||
@container (max-width: 32px) {
|
||||
.initials {
|
||||
--initials-scale-cap: 0.88;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 26px) {
|
||||
.initials {
|
||||
--initials-scale-cap: 0.78;
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,8 @@
|
||||
|
||||
import {useHover} from '@app/features/app/hooks/useHover';
|
||||
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
|
||||
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
|
||||
import styles from '@app/features/guild/components/popouts/GuildIcon.module.css';
|
||||
import * as StringUtils from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {
|
||||
getGuildIconDisplayInitials,
|
||||
getGuildInitialsFitStyle,
|
||||
getInitialsLength,
|
||||
} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
|
||||
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
@@ -16,7 +11,7 @@ import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
interface GuildIconProps {
|
||||
id: string;
|
||||
@@ -43,8 +38,6 @@ export const GuildIcon = observer(function GuildIcon({
|
||||
sizePx,
|
||||
containerProps,
|
||||
}: GuildIconProps) {
|
||||
const initials = useMemo(() => getGuildIconDisplayInitials(StringUtils.getInitialsFromName(name)), [name]);
|
||||
const initialsLength = getInitialsLength(initials);
|
||||
const [hoverRef, isHovering] = useHover();
|
||||
const iconUrl = icon ? AvatarUtils.getGuildIconURL({id, icon}) : null;
|
||||
const hoverIconUrl = icon ? AvatarUtils.getGuildIconURL({id, icon}, true) : null;
|
||||
@@ -72,18 +65,9 @@ export const GuildIcon = observer(function GuildIcon({
|
||||
className={clsx(styles.container, className, paintedUrl == null && styles.containerNoIcon)}
|
||||
data-flx="guild.guild-icon.container"
|
||||
{...containerProps}
|
||||
data-initials-length={initialsLength}
|
||||
style={styleVars}
|
||||
>
|
||||
{paintedUrl == null && (
|
||||
<span
|
||||
className={styles.initials}
|
||||
style={getGuildInitialsFitStyle(initials)}
|
||||
data-flx="guild.guild-icon.initials"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
{paintedUrl == null && <GuildAcronym name={name} data-flx="guild.guild-icon.acronym" />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
getGuildAcronym,
|
||||
getGuildAcronymFontScale,
|
||||
getGuildAcronymLength,
|
||||
} from '@app/features/guild/utils/GuildAcronymUtils';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
const ACRONYM_VECTORS: ReadonlyArray<readonly [name: string, acronym: string, length: number]> = [
|
||||
['Movies? We Like Movies', 'M?WLM', 5],
|
||||
['Movies Movies', 'MM', 2],
|
||||
['(Internal) Fluxer DevOps', '(I)FD', 5],
|
||||
['secret server', 'ss', 2],
|
||||
['Hampus server', 'Hs', 2],
|
||||
['Fluxer', 'F', 1],
|
||||
[' Fluxer ', 'F', 1],
|
||||
['a b\tc\nd', 'abcd', 4],
|
||||
["Bob's Burgers", 'BB', 2],
|
||||
['Bob’s Burgers', 'BB', 2],
|
||||
["Bob's", "B's", 3],
|
||||
["Don't Panic", "D'tP", 4],
|
||||
['Area 51', 'A5', 2],
|
||||
['2024 Reunion', '2R', 2],
|
||||
['Rock-n-Roll', 'R-n-R', 5],
|
||||
['Spider-Man Fans', 'S-MF', 4],
|
||||
['snake_case_name', 's', 1],
|
||||
['C++ & Rust', 'C++&R', 5],
|
||||
['@everyone', '@e', 2],
|
||||
['!!!', '!!!', 3],
|
||||
['\u{1F3AE}\u{1F3B2}', '\u{1F3AE}\u{1F3B2}', 2],
|
||||
['\u{1F496}tes tes', '\u{1F496}tt', 3],
|
||||
['❤️love', '❤️l', 2],
|
||||
['\u{1F468}\u{1F469}\u{1F467} Family', '\u{1F468}\u{1F469}\u{1F467}F', 2],
|
||||
['\u{1F1F8}\u{1F1EA} Sweden', '\u{1F1F8}\u{1F1EA}S', 2],
|
||||
['1️⃣ One', '1️⃣O', 2],
|
||||
['日本語 サーバー', '日サ', 2],
|
||||
['原神交流群', '原', 1],
|
||||
['한국 서버', '한서', 2],
|
||||
['Привет Мир', 'ПМ', 2],
|
||||
['مرحبا بالعالم', 'مب', 2],
|
||||
['שלום עולם', 'שע', 2],
|
||||
['میخواهم بازی', 'مب', 2],
|
||||
['École Normale', 'ÉN', 2],
|
||||
['नमस्ते दुनिया', 'नदु', 2],
|
||||
['one two three four five six seven eight nine ten', 'ottffssent', 10],
|
||||
['', '', 0],
|
||||
[' \t ', '', 0],
|
||||
];
|
||||
|
||||
const FONT_SCALE_VECTORS: ReadonlyArray<readonly [length: number, fontSizePxAt48: number]> = [
|
||||
[0, 18],
|
||||
[1, 18],
|
||||
[2, 18],
|
||||
[3, 16],
|
||||
[4, 14],
|
||||
[5, 12],
|
||||
[6, 10],
|
||||
[10, 10],
|
||||
];
|
||||
|
||||
describe('getGuildAcronym', () => {
|
||||
test.each(ACRONYM_VECTORS)('%j -> %j', (name, acronym, length) => {
|
||||
expect(getGuildAcronym(name)).toBe(acronym);
|
||||
expect(getGuildAcronymLength(acronym)).toBe(length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuildAcronymFontScale', () => {
|
||||
test.each(FONT_SCALE_VECTORS)('%i graphemes -> %ipx on a 48px icon', (length, fontSizePx) => {
|
||||
expect(getGuildAcronymFontScale(length) * 48).toBeCloseTo(fontSizePx);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const POSSESSIVE_SUFFIX = /['’]s(?=\s)/gu;
|
||||
const WORD_RUN = /([\p{L}\p{N}_]\p{M}*)[\p{L}\p{N}\p{M}_]*/gu;
|
||||
const WHITESPACE = /\s+/gu;
|
||||
const GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX = 48;
|
||||
const GUILD_ACRONYM_FONT_SIZES_PX = [18, 18, 16, 14, 12, 10];
|
||||
const GUILD_ACRONYM_FIT_PADDING_PX = 4;
|
||||
const GUILD_ACRONYM_FIT_WIDTH_RATIO =
|
||||
(GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX - GUILD_ACRONYM_FIT_PADDING_PX * 2) / GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX;
|
||||
const GUILD_ACRONYM_MEASURE_FONT_SIZE_PX = 100;
|
||||
const GUILD_ACRONYM_WIDTH_CACHE_CAPACITY = 512;
|
||||
|
||||
const acronymWidthCache = new Map<string, number>();
|
||||
let measureContext: CanvasRenderingContext2D | null | undefined;
|
||||
|
||||
export const GUILD_ACRONYM_FALLBACK = '?';
|
||||
|
||||
const graphemeSegmenter =
|
||||
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
||||
? new Intl.Segmenter(undefined, {granularity: 'grapheme'})
|
||||
: null;
|
||||
|
||||
function firstGrapheme(wordRun: string, leadingCharacterWithMarks: string): string {
|
||||
if (graphemeSegmenter == null) return leadingCharacterWithMarks;
|
||||
for (const {segment} of graphemeSegmenter.segment(wordRun)) {
|
||||
return segment;
|
||||
}
|
||||
return leadingCharacterWithMarks;
|
||||
}
|
||||
|
||||
export function getGuildAcronym(name: string): string {
|
||||
return name.replace(POSSESSIVE_SUFFIX, '').replace(WORD_RUN, firstGrapheme).replace(WHITESPACE, '');
|
||||
}
|
||||
|
||||
export function getGuildAcronymLength(acronym: string): number {
|
||||
if (graphemeSegmenter == null) return Array.from(acronym).length;
|
||||
let length = 0;
|
||||
for (const _segment of graphemeSegmenter.segment(acronym)) {
|
||||
length += 1;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
export function getGuildAcronymFontScale(acronymLength: number): number {
|
||||
const index = Math.min(Math.max(acronymLength, 1), GUILD_ACRONYM_FONT_SIZES_PX.length) - 1;
|
||||
return GUILD_ACRONYM_FONT_SIZES_PX[index] / GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX;
|
||||
}
|
||||
|
||||
function getMeasureContext(): CanvasRenderingContext2D | null {
|
||||
if (measureContext !== undefined) return measureContext;
|
||||
if (typeof document === 'undefined' || document.body == null) return null;
|
||||
const context = document.createElement('canvas').getContext('2d');
|
||||
if (context != null) {
|
||||
context.font = `500 ${GUILD_ACRONYM_MEASURE_FONT_SIZE_PX}px ${getComputedStyle(document.body).fontFamily}`;
|
||||
}
|
||||
measureContext = context;
|
||||
return context;
|
||||
}
|
||||
|
||||
function getGuildAcronymWidthPerEm(acronym: string): number | null {
|
||||
const cached = acronymWidthCache.get(acronym);
|
||||
if (cached !== undefined) return cached;
|
||||
const context = getMeasureContext();
|
||||
if (context == null) return null;
|
||||
const widthPerEm = context.measureText(acronym).width / GUILD_ACRONYM_MEASURE_FONT_SIZE_PX;
|
||||
if (acronymWidthCache.size >= GUILD_ACRONYM_WIDTH_CACHE_CAPACITY) acronymWidthCache.clear();
|
||||
acronymWidthCache.set(acronym, widthPerEm);
|
||||
return widthPerEm;
|
||||
}
|
||||
|
||||
export function fitGuildAcronymFontScale(acronymLength: number, widthPerEm: number | null): number {
|
||||
const scale = getGuildAcronymFontScale(acronymLength);
|
||||
if (widthPerEm == null || widthPerEm <= 0) return scale;
|
||||
const maxScale = GUILD_ACRONYM_FIT_WIDTH_RATIO / widthPerEm;
|
||||
if (scale <= maxScale) return scale;
|
||||
for (const fontSize of GUILD_ACRONYM_FONT_SIZES_PX) {
|
||||
const candidate = fontSize / GUILD_ACRONYM_REFERENCE_ICON_SIZE_PX;
|
||||
if (candidate < scale && candidate <= maxScale) return candidate;
|
||||
}
|
||||
return maxScale;
|
||||
}
|
||||
|
||||
export function getFittedGuildAcronymFontScale(acronym: string): number {
|
||||
return fitGuildAcronymFontScale(getGuildAcronymLength(acronym), getGuildAcronymWidthPerEm(acronym));
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
const GUILD_ICON_INITIALS_MAX_LENGTH = 4;
|
||||
const MEASURE_FONT_SIZES_PX = [10, 12, 14, 16, 20, 24];
|
||||
const MEASURE_FONT_WEIGHT = 600;
|
||||
|
||||
export const GuildInitialsLength = Object.freeze({
|
||||
SHORT: 'short',
|
||||
MEDIUM: 'medium',
|
||||
} as const);
|
||||
|
||||
export type GuildInitialsLength = (typeof GuildInitialsLength)[keyof typeof GuildInitialsLength];
|
||||
|
||||
export type GuildInitialsFitStyle = React.CSSProperties & {
|
||||
'--initials-width-em'?: string;
|
||||
};
|
||||
|
||||
const measuredWidthsEm = new Map<string, number>();
|
||||
let measureContext: OffscreenCanvasRenderingContext2D | null | undefined;
|
||||
|
||||
export function getInitialsFromName(name: string): string {
|
||||
const words = name.split(/\s+/u).filter(Boolean);
|
||||
return words
|
||||
.map((word) => Array.from(word)[0])
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function truncateInitials(initials: string, maxLength: number): string {
|
||||
if (maxLength <= 0) return '';
|
||||
return Array.from(initials).slice(0, maxLength).join('');
|
||||
}
|
||||
|
||||
export function getGuildIconDisplayInitials(initials: string): string {
|
||||
return truncateInitials(initials, GUILD_ICON_INITIALS_MAX_LENGTH);
|
||||
}
|
||||
|
||||
export function getInitialsLength(displayInitials: string): GuildInitialsLength {
|
||||
return Array.from(displayInitials).length <= 2 ? GuildInitialsLength.SHORT : GuildInitialsLength.MEDIUM;
|
||||
}
|
||||
|
||||
function getMeasureContext(): OffscreenCanvasRenderingContext2D | null {
|
||||
if (measureContext !== undefined) return measureContext;
|
||||
if (typeof document === 'undefined' || typeof OffscreenCanvas === 'undefined') {
|
||||
measureContext = null;
|
||||
return measureContext;
|
||||
}
|
||||
measureContext = new OffscreenCanvas(1, 1).getContext('2d');
|
||||
document.fonts?.addEventListener('loadingdone', () => measuredWidthsEm.clear());
|
||||
return measureContext;
|
||||
}
|
||||
|
||||
function measureInitialsWidthEm(initials: string): number | null {
|
||||
const context = getMeasureContext();
|
||||
if (context == null || document.body == null) return null;
|
||||
const fontFamily = getComputedStyle(document.body).fontFamily;
|
||||
const key = `${fontFamily}\u0000${initials}`;
|
||||
const cached = measuredWidthsEm.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
let widthEm = 0;
|
||||
for (const fontSize of MEASURE_FONT_SIZES_PX) {
|
||||
context.font = `${MEASURE_FONT_WEIGHT} ${fontSize}px ${fontFamily}`;
|
||||
widthEm = Math.max(widthEm, context.measureText(initials).width / fontSize);
|
||||
}
|
||||
measuredWidthsEm.set(key, widthEm);
|
||||
return widthEm;
|
||||
}
|
||||
|
||||
export function getGuildInitialsFitStyle(displayInitials: string): GuildInitialsFitStyle | undefined {
|
||||
if (!displayInitials) return undefined;
|
||||
const widthEm = measureInitialsWidthEm(displayInitials);
|
||||
if (widthEm == null || widthEm <= 0) return undefined;
|
||||
return {'--initials-width-em': widthEm.toFixed(3)};
|
||||
}
|
||||
@@ -63,7 +63,7 @@ const InviteLoginPage = observer(function InviteLoginPage({code, invite}: Invite
|
||||
title={null}
|
||||
onLoginComplete={runtimeTarget.reset}
|
||||
onBackActionChange={null}
|
||||
completeLoginRedirectPath={null}
|
||||
completeLoginRedirectPath={Routes.ME}
|
||||
forceCredentials={false}
|
||||
startWithAddAccount={false}
|
||||
runtimeTarget={runtimeTarget}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {AuthRuntimeTargetGate} from '@app/features/auth/flow/AuthRuntimeTargetGa
|
||||
import {AuthSsoPanel, resolveAuthPanelSso} from '@app/features/auth/flow/AuthSsoPanel';
|
||||
import {DesktopDeepLinkPrompt} from '@app/features/auth/flow/DesktopDeepLinkPrompt';
|
||||
import {InviteHeader} from '@app/features/auth/flow/InviteHeader';
|
||||
import {linkRegisterFields, readRegistrationUrlCode} from '@app/features/auth/flow/RegisterFormFields';
|
||||
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
|
||||
import {AuthCardVariant} from '@app/features/auth/state/AuthLayoutContext';
|
||||
import {useAuthRuntimeTarget} from '@app/features/auth/state/AuthRuntimeTarget';
|
||||
@@ -104,16 +105,7 @@ const InviteRegisterPageContent = observer(function InviteRegisterPageContent({
|
||||
<InviteHeader invite={invite} data-flx="invite.invite-register-page.invite-header" />
|
||||
<div className={sharedStyles.container} data-flx="invite.invite-register-page.div--4">
|
||||
<AuthRegisterFormCore
|
||||
fields={
|
||||
usernameSignIn
|
||||
? {
|
||||
showPassword: true,
|
||||
showPasswordConfirmation: true,
|
||||
showUsernameValidation: true,
|
||||
requireUsername: true,
|
||||
}
|
||||
: {showUsername: false}
|
||||
}
|
||||
fields={linkRegisterFields(runtimeSnapshot, readRegistrationUrlCode(params))}
|
||||
submitLabel={i18n._(CREATE_ACCOUNT_DESCRIPTOR)}
|
||||
redirectPath="/"
|
||||
offerRecoveryKit={usernameSignIn}
|
||||
|
||||
@@ -61,6 +61,12 @@ function findNextEmojiToken(text: string, startIndex: number): EmojiToken | null
|
||||
return surrogate.start < shortcode.start ? {...surrogate, surrogate: true} : {...shortcode, surrogate: false};
|
||||
}
|
||||
|
||||
interface EmojiTokenEdit {
|
||||
start: number;
|
||||
end: number;
|
||||
replacement: ResolvedTypedEmoji | string;
|
||||
}
|
||||
|
||||
function isEscapedAt(text: string, index: number): boolean {
|
||||
let backslashes = 0;
|
||||
let cursor = index - 1;
|
||||
@@ -80,30 +86,27 @@ function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiResolver):
|
||||
return;
|
||||
}
|
||||
const text = node.getTextContent();
|
||||
const edits: Array<EmojiTokenEdit> = [];
|
||||
let searchFrom = 0;
|
||||
let backticks = 0;
|
||||
while (true) {
|
||||
const match = findNextEmojiToken(text, searchFrom);
|
||||
if (match == null) {
|
||||
return;
|
||||
break;
|
||||
}
|
||||
const codeMarkers = text.slice(0, match.start).match(/`/g);
|
||||
if ((codeMarkers == null ? 0 : codeMarkers.length) % 2 === 1) {
|
||||
return;
|
||||
for (let index = searchFrom; index < match.start; index++) {
|
||||
if (text[index] === '`') {
|
||||
backticks += 1;
|
||||
}
|
||||
}
|
||||
searchFrom = match.end;
|
||||
if (backticks % 2 === 1) {
|
||||
continue;
|
||||
}
|
||||
if (isEscapedAt(text, match.start)) {
|
||||
if (match.surrogate) {
|
||||
const shortcodeText = `:${match.name}:`;
|
||||
const selection = $captureSelectionOffsets();
|
||||
const nodeDisplayStart = $getComposerNodeDisplayStart(node);
|
||||
const replaced = `${text.slice(0, match.start)}${shortcodeText}${text.slice(match.end)}`;
|
||||
node.setTextContent(replaced);
|
||||
if (selection != null) {
|
||||
const adjusted = $adjustEmbedCaret(selection, nodeDisplayStart, match.start, match.end, shortcodeText.length);
|
||||
$selectComposerRange(adjusted.anchor, adjusted.focus);
|
||||
}
|
||||
return;
|
||||
edits.push({start: match.start, end: match.end, replacement: `:${match.name}:`});
|
||||
}
|
||||
searchFrom = match.end;
|
||||
continue;
|
||||
}
|
||||
const previous = match.start === 0 ? node.getPreviousSibling() : null;
|
||||
@@ -143,28 +146,48 @@ function $convertEmojiShortcode(node: TextNode, resolve: ComposerEmojiResolver):
|
||||
}
|
||||
}
|
||||
const resolved = resolve(match.name);
|
||||
if (resolved == null) {
|
||||
searchFrom = match.end;
|
||||
continue;
|
||||
}
|
||||
const selection = $captureSelectionOffsets();
|
||||
const nodeDisplayStart = $getComposerNodeDisplayStart(node);
|
||||
const emojiNode =
|
||||
resolved.kind === 'standard'
|
||||
? $createComposerStandardEmojiNode(resolved.name, resolved.surrogate, resolved.url, resolved.display)
|
||||
: $createComposerCustomEmojiNode(resolved.emojiId, resolved.animated, resolved.display, resolved.wire);
|
||||
const segments = node.splitText(match.start, match.end);
|
||||
const target = segments[match.start > 0 ? 1 : 0];
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
target.replace(emojiNode);
|
||||
if (selection != null) {
|
||||
const adjusted = $adjustEmbedCaret(selection, nodeDisplayStart, match.start, match.end, resolved.display.length);
|
||||
$selectComposerRange(adjusted.anchor, adjusted.focus);
|
||||
if (resolved != null) {
|
||||
edits.push({start: match.start, end: match.end, replacement: resolved});
|
||||
}
|
||||
}
|
||||
if (edits.length === 0) {
|
||||
return;
|
||||
}
|
||||
let selection = $captureSelectionOffsets();
|
||||
const nodeDisplayStart = $getComposerNodeDisplayStart(node);
|
||||
for (let index = edits.length - 1; index >= 0; index--) {
|
||||
const {start, end, replacement} = edits[index]!;
|
||||
if (typeof replacement === 'string') {
|
||||
node.spliceText(start, end - start, replacement);
|
||||
} else {
|
||||
const emojiNode =
|
||||
replacement.kind === 'standard'
|
||||
? $createComposerStandardEmojiNode(
|
||||
replacement.name,
|
||||
replacement.surrogate,
|
||||
replacement.url,
|
||||
replacement.display,
|
||||
)
|
||||
: $createComposerCustomEmojiNode(
|
||||
replacement.emojiId,
|
||||
replacement.animated,
|
||||
replacement.display,
|
||||
replacement.wire,
|
||||
);
|
||||
const target = node.splitText(start, end)[start > 0 ? 1 : 0];
|
||||
if (target == null) {
|
||||
break;
|
||||
}
|
||||
target.replace(emojiNode);
|
||||
}
|
||||
if (selection != null) {
|
||||
const newLen = typeof replacement === 'string' ? replacement.length : replacement.display.length;
|
||||
selection = $adjustEmbedCaret(selection, nodeDisplayStart, start, end, newLen);
|
||||
}
|
||||
}
|
||||
if (selection != null) {
|
||||
$selectComposerRange(selection.anchor, selection.focus);
|
||||
}
|
||||
}
|
||||
|
||||
function $adjustEmbedCaret(
|
||||
|
||||
@@ -14,10 +14,9 @@ import {getDMDisplayName, getIcon, getName} from '@app/features/channel/utils/Ch
|
||||
import DeveloperMode from '@app/features/devtools/state/DeveloperMode';
|
||||
import {joinDiscoveryGuild} from '@app/features/discovery/commands/DiscoveryJoinCommands';
|
||||
import DiscoveryChannelPreviews from '@app/features/discovery/state/DiscoveryChannelPreviews';
|
||||
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import * as StringUtils from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {getGuildIconDisplayInitials} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {
|
||||
COPY_LINK_DESCRIPTOR,
|
||||
OKAY_DESCRIPTOR,
|
||||
@@ -228,7 +227,6 @@ function CompactGuildMentionIcon({guild}: {guild: Pick<Guild, 'id' | 'name' | 'i
|
||||
const style: CompactGuildMentionIconStyle | undefined = iconUrl
|
||||
? {'--jump-link-guild-icon-image': `url(${iconUrl})`}
|
||||
: undefined;
|
||||
const initials = getGuildIconDisplayInitials(StringUtils.getInitialsFromName(guild.name));
|
||||
return (
|
||||
<span
|
||||
className={clsx(jumpLinkStyles.icon, jumpLinkStyles.guildIcon)}
|
||||
@@ -238,12 +236,10 @@ function CompactGuildMentionIcon({guild}: {guild: Pick<Guild, 'id' | 'name' | 'i
|
||||
data-flx="messaging.markdown.renderers.link-renderer.compact-guild-mention-icon"
|
||||
>
|
||||
{!iconUrl && (
|
||||
<span
|
||||
className={jumpLinkStyles.guildInitials}
|
||||
data-flx="messaging.markdown.renderers.link-renderer.compact-guild-mention-icon.initials"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<GuildAcronym
|
||||
name={guild.name}
|
||||
data-flx="messaging.markdown.renderers.link-renderer.compact-guild-mention-icon.acronym"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
-15
@@ -59,21 +59,6 @@
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.guildInitials {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 0.56em;
|
||||
line-height: 1;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.divider {
|
||||
|
||||
@@ -766,6 +766,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
|
||||
return (
|
||||
<Modal.Root
|
||||
size="medium"
|
||||
centered
|
||||
initialFocusRef={autoFocusNote ? noteRef : undefined}
|
||||
className={userProfileModalStyles.modalRoot}
|
||||
data-flx="user.user-profile-modal.modal-root"
|
||||
|
||||
@@ -30,7 +30,10 @@ import * as UserCommands from '@app/features/user/commands/UserCommands';
|
||||
import * as UserProfileCommands from '@app/features/user/commands/UserProfileCommands';
|
||||
import styles from '@app/features/user/components/modals/tabs/MyProfileTab.module.css';
|
||||
import {AccentColorPicker} from '@app/features/user/components/modals/tabs/my_profile_tab/AccentColorPicker';
|
||||
import {AvatarUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/AvatarUploader';
|
||||
import {
|
||||
AvatarUploader,
|
||||
useChangeAvatarAction,
|
||||
} from '@app/features/user/components/modals/tabs/my_profile_tab/AvatarUploader';
|
||||
import {BannerUploader} from '@app/features/user/components/modals/tabs/my_profile_tab/BannerUploader';
|
||||
import {BIO_MARKDOWN_PARSER_FLAGS, BioEditor} from '@app/features/user/components/modals/tabs/my_profile_tab/BioEditor';
|
||||
import {UsernameSection} from '@app/features/user/components/modals/tabs/my_profile_tab/MyProfileTabUsernameSection';
|
||||
@@ -615,6 +618,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
form.setValue('avatar', null);
|
||||
transitionAvatarAsset({type: 'asset.cleared'});
|
||||
}, [form, transitionAvatarAsset]);
|
||||
const {openChangeAvatar} = useChangeAvatarAction({onAvatarChange: handleAvatarChange});
|
||||
const handleBannerChange = useCallback(
|
||||
(base64: string) => {
|
||||
form.setValue('banner', base64);
|
||||
@@ -674,6 +678,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
const guilds = Guilds.getGuilds();
|
||||
const selectedGuild = selectedGuildId ? guilds.find((g) => g.id === selectedGuildId) : null;
|
||||
const isPerGuildProfileCustomizationDisabled = isPerGuildProfile && !hasPerGuildProfiles;
|
||||
const isAvatarCustomizationDisabled = isProfileCustomizationLocked || isPerGuildProfileCustomizationDisabled;
|
||||
const isPronounsDisabled = isProfileCustomizationLocked;
|
||||
const profileCustomizationDescription = isProfileEmailLocked
|
||||
? isPerGuildProfile
|
||||
@@ -840,7 +845,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
hasAvatar={hasAvatar}
|
||||
onAvatarChange={handleAvatarChange}
|
||||
onAvatarClear={handleAvatarClear}
|
||||
disabled={isProfileCustomizationLocked || isPerGuildProfileCustomizationDisabled}
|
||||
disabled={isAvatarCustomizationDisabled}
|
||||
disableModeSelection={isProfileCustomizationLocked}
|
||||
isPerGuildProfile={isPerGuildProfile}
|
||||
errorMessage={form.formState.errors.avatar?.message}
|
||||
@@ -934,6 +939,9 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
|
||||
}}
|
||||
ignoreGuildAvatarInPreview={isPerGuildProfile && avatarAsset.mode === 'inherit'}
|
||||
ignoreGuildBannerInPreview={isPerGuildProfile && bannerAsset.mode === 'inherit'}
|
||||
onAvatarClick={
|
||||
mobileLayout.enabled && !isAvatarCustomizationDisabled ? openChangeAvatar : undefined
|
||||
}
|
||||
data-flx="user.my-profile-tab.my-profile-tab-component.profile-preview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-2
@@ -203,8 +203,7 @@
|
||||
border: 0.0625rem solid var(--entrance-sound-border);
|
||||
background: color-mix(in srgb, #0f1014 80%, var(--background-header-secondary) 20%);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scopeTileText {
|
||||
|
||||
+5
-2
@@ -3,6 +3,7 @@
|
||||
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 {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {DIRECT_MESSAGES_DESCRIPTOR, GET_PREMIUM_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
|
||||
@@ -186,7 +187,6 @@ interface ScopeArtworkProps {
|
||||
function ScopeArtwork({option, selected, compact}: ScopeArtworkProps): React.ReactElement {
|
||||
if (option.guildId) {
|
||||
const iconUrl = option.guildIcon ? AvatarUtils.getGuildIconURL({id: option.guildId, icon: option.guildIcon}) : null;
|
||||
const initial = option.label.trim().charAt(0).toUpperCase() || '?';
|
||||
return iconUrl ? (
|
||||
<span
|
||||
className={clsx(styles.scopeAvatar, compact && styles.scopeAvatarCompact)}
|
||||
@@ -200,7 +200,10 @@ function ScopeArtwork({option, selected, compact}: ScopeArtworkProps): React.Rea
|
||||
aria-hidden
|
||||
data-flx="user.entrance-sound-section.scope-artwork.scope-avatar-placeholder"
|
||||
>
|
||||
{initial}
|
||||
<GuildAcronym
|
||||
name={option.label}
|
||||
data-flx="user.entrance-sound-section.scope-artwork.scope-avatar-placeholder-acronym"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
+201
-184
@@ -116,6 +116,203 @@ interface AvatarUploaderProps {
|
||||
onAvatarModeChange?: (mode: AvatarMode) => void;
|
||||
}
|
||||
|
||||
interface ChangeAvatarActionOptions {
|
||||
onAvatarChange: (base64: string) => void;
|
||||
requireAnimatedAvatarEntitlement?: boolean;
|
||||
}
|
||||
|
||||
export function useChangeAvatarAction({
|
||||
onAvatarChange,
|
||||
requireAnimatedAvatarEntitlement = true,
|
||||
}: ChangeAvatarActionOptions) {
|
||||
const {i18n} = useLingui();
|
||||
const PREMIUM_PRODUCT_NAME = getPremiumProductName();
|
||||
const hasAnimatedAvatarEntitlement = isLimitToggleEnabled(
|
||||
{feature_animated_avatar: LimitResolver.resolve({key: 'feature_animated_avatar', fallback: 0})},
|
||||
'feature_animated_avatar',
|
||||
);
|
||||
const canUploadAnimatedAvatar = !requireAnimatedAvatarEntitlement || hasAnimatedAvatarEntitlement;
|
||||
const processAvatarFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > GlobalLimits.getAvatarMaxSize()) {
|
||||
showGenericErrorModal({
|
||||
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
|
||||
message: i18n._(AVATAR_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, {
|
||||
imageMaxSizeLabel: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
}),
|
||||
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const svg = isSvgFile(file);
|
||||
if (!svg && !(await canCropFile(file))) {
|
||||
showGenericErrorModal({
|
||||
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
|
||||
message: getAssetFormatErrorMessage(i18n, 'avatar', 'unsupported_mime'),
|
||||
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const {format, animated} = svg ? {format: 'unknown', animated: false} : await inspectImageFile(file);
|
||||
if (animated && !canUploadAnimatedAvatar) {
|
||||
if (shouldShowPremiumFeatures()) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(ANIMATED_AVATARS_REQUIRE_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
description={
|
||||
<>
|
||||
<p data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.p">
|
||||
<Trans>
|
||||
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are exclusively available to {PREMIUM_PRODUCT_NAME}{' '}
|
||||
subscribers.
|
||||
</Trans>
|
||||
</p>
|
||||
<p
|
||||
className={styles.spacedParagraph}
|
||||
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.spaced-paragraph"
|
||||
>
|
||||
{RuntimeConfig.usesUniqueUsernames ? (
|
||||
<Trans>
|
||||
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
|
||||
profile banners, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
|
||||
profile banners, customize your tag, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
primaryVariant="primary"
|
||||
secondaryText={i18n._(CANCEL_DESCRIPTOR)}
|
||||
onPrimary={() => {
|
||||
window.setTimeout(() => {
|
||||
PremiumModalCommands.open();
|
||||
}, 0);
|
||||
}}
|
||||
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(ANIMATED_AVATARS_NOT_AVAILABLE_DESCRIPTOR)}
|
||||
message={
|
||||
<Trans>
|
||||
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are not available on this instance. Upload a static
|
||||
image instead.
|
||||
</Trans>
|
||||
}
|
||||
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal--2"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const isAnimatedAvif = animated && format === 'avif';
|
||||
if (isAnimatedAvif && !(await canDecodeAnimatedAvif())) {
|
||||
showAnimatedAvifUnsupportedModal({i18n});
|
||||
return;
|
||||
}
|
||||
const base64 = svg ? await readImageFileAsUploadDataUrl(file) : await readBlobAsDataUrl(file);
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<AssetCropModal
|
||||
assetType={AssetType.AVATAR}
|
||||
imageUrl={base64}
|
||||
onCropComplete={(croppedBlob) => {
|
||||
void readBlobAsDataUrl(croppedBlob).then(
|
||||
(croppedBase64) => {
|
||||
onAvatarChange(croppedBase64);
|
||||
},
|
||||
() => {
|
||||
showGenericErrorModal({
|
||||
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
|
||||
message: i18n._(FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR),
|
||||
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
|
||||
});
|
||||
},
|
||||
);
|
||||
}}
|
||||
onSkip={
|
||||
isAnimatedAvif
|
||||
? undefined
|
||||
: () => {
|
||||
onAvatarChange(base64);
|
||||
}
|
||||
}
|
||||
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.asset-crop-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
},
|
||||
[canUploadAnimatedAvatar, onAvatarChange, i18n],
|
||||
);
|
||||
const showAvatarUploadError = useCallback(() => {
|
||||
showGenericErrorModal({
|
||||
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
|
||||
message: i18n._(INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR),
|
||||
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
|
||||
});
|
||||
}, [i18n]);
|
||||
const handlePickAvatarFile = useCallback(async () => {
|
||||
try {
|
||||
const [file] = await openFilePicker({accept: getAcceptString('avatar')});
|
||||
if (!file) return;
|
||||
await processAvatarFile(file);
|
||||
} catch {
|
||||
showAvatarUploadError();
|
||||
}
|
||||
}, [processAvatarFile, showAvatarUploadError]);
|
||||
const handleSelectAvatarGif = useCallback(
|
||||
async (gif: Gif) => {
|
||||
try {
|
||||
await processAvatarFile(await downloadGifAsImageFile(gif));
|
||||
} catch {
|
||||
showAvatarUploadError();
|
||||
}
|
||||
},
|
||||
[processAvatarFile, showAvatarUploadError],
|
||||
);
|
||||
const handleAvatarUpload = useCallback(() => {
|
||||
const uploadHint = canUploadAnimatedAvatar
|
||||
? formatImageUploadRecommendedHint(i18n, {
|
||||
formats: ANIMATED_IMAGE_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
})
|
||||
: shouldShowPremiumFeatures()
|
||||
? formatImageUploadRecommendedHintWithNote(i18n, {
|
||||
formats: STATIC_IMAGE_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
note: i18n._(ANIMATED_AVATARS_REQUIRE_PREMIUM_NOTE_DESCRIPTOR, {
|
||||
animatedAvatarFormats: ANIMATED_AVATAR_FORMATS,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
}),
|
||||
})
|
||||
: formatImageUploadRecommendedHint(i18n, {
|
||||
formats: STATIC_IMAGE_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
});
|
||||
openAssetSourceModal({
|
||||
title: i18n._(CHANGE_AVATAR_DESCRIPTOR),
|
||||
uploadHint,
|
||||
onPickUpload: handlePickAvatarFile,
|
||||
onSelectGif: (gif) => void handleSelectAvatarGif(gif),
|
||||
});
|
||||
}, [canUploadAnimatedAvatar, handlePickAvatarFile, handleSelectAvatarGif, i18n]);
|
||||
return {canUploadAnimatedAvatar, openChangeAvatar: handleAvatarUpload};
|
||||
}
|
||||
|
||||
export const AvatarUploader = observer(
|
||||
({
|
||||
hasAvatar,
|
||||
@@ -129,13 +326,11 @@ 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})},
|
||||
'feature_animated_avatar',
|
||||
);
|
||||
const canUploadAnimatedAvatar = !requireAnimatedAvatarEntitlement || hasAnimatedAvatarEntitlement;
|
||||
const {canUploadAnimatedAvatar, openChangeAvatar: handleAvatarUpload} = useChangeAvatarAction({
|
||||
onAvatarChange,
|
||||
requireAnimatedAvatarEntitlement,
|
||||
});
|
||||
const getAvatarModeOptions = useCallback(
|
||||
() => [
|
||||
{
|
||||
@@ -157,184 +352,6 @@ export const AvatarUploader = observer(
|
||||
],
|
||||
[canUploadAnimatedAvatar, i18n],
|
||||
);
|
||||
const processAvatarFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > GlobalLimits.getAvatarMaxSize()) {
|
||||
showGenericErrorModal({
|
||||
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
|
||||
message: i18n._(AVATAR_FILE_IS_TOO_LARGE_PLEASE_CHOOSE_A_DESCRIPTOR, {
|
||||
imageMaxSizeLabel: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
}),
|
||||
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const svg = isSvgFile(file);
|
||||
if (!svg && !(await canCropFile(file))) {
|
||||
showGenericErrorModal({
|
||||
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
|
||||
message: getAssetFormatErrorMessage(i18n, 'avatar', 'unsupported_mime'),
|
||||
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const {format, animated} = svg ? {format: 'unknown', animated: false} : await inspectImageFile(file);
|
||||
if (animated && !canUploadAnimatedAvatar) {
|
||||
if (shouldShowPremiumFeatures()) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(ANIMATED_AVATARS_REQUIRE_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
description={
|
||||
<>
|
||||
<p data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.p">
|
||||
<Trans>
|
||||
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are exclusively available to{' '}
|
||||
{PREMIUM_PRODUCT_NAME} subscribers.
|
||||
</Trans>
|
||||
</p>
|
||||
<p
|
||||
className={styles.spacedParagraph}
|
||||
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.spaced-paragraph"
|
||||
>
|
||||
{RuntimeConfig.usesUniqueUsernames ? (
|
||||
<Trans>
|
||||
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
|
||||
profile banners, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
With {PREMIUM_PRODUCT_NAME}, you can use animated avatars ({ANIMATED_AVATAR_FORMATS}) and
|
||||
profile banners, customize your tag, and unlock many other {PREMIUM_PRODUCT_NAME} perks.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
primaryText={i18n._(GET_PREMIUM_DESCRIPTOR, {premiumProductName: getPremiumProductName()})}
|
||||
primaryVariant="primary"
|
||||
secondaryText={i18n._(CANCEL_DESCRIPTOR)}
|
||||
onPrimary={() => {
|
||||
window.setTimeout(() => {
|
||||
PremiumModalCommands.open();
|
||||
}, 0);
|
||||
}}
|
||||
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
title={i18n._(ANIMATED_AVATARS_NOT_AVAILABLE_DESCRIPTOR)}
|
||||
message={
|
||||
<Trans>
|
||||
Animated avatars ({ANIMATED_AVATAR_FORMATS}) are not available on this instance. Upload a static
|
||||
image instead.
|
||||
</Trans>
|
||||
}
|
||||
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.confirm-modal--2"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const isAnimatedAvif = animated && format === 'avif';
|
||||
if (isAnimatedAvif && !(await canDecodeAnimatedAvif())) {
|
||||
showAnimatedAvifUnsupportedModal({i18n});
|
||||
return;
|
||||
}
|
||||
const base64 = svg ? await readImageFileAsUploadDataUrl(file) : await readBlobAsDataUrl(file);
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<AssetCropModal
|
||||
assetType={AssetType.AVATAR}
|
||||
imageUrl={base64}
|
||||
onCropComplete={(croppedBlob) => {
|
||||
void readBlobAsDataUrl(croppedBlob).then(
|
||||
(croppedBase64) => {
|
||||
onAvatarChange(croppedBase64);
|
||||
},
|
||||
() => {
|
||||
showGenericErrorModal({
|
||||
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
|
||||
message: i18n._(FAILED_TO_PROCESS_CROPPED_IMAGE_DESCRIPTOR),
|
||||
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
|
||||
});
|
||||
},
|
||||
);
|
||||
}}
|
||||
onSkip={
|
||||
isAnimatedAvif
|
||||
? undefined
|
||||
: () => {
|
||||
onAvatarChange(base64);
|
||||
}
|
||||
}
|
||||
data-flx="user.my-profile-tab.avatar-uploader.handle-avatar-upload.asset-crop-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
},
|
||||
[canUploadAnimatedAvatar, onAvatarChange, i18n],
|
||||
);
|
||||
const showAvatarUploadError = useCallback(() => {
|
||||
showGenericErrorModal({
|
||||
title: i18n._(COULDN_T_UPLOAD_AVATAR_DESCRIPTOR),
|
||||
message: i18n._(INVALID_IMAGE_TRY_ANOTHER_DESCRIPTOR),
|
||||
dataFlx: 'user.user-error-modal-utils.show-user-error-modal.generic-error-modal',
|
||||
});
|
||||
}, [i18n]);
|
||||
const handlePickAvatarFile = useCallback(async () => {
|
||||
try {
|
||||
const [file] = await openFilePicker({accept: getAcceptString('avatar')});
|
||||
if (!file) return;
|
||||
await processAvatarFile(file);
|
||||
} catch {
|
||||
showAvatarUploadError();
|
||||
}
|
||||
}, [processAvatarFile, showAvatarUploadError]);
|
||||
const handleSelectAvatarGif = useCallback(
|
||||
async (gif: Gif) => {
|
||||
try {
|
||||
await processAvatarFile(await downloadGifAsImageFile(gif));
|
||||
} catch {
|
||||
showAvatarUploadError();
|
||||
}
|
||||
},
|
||||
[processAvatarFile, showAvatarUploadError],
|
||||
);
|
||||
const handleAvatarUpload = useCallback(() => {
|
||||
const uploadHint = canUploadAnimatedAvatar
|
||||
? formatImageUploadRecommendedHint(i18n, {
|
||||
formats: ANIMATED_IMAGE_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
})
|
||||
: shouldShowPremiumFeatures()
|
||||
? formatImageUploadRecommendedHintWithNote(i18n, {
|
||||
formats: STATIC_IMAGE_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
note: i18n._(ANIMATED_AVATARS_REQUIRE_PREMIUM_NOTE_DESCRIPTOR, {
|
||||
animatedAvatarFormats: ANIMATED_AVATAR_FORMATS,
|
||||
premiumProductName: getPremiumProductName(),
|
||||
}),
|
||||
})
|
||||
: formatImageUploadRecommendedHint(i18n, {
|
||||
formats: STATIC_IMAGE_FORMATS,
|
||||
maxSize: formatFileSize(i18n.locale, GlobalLimits.getAvatarMaxSize()),
|
||||
recommendedSize: AVATAR_RECOMMENDED_SIZE_LABEL,
|
||||
});
|
||||
openAssetSourceModal({
|
||||
title: i18n._(CHANGE_AVATAR_DESCRIPTOR),
|
||||
uploadHint,
|
||||
onPickUpload: handlePickAvatarFile,
|
||||
onSelectGif: (gif) => void handleSelectAvatarGif(gif),
|
||||
});
|
||||
}, [canUploadAnimatedAvatar, handlePickAvatarFile, handleSelectAvatarGif, i18n]);
|
||||
const handleModeChange = useCallback(
|
||||
(mode: AvatarMode) => {
|
||||
if (mode === 'custom') {
|
||||
|
||||
+1
-2
@@ -48,8 +48,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
border: 0.0625rem solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GuildComboboxOption} from '@app/features/app/components/dialogs/shared/GuildComboboxRenderers';
|
||||
import {GuildAcronym} from '@app/features/guild/components/GuildAcronym';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {Combobox} from '@app/features/ui/components/form/FormCombobox';
|
||||
import styles from '@app/features/user/components/modals/tabs/my_profile_tab/ProfileTypeSelector.module.css';
|
||||
@@ -31,7 +32,6 @@ export const ProfileTypeSelector = observer(({selectedGuildId, onChange, disable
|
||||
const renderGuildRow = useCallback((option: GuildComboboxOption) => {
|
||||
const isGlobal = !option.value;
|
||||
const iconUrl = option.icon ? AvatarUtils.getGuildIconURL({id: option.value, icon: option.icon}) : null;
|
||||
const initial = option.label.charAt(0).toUpperCase();
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.guildOption, isGlobal && styles.guildOptionGlobal)}
|
||||
@@ -51,7 +51,10 @@ export const ProfileTypeSelector = observer(({selectedGuildId, onChange, disable
|
||||
aria-hidden
|
||||
data-flx="user.my-profile-tab.profile-type-selector.render-guild-row.guild-avatar-placeholder"
|
||||
>
|
||||
{initial}
|
||||
<GuildAcronym
|
||||
name={option.label}
|
||||
data-flx="user.my-profile-tab.profile-type-selector.render-guild-row.guild-avatar-placeholder-acronym"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<span
|
||||
|
||||
@@ -65,6 +65,7 @@ interface ProfilePreviewProps {
|
||||
showMessageButton?: boolean;
|
||||
showPreviewLabel?: boolean;
|
||||
previewCustomStatus?: CustomStatus | null;
|
||||
onAvatarClick?: () => void;
|
||||
}
|
||||
|
||||
export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
|
||||
@@ -90,6 +91,7 @@ export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
|
||||
showMessageButton = true,
|
||||
showPreviewLabel = true,
|
||||
previewCustomStatus,
|
||||
onAvatarClick,
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const previewOverrides = useMemo<ProfilePreviewOverrides>(
|
||||
@@ -245,7 +247,7 @@ export const ProfilePreview: React.FC<ProfilePreviewProps> = observer(
|
||||
avatarUrl={finalAvatarUrl}
|
||||
hoverAvatarUrl={finalHoverAvatarUrl}
|
||||
isClickable={true}
|
||||
onAvatarClick={openMockProfile}
|
||||
onAvatarClick={onAvatarClick ?? openMockProfile}
|
||||
data-flx="user.profile.profile-preview.profile-card-banner"
|
||||
/>
|
||||
<UserProfileBadges
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import '@fluxer/schema/src/ZodJitless';
|
||||
import '@app/app/AbortSignalPolyfill';
|
||||
import '@app/app/BootShell.css';
|
||||
import {startAppStorage} from '@app/features/platform/state/AppStorageBootstrap';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const {execFile} = require('node:child_process');
|
||||
const {readFileSync} = require('node:fs');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
@@ -102,6 +103,7 @@ const isMacBuild = process.argv.includes('--mac');
|
||||
const isWindowsBuild = process.argv.includes('--win');
|
||||
const targetPlatform = isLinuxBuild ? 'linux' : isMacBuild ? 'darwin' : isWindowsBuild ? 'win32' : process.platform;
|
||||
const metadataName = isLinuxBuild ? linuxPackageName : packageName;
|
||||
const shellVersion = process.env.VERSION || readBundledRendererVersion();
|
||||
const supportedTargetArchs = ['x64', 'arm64'];
|
||||
const supportedMacTargetArchs = [...supportedTargetArchs, 'universal'];
|
||||
const electronArch = process.env.ELECTRON_ARCH;
|
||||
@@ -644,6 +646,15 @@ function packagePathParts(packageName) {
|
||||
return [packageName];
|
||||
}
|
||||
|
||||
function readBundledRendererVersion() {
|
||||
try {
|
||||
const {version} = JSON.parse(readFileSync(path.join(__dirname, 'dist', 'renderer', 'version.json'), 'utf8'));
|
||||
return typeof version === 'string' && /^\d+\.\d+\.\d+$/u.test(version) ? version : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
@@ -1623,7 +1634,7 @@ module.exports = {
|
||||
extraMetadata: {
|
||||
main: 'dist/main/index.js',
|
||||
name: metadataName,
|
||||
...(process.env.VERSION ? {version: process.env.VERSION} : {}),
|
||||
...(shellVersion ? {version: shellVersion} : {}),
|
||||
...(targetPlatform === 'linux' ? {desktopName: `${linuxDesktopId}.desktop`} : {}),
|
||||
},
|
||||
extraResources: [
|
||||
|
||||
+2
-2
@@ -614,9 +614,9 @@ checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
|
||||
@@ -39,18 +39,20 @@ const isProduction =
|
||||
process.env.FLUXER_DESKTOP_PRODUCTION === 'true' ||
|
||||
process.env.GITHUB_ACTIONS === 'true';
|
||||
const skipNative = process.env.FLUXER_SKIP_NATIVE === 'true';
|
||||
const embeddedBuildVersion = process.env.PUBLIC_BUILD_VERSION || process.env.BUILD_VERSION || '';
|
||||
let embeddedBuildVersion = process.env.PUBLIC_BUILD_VERSION || process.env.BUILD_VERSION || '';
|
||||
const embeddedReleaseChannel = process.env.PUBLIC_RELEASE_CHANNEL || process.env.RELEASE_CHANNEL || '';
|
||||
const modulesEnabled = process.env.FLUXER_MODULES === '1' || process.env.FLUXER_MODULES === 'true';
|
||||
const offlineBuild = process.env.FLUXER_OFFLINE === '1' || process.env.FLUXER_OFFLINE === 'true';
|
||||
const publicBuildDefines = {
|
||||
'process.env.PUBLIC_BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
|
||||
'process.env.BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
|
||||
'process.env.PUBLIC_RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
|
||||
'process.env.RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
|
||||
'process.env.FLUXER_MODULES': JSON.stringify(modulesEnabled ? '1' : ''),
|
||||
'process.env.FLUXER_OFFLINE': JSON.stringify(offlineBuild ? '1' : ''),
|
||||
};
|
||||
function publicBuildDefines() {
|
||||
return {
|
||||
'process.env.PUBLIC_BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
|
||||
'process.env.BUILD_VERSION': JSON.stringify(embeddedBuildVersion),
|
||||
'process.env.PUBLIC_RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
|
||||
'process.env.RELEASE_CHANNEL': JSON.stringify(embeddedReleaseChannel),
|
||||
'process.env.FLUXER_MODULES': JSON.stringify(modulesEnabled ? '1' : ''),
|
||||
'process.env.FLUXER_OFFLINE': JSON.stringify(offlineBuild ? '1' : ''),
|
||||
};
|
||||
}
|
||||
const electronExternals = [
|
||||
'electron',
|
||||
'electron-log',
|
||||
@@ -116,6 +118,16 @@ function readBundledRendererVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
function fallBackToBundledRendererVersion() {
|
||||
if (embeddedBuildVersion !== '') {
|
||||
return;
|
||||
}
|
||||
const bundledVersion = readBundledRendererVersion();
|
||||
if (BUNDLED_RENDERER_VERSION_PATTERN.test(bundledVersion)) {
|
||||
embeddedBuildVersion = bundledVersion;
|
||||
}
|
||||
}
|
||||
|
||||
class RendererServiceWorkerPresentError extends Error {
|
||||
constructor(present) {
|
||||
super(
|
||||
@@ -674,7 +686,7 @@ async function buildMain() {
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
|
||||
[MAIN_APP_OUTPUT_FILE_DEFINE]: JSON.stringify(MAIN_APP_OUTPUT_FILE_NAME),
|
||||
...publicBuildDefines,
|
||||
...publicBuildDefines(),
|
||||
},
|
||||
banner: {
|
||||
js: `import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);`,
|
||||
@@ -806,7 +818,7 @@ function preloadBuildOptions(entryFileName, outputFileName) {
|
||||
tsconfig: DESKTOP_TSCONFIG,
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(isProduction ? 'production' : 'development'),
|
||||
...publicBuildDefines,
|
||||
...publicBuildDefines(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -887,6 +899,7 @@ async function build() {
|
||||
if (!options.useSharedRenderer) {
|
||||
copyRendererIntoDist();
|
||||
}
|
||||
fallBackToBundledRendererVersion();
|
||||
if (modulesEnabled) {
|
||||
prunePackedRendererModules();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {after, describe, test} from 'node:test';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const DESKTOP_DIR = path.resolve(import.meta.dirname, '..', '..');
|
||||
const PREFLIGHT_MODULES = path.join('src', 'main', 'NativeModulePreflightModules.json');
|
||||
const temporaryRoots = [];
|
||||
|
||||
after(() => {
|
||||
for (const root of temporaryRoots) {
|
||||
fs.rmSync(root, {recursive: true, force: true});
|
||||
}
|
||||
});
|
||||
|
||||
function loadMetadataVersion({version, rendererVersionJson}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'builder-shell-version-'));
|
||||
temporaryRoots.push(root);
|
||||
fs.copyFileSync(
|
||||
path.join(DESKTOP_DIR, 'electron-builder.config.cjs'),
|
||||
path.join(root, 'electron-builder.config.cjs'),
|
||||
);
|
||||
fs.mkdirSync(path.join(root, 'src', 'main'), {recursive: true});
|
||||
fs.copyFileSync(path.join(DESKTOP_DIR, PREFLIGHT_MODULES), path.join(root, PREFLIGHT_MODULES));
|
||||
if (rendererVersionJson != null) {
|
||||
fs.mkdirSync(path.join(root, 'dist', 'renderer'), {recursive: true});
|
||||
fs.writeFileSync(path.join(root, 'dist', 'renderer', 'version.json'), rendererVersionJson);
|
||||
}
|
||||
const previous = process.env.VERSION;
|
||||
if (version == null) {
|
||||
delete process.env.VERSION;
|
||||
} else {
|
||||
process.env.VERSION = version;
|
||||
}
|
||||
try {
|
||||
return require(path.join(root, 'electron-builder.config.cjs')).extraMetadata.version;
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.VERSION;
|
||||
} else {
|
||||
process.env.VERSION = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('the packaged shell version', () => {
|
||||
test('comes from VERSION whenever it is set', () => {
|
||||
assert.equal(
|
||||
loadMetadataVersion({version: '2026.1014.2', rendererVersionJson: '{"version":"2026.1014.9"}'}),
|
||||
'2026.1014.2',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the bundled renderer version when VERSION is unset', () => {
|
||||
assert.equal(loadMetadataVersion({rendererVersionJson: '{"version":"2026.1014.9"}'}), '2026.1014.9');
|
||||
});
|
||||
|
||||
test('keeps the package.json version when the renderer has no numeric version', () => {
|
||||
assert.equal(loadMetadataVersion({rendererVersionJson: '{"version":"dev"}'}), undefined);
|
||||
assert.equal(loadMetadataVersion({rendererVersionJson: 'null'}), undefined);
|
||||
assert.equal(loadMetadataVersion({}), undefined);
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,7 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
const {configureUserDataPath} = await import('@electron/common/UserDataPath');
|
||||
const {armNativeProbeCache} = await import('@electron/main/NativeProbeCache');
|
||||
const {isStartMinimizedLaunch} = await import('@electron/main/AutostartLaunch');
|
||||
const {isDesktopUpdateRequested} = await import('@electron/main/LaunchOptions');
|
||||
const {areModuleUpdatesDisabled, isDesktopUpdateRequested} = await import('@electron/main/LaunchOptions');
|
||||
const {relaunchStableLaunchPath} = await import('@electron/main/LinuxLaunchPath');
|
||||
const {getDesktopLocalAppProtocol} = await import('@electron/main/LocalAppProtocol');
|
||||
const {
|
||||
@@ -410,6 +410,10 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
logger.warn('Failed to record whether an update is waiting', error);
|
||||
}
|
||||
};
|
||||
const moduleUpdatesDisabled = areModuleUpdatesDisabled();
|
||||
if (moduleUpdatesDisabled) {
|
||||
logger.info('Module updates are disabled by FLUXER_DISABLE_MODULE_UPDATES');
|
||||
}
|
||||
const updater = new ModuleUpdater({
|
||||
store,
|
||||
shellVersion: app.getVersion(),
|
||||
@@ -417,6 +421,7 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
bundledRendererVersion: BUNDLED_RENDERER_VERSION,
|
||||
preferUnversionedBundle: app.isPackaged,
|
||||
forceStartupUpdate: store.shellVersionChanged || updateWasPending || isDesktopUpdateRequested(),
|
||||
moduleUpdatesDisabled,
|
||||
selfUpdateShellFirst:
|
||||
shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE
|
||||
? async (latestVersion) => {
|
||||
@@ -705,35 +710,37 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
},
|
||||
};
|
||||
|
||||
const desktopUpdate = new DesktopUpdateRun({
|
||||
probe: () => updater.checkForUpdate(),
|
||||
canSelfUpdateShell: shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE,
|
||||
runShellSelfUpdate: () => runShellSelfUpdateOnSplash(),
|
||||
installModules: async () => {
|
||||
const launchAttempt = await updater.installPending();
|
||||
if (launchAttempt != null) {
|
||||
await refreshModuleRoots(launchAttempt.committed);
|
||||
}
|
||||
return launchAttempt;
|
||||
},
|
||||
takeover: updateTakeover,
|
||||
publish: (check) => {
|
||||
recordPendingUpdate(check);
|
||||
publishDesktopUpdateCheck(check);
|
||||
},
|
||||
reportFailure: (failure) => {
|
||||
void import('@electron/main/DesktopUpdatePrompt')
|
||||
.then(({reportDesktopUpdateFailure}) => reportDesktopUpdateFailure(failure))
|
||||
.catch((error: unknown) => {
|
||||
logger.error('Failed to report the desktop update failure', error);
|
||||
});
|
||||
},
|
||||
openDownloadsPage: async () => {
|
||||
const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads');
|
||||
await shell.openExternal(DOWNLOAD_PAGE_URL);
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const desktopUpdate = moduleUpdatesDisabled
|
||||
? null
|
||||
: new DesktopUpdateRun({
|
||||
probe: () => updater.checkForUpdate(),
|
||||
canSelfUpdateShell: shellUpdatePlan.capability === ShellUpdateCapability.SELF_UPDATE,
|
||||
runShellSelfUpdate: () => runShellSelfUpdateOnSplash(),
|
||||
installModules: async () => {
|
||||
const launchAttempt = await updater.installPending();
|
||||
if (launchAttempt != null) {
|
||||
await refreshModuleRoots(launchAttempt.committed);
|
||||
}
|
||||
return launchAttempt;
|
||||
},
|
||||
takeover: updateTakeover,
|
||||
publish: (check) => {
|
||||
recordPendingUpdate(check);
|
||||
publishDesktopUpdateCheck(check);
|
||||
},
|
||||
reportFailure: (failure) => {
|
||||
void import('@electron/main/DesktopUpdatePrompt')
|
||||
.then(({reportDesktopUpdateFailure}) => reportDesktopUpdateFailure(failure))
|
||||
.catch((error: unknown) => {
|
||||
logger.error('Failed to report the desktop update failure', error);
|
||||
});
|
||||
},
|
||||
openDownloadsPage: async () => {
|
||||
const {DOWNLOAD_PAGE_URL} = await import('@electron/main/UpdaterDownloads');
|
||||
await shell.openExternal(DOWNLOAD_PAGE_URL);
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
const permit = await runModuleUpdateLoop();
|
||||
localNetworkHint.disarm();
|
||||
@@ -741,25 +748,30 @@ async function runModuleBootstrap(): Promise<void> {
|
||||
recordPendingUpdate({shellNewer: false, modulesChanged: false});
|
||||
}
|
||||
await refreshModuleRoots(permit.launchAttempt.committed);
|
||||
setOnDemandModuleInstaller(
|
||||
createOnDemandModuleInstaller({
|
||||
ensure: (moduleName) => updater.ensureModule(moduleName),
|
||||
refresh: serveOnDemandModules,
|
||||
onFailure: (moduleName, error) => {
|
||||
logger.error('Failed to install a module on demand', {module: moduleName, error});
|
||||
},
|
||||
}),
|
||||
);
|
||||
markSplashLaunching();
|
||||
armDesktopUpdate({
|
||||
check: async (manual) => {
|
||||
const check = await desktopUpdate.check({manual});
|
||||
recordPendingUpdate(check);
|
||||
return check;
|
||||
},
|
||||
start: () => desktopUpdate.start(),
|
||||
});
|
||||
if (desktopUpdate != null) {
|
||||
setOnDemandModuleInstaller(
|
||||
createOnDemandModuleInstaller({
|
||||
ensure: (moduleName) => updater.ensureModule(moduleName),
|
||||
refresh: serveOnDemandModules,
|
||||
onFailure: (moduleName, error) => {
|
||||
logger.error('Failed to install a module on demand', {module: moduleName, error});
|
||||
},
|
||||
}),
|
||||
);
|
||||
armDesktopUpdate({
|
||||
check: async (manual) => {
|
||||
const check = await desktopUpdate.check({manual});
|
||||
recordPendingUpdate(check);
|
||||
return check;
|
||||
},
|
||||
start: () => desktopUpdate.start(),
|
||||
});
|
||||
}
|
||||
armMainWindowHandoff(updater, permit.launchAttempt, () => {
|
||||
if (desktopUpdate == null) {
|
||||
return;
|
||||
}
|
||||
desktopUpdate.markLaunchSettled();
|
||||
preloadUpdateSplash();
|
||||
armDesktopUpdatePoll(
|
||||
|
||||
@@ -275,3 +275,26 @@ describe('Bootstrap entry point', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FLUXER_DISABLE_MODULE_UPDATES', () => {
|
||||
test('reads the environment variable like the other FLUXER_DISABLE switches', async () => {
|
||||
const {areModuleUpdatesDisabled} = await import('./LaunchOptions.ts');
|
||||
const previous = process.env.FLUXER_DISABLE_MODULE_UPDATES;
|
||||
try {
|
||||
delete process.env.FLUXER_DISABLE_MODULE_UPDATES;
|
||||
assert.equal(areModuleUpdatesDisabled(), false);
|
||||
for (const value of ['1', 'true', 'YES', 'on']) {
|
||||
process.env.FLUXER_DISABLE_MODULE_UPDATES = value;
|
||||
assert.equal(areModuleUpdatesDisabled(), true);
|
||||
}
|
||||
process.env.FLUXER_DISABLE_MODULE_UPDATES = '0';
|
||||
assert.equal(areModuleUpdatesDisabled(), false);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.FLUXER_DISABLE_MODULE_UPDATES;
|
||||
} else {
|
||||
process.env.FLUXER_DISABLE_MODULE_UPDATES = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '@electron/common/Types';
|
||||
import {getDesktopUpdateState, observeDesktopUpdateState} from '@electron/main/DesktopUpdateGate';
|
||||
import {checkForUpdatesFromShell, startDesktopUpdateFromShell} from '@electron/main/DesktopUpdatePrompt';
|
||||
import {areModuleUpdatesDisabled} from '@electron/main/LaunchOptions';
|
||||
import {relaunchStableLaunchPath} from '@electron/main/LinuxLaunchPath';
|
||||
import {onLocaleChange, t} from '@electron/main/MainI18n';
|
||||
import {app, type BrowserWindow, clipboard, Menu, nativeImage, Tray} from 'electron';
|
||||
@@ -399,13 +400,15 @@ function buildTrayMenu(): Menu {
|
||||
click: () => runTrayMenuAction(startDesktopUpdateFromShell),
|
||||
});
|
||||
}
|
||||
menuTemplate.push({
|
||||
label: t('desktop.tray.checkForUpdates'),
|
||||
click: () =>
|
||||
runTrayMenuAction(() => {
|
||||
void checkForUpdatesFromShell();
|
||||
}),
|
||||
});
|
||||
if (!areModuleUpdatesDisabled()) {
|
||||
menuTemplate.push({
|
||||
label: t('desktop.tray.checkForUpdates'),
|
||||
click: () =>
|
||||
runTrayMenuAction(() => {
|
||||
void checkForUpdatesFromShell();
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (trayState.buildInfo) {
|
||||
menuTemplate.push({
|
||||
label: t('desktop.tray.copyBuildInfo'),
|
||||
|
||||
@@ -25,8 +25,8 @@ function deferred() {
|
||||
|
||||
describe('DesktopUpdateGate', () => {
|
||||
test('nothing is available and nothing starts before the bootstrap arms the gate', async () => {
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false});
|
||||
assert.deepEqual(await checkDesktopUpdateNow(), {available: false, updating: false});
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false, replacesShell: false});
|
||||
assert.deepEqual(await checkDesktopUpdateNow(), {available: false, updating: false, replacesShell: false});
|
||||
assert.equal(startDesktopUpdate(), false);
|
||||
});
|
||||
|
||||
@@ -44,17 +44,17 @@ describe('DesktopUpdateGate', () => {
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(await checkDesktopUpdateNow(), {available: true, updating: false});
|
||||
assert.deepEqual(await checkDesktopUpdateNow(), {available: true, updating: false, replacesShell: false});
|
||||
assert.equal(startDesktopUpdate(), true);
|
||||
assert.equal(startDesktopUpdate(), false, 'a second click while the update runs is a no-op');
|
||||
assert.deepEqual(
|
||||
getDesktopUpdateState(),
|
||||
{available: false, updating: true},
|
||||
{available: false, updating: true, replacesShell: false},
|
||||
'the affordance hides the moment it is clicked',
|
||||
);
|
||||
assert.deepEqual(
|
||||
await checkDesktopUpdateNow(),
|
||||
{available: false, updating: true},
|
||||
{available: false, updating: true, replacesShell: false},
|
||||
'a poll during the update changes nothing',
|
||||
);
|
||||
|
||||
@@ -62,11 +62,11 @@ describe('DesktopUpdateGate', () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(starts, 1);
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false});
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: false, updating: false, replacesShell: false});
|
||||
assert.deepEqual(states, [
|
||||
{available: true, updating: false},
|
||||
{available: false, updating: true},
|
||||
{available: false, updating: false},
|
||||
{available: true, updating: false, replacesShell: false},
|
||||
{available: false, updating: true, replacesShell: false},
|
||||
{available: false, updating: false, replacesShell: false},
|
||||
]);
|
||||
stop();
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describe('DesktopUpdateGate', () => {
|
||||
pending.resolve();
|
||||
await background;
|
||||
|
||||
assert.deepEqual(await manual, {available: true, updating: false});
|
||||
assert.deepEqual(await manual, {available: true, updating: false, replacesShell: true});
|
||||
assert.deepEqual(calls, [false, true]);
|
||||
});
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('DesktopUpdateGate', () => {
|
||||
assert.equal(startDesktopUpdate(), true);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: true, updating: false});
|
||||
assert.deepEqual(getDesktopUpdateState(), {available: true, updating: false, replacesShell: true});
|
||||
assert.equal(startDesktopUpdate(), true);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ interface DesktopUpdateGateState {
|
||||
checking: {readonly manual: boolean; readonly settled: Promise<void>} | null;
|
||||
notifiedAvailable: boolean;
|
||||
notifiedUpdating: boolean;
|
||||
notifiedReplacesShell: boolean;
|
||||
readonly listeners: Set<DesktopUpdateStateListener>;
|
||||
}
|
||||
|
||||
@@ -34,6 +35,7 @@ const gateState: DesktopUpdateGateState = {
|
||||
checking: null,
|
||||
notifiedAvailable: false,
|
||||
notifiedUpdating: false,
|
||||
notifiedReplacesShell: false,
|
||||
listeners: new Set(),
|
||||
};
|
||||
|
||||
@@ -42,9 +44,11 @@ function isAvailable(check: DesktopUpdateCheck | null): boolean {
|
||||
}
|
||||
|
||||
export function getDesktopUpdateState(): DesktopUpdateState {
|
||||
const available = gateState.running == null && isAvailable(gateState.lastCheck);
|
||||
return {
|
||||
available: gateState.running == null && isAvailable(gateState.lastCheck),
|
||||
available,
|
||||
updating: gateState.running != null,
|
||||
replacesShell: available && gateState.lastCheck?.shellNewer === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,11 +59,17 @@ export function desktopUpdateReplacesShell(): boolean {
|
||||
function notify(): void {
|
||||
const state = getDesktopUpdateState();
|
||||
const updating = state.updating === true;
|
||||
if (state.available === gateState.notifiedAvailable && updating === gateState.notifiedUpdating) {
|
||||
const replacesShell = state.replacesShell === true;
|
||||
if (
|
||||
state.available === gateState.notifiedAvailable &&
|
||||
updating === gateState.notifiedUpdating &&
|
||||
replacesShell === gateState.notifiedReplacesShell
|
||||
) {
|
||||
return;
|
||||
}
|
||||
gateState.notifiedAvailable = state.available;
|
||||
gateState.notifiedUpdating = updating;
|
||||
gateState.notifiedReplacesShell = replacesShell;
|
||||
for (const listener of Array.from(gateState.listeners)) {
|
||||
try {
|
||||
listener(state);
|
||||
|
||||
@@ -159,9 +159,14 @@ export function describeLaunchDiagnosticOptions(argv: ReadonlyArray<string> = pr
|
||||
spellcheck: getSpellcheckLaunchMode(argv),
|
||||
v8CodeCacheDisabled: shouldDisableV8CodeCache(argv),
|
||||
nativeGatewayDisableReason: getNativeGatewayDisableReason(argv),
|
||||
moduleUpdatesDisabled: areModuleUpdatesDisabled(),
|
||||
};
|
||||
}
|
||||
|
||||
export function areModuleUpdatesDisabled(): boolean {
|
||||
return truthyEnv('FLUXER_DISABLE_MODULE_UPDATES');
|
||||
}
|
||||
|
||||
export function isDesktopUpdateRequested(argv: ReadonlyArray<string> = process.argv): boolean {
|
||||
return hasFlag(argv, DESKTOP_UPDATE_ARGS);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {DESKTOP_APP_NAME} from '@electron/common/DesktopIdentity';
|
||||
import {checkForUpdatesFromShell} from '@electron/main/DesktopUpdatePrompt';
|
||||
import {areModuleUpdatesDisabled} from '@electron/main/LaunchOptions';
|
||||
import {onLocaleChange, t} from '@electron/main/MainI18n';
|
||||
import {openExternalDeduped} from '@electron/main/OpenExternal';
|
||||
import {buildTroubleshootingMenuItems} from '@electron/main/Troubleshooting';
|
||||
@@ -31,7 +32,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
|
||||
role: 'about',
|
||||
label: t('desktop.appMenu.about', {appName}),
|
||||
},
|
||||
checkForUpdatesItem(),
|
||||
...(areModuleUpdatesDisabled() ? [] : [checkForUpdatesItem()]),
|
||||
{type: 'separator'},
|
||||
{
|
||||
label: t('desktop.appMenu.preferences'),
|
||||
@@ -209,7 +210,7 @@ function buildTemplate(): Array<MenuItemConstructorOptions> {
|
||||
? `${t('desktop.appMenu.help')}${MACOS_HELP_MENU_TITLE_AUTODETECT_OPT_OUT}`
|
||||
: t('desktop.appMenu.help'),
|
||||
submenu: [
|
||||
...(isMac ? [] : [checkForUpdatesItem(), {type: 'separator' as const}]),
|
||||
...(isMac || areModuleUpdatesDisabled() ? [] : [checkForUpdatesItem(), {type: 'separator' as const}]),
|
||||
{
|
||||
label: t('desktop.appMenu.website'),
|
||||
click: async () => {
|
||||
|
||||
@@ -127,6 +127,7 @@ function createUpdater(store, modules, options = {}) {
|
||||
hasOfflineRenderer: options.bundledRendererVersion != null,
|
||||
bundledRendererVersion: options.bundledRendererVersion ?? null,
|
||||
forceStartupUpdate: options.forceStartupUpdate ?? false,
|
||||
moduleUpdatesDisabled: options.moduleUpdatesDisabled ?? false,
|
||||
selfUpdateShellFirst: options.selfUpdateShellFirst,
|
||||
onState: options.onState,
|
||||
fetch: async (url, init) => {
|
||||
@@ -506,6 +507,75 @@ describe('ModuleUpdater.run', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModuleUpdater with module updates disabled', () => {
|
||||
test('launches the verified set without fetching even when the manifest demands a shell update', async () => {
|
||||
const userDataPath = createUserData();
|
||||
const store = await openStore(userDataPath);
|
||||
installModuleOnDisk(getModuleStoreRoot(userDataPath), 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
|
||||
await store.commit({fluxer_renderer: RENDERER_SHA});
|
||||
assert.equal((await createUpdater(store, {fluxer_renderer: RENDERER_SHA}).updater.run()).status, 'launching');
|
||||
const statuses = [];
|
||||
let shellUpdates = 0;
|
||||
const {updater, requests, sleeps} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{
|
||||
shellLatest: '2026.824.0',
|
||||
shellMinimum: '2026.824.0',
|
||||
forceStartupUpdate: true,
|
||||
moduleUpdatesDisabled: true,
|
||||
onState: (state) => statuses.push(state.status),
|
||||
selfUpdateShellFirst: async () => {
|
||||
shellUpdates += 1;
|
||||
return {reason: 'no-update'};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'unreachable-launch');
|
||||
assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA});
|
||||
assert.deepEqual(requests, []);
|
||||
assert.deepEqual(sleeps, []);
|
||||
assert.equal(shellUpdates, 0);
|
||||
assert.deepEqual(statuses, ['unreachable-launch']);
|
||||
assert.equal(outcome.launchAttempt.committed.fluxer_renderer, RENDERER_SHA);
|
||||
});
|
||||
|
||||
test('launches the bundled renderer on a fresh store without fetching', async () => {
|
||||
const store = await openStore(createUserData());
|
||||
const {updater, requests} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: NEXT_RENDERER_SHA},
|
||||
{bundledRendererVersion: SHELL_VERSION, moduleUpdatesDisabled: true},
|
||||
);
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'unreachable-launch');
|
||||
assert.deepEqual(outcome.committed, {});
|
||||
assert.deepEqual(requests, []);
|
||||
});
|
||||
|
||||
test('blocks at once on the unreachable splash without fetching when nothing is installed', async () => {
|
||||
const store = await openStore(createUserData());
|
||||
const {updater, requests, sleeps} = createUpdater(
|
||||
store,
|
||||
{fluxer_renderer: RENDERER_SHA},
|
||||
{moduleUpdatesDisabled: true},
|
||||
);
|
||||
|
||||
const outcome = await updater.run();
|
||||
|
||||
assert.equal(outcome.status, 'blocked-update-required');
|
||||
assert.equal(outcome.reason, 'nothing-installed');
|
||||
assert.equal(outcome.updateServerUnreachable, true);
|
||||
assert.deepEqual(requests, []);
|
||||
assert.deepEqual(sleeps, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ModuleUpdater checks without downloading and installs only when asked', () => {
|
||||
async function storeOnRenderer() {
|
||||
const userDataPath = createUserData();
|
||||
|
||||
@@ -267,6 +267,7 @@ interface ModuleUpdaterOptions {
|
||||
readonly bundledRendererVersion?: string | null;
|
||||
readonly preferUnversionedBundle?: boolean;
|
||||
readonly forceStartupUpdate?: boolean;
|
||||
readonly moduleUpdatesDisabled?: boolean;
|
||||
readonly selfUpdateShellFirst?: (latestVersion: string) => Promise<{readonly reason: string}>;
|
||||
readonly onState?: (state: ModuleUpdaterSplashState) => void;
|
||||
readonly report?: (report: ModuleUpdaterReport) => void;
|
||||
@@ -286,6 +287,7 @@ export class ModuleUpdater {
|
||||
private readonly shellVersion: ModuleVersion;
|
||||
private readonly hasOfflineRenderer: boolean;
|
||||
private readonly forceStartupUpdate: boolean;
|
||||
private readonly moduleUpdatesDisabled: boolean;
|
||||
private readonly selfUpdateShellFirst: ((latestVersion: string) => Promise<{readonly reason: string}>) | null;
|
||||
private readonly shellFirstAttempts = new Map<string, {readonly reason: string} | null>();
|
||||
private readonly manifestFeed: ModuleManifestFeedIdentity;
|
||||
@@ -314,6 +316,7 @@ export class ModuleUpdater {
|
||||
this.shellVersion = parseModuleVersion(options.shellVersion, 'desktop shell version');
|
||||
this.hasOfflineRenderer = options.hasOfflineRenderer ?? false;
|
||||
this.forceStartupUpdate = options.forceStartupUpdate ?? false;
|
||||
this.moduleUpdatesDisabled = options.moduleUpdatesDisabled ?? false;
|
||||
this.selfUpdateShellFirst = options.selfUpdateShellFirst ?? null;
|
||||
const releaseChannel = options.releaseChannel ?? BUILD_CHANNEL;
|
||||
this.platform = options.platform ?? process.platform;
|
||||
@@ -418,6 +421,18 @@ export class ModuleUpdater {
|
||||
return reverted;
|
||||
}
|
||||
}
|
||||
if (this.moduleUpdatesDisabled) {
|
||||
const decision = await this.planner.evaluateUnreachableLaunch(this.startupUpdateEnforced);
|
||||
if (decision.kind === ModuleUnreachableLaunchDecision.LAUNCH) {
|
||||
return await this.launchUnreachable();
|
||||
}
|
||||
return this.blockUpdateRequired(
|
||||
decision.reason,
|
||||
[],
|
||||
'module updates are disabled by FLUXER_DISABLE_MODULE_UPDATES',
|
||||
{updateServerUnreachable: true},
|
||||
);
|
||||
}
|
||||
if (this.platform === 'linux' && !(await this.startupUpdateRequired())) {
|
||||
return await this.launchIfAtFloor();
|
||||
}
|
||||
@@ -486,13 +501,7 @@ export class ModuleUpdater {
|
||||
if (exhausted || !fetched) {
|
||||
const decision = await this.planner.evaluateUnreachableLaunch(this.startupUpdateEnforced);
|
||||
if (decision.kind === ModuleUnreachableLaunchDecision.LAUNCH) {
|
||||
const launchAttempt = await this.store.recordLaunchAttempt();
|
||||
this.emit(ModuleUpdaterStatus.UNREACHABLE_LAUNCH);
|
||||
return {
|
||||
status: ModuleUpdaterStatus.UNREACHABLE_LAUNCH,
|
||||
committed: this.store.getCommitted(),
|
||||
launchAttempt,
|
||||
};
|
||||
return await this.launchUnreachable();
|
||||
}
|
||||
if (exhausted) {
|
||||
return this.blockUpdateRequired(decision.reason, [], describeErrorChain(error), {
|
||||
@@ -802,6 +811,16 @@ export class ModuleUpdater {
|
||||
};
|
||||
}
|
||||
|
||||
private async launchUnreachable(): Promise<ModuleUpdaterOutcome> {
|
||||
const launchAttempt = await this.store.recordLaunchAttempt();
|
||||
this.emit(ModuleUpdaterStatus.UNREACHABLE_LAUNCH);
|
||||
return {
|
||||
status: ModuleUpdaterStatus.UNREACHABLE_LAUNCH,
|
||||
committed: this.store.getCommitted(),
|
||||
launchAttempt,
|
||||
};
|
||||
}
|
||||
|
||||
private async launchRevertedSetBypassingFloor(
|
||||
committed: Readonly<Record<string, string>>,
|
||||
): Promise<ModuleUpdaterOutcome | null> {
|
||||
|
||||
@@ -73,7 +73,7 @@ import {
|
||||
registerIpcHandlers,
|
||||
} from '@electron/main/IpcHandlers';
|
||||
import {initializeJumpList} from '@electron/main/JumpList';
|
||||
import {describeLaunchDiagnosticOptions} from '@electron/main/LaunchOptions';
|
||||
import {areModuleUpdatesDisabled, describeLaunchDiagnosticOptions} from '@electron/main/LaunchOptions';
|
||||
import {cleanupLegacyHarvestHandlers, registerLegacyHarvestHandlers} from '@electron/main/LegacyHarvestIpc';
|
||||
import {initializeLegacyOriginHarvest} from '@electron/main/LegacyOriginHarvest';
|
||||
import {cleanupVirtmic, registerVirtmicHandlers} from '@electron/main/LinuxAudioCapture';
|
||||
@@ -600,7 +600,7 @@ if (launchConfigurationError) {
|
||||
showWindow,
|
||||
});
|
||||
});
|
||||
if (process.env.FLUXER_OFFLINE !== '1') {
|
||||
if (process.env.FLUXER_OFFLINE !== '1' && !areModuleUpdatesDisabled()) {
|
||||
const {registerUpdater} = await import('@electron/main/Updater');
|
||||
registerUpdater(getMainWindow);
|
||||
}
|
||||
|
||||
@@ -309,9 +309,12 @@ One account that registered and is still waiting for an Admin decision.
|
||||
| requested_at | ISO8601 timestamp | Time the account registered |
|
||||
| registration_url_id | ?string | [Registration URL](#registration-url-object) the account signed up through (1-128 characters), or null when it registered directly |
|
||||
| client_ip<sup>1</sup> | ?string | IP address the account registered from, or null when none was recorded |
|
||||
| invite_code<sup>2</sup> | ?string | Invite the account joins on approval (0-256 characters), or null when it has none |
|
||||
|
||||
<sup>1</sup> Stored at registration time and never refreshed, so it can be stale by the time an Admin reads it
|
||||
|
||||
<sup>2</sup> The invite code sent with the registration, or the instance's configured auto-join invite when it sent none. An account that registered through SSO sends no invite code, so it stores the auto-join invite or null
|
||||
|
||||
## Pending registration list object
|
||||
|
||||
Every account still waiting for an Admin decision.
|
||||
@@ -940,7 +943,7 @@ Both decisions remove the pending registration, but the endpoint does not requir
|
||||
|
||||
### Side effects
|
||||
|
||||
Approval removes both the `registration_pending_approval` trait and the `registration_rejected` trait, and joins the account to the single community when that mode is enabled and a guild is designated. A join that fails is logged and does not fail the request. Rejection removes the `registration_pending_approval` trait and adds the `registration_rejected` trait, which blocks login and every later session creation. A session issued before the decision stays valid. The pending registration is removed either way.
|
||||
Approval removes both the `registration_pending_approval` trait and the `registration_rejected` trait. When the account still had the `registration_pending_approval` trait, approval then accepts the stored `invite_code` when there is one and joins the account to the single community when that mode is enabled and a guild is designated. A join that fails, such as one through an invite that was deleted, has expired or needs the account to be online, is logged and does not fail the request. Rejection removes the `registration_pending_approval` trait and adds the `registration_rejected` trait, which blocks login and every later session creation. A session issued before the decision stays valid. The pending registration is removed either way.
|
||||
|
||||
When the stored pending registration list fails validation, the request fails before Fluxer changes the account. A failure after Fluxer writes the account traits leaves those traits written and can leave the entry in the pending list. Check the account traits and the pending list before retrying.
|
||||
|
||||
|
||||
@@ -590,7 +590,7 @@ Registration verifies a [CAPTCHA](/topics/captcha/). It permits 3 attempts per h
|
||||
|
||||
Fluxer resolves the region from the client IP address, and an age below the minimum for that region returns the field code `MUST_BE_MINIMUM_AGE`. That minimum is 13 years unless the region sets a different minimum, and the applied minimum appears only in the localised message.
|
||||
|
||||
Registration returns 403 `REGISTRATION_CLOSED` when the instance is closed and no valid registration URL was supplied. A username whose discriminator space is exhausted returns the field code `TOO_MANY_USERS_WITH_THIS_USERNAME`. A username or display name containing a blocked substring returns 403 `CONTENT_BLOCKED`.
|
||||
Registration returns 403 `REGISTRATION_CLOSED` when the instance is closed and no valid registration URL was supplied. A registration that would wait for approval, through the approval mode or a registration URL that requires approval, returns 400 `REGISTRATION_CREDENTIALS_REQUIRED` unless it has a `password`, and on an `email` instance also an `email`, because an account without them could never sign in once approved. That refusal counts against no registration allowance and uses no registration URL seat. A username whose discriminator space is exhausted returns the field code `TOO_MANY_USERS_WITH_THIS_USERNAME`. A username or display name containing a blocked substring returns 403 `CONTENT_BLOCKED`.
|
||||
|
||||
After the route rate limit, the CAPTCHA and schema validation, the setup token is checked before every other registration rule. A registration that presents the correct token is admitted whatever the registration mode is, is exempt from the registration allowances above, and receives the wildcard [Admin ACL](/admin-api/#acl-registry) when it creates a claimed account, which needs `email` on an `email` instance and `password` on a `username` instance. A client IP address is permitted 10 wrong tokens per 30 minutes, and further requests that present a token from that address return 429 until the window passes, the correct token included. A request with no token does not count.
|
||||
|
||||
@@ -607,7 +607,7 @@ There is no retry key. A repeated request with the same values creates a second
|
||||
| Status | Body | Condition |
|
||||
| --- | --- | --- |
|
||||
| 200 | [authentication token response](#authentication-token-response) \| [registration pending approval response](#registration-pending-approval-response-object) | Registration completed or entered the approval state |
|
||||
| 400 | [error response](/http-api/#error-response) | The body, CAPTCHA, account fields, registration URL code, or setup token is invalid |
|
||||
| 400 | [error response](/http-api/#error-response) | The body, CAPTCHA, account fields, sign-in credentials, registration URL code, or setup token is invalid |
|
||||
| 403 | [error response](/http-api/#error-response) | SSO enforcement, registration policy, or content moderation rejects the registration |
|
||||
| 423 | [error response](/http-api/#error-response) | `GENERAL_ERROR`, because a self-hosted instance with no account yet is still settling its [sign-in method](/http-api/instance/#set-the-setup-sign-in-method) after 15 seconds |
|
||||
| 429 | [rate limit response](/topics/rate-limits/#rate-limit-response-object) | A route, global, email, IP, or subnet bucket denies the request, or the address has used its wrong setup token allowance |
|
||||
@@ -620,7 +620,7 @@ The operation applies the instance's registration, email-domain, breached-passwo
|
||||
|
||||
Registration accepts the `invite_code` from the body, or the instance's configured auto-join invite when the body has none. On an instance with single-community mode enabled, the account also joins the community guild. Each join emits [Guild Member Add](/gateway/events/#guild-member-add) to the affected guild's sessions.
|
||||
|
||||
Approval mode registration creates no guild membership and no authentication session, and the account cannot sign in until an administrator approves it. Every other successful registration creates one session and returns its token.
|
||||
Approval mode registration creates no guild membership and no authentication session, and the account cannot sign in until an administrator approves it. The pending registration keeps that invite, and [approving it](/admin-api/instance/#update-pending-registration) joins the account to that guild and to the single community guild. Every other successful registration creates one session and returns its token.
|
||||
|
||||
### Rate limit
|
||||
|
||||
|
||||
@@ -890,6 +890,10 @@ Email verification is required for this action
|
||||
|
||||
Registration is closed on this instance
|
||||
|
||||
### `REGISTRATION_CREDENTIALS_REQUIRED`
|
||||
|
||||
This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved
|
||||
|
||||
### `REGISTRATION_PENDING_APPROVAL`
|
||||
|
||||
This registration is waiting for admin approval
|
||||
|
||||
@@ -1150,7 +1150,7 @@ Set by Docker. Node identity in metrics and logs.
|
||||
|
||||
#### `FLUXER_ENV`
|
||||
|
||||
Default `development`. The runtime mode. `development`, `production`, or `test`. Compose pins `production`, which gates Postgres validation and the admin cookie flags. On a self-hosted instance, `production` also enforces the [setup token](#setup-token).
|
||||
Default `production` when `FLUXER_SELF_HOSTED` is `true`, otherwise `development`. The runtime mode. `development`, `production`, or `test`. Compose pins `production`, which gates Postgres validation and the admin cookie flags. On a self-hosted instance, `production` also enforces the [setup token](#setup-token).
|
||||
|
||||
#### `FLUXER_SELF_HOSTED`
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1525,9 +1525,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
|
||||
@@ -665,6 +665,13 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
|
||||
return config;
|
||||
}
|
||||
|
||||
function applySelfHostedEnvDefault(config: MasterConfig, explicitEnv: string | undefined): MasterConfig {
|
||||
if (explicitEnv !== undefined || !config.instance.self_hosted) {
|
||||
return config;
|
||||
}
|
||||
return {...config, env: 'production'};
|
||||
}
|
||||
|
||||
function applyPublicOrigin(config: MasterConfig): MasterConfig {
|
||||
const raw = config.domain.public_origin;
|
||||
if (raw.trim().length === 0 && !/\p{Cc}/u.test(raw)) {
|
||||
@@ -787,7 +794,10 @@ export async function loadConfig(): Promise<MasterConfig> {
|
||||
return cachedConfig;
|
||||
}
|
||||
const overrides = buildNamedFluxerEnvOverrides(process.env);
|
||||
const merged = applyPublicOrigin(mergeConfig(defaultConfig(), overrides));
|
||||
const merged = applySelfHostedEnvDefault(
|
||||
applyPublicOrigin(mergeConfig(defaultConfig(), overrides)),
|
||||
readEnvValue(process.env, 'FLUXER_ENV'),
|
||||
);
|
||||
const normalized = normalizeConfig(merged);
|
||||
const derived = deriveEndpointsFromDomain(normalized.domain);
|
||||
const endpoints = {...derived, ...(normalized.endpoint_overrides ?? {})};
|
||||
|
||||
@@ -80,6 +80,29 @@ describe('ConfigLoader', () => {
|
||||
expect((await loadConfig()).domain.base_domain).toBe('localhost');
|
||||
});
|
||||
|
||||
test('a self-hosted instance without FLUXER_ENV runs in production', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_SELF_HOSTED: 'true',
|
||||
FLUXER_PROFILE_PSEUDONYM_SECRET: 'self-hosted-secret',
|
||||
FLUXER_POSTGRES_HOST: 'postgres',
|
||||
FLUXER_POSTGRES_DATABASE: 'fluxer',
|
||||
FLUXER_POSTGRES_PASSWORD: 'self-hosted-password',
|
||||
});
|
||||
vi.stubEnv('FLUXER_ENV', undefined);
|
||||
expect((await loadConfig()).env).toBe('production');
|
||||
});
|
||||
|
||||
test('a self-hosted instance keeps an explicit FLUXER_ENV', async () => {
|
||||
stubMinimalEnv({FLUXER_SELF_HOSTED: 'true', FLUXER_ENV: 'development'});
|
||||
expect((await loadConfig()).env).toBe('development');
|
||||
});
|
||||
|
||||
test('an instance that is not self-hosted still defaults to development', async () => {
|
||||
stubMinimalEnv();
|
||||
vi.stubEnv('FLUXER_ENV', undefined);
|
||||
expect((await loadConfig()).env).toBe('development');
|
||||
});
|
||||
|
||||
test('endpoint overrides take precedence over derived endpoints', async () => {
|
||||
stubMinimalEnv({
|
||||
FLUXER_API_ENDPOINT: 'https://custom-api.example.com',
|
||||
|
||||
@@ -84,6 +84,7 @@ export const APIErrorCodes = {
|
||||
REACTION_EMAIL_VERIFICATION_REQUIRED: 'REACTION_EMAIL_VERIFICATION_REQUIRED',
|
||||
REPORT_EMAIL_VERIFICATION_REQUIRED: 'REPORT_EMAIL_VERIFICATION_REQUIRED',
|
||||
REGISTRATION_CLOSED: 'REGISTRATION_CLOSED',
|
||||
REGISTRATION_CREDENTIALS_REQUIRED: 'REGISTRATION_CREDENTIALS_REQUIRED',
|
||||
REGISTRATION_PENDING_APPROVAL: 'REGISTRATION_PENDING_APPROVAL',
|
||||
REGISTRATION_REJECTED: 'REGISTRATION_REJECTED',
|
||||
REGISTRATION_URL_INVALID: 'REGISTRATION_URL_INVALID',
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface DesktopModuleAPI {
|
||||
export interface DesktopUpdateState {
|
||||
readonly available: boolean;
|
||||
readonly updating?: boolean;
|
||||
readonly replacesShell?: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateAPI {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
|
||||
export class RegistrationCredentialsRequiredError extends BadRequestError {
|
||||
constructor() {
|
||||
super({code: APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED});
|
||||
this.name = 'RegistrationCredentialsRequiredError';
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,7 @@ export const ErrorCodeToI18nKey = {
|
||||
[APIErrorCodes.PROCESSING_FAILED]: 'admin_and_system.processing_failed',
|
||||
[APIErrorCodes.RATE_LIMITED]: 'rate_limits.rate_limited',
|
||||
[APIErrorCodes.REGISTRATION_CLOSED]: 'auth_and_oauth.registration_closed',
|
||||
[APIErrorCodes.REGISTRATION_CREDENTIALS_REQUIRED]: 'auth_and_oauth.registration_credentials_required',
|
||||
[APIErrorCodes.REGISTRATION_PENDING_APPROVAL]: 'auth_and_oauth.registration_pending_approval',
|
||||
[APIErrorCodes.REGISTRATION_REJECTED]: 'auth_and_oauth.registration_rejected',
|
||||
[APIErrorCodes.REGISTRATION_URL_INVALID]: 'auth_and_oauth.registration_url_invalid',
|
||||
|
||||
@@ -106,6 +106,8 @@ export const ERROR_I18N_MESSAGES = {
|
||||
'auth_and_oauth.origin_handoff_nonce_invalid': "This sign-in transfer doesn't match the one you started.",
|
||||
'auth_and_oauth.passkey_bridge_nonce_invalid': 'This passkey request could not be confirmed. Try again.',
|
||||
'auth_and_oauth.registration_closed': 'Registration is closed on this instance.',
|
||||
'auth_and_oauth.registration_credentials_required':
|
||||
'This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.',
|
||||
'auth_and_oauth.registration_pending_approval': 'This registration is waiting for admin approval.',
|
||||
'auth_and_oauth.registration_rejected': 'This registration request was rejected.',
|
||||
'auth_and_oauth.registration_url_invalid': 'This registration link is invalid or has expired.',
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"auth_and_oauth.origin_handoff_nonce_invalid": "عملية نقل تسجيل الدخول هذه لا تطابق العملية التي بدأتها.",
|
||||
"auth_and_oauth.passkey_bridge_nonce_invalid": "تعذّر تأكيد طلب مفتاح المرور هذا. حاول مرة أخرى.",
|
||||
"auth_and_oauth.registration_closed": "التسجيل مغلق في هذا المثيل.",
|
||||
"auth_and_oauth.registration_credentials_required": "يراجع هذا المثيل الحسابات الجديدة. أنشئ حسابك من صفحة التسجيل باستخدام كلمة مرور حتى تتمكن من تسجيل الدخول بعد الموافقة عليه.",
|
||||
"auth_and_oauth.registration_pending_approval": "هذا التسجيل في انتظار موافقة المسؤول.",
|
||||
"auth_and_oauth.registration_rejected": "تم رفض طلب التسجيل هذا.",
|
||||
"auth_and_oauth.registration_url_invalid": "رابط التسجيل هذا غير صالح أو انتهت صلاحيته.",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"auth_and_oauth.origin_handoff_nonce_invalid": "Това прехвърляне на влизането не съвпада с започнатото от теб.",
|
||||
"auth_and_oauth.passkey_bridge_nonce_invalid": "Тази заявка за ключ за достъп не можа да бъде потвърдена. Опитай отново.",
|
||||
"auth_and_oauth.registration_closed": "Регистрацията е затворена на тази инстанция.",
|
||||
"auth_and_oauth.registration_credentials_required": "Тази инстанция преглежда новите акаунти. Регистрирайте се от страницата за регистрация с парола, за да можете да влезете след одобрение.",
|
||||
"auth_and_oauth.registration_pending_approval": "Тази регистрация чака одобрение от администратор.",
|
||||
"auth_and_oauth.registration_rejected": "Тази заявка за регистрация беше отхвърлена.",
|
||||
"auth_and_oauth.registration_url_invalid": "Този линк за регистрация е невалиден или е изтекъл.",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"auth_and_oauth.origin_handoff_nonce_invalid": "Tento přenos přihlášení neodpovídá tomu, který jste zahájili.",
|
||||
"auth_and_oauth.passkey_bridge_nonce_invalid": "Tento požadavek na přístupový klíč se nepodařilo potvrdit. Zkuste to znovu.",
|
||||
"auth_and_oauth.registration_closed": "Registrace na této instanci je uzavřena.",
|
||||
"auth_and_oauth.registration_credentials_required": "Tato instance kontroluje nové účty. Zaregistrujte se na registrační stránce s heslem, abyste se po schválení mohli přihlásit.",
|
||||
"auth_and_oauth.registration_pending_approval": "Tato registrace čeká na schválení administrátorem.",
|
||||
"auth_and_oauth.registration_rejected": "Tato žádost o registraci byla zamítnuta.",
|
||||
"auth_and_oauth.registration_url_invalid": "Tento odkaz k registraci je neplatný nebo vypršel.",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"auth_and_oauth.origin_handoff_nonce_invalid": "Denne overførsel af login matcher ikke den, du startede.",
|
||||
"auth_and_oauth.passkey_bridge_nonce_invalid": "Denne anmodning om adgangsnøgle kunne ikke bekræftes. Prøv igen.",
|
||||
"auth_and_oauth.registration_closed": "Tilmelding er lukket på denne instans.",
|
||||
"auth_and_oauth.registration_credentials_required": "Denne instans gennemgår nye konti. Tilmeld dig fra tilmeldingssiden med en adgangskode, så du kan logge ind, når kontoen er godkendt.",
|
||||
"auth_and_oauth.registration_pending_approval": "Denne tilmelding afventer godkendelse fra administratoren.",
|
||||
"auth_and_oauth.registration_rejected": "Denne anmodning om tilmelding blev afvist.",
|
||||
"auth_and_oauth.registration_url_invalid": "Dette tilmeldingslink er ugyldigt eller er udløbet.",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"auth_and_oauth.origin_handoff_nonce_invalid": "Diese Anmeldeübertragung stimmt nicht mit der von dir gestarteten überein.",
|
||||
"auth_and_oauth.passkey_bridge_nonce_invalid": "Diese Passkey-Anfrage konnte nicht bestätigt werden. Versuche es erneut.",
|
||||
"auth_and_oauth.registration_closed": "Die Registrierung ist auf dieser Instanz geschlossen.",
|
||||
"auth_and_oauth.registration_credentials_required": "Diese Instanz prüft neue Konten. Registriere dich über die Registrierungsseite mit einem Passwort, damit du dich nach der Freigabe anmelden kannst.",
|
||||
"auth_and_oauth.registration_pending_approval": "Diese Registrierung wartet auf die Genehmigung durch einen Administrator.",
|
||||
"auth_and_oauth.registration_rejected": "Diese Registrierungsanfrage wurde abgelehnt.",
|
||||
"auth_and_oauth.registration_url_invalid": "Dieser Registrierungslink ist ungültig oder abgelaufen.",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"auth_and_oauth.origin_handoff_nonce_invalid": "Αυτή η μεταφορά σύνδεσης δεν αντιστοιχεί σε αυτήν που ξεκινήσατε.",
|
||||
"auth_and_oauth.passkey_bridge_nonce_invalid": "Δεν ήταν δυνατή η επιβεβαίωση αυτού του αιτήματος κλειδιού πρόσβασης. Δοκίμασε ξανά.",
|
||||
"auth_and_oauth.registration_closed": "Η εγγραφή είναι κλειστή σε αυτήν την εγκατάσταση.",
|
||||
"auth_and_oauth.registration_credentials_required": "Αυτή η παρουσία ελέγχει τους νέους λογαριασμούς. Εγγραφείτε από τη σελίδα εγγραφής με κωδικό πρόσβασης, ώστε να μπορείτε να συνδεθείτε μετά την έγκριση.",
|
||||
"auth_and_oauth.registration_pending_approval": "Αυτή η εγγραφή περιμένει έγκριση από τον διαχειριστή.",
|
||||
"auth_and_oauth.registration_rejected": "Αυτό το αίτημα εγγραφής απορρίφθηκε.",
|
||||
"auth_and_oauth.registration_url_invalid": "Αυτός ο σύνδεσμος εγγραφής είναι άκυρος ή έχει λήξει.",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"auth_and_oauth.origin_handoff_nonce_invalid": "This sign-in transfer doesn't match the one you started.",
|
||||
"auth_and_oauth.passkey_bridge_nonce_invalid": "This passkey request could not be confirmed. Try again.",
|
||||
"auth_and_oauth.registration_closed": "Registration is closed on this instance.",
|
||||
"auth_and_oauth.registration_credentials_required": "This instance reviews new accounts. Sign up from the sign-up page with a password so you can sign in once approved.",
|
||||
"auth_and_oauth.registration_pending_approval": "This registration is waiting for admin approval.",
|
||||
"auth_and_oauth.registration_rejected": "This registration request was rejected.",
|
||||
"auth_and_oauth.registration_url_invalid": "This registration link is invalid or has expired.",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"auth_and_oauth.origin_handoff_nonce_invalid": "Esta transferencia de inicio de sesión no coincide con la que iniciaste.",
|
||||
"auth_and_oauth.passkey_bridge_nonce_invalid": "No se pudo confirmar esta solicitud de llave de acceso. Inténtalo de nuevo.",
|
||||
"auth_and_oauth.registration_closed": "El registro está cerrado en esta instancia.",
|
||||
"auth_and_oauth.registration_credentials_required": "Esta instancia revisa las cuentas nuevas. Regístrate desde la página de registro con una contraseña para poder iniciar sesión cuando se apruebe.",
|
||||
"auth_and_oauth.registration_pending_approval": "Este registro está esperando la aprobación del administrador.",
|
||||
"auth_and_oauth.registration_rejected": "Esta solicitud de registro fue rechazada.",
|
||||
"auth_and_oauth.registration_url_invalid": "Este enlace de registro es inválido o ha expirado.",
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user