mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62324df039 | ||
|
|
9f78b3d9a6 | ||
|
|
362a89f2b2 | ||
|
|
ce41960fcd | ||
|
|
2083eaddd6 | ||
|
|
d398ebc44b | ||
|
|
59887ad404 | ||
|
|
5aa283e8e0 | ||
|
|
d9ed256a4b | ||
|
|
a297f89b83 | ||
|
|
4a16921242 | ||
|
|
a6f83c4fb1 | ||
|
|
7b5c82c6cf | ||
|
|
30a61ce90f | ||
|
|
22bc2cab74 | ||
|
|
53df9a0d6d | ||
|
|
f9b7ec4d0b | ||
|
|
569abf57b7 | ||
|
|
ae8e32d809 | ||
|
|
a81b1abec7 | ||
|
|
8836565c32 | ||
|
|
a1b595d52f | ||
|
|
abb71ed558 | ||
|
|
c006d413ac | ||
|
|
fa11acae15 | ||
|
|
300f467ad0 | ||
|
|
698469fa96 | ||
|
|
591e9fe2ba | ||
|
|
d148b4e5b7 | ||
|
|
324f333bb5 | ||
|
|
9b0b703c9d | ||
|
|
5660972c71 | ||
|
|
b4a5eb77a8 | ||
|
|
4bbfee4cec |
@@ -25,7 +25,7 @@ Closes #456
|
||||
|
||||
You must understand every line you submit and be able to explain why the change is correct.
|
||||
|
||||
The [LLM usage policy](LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
|
||||
The [LLM usage policy](https://github.com/fluxerapp/fluxer/blob/main/.github/LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
|
||||
|
||||
Each contribution must contain one coherent change. Do not include unrelated fixes, refactoring or formatting changes.
|
||||
|
||||
@@ -80,15 +80,15 @@ Complete every section of the pull request template. Clearly describe:
|
||||
|
||||
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
|
||||
|
||||
Report security vulnerabilities privately through the channels specified in the [security policy](SECURITY.md). Do not report vulnerabilities in public issues or discussions.
|
||||
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
|
||||
|
||||
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
|
||||
|
||||
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
|
||||
|
||||
All repository activity is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
|
||||
## Private marketing project
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ External contributions do not grant voting rights, commit access, employment or
|
||||
|
||||
## Licence and contributor rights
|
||||
|
||||
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). The licence permits its use, modification and redistribution subject to its terms.
|
||||
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). The licence permits its use, modification and redistribution subject to its terms.
|
||||
|
||||
Fluxer Platform AB does not require contributors to sign a contributor licence agreement or assign their copyright. Contributors retain the copyright in their work.
|
||||
|
||||
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
|
||||
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](https://github.com/fluxerapp/fluxer/blob/main/.github/CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
|
||||
|
||||
## Name and marks
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ Deliberately submitting a fabricated security report MAY result in an immediate
|
||||
|
||||
Maintainers are not required to investigate possible LLM use proactively. Writing style alone is not evidence of a violation.
|
||||
|
||||
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
## 11. Normative References
|
||||
|
||||
|
||||
@@ -434,41 +434,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_instance_config_hides_self_host_setup_controls() {
|
||||
let app = setup().await;
|
||||
let body = get(&app, "/instance-config", &[]).await;
|
||||
|
||||
assert_full_layout(&body);
|
||||
assert!(body.contains("Registration Controls"), "{body}");
|
||||
assert!(body.contains("Runtime Integrations"), "{body}");
|
||||
assert!(body.contains("Gateway Rollout Configuration"), "{body}");
|
||||
assert!(!body.contains("Public App Identity"), "{body}");
|
||||
assert!(!body.contains("Setup complete"), "{body}");
|
||||
assert!(!body.contains("Community & Policy"), "{body}");
|
||||
assert!(!body.contains("Single community"), "{body}");
|
||||
assert!(!body.contains("Direct messages & friends"), "{body}");
|
||||
assert!(!body.contains("Premium model"), "{body}");
|
||||
assert!(!body.contains("Optional services"), "{body}");
|
||||
assert!(!body.contains("Registration Fields"), "{body}");
|
||||
assert!(
|
||||
!body.contains("Collect date of birth during registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_public"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_policy"),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
|
||||
let app = setup().await;
|
||||
|
||||
@@ -41,10 +41,11 @@ import * as AuthUtility from '../../auth/AuthUtility';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '../../BrandedTypes';
|
||||
import type {UserRow} from '../../database/types/UserTypes';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
@@ -749,7 +750,7 @@ export class AdminUserSecurityService {
|
||||
approximateLastUsedAt: Date;
|
||||
clientIp: string;
|
||||
clientUserAgent: string | null;
|
||||
clientIsDesktop: boolean | null;
|
||||
clientOs: string | null;
|
||||
deletedAt: Date | null;
|
||||
}> = [
|
||||
...activeSessions.map((s) => ({
|
||||
@@ -758,7 +759,7 @@ export class AdminUserSecurityService {
|
||||
approximateLastUsedAt: s.approximateLastUsedAt,
|
||||
clientIp: s.clientIp,
|
||||
clientUserAgent: s.clientUserAgent,
|
||||
clientIsDesktop: s.clientIsDesktop,
|
||||
clientOs: s.clientOs ?? null,
|
||||
deletedAt: null as Date | null,
|
||||
})),
|
||||
...tombstones.map((t) => ({
|
||||
@@ -767,7 +768,7 @@ export class AdminUserSecurityService {
|
||||
approximateLastUsedAt: t.approximateLastUsedAt,
|
||||
clientIp: t.clientIp,
|
||||
clientUserAgent: t.clientUserAgent,
|
||||
clientIsDesktop: t.clientIsDesktop,
|
||||
clientOs: t.clientOs ?? null,
|
||||
deletedAt: t.deletedAt,
|
||||
})),
|
||||
];
|
||||
@@ -776,6 +777,8 @@ export class AdminUserSecurityService {
|
||||
if (a.deletedAt !== null && b.deletedAt === null) return 1;
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
});
|
||||
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
const productName = branding.product_name;
|
||||
const canViewIp = acls.has(AdminACLs.USER_VIEW_IP) || acls.has(AdminACLs.WILDCARD);
|
||||
if (!canViewIp) {
|
||||
await auditService.createAuditLog({
|
||||
@@ -788,9 +791,10 @@ export class AdminUserSecurityService {
|
||||
});
|
||||
return {
|
||||
sessions: entries.map((entry) => {
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: entry.clientUserAgent,
|
||||
isDesktopClient: entry.clientIsDesktop,
|
||||
reportedOs: entry.clientOs,
|
||||
productName,
|
||||
});
|
||||
return {
|
||||
session_id_hash: entry.sessionIdHash.toString('base64url'),
|
||||
@@ -798,8 +802,8 @@ export class AdminUserSecurityService {
|
||||
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
|
||||
client_ip: '[redacted]',
|
||||
client_ip_reverse: null,
|
||||
client_os: clientOs,
|
||||
client_platform: clientPlatform,
|
||||
client_os: clientInfo.os,
|
||||
client_platform: clientInfo.platform,
|
||||
client_location: null,
|
||||
deleted_at: entry.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
@@ -836,9 +840,10 @@ export class AdminUserSecurityService {
|
||||
const clientLocation = locationResult.status === 'fulfilled' ? locationResult.value : null;
|
||||
const reverseDnsResult = reverseDnsResults[index];
|
||||
const clientIpReverse = reverseDnsResult?.status === 'fulfilled' ? reverseDnsResult.value : null;
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: entry.clientUserAgent,
|
||||
isDesktopClient: entry.clientIsDesktop,
|
||||
reportedOs: entry.clientOs,
|
||||
productName,
|
||||
});
|
||||
return {
|
||||
session_id_hash: entry.sessionIdHash.toString('base64url'),
|
||||
@@ -846,8 +851,8 @@ export class AdminUserSecurityService {
|
||||
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
|
||||
client_ip: entry.clientIp,
|
||||
client_ip_reverse: clientIpReverse,
|
||||
client_os: clientOs,
|
||||
client_platform: clientPlatform,
|
||||
client_os: clientInfo.os,
|
||||
client_platform: clientInfo.platform,
|
||||
client_location: clientLocation,
|
||||
deleted_at: entry.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
|
||||
@@ -551,18 +551,7 @@ export function AuthController(app: HonoApp) {
|
||||
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const clientIp = requireClientIp(ctx.req.raw, {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
const clientPlatform = ctx.req.header('x-fluxer-platform')?.trim().toLowerCase() ?? undefined;
|
||||
return ctx.json(
|
||||
await ctx.get('authRequestService').initiateHandoff({
|
||||
userAgent: ctx.req.header('User-Agent'),
|
||||
clientIp,
|
||||
clientPlatform,
|
||||
}),
|
||||
);
|
||||
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -611,7 +600,6 @@ export function AuthController(app: HonoApp) {
|
||||
});
|
||||
await ctx.get('authRequestService').completeHandoff({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
clientIp,
|
||||
authToken: ctx.get('authToken') ?? undefined,
|
||||
});
|
||||
|
||||
@@ -100,7 +100,10 @@ export async function revertEmailChange(
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser),
|
||||
});
|
||||
const [authToken] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
|
||||
const [authToken] = await AuthSession.createAuthSession(ctx, {
|
||||
user: updatedUser,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
await contactChangeLog.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
|
||||
@@ -105,9 +105,7 @@ export interface IpAuthorizationTicketCache {
|
||||
userId: string;
|
||||
email: string;
|
||||
username: string;
|
||||
clientIp: string;
|
||||
userAgent: string;
|
||||
platform: string | null;
|
||||
origin: AuthSession.SessionOrigin;
|
||||
authToken: string;
|
||||
clientLocation: string;
|
||||
inviteCode?: string | null;
|
||||
@@ -115,8 +113,8 @@ export interface IpAuthorizationTicketCache {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function getTicketCacheKey(ticket: string): string {
|
||||
return `ip-auth-ticket:${ticket}`;
|
||||
export function getTicketCacheKey(ticket: string): string {
|
||||
return `ip-auth-ticket-v2:${ticket}`;
|
||||
}
|
||||
|
||||
function getTokenCacheKey(token: string): string {
|
||||
@@ -148,7 +146,7 @@ export async function resendIpAuthorization(
|
||||
payload.email,
|
||||
payload.username,
|
||||
payload.authToken,
|
||||
payload.clientIp,
|
||||
payload.origin.ip,
|
||||
payload.clientLocation,
|
||||
null,
|
||||
);
|
||||
@@ -172,7 +170,7 @@ export async function completeIpAuthorization(
|
||||
user_id: string;
|
||||
ticket: string;
|
||||
}> {
|
||||
const {users, cache, config} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const tokenMapping = await cache.get<{
|
||||
ticket: string;
|
||||
}>(getTokenCacheKey(token));
|
||||
@@ -193,19 +191,8 @@ export async function completeIpAuthorization(
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
await users.createAuthorizedIp(user.id, payload.clientIp);
|
||||
const headers: Record<string, string> = {
|
||||
[config.proxy.client_ip_header]: payload.clientIp,
|
||||
'user-agent': payload.userAgent,
|
||||
};
|
||||
if (payload.platform) {
|
||||
headers['x-fluxer-platform'] = payload.platform;
|
||||
}
|
||||
const syntheticRequest = new Request('https://api.fluxer.app/auth/ip-authorization', {
|
||||
headers,
|
||||
method: 'POST',
|
||||
});
|
||||
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, request: syntheticRequest});
|
||||
await users.createAuthorizedIp(user.id, payload.origin.ip);
|
||||
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, origin: payload.origin});
|
||||
await cache.delete(cacheKey);
|
||||
await cache.delete(getTokenCacheKey(token));
|
||||
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
|
||||
@@ -313,15 +300,11 @@ export async function login(
|
||||
const authToken = createIpAuthorizationToken(await AuthUtility.generateSecureToken(ctx));
|
||||
const geoipResult = await lookupGeoip(clientIp);
|
||||
const clientLocation = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const platform = request.headers.get('x-fluxer-platform');
|
||||
const cachePayload: IpAuthorizationTicketCache = {
|
||||
userId: currentUser.id.toString(),
|
||||
email: currentUser.email!,
|
||||
username: currentUser.username,
|
||||
clientIp,
|
||||
userAgent,
|
||||
platform: platform ?? null,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
authToken,
|
||||
clientLocation,
|
||||
inviteCode: data.invite_code ?? null,
|
||||
@@ -329,7 +312,7 @@ export async function login(
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const ttlSeconds = seconds('15 minutes');
|
||||
await cache.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
|
||||
await cache.set<IpAuthorizationTicketCache>(getTicketCacheKey(ticket), cachePayload, ttlSeconds);
|
||||
await cache.set<{
|
||||
ticket: string;
|
||||
}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
|
||||
@@ -364,7 +347,10 @@ export async function login(
|
||||
Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login');
|
||||
}
|
||||
}
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user: currentUser, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user: currentUser,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {
|
||||
user_id: currentUser.id.toString(),
|
||||
token,
|
||||
@@ -418,7 +404,10 @@ export async function loginMfaTotp(
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await cache.delete(attemptsKey);
|
||||
await cache.delete(userAttemptsKey);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
@@ -438,7 +427,10 @@ export async function loginMfaWebAuthn(
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSche
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {AuthSession} from '../models/AuthSession';
|
||||
import {getLocationLabelFromIp} from '../utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
|
||||
|
||||
const DEV_FALLBACK_AUTH_SESSION_LOCATION = 'Stockholm, Stockholm County, Sweden';
|
||||
|
||||
@@ -40,30 +41,24 @@ export async function mapAuthSessionsToResponse({
|
||||
const locationResults = await Promise.allSettled(
|
||||
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
|
||||
);
|
||||
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
return sortedSessions.map((authSession, index): AuthSessionResponse => {
|
||||
const locationResult = locationResults[index];
|
||||
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
|
||||
let clientOs: string;
|
||||
let clientPlatform: string;
|
||||
if (authSession.clientUserAgent) {
|
||||
const parsed = resolveSessionClientInfo({
|
||||
userAgent: authSession.clientUserAgent,
|
||||
isDesktopClient: authSession.clientIsDesktop,
|
||||
});
|
||||
clientOs = parsed.clientOs;
|
||||
clientPlatform = parsed.clientPlatform;
|
||||
} else {
|
||||
clientOs = authSession.clientOs || 'Unknown';
|
||||
clientPlatform = authSession.clientPlatform || 'Unknown';
|
||||
}
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: authSession.clientUserAgent,
|
||||
reportedOs: authSession.clientOs ?? null,
|
||||
productName: branding.product_name,
|
||||
});
|
||||
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
|
||||
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
|
||||
return {
|
||||
id_hash: idHash,
|
||||
client_info: {
|
||||
platform: clientPlatform,
|
||||
os: clientOs,
|
||||
browser: undefined,
|
||||
platform: clientInfo.platform,
|
||||
os: clientInfo.os,
|
||||
browser: clientInfo.browser,
|
||||
device: clientInfo.device,
|
||||
location: clientLocation
|
||||
? {
|
||||
city: clientLocation.split(',').at(0)?.trim() || null,
|
||||
|
||||
@@ -275,7 +275,10 @@ export async function resetPassword(
|
||||
if (hasMfa) {
|
||||
return await createMfaTicketResponse(ctx, updatedUser);
|
||||
}
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user: updatedUser,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: updatedUser.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
@@ -444,7 +444,10 @@ export async function register(
|
||||
);
|
||||
}
|
||||
await singleCommunityService.joinStockCommunity(userId, requestCache);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
if (grantBootstrapAdmin) {
|
||||
await instanceConfigRepository.markAdminBootstrapped();
|
||||
}
|
||||
|
||||
@@ -33,11 +33,12 @@ import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResp
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
import {createUserID, type UserID} from '../BrandedTypes';
|
||||
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {User} from '../models/User';
|
||||
import {mapUserToPartialResponse} from '../user/UserMappers';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
|
||||
import {generateUsernameSuggestions} from '../utils/UsernameSuggestionUtils';
|
||||
import * as AuthEmail from './AuthEmail';
|
||||
import * as AuthEmailRevert from './AuthEmailRevert';
|
||||
@@ -89,7 +90,6 @@ interface AuthLogoutRequest {
|
||||
|
||||
interface AuthHandoffCompleteRequest {
|
||||
data: HandoffCompleteRequest;
|
||||
request: Request;
|
||||
clientIp: string;
|
||||
authToken?: string;
|
||||
}
|
||||
@@ -122,9 +122,7 @@ interface AuthLogoutAuthSessionsRequest {
|
||||
}
|
||||
|
||||
interface AuthHandoffInitiateRequest {
|
||||
userAgent?: string;
|
||||
clientIp: string;
|
||||
clientPlatform?: string;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthHandoffInfoRequest {
|
||||
@@ -263,7 +261,7 @@ export class AuthRequestService {
|
||||
user: await this.getUserPartial(parsed.user_id),
|
||||
};
|
||||
}
|
||||
const ticketPayload = await cache.get(`ip-auth-ticket:${ticket}`);
|
||||
const ticketPayload = await cache.get(AuthLogin.getTicketCacheKey(ticket));
|
||||
if (!ticketPayload) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET);
|
||||
}
|
||||
@@ -276,7 +274,10 @@ export class AuthRequestService {
|
||||
|
||||
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
|
||||
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
@@ -298,12 +299,9 @@ export class AuthRequestService {
|
||||
return {suggestions: generateUsernameSuggestions(globalName)};
|
||||
}
|
||||
|
||||
async initiateHandoff({
|
||||
userAgent,
|
||||
clientIp,
|
||||
clientPlatform,
|
||||
}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const result = await this.desktopHandoffService.initiateHandoff({userAgent, clientIp, clientPlatform});
|
||||
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
|
||||
const result = await this.desktopHandoffService.initiateHandoff({origin});
|
||||
return {
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
@@ -312,19 +310,22 @@ export class AuthRequestService {
|
||||
|
||||
async getHandoffInfo({code, clientIp}: AuthHandoffInfoRequest): Promise<HandoffInfoResponse> {
|
||||
const info = await this.desktopHandoffService.getHandoffInfo(code, clientIp);
|
||||
if (info.status === 'expired' || !info.clientIp) {
|
||||
if (info.status === 'expired' || !info.origin) {
|
||||
return {status: info.status, client_info: null};
|
||||
}
|
||||
const geo = await lookupGeoip(info.clientIp);
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
userAgent: info.userAgent ?? null,
|
||||
isDesktopClient: info.clientPlatform === 'desktop',
|
||||
const geo = await lookupGeoip(info.origin.ip);
|
||||
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
const resolved = resolveSessionClientInfo({
|
||||
userAgent: info.origin.userAgent,
|
||||
reportedOs: info.origin.clientOs,
|
||||
productName: branding.product_name,
|
||||
});
|
||||
return {
|
||||
status: 'pending',
|
||||
client_info: {
|
||||
platform: clientPlatform,
|
||||
os: clientOs,
|
||||
platform: resolved.platform,
|
||||
os: resolved.os,
|
||||
device: resolved.device,
|
||||
location: {
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
@@ -334,18 +335,18 @@ export class AuthRequestService {
|
||||
};
|
||||
}
|
||||
|
||||
async completeHandoff({data, request, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
const sessionToken = data.token ?? authToken;
|
||||
if (!sessionToken) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
await this.desktopHandoffService.completeHandoff(
|
||||
data.code,
|
||||
() =>
|
||||
(origin) =>
|
||||
AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
token: sessionToken,
|
||||
expectedUserId: data.user_id,
|
||||
request,
|
||||
origin,
|
||||
}),
|
||||
clientIp,
|
||||
);
|
||||
|
||||
@@ -15,12 +15,19 @@ import {Logger} from '../Logger';
|
||||
import type {AuthSession} from '../models/AuthSession';
|
||||
import type {User} from '../models/User';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {isFluxerNativeUserAgent, parseReportedClientOs} from '../utils/SessionClientIdentity';
|
||||
import {mapAuthSessionsToResponse} from './AuthModel';
|
||||
import * as AuthUtility from './AuthUtility';
|
||||
|
||||
export interface SessionOrigin {
|
||||
ip: string;
|
||||
userAgent: string | null;
|
||||
clientOs: string | null;
|
||||
}
|
||||
|
||||
interface CreateAuthSessionParams {
|
||||
user: User;
|
||||
request: Request;
|
||||
origin: SessionOrigin;
|
||||
}
|
||||
|
||||
interface LogoutAuthSessionsParams {
|
||||
@@ -60,29 +67,35 @@ interface ReplaceCurrentAuthSessionResult {
|
||||
interface CreateAdditionalAuthSessionFromTokenParams {
|
||||
token: string;
|
||||
expectedUserId?: string;
|
||||
request: Request;
|
||||
origin: SessionOrigin;
|
||||
}
|
||||
|
||||
export function resolveSessionOrigin(ctx: ApiContext, request: Request): SessionOrigin {
|
||||
const {config} = ctx.services;
|
||||
const ip = requireClientIp(request, {
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
});
|
||||
const userAgent = request.headers.get('user-agent')?.trim() || null;
|
||||
const clientOs = isFluxerNativeUserAgent(userAgent)
|
||||
? parseReportedClientOs(request.headers.get('x-fluxer-client-properties'))
|
||||
: null;
|
||||
return {ip, userAgent, clientOs};
|
||||
}
|
||||
|
||||
export async function createAuthSession(
|
||||
ctx: ApiContext,
|
||||
{user, request}: CreateAuthSessionParams,
|
||||
{user, origin}: CreateAuthSessionParams,
|
||||
): Promise<[token: string, AuthSession]> {
|
||||
const {users, config} = ctx.services;
|
||||
const {users} = ctx.services;
|
||||
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
|
||||
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
|
||||
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
|
||||
const now = new Date();
|
||||
const token = await AuthUtility.generateAuthToken(ctx);
|
||||
const ip = requireClientIp(request, {
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
});
|
||||
const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
|
||||
const uaRaw = request.headers.get('user-agent') ?? '';
|
||||
const isDesktopClient = platformHeader === 'desktop';
|
||||
let clientCountry: string | null = null;
|
||||
try {
|
||||
const geoip = await lookupGeoip(ip);
|
||||
const geoip = await lookupGeoip(origin.ip);
|
||||
clientCountry = geoip.countryCode ? geoip.countryCode.toUpperCase() : null;
|
||||
} catch (error) {
|
||||
Logger.warn({userId: user.id.toString(), error}, 'GeoIP lookup failed at session creation');
|
||||
@@ -92,11 +105,9 @@ export async function createAuthSession(
|
||||
session_id_hash: Buffer.from(AuthUtility.getTokenIdHash(ctx, token)),
|
||||
created_at: now,
|
||||
approx_last_used_at: now,
|
||||
client_ip: ip,
|
||||
client_user_agent: uaRaw || null,
|
||||
client_is_desktop: isDesktopClient,
|
||||
client_os: null,
|
||||
client_platform: null,
|
||||
client_ip: origin.ip,
|
||||
client_user_agent: origin.userAgent,
|
||||
client_os: origin.clientOs,
|
||||
client_country: clientCountry,
|
||||
version: 1,
|
||||
});
|
||||
@@ -105,7 +116,7 @@ export async function createAuthSession(
|
||||
|
||||
export async function createAdditionalAuthSessionFromToken(
|
||||
ctx: ApiContext,
|
||||
{token, expectedUserId, request}: CreateAdditionalAuthSessionFromTokenParams,
|
||||
{token, expectedUserId, origin}: CreateAdditionalAuthSessionFromTokenParams,
|
||||
): Promise<{
|
||||
token: string;
|
||||
userId: string;
|
||||
@@ -122,7 +133,7 @@ export async function createAdditionalAuthSessionFromToken(
|
||||
if (expectedUserId && user.id.toString() !== expectedUserId) {
|
||||
throw new SessionTokenMismatchError();
|
||||
}
|
||||
const [newToken] = await createAuthSession(ctx, {user, request});
|
||||
const [newToken] = await createAuthSession(ctx, {user, origin});
|
||||
return {token: newToken, userId: user.id.toString()};
|
||||
}
|
||||
|
||||
@@ -205,7 +216,7 @@ export async function replaceCurrentAuthSession(
|
||||
(authSession) => !authSession.sessionIdHash.equals(currentAuthSession.sessionIdHash),
|
||||
);
|
||||
await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions);
|
||||
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, request});
|
||||
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)});
|
||||
const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash);
|
||||
await dispatchAuthSessionChange(ctx, {
|
||||
userId: user.id,
|
||||
|
||||
@@ -5,8 +5,9 @@ import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCo
|
||||
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import type {SessionOrigin} from '../AuthSession';
|
||||
|
||||
const HANDOFF_CODE_PREFIX = 'desktop-handoff:';
|
||||
const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:';
|
||||
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
|
||||
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 12;
|
||||
@@ -19,9 +20,7 @@ const MAX_INFO_LOOKUPS = 3;
|
||||
|
||||
interface HandoffData {
|
||||
createdAt: number;
|
||||
userAgent?: string;
|
||||
clientIp: string;
|
||||
clientPlatform?: string;
|
||||
origin: SessionOrigin;
|
||||
infoLookupCount: number;
|
||||
}
|
||||
|
||||
@@ -61,7 +60,7 @@ function assertValidHandoffCode(code: string): void {
|
||||
export class DesktopHandoffService {
|
||||
constructor(private readonly apiContext: ApiContext) {}
|
||||
|
||||
async initiateHandoff(args: {userAgent?: string; clientIp: string; clientPlatform?: string}): Promise<{
|
||||
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
}> {
|
||||
@@ -70,9 +69,7 @@ export class DesktopHandoffService {
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
const handoffData: HandoffData = {
|
||||
createdAt: Date.now(),
|
||||
userAgent: args.userAgent,
|
||||
clientIp: args.clientIp,
|
||||
clientPlatform: args.clientPlatform,
|
||||
origin: args.origin,
|
||||
infoLookupCount: 0,
|
||||
};
|
||||
const expirySeconds = seconds('5 minutes');
|
||||
@@ -83,7 +80,7 @@ export class DesktopHandoffService {
|
||||
|
||||
async completeHandoff(
|
||||
code: string,
|
||||
createTokenData: () => Promise<{token: string; userId: string}>,
|
||||
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
|
||||
approverIp: string,
|
||||
): Promise<void> {
|
||||
const {cache} = this.apiContext.services;
|
||||
@@ -107,7 +104,7 @@ export class DesktopHandoffService {
|
||||
if (remainingSeconds <= 0) {
|
||||
throw new HandoffCodeExpiredError();
|
||||
}
|
||||
const {token, userId} = await createTokenData();
|
||||
const {token, userId} = await createTokenData(handoffData.origin);
|
||||
const tokenData: HandoffTokenData = {
|
||||
token,
|
||||
userId,
|
||||
@@ -122,9 +119,7 @@ export class DesktopHandoffService {
|
||||
approverIp: string,
|
||||
): Promise<{
|
||||
status: 'pending' | 'expired';
|
||||
userAgent?: string;
|
||||
clientIp?: string;
|
||||
clientPlatform?: string;
|
||||
origin?: SessionOrigin;
|
||||
}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
@@ -149,12 +144,7 @@ export class DesktopHandoffService {
|
||||
{approvedAt: Date.now()},
|
||||
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
|
||||
);
|
||||
return {
|
||||
status: 'pending',
|
||||
userAgent: handoffData.userAgent,
|
||||
clientIp: handoffData.clientIp,
|
||||
clientPlatform: handoffData.clientPlatform,
|
||||
};
|
||||
return {status: 'pending', origin: handoffData.origin};
|
||||
}
|
||||
|
||||
async getHandoffStatus(
|
||||
|
||||
@@ -332,7 +332,10 @@ export class SsoService {
|
||||
});
|
||||
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
|
||||
const user = await this.resolveUserFromClaims(claims, config);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
return {token, user_id: user.id.toString(), redirect_to: statePayload.redirectTo ?? ''};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {maskIpForDisplay} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
|
||||
import {createAuthHarness, createTestAccount, fetchMe, loginAccount} from './AuthTestUtils';
|
||||
|
||||
interface HandoffInitiateResponse {
|
||||
@@ -17,6 +19,7 @@ interface HandoffInfoResponse {
|
||||
client_info?: {
|
||||
platform?: string | null;
|
||||
os?: string | null;
|
||||
device?: 'mobile' | 'desktop';
|
||||
location?: {
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
@@ -25,6 +28,16 @@ interface HandoffInfoResponse {
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface AuthSessionsResponseItem {
|
||||
masked_ip?: string | null;
|
||||
client_info?: {
|
||||
platform?: string | null;
|
||||
os?: string | null;
|
||||
browser?: string | null;
|
||||
device?: 'mobile' | 'desktop';
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface HandoffStatusResponse {
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
token?: string;
|
||||
@@ -53,6 +66,46 @@ describe('Auth desktop handoff flow', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('attributes the handed-off session to the initiating desktop, not the approving browser', async () => {
|
||||
const DESKTOP_IP = '203.0.113.77';
|
||||
const desktopUserAgent =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/1.4.0 Chrome/128.0.0.0 Electron/32.0.0 Safari/537.36';
|
||||
const browserUserAgent =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
|
||||
.post('/auth/handoff/initiate')
|
||||
.header('User-Agent', desktopUserAgent)
|
||||
.header('x-forwarded-for', DESKTOP_IP)
|
||||
.body(null)
|
||||
.execute();
|
||||
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
|
||||
.get(`/auth/handoff/${initResp.code}/info`)
|
||||
.header('User-Agent', browserUserAgent)
|
||||
.execute();
|
||||
expect(info.client_info?.platform).toBe('Fluxer macOS');
|
||||
expect(info.client_info?.device).toBe('desktop');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.header('User-Agent', browserUserAgent)
|
||||
.body({code: initResp.code, token: login.token, user_id: login.userId})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
|
||||
.get(`/auth/handoff/${initResp.code}/status`)
|
||||
.execute();
|
||||
const sessions = await createBuilder<Array<AuthSessionsResponseItem>>(harness, completed.token!)
|
||||
.get('/auth/sessions')
|
||||
.execute();
|
||||
const handedOff = sessions.filter((session) => session.client_info?.platform === 'Fluxer macOS');
|
||||
expect(handedOff).toHaveLength(1);
|
||||
expect(handedOff[0]?.client_info?.os).toBe('macOS');
|
||||
expect(handedOff[0]?.client_info?.browser).toBeNull();
|
||||
expect(handedOff[0]?.client_info?.device).toBe('desktop');
|
||||
expect(sessions.some((session) => session.client_info?.browser === 'Chrome')).toBe(false);
|
||||
expect(handedOff[0]?.masked_ip).toBe(maskIpForDisplay(DESKTOP_IP));
|
||||
});
|
||||
it('completes full handoff flow: initiate → info → complete → status', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
|
||||
@@ -93,19 +93,7 @@ export class MessageEditService {
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
}
|
||||
if (data.message_snapshots !== undefined) {
|
||||
const isAuthor = message.authorId === userId;
|
||||
const canManage = isAuthor ? true : await hasPermission(Permissions.MANAGE_MESSAGES);
|
||||
if (!isAuthor && !canManage) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const updatedMessage = await this.withMessageLock(channelId, messageId, () =>
|
||||
this.deps.persistenceService.updateSnapshotAttachments({
|
||||
message,
|
||||
snapshotEdits: data.message_snapshots ?? [],
|
||||
}),
|
||||
);
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
return updatedMessage;
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const user = await this.deps.userRepository.findUnique(userId);
|
||||
this.deps.validationService.validateMessageEditable(message);
|
||||
|
||||
@@ -18,9 +18,7 @@ export interface AuthSessionRow {
|
||||
approx_last_used_at: Date;
|
||||
client_ip: string;
|
||||
client_user_agent: Nullish<string>;
|
||||
client_is_desktop: Nullish<boolean>;
|
||||
client_os?: Nullish<string>;
|
||||
client_platform?: Nullish<string>;
|
||||
client_os: Nullish<string>;
|
||||
client_country: Nullish<string>;
|
||||
version: number;
|
||||
}
|
||||
@@ -32,9 +30,7 @@ export interface AuthSessionTombstoneRow {
|
||||
approx_last_used_at: Date;
|
||||
client_ip: string;
|
||||
client_user_agent: Nullish<string>;
|
||||
client_is_desktop: Nullish<boolean>;
|
||||
client_os: Nullish<string>;
|
||||
client_platform: Nullish<string>;
|
||||
client_country: Nullish<string>;
|
||||
deleted_at: Date;
|
||||
version: number;
|
||||
@@ -140,9 +136,7 @@ export const AUTH_SESSION_COLUMNS = [
|
||||
'approx_last_used_at',
|
||||
'client_ip',
|
||||
'client_user_agent',
|
||||
'client_is_desktop',
|
||||
'client_os',
|
||||
'client_platform',
|
||||
'client_country',
|
||||
'version',
|
||||
] as const satisfies ReadonlyArray<keyof AuthSessionRow>;
|
||||
@@ -153,9 +147,7 @@ export const AUTH_SESSION_TOMBSTONE_COLUMNS = [
|
||||
'approx_last_used_at',
|
||||
'client_ip',
|
||||
'client_user_agent',
|
||||
'client_is_desktop',
|
||||
'client_os',
|
||||
'client_platform',
|
||||
'client_country',
|
||||
'deleted_at',
|
||||
'version',
|
||||
|
||||
@@ -10,9 +10,7 @@ export class AuthSession {
|
||||
readonly approximateLastUsedAt: Date;
|
||||
readonly clientIp: string;
|
||||
readonly clientUserAgent: string | null;
|
||||
readonly clientIsDesktop: boolean | null;
|
||||
readonly clientOs?: string | null;
|
||||
readonly clientPlatform?: string | null;
|
||||
readonly clientOs: string | null;
|
||||
readonly clientCountry: string | null;
|
||||
readonly version: number;
|
||||
|
||||
@@ -23,9 +21,7 @@ export class AuthSession {
|
||||
this.approximateLastUsedAt = row.approx_last_used_at;
|
||||
this.clientIp = row.client_ip;
|
||||
this.clientUserAgent = row.client_user_agent ?? null;
|
||||
this.clientIsDesktop = row.client_is_desktop ?? null;
|
||||
this.clientOs = row.client_os ?? null;
|
||||
this.clientPlatform = row.client_platform ?? null;
|
||||
this.clientCountry = row.client_country ?? null;
|
||||
this.version = row.version;
|
||||
}
|
||||
@@ -38,9 +34,7 @@ export class AuthSession {
|
||||
approx_last_used_at: this.approximateLastUsedAt,
|
||||
client_ip: this.clientIp,
|
||||
client_user_agent: this.clientUserAgent,
|
||||
client_is_desktop: this.clientIsDesktop,
|
||||
client_os: this.clientOs,
|
||||
client_platform: this.clientPlatform,
|
||||
client_country: this.clientCountry,
|
||||
version: this.version,
|
||||
};
|
||||
@@ -54,9 +48,7 @@ export class AuthSessionTombstone {
|
||||
readonly approximateLastUsedAt: Date;
|
||||
readonly clientIp: string;
|
||||
readonly clientUserAgent: string | null;
|
||||
readonly clientIsDesktop: boolean | null;
|
||||
readonly clientOs?: string | null;
|
||||
readonly clientPlatform?: string | null;
|
||||
readonly clientOs: string | null;
|
||||
readonly clientCountry: string | null;
|
||||
readonly deletedAt: Date;
|
||||
readonly version: number;
|
||||
@@ -68,9 +60,7 @@ export class AuthSessionTombstone {
|
||||
this.approximateLastUsedAt = row.approx_last_used_at;
|
||||
this.clientIp = row.client_ip;
|
||||
this.clientUserAgent = row.client_user_agent ?? null;
|
||||
this.clientIsDesktop = row.client_is_desktop ?? null;
|
||||
this.clientOs = row.client_os ?? null;
|
||||
this.clientPlatform = row.client_platform ?? null;
|
||||
this.clientCountry = row.client_country ?? null;
|
||||
this.deletedAt = row.deleted_at;
|
||||
this.version = row.version;
|
||||
|
||||
@@ -26040,11 +26040,18 @@
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "The operating system of the requesting device"
|
||||
},
|
||||
"device": {
|
||||
"enum": ["mobile", "desktop"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["mobile", "desktop"],
|
||||
"description": "Device class of the requesting device, decided by the server"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
|
||||
"description": "The approximate location of the requesting device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["device"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -26288,11 +26295,18 @@
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "The browser reported by the client"
|
||||
},
|
||||
"device": {
|
||||
"enum": ["mobile", "desktop"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["mobile", "desktop"],
|
||||
"description": "Device class of the session, decided by the server"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
|
||||
"description": "The geolocation data sent by the client"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["device"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -27388,9 +27402,9 @@
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 25,
|
||||
"maximum": 50,
|
||||
"format": "int32",
|
||||
"description": "Number of messages to return for this channel (1-25)"
|
||||
"description": "Number of messages to return for this channel (1-50)"
|
||||
},
|
||||
"before": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"after": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
@@ -33239,12 +33253,20 @@
|
||||
"payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refund_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refunded_amount_cents": {"type": "integer", "format": "int53"},
|
||||
"refunded_amount_cents": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success"
|
||||
},
|
||||
"invoice_amount_paid_cents": {"type": "integer", "format": "int53"},
|
||||
"currency": {"type": "string"},
|
||||
"subscription_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Subscription that was cancelled along with the refund, when applicable"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -33255,7 +33277,8 @@
|
||||
"refunded_amount_cents",
|
||||
"invoice_amount_paid_cents",
|
||||
"currency",
|
||||
"subscription_id"
|
||||
"subscription_id",
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"ReadStateAckResponse": {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
SelfServeRefundResponse,
|
||||
} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
|
||||
import type Stripe from 'stripe';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import {createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getBillingRepository} from '../../middleware/ServiceRegistry';
|
||||
@@ -206,6 +206,51 @@ export class StripeRefundService {
|
||||
};
|
||||
}
|
||||
|
||||
private async countPriorTerminalFailures(invoiceId: string): Promise<number> {
|
||||
const priorRefunds = await getBillingRepository().refunds.listByInvoice(invoiceId);
|
||||
return priorRefunds.filter((r) => r.status === 'failed' || r.status === 'canceled').length;
|
||||
}
|
||||
|
||||
private async finalizeIfSucceeded(refund: Stripe.Refund): Promise<void> {
|
||||
if (refund.status !== 'succeeded' || refund.metadata?.refund_kind !== 'self_serve') {
|
||||
return;
|
||||
}
|
||||
const userIdRaw = refund.metadata.user_id;
|
||||
if (!userIdRaw) {
|
||||
return;
|
||||
}
|
||||
let userId: UserID;
|
||||
try {
|
||||
userId = createUserID(BigInt(userIdRaw));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user || user.firstRefundAt) {
|
||||
return;
|
||||
}
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
if (subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), subscriptionId},
|
||||
'Self-serve refund confirmed but subscription cancellation failed; will reconcile via webhook',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
Logger.info(
|
||||
{userId: user.id.toString(), refundId: refund.id, subscriptionId: subscriptionId || null},
|
||||
'Self-serve refund confirmed succeeded; cooldown and cancellation finalized',
|
||||
);
|
||||
}
|
||||
|
||||
async handleRefundWebhookEvent(refund: Stripe.Refund): Promise<void> {
|
||||
await this.finalizeIfSucceeded(refund);
|
||||
}
|
||||
|
||||
async refundLatestPurchase(userId: UserID): Promise<SelfServeRefundResponse> {
|
||||
const stripe = this.ensureStripe();
|
||||
const user = await this.getRequiredUser(userId);
|
||||
@@ -220,6 +265,14 @@ export class StripeRefundService {
|
||||
if (this.cooldownExpiresAt(user)) {
|
||||
throw new StripeRefundCooldownActiveError();
|
||||
}
|
||||
const priorFailures = await this.countPriorTerminalFailures(target.invoiceId);
|
||||
const idempotencyKey = [
|
||||
'self-serve-refund',
|
||||
user.id.toString(),
|
||||
target.invoiceId,
|
||||
target.paymentIntentId ?? target.chargeId,
|
||||
...(priorFailures > 0 ? [`retry-${priorFailures}`] : []),
|
||||
].join(':');
|
||||
let refund: Stripe.Response<Stripe.Refund>;
|
||||
try {
|
||||
refund = await stripe.refunds.create(
|
||||
@@ -232,11 +285,10 @@ export class StripeRefundService {
|
||||
invoice_id: target.invoiceId,
|
||||
refund_kind: 'self_serve',
|
||||
refund_window_days: String(SELF_SERVE_REFUND_WINDOW_DAYS),
|
||||
...(target.subscriptionId ? {subscription_id: target.subscriptionId} : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
idempotencyKey: `self-serve-refund:${user.id}:${target.invoiceId}:${target.paymentIntentId ?? target.chargeId}`,
|
||||
},
|
||||
{idempotencyKey},
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
@@ -254,36 +306,29 @@ export class StripeRefundService {
|
||||
} catch (mirrorErr) {
|
||||
Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal');
|
||||
}
|
||||
if (target.subscriptionId) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), subscriptionId: target.subscriptionId},
|
||||
'Self-serve refund issued but subscription cancellation failed; will reconcile via webhook',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.userRepository.patchUpsert(user.id, {first_refund_at: new Date()}, user.toRow());
|
||||
await this.finalizeIfSucceeded(refund);
|
||||
const succeeded = refund.status === 'succeeded';
|
||||
Logger.info(
|
||||
{
|
||||
userId: user.id.toString(),
|
||||
invoiceId: target.invoiceId,
|
||||
refundId: refund.id,
|
||||
status: refund.status,
|
||||
amountCents: refund.amount,
|
||||
subscriptionId: target.subscriptionId,
|
||||
},
|
||||
'Self-serve refund issued',
|
||||
succeeded ? 'Self-serve refund issued' : 'Self-serve refund created; awaiting confirmation from provider',
|
||||
);
|
||||
return {
|
||||
invoice_id: target.invoiceId,
|
||||
payment_intent_id: target.paymentIntentId,
|
||||
charge_id: target.chargeId,
|
||||
refund_id: refund.id,
|
||||
refunded_amount_cents: refund.amount,
|
||||
refunded_amount_cents: succeeded ? refund.amount : 0,
|
||||
invoice_amount_paid_cents: target.amountPaidCents,
|
||||
currency: target.currency,
|
||||
subscription_id: target.subscriptionId,
|
||||
subscription_id: succeeded ? target.subscriptionId : null,
|
||||
status: refund.status ?? 'pending',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {StripeGiftReversalHandler} from './StripeGiftReversalHandler';
|
||||
import type {StripeGiftService} from './StripeGiftService';
|
||||
import {StripePaymentFraudService} from './StripePaymentFraudService';
|
||||
import type {StripePremiumService} from './StripePremiumService';
|
||||
import type {StripeRefundService} from './StripeRefundService';
|
||||
import {StripeSubscriptionReconciler} from './StripeSubscriptionReconciler';
|
||||
import {StripeSubscriptionWebhookHandler} from './StripeSubscriptionWebhookHandler';
|
||||
|
||||
@@ -61,6 +62,7 @@ export class StripeWebhookService {
|
||||
adminRepository: AdminRepository,
|
||||
snowflakeService: ISnowflakeService,
|
||||
private billingRepository: BillingRepository,
|
||||
private refundService: StripeRefundService,
|
||||
) {
|
||||
this.checkoutHandler = new StripeCheckoutWebhookHandler(
|
||||
stripe,
|
||||
@@ -336,6 +338,7 @@ export class StripeWebhookService {
|
||||
livemode: event.livemode,
|
||||
}),
|
||||
);
|
||||
await this.refundService.handleRefundWebhookEvent(r);
|
||||
break;
|
||||
}
|
||||
case 'invoice.created':
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, type TestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createStripeApiHandlers} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
@@ -105,18 +106,30 @@ function invoiceListHandler(invoices: ReadonlyArray<MockStripeInvoice>) {
|
||||
});
|
||||
}
|
||||
|
||||
function refundCreateHandler() {
|
||||
function refundCreateHandler(opts?: {
|
||||
status?: 'succeeded' | 'pending' | 'failed';
|
||||
failureReason?: string;
|
||||
onRequest?: (idempotencyKey: string | null) => void;
|
||||
}) {
|
||||
return http.post(`${STRIPE_API_BASE}/v1/refunds`, async ({request}) => {
|
||||
opts?.onRequest?.(request.headers.get('idempotency-key'));
|
||||
const formData = await request.formData();
|
||||
const params = Object.fromEntries(formData.entries());
|
||||
const metadata: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const match = key.match(/^metadata\[(.+)\]$/);
|
||||
if (match) metadata[match[1]] = value as string;
|
||||
}
|
||||
return HttpResponse.json({
|
||||
id: 're_test_self_serve',
|
||||
object: 'refund',
|
||||
amount: Number.parseInt((params.amount as string) ?? '0', 10),
|
||||
currency: 'usd',
|
||||
status: 'succeeded',
|
||||
status: opts?.status ?? 'succeeded',
|
||||
failure_reason: opts?.failureReason ?? null,
|
||||
payment_intent: params.payment_intent ?? null,
|
||||
charge: params.charge ?? null,
|
||||
metadata,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -274,5 +287,62 @@ describe('StripeRefundService self-serve refund', () => {
|
||||
.expect(400, APIErrorCodes.STRIPE_NO_PURCHASE_HISTORY)
|
||||
.execute();
|
||||
});
|
||||
test('does not finalize cooldown or cancel the subscription while the refund is still pending at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(
|
||||
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
|
||||
refundCreateHandler({status: 'pending'}),
|
||||
);
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.status).toBe('pending');
|
||||
expect(response.refunded_amount_cents).toBe(0);
|
||||
expect(response.subscription_id).toBeNull();
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('does not finalize cooldown or cancel the subscription when the refund fails at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(
|
||||
invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]),
|
||||
refundCreateHandler({status: 'failed', failureReason: 'unknown'}),
|
||||
);
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {
|
||||
stripe_customer_id: MOCK_CUSTOMER_ID,
|
||||
stripe_subscription_id: MOCK_SUBSCRIPTION_ID,
|
||||
});
|
||||
const response = await createBuilder<SelfServeRefundResponse>(harness, account.token)
|
||||
.post('/premium/refund-latest')
|
||||
.execute();
|
||||
expect(response.status).toBe('failed');
|
||||
expect(response.refunded_amount_cents).toBe(0);
|
||||
expect(response.subscription_id).toBeNull();
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const updatedUser = await new UserRepository().findUnique(createUserID(BigInt(account.userId)));
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
test('retries with a fresh idempotency key once a prior attempt has failed at the provider', async () => {
|
||||
server.use(...createStripeApiHandlers().handlers);
|
||||
server.use(invoiceListHandler([buildInvoice({id: 'in_recent', paidAtSecondsAgo: SECONDS_PER_DAY})]));
|
||||
const account = await createTestAccount(harness);
|
||||
await setStripeIds(harness, account, {stripe_customer_id: MOCK_CUSTOMER_ID});
|
||||
const idempotencyKeys: Array<string | null> = [];
|
||||
server.use(refundCreateHandler({status: 'failed', onRequest: (key) => idempotencyKeys.push(key)}));
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
await createBuilder<SelfServeRefundResponse>(harness, account.token).post('/premium/refund-latest').execute();
|
||||
expect(idempotencyKeys).toHaveLength(2);
|
||||
expect(idempotencyKeys[0]).not.toBeNull();
|
||||
expect(idempotencyKeys[1]).not.toBeNull();
|
||||
expect(idempotencyKeys[1]).not.toBe(idempotencyKeys[0]);
|
||||
expect(idempotencyKeys[1]).toContain('retry-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {PremiumFlags, UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {HttpResponse, http} from 'msw';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createMockWebhookPayload, type StripeWebhookEventData} from '../../test/msw/handlers/StripeApiHandlers';
|
||||
import {server} from '../../test/msw/server';
|
||||
import {createBuilder} from '../../test/TestRequestBuilder';
|
||||
import {UserRepository} from '../../user/repositories/UserRepository';
|
||||
import {setupSyncStripeWebhookWorker} from './StripeWebhookTestUtils';
|
||||
@@ -341,4 +343,90 @@ describe('Stripe Webhook Refund', () => {
|
||||
expect(updatedRedeemer.premium_type).toBe(UserPremiumTypes.LIFETIME);
|
||||
});
|
||||
});
|
||||
describe('refund.updated', () => {
|
||||
test('finalizes self-serve cooldown and cancels the subscription once the refund is confirmed succeeded', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
const subscriptionId = 'sub_test_webhook_finalize';
|
||||
await userRepository.patchUpsert(
|
||||
userId,
|
||||
{stripe_subscription_id: subscriptionId},
|
||||
(await userRepository.findUnique(userId))!.toRow(),
|
||||
);
|
||||
server.use(
|
||||
http.delete('https://api.stripe.com/v1/subscriptions/:id', ({params}) =>
|
||||
HttpResponse.json({id: params.id, object: 'subscription', status: 'canceled'}),
|
||||
),
|
||||
);
|
||||
await sendWebhook({
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_finalize',
|
||||
status: 'succeeded',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_finalize',
|
||||
subscription_id: subscriptionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).not.toBeNull();
|
||||
});
|
||||
test('does not finalize cooldown while the refund is still pending', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
await sendWebhook({
|
||||
type: 'refund.updated',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_pending',
|
||||
status: 'pending',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_pending',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
});
|
||||
describe('refund.failed', () => {
|
||||
test('does not finalize cooldown when the refund ultimately fails', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const userRepository = new UserRepository();
|
||||
await sendWebhook({
|
||||
type: 'refund.failed',
|
||||
data: {
|
||||
object: {
|
||||
id: 'pyr_test_webhook_failed',
|
||||
status: 'failed',
|
||||
failure_reason: 'unknown',
|
||||
amount: 2499,
|
||||
currency: 'brl',
|
||||
metadata: {
|
||||
refund_kind: 'self_serve',
|
||||
user_id: account.userId.toString(),
|
||||
invoice_id: 'in_test_webhook_failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const updatedUser = await userRepository.findUnique(userId);
|
||||
expect(updatedUser!.firstRefundAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {Context} from 'hono';
|
||||
import {seconds} from 'itty-time';
|
||||
import {AttachmentDecayRepository} from '../attachment/AttachmentDecayRepository';
|
||||
import type {IpAuthorizationTicketCache} from '../auth/AuthLogin';
|
||||
import {getTicketCacheKey} from '../auth/AuthLogin';
|
||||
import {
|
||||
type ChannelID,
|
||||
createApplicationID,
|
||||
@@ -86,6 +87,7 @@ import {UserRepository} from '../user/repositories/UserRepository';
|
||||
import {processUserDeletion} from '../user/services/UserDeletionService';
|
||||
import {UserHarvestRepository} from '../user/UserHarvestRepository';
|
||||
import {getExpiryBucket} from '../utils/AttachmentDecay';
|
||||
import {parseReportedClientOs} from '../utils/SessionClientIdentity';
|
||||
import {ScheduledMessageExecutor} from '../worker/executors/ScheduledMessageExecutor';
|
||||
import {processExpiredAttachments} from '../worker/tasks/ExpireAttachments';
|
||||
import {processInactivityDeletionsCore} from '../worker/tasks/ProcessInactivityDeletions';
|
||||
@@ -627,7 +629,7 @@ export function TestHarnessController(app: HonoApp) {
|
||||
client_ip: clientIp,
|
||||
user_agent: userAgent,
|
||||
client_location: clientLocation,
|
||||
platform,
|
||||
client_properties: clientProperties,
|
||||
resend_used: resendUsed,
|
||||
invite_code: inviteCode,
|
||||
created_at: createdAtInput,
|
||||
@@ -649,9 +651,11 @@ export function TestHarnessController(app: HonoApp) {
|
||||
userId: String(userId),
|
||||
email: String(email),
|
||||
username: String(username),
|
||||
clientIp: String(clientIp),
|
||||
userAgent: String(userAgent),
|
||||
platform: platform ? String(platform) : null,
|
||||
origin: {
|
||||
ip: String(clientIp),
|
||||
userAgent: userAgent ? String(userAgent) : null,
|
||||
clientOs: parseReportedClientOs(clientProperties ? String(clientProperties) : null),
|
||||
},
|
||||
authToken: String(token),
|
||||
clientLocation: String(clientLocation),
|
||||
inviteCode: inviteCode ? String(inviteCode) : null,
|
||||
@@ -659,7 +663,7 @@ export function TestHarnessController(app: HonoApp) {
|
||||
createdAt,
|
||||
};
|
||||
const ttl = typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : seconds('15 minutes');
|
||||
await cacheService.set(`ip-auth-ticket:${ticket}`, payload, ttl);
|
||||
await cacheService.set(getTicketCacheKey(String(ticket)), payload, ttl);
|
||||
await cacheService.set(`ip-auth-token:${token}`, {ticket: String(ticket)}, ttl);
|
||||
return ctx.json(
|
||||
{
|
||||
@@ -721,7 +725,7 @@ export function TestHarnessController(app: HonoApp) {
|
||||
return ctx.json({error: 'ticket or token is required'}, 400);
|
||||
}
|
||||
if (ticket) {
|
||||
await cacheService.delete(`ip-auth-ticket:${ticket}`);
|
||||
await cacheService.delete(getTicketCacheKey(String(ticket)));
|
||||
await cacheService.delete(`ip-auth:${ticket}`);
|
||||
}
|
||||
if (token) {
|
||||
|
||||
@@ -211,9 +211,7 @@ function toTombstoneRow(row: AuthSessionRow, deletedAt: Date): AuthSessionTombst
|
||||
approx_last_used_at: row.approx_last_used_at,
|
||||
client_ip: row.client_ip,
|
||||
client_user_agent: row.client_user_agent,
|
||||
client_is_desktop: row.client_is_desktop,
|
||||
client_os: row.client_os ?? null,
|
||||
client_platform: row.client_platform ?? null,
|
||||
client_country: row.client_country ?? null,
|
||||
deleted_at: deletedAt,
|
||||
version: row.version,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Bowser from 'bowser';
|
||||
import {Logger} from '../Logger';
|
||||
import {parseJsonRecord} from './JsonBoundaryUtils';
|
||||
|
||||
export type SessionDeviceClass = 'mobile' | 'desktop';
|
||||
|
||||
interface SessionClientSignals {
|
||||
userAgent: string | null;
|
||||
reportedOs: string | null;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
export interface SessionClientInfo {
|
||||
platform: string | null;
|
||||
os: string | null;
|
||||
browser: string | null;
|
||||
device: SessionDeviceClass;
|
||||
}
|
||||
|
||||
type OsToken = 'android' | 'ios' | 'macos' | 'windows' | 'linux';
|
||||
|
||||
const OS_DISPLAY: Record<OsToken, string> = {
|
||||
android: 'Android',
|
||||
ios: 'iOS',
|
||||
macos: 'macOS',
|
||||
windows: 'Windows',
|
||||
linux: 'Linux',
|
||||
};
|
||||
|
||||
const BOWSER_OS_TO_TOKEN: Record<string, OsToken> = {
|
||||
macOS: 'macos',
|
||||
Windows: 'windows',
|
||||
Linux: 'linux',
|
||||
iOS: 'ios',
|
||||
Android: 'android',
|
||||
};
|
||||
|
||||
const NATIVE_UA_REGEX = /^Fluxer (Android|iOS|Linux|Desktop|Client)(?=[/ ]|$)/;
|
||||
const ELECTRON_UA_REGEX = /\bElectron\/\d+(?:\.\d+)*/;
|
||||
const PRODUCT_TOKEN_OS: Record<string, OsToken | null> = {
|
||||
Android: 'android',
|
||||
iOS: 'ios',
|
||||
Linux: 'linux',
|
||||
Desktop: null,
|
||||
Client: null,
|
||||
};
|
||||
const CLIENT_PROPERTIES_HEADER_MAX_LENGTH = 4096;
|
||||
const MOBILE_PLATFORM_TYPES = new Set(['mobile', 'tablet']);
|
||||
|
||||
function narrowOsToken(value: string | null): OsToken | null {
|
||||
if (value === null) return null;
|
||||
return Object.hasOwn(OS_DISPLAY, value) ? (value as OsToken) : null;
|
||||
}
|
||||
|
||||
function parseUserAgent(userAgent: string): Bowser.Parser.Parser | null {
|
||||
try {
|
||||
return Bowser.getParser(userAgent);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to parse user agent');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function bowserOsToken(userAgent: string): OsToken | null {
|
||||
if (!userAgent) return null;
|
||||
return narrowOsToken(BOWSER_OS_TO_TOKEN[parseUserAgent(userAgent)?.getOSName() ?? ''] ?? null);
|
||||
}
|
||||
|
||||
export function isFluxerNativeUserAgent(userAgent: string | null): boolean {
|
||||
return NATIVE_UA_REGEX.test(userAgent?.trim() ?? '');
|
||||
}
|
||||
|
||||
export function parseReportedClientOs(headerValue: string | null): OsToken | null {
|
||||
if (!headerValue) return null;
|
||||
const trimmed = headerValue.trim();
|
||||
if (!trimmed || trimmed.length > CLIENT_PROPERTIES_HEADER_MAX_LENGTH) return null;
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = Buffer.from(trimmed, 'base64').toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const record = parseJsonRecord(decoded);
|
||||
if (!record) return null;
|
||||
const os = record.os;
|
||||
return typeof os === 'string' ? narrowOsToken(os) : null;
|
||||
}
|
||||
|
||||
export function resolveSessionClientInfo({
|
||||
userAgent,
|
||||
reportedOs,
|
||||
productName,
|
||||
}: SessionClientSignals): SessionClientInfo {
|
||||
const ua = userAgent?.trim() ?? '';
|
||||
const nativeMatch = NATIVE_UA_REGEX.exec(ua);
|
||||
|
||||
if (nativeMatch) {
|
||||
const productToken = nativeMatch[1] as keyof typeof PRODUCT_TOKEN_OS;
|
||||
const osToken = narrowOsToken(reportedOs) ?? PRODUCT_TOKEN_OS[productToken] ?? bowserOsToken(ua);
|
||||
const osDisplay = osToken ? OS_DISPLAY[osToken] : null;
|
||||
const mobile = osToken === 'ios' || osToken === 'android';
|
||||
const platform = osDisplay
|
||||
? mobile
|
||||
? `${productName} ${osDisplay}`
|
||||
: `${productName} Lite ${osDisplay}`
|
||||
: `${productName} Lite`;
|
||||
return {platform, os: osDisplay, browser: null, device: mobile ? 'mobile' : 'desktop'};
|
||||
}
|
||||
|
||||
if (ELECTRON_UA_REGEX.test(ua)) {
|
||||
const osToken = bowserOsToken(ua);
|
||||
const osDisplay = osToken ? OS_DISPLAY[osToken] : null;
|
||||
const mobile = osToken === 'ios' || osToken === 'android';
|
||||
return {
|
||||
platform: osDisplay ? `${productName} ${osDisplay}` : productName,
|
||||
os: osDisplay,
|
||||
browser: null,
|
||||
device: mobile ? 'mobile' : 'desktop',
|
||||
};
|
||||
}
|
||||
|
||||
const parser = ua ? parseUserAgent(ua) : null;
|
||||
const browser = parser?.getBrowserName() || null;
|
||||
const os = parser?.getOSName() || null;
|
||||
const platformType = parser?.getPlatformType(true) ?? '';
|
||||
return {
|
||||
platform: browser ?? os,
|
||||
os,
|
||||
browser,
|
||||
device: MOBILE_PLATFORM_TYPES.has(platformType) ? 'mobile' : 'desktop',
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Bowser from 'bowser';
|
||||
import {Logger} from '../Logger';
|
||||
|
||||
interface UserAgentInfo {
|
||||
clientOs: string;
|
||||
detectedPlatform: string;
|
||||
}
|
||||
|
||||
const UNKNOWN_LABEL = 'Unknown';
|
||||
|
||||
function formatName(name?: string | null): string {
|
||||
const normalized = name?.trim();
|
||||
return normalized || UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
|
||||
const ua = userAgentRaw.trim();
|
||||
if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
try {
|
||||
const parser = Bowser.getParser(ua);
|
||||
return {
|
||||
clientOs: formatName(parser.getOSName()),
|
||||
detectedPlatform: formatName(parser.getBrowserName()),
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to parse user agent');
|
||||
return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
|
||||
clientOs: string;
|
||||
clientPlatform: string;
|
||||
} {
|
||||
const parsed = parseUserAgentSafe(args.userAgent ?? '');
|
||||
const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform;
|
||||
return {
|
||||
clientOs: parsed.clientOs,
|
||||
clientPlatform,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
isFluxerNativeUserAgent,
|
||||
parseReportedClientOs,
|
||||
resolveSessionClientInfo,
|
||||
type SessionClientInfo,
|
||||
} from '../SessionClientIdentity';
|
||||
|
||||
const ELECTRON_MAC_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36';
|
||||
const ELECTRON_WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) FluxerCanary/2026.614.83512 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36';
|
||||
const CHROME_MAC_UA =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||
const SAFARI_IPHONE_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
|
||||
const resolve = (userAgent: string | null, reportedOs: string | null, productName = 'Fluxer'): SessionClientInfo =>
|
||||
resolveSessionClientInfo({userAgent, reportedOs, productName});
|
||||
|
||||
describe('resolveSessionClientInfo', () => {
|
||||
it('labels the Flutter mobile clients by their operating system', () => {
|
||||
expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios')).toEqual({
|
||||
platform: 'Fluxer iOS',
|
||||
os: 'iOS',
|
||||
browser: null,
|
||||
device: 'mobile',
|
||||
});
|
||||
expect(resolve('Fluxer Android/1.4.2 (stable)', 'android')).toEqual({
|
||||
platform: 'Fluxer Android',
|
||||
os: 'Android',
|
||||
browser: null,
|
||||
device: 'mobile',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts the versionless user agent emitted before package info loads', () => {
|
||||
expect(resolve('Fluxer iOS (stable)', 'ios')).toEqual({
|
||||
platform: 'Fluxer iOS',
|
||||
os: 'iOS',
|
||||
browser: null,
|
||||
device: 'mobile',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels Flutter desktop builds as Lite and distinguishes them by reported operating system', () => {
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'macos')).toEqual({
|
||||
platform: 'Fluxer Lite macOS',
|
||||
os: 'macOS',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows')).toEqual({
|
||||
platform: 'Fluxer Lite Windows',
|
||||
os: 'Windows',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'linux')).toEqual({
|
||||
platform: 'Fluxer Lite Linux',
|
||||
os: 'Linux',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a narrow Linux window as a desktop because the product token cannot carry form factor', () => {
|
||||
expect(resolve('Fluxer Linux/1.4.2 (stable)', 'linux')).toEqual({
|
||||
platform: 'Fluxer Lite Linux',
|
||||
os: 'Linux',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves legacy rows that predate the reported operating system', () => {
|
||||
expect(resolve('Fluxer iOS/1.4.2 (stable)', null)).toEqual({
|
||||
platform: 'Fluxer iOS',
|
||||
os: 'iOS',
|
||||
browser: null,
|
||||
device: 'mobile',
|
||||
});
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', null)).toEqual({
|
||||
platform: 'Fluxer Lite',
|
||||
os: null,
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a corrupt reported operating system instead of rendering it', () => {
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'Windows 11')).toEqual({
|
||||
platform: 'Fluxer Lite',
|
||||
os: null,
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels the desktop application by operating system without naming a browser', () => {
|
||||
expect(resolve(ELECTRON_MAC_UA, null)).toEqual({
|
||||
platform: 'Fluxer macOS',
|
||||
os: 'macOS',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
expect(resolve(ELECTRON_WINDOWS_UA, null)).toEqual({
|
||||
platform: 'Fluxer Windows',
|
||||
os: 'Windows',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps browser sessions reporting their browser', () => {
|
||||
expect(resolve(CHROME_MAC_UA, null)).toEqual({
|
||||
platform: 'Chrome',
|
||||
os: 'macOS',
|
||||
browser: 'Chrome',
|
||||
device: 'desktop',
|
||||
});
|
||||
expect(resolve(SAFARI_IPHONE_UA, null)).toEqual({
|
||||
platform: 'Safari',
|
||||
os: 'iOS',
|
||||
browser: 'Safari',
|
||||
device: 'mobile',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the operating system when the browser cannot be named', () => {
|
||||
expect(resolve('SomeBot (Windows NT 10.0; Win64; x64)', null)).toEqual({
|
||||
platform: 'Windows',
|
||||
os: 'Windows',
|
||||
browser: null,
|
||||
device: 'desktop',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a product token that only prefixes a real one', () => {
|
||||
const resolved = resolve('Fluxer iOS-not-really/6.6.6', 'ios');
|
||||
expect(resolved.platform).not.toBe('Fluxer iOS');
|
||||
expect(resolved.browser).toBe(resolved.platform);
|
||||
});
|
||||
|
||||
it('never emits an Unknown literal when the user agent is absent', () => {
|
||||
expect(resolve(null, null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'});
|
||||
expect(resolve('', null)).toEqual({platform: null, os: null, browser: null, device: 'desktop'});
|
||||
});
|
||||
|
||||
it('uses instance branding for the product word', () => {
|
||||
expect(resolve('Fluxer iOS/1.4.2 (stable)', 'ios', 'Acme').platform).toBe('Acme iOS');
|
||||
expect(resolve('Fluxer Desktop/1.4.2 (stable)', 'windows', 'Acme').platform).toBe('Acme Lite Windows');
|
||||
expect(resolve(ELECTRON_MAC_UA, null, 'Acme').platform).toBe('Acme macOS');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFluxerNativeUserAgent', () => {
|
||||
it('matches only the Fluxer native product tokens', () => {
|
||||
expect(isFluxerNativeUserAgent('Fluxer iOS/1.4.2 (stable)')).toBe(true);
|
||||
expect(isFluxerNativeUserAgent('Fluxer Desktop (canary)')).toBe(true);
|
||||
expect(isFluxerNativeUserAgent(ELECTRON_MAC_UA)).toBe(false);
|
||||
expect(isFluxerNativeUserAgent(CHROME_MAC_UA)).toBe(false);
|
||||
expect(isFluxerNativeUserAgent(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReportedClientOs', () => {
|
||||
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value), 'utf8').toString('base64');
|
||||
|
||||
it('reads the operating system from the client properties header', () => {
|
||||
expect(parseReportedClientOs(encode({os: 'macos', device: 'mobile'}))).toBe('macos');
|
||||
expect(parseReportedClientOs(encode({os: 'ios'}))).toBe('ios');
|
||||
});
|
||||
|
||||
it('rejects anything outside the known operating systems', () => {
|
||||
expect(parseReportedClientOs(null)).toBeNull();
|
||||
expect(parseReportedClientOs('')).toBeNull();
|
||||
expect(parseReportedClientOs('not base64 $$$')).toBeNull();
|
||||
expect(parseReportedClientOs(encode([1, 2]))).toBeNull();
|
||||
expect(parseReportedClientOs(encode({os: 'solaris'}))).toBeNull();
|
||||
expect(parseReportedClientOs(encode({os: 42}))).toBeNull();
|
||||
expect(parseReportedClientOs(encode({}))).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an oversized header without decoding it', () => {
|
||||
expect(parseReportedClientOs('a'.repeat(4097))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ import type {User} from '../../models/User';
|
||||
import type {UserGuildSettings} from '../../models/UserGuildSettings';
|
||||
import type {UserSettings} from '../../models/UserSettings';
|
||||
import type {WebAuthnCredential} from '../../models/WebAuthnCredential';
|
||||
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
|
||||
import {createArchiveJsonBuffer} from '../utils/ArchiveJson';
|
||||
import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers';
|
||||
import {ContentAddressedAttachmentCollector} from '../utils/ContentAddressedAttachmentCollector';
|
||||
@@ -114,6 +114,7 @@ interface HarvestMessageResult {
|
||||
interface UserDataJsonParams {
|
||||
user: User;
|
||||
userId: UserID;
|
||||
productName: string;
|
||||
authSessions: Array<AuthSession>;
|
||||
relationships: Array<Relationship>;
|
||||
userNotes: Map<UserID, string>;
|
||||
@@ -354,6 +355,7 @@ function buildUserDataJson(params: UserDataJsonParams) {
|
||||
const {
|
||||
user,
|
||||
userId,
|
||||
productName,
|
||||
authSessions,
|
||||
relationships,
|
||||
userNotes,
|
||||
@@ -412,17 +414,18 @@ function buildUserDataJson(params: UserDataJsonParams) {
|
||||
authenticator_types: Array.from(user.authenticatorTypes),
|
||||
},
|
||||
auth_sessions: authSessions.map((session) => {
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: session.clientUserAgent,
|
||||
isDesktopClient: session.clientIsDesktop,
|
||||
reportedOs: session.clientOs ?? null,
|
||||
productName,
|
||||
});
|
||||
return {
|
||||
created_at: session.createdAt.toISOString(),
|
||||
approx_last_used_at: session.approximateLastUsedAt?.toISOString() ?? null,
|
||||
client_ip: session.clientIp,
|
||||
client_os: clientOs,
|
||||
client_os: clientInfo.os,
|
||||
client_user_agent: session.clientUserAgent,
|
||||
client_platform: clientPlatform,
|
||||
client_platform: clientInfo.platform,
|
||||
};
|
||||
}),
|
||||
relationships: relationships.map((rel) => ({
|
||||
@@ -872,9 +875,11 @@ const harvestUserData: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const guildSettings = await Promise.all(
|
||||
guildIds.map((guildId: GuildID) => userRepository.findGuildSettings(userId, guildId)),
|
||||
);
|
||||
const {branding} = await instanceConfigRepository.getAppPublicConfig();
|
||||
const userData = buildUserDataJson({
|
||||
user,
|
||||
userId,
|
||||
productName: branding.product_name,
|
||||
authSessions,
|
||||
relationships,
|
||||
userNotes,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {AgeVerificationService} from '../../stripe/services/AgeVerificationServi
|
||||
import {StripeCheckoutService} from '../../stripe/services/StripeCheckoutService';
|
||||
import {StripeGiftService} from '../../stripe/services/StripeGiftService';
|
||||
import {StripePremiumService} from '../../stripe/services/StripePremiumService';
|
||||
import {StripeRefundService} from '../../stripe/services/StripeRefundService';
|
||||
import {StripeSubscriptionService} from '../../stripe/services/StripeSubscriptionService';
|
||||
import {StripeWebhookService} from '../../stripe/services/StripeWebhookService';
|
||||
import type {IUserRepositoryAggregate} from '../../user/repositories/IUserRepositoryAggregate';
|
||||
@@ -80,6 +81,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const ageVerificationService = deps.stripe
|
||||
? new AgeVerificationService(deps.stripe, deps.userRepository, deps.gatewayService, deps.cacheService)
|
||||
: null;
|
||||
const refundService = new StripeRefundService(deps.stripe, deps.userRepository, subscriptionService);
|
||||
const webhookService = new StripeWebhookService(
|
||||
deps.stripe,
|
||||
checkoutService,
|
||||
@@ -99,6 +101,7 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
deps.adminRepository,
|
||||
deps.snowflakeService,
|
||||
deps.billingRepository,
|
||||
refundService,
|
||||
);
|
||||
await webhookService.handleWebhook({body, signature});
|
||||
};
|
||||
|
||||
@@ -16,11 +16,6 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
|
||||
:local(.appContainer) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(html.is-standalone:not(.auth-page)) :local(.appContainer) {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
@@ -86,12 +81,6 @@
|
||||
--skip-link-titlebar-offset: var(--native-titlebar-height);
|
||||
--skip-link-traffic-lights-offset: 4.5rem;
|
||||
}
|
||||
:global(html.platform-native.window-focus-activation-guard:not(.first-click-passthrough-when-unfocused))
|
||||
:local(.overlayScope)
|
||||
> :not([data-overlay-pass-through]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.quickSwitcherPortal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -45,15 +45,9 @@ import {getActivePortalHost, setActivePortalHost} from '@app/features/ui/overlay
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import Modal from '@app/features/ui/state/Modal';
|
||||
import Popout from '@app/features/ui/state/Popout';
|
||||
import {
|
||||
getDesktopWindowBehaviorSettings,
|
||||
setDesktopWindowBehaviorSettings,
|
||||
} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
|
||||
import {getDesktopWindowBehaviorSettings} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
|
||||
import {attachExternalLinkInterceptor, isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import {
|
||||
FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS,
|
||||
UNFOCUSED_FULLY_INTERACTIVE_CLASS,
|
||||
} from '@app/features/ui/utils/WindowFocusInteractionGuard';
|
||||
import {UNFOCUSED_FULLY_INTERACTIVE_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import {IncomingCallManager} from '@app/features/voice/components/IncomingCallManager';
|
||||
import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot';
|
||||
@@ -86,7 +80,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const reducedMotion = Accessibility.useReducedMotion;
|
||||
const stayInteractiveWhenUnfocused = Accessibility.stayInteractiveWhenUnfocused;
|
||||
const firstClickPassThroughWhenUnfocused = Accessibility.firstClickPassThroughWhenUnfocused;
|
||||
const {platform, isNative} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
|
||||
@@ -163,11 +156,6 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
useDocumentClassToggle('reduced-motion', reducedMotion);
|
||||
useDocumentClassToggle('mobile-layout', MobileLayout.platformMobileDetected || MobileLayout.enabled);
|
||||
useDocumentClassToggle(UNFOCUSED_FULLY_INTERACTIVE_CLASS, stayInteractiveWhenUnfocused);
|
||||
useDocumentClassToggle(FIRST_CLICK_PASSTHROUGH_WHEN_UNFOCUSED_CLASS, firstClickPassThroughWhenUnfocused);
|
||||
useEffect(() => {
|
||||
if (!isNative) return;
|
||||
void setDesktopWindowBehaviorSettings({firstClickPassThroughWhenUnfocused});
|
||||
}, [isNative, firstClickPassThroughWhenUnfocused]);
|
||||
useDesktopAllowTransparency(isNative);
|
||||
useWindowEventListeners({preventDocumentScroll: !isNative});
|
||||
useRemScaleTracking();
|
||||
|
||||
@@ -600,7 +600,6 @@ export interface AccessibilitySettings {
|
||||
hdrDisplayMode: HdrDisplayMode;
|
||||
preserveEditDraft: boolean;
|
||||
stayInteractiveWhenUnfocused: boolean;
|
||||
firstClickPassThroughWhenUnfocused: boolean;
|
||||
scrollToBottomOnMessageSend: boolean;
|
||||
sequentialFileSend: boolean;
|
||||
showNeko: boolean;
|
||||
@@ -718,7 +717,6 @@ class Accessibility {
|
||||
hdrDisplayMode = HdrDisplayMode.FULL;
|
||||
preserveEditDraft = false;
|
||||
stayInteractiveWhenUnfocused = false;
|
||||
firstClickPassThroughWhenUnfocused = false;
|
||||
scrollToBottomOnMessageSend = true;
|
||||
sequentialFileSend = false;
|
||||
showNeko = false;
|
||||
@@ -829,7 +827,6 @@ class Accessibility {
|
||||
'hdrDisplayMode',
|
||||
'preserveEditDraft',
|
||||
'stayInteractiveWhenUnfocused',
|
||||
'firstClickPassThroughWhenUnfocused',
|
||||
'scrollToBottomOnMessageSend',
|
||||
'sequentialFileSend',
|
||||
],
|
||||
@@ -890,7 +887,6 @@ class Accessibility {
|
||||
hdrDisplayMode: HDR_TO_PROTO[s.hdrDisplayMode],
|
||||
preserveEditDraft: s.preserveEditDraft,
|
||||
stayInteractiveWhenUnfocused: s.stayInteractiveWhenUnfocused,
|
||||
firstClickPassThroughWhenUnfocused: s.firstClickPassThroughWhenUnfocused,
|
||||
scrollToBottomOnMessageSend: s.scrollToBottomOnMessageSend,
|
||||
sequentialFileSend: s.sequentialFileSend,
|
||||
}),
|
||||
@@ -980,8 +976,6 @@ class Accessibility {
|
||||
if (m.preserveEditDraft !== undefined) s.preserveEditDraft = m.preserveEditDraft;
|
||||
if (m.stayInteractiveWhenUnfocused !== undefined)
|
||||
s.stayInteractiveWhenUnfocused = m.stayInteractiveWhenUnfocused;
|
||||
if (m.firstClickPassThroughWhenUnfocused !== undefined)
|
||||
s.firstClickPassThroughWhenUnfocused = m.firstClickPassThroughWhenUnfocused;
|
||||
if (m.scrollToBottomOnMessageSend !== undefined) s.scrollToBottomOnMessageSend = m.scrollToBottomOnMessageSend;
|
||||
if (m.sequentialFileSend !== undefined) s.sequentialFileSend = m.sequentialFileSend;
|
||||
},
|
||||
@@ -1316,8 +1310,6 @@ class Accessibility {
|
||||
if (validated.preserveEditDraft !== undefined) this.preserveEditDraft = validated.preserveEditDraft;
|
||||
if (validated.stayInteractiveWhenUnfocused !== undefined)
|
||||
this.stayInteractiveWhenUnfocused = validated.stayInteractiveWhenUnfocused;
|
||||
if (validated.firstClickPassThroughWhenUnfocused !== undefined)
|
||||
this.firstClickPassThroughWhenUnfocused = validated.firstClickPassThroughWhenUnfocused;
|
||||
if (validated.scrollToBottomOnMessageSend !== undefined)
|
||||
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
|
||||
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
|
||||
@@ -1418,8 +1410,6 @@ class Accessibility {
|
||||
hdrDisplayMode: data.hdrDisplayMode ?? this.hdrDisplayMode,
|
||||
preserveEditDraft: data.preserveEditDraft ?? this.preserveEditDraft,
|
||||
stayInteractiveWhenUnfocused: data.stayInteractiveWhenUnfocused ?? this.stayInteractiveWhenUnfocused,
|
||||
firstClickPassThroughWhenUnfocused:
|
||||
data.firstClickPassThroughWhenUnfocused ?? this.firstClickPassThroughWhenUnfocused,
|
||||
scrollToBottomOnMessageSend: data.scrollToBottomOnMessageSend ?? this.scrollToBottomOnMessageSend,
|
||||
sequentialFileSend: data.sequentialFileSend ?? this.sequentialFileSend,
|
||||
showNeko: data.showNeko ?? this.showNeko,
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
|
||||
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
|
||||
import {
|
||||
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
|
||||
UNREAD_PREVIEW_MESSAGE_LIMIT,
|
||||
} from '@app/features/app/components/floating/UnreadPreviewBudget';
|
||||
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import {renderChannelStream} from '@app/features/channel/components/ChannelMessageStream';
|
||||
@@ -131,8 +135,6 @@ interface ChannelPreviewData {
|
||||
|
||||
const INITIAL_VISIBLE_CHANNELS = 10;
|
||||
const LOAD_MORE_CHUNK = 10;
|
||||
const UNREAD_PREVIEW_MESSAGE_LIMIT = 5;
|
||||
const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 10;
|
||||
|
||||
interface CacheEntry {
|
||||
cacheKey: string;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
BULK_PREVIEW_CHANNEL_BATCH_SIZE,
|
||||
UNREAD_PREVIEW_MESSAGE_LIMIT,
|
||||
} from '@app/features/app/components/floating/UnreadPreviewBudget';
|
||||
import {BulkMessageFetchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const buildBatch = (limit: number) => ({
|
||||
requests: Array.from({length: BULK_PREVIEW_CHANNEL_BATCH_SIZE}, (_, index) => ({
|
||||
channel_id: String(1000000000000000000n + BigInt(index)),
|
||||
limit,
|
||||
})),
|
||||
});
|
||||
|
||||
describe('unread preview fetch budget', () => {
|
||||
it('keeps the anchored window inside the bulk fetch schema', () => {
|
||||
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2))).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps the unanchored window inside the bulk fetch schema', () => {
|
||||
expect(() => BulkMessageFetchRequest.parse(buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT))).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a batch one channel wider than the client sends', () => {
|
||||
const oversized = buildBatch(UNREAD_PREVIEW_MESSAGE_LIMIT * 2);
|
||||
oversized.requests.push({channel_id: '1000000000000000099', limit: UNREAD_PREVIEW_MESSAGE_LIMIT * 2});
|
||||
expect(() => BulkMessageFetchRequest.parse(oversized)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const UNREAD_PREVIEW_MESSAGE_LIMIT = 25;
|
||||
export const BULK_PREVIEW_CHANNEL_BATCH_SIZE = 5;
|
||||
@@ -3,7 +3,3 @@
|
||||
.nativeDragRegion {
|
||||
-webkit-app-region: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos) .nativeDragRegion {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
@@ -19,10 +19,12 @@ export interface PlaceholderMessageGroup {
|
||||
readonly usernameWidth: number;
|
||||
readonly timestampWidth: number;
|
||||
readonly attachment: PlaceholderAttachmentSize | null;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
interface MutablePlaceholderMessageGroup extends PlaceholderMessageGroup {
|
||||
attachment: PlaceholderAttachmentSize | null;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PlaceholderSpecs {
|
||||
@@ -100,20 +102,20 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
|
||||
for (let line = 0; line < lineCount; line++) {
|
||||
lineWidths.push(LINE_WIDTH_MIN + random() * LINE_WIDTH_RANGE);
|
||||
}
|
||||
const groupHeight = compact
|
||||
? MESSAGE_HEIGHT_COMPACT * lineCount
|
||||
: COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
|
||||
groups.push({
|
||||
lineWidths,
|
||||
usernameWidth: USERNAME_WIDTH_MIN + random() * USERNAME_WIDTH_RANGE,
|
||||
timestampWidth: TIMESTAMP_WIDTH_MIN + random() * TIMESTAMP_WIDTH_RANGE,
|
||||
attachment: null,
|
||||
height: groupHeight,
|
||||
});
|
||||
if (index > 0) {
|
||||
totalHeight += groupSpacing;
|
||||
}
|
||||
if (compact) {
|
||||
totalHeight += MESSAGE_HEIGHT_COMPACT * lineCount;
|
||||
} else {
|
||||
totalHeight += COZY_LEAD_MESSAGE_HEIGHT + COZY_GROUPED_MESSAGE_HEIGHT * (lineCount - 1);
|
||||
}
|
||||
totalHeight += groupHeight;
|
||||
}
|
||||
const availableGroupIndices = Array.from(Array(groups.length).keys());
|
||||
for (let index = 0; index < attachments && availableGroupIndices.length > 0; index++) {
|
||||
@@ -123,6 +125,7 @@ function generatePlaceholderSpecs(options: PlaceholderGenerationOptions): Placeh
|
||||
height: randomInRange(random, ATTACHMENT_HEIGHT_MIN, ATTACHMENT_HEIGHT_MAX),
|
||||
};
|
||||
groups[groupIndex].attachment = attachment;
|
||||
groups[groupIndex].height += attachment.height + ATTACHMENT_MARGIN;
|
||||
totalHeight += attachment.height + ATTACHMENT_MARGIN;
|
||||
}
|
||||
return {compact, compactAvatarsVisible, groups, totalHeight, groupSpacing};
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
.messageGroup {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
.group {
|
||||
|
||||
@@ -52,7 +52,10 @@ function CozyScrollFillerSkeletonGroup({
|
||||
return (
|
||||
<flx-message-list-skeleton-group
|
||||
className={flxElementClassName(styles.messageGroup)}
|
||||
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
|
||||
style={{
|
||||
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
|
||||
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
|
||||
}}
|
||||
data-flx="app.skeleton.scroll-filler-skeleton.cozy-scroll-filler-skeleton-group.message-group"
|
||||
>
|
||||
<flx-message-list-skeleton-group-row
|
||||
@@ -170,7 +173,10 @@ function CompactScrollFillerSkeletonGroup({
|
||||
return (
|
||||
<flx-message-list-skeleton-group
|
||||
className={flxElementClassName(styles.messageGroup)}
|
||||
style={{marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing)}}
|
||||
style={{
|
||||
marginBottom: resolveSkeletonGroupMarginBottom(groupIndex, groupCount, groupSpacing),
|
||||
containIntrinsicSize: `auto ${remFromPx(group.height)}`,
|
||||
}}
|
||||
data-flx="app.skeleton.scroll-filler-skeleton.compact-scroll-filler-skeleton-group.message-group"
|
||||
>
|
||||
<flx-message-list-skeleton-compact-messages
|
||||
|
||||
@@ -6,7 +6,6 @@ import {THE_OTHER_PLATFORM} from '@fluxer/constants/src/ExternalPlatformConstant
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME} from './ProductConstants';
|
||||
|
||||
export {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME};
|
||||
export const DESKTOP_PRODUCT_NAME = `${PRODUCT_NAME} Desktop`;
|
||||
export const PRODUCT_API_NAME = `${PRODUCT_NAME} API`;
|
||||
export const PRODUCT_HQ_COMMUNITY_NAME = `${PRODUCT_NAME} HQ`;
|
||||
export const CANARY_RELEASE_CHANNEL_NAME = `${PRODUCT_NAME} Canary`;
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList';
|
||||
import {act} from 'react';
|
||||
import {createRoot, type Root} from 'react-dom/client';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function RovingFocusProbe() {
|
||||
const listRef = useRovingFocusList<HTMLDivElement>({
|
||||
autoFocusFirst: true,
|
||||
focusableSelector: '[data-roving-focus="true"]',
|
||||
manageTabIndex: true,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-roving-focus="true"
|
||||
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button"
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-roving-focus="true"
|
||||
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitem.button--2"
|
||||
>
|
||||
Second
|
||||
</button>
|
||||
<div
|
||||
role="menuitemcheckbox"
|
||||
aria-checked="false"
|
||||
tabIndex={0}
|
||||
data-roving-focus="true"
|
||||
data-flx="app.use-roving-focus-list-test.roving-focus-probe.menuitemcheckbox"
|
||||
>
|
||||
Third
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualEntryRovingFocusProbe() {
|
||||
const listRef = useRovingFocusList<HTMLDivElement>({
|
||||
focusableSelector: '[data-roving-focus="true"]',
|
||||
manageTabIndex: true,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={listRef}
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
tabIndex={-1}
|
||||
data-autofocus
|
||||
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-roving-focus="true"
|
||||
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button"
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-roving-focus="true"
|
||||
data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe.menuitem.button--2"
|
||||
>
|
||||
Second
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const press = (target: Element, key: string): KeyboardEvent => {
|
||||
const event = new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true});
|
||||
act(() => {
|
||||
target.dispatchEvent(event);
|
||||
});
|
||||
return event;
|
||||
};
|
||||
|
||||
const getItems = (container: HTMLElement): Array<HTMLElement> =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>('[data-roving-focus="true"]'));
|
||||
|
||||
describe('useRovingFocusList', () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it('moves focus with vertical arrow keys while keeping one tabbable item', () => {
|
||||
act(() => {
|
||||
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe" />);
|
||||
});
|
||||
const items = getItems(container!);
|
||||
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]);
|
||||
|
||||
const down = press(items[0], 'ArrowDown');
|
||||
expect(down.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(items[1]);
|
||||
expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]);
|
||||
|
||||
press(items[1], 'ArrowDown');
|
||||
expect(document.activeElement).toBe(items[2]);
|
||||
expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
|
||||
|
||||
press(items[2], 'ArrowUp');
|
||||
expect(document.activeElement).toBe(items[1]);
|
||||
expect(items.map((item) => item.tabIndex)).toEqual([-1, 0, -1]);
|
||||
});
|
||||
|
||||
it('supports Home and End in a managed roving list', () => {
|
||||
act(() => {
|
||||
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe--2" />);
|
||||
});
|
||||
const items = getItems(container!);
|
||||
|
||||
press(items[0], 'End');
|
||||
expect(document.activeElement).toBe(items[2]);
|
||||
expect(items.map((item) => item.tabIndex)).toEqual([-1, -1, 0]);
|
||||
|
||||
press(items[2], 'Home');
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]);
|
||||
});
|
||||
|
||||
it('does not rewrite the roving-managed marker attribute on every navigation', () => {
|
||||
act(() => {
|
||||
root?.render(<RovingFocusProbe data-flx="app.use-roving-focus-list-test.roving-focus-probe--3" />);
|
||||
});
|
||||
const items = getItems(container!);
|
||||
expect(items.every((item) => item.hasAttribute('data-roving-focus-managed'))).toBe(true);
|
||||
|
||||
// Observe only the marker attribute, after it has already been applied once.
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(container!, {
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['data-roving-focus-managed'],
|
||||
});
|
||||
|
||||
press(items[0], 'ArrowDown');
|
||||
press(items[1], 'ArrowDown');
|
||||
press(items[2], 'ArrowUp');
|
||||
press(items[1], 'ArrowUp');
|
||||
press(items[0], 'End');
|
||||
press(items[2], 'Home');
|
||||
|
||||
const records = observer.takeRecords();
|
||||
observer.disconnect();
|
||||
|
||||
// The marker is write-once and only ever read via hasAttribute, so navigating
|
||||
// must not touch it again. Rewriting it invalidates style for every list item
|
||||
// on every keystroke, which is what drove idle style-recalc churn.
|
||||
expect(records.length).toBe(0);
|
||||
expect(items.every((item) => item.hasAttribute('data-roving-focus-managed'))).toBe(true);
|
||||
});
|
||||
|
||||
it('can start on the container with no focused item before ArrowDown enters the list', () => {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<ManualEntryRovingFocusProbe data-flx="app.use-roving-focus-list-test.manual-entry-roving-focus-probe" />,
|
||||
);
|
||||
});
|
||||
const menu = container?.querySelector<HTMLElement>('[role="menu"]');
|
||||
const items = getItems(container!);
|
||||
expect(menu).toBeInstanceOf(HTMLElement);
|
||||
expect(document.activeElement).not.toBe(items[0]);
|
||||
|
||||
act(() => {
|
||||
menu?.focus();
|
||||
});
|
||||
expect(document.activeElement).toBe(menu);
|
||||
expect(items.map((item) => item.tabIndex)).toEqual([0, -1]);
|
||||
|
||||
const down = press(menu!, 'ArrowDown');
|
||||
expect(down.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
expect(items.map((item) => item.tabIndex)).toEqual([0, -1]);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@ class Initialization {
|
||||
state: InitializationState = InitializationState.LOADING;
|
||||
hasCompletedInitialLoad = false;
|
||||
error: string | null = null;
|
||||
readyPayload: unknown = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -46,29 +45,25 @@ class Initialization {
|
||||
setLoading(): void {
|
||||
this.state = InitializationState.LOADING;
|
||||
this.error = null;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
|
||||
@action
|
||||
setConnecting(): void {
|
||||
this.state = InitializationState.CONNECTING;
|
||||
this.error = null;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
|
||||
@action
|
||||
setReady(payload: unknown): void {
|
||||
setReady(): void {
|
||||
this.state = InitializationState.READY;
|
||||
this.hasCompletedInitialLoad = true;
|
||||
this.error = null;
|
||||
this.readyPayload = payload;
|
||||
}
|
||||
|
||||
@action
|
||||
setError(error: string): void {
|
||||
this.state = InitializationState.ERROR;
|
||||
this.error = error;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -76,7 +71,6 @@ class Initialization {
|
||||
this.state = InitializationState.LOADING;
|
||||
this.hasCompletedInitialLoad = false;
|
||||
this.error = null;
|
||||
this.readyPayload = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
@@ -24,9 +23,7 @@ import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSche
|
||||
import type {AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON} from '@simplewebauthn/browser';
|
||||
|
||||
const logger = new Logger('AuthService');
|
||||
const getPlatformHeaderValue = (): 'web' | 'desktop' | 'mobile' => (isDesktop() ? 'desktop' : 'web');
|
||||
const withPlatformHeader = (headers?: Record<string, string>): Record<string, string> => ({
|
||||
'X-Fluxer-Platform': getPlatformHeaderValue(),
|
||||
const withAuthLocaleHeader = (headers?: Record<string, string>): Record<string, string> => ({
|
||||
'Accept-Language': UserSettings.getLocale(),
|
||||
...(headers ?? {}),
|
||||
});
|
||||
@@ -247,7 +244,7 @@ export async function login({
|
||||
try {
|
||||
const response = await http.post<LoginResponse>(Endpoints.AUTH_LOGIN, {
|
||||
body: loginBody({email, password, inviteCode}),
|
||||
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
|
||||
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
|
||||
});
|
||||
logger.debug('Login successful', {mfa: response.body?.mfa});
|
||||
return response.body;
|
||||
@@ -268,7 +265,7 @@ export async function loginMfaTotp(code: string, ticket: string, inviteCode?: st
|
||||
try {
|
||||
const response = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_TOTP, {
|
||||
body: mfaTotpBody(code, ticket, inviteCode),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('MFA TOTP authentication successful');
|
||||
@@ -288,7 +285,7 @@ export async function loginMfaWebAuthn(
|
||||
try {
|
||||
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN, {
|
||||
body: mfaWebAuthnBody(response, challenge, ticket, inviteCode),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = httpResponse.body;
|
||||
logger.debug('MFA WebAuthn authentication successful');
|
||||
@@ -303,7 +300,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
|
||||
try {
|
||||
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS, {
|
||||
body: ticketBody(ticket),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('WebAuthn MFA options retrieved');
|
||||
@@ -317,7 +314,7 @@ export async function getWebAuthnMfaOptions(ticket: string): Promise<PublicKeyCr
|
||||
export async function getWebAuthnAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
|
||||
try {
|
||||
const response = await http.post<PublicKeyCredentialRequestOptionsJSON>(Endpoints.AUTH_WEBAUTHN_OPTIONS, {
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('WebAuthn authentication options retrieved');
|
||||
@@ -336,7 +333,7 @@ export async function authenticateWithWebAuthn(
|
||||
try {
|
||||
const httpResponse = await http.post<TokenResponse>(Endpoints.AUTH_WEBAUTHN_AUTHENTICATE, {
|
||||
body: webAuthnBody(response, challenge, inviteCode),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = httpResponse.body;
|
||||
logger.debug('WebAuthn authentication successful');
|
||||
@@ -351,7 +348,7 @@ export async function register(data: RegisterData): Promise<RegisterResponse> {
|
||||
try {
|
||||
const response = await http.post<RegisterResponse>(Endpoints.AUTH_REGISTER, {
|
||||
body: registerBody(data),
|
||||
headers: withPlatformHeader(captchaHeaders(data)),
|
||||
headers: withAuthLocaleHeader(captchaHeaders(data)),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Registration successful');
|
||||
@@ -370,7 +367,7 @@ export async function getUsernameSuggestions(globalName: string): Promise<Array<
|
||||
try {
|
||||
const response = await http.post<UsernameSuggestionsResponse>(Endpoints.AUTH_USERNAME_SUGGESTIONS, {
|
||||
body: {global_name: globalName},
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.debug('Username suggestions retrieved', {count: responseBody?.suggestions?.length || 0});
|
||||
@@ -389,7 +386,7 @@ export async function forgotPassword(
|
||||
try {
|
||||
await http.post(Endpoints.AUTH_FORGOT_PASSWORD, {
|
||||
body: {email},
|
||||
headers: withPlatformHeader(captchaHeaders({captchaToken, captchaType})),
|
||||
headers: withAuthLocaleHeader(captchaHeaders({captchaToken, captchaType})),
|
||||
});
|
||||
logger.debug('Password reset email sent');
|
||||
} catch (error) {
|
||||
@@ -402,7 +399,7 @@ export async function validateResetPasswordToken(token: string): Promise<boolean
|
||||
const response = await http.get<{
|
||||
valid: boolean;
|
||||
}>(Endpoints.AUTH_VALIDATE_RESET_PASSWORD_TOKEN(token), {
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
return response.body.valid;
|
||||
} catch (error) {
|
||||
@@ -415,7 +412,7 @@ export async function resetPassword(token: string, password: string): Promise<Re
|
||||
try {
|
||||
const response = await http.post<ResetPasswordResponse>(Endpoints.AUTH_RESET_PASSWORD, {
|
||||
body: {token, password},
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Password reset successful');
|
||||
@@ -430,7 +427,7 @@ export async function revertEmailChange(token: string, password: string): Promis
|
||||
try {
|
||||
const response = await http.post<TokenResponse>(Endpoints.AUTH_EMAIL_REVERT, {
|
||||
body: {token, password},
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
const responseBody = response.body;
|
||||
logger.info('Email revert successful');
|
||||
@@ -445,7 +442,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
|
||||
try {
|
||||
await http.post(Endpoints.AUTH_VERIFY_EMAIL, {
|
||||
body: tokenBody(token),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
logger.info('Email verification successful');
|
||||
return VerificationResult.SUCCESS;
|
||||
@@ -463,7 +460,7 @@ export async function verifyEmail(token: string): Promise<VerificationResult> {
|
||||
export async function resendVerificationEmail(): Promise<VerificationResult> {
|
||||
try {
|
||||
await http.post(Endpoints.AUTH_RESEND_VERIFICATION, {
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
logger.info('Verification email resent');
|
||||
return VerificationResult.SUCCESS;
|
||||
@@ -486,7 +483,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
|
||||
try {
|
||||
await http.post(Endpoints.AUTH_AUTHORIZE_IP, {
|
||||
body: tokenBody(token),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
logger.info('IP authorization successful');
|
||||
return VerificationResult.SUCCESS;
|
||||
@@ -504,7 +501,7 @@ export async function authorizeIp(token: string): Promise<VerificationResult> {
|
||||
export async function resendIpAuthorization(ticket: string): Promise<void> {
|
||||
await http.post(Endpoints.AUTH_IP_AUTHORIZATION_RESEND, {
|
||||
body: ticketBody(ticket),
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -517,7 +514,7 @@ export interface IpAuthorizationPollResult {
|
||||
|
||||
export async function pollIpAuthorization(ticket: string): Promise<IpAuthorizationPollResult> {
|
||||
const response = await http.get<IpAuthorizationPollResult>(Endpoints.AUTH_IP_AUTHORIZATION_POLL(ticket), {
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
@@ -547,7 +544,7 @@ export async function completeDesktopHandoff({
|
||||
}): Promise<void> {
|
||||
await http.post(Endpoints.AUTH_HANDOFF_COMPLETE, {
|
||||
body: {code, user_id: userId},
|
||||
headers: withPlatformHeader({Authorization: token}),
|
||||
headers: withAuthLocaleHeader({Authorization: token}),
|
||||
auth: 'none',
|
||||
});
|
||||
}
|
||||
@@ -643,7 +640,7 @@ export async function startSso({
|
||||
};
|
||||
const response = await http.post<SsoStartResponse>(Endpoints.AUTH_SSO_START, {
|
||||
body,
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
@@ -651,7 +648,7 @@ export async function startSso({
|
||||
export async function completeSso({code, state}: {code: string; state: string}): Promise<SsoCompleteResponse> {
|
||||
const response = await http.post<SsoCompleteResponse>(Endpoints.AUTH_SSO_COMPLETE, {
|
||||
body: {code, state},
|
||||
headers: withPlatformHeader(),
|
||||
headers: withAuthLocaleHeader(),
|
||||
});
|
||||
return response.body;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import type {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
|
||||
@@ -18,11 +17,6 @@ const SIGN_IN_CODE_DESCRIPTOR = msg({
|
||||
message: 'Sign-in code',
|
||||
comment: 'Short label in the authentication handoff approval flow. Keep the tone plain and specific.',
|
||||
});
|
||||
const PRODUCT_DESKTOP_DESCRIPTOR = msg({
|
||||
message: '{productName} Desktop',
|
||||
comment:
|
||||
'Display name for the product desktop client in the authentication handoff approval flow when the raw client name is Electron. Preserve {productName}; it is inserted by code.',
|
||||
});
|
||||
const CODE_LENGTH = 12;
|
||||
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{12}$/;
|
||||
|
||||
@@ -53,10 +47,6 @@ function formatLocation(location: {
|
||||
return parts.length > 0 ? parts.join(', ') : null;
|
||||
}
|
||||
|
||||
function isElectronClientLabel(label: string): boolean {
|
||||
return label.trim().toLowerCase() === 'electron';
|
||||
}
|
||||
|
||||
interface HandoffApprovalFlowProps {
|
||||
mode: DesktopHandoffMode;
|
||||
error: string | null;
|
||||
@@ -164,11 +154,6 @@ export function HandoffApprovalFlow({
|
||||
const os = clientInfo?.os ?? null;
|
||||
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
|
||||
const hasAnyDeviceInfo = Boolean(platform || os || location);
|
||||
const platformLabel = platform
|
||||
? isElectronClientLabel(platform)
|
||||
? i18n._(PRODUCT_DESKTOP_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
: platform
|
||||
: null;
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--4">
|
||||
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--3">
|
||||
@@ -183,13 +168,13 @@ export function HandoffApprovalFlow({
|
||||
</p>
|
||||
{hasAnyDeviceInfo ? (
|
||||
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
|
||||
{platformLabel ? (
|
||||
{platform ? (
|
||||
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
|
||||
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
|
||||
<Trans>Platform</Trans>
|
||||
</span>
|
||||
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
|
||||
{platformLabel}
|
||||
{platform}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -7,6 +7,8 @@ export class AuthSession {
|
||||
readonly approxLastUsedAt: Date | null;
|
||||
readonly clientOs: string | null;
|
||||
readonly clientPlatform: string | null;
|
||||
readonly clientBrowser: string | null;
|
||||
readonly clientDevice: 'mobile' | 'desktop';
|
||||
readonly clientLocation: string | null;
|
||||
readonly maskedIp: string | null;
|
||||
readonly isCurrent: boolean;
|
||||
@@ -18,6 +20,8 @@ export class AuthSession {
|
||||
this.clientInfo = data.client_info ?? null;
|
||||
this.clientOs = this.clientInfo?.os ?? null;
|
||||
this.clientPlatform = this.clientInfo?.platform ?? null;
|
||||
this.clientBrowser = this.clientInfo?.browser ?? null;
|
||||
this.clientDevice = this.clientInfo?.device ?? 'desktop';
|
||||
this.clientLocation = getLocationLabel(this.clientInfo?.location ?? null);
|
||||
this.maskedIp = data.masked_ip ?? null;
|
||||
this.isCurrent = data.current;
|
||||
|
||||
@@ -67,7 +67,6 @@ import {Popout} from '@app/features/ui/popover/PopoverPopout';
|
||||
import ContextMenu from '@app/features/ui/state/ContextMenu';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {canUseWindowFocusedActivationClick} from '@app/features/ui/utils/WindowFocusInteractionGuard';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {MessageStates} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -187,14 +186,6 @@ const useShiftKey = (enabled: boolean) => {
|
||||
}, [enabled]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, shiftKeyManager.getServerSnapshot);
|
||||
};
|
||||
const suppressBlockedActivationClick = (event: React.SyntheticEvent): boolean => {
|
||||
if (canUseWindowFocusedActivationClick()) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return true;
|
||||
};
|
||||
|
||||
interface MessageActionBarButtonProps {
|
||||
label: string;
|
||||
@@ -211,9 +202,6 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
|
||||
({label, icon, onClick, onPointerDownCapture, danger, isActive, hidden, dataAction}, ref) => {
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent | React.KeyboardEvent) => {
|
||||
if (suppressBlockedActivationClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClick?.(event);
|
||||
@@ -222,9 +210,6 @@ const MessageActionBarButton = React.forwardRef<HTMLButtonElement, MessageAction
|
||||
);
|
||||
const handlePointerDownCapture = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
if (suppressBlockedActivationClick(event)) {
|
||||
return;
|
||||
}
|
||||
onPointerDownCapture?.(event);
|
||||
},
|
||||
[onPointerDownCapture],
|
||||
@@ -275,9 +260,6 @@ const QuickReactionButton = observer(
|
||||
const {url: displayUrl} = getEmojiDisplayData(emoji);
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent | React.KeyboardEvent) => {
|
||||
if (suppressBlockedActivationClick(event)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
EmojiPickerCommands.trackEmojiUsage(emoji);
|
||||
@@ -700,7 +682,6 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
|
||||
uniqueId={`emoji_picker-actionbar-${message.id}`}
|
||||
shouldAutoUpdate={false}
|
||||
animationType="none"
|
||||
shouldOpenOnClick={() => canUseWindowFocusedActivationClick()}
|
||||
onOpen={handleEmojiPickerOpen}
|
||||
onClose={handleEmojiPickerClose}
|
||||
data-flx="channel.message-action-bar.message-action-bar-core.popout"
|
||||
|
||||
@@ -74,7 +74,11 @@ export function formatSlowmodeDuration(ms: number, locale: string): string {
|
||||
return formatDurationPart(totalSeconds, 'second', locale);
|
||||
}
|
||||
if (totalSeconds < SECONDS_PER_HOUR) {
|
||||
return formatDurationPart(Math.round(totalSeconds / SECONDS_PER_MINUTE), 'minute', locale);
|
||||
const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE);
|
||||
const remainingSeconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
const minutePart = formatDurationPart(minutes, 'minute', locale);
|
||||
if (remainingSeconds === 0) return minutePart;
|
||||
return `${minutePart} ${formatDurationPart(remainingSeconds, 'second', locale)}`;
|
||||
}
|
||||
if (totalSeconds < SECONDS_PER_DAY) {
|
||||
const hours = Math.floor(totalSeconds / SECONDS_PER_HOUR);
|
||||
|
||||
@@ -770,8 +770,46 @@ export const UserMessage = observer(() => {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(message.editedTimestamp || message.isEditing) &&
|
||||
(message.isEditing ? (
|
||||
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--3">
|
||||
{' '}
|
||||
{i18n._(EDITED_DESCRIPTOR)}
|
||||
</span>
|
||||
) : (
|
||||
<TimestampWithTooltip
|
||||
date={message.editedTimestamp!}
|
||||
className={styles.editedTimestamp}
|
||||
data-flx="channel.user-message.edited-timestamp--2"
|
||||
>
|
||||
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--4">
|
||||
{' '}
|
||||
{i18n._(EDITED_DESCRIPTOR)}
|
||||
</span>
|
||||
</TimestampWithTooltip>
|
||||
))}
|
||||
</AuthorHeading>
|
||||
)}
|
||||
{((!message.content && !isEditing) || (shouldHideContent && !isEditing)) &&
|
||||
shouldGroup &&
|
||||
(message.editedTimestamp || message.isEditing) &&
|
||||
(message.isEditing ? (
|
||||
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--5">
|
||||
{' '}
|
||||
{i18n._(EDITED_DESCRIPTOR)}
|
||||
</span>
|
||||
) : (
|
||||
<TimestampWithTooltip
|
||||
date={message.editedTimestamp!}
|
||||
className={styles.editedTimestamp}
|
||||
data-flx="channel.user-message.edited-timestamp--3"
|
||||
>
|
||||
<span className={styles.editedLabel} data-flx="channel.user-message.edited-label--6">
|
||||
{' '}
|
||||
{i18n._(EDITED_DESCRIPTOR)}
|
||||
</span>
|
||||
</TimestampWithTooltip>
|
||||
))}
|
||||
<MessageAttachments data-flx="channel.user-message.message-attachments--3" />
|
||||
{renderFailedFooter()}
|
||||
</div>
|
||||
|
||||
@@ -32,14 +32,18 @@ export const VerificationBarrier = observer(({channel}: Props) => {
|
||||
case VerificationFailureReason.ACCOUNT_TOO_NEW:
|
||||
return (
|
||||
<AccountTooNewBarrier
|
||||
initialTimeRemaining={verificationStatus.timeRemaining || 0}
|
||||
initialTimeRemaining={
|
||||
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
|
||||
}
|
||||
data-flx="channel.verification-barrier.account-too-new-barrier"
|
||||
/>
|
||||
);
|
||||
case VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH:
|
||||
return (
|
||||
<NotMemberLongEnoughBarrier
|
||||
initialTimeRemaining={verificationStatus.timeRemaining || 0}
|
||||
initialTimeRemaining={
|
||||
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
|
||||
}
|
||||
data-flx="channel.verification-barrier.not-member-long-enough-barrier"
|
||||
/>
|
||||
);
|
||||
@@ -50,7 +54,9 @@ export const VerificationBarrier = observer(({channel}: Props) => {
|
||||
case VerificationFailureReason.TIMED_OUT:
|
||||
return (
|
||||
<TimeoutBarrier
|
||||
initialTimeRemaining={verificationStatus.timeRemaining || 0}
|
||||
initialTimeRemaining={
|
||||
verificationStatus.verificationEndsAt ? Math.max(0, verificationStatus.verificationEndsAt - Date.now()) : 0
|
||||
}
|
||||
data-flx="channel.verification-barrier.timeout-barrier"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.embedIconPending {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.embedTitle {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -5,6 +5,7 @@ import styles from '@app/features/channel/components/embeds/ChannelEmbed.module.
|
||||
import {EmbedLink} from '@app/features/channel/components/embeds/channel_embed/EmbedLink';
|
||||
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {useCachedImageLoaded} from '@app/features/messaging/hooks/useCachedImageLoaded';
|
||||
import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
|
||||
import * as DateUtils from '@app/features/user/utils/DateFormatting';
|
||||
import type {EmbedAuthor, EmbedField, EmbedFooter} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
@@ -32,13 +33,14 @@ export const EmbedAuthorComponent: FC<{author?: EmbedAuthor}> = observer(({autho
|
||||
const iconSrc = author?.proxy_icon_url
|
||||
? buildMediaProxyURL(author.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
|
||||
: undefined;
|
||||
const iconLoaded = useCachedImageLoaded(iconSrc);
|
||||
if (!author) return null;
|
||||
return (
|
||||
<div className={styles.embedAuthor} data-flx="channel.embeds.embed.embed-author-component.embed-author">
|
||||
{iconSrc && (
|
||||
<img
|
||||
alt=""
|
||||
className={styles.embedAuthorIcon}
|
||||
className={clsx(styles.embedAuthorIcon, !iconLoaded && styles.embedIconPending)}
|
||||
src={iconSrc}
|
||||
width={24}
|
||||
height={24}
|
||||
@@ -181,6 +183,7 @@ export const EmbedFooterComponent: FC<{
|
||||
const iconSrc = footer?.proxy_icon_url
|
||||
? buildMediaProxyURL(footer.proxy_icon_url, animatedMediaPlaybackAllowed ? {} : {format: 'webp', animated: false})
|
||||
: undefined;
|
||||
const iconLoaded = useCachedImageLoaded(iconSrc);
|
||||
if (!(footer || formattedTimestamp)) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -190,7 +193,7 @@ export const EmbedFooterComponent: FC<{
|
||||
{iconSrc && (
|
||||
<img
|
||||
alt=""
|
||||
className={styles.embedFooterIcon}
|
||||
className={clsx(styles.embedFooterIcon, !iconLoaded && styles.embedIconPending)}
|
||||
src={iconSrc}
|
||||
width={20}
|
||||
height={20}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
reduceGIFVRenderState,
|
||||
} from '@app/features/channel/components/embeds/media/GIFVRenderStateMachine';
|
||||
import {GifIndicator} from '@app/features/channel/components/embeds/media/GifIndicator';
|
||||
import {useGifViewportGate} from '@app/features/channel/components/embeds/media/GifViewportGate';
|
||||
import {getMediaButtonVisibility} from '@app/features/channel/components/embeds/media/MediaButtonUtils';
|
||||
import {MediaContainer, shouldShowOverlays} from '@app/features/channel/components/embeds/media/MediaContainer';
|
||||
import type {BaseMediaProps} from '@app/features/channel/components/embeds/media/MediaTypes';
|
||||
@@ -985,9 +986,15 @@ export const EmbedGif: FC<
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const animatedImageRef = useRef<HTMLImageElement>(null);
|
||||
const freezeCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const {ref: visibilityRef, isNearViewport} = useNearViewport<HTMLDivElement>({
|
||||
disabled: !isMobile,
|
||||
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
|
||||
const {
|
||||
ref: visibilityRef,
|
||||
loadMedia: shouldLoadMedia,
|
||||
animate: shouldAnimate,
|
||||
} = useGifViewportGate<HTMLDivElement>({
|
||||
element: containerElement,
|
||||
rememberKey: proxyURL,
|
||||
shouldBlur,
|
||||
});
|
||||
const setContainerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
@@ -1018,8 +1025,6 @@ export const EmbedGif: FC<
|
||||
Math.round(displayHeight * 2),
|
||||
);
|
||||
const animatedImageSourceRef = useRef(optimizedAnimatedURL);
|
||||
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
|
||||
const shouldLoadMedia = isNearViewport && !shouldBlur;
|
||||
const {
|
||||
loaded,
|
||||
error,
|
||||
@@ -1049,7 +1054,7 @@ export const EmbedGif: FC<
|
||||
status: ImageCacheUtils.hasImage(optimizedAnimatedURL) ? 'ready' : 'idle',
|
||||
});
|
||||
const [decoderRequested, setDecoderRequested] = useState(() => getAnimatedMediaPlaybackAllowed());
|
||||
const shouldUseDecoder = shouldLoadMedia && hasStartedAnimating;
|
||||
const shouldUseDecoder = shouldAnimate && hasStartedAnimating;
|
||||
const decoderPlaying =
|
||||
shouldUseDecoder && decoderRequested && animatedMediaPlaybackAllowed && (gifAutoPlay || isHoveredState);
|
||||
const decoderState = useAnimatedImageDecoder({
|
||||
@@ -1061,7 +1066,7 @@ export const EmbedGif: FC<
|
||||
const useDecoder =
|
||||
shouldUseDecoder && decoderRequested && decoderState.supported && hasStartedAnimating && !decoderState.error;
|
||||
const decoderCanvasVisible = useDecoder && decoderState.loaded;
|
||||
const shouldRenderFreezeFrame = !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
|
||||
const shouldRenderFreezeFrame = shouldAnimate && !decoderCanvasVisible && (gifAutoPlay || hasStartedAnimating);
|
||||
const setAnimatedImageRef = useCallback(
|
||||
(node: HTMLImageElement | null) => {
|
||||
animatedImageRef.current = node;
|
||||
@@ -1406,7 +1411,7 @@ export const EmbedGif: FC<
|
||||
...(aspectRatio ? {aspectRatio} : {}),
|
||||
} as React.CSSProperties;
|
||||
const shouldUseAnimatedImage =
|
||||
shouldLoadMedia &&
|
||||
shouldAnimate &&
|
||||
animatedMediaPlaybackAllowed &&
|
||||
!decoderCanvasVisible &&
|
||||
(gifAutoPlay || (hasStartedAnimating && isHoveredState));
|
||||
|
||||
@@ -356,9 +356,9 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
() => {
|
||||
if (!active) return;
|
||||
setLoadedPosterSrc(posterSrc);
|
||||
const image = ImageCacheUtils.getImage(posterSrc);
|
||||
if (active && image && image.naturalWidth > 0 && image.naturalHeight > 0) {
|
||||
setPosterNaturalDimensions({src: posterSrc, width: image.naturalWidth, height: image.naturalHeight});
|
||||
const posterSize = ImageCacheUtils.getImageSize(posterSrc);
|
||||
if (active && posterSize) {
|
||||
setPosterNaturalDimensions({src: posterSrc, width: posterSize.width, height: posterSize.height});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
|
||||
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const ANIMATION_ROOT_MARGIN = '600px 0px';
|
||||
|
||||
export interface GifViewportGate {
|
||||
loadMedia: boolean;
|
||||
animate: boolean;
|
||||
}
|
||||
|
||||
export function resolveGifViewportGate({
|
||||
isNearViewport,
|
||||
isInViewport,
|
||||
shouldBlur,
|
||||
}: {
|
||||
isNearViewport: boolean;
|
||||
isInViewport: boolean;
|
||||
shouldBlur: boolean;
|
||||
}): GifViewportGate {
|
||||
if (shouldBlur) return {loadMedia: false, animate: false};
|
||||
return {loadMedia: isNearViewport, animate: isNearViewport && isInViewport};
|
||||
}
|
||||
|
||||
export function useGifViewportGate<T extends Element>({
|
||||
element,
|
||||
rememberKey,
|
||||
shouldBlur,
|
||||
}: {
|
||||
element: Element | null;
|
||||
rememberKey: string;
|
||||
shouldBlur: boolean;
|
||||
}): GifViewportGate & {ref: (node: T | null) => void} {
|
||||
const {ref, isNearViewport} = useNearViewport<T>({rememberKey});
|
||||
const [isInViewport, setIsInViewport] = useState(() => typeof IntersectionObserver === 'undefined');
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setIsInViewport(true);
|
||||
return;
|
||||
}
|
||||
if (element == null) {
|
||||
setIsInViewport(false);
|
||||
return;
|
||||
}
|
||||
return observeIntersection(
|
||||
element,
|
||||
(entry) => {
|
||||
setIsInViewport(entry.isIntersecting || entry.intersectionRatio > 0);
|
||||
},
|
||||
{rootMargin: ANIMATION_ROOT_MARGIN},
|
||||
);
|
||||
}, [element]);
|
||||
return {ref, ...resolveGifViewportGate({isNearViewport, isInViewport, shouldBlur})};
|
||||
}
|
||||
@@ -9,17 +9,6 @@ export interface TrackedRecipientUser {
|
||||
readonly globalName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the MobX tracking key for group-DM display names.
|
||||
*
|
||||
* Only the recipients of tracked group DMs are read, so the observing reaction
|
||||
* depends on those users alone rather than on the whole (unbounded) user cache.
|
||||
* Reading a recipient that is not cached yet still registers a dependency on
|
||||
* that key, so a user arriving later re-triggers the reaction.
|
||||
*
|
||||
* Kept as a standalone function rather than a class method because
|
||||
* makeAutoObservable converts methods into actions, and actions run untracked.
|
||||
*/
|
||||
export const trackedRecipientNameKey = (
|
||||
snapshots: ReadonlyMap<string, TrackedRecipientSnapshot>,
|
||||
users: Record<string, TrackedRecipientUser | undefined>,
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/Ch
|
||||
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {action, makeAutoObservable} from 'mobx';
|
||||
import {action, makeAutoObservable, observable} from 'mobx';
|
||||
|
||||
const EMPTY_CHANNELS: ReadonlyArray<Channel> = Object.freeze([]);
|
||||
const sortDMs = (a: Channel, b: Channel) => {
|
||||
@@ -30,12 +30,37 @@ const sortDMs = (a: Channel, b: Channel) => {
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
};
|
||||
|
||||
const isPrivateChannel = (channel: Channel) =>
|
||||
channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
|
||||
|
||||
const insertSorted = (channels: ReadonlyArray<Channel>, channel: Channel): Array<Channel> => {
|
||||
let low = 0;
|
||||
let high = channels.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (ChannelUtils.compareChannels(channels[middle], channel) <= 0) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
const next = channels.slice();
|
||||
next.splice(low, 0, channel);
|
||||
return next;
|
||||
};
|
||||
|
||||
class Channels {
|
||||
private readonly channelsById = new Map<string, Channel>();
|
||||
private readonly channelsByGuildId = new Map<string, ReadonlyArray<Channel>>();
|
||||
private privateChannelList: ReadonlyArray<Channel> = EMPTY_CHANNELS;
|
||||
private readonly optimisticChannelBackups = new Map<string, Channel>();
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
makeAutoObservable<this, 'channelsByGuildId' | 'privateChannelList'>(
|
||||
this,
|
||||
{channelsByGuildId: observable.shallow, privateChannelList: observable.ref},
|
||||
{autoBind: true},
|
||||
);
|
||||
}
|
||||
|
||||
get channels(): ReadonlyArray<Channel> {
|
||||
@@ -51,31 +76,14 @@ class Channels {
|
||||
readonly dms: ReadonlyArray<Channel>;
|
||||
readonly privateChannels: ReadonlyArray<Channel>;
|
||||
} {
|
||||
const byGuild = new Map<string, Array<Channel>>();
|
||||
const dms: Array<Channel> = [];
|
||||
const privateChannels: Array<Channel> = [];
|
||||
for (const channel of this.channelsById.values()) {
|
||||
if (channel.guildId) {
|
||||
let list = byGuild.get(channel.guildId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
byGuild.set(channel.guildId, list);
|
||||
}
|
||||
list.push(channel);
|
||||
} else if (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM) {
|
||||
privateChannels.push(channel);
|
||||
dms.push(channel);
|
||||
}
|
||||
}
|
||||
for (const list of byGuild.values()) {
|
||||
list.sort(ChannelUtils.compareChannels);
|
||||
}
|
||||
dms.sort(sortDMs);
|
||||
return {byGuild, dms, privateChannels};
|
||||
return {byGuild: this.channelsByGuildId, dms: this.dmChannels, privateChannels: this.privateChannelList};
|
||||
}
|
||||
|
||||
get dmChannels(): ReadonlyArray<Channel> {
|
||||
return this.channelGroups.dms;
|
||||
if (this.privateChannelList.length === 0) {
|
||||
return EMPTY_CHANNELS;
|
||||
}
|
||||
return this.privateChannelList.slice().sort(sortDMs);
|
||||
}
|
||||
|
||||
getChannel(channelId: string): Channel | undefined {
|
||||
@@ -83,11 +91,11 @@ class Channels {
|
||||
}
|
||||
|
||||
getGuildChannels(guildId: string): ReadonlyArray<Channel> {
|
||||
return this.channelGroups.byGuild.get(guildId) ?? EMPTY_CHANNELS;
|
||||
return this.channelsByGuildId.get(guildId) ?? EMPTY_CHANNELS;
|
||||
}
|
||||
|
||||
getPrivateChannels(): ReadonlyArray<Channel> {
|
||||
return this.channelGroups.privateChannels;
|
||||
return this.privateChannelList;
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -100,8 +108,7 @@ class Channels {
|
||||
return;
|
||||
}
|
||||
this.optimisticChannelBackups.set(channelId, channel);
|
||||
this.channelsById.delete(channelId);
|
||||
ChannelDisplayName.removeChannel(channelId);
|
||||
this.deleteChannelRecord(channelId);
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -122,7 +129,16 @@ class Channels {
|
||||
@action
|
||||
private removeChannel(channelId: string): void {
|
||||
this.clearOptimisticallyRemovedChannel(channelId);
|
||||
this.deleteChannelRecord(channelId);
|
||||
}
|
||||
|
||||
@action
|
||||
private deleteChannelRecord(channelId: string): void {
|
||||
const channel = this.channelsById.get(channelId);
|
||||
this.channelsById.delete(channelId);
|
||||
if (channel) {
|
||||
this.removeChannelFromIndex(channel);
|
||||
}
|
||||
ChannelDisplayName.removeChannel(channelId);
|
||||
}
|
||||
|
||||
@@ -134,12 +150,95 @@ class Channels {
|
||||
return;
|
||||
}
|
||||
this.channelsById.set(record.id, record);
|
||||
this.indexChannel(existing, record);
|
||||
ChannelDisplayName.syncChannel(record);
|
||||
}
|
||||
|
||||
@action
|
||||
private indexChannel(previous: Channel | undefined, next: Channel): void {
|
||||
if (previous && previous.guildId === next.guildId && isPrivateChannel(previous) === isPrivateChannel(next)) {
|
||||
this.replaceChannelInIndex(previous, next);
|
||||
return;
|
||||
}
|
||||
if (previous) {
|
||||
this.removeChannelFromIndex(previous);
|
||||
}
|
||||
this.addChannelToIndex(next);
|
||||
}
|
||||
|
||||
@action
|
||||
private addChannelToIndex(channel: Channel): void {
|
||||
if (channel.guildId) {
|
||||
const list = this.channelsByGuildId.get(channel.guildId) ?? EMPTY_CHANNELS;
|
||||
this.channelsByGuildId.set(channel.guildId, insertSorted(list, channel));
|
||||
return;
|
||||
}
|
||||
if (isPrivateChannel(channel)) {
|
||||
this.privateChannelList = [...this.privateChannelList, channel];
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
private removeChannelFromIndex(channel: Channel): void {
|
||||
if (channel.guildId) {
|
||||
const list = this.channelsByGuildId.get(channel.guildId);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
const next = list.filter((entry) => entry.id !== channel.id);
|
||||
if (next.length === list.length) {
|
||||
return;
|
||||
}
|
||||
if (next.length === 0) {
|
||||
this.channelsByGuildId.delete(channel.guildId);
|
||||
} else {
|
||||
this.channelsByGuildId.set(channel.guildId, next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isPrivateChannel(channel)) {
|
||||
const next = this.privateChannelList.filter((entry) => entry.id !== channel.id);
|
||||
if (next.length !== this.privateChannelList.length) {
|
||||
this.privateChannelList = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
private replaceChannelInIndex(previous: Channel, next: Channel): void {
|
||||
if (next.guildId) {
|
||||
const list = this.channelsByGuildId.get(next.guildId);
|
||||
const index = list ? list.findIndex((entry) => entry.id === next.id) : -1;
|
||||
if (!list || index === -1) {
|
||||
this.addChannelToIndex(next);
|
||||
return;
|
||||
}
|
||||
const updated = list.slice();
|
||||
updated[index] = next;
|
||||
if (ChannelUtils.compareChannels(previous, next) !== 0) {
|
||||
updated.sort(ChannelUtils.compareChannels);
|
||||
}
|
||||
this.channelsByGuildId.set(next.guildId, updated);
|
||||
return;
|
||||
}
|
||||
if (!isPrivateChannel(next)) {
|
||||
return;
|
||||
}
|
||||
const index = this.privateChannelList.findIndex((entry) => entry.id === next.id);
|
||||
if (index === -1) {
|
||||
this.addChannelToIndex(next);
|
||||
return;
|
||||
}
|
||||
const updated = this.privateChannelList.slice();
|
||||
updated[index] = next;
|
||||
this.privateChannelList = updated;
|
||||
}
|
||||
|
||||
@action
|
||||
handleConnectionOpen({channels}: {channels: ReadonlyArray<WireChannel>}): void {
|
||||
this.channelsById.clear();
|
||||
this.channelsByGuildId.clear();
|
||||
this.privateChannelList = EMPTY_CHANNELS;
|
||||
ChannelDisplayName.clear();
|
||||
const allRecipients = channels
|
||||
.filter((channel) => channel.recipients && channel.recipients.length > 0)
|
||||
@@ -177,7 +276,7 @@ class Channels {
|
||||
return;
|
||||
}
|
||||
const syncedChannelIds = new Set(guild.channels.map((channel) => channel.id));
|
||||
const existingGuildChannels = this.channelGroups.byGuild.get(guild.id) ?? EMPTY_CHANNELS;
|
||||
const existingGuildChannels = this.getGuildChannels(guild.id);
|
||||
for (const channel of existingGuildChannels) {
|
||||
if (!syncedChannelIds.has(channel.id)) {
|
||||
this.removeChannel(channel.id);
|
||||
@@ -190,8 +289,8 @@ class Channels {
|
||||
|
||||
@action
|
||||
handleGuildDelete({guildId}: {guildId: string}): void {
|
||||
const guildChannels = this.channelGroups.byGuild.get(guildId);
|
||||
if (!guildChannels || guildChannels.length === 0) return;
|
||||
const guildChannels = this.getGuildChannels(guildId);
|
||||
if (guildChannels.length === 0) return;
|
||||
const ids: Array<string> = [];
|
||||
for (const channel of guildChannels) ids.push(channel.id);
|
||||
for (const id of ids) {
|
||||
@@ -269,8 +368,7 @@ class Channels {
|
||||
return;
|
||||
}
|
||||
if (user.id === Authentication.currentUserId) {
|
||||
this.channelsById.delete(channelId);
|
||||
ChannelDisplayName.removeChannel(channelId);
|
||||
this.deleteChannelRecord(channelId);
|
||||
const history = RouterUtils.getHistory();
|
||||
const currentPath = history?.location.pathname ?? '';
|
||||
const expectedPath = Routes.dmChannel(channelId);
|
||||
@@ -332,8 +430,8 @@ class Channels {
|
||||
|
||||
@action
|
||||
handleGuildRoleDelete({guildId, roleId}: {guildId: string; roleId: string}): void {
|
||||
const guildChannels = this.channelGroups.byGuild.get(guildId);
|
||||
if (!guildChannels || guildChannels.length === 0) return;
|
||||
const guildChannels = this.getGuildChannels(guildId);
|
||||
if (guildChannels.length === 0) return;
|
||||
const snapshot = Array.from(guildChannels);
|
||||
for (const channel of snapshot) {
|
||||
if (!(roleId in channel.permissionOverwrites)) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
|
||||
import {DiscoveryLanguageDropdown} from '@app/features/discovery/discovery/DiscoveryLanguageDropdown';
|
||||
import Discovery from '@app/features/discovery/state/Discovery';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import {useContextMenuTrigger} from '@app/features/ui/hooks/useContextMenuTrigger';
|
||||
import {getDiscoveryLanguageLabel} from '@app/features/user/utils/LocaleUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {TranslateIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const FILTER_BY_LANGUAGE_DESCRIPTOR = msg({
|
||||
message: 'Filter by language',
|
||||
comment: 'Label for the discovery language filter button. Keep it concise.',
|
||||
});
|
||||
const FILTERING_BY_LANGUAGE_DESCRIPTOR = msg({
|
||||
message: 'Filtering by {language}',
|
||||
comment:
|
||||
'Label for the discovery language filter button once a language is chosen. Preserve {language}; it is inserted by code.',
|
||||
});
|
||||
|
||||
export const DiscoveryLanguageButton = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const {isOpen, withTracking} = useContextMenuTrigger();
|
||||
const activeLanguage = Discovery.language;
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ContextMenuCommands.openFromElementBottomRight(
|
||||
event,
|
||||
({onClose}) => (
|
||||
<DiscoveryLanguageDropdown
|
||||
onClose={onClose}
|
||||
data-flx="discovery.discovery.discovery-language-button.handle-click.discovery-language-dropdown"
|
||||
/>
|
||||
),
|
||||
withTracking(),
|
||||
);
|
||||
},
|
||||
[withTracking],
|
||||
);
|
||||
return (
|
||||
<ChannelHeaderIcon
|
||||
icon={TranslateIcon}
|
||||
label={
|
||||
activeLanguage == null
|
||||
? i18n._(FILTER_BY_LANGUAGE_DESCRIPTOR)
|
||||
: i18n._(FILTERING_BY_LANGUAGE_DESCRIPTOR, {language: getDiscoveryLanguageLabel(activeLanguage)})
|
||||
}
|
||||
isSelected={isOpen || activeLanguage != null}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
onClick={handleClick}
|
||||
data-flx="discovery.discovery.discovery-language-button.channel-header-icon.click"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Discovery from '@app/features/discovery/state/Discovery';
|
||||
import {ContextMenuCloseProvider} from '@app/features/ui/action_menu/ContextMenu';
|
||||
import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup';
|
||||
import {MenuItemRadio} from '@app/features/ui/action_menu/MenuItemRadio';
|
||||
import {getSortedDiscoveryLanguages} from '@app/features/user/utils/LocaleUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
|
||||
const ALL_LANGUAGES_DESCRIPTOR = msg({
|
||||
message: 'All languages',
|
||||
comment: 'Option in the discovery language filter that clears the filter. Keep it concise.',
|
||||
});
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DiscoveryLanguageDropdown: React.FC<Props> = observer(({onClose}) => {
|
||||
const {i18n} = useLingui();
|
||||
const languages = useMemo(() => getSortedDiscoveryLanguages(), [i18n.locale]);
|
||||
const selectLanguage = useCallback(
|
||||
(language: string | null) => {
|
||||
if (Discovery.language !== language) {
|
||||
void Discovery.search({language, offset: 0});
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
return (
|
||||
<ContextMenuCloseProvider
|
||||
value={onClose}
|
||||
data-flx="discovery.discovery.discovery-language-dropdown.context-menu-close-provider"
|
||||
>
|
||||
<MenuGroup data-flx="discovery.discovery.discovery-language-dropdown.menu-group">
|
||||
<MenuItemRadio
|
||||
label={i18n._(ALL_LANGUAGES_DESCRIPTOR)}
|
||||
selected={Discovery.language == null}
|
||||
onSelect={() => selectLanguage(null)}
|
||||
data-flx="discovery.discovery.discovery-language-dropdown.menu-item-radio.all"
|
||||
/>
|
||||
{languages.map((language) => (
|
||||
<MenuItemRadio
|
||||
key={language.code}
|
||||
label={language.label}
|
||||
selected={Discovery.language === language.code}
|
||||
onSelect={() => selectLanguage(language.code)}
|
||||
data-flx="discovery.discovery.discovery-language-dropdown.menu-item-radio.language"
|
||||
/>
|
||||
))}
|
||||
</MenuGroup>
|
||||
</ContextMenuCloseProvider>
|
||||
);
|
||||
});
|
||||
@@ -114,6 +114,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.searchArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.searchForm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {reportSkeletonDiscoveryCategoryTabs} from '@app/features/app/components/
|
||||
import {measureSkeletonWidthPx, useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import {ChannelHeaderIcon} from '@app/features/channel/components/channel_header_components/ChannelHeaderIcon';
|
||||
import searchBarStyles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
|
||||
import {DiscoveryLanguageButton} from '@app/features/discovery/discovery/DiscoveryLanguageButton';
|
||||
import styles from '@app/features/discovery/discovery/DiscoveryNavbar.module.css';
|
||||
import {DiscoveryMotionKind, DiscoveryTransition} from '@app/features/discovery/discovery/DiscoveryTransition';
|
||||
import Discovery from '@app/features/discovery/state/Discovery';
|
||||
@@ -251,51 +252,54 @@ export const DiscoveryNavbar = observer(function DiscoveryNavbar({
|
||||
)}
|
||||
</DiscoveryTransition>
|
||||
</div>
|
||||
<form
|
||||
className={styles.searchForm}
|
||||
role="search"
|
||||
aria-label={i18n._(DISCOVERY_SEARCH_DESCRIPTOR)}
|
||||
onSubmit={handleSubmit}
|
||||
data-flx="discovery.discovery.discovery-navbar.search-form.submit"
|
||||
>
|
||||
<div className={searchBarStyles.inputContainer} data-flx="discovery.discovery.discovery-navbar.div">
|
||||
<MagnifyingGlassIcon
|
||||
className={clsx(searchBarStyles.searchIcon, styles.searchIcon)}
|
||||
weight="bold"
|
||||
aria-hidden
|
||||
data-flx="discovery.discovery.discovery-navbar.search-icon"
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className={styles.searchInput}
|
||||
type="search"
|
||||
aria-label={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
|
||||
aria-controls={resultsRegionId}
|
||||
placeholder={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
|
||||
value={draftQuery}
|
||||
onChange={(event) => setDraftQuery(event.target.value)}
|
||||
data-flx="discovery.discovery.discovery-navbar.search-input.set-draft-query"
|
||||
/>
|
||||
{draftQuery.length > 0 && (
|
||||
<FocusRing offset={-2} data-flx="discovery.discovery.discovery-navbar.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={searchBarStyles.clearButton}
|
||||
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
|
||||
onClick={handleClear}
|
||||
data-flx="discovery.discovery.discovery-navbar.button.clear"
|
||||
>
|
||||
<XIcon
|
||||
weight="bold"
|
||||
className={searchBarStyles.optionMetaIcon}
|
||||
aria-hidden
|
||||
data-flx="discovery.discovery.discovery-navbar.x-icon"
|
||||
/>
|
||||
</button>
|
||||
</FocusRing>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<div className={styles.searchArea} data-flx="discovery.discovery.discovery-navbar.search-area">
|
||||
<form
|
||||
className={styles.searchForm}
|
||||
role="search"
|
||||
aria-label={i18n._(DISCOVERY_SEARCH_DESCRIPTOR)}
|
||||
onSubmit={handleSubmit}
|
||||
data-flx="discovery.discovery.discovery-navbar.search-form.submit"
|
||||
>
|
||||
<div className={searchBarStyles.inputContainer} data-flx="discovery.discovery.discovery-navbar.div">
|
||||
<MagnifyingGlassIcon
|
||||
className={clsx(searchBarStyles.searchIcon, styles.searchIcon)}
|
||||
weight="bold"
|
||||
aria-hidden
|
||||
data-flx="discovery.discovery.discovery-navbar.search-icon"
|
||||
/>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className={styles.searchInput}
|
||||
type="search"
|
||||
aria-label={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
|
||||
aria-controls={resultsRegionId}
|
||||
placeholder={i18n._(SEARCH_COMMUNITIES_DESCRIPTOR)}
|
||||
value={draftQuery}
|
||||
onChange={(event) => setDraftQuery(event.target.value)}
|
||||
data-flx="discovery.discovery.discovery-navbar.search-input.set-draft-query"
|
||||
/>
|
||||
{draftQuery.length > 0 && (
|
||||
<FocusRing offset={-2} data-flx="discovery.discovery.discovery-navbar.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
className={searchBarStyles.clearButton}
|
||||
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
|
||||
onClick={handleClear}
|
||||
data-flx="discovery.discovery.discovery-navbar.button.clear"
|
||||
>
|
||||
<XIcon
|
||||
weight="bold"
|
||||
className={searchBarStyles.optionMetaIcon}
|
||||
aria-hidden
|
||||
data-flx="discovery.discovery.discovery-navbar.x-icon"
|
||||
/>
|
||||
</button>
|
||||
</FocusRing>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<DiscoveryLanguageButton data-flx="discovery.discovery.discovery-navbar.discovery-language-button" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ interface BatchRequest {
|
||||
|
||||
const PARALLEL_FRAME_THRESHOLD = 8;
|
||||
const PARALLEL_PIXEL_THRESHOLD = 1_000_000;
|
||||
const WORKER_IDLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
export class CropPipelineError extends Error {
|
||||
readonly code: CropWorkerErrorCode;
|
||||
@@ -70,6 +71,8 @@ export class AnimatedImageCropWorkerManager {
|
||||
private readonly pendingBatchRequests = new Set<BatchRequest>();
|
||||
private readonly maxWorkers: number;
|
||||
private terminated = false;
|
||||
private activeJobCount = 0;
|
||||
private idleTerminationTimeout: NodeJS.Timeout | null = null;
|
||||
private readonly workerTimeout: number = 30000;
|
||||
|
||||
private constructor() {
|
||||
@@ -197,12 +200,27 @@ export class AnimatedImageCropWorkerManager {
|
||||
if (this.terminated) {
|
||||
throw new CropPipelineError('internal', 'Worker manager has been terminated');
|
||||
}
|
||||
this.beginJob();
|
||||
return this.dispatchLibfluxcoreWorker(imageBytes, format, options, outputFormat).finally(() => {
|
||||
this.endJob();
|
||||
});
|
||||
}
|
||||
|
||||
private dispatchLibfluxcoreWorker(
|
||||
imageBytes: Uint8Array,
|
||||
format: CropWorkerImageFormat,
|
||||
options: CropParams,
|
||||
outputFormat: CropOutputFormat | undefined,
|
||||
): Promise<Uint8Array> {
|
||||
if (this.terminated) {
|
||||
return Promise.reject(new CropPipelineError('internal', 'Worker manager has been terminated'));
|
||||
}
|
||||
this.ensureWorkersInitialized();
|
||||
const workerState = this.findAvailableWorker();
|
||||
if (!workerState) {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
this.runLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject);
|
||||
this.dispatchLibfluxcoreWorker(imageBytes, format, options, outputFormat).then(resolve, reject);
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
@@ -316,25 +334,30 @@ export class AnimatedImageCropWorkerManager {
|
||||
frames: Array<NativeFrame>,
|
||||
options: CropOptionsEx,
|
||||
): Promise<Array<NativeFrame>> {
|
||||
this.ensureWorkersInitialized();
|
||||
const availableWorkers = this.workers.filter((worker) => !worker.busy);
|
||||
if (availableWorkers.length <= 1) {
|
||||
return frames.map((frame) => cropDecodedFrame(frame, options));
|
||||
this.beginJob();
|
||||
try {
|
||||
this.ensureWorkersInitialized();
|
||||
const availableWorkers = this.workers.filter((worker) => !worker.busy);
|
||||
if (availableWorkers.length <= 1) {
|
||||
return frames.map((frame) => cropDecodedFrame(frame, options));
|
||||
}
|
||||
const batches = createFrameBatches(frames, availableWorkers.length);
|
||||
const results = await Promise.all(
|
||||
batches.map((batch, index) =>
|
||||
this.processFrameBatchWithWorker(availableWorkers[index], index + 1, batch, options),
|
||||
),
|
||||
);
|
||||
const ordered: Array<NativeFrame | undefined> = new Array(frames.length);
|
||||
for (const batch of results) {
|
||||
for (const item of batch) ordered[item.index] = item.frame;
|
||||
}
|
||||
return ordered.map((frame, index) => {
|
||||
if (!frame) throw new CropPipelineError('internal', `Missing transformed frame ${index}`);
|
||||
return frame;
|
||||
});
|
||||
} finally {
|
||||
this.endJob();
|
||||
}
|
||||
const batches = createFrameBatches(frames, availableWorkers.length);
|
||||
const results = await Promise.all(
|
||||
batches.map((batch, index) =>
|
||||
this.processFrameBatchWithWorker(availableWorkers[index], index + 1, batch, options),
|
||||
),
|
||||
);
|
||||
const ordered: Array<NativeFrame | undefined> = new Array(frames.length);
|
||||
for (const batch of results) {
|
||||
for (const item of batch) ordered[item.index] = item.frame;
|
||||
}
|
||||
return ordered.map((frame, index) => {
|
||||
if (!frame) throw new CropPipelineError('internal', `Missing transformed frame ${index}`);
|
||||
return frame;
|
||||
});
|
||||
}
|
||||
|
||||
private processFrameBatchWithWorker(
|
||||
@@ -417,6 +440,54 @@ export class AnimatedImageCropWorkerManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
private beginJob(): void {
|
||||
this.clearIdleTermination();
|
||||
this.activeJobCount += 1;
|
||||
}
|
||||
|
||||
private endJob(): void {
|
||||
this.activeJobCount = Math.max(0, this.activeJobCount - 1);
|
||||
this.scheduleIdleTermination();
|
||||
}
|
||||
|
||||
private isPoolIdle(): boolean {
|
||||
return (
|
||||
this.activeJobCount === 0 &&
|
||||
this.pendingBatchRequests.size === 0 &&
|
||||
this.workers.every((workerState) => !workerState.busy && workerState.currentRequest === null)
|
||||
);
|
||||
}
|
||||
|
||||
private clearIdleTermination(): void {
|
||||
if (this.idleTerminationTimeout === null) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(this.idleTerminationTimeout);
|
||||
this.idleTerminationTimeout = null;
|
||||
}
|
||||
|
||||
private scheduleIdleTermination(): void {
|
||||
this.clearIdleTermination();
|
||||
if (this.terminated || this.workers.length === 0 || !this.isPoolIdle()) {
|
||||
return;
|
||||
}
|
||||
this.idleTerminationTimeout = setTimeout(() => {
|
||||
this.idleTerminationTimeout = null;
|
||||
if (this.isPoolIdle()) {
|
||||
this.terminateIdleWorkers();
|
||||
}
|
||||
}, WORKER_IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private terminateIdleWorkers(): void {
|
||||
for (const workerState of this.workers) {
|
||||
try {
|
||||
workerState.worker.terminate();
|
||||
} catch {}
|
||||
}
|
||||
this.workers = [];
|
||||
}
|
||||
|
||||
getActiveWorkerCount(): number {
|
||||
return this.workers.filter((w) => w.busy).length;
|
||||
}
|
||||
@@ -430,6 +501,7 @@ export class AnimatedImageCropWorkerManager {
|
||||
return;
|
||||
}
|
||||
this.terminated = true;
|
||||
this.clearIdleTermination();
|
||||
for (const workerState of this.workers) {
|
||||
if (workerState.currentRequest) {
|
||||
clearTimeout(workerState.currentRequest.timeout);
|
||||
@@ -453,6 +525,7 @@ export class AnimatedImageCropWorkerManager {
|
||||
}
|
||||
this.terminated = false;
|
||||
this.ensureWorkersInitialized();
|
||||
this.scheduleIdleTermination();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ function handleReadyInternal(data: ReadyPayload, context: GatewayHandlerContext)
|
||||
GuildReadState.handleConnectionOpen();
|
||||
Presence.handleConnectionOpen(data.user, guilds, data.presences);
|
||||
MediaEngine.handleConnectionOpen(guilds);
|
||||
Initialization.setReady(data);
|
||||
Initialization.setReady();
|
||||
context.setReady();
|
||||
Messages.handleConnectionOpen((channelId) => ReadStates.getUnreadJumpAnchor(channelId));
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export type VerificationFailureReason = ValueOf<typeof VerificationFailureReason
|
||||
interface VerificationStatus {
|
||||
canAccess: boolean;
|
||||
reason?: VerificationFailureReason;
|
||||
timeRemaining?: number;
|
||||
verificationEndsAt?: number;
|
||||
}
|
||||
|
||||
class GuildVerification {
|
||||
@@ -81,10 +81,11 @@ class GuildVerification {
|
||||
for (const guild of guilds) {
|
||||
const status = this.computeVerificationStatus(guild);
|
||||
newVerificationStatus[guild.id] = status;
|
||||
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
|
||||
const delay = status.verificationEndsAt ? status.verificationEndsAt - Date.now() : undefined;
|
||||
if (!status.canAccess && delay && delay > 0) {
|
||||
newTimers[guild.id] = setTimeout(() => {
|
||||
this.recomputeGuild(guild.id);
|
||||
}, status.timeRemaining);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
this.verificationStatus = Object.freeze(newVerificationStatus);
|
||||
@@ -106,10 +107,11 @@ class GuildVerification {
|
||||
}
|
||||
const newTimers = {...this.timers};
|
||||
delete newTimers[guildId];
|
||||
if (!status.canAccess && status.timeRemaining && status.timeRemaining > 0) {
|
||||
const delay = status.verificationEndsAt ? status.verificationEndsAt - Date.now() : undefined;
|
||||
if (!status.canAccess && delay && delay > 0) {
|
||||
newTimers[guildId] = setTimeout(() => {
|
||||
this.recomputeGuild(guildId);
|
||||
}, status.timeRemaining);
|
||||
}, delay);
|
||||
}
|
||||
this.verificationStatus = Object.freeze(newVerificationStatus);
|
||||
this.timers = newTimers;
|
||||
@@ -124,12 +126,11 @@ class GuildVerification {
|
||||
const now = Date.now();
|
||||
if (member?.communicationDisabledUntil) {
|
||||
const timeoutUntil = member.communicationDisabledUntil;
|
||||
const timeRemaining = timeoutUntil.getTime() - now;
|
||||
if (timeRemaining > 0) {
|
||||
if (timeoutUntil.getTime() > now) {
|
||||
return {
|
||||
canAccess: false,
|
||||
reason: VerificationFailureReason.TIMED_OUT,
|
||||
timeRemaining,
|
||||
verificationEndsAt: timeoutUntil.getTime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -164,18 +165,16 @@ class GuildVerification {
|
||||
}
|
||||
}
|
||||
if (verificationLevel >= GuildVerificationLevel.MEDIUM) {
|
||||
const accountAge = Date.now() - user.createdAt.getTime();
|
||||
if (accountAge < FIVE_MINUTES_MS) {
|
||||
const timeRemaining = FIVE_MINUTES_MS - accountAge;
|
||||
return {canAccess: false, reason: VerificationFailureReason.ACCOUNT_TOO_NEW, timeRemaining};
|
||||
const verificationEndsAt = user.createdAt.getTime() + FIVE_MINUTES_MS;
|
||||
if (verificationEndsAt > now) {
|
||||
return {canAccess: false, reason: VerificationFailureReason.ACCOUNT_TOO_NEW, verificationEndsAt};
|
||||
}
|
||||
}
|
||||
if (verificationLevel >= GuildVerificationLevel.HIGH) {
|
||||
if (member?.joinedAt) {
|
||||
const membershipDuration = Date.now() - member.joinedAt.getTime();
|
||||
if (membershipDuration < TEN_MINUTES_MS) {
|
||||
const timeRemaining = TEN_MINUTES_MS - membershipDuration;
|
||||
return {canAccess: false, reason: VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH, timeRemaining};
|
||||
const verificationEndsAt = member.joinedAt.getTime() + TEN_MINUTES_MS;
|
||||
if (verificationEndsAt > now) {
|
||||
return {canAccess: false, reason: VerificationFailureReason.NOT_MEMBER_LONG_ENOUGH, verificationEndsAt};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +195,8 @@ class GuildVerification {
|
||||
}
|
||||
|
||||
getTimeRemaining(guildId: string): number | null {
|
||||
return this.verificationStatus[guildId]?.timeRemaining ?? null;
|
||||
const verificationEndsAt = this.verificationStatus[guildId]?.verificationEndsAt;
|
||||
return verificationEndsAt ? Math.max(0, verificationEndsAt - Date.now()) : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1380,6 +1380,9 @@
|
||||
{
|
||||
"msgid": "File ordering"
|
||||
},
|
||||
{
|
||||
"msgid": "Filtering by {language}"
|
||||
},
|
||||
{
|
||||
"msgid": "Finish"
|
||||
},
|
||||
@@ -1743,6 +1746,9 @@
|
||||
{
|
||||
"msgid": "online"
|
||||
},
|
||||
{
|
||||
"msgid": "{PRODUCT_NAME} needs to restart for the title bar change to take effect."
|
||||
},
|
||||
{
|
||||
"msgid": "{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user