Compare commits

...
42 changed files with 1119 additions and 626 deletions

No files matched your search

Generated
+4 -4
View File
@@ -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",
@@ -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'}]);
});
});
@@ -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,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
@@ -769,7 +769,7 @@ export async function fetchDesktopHandoffInfo({
return response.body;
}
export interface CompleteLoginOptions {
interface CompleteLoginOptions {
redirectPath?: string | null;
}
@@ -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);
}
@@ -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>
@@ -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],
@@ -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;
}
@@ -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);
}
@@ -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';
@@ -141,7 +142,7 @@ const ResourceAuthContent = observer(function ResourceAuthContent<T>({
showTitle={false}
title={null}
onBackActionChange={null}
completeLoginRedirectPath={null}
completeLoginRedirectPath={Routes.ME}
forceCredentials={false}
startWithAddAccount={false}
runtimeTarget={runtimeTarget}
@@ -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 {
@@ -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}
@@ -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>
);
@@ -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>
@@ -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 {
@@ -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>
);
}
@@ -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') {
@@ -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);
}
@@ -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
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",