mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
532e828fe6 | ||
|
|
12a407aca8 | ||
|
|
5ca458dada | ||
|
|
0aeff01c2d | ||
|
|
2ac164d5b8 | ||
|
|
14d475df9a | ||
|
|
f3c777b244 | ||
|
|
1544e58e76 | ||
|
|
5d0c9c7cbe | ||
|
|
8f58fcc4c4 | ||
|
|
5799ef705d |
@@ -138,6 +138,7 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_STRIPE_PRICES={}
|
||||
#FLUXER_STRIPE_LEGACY_PRICES={}
|
||||
#FLUXER_API_DONATION_PROXY_KEY=
|
||||
#FLUXER_API_TRUSTED_CALLERS=[]
|
||||
#FLUXER_VISIONARIES_GUILD_ID=
|
||||
#FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID=
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_STRIPE_PRICES: ${FLUXER_STRIPE_PRICES:-}
|
||||
FLUXER_STRIPE_LEGACY_PRICES: ${FLUXER_STRIPE_LEGACY_PRICES:-}
|
||||
FLUXER_API_DONATION_PROXY_KEY: ${FLUXER_API_DONATION_PROXY_KEY:-}
|
||||
FLUXER_API_TRUSTED_CALLERS: ${FLUXER_API_TRUSTED_CALLERS:-}
|
||||
FLUXER_VISIONARIES_GUILD_ID: ${FLUXER_VISIONARIES_GUILD_ID:-}
|
||||
FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: ${FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID:-}
|
||||
|
||||
|
||||
@@ -211,3 +211,75 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
|
||||
expect(config.breachedPasswordCheck.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
async function trustedCallersFromEnv(env: Record<string, string>) {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
vi.stubEnv(key, value);
|
||||
}
|
||||
resetConfig();
|
||||
return buildAPIConfigFromMaster(await loadConfig()).internal.trustedCallers;
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster trusted callers', () => {
|
||||
const bugsKey = 'b'.repeat(32);
|
||||
const donationKey = 'd'.repeat(32);
|
||||
|
||||
test('reads callers from FLUXER_API_TRUSTED_CALLERS', async () => {
|
||||
const callers = await trustedCallersFromEnv({
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([
|
||||
{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'oauth:revoke']},
|
||||
]),
|
||||
});
|
||||
expect(callers).toEqual([{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'oauth:revoke']}]);
|
||||
});
|
||||
|
||||
test('turns FLUXER_API_DONATION_PROXY_KEY into a caller scoped to the donation buckets', async () => {
|
||||
const callers = await trustedCallersFromEnv({FLUXER_API_DONATION_PROXY_KEY: donationKey});
|
||||
expect(callers).toEqual([
|
||||
{
|
||||
name: 'donation',
|
||||
key: donationKey,
|
||||
buckets: ['donation:request_link', 'donation:manage', 'donation:checkout'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps both forms side by side', async () => {
|
||||
const callers = await trustedCallersFromEnv({
|
||||
FLUXER_API_DONATION_PROXY_KEY: donationKey,
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: bugsKey, buckets: ['oauth:token']}]),
|
||||
});
|
||||
expect(callers.map((caller) => caller.name)).toEqual(['bugs', 'donation']);
|
||||
});
|
||||
|
||||
test('tolerates bucket names and fields this build does not know', async () => {
|
||||
const callers = await trustedCallersFromEnv({
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([
|
||||
{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'future:bucket'], note: 'added later'},
|
||||
]),
|
||||
});
|
||||
expect(callers).toEqual([{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'future:bucket']}]);
|
||||
});
|
||||
|
||||
test('fails at boot on a short key', async () => {
|
||||
await expect(
|
||||
trustedCallersFromEnv({
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: 'short', buckets: ['oauth:token']}]),
|
||||
}),
|
||||
).rejects.toThrow('FLUXER_API_TRUSTED_CALLERS entry 1 key must be at least 32 characters');
|
||||
});
|
||||
|
||||
test('fails at boot on an entry with no buckets', async () => {
|
||||
await expect(
|
||||
trustedCallersFromEnv({
|
||||
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: bugsKey, buckets: []}]),
|
||||
}),
|
||||
).rejects.toThrow('FLUXER_API_TRUSTED_CALLERS entry 1 buckets must be a non-empty list of bucket names');
|
||||
});
|
||||
|
||||
test('fails at boot on a value that is not a JSON array', async () => {
|
||||
await expect(trustedCallersFromEnv({FLUXER_API_TRUSTED_CALLERS: '{"name":"bugs"}'})).rejects.toThrow(
|
||||
'FLUXER_API_TRUSTED_CALLERS must be a JSON array',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
|
||||
import type {APIConfig, BlueskyOAuthConfig, TrustedCallerConfig} from '@app/api/config/APIConfig';
|
||||
import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRateLimitConfig';
|
||||
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
@@ -99,6 +100,52 @@ function mapApnsApps(
|
||||
});
|
||||
}
|
||||
|
||||
const TRUSTED_CALLER_MIN_KEY_LENGTH = 32;
|
||||
|
||||
function parseTrustedCaller(entry: unknown, index: number): TrustedCallerConfig {
|
||||
const label = `FLUXER_API_TRUSTED_CALLERS entry ${index + 1}`;
|
||||
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
||||
throw new Error(`${label} must be a JSON object`);
|
||||
}
|
||||
const name = Reflect.get(entry, 'name');
|
||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
||||
throw new Error(`${label} must have a name`);
|
||||
}
|
||||
const key = Reflect.get(entry, 'key');
|
||||
if (typeof key !== 'string' || key.trim().length < TRUSTED_CALLER_MIN_KEY_LENGTH) {
|
||||
throw new Error(`${label} key must be at least ${TRUSTED_CALLER_MIN_KEY_LENGTH} characters`);
|
||||
}
|
||||
const buckets = Reflect.get(entry, 'buckets');
|
||||
if (
|
||||
!Array.isArray(buckets) ||
|
||||
buckets.length === 0 ||
|
||||
!buckets.every((bucket) => typeof bucket === 'string' && bucket.trim().length > 0)
|
||||
) {
|
||||
throw new Error(`${label} buckets must be a non-empty list of bucket names`);
|
||||
}
|
||||
return {
|
||||
name: name.trim(),
|
||||
key: key.trim(),
|
||||
buckets: buckets.map((bucket: string) => bucket.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrustedCallers(master: MasterConfig): Array<TrustedCallerConfig> {
|
||||
const trustedCallers = (master.services.api.trusted_callers ?? []).map(parseTrustedCaller);
|
||||
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
|
||||
if (donationProxyKey.length > 0 && donationProxyKey.length < TRUSTED_CALLER_MIN_KEY_LENGTH) {
|
||||
throw new Error(`FLUXER_API_DONATION_PROXY_KEY must be at least ${TRUSTED_CALLER_MIN_KEY_LENGTH} characters`);
|
||||
}
|
||||
if (donationProxyKey.length > 0) {
|
||||
trustedCallers.push({
|
||||
name: 'donation',
|
||||
key: donationProxyKey,
|
||||
buckets: Object.values(DonationRateLimitConfigs).map((routeConfig) => routeConfig.bucket),
|
||||
});
|
||||
}
|
||||
return trustedCallers;
|
||||
}
|
||||
|
||||
export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
if (!master.internal) {
|
||||
throw new Error('internal configuration is required for the API');
|
||||
@@ -118,10 +165,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
|
||||
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
|
||||
}
|
||||
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
|
||||
if (donationProxyKey.length > 0 && donationProxyKey.length < 32) {
|
||||
throw new Error('FLUXER_API_DONATION_PROXY_KEY must be at least 32 characters');
|
||||
}
|
||||
const trustedCallers = buildTrustedCallers(master);
|
||||
if (!s3Config) {
|
||||
throw new Error('S3 configuration is required for the API');
|
||||
}
|
||||
@@ -227,7 +271,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
},
|
||||
internal: {
|
||||
gatewayRpcAuthToken: master.services.gateway.rpc_auth_token ?? '',
|
||||
donationProxyKey,
|
||||
trustedCallers,
|
||||
},
|
||||
hosts: {
|
||||
marketing: extractHostname(master.endpoints.marketing),
|
||||
|
||||
@@ -90,6 +90,10 @@ export class MessageMentionService {
|
||||
const roleMentions = allMentions.roles;
|
||||
const channelMentions = allMentions.channels;
|
||||
const isDMChannel = channelType === ChannelTypes.DM || channelType === ChannelTypes.DM_PERSONAL_NOTES;
|
||||
if (isDMChannel) {
|
||||
mentionsEveryone = false;
|
||||
mentionsHere = false;
|
||||
}
|
||||
const shouldAddReferencedUser =
|
||||
referencedMessage?.authorId &&
|
||||
referencedMessage.authorId !== message.authorId &&
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGroupDmChannel,
|
||||
sendChannelMessage,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('DM everyone mention', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('does not mention everyone in a one-to-one DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
await createFriendship(harness, user1, user2);
|
||||
const dm = await createDmChannel(harness, user1.token, user2.userId);
|
||||
const everyone = await sendChannelMessage(harness, user1.token, dm.id, '@everyone test');
|
||||
const here = await sendChannelMessage(harness, user1.token, dm.id, '@here test');
|
||||
expect(everyone.mention_everyone).toBe(false);
|
||||
expect(here.mention_everyone).toBe(false);
|
||||
});
|
||||
it('still mentions everyone in a group DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const user3 = await createTestAccount(harness);
|
||||
await createFriendship(harness, user1, user2);
|
||||
await createFriendship(harness, user1, user3);
|
||||
const groupDm = await createGroupDmChannel(harness, user1.token, [user2.userId, user3.userId]);
|
||||
const message = await sendChannelMessage(harness, user1.token, groupDm.id, '@everyone test');
|
||||
expect(message.mention_everyone).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,12 @@ export interface AppStoreAppConfig {
|
||||
appAppleId: number;
|
||||
}
|
||||
|
||||
export interface TrustedCallerConfig {
|
||||
name: string;
|
||||
key: string;
|
||||
buckets: Array<string>;
|
||||
}
|
||||
|
||||
export interface APICachePurgeConfig {
|
||||
adapter: CachePurgeAdapterName;
|
||||
http: {
|
||||
@@ -129,7 +135,7 @@ export interface APIConfig {
|
||||
};
|
||||
internal: {
|
||||
gatewayRpcAuthToken: string;
|
||||
donationProxyKey: string;
|
||||
trustedCallers: Array<TrustedCallerConfig>;
|
||||
};
|
||||
hosts: {
|
||||
marketing: string;
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface RouteRateLimitConfig {
|
||||
bucket: string;
|
||||
config: BucketConfig;
|
||||
scope?: RateLimitScope;
|
||||
trustDonorIpHeader?: boolean;
|
||||
trustForwardedClientIp?: boolean;
|
||||
emailBucket?: {
|
||||
bucket: string;
|
||||
config: BucketConfig;
|
||||
@@ -29,7 +29,8 @@ export interface RouteRateLimitConfig {
|
||||
const TEST_ENABLE_RATE_LIMITS_HEADER = 'x-fluxer-test-enable-rate-limits';
|
||||
const TEST_GLOBAL_RATE_LIMIT_OVERRIDE_HEADER = 'x-fluxer-test-global-rate-limit';
|
||||
const INTERNAL_KEY_HEADER = 'x-fluxer-internal-key';
|
||||
const DONOR_IP_HEADER = 'x-fluxer-donor-ip';
|
||||
const FORWARDED_CLIENT_IP_HEADER = 'x-fluxer-client-ip';
|
||||
const LEGACY_FORWARDED_CLIENT_IP_HEADER = 'x-fluxer-donor-ip';
|
||||
|
||||
function shouldEnforceRateLimits(ctx: Context<HonoEnv>): boolean {
|
||||
if (!Config.dev.testModeEnabled) {
|
||||
@@ -57,24 +58,32 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
|
||||
return accountType === 'bot' || accountType === 'webhook';
|
||||
}
|
||||
|
||||
function isTrustedInternalCaller(ctx: Context<HonoEnv>): boolean {
|
||||
const expectedKey = Config.internal.donationProxyKey;
|
||||
if (!expectedKey) return false;
|
||||
const providedKey = ctx.req.header(INTERNAL_KEY_HEADER);
|
||||
if (!providedKey) return false;
|
||||
function keysMatch(expectedKey: string, providedKey: string): boolean {
|
||||
const expectedBuffer = Buffer.from(expectedKey);
|
||||
const providedBuffer = Buffer.from(providedKey);
|
||||
if (expectedBuffer.length !== providedBuffer.length) return false;
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
function getForwardedDonorIdentifier(ctx: Context<HonoEnv>): string | null {
|
||||
if (!isTrustedInternalCaller(ctx)) return null;
|
||||
const headerValue = ctx.req.header(DONOR_IP_HEADER)?.split(',', 1)[0].trim();
|
||||
function isTrustedCallerForBucket(ctx: Context<HonoEnv>, bucket: string): boolean {
|
||||
const providedKey = ctx.req.header(INTERNAL_KEY_HEADER);
|
||||
if (!providedKey) return false;
|
||||
let trusted = false;
|
||||
for (const caller of Config.internal.trustedCallers) {
|
||||
if (!caller.buckets.includes(bucket)) continue;
|
||||
if (keysMatch(caller.key, providedKey)) trusted = true;
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
|
||||
function getForwardedClientIdentifier(ctx: Context<HonoEnv>, bucket: string): string | null {
|
||||
if (!isTrustedCallerForBucket(ctx, bucket)) return null;
|
||||
const rawHeader = ctx.req.header(FORWARDED_CLIENT_IP_HEADER) ?? ctx.req.header(LEGACY_FORWARDED_CLIENT_IP_HEADER);
|
||||
const headerValue = rawHeader?.split(',', 1)[0].trim();
|
||||
if (!headerValue) return null;
|
||||
const donorIp = parseIpAddress(headerValue);
|
||||
if (!donorIp) return null;
|
||||
return `ip:${getSameIpDecisionKey(donorIp.normalized) ?? donorIp.normalized}`;
|
||||
const clientIp = parseIpAddress(headerValue);
|
||||
if (!clientIp) return null;
|
||||
return `ip:${getSameIpDecisionKey(clientIp.normalized) ?? clientIp.normalized}`;
|
||||
}
|
||||
|
||||
function getClientIdentifier(ctx: Context<HonoEnv>, routeConfig: RouteRateLimitConfig): string {
|
||||
@@ -86,9 +95,9 @@ function getClientIdentifier(ctx: Context<HonoEnv>, routeConfig: RouteRateLimitC
|
||||
}
|
||||
return `user:${user.id}:${tokenType}`;
|
||||
}
|
||||
if (routeConfig.trustDonorIpHeader) {
|
||||
const donorIdentifier = getForwardedDonorIdentifier(ctx);
|
||||
if (donorIdentifier) return donorIdentifier;
|
||||
if (routeConfig.trustForwardedClientIp) {
|
||||
const forwardedIdentifier = getForwardedClientIdentifier(ctx, routeConfig.bucket);
|
||||
if (forwardedIdentifier) return forwardedIdentifier;
|
||||
}
|
||||
const ip = getRequestClientIp(ctx);
|
||||
if (!ip) return 'internal';
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {TrustedCallerConfig} from '@app/api/config/APIConfig';
|
||||
import {RateLimitMiddleware, type RouteRateLimitConfig} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRateLimitConfig';
|
||||
import {OAuthRateLimitConfigs} from '@app/api/rate_limit_configs/OAuthRateLimitConfig';
|
||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import type {
|
||||
BucketConfig,
|
||||
@@ -10,7 +14,7 @@ import type {
|
||||
RateLimitResult,
|
||||
} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import {type Context, Hono} from 'hono';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const CLIENT_IP = '203.0.113.10';
|
||||
const SWAPPED_CLIENT_IP = '198.51.100.7';
|
||||
@@ -146,3 +150,169 @@ describe('RateLimitMiddleware', () => {
|
||||
expect(harness.service.buckets).toEqual([`ip:${CLIENT_IP}:webhook:read:111`]);
|
||||
});
|
||||
});
|
||||
|
||||
const CALLER_IP = '192.0.2.50';
|
||||
const FORWARDED_IP = '203.0.113.77';
|
||||
const OTHER_FORWARDED_IP = '203.0.113.78';
|
||||
const BUGS_KEY = 'bugs-key-0123456789abcdefghijklmnopqrstuv';
|
||||
const DONATION_KEY = 'donation-key-0123456789abcdefghijklmnopq';
|
||||
|
||||
const TRUSTED_CALLERS: Array<TrustedCallerConfig> = [
|
||||
{name: 'bugs', key: BUGS_KEY, buckets: ['oauth:token', 'oauth:revoke']},
|
||||
{
|
||||
name: 'donation',
|
||||
key: DONATION_KEY,
|
||||
buckets: ['donation:request_link', 'donation:manage', 'donation:checkout'],
|
||||
},
|
||||
];
|
||||
|
||||
function buildTrustedHarness(routeConfig: RouteRateLimitConfig): Harness {
|
||||
const service = new RecordingRateLimitService();
|
||||
let context: Context<HonoEnv> | null = null;
|
||||
const app = new Hono<HonoEnv>({strict: true});
|
||||
app.use('*', async (ctx, next) => {
|
||||
context = ctx;
|
||||
ctx.set('rateLimitService', service);
|
||||
await next();
|
||||
});
|
||||
app.post('/route', RateLimitMiddleware(routeConfig), (ctx) => ctx.text('ok'));
|
||||
return {
|
||||
app,
|
||||
service,
|
||||
getContext(): Context<HonoEnv> {
|
||||
if (!context) {
|
||||
throw new Error('no request has run yet');
|
||||
}
|
||||
return context;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function callTrustedRoute(harness: Harness, headers: Record<string, string>): Promise<Response> {
|
||||
return await harness.app.request('http://localhost/route', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-forwarded-for': CALLER_IP,
|
||||
'x-fluxer-test-enable-rate-limits': 'true',
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('RateLimitMiddleware trusted callers', () => {
|
||||
let previousTrustedCallers: Array<TrustedCallerConfig>;
|
||||
|
||||
beforeEach(() => {
|
||||
previousTrustedCallers = Config.internal.trustedCallers;
|
||||
Config.internal.trustedCallers = TRUSTED_CALLERS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Config.internal.trustedCallers = previousTrustedCallers;
|
||||
});
|
||||
|
||||
test('keys oauth:token on the address the bugs caller forwards, one bucket per address', async () => {
|
||||
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_TOKEN);
|
||||
|
||||
const first = await callTrustedRoute(harness, {
|
||||
'x-fluxer-internal-key': BUGS_KEY,
|
||||
'x-fluxer-client-ip': FORWARDED_IP,
|
||||
});
|
||||
const second = await callTrustedRoute(harness, {
|
||||
'x-fluxer-internal-key': BUGS_KEY,
|
||||
'x-fluxer-client-ip': OTHER_FORWARDED_IP,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
expect(harness.service.globalIdentifiers).toEqual([`ip:${FORWARDED_IP}`, `ip:${OTHER_FORWARDED_IP}`]);
|
||||
expect(harness.service.buckets).toEqual([`ip:${FORWARDED_IP}:oauth:token`, `ip:${OTHER_FORWARDED_IP}:oauth:token`]);
|
||||
});
|
||||
|
||||
test('keys oauth:revoke on the address the bugs caller forwards', async () => {
|
||||
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_REVOKE);
|
||||
|
||||
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
|
||||
|
||||
expect(harness.service.buckets).toEqual([`ip:${FORWARDED_IP}:oauth:revoke`]);
|
||||
});
|
||||
|
||||
test('ignores the bugs key on a donation route', async () => {
|
||||
const harness = buildTrustedHarness(DonationRateLimitConfigs.DONATION_MANAGE);
|
||||
|
||||
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
|
||||
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-donor-ip': FORWARDED_IP});
|
||||
|
||||
expect(harness.service.globalIdentifiers).toEqual([`ip:${CALLER_IP}`, `ip:${CALLER_IP}`]);
|
||||
expect(harness.service.buckets).toEqual([`ip:${CALLER_IP}:donation:manage`, `ip:${CALLER_IP}:donation:manage`]);
|
||||
});
|
||||
|
||||
test('ignores the donation key on oauth:token', async () => {
|
||||
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_TOKEN);
|
||||
|
||||
await callTrustedRoute(harness, {'x-fluxer-internal-key': DONATION_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
|
||||
|
||||
expect(harness.service.buckets).toEqual([`ip:${CALLER_IP}:oauth:token`]);
|
||||
});
|
||||
|
||||
test('accepts the donation key on its routes with the new and the old address header', async () => {
|
||||
for (const routeConfig of Object.values(DonationRateLimitConfigs)) {
|
||||
const harness = buildTrustedHarness(routeConfig);
|
||||
|
||||
await callTrustedRoute(harness, {'x-fluxer-internal-key': DONATION_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
|
||||
await callTrustedRoute(harness, {
|
||||
'x-fluxer-internal-key': DONATION_KEY,
|
||||
'x-fluxer-donor-ip': OTHER_FORWARDED_IP,
|
||||
});
|
||||
|
||||
expect(harness.service.buckets).toEqual([
|
||||
`ip:${FORWARDED_IP}:${routeConfig.bucket}`,
|
||||
`ip:${OTHER_FORWARDED_IP}:${routeConfig.bucket}`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('prefers the new address header when both are sent', async () => {
|
||||
const harness = buildTrustedHarness(DonationRateLimitConfigs.DONATION_MANAGE);
|
||||
|
||||
await callTrustedRoute(harness, {
|
||||
'x-fluxer-internal-key': DONATION_KEY,
|
||||
'x-fluxer-client-ip': FORWARDED_IP,
|
||||
'x-fluxer-donor-ip': OTHER_FORWARDED_IP,
|
||||
});
|
||||
|
||||
expect(harness.service.buckets).toEqual([`ip:${FORWARDED_IP}:donation:manage`]);
|
||||
});
|
||||
|
||||
test('ignores a wrong key of the same or a different length, and a missing key', async () => {
|
||||
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_TOKEN);
|
||||
const sameLengthWrongKey = `${BUGS_KEY.slice(0, -1)}${BUGS_KEY.endsWith('v') ? 'w' : 'v'}`;
|
||||
|
||||
await callTrustedRoute(harness, {'x-fluxer-internal-key': sameLengthWrongKey, 'x-fluxer-client-ip': FORWARDED_IP});
|
||||
await callTrustedRoute(harness, {'x-fluxer-internal-key': `${BUGS_KEY}x`, 'x-fluxer-client-ip': FORWARDED_IP});
|
||||
await callTrustedRoute(harness, {'x-fluxer-client-ip': FORWARDED_IP});
|
||||
|
||||
expect(harness.service.buckets).toEqual([
|
||||
`ip:${CALLER_IP}:oauth:token`,
|
||||
`ip:${CALLER_IP}:oauth:token`,
|
||||
`ip:${CALLER_IP}:oauth:token`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to the caller when a trusted key sends an unparsable address', async () => {
|
||||
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_TOKEN);
|
||||
|
||||
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-client-ip': 'not-an-ip'});
|
||||
|
||||
expect(harness.service.buckets).toEqual([`ip:${CALLER_IP}:oauth:token`]);
|
||||
});
|
||||
|
||||
test('ignores a trusted key on a route that does not opt in, even when the bucket is listed', async () => {
|
||||
Config.internal.trustedCallers = [{name: 'wide', key: BUGS_KEY, buckets: ['oauth:introspect']}];
|
||||
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_INTROSPECT);
|
||||
|
||||
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
|
||||
|
||||
expect(harness.service.buckets).toEqual([`ip:${CALLER_IP}:oauth:introspect`]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ export function OAuth2Controller(app: HonoApp) {
|
||||
);
|
||||
app.post(
|
||||
'/oauth2/token/revoke',
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
|
||||
RateLimitMiddleware(RateLimitConfigs.OAUTH_REVOKE),
|
||||
Validator('form', RevokeRequestForm),
|
||||
OpenAPI({
|
||||
operationId: 'revoke_oauth2_token',
|
||||
|
||||
@@ -13398,115 +13398,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/stripe/checkout/subscription/preapproval": {
|
||||
"post": {
|
||||
"operationId": "create_localized_card_preapproval_session",
|
||||
"summary": "Create localized card preapproval session",
|
||||
"tags": ["Billing"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UrlResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Initiates a Stripe Checkout setup-mode session to preapprove a local card before continuing to paid localized checkout.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateCheckoutSessionRequest"}}}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/stripe/checkout/subscription/preapproval/continue": {
|
||||
"post": {
|
||||
"operationId": "continue_localized_card_preapproval_session",
|
||||
"summary": "Continue localized card preapproval session",
|
||||
"tags": ["Billing"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/LocalizedCardPreapprovalContinueResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Checks the status of a localized card preapproval flow and returns the paid Stripe Checkout URL when it is ready.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/LocalizedCardPreapprovalContinueRequest"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/stripe/webhook": {
|
||||
"post": {
|
||||
"operationId": "process_stripe_webhook",
|
||||
@@ -25771,21 +25662,6 @@
|
||||
"required": ["received"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"LocalizedCardPreapprovalContinueRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {"description": "Continuation token for the localized card preapproval flow", "type": "string"}
|
||||
},
|
||||
"required": ["token"]
|
||||
},
|
||||
"LocalizedCardPreapprovalContinueResponse": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/components/schemas/PendingLocalizedCardPreapprovalContinueResponse"},
|
||||
{"$ref": "#/components/schemas/ReadyLocalizedCardPreapprovalContinueResponse"},
|
||||
{"$ref": "#/components/schemas/RejectedLocalizedCardPreapprovalContinueResponse"},
|
||||
{"$ref": "#/components/schemas/ExpiredLocalizedCardPreapprovalContinueResponse"}
|
||||
]
|
||||
},
|
||||
"CreateCheckoutSessionRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -35251,71 +35127,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"CheckoutPaymentMethodEnum": {"type": "string", "enum": ["card", "pix", "upi"]},
|
||||
"ExpiredLocalizedCardPreapprovalContinueResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"const": "expired",
|
||||
"description": "The preapproval token has expired or is unknown"
|
||||
}
|
||||
},
|
||||
"required": ["status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RejectedLocalizedCardPreapprovalContinueResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"const": "rejected",
|
||||
"description": "The preapproval failed and the paid checkout should not continue"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"country_mismatch",
|
||||
"missing_customer",
|
||||
"missing_payment_method",
|
||||
"missing_setup_intent",
|
||||
"payment_method_not_card",
|
||||
"unknown"
|
||||
],
|
||||
"description": "The reason the preapproval was rejected"
|
||||
},
|
||||
"actual_country": {
|
||||
"description": "The detected card issuing country when available",
|
||||
"anyOf": [{"type": "string", "minLength": 2, "maxLength": 2}, {"type": "null"}]
|
||||
}
|
||||
},
|
||||
"required": ["status", "reason"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ReadyLocalizedCardPreapprovalContinueResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"const": "ready",
|
||||
"description": "The preapproval succeeded and the paid checkout URL is ready"
|
||||
},
|
||||
"url": {"type": "string", "description": "The URL to redirect to"}
|
||||
},
|
||||
"required": ["status", "url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PendingLocalizedCardPreapprovalContinueResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"const": "pending",
|
||||
"description": "The preapproval result is still being processed"
|
||||
}
|
||||
},
|
||||
"required": ["status"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"GifCategoryTagResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const DonationRateLimitConfigs = {
|
||||
DONATION_REQUEST_LINK: {
|
||||
bucket: 'donation:request_link',
|
||||
config: {limit: 3, windowMs: ms('1 hour')},
|
||||
trustDonorIpHeader: true,
|
||||
trustForwardedClientIp: true,
|
||||
emailBucket: {
|
||||
bucket: 'donation:request_link:email',
|
||||
config: {limit: 10, windowMs: ms('1 hour')},
|
||||
@@ -16,12 +16,12 @@ export const DonationRateLimitConfigs = {
|
||||
DONATION_MANAGE: {
|
||||
bucket: 'donation:manage',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
trustDonorIpHeader: true,
|
||||
trustForwardedClientIp: true,
|
||||
} as RouteRateLimitConfig,
|
||||
DONATION_CHECKOUT: {
|
||||
bucket: 'donation:checkout',
|
||||
config: {limit: 5, windowMs: ms('1 minute')},
|
||||
trustDonorIpHeader: true,
|
||||
trustForwardedClientIp: true,
|
||||
emailBucket: {
|
||||
bucket: 'donation:checkout:email',
|
||||
config: {limit: 10, windowMs: ms('1 hour')},
|
||||
|
||||
@@ -36,14 +36,6 @@ export const IntegrationRateLimitConfigs = {
|
||||
bucket: 'stripe:checkout:subscription',
|
||||
config: {limit: 3, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL: {
|
||||
bucket: 'stripe:checkout:subscription:preapproval',
|
||||
config: {limit: 5, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL_CONTINUE: {
|
||||
bucket: 'stripe:checkout:subscription:preapproval:continue',
|
||||
config: {limit: 30, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
STRIPE_CHECKOUT_GIFT: {
|
||||
bucket: 'stripe:checkout:gift',
|
||||
config: {limit: 3, windowMs: ms('1 minute')},
|
||||
|
||||
@@ -15,6 +15,7 @@ export const OAuthRateLimitConfigs = {
|
||||
OAUTH_TOKEN: {
|
||||
bucket: 'oauth:token',
|
||||
config: {limit: 120, windowMs: ms('1 minute')},
|
||||
trustForwardedClientIp: true,
|
||||
} as RouteRateLimitConfig,
|
||||
OAUTH_INTROSPECT: {
|
||||
bucket: 'oauth:introspect',
|
||||
@@ -23,6 +24,7 @@ export const OAuthRateLimitConfigs = {
|
||||
OAUTH_REVOKE: {
|
||||
bucket: 'oauth:revoke',
|
||||
config: {limit: 120, windowMs: ms('1 minute')},
|
||||
trustForwardedClientIp: true,
|
||||
} as RouteRateLimitConfig,
|
||||
OAUTH_DEV_CLIENTS_LIST: {
|
||||
bucket: 'oauth_dev:clients:list',
|
||||
|
||||
@@ -17,6 +17,16 @@ export interface MeilisearchTask {
|
||||
};
|
||||
}
|
||||
|
||||
export class MeilisearchTaskError extends Error {
|
||||
readonly code: string | undefined;
|
||||
|
||||
constructor(message: string, code: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'MeilisearchTaskError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MeilisearchClient {
|
||||
request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse>;
|
||||
waitForTask(taskUid: number): Promise<void>;
|
||||
@@ -84,7 +94,10 @@ export class MeilisearchHttpClient implements MeilisearchClient {
|
||||
return;
|
||||
}
|
||||
if (task.status === 'failed' || task.status === 'canceled') {
|
||||
throw new Error(task.error?.message ?? `Meilisearch task ${taskUid} ${task.status}`);
|
||||
throw new MeilisearchTaskError(
|
||||
task.error?.message ?? `Meilisearch task ${taskUid} ${task.status}`,
|
||||
task.error?.code,
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, TASK_POLL_INTERVAL_MS));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MeilisearchClient, MeilisearchTask} from '@app/api/search/meilisearch/MeilisearchClient';
|
||||
import {MeilisearchTaskError} from '@app/api/search/meilisearch/MeilisearchClient';
|
||||
import {MeilisearchMessageAdapter} from '@app/api/search/meilisearch/MeilisearchDomainAdapters';
|
||||
import {MEILISEARCH_MAX_TRACKED_BULK_TASKS} from '@app/api/search/meilisearch/MeilisearchIndexAdapter';
|
||||
import type {SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
@@ -15,6 +16,7 @@ interface RecordedMeilisearchRequest {
|
||||
class FakeMeilisearchClient implements MeilisearchClient {
|
||||
readonly requests: Array<RecordedMeilisearchRequest> = [];
|
||||
readonly waitedTaskUids: Array<number> = [];
|
||||
readonly failedTasks = new Map<number, MeilisearchTaskError>();
|
||||
private nextTaskUid = 1;
|
||||
indexExists = false;
|
||||
|
||||
@@ -50,6 +52,10 @@ class FakeMeilisearchClient implements MeilisearchClient {
|
||||
|
||||
async waitForTask(taskUid: number): Promise<void> {
|
||||
this.waitedTaskUids.push(taskUid);
|
||||
const failure = this.failedTasks.get(taskUid);
|
||||
if (failure) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -87,6 +93,26 @@ describe('MeilisearchMessageAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an index created concurrently by another process as created', async () => {
|
||||
const client = new FakeMeilisearchClient();
|
||||
client.failedTasks.set(1, new MeilisearchTaskError('Index `messages` already exists.', 'index_already_exists'));
|
||||
const adapter = new MeilisearchMessageAdapter({client});
|
||||
|
||||
await adapter.initialize();
|
||||
|
||||
expect(adapter.isAvailable()).toBe(true);
|
||||
expect(client.waitedTaskUids).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('still fails when creating the index fails for another reason', async () => {
|
||||
const client = new FakeMeilisearchClient();
|
||||
client.failedTasks.set(1, new MeilisearchTaskError('Index uid is invalid.', 'invalid_index_uid'));
|
||||
const adapter = new MeilisearchMessageAdapter({client});
|
||||
|
||||
await expect(adapter.initialize()).rejects.toThrow('Index uid is invalid.');
|
||||
expect(adapter.isAvailable()).toBe(false);
|
||||
});
|
||||
|
||||
it('builds Meilisearch search requests from message filters', async () => {
|
||||
const client = new FakeMeilisearchClient();
|
||||
client.indexExists = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MeilisearchClient, MeilisearchTask} from '@app/api/search/meilisearch/MeilisearchClient';
|
||||
import {MeilisearchTaskError} from '@app/api/search/meilisearch/MeilisearchClient';
|
||||
import type {MeilisearchFilter} from '@app/api/search/meilisearch/MeilisearchFilterUtils';
|
||||
import {joinMeiliFilters} from '@app/api/search/meilisearch/MeilisearchFilterUtils';
|
||||
import type {MeilisearchIndexDefinition} from '@app/api/search/meilisearch/MeilisearchIndexDefinitions';
|
||||
@@ -58,7 +59,13 @@ export class MeilisearchIndexAdapter<
|
||||
uid,
|
||||
primaryKey: this.indexDefinition.primaryKey,
|
||||
});
|
||||
await this.client.waitForTask(task.taskUid);
|
||||
try {
|
||||
await this.client.waitForTask(task.taskUid);
|
||||
} catch (error) {
|
||||
if (!(error instanceof MeilisearchTaskError && error.code === 'index_already_exists')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
this.applySetting('PUT', 'searchable-attributes', this.indexDefinition.searchableAttributes),
|
||||
|
||||
@@ -29,8 +29,6 @@ import {
|
||||
import {
|
||||
ChangeSubscriptionRequest,
|
||||
CurrentSubscriptionPriceResponse,
|
||||
LocalizedCardPreapprovalContinueRequest,
|
||||
LocalizedCardPreapprovalContinueResponse,
|
||||
PriceIdsQueryRequest,
|
||||
PriceIdsResponse,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
@@ -140,60 +138,6 @@ export function StripeController(app: HonoApp) {
|
||||
return ctx.json({url: checkoutUrl});
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/stripe/checkout/subscription/preapproval',
|
||||
BillingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'create_localized_card_preapproval_session',
|
||||
summary: 'Create localized card preapproval session',
|
||||
description:
|
||||
'Initiates a Stripe Checkout setup-mode session to preapprove a local card before continuing to paid localized checkout.',
|
||||
responseSchema: UrlResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
tags: 'Billing',
|
||||
}),
|
||||
Validator('json', CreateCheckoutSessionRequest),
|
||||
async (ctx) => {
|
||||
const {price_id, country_code, client_geoip_country_code, eu_withdrawal_waiver_accepted, is_business} =
|
||||
ctx.req.valid('json');
|
||||
const userId = ctx.get('user').id;
|
||||
const checkoutUrl = await ctx.get('stripeService').createLocalizedCardPreapprovalSession({
|
||||
userId,
|
||||
priceId: price_id,
|
||||
countryCode: country_code,
|
||||
clientGeoipCountryCode: client_geoip_country_code,
|
||||
purchaseGeoipCountryCode: await getPurchaseGeoipCountryCode(ctx.req.raw),
|
||||
euWithdrawalWaiverAccepted: eu_withdrawal_waiver_accepted,
|
||||
isBusiness: is_business,
|
||||
});
|
||||
return ctx.json({url: checkoutUrl});
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/stripe/checkout/subscription/preapproval/continue',
|
||||
BillingRouteAvailable,
|
||||
RateLimitMiddleware(RateLimitConfigs.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL_CONTINUE),
|
||||
OpenAPI({
|
||||
operationId: 'continue_localized_card_preapproval_session',
|
||||
summary: 'Continue localized card preapproval session',
|
||||
description:
|
||||
'Checks the status of a localized card preapproval flow and returns the paid Stripe Checkout URL when it is ready.',
|
||||
responseSchema: LocalizedCardPreapprovalContinueResponse,
|
||||
statusCode: 200,
|
||||
security: [],
|
||||
tags: 'Billing',
|
||||
}),
|
||||
Validator('json', LocalizedCardPreapprovalContinueRequest),
|
||||
async (ctx) => {
|
||||
const {token} = ctx.req.valid('json');
|
||||
const result = await ctx.get('stripeService').continueLocalizedCardPreapproval(token);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/stripe/checkout/gift',
|
||||
BillingRouteAvailable,
|
||||
|
||||
@@ -14,10 +14,7 @@ import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlem
|
||||
import {getProductRegistry, type ProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import {getStripeClient} from '@app/api/stripe/StripeClient';
|
||||
import {PremiumStateService} from '@app/api/stripe/services/PremiumStateService';
|
||||
import type {
|
||||
ContinueLocalizedCardPreapprovalResult,
|
||||
CreateCheckoutSessionParams,
|
||||
} from '@app/api/stripe/services/StripeCheckoutService';
|
||||
import type {CreateCheckoutSessionParams} from '@app/api/stripe/services/StripeCheckoutService';
|
||||
import {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
|
||||
import {StripeGiftService} from '@app/api/stripe/services/StripeGiftService';
|
||||
import {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
|
||||
@@ -139,25 +136,6 @@ export class StripeService {
|
||||
return `${Config.endpoints.webApp}/premium-callback?status=success`;
|
||||
}
|
||||
|
||||
async createLocalizedCardPreapprovalSession(
|
||||
params: Pick<
|
||||
CreateCheckoutSessionParams,
|
||||
| 'clientGeoipCountryCode'
|
||||
| 'countryCode'
|
||||
| 'euWithdrawalWaiverAccepted'
|
||||
| 'isBusiness'
|
||||
| 'priceId'
|
||||
| 'purchaseGeoipCountryCode'
|
||||
| 'userId'
|
||||
>,
|
||||
): Promise<string> {
|
||||
return this.checkoutService.createLocalizedCardPreapprovalSession(params);
|
||||
}
|
||||
|
||||
async continueLocalizedCardPreapproval(token: string): Promise<ContinueLocalizedCardPreapprovalResult> {
|
||||
return this.checkoutService.continueLocalizedCardPreapproval(token);
|
||||
}
|
||||
|
||||
async createCustomerPortalSession(userId: UserID): Promise<string> {
|
||||
return this.checkoutService.createCustomerPortalSession(userId);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {getContentMessage} from '@app/api/content_i18n/ContentI18n';
|
||||
import type {UserRow} from '@app/api/database/types/UserTypes';
|
||||
@@ -95,6 +94,10 @@ export interface CreateCheckoutSessionParams {
|
||||
|
||||
const PIX_UPI_MANDATE_HEADROOM_MULTIPLIER = 1.25;
|
||||
|
||||
const LOCAL_PAYMENT_METHOD_BY_CURRENCY: Partial<Record<Currency, CheckoutPaymentMethod>> = {
|
||||
BRL: 'pix',
|
||||
};
|
||||
|
||||
interface ResolvedPriceIds {
|
||||
monthly: string | null;
|
||||
yearly: string | null;
|
||||
@@ -118,51 +121,6 @@ interface EuWithdrawalWaiverContext {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
type LocalizedCardPreapprovalStatus = 'approved' | 'checkout_created' | 'pending' | 'rejected';
|
||||
type LocalizedCardPreapprovalRejectedReason =
|
||||
| 'country_mismatch'
|
||||
| 'missing_customer'
|
||||
| 'missing_payment_method'
|
||||
| 'missing_setup_intent'
|
||||
| 'payment_method_not_card'
|
||||
| 'unknown';
|
||||
|
||||
interface LocalizedCardPreapprovalFlowState {
|
||||
actualCardCountry: string | null;
|
||||
approvedPaymentMethodId: string | null;
|
||||
clientGeoipCountryCode: string | null;
|
||||
countryCode: string;
|
||||
customerId: string;
|
||||
currency: Currency;
|
||||
euWithdrawalWaiverAccepted: boolean;
|
||||
finalCheckoutUrl: string | null;
|
||||
isBusiness: boolean;
|
||||
preapprovalSessionId: string;
|
||||
purchaseGeoipCountryCode: string | null;
|
||||
priceId: string;
|
||||
rejectionReason: LocalizedCardPreapprovalRejectedReason | null;
|
||||
status: LocalizedCardPreapprovalStatus;
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export type ContinueLocalizedCardPreapprovalResult =
|
||||
| {
|
||||
status: 'expired';
|
||||
}
|
||||
| {
|
||||
status: 'pending';
|
||||
}
|
||||
| {
|
||||
status: 'ready';
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
status: 'rejected';
|
||||
reason: LocalizedCardPreapprovalRejectedReason;
|
||||
actual_country?: string | null;
|
||||
};
|
||||
|
||||
export class StripeCheckoutService {
|
||||
constructor(
|
||||
private stripe: Stripe | null,
|
||||
@@ -192,7 +150,15 @@ export class StripeCheckoutService {
|
||||
});
|
||||
const isRecurringSubscription = this.productRegistry.isRecurringSubscription(productInfo);
|
||||
const checkoutMode: CheckoutSessionMode = isRecurringSubscription ? 'subscription' : 'payment';
|
||||
this.assertPaymentMethodCompatibility({paymentMethod, productInfo, isGift, userId, priceId});
|
||||
const effectivePaymentMethod =
|
||||
this.resolveRequiredLocalPaymentMethod({productInfo, isGift, isRecurringSubscription}) ?? paymentMethod;
|
||||
this.assertPaymentMethodCompatibility({
|
||||
paymentMethod: effectivePaymentMethod,
|
||||
productInfo,
|
||||
isGift,
|
||||
userId,
|
||||
priceId,
|
||||
});
|
||||
const waiverContext = this.resolveEuWithdrawalWaiverContext({
|
||||
countryCode,
|
||||
clientGeoipCountryCode,
|
||||
@@ -202,10 +168,10 @@ export class StripeCheckoutService {
|
||||
const paymentMethodOptions = await this.buildPaymentMethodOptions({
|
||||
productInfo,
|
||||
checkoutMode,
|
||||
paymentMethod,
|
||||
paymentMethod: effectivePaymentMethod,
|
||||
priceId,
|
||||
});
|
||||
const paymentMethodTypes = this.resolvePaymentMethodTypes(paymentMethod);
|
||||
const paymentMethodTypes = this.resolvePaymentMethodTypes(effectivePaymentMethod);
|
||||
const branding = await getBillingBranding();
|
||||
const billing = getEffectiveBillingConfig();
|
||||
const checkoutMetadata = {
|
||||
@@ -220,7 +186,7 @@ export class StripeCheckoutService {
|
||||
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
|
||||
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
|
||||
eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION,
|
||||
payment_method: paymentMethod,
|
||||
payment_method: effectivePaymentMethod,
|
||||
};
|
||||
const checkoutParams: CheckoutSessionCreateParams = {
|
||||
customer: customerId,
|
||||
@@ -296,256 +262,6 @@ export class StripeCheckoutService {
|
||||
});
|
||||
}
|
||||
|
||||
async createLocalizedCardPreapprovalSession({
|
||||
userId,
|
||||
priceId,
|
||||
countryCode,
|
||||
clientGeoipCountryCode,
|
||||
purchaseGeoipCountryCode,
|
||||
euWithdrawalWaiverAccepted,
|
||||
isBusiness = false,
|
||||
}: Pick<
|
||||
CreateCheckoutSessionParams,
|
||||
| 'clientGeoipCountryCode'
|
||||
| 'countryCode'
|
||||
| 'euWithdrawalWaiverAccepted'
|
||||
| 'isBusiness'
|
||||
| 'priceId'
|
||||
| 'purchaseGeoipCountryCode'
|
||||
| 'userId'
|
||||
>): Promise<string> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
}
|
||||
const normalizedCountryCode = this.resolveEnforcedPricingCountryCode({countryCode, purchaseGeoipCountryCode});
|
||||
if (!normalizedCountryCode) {
|
||||
Logger.error({priceId, userId}, 'Localized card preapproval requires a country code');
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
const {customerId, productInfo} = await this.prepareCheckoutContext({
|
||||
userId,
|
||||
priceId,
|
||||
isGift: false,
|
||||
countryCode: normalizedCountryCode,
|
||||
});
|
||||
if (!this.requiresLocalizedCardPreapproval(productInfo)) {
|
||||
Logger.error(
|
||||
{priceId, userId, currency: productInfo.currency, countryCode: normalizedCountryCode},
|
||||
'Localized card preapproval requested for non-localized recurring price',
|
||||
);
|
||||
throw new StripeInvalidProductConfigurationError();
|
||||
}
|
||||
const waiverContext = this.resolveEuWithdrawalWaiverContext({
|
||||
countryCode: normalizedCountryCode,
|
||||
clientGeoipCountryCode,
|
||||
purchaseGeoipCountryCode,
|
||||
euWithdrawalWaiverAccepted,
|
||||
});
|
||||
const token = randomUUID();
|
||||
const checkoutParams: CheckoutSessionCreateParams = {
|
||||
customer: customerId,
|
||||
client_reference_id: userId.toString(),
|
||||
metadata: {
|
||||
user_id: userId.toString(),
|
||||
price_id: priceId,
|
||||
product_type: productInfo.type,
|
||||
country_code: normalizedCountryCode,
|
||||
...(purchaseGeoipCountryCode ? {purchase_geoip_country_code: purchaseGeoipCountryCode.toUpperCase()} : {}),
|
||||
...(clientGeoipCountryCode ? {purchase_client_country_code: clientGeoipCountryCode.toUpperCase()} : {}),
|
||||
eu_withdrawal_waiver_required: waiverContext.required ? 'true' : 'false',
|
||||
eu_withdrawal_waiver_accepted: waiverContext.accepted ? 'true' : 'false',
|
||||
...(waiverContext.acceptedAt ? {eu_withdrawal_waiver_accepted_at: waiverContext.acceptedAt.toISOString()} : {}),
|
||||
...(waiverContext.required ? {eu_withdrawal_waiver_text_version: EU_WITHDRAWAL_WAIVER_TEXT_VERSION} : {}),
|
||||
setup_type: 'localized_card_preapproval',
|
||||
localized_card_preapproval_currency: productInfo.currency,
|
||||
localized_card_preapproval_token: token,
|
||||
is_business: isBusiness ? 'true' : 'false',
|
||||
},
|
||||
mode: 'setup',
|
||||
payment_method_types: ['card'],
|
||||
success_url: `${Config.endpoints.webApp}/premium-callback?status=preapproval-success&token=${encodeURIComponent(token)}`,
|
||||
cancel_url: `${Config.endpoints.webApp}/premium-callback?status=preapproval-cancel`,
|
||||
tax_id_collection: {
|
||||
enabled: getEffectiveBillingConfig().taxIdCollection,
|
||||
},
|
||||
billing_address_collection: isBusiness ? 'required' : 'auto',
|
||||
customer_update: {
|
||||
address: 'auto',
|
||||
name: 'auto',
|
||||
},
|
||||
};
|
||||
try {
|
||||
const session = await this.stripe.checkout.sessions.create(checkoutParams);
|
||||
try {
|
||||
await getBillingRepository().checkoutSessions.upsertFromStripe(session, {knownUserId: userId});
|
||||
} catch (mirrorErr) {
|
||||
Logger.error(
|
||||
{mirrorErr, sessionId: session.id},
|
||||
'Mirror upsert failed after Stripe write; reconciler will heal',
|
||||
);
|
||||
}
|
||||
if (!session.url) {
|
||||
Logger.error({userId, sessionId: session.id}, 'Stripe localized card preapproval session missing url');
|
||||
throw new StripeError('Stripe localized card preapproval session missing url');
|
||||
}
|
||||
await this.setLocalizedCardPreapprovalFlow(token, {
|
||||
actualCardCountry: null,
|
||||
approvedPaymentMethodId: null,
|
||||
clientGeoipCountryCode: this.normalizeCountryCode(clientGeoipCountryCode),
|
||||
countryCode: normalizedCountryCode,
|
||||
customerId,
|
||||
currency: productInfo.currency,
|
||||
euWithdrawalWaiverAccepted: waiverContext.accepted,
|
||||
finalCheckoutUrl: null,
|
||||
isBusiness,
|
||||
preapprovalSessionId: session.id,
|
||||
purchaseGeoipCountryCode: this.normalizeCountryCode(purchaseGeoipCountryCode),
|
||||
priceId,
|
||||
rejectionReason: null,
|
||||
status: 'pending',
|
||||
token,
|
||||
userId: userId.toString(),
|
||||
});
|
||||
Logger.debug(
|
||||
{userId, sessionId: session.id, countryCode: normalizedCountryCode},
|
||||
'Localized card preapproval session created',
|
||||
);
|
||||
return session.url;
|
||||
} catch (error: unknown) {
|
||||
Logger.error(
|
||||
{error, userId, countryCode: normalizedCountryCode},
|
||||
'Failed to create localized card preapproval session',
|
||||
);
|
||||
const message = error instanceof Error ? error.message : 'Failed to create localized card preapproval session';
|
||||
throw new StripeError(message);
|
||||
}
|
||||
}
|
||||
|
||||
async continueLocalizedCardPreapproval(token: string): Promise<ContinueLocalizedCardPreapprovalResult> {
|
||||
const normalizedToken = token.trim();
|
||||
if (!normalizedToken) {
|
||||
return {status: 'expired'};
|
||||
}
|
||||
const flowState = await this.getLocalizedCardPreapprovalFlow(normalizedToken);
|
||||
if (!flowState) {
|
||||
return {status: 'expired'};
|
||||
}
|
||||
if (flowState.finalCheckoutUrl) {
|
||||
return {status: 'ready', url: flowState.finalCheckoutUrl};
|
||||
}
|
||||
if (flowState.status === 'pending') {
|
||||
return {status: 'pending'};
|
||||
}
|
||||
if (flowState.status === 'rejected') {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: flowState.rejectionReason ?? 'unknown',
|
||||
actual_country: flowState.actualCardCountry,
|
||||
};
|
||||
}
|
||||
const lockKey = this.getLocalizedCardPreapprovalContinueLockKey(normalizedToken);
|
||||
const lockToken = await this.cacheService.acquireLock(
|
||||
lockKey,
|
||||
StripeCheckoutService.LOCALIZED_CARD_PREAPPROVAL_CONTINUE_LOCK_TTL_SECONDS,
|
||||
);
|
||||
if (!lockToken) {
|
||||
return {status: 'pending'};
|
||||
}
|
||||
try {
|
||||
const freshFlowState = await this.getLocalizedCardPreapprovalFlow(normalizedToken);
|
||||
if (!freshFlowState) {
|
||||
return {status: 'expired'};
|
||||
}
|
||||
if (freshFlowState.finalCheckoutUrl) {
|
||||
return {status: 'ready', url: freshFlowState.finalCheckoutUrl};
|
||||
}
|
||||
if (freshFlowState.status === 'pending') {
|
||||
return {status: 'pending'};
|
||||
}
|
||||
if (freshFlowState.status === 'rejected') {
|
||||
return {
|
||||
status: 'rejected',
|
||||
reason: freshFlowState.rejectionReason ?? 'unknown',
|
||||
actual_country: freshFlowState.actualCardCountry,
|
||||
};
|
||||
}
|
||||
if (freshFlowState.approvedPaymentMethodId && this.stripe) {
|
||||
await this.setCustomerDefaultPaymentMethod(freshFlowState.customerId, freshFlowState.approvedPaymentMethodId);
|
||||
}
|
||||
const checkoutUrl = await this.createCheckoutSession({
|
||||
userId: createUserID(BigInt(freshFlowState.userId)),
|
||||
priceId: freshFlowState.priceId,
|
||||
isGift: false,
|
||||
countryCode: freshFlowState.countryCode,
|
||||
clientGeoipCountryCode: freshFlowState.clientGeoipCountryCode,
|
||||
purchaseGeoipCountryCode: freshFlowState.purchaseGeoipCountryCode,
|
||||
euWithdrawalWaiverAccepted: freshFlowState.euWithdrawalWaiverAccepted,
|
||||
isBusiness: freshFlowState.isBusiness,
|
||||
});
|
||||
const updatedFlowState: LocalizedCardPreapprovalFlowState = {
|
||||
...freshFlowState,
|
||||
finalCheckoutUrl: checkoutUrl,
|
||||
status: 'checkout_created',
|
||||
};
|
||||
await this.setLocalizedCardPreapprovalFlow(normalizedToken, updatedFlowState);
|
||||
return {status: 'ready', url: checkoutUrl};
|
||||
} finally {
|
||||
try {
|
||||
await this.cacheService.releaseLock(lockKey, lockToken);
|
||||
} catch (error) {
|
||||
Logger.error({error, token: normalizedToken}, 'Failed to release localized card preapproval continuation lock');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async completeLocalizedCardPreapproval(session: Stripe.Checkout.Session): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
}
|
||||
const token = session.metadata?.localized_card_preapproval_token?.trim();
|
||||
if (!token) {
|
||||
Logger.error({sessionId: session.id}, 'Localized card preapproval session missing token');
|
||||
return;
|
||||
}
|
||||
const countryCode = session.metadata?.country_code?.trim().toUpperCase();
|
||||
if (!countryCode) {
|
||||
await this.rejectLocalizedCardPreapproval(session, token, 'unknown');
|
||||
return;
|
||||
}
|
||||
const setupIntentId = extractId(session.setup_intent);
|
||||
if (!setupIntentId) {
|
||||
await this.rejectLocalizedCardPreapproval(session, token, 'missing_setup_intent');
|
||||
return;
|
||||
}
|
||||
const setupIntent = await this.stripe.setupIntents.retrieve(setupIntentId, {
|
||||
expand: ['payment_method'],
|
||||
});
|
||||
const paymentMethod = setupIntent.payment_method;
|
||||
if (!paymentMethod || typeof paymentMethod === 'string') {
|
||||
await this.rejectLocalizedCardPreapproval(session, token, 'missing_payment_method');
|
||||
return;
|
||||
}
|
||||
if (paymentMethod.type !== 'card' || !paymentMethod.card) {
|
||||
await this.rejectLocalizedCardPreapproval(session, token, 'payment_method_not_card');
|
||||
return;
|
||||
}
|
||||
const cardCountry = paymentMethod.card.country?.trim().toUpperCase() ?? null;
|
||||
if (cardCountry !== countryCode) {
|
||||
await this.rejectLocalizedCardPreapproval(session, token, 'country_mismatch', cardCountry);
|
||||
return;
|
||||
}
|
||||
const flowState = await this.buildLocalizedCardPreapprovalFlowStateFromSession(session, token);
|
||||
const approvedFlowState: LocalizedCardPreapprovalFlowState = {
|
||||
...flowState,
|
||||
actualCardCountry: cardCountry,
|
||||
approvedPaymentMethodId: paymentMethod.id,
|
||||
rejectionReason: null,
|
||||
status: 'approved',
|
||||
};
|
||||
await this.setLocalizedCardPreapprovalFlow(token, approvedFlowState);
|
||||
Logger.info({sessionId: session.id, userId: flowState.userId, countryCode}, 'Localized card preapproval completed');
|
||||
}
|
||||
|
||||
private async prepareCheckoutContext({
|
||||
userId,
|
||||
priceId,
|
||||
@@ -746,132 +462,10 @@ export class StripeCheckoutService {
|
||||
}
|
||||
}
|
||||
|
||||
private requiresLocalizedCardPreapproval(productInfo: ProductInfo): boolean {
|
||||
return this.productRegistry.isRecurringSubscription(productInfo) && isLocalizedCurrency(productInfo.currency);
|
||||
}
|
||||
|
||||
private requiresCountryCodeForLocalizedCurrency(currency: Currency): boolean {
|
||||
return isLocalizedCurrency(currency);
|
||||
}
|
||||
|
||||
private async rejectLocalizedCardPreapproval(
|
||||
session: Stripe.Checkout.Session,
|
||||
token: string,
|
||||
rejectionReason: LocalizedCardPreapprovalRejectedReason,
|
||||
actualCardCountry: string | null = null,
|
||||
): Promise<void> {
|
||||
const flowState = await this.buildLocalizedCardPreapprovalFlowStateFromSession(session, token);
|
||||
const rejectedFlowState: LocalizedCardPreapprovalFlowState = {
|
||||
...flowState,
|
||||
actualCardCountry,
|
||||
approvedPaymentMethodId: null,
|
||||
rejectionReason,
|
||||
status: 'rejected',
|
||||
};
|
||||
await this.setLocalizedCardPreapprovalFlow(token, rejectedFlowState);
|
||||
Logger.info(
|
||||
{
|
||||
sessionId: session.id,
|
||||
userId: flowState.userId,
|
||||
countryCode: flowState.countryCode,
|
||||
actualCardCountry,
|
||||
rejectionReason,
|
||||
},
|
||||
'Localized card preapproval rejected',
|
||||
);
|
||||
}
|
||||
|
||||
private async buildLocalizedCardPreapprovalFlowStateFromSession(
|
||||
session: Stripe.Checkout.Session,
|
||||
token: string,
|
||||
): Promise<LocalizedCardPreapprovalFlowState> {
|
||||
const existingFlowState = await this.getLocalizedCardPreapprovalFlow(token);
|
||||
if (existingFlowState) {
|
||||
return existingFlowState;
|
||||
}
|
||||
const userId = session.metadata?.user_id?.trim();
|
||||
const priceId = session.metadata?.price_id?.trim();
|
||||
const countryCode = session.metadata?.country_code?.trim().toUpperCase();
|
||||
const currency = session.metadata?.localized_card_preapproval_currency?.trim().toUpperCase() as
|
||||
| Currency
|
||||
| undefined;
|
||||
const customerId = extractId(session.customer);
|
||||
if (!userId || !priceId || !countryCode || !currency || !customerId) {
|
||||
throw new StripeError('Localized card preapproval session missing required metadata');
|
||||
}
|
||||
return {
|
||||
actualCardCountry: null,
|
||||
approvedPaymentMethodId: null,
|
||||
clientGeoipCountryCode: this.normalizeCountryCode(session.metadata?.purchase_client_country_code),
|
||||
countryCode,
|
||||
customerId,
|
||||
currency,
|
||||
euWithdrawalWaiverAccepted: session.metadata?.eu_withdrawal_waiver_accepted === 'true',
|
||||
finalCheckoutUrl: null,
|
||||
isBusiness: session.metadata?.is_business === 'true',
|
||||
preapprovalSessionId: session.id,
|
||||
purchaseGeoipCountryCode: this.normalizeCountryCode(session.metadata?.purchase_geoip_country_code),
|
||||
priceId,
|
||||
rejectionReason: null,
|
||||
status: 'pending',
|
||||
token,
|
||||
userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async getLocalizedCardPreapprovalFlow(token: string): Promise<LocalizedCardPreapprovalFlowState | null> {
|
||||
return (
|
||||
(await this.cacheService.get<LocalizedCardPreapprovalFlowState>(
|
||||
this.getLocalizedCardPreapprovalFlowKey(token),
|
||||
)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private async setLocalizedCardPreapprovalFlow(
|
||||
token: string,
|
||||
flowState: LocalizedCardPreapprovalFlowState,
|
||||
): Promise<void> {
|
||||
await this.cacheService.set(
|
||||
this.getLocalizedCardPreapprovalFlowKey(token),
|
||||
flowState,
|
||||
StripeCheckoutService.LOCALIZED_CARD_PREAPPROVAL_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
private getLocalizedCardPreapprovalFlowKey(token: string): string {
|
||||
return `stripe:localized-card-preapproval:flow:${token}`;
|
||||
}
|
||||
|
||||
private getLocalizedCardPreapprovalContinueLockKey(token: string): string {
|
||||
return `stripe:localized-card-preapproval:continue:${token}`;
|
||||
}
|
||||
|
||||
private async setCustomerDefaultPaymentMethod(customerId: string, paymentMethodId: string): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updatedCustomer = await this.stripe.customers.update(customerId, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentMethodId,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await getBillingRepository().customers.upsertFromStripe(updatedCustomer);
|
||||
} catch (mirrorErr) {
|
||||
Logger.error(
|
||||
{mirrorErr, customerId: updatedCustomer.id},
|
||||
'Mirror upsert failed after Stripe write; reconciler will heal',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, customerId, paymentMethodId},
|
||||
'Failed to set localized card preapproval default payment method',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findBlockingSubscriptionForCustomer(customerId: string): Promise<Stripe.Subscription | null> {
|
||||
if (!this.stripe) {
|
||||
throw new StripePaymentNotAvailableError();
|
||||
@@ -1060,8 +654,6 @@ export class StripeCheckoutService {
|
||||
}
|
||||
|
||||
private static readonly CUSTOMER_LOCK_TTL_SECONDS = seconds('30 seconds');
|
||||
private static readonly LOCALIZED_CARD_PREAPPROVAL_CONTINUE_LOCK_TTL_SECONDS = seconds('30 seconds');
|
||||
private static readonly LOCALIZED_CARD_PREAPPROVAL_TTL_SECONDS = seconds('1 day');
|
||||
|
||||
private resolveConfiguredPriceIds(countryCode?: string): ResolvedPriceIds {
|
||||
const recurringCurrencyPreferences = getCurrencyPreferences(countryCode);
|
||||
@@ -1133,6 +725,24 @@ export class StripeCheckoutService {
|
||||
return getCachedStripePriceSummary({stripe: this.stripe, cacheService: this.cacheService, priceId});
|
||||
}
|
||||
|
||||
private resolveRequiredLocalPaymentMethod({
|
||||
productInfo,
|
||||
isGift,
|
||||
isRecurringSubscription,
|
||||
}: {
|
||||
productInfo: ProductInfo;
|
||||
isGift: boolean;
|
||||
isRecurringSubscription: boolean;
|
||||
}): CheckoutPaymentMethod | null {
|
||||
if (isGift || !isRecurringSubscription) {
|
||||
return null;
|
||||
}
|
||||
if (getEffectiveBillingConfig().catalogMode === 'operator') {
|
||||
return null;
|
||||
}
|
||||
return LOCAL_PAYMENT_METHOD_BY_CURRENCY[productInfo.currency] ?? null;
|
||||
}
|
||||
|
||||
private assertPaymentMethodCompatibility({
|
||||
paymentMethod,
|
||||
productInfo,
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {StripeGiftService} from '@app/api/stripe/services/StripeGiftService
|
||||
import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumService';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {isLocalizedCurrency} from '@app/api/utils/CurrencyUtils';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
@@ -46,12 +45,6 @@ interface DonationSubscriptionDetails {
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
interface CheckoutChargeDetails {
|
||||
chargeId: string | null;
|
||||
paymentMethodType: string | null;
|
||||
cardCountry: string | null;
|
||||
}
|
||||
|
||||
type CheckoutPremiumApplyResult = 'granted' | 'refunded_duplicate_subscription';
|
||||
type CheckoutSideEffectResult = 'continue' | 'stop';
|
||||
|
||||
@@ -198,10 +191,6 @@ export class StripeCheckoutWebhookHandler {
|
||||
);
|
||||
throw new StripeError('Checkout session missing amount or currency');
|
||||
}
|
||||
const cardEligible = await this.validateLocalizedCardEligibility(session, payment, productInfo, user);
|
||||
if (!cardEligible) {
|
||||
return;
|
||||
}
|
||||
const customerId = extractId(session.customer);
|
||||
const subscriptionId = extractId(session.subscription);
|
||||
const isRecurring = this.productRegistry.isRecurringSubscription(productInfo);
|
||||
@@ -263,131 +252,6 @@ export class StripeCheckoutWebhookHandler {
|
||||
);
|
||||
}
|
||||
|
||||
private async validateLocalizedCardEligibility(
|
||||
session: Stripe.Checkout.Session,
|
||||
payment: Payment,
|
||||
productInfo: ProductInfo,
|
||||
user: User,
|
||||
): Promise<boolean> {
|
||||
const requestedCountryCode = session.metadata?.country_code?.trim().toUpperCase() ?? null;
|
||||
if (!requestedCountryCode || !this.requiresLocalizedCardEligibility(productInfo)) {
|
||||
return true;
|
||||
}
|
||||
const paymentIntentId = extractId(session.payment_intent);
|
||||
if (!paymentIntentId) {
|
||||
const inferredPaymentMethodType = this.getDeclaredCheckoutPaymentMethodType(session);
|
||||
if (inferredPaymentMethodType && inferredPaymentMethodType !== 'card') {
|
||||
Logger.debug(
|
||||
{
|
||||
sessionId: session.id,
|
||||
requestedCountryCode,
|
||||
currency: productInfo.currency,
|
||||
inferredPaymentMethodType,
|
||||
},
|
||||
'Skipping localized card eligibility validation because checkout explicitly used a non-card payment method',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const fallbackChargeContext = await this.getLocalizedCheckoutChargeDetailsFromSubscription(session);
|
||||
if (fallbackChargeContext?.chargeDetails) {
|
||||
const fallbackChargeDetails = fallbackChargeContext.chargeDetails;
|
||||
if (fallbackChargeDetails.paymentMethodType !== 'card') {
|
||||
Logger.debug(
|
||||
{
|
||||
sessionId: session.id,
|
||||
requestedCountryCode,
|
||||
currency: productInfo.currency,
|
||||
fallbackPaymentMethodType: fallbackChargeDetails.paymentMethodType,
|
||||
},
|
||||
'Skipping localized card eligibility validation because subscription fallback resolved to a non-card payment method',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const normalizedFallbackCardCountry = fallbackChargeDetails.cardCountry?.trim().toUpperCase() ?? null;
|
||||
if (normalizedFallbackCardCountry === requestedCountryCode) {
|
||||
Logger.debug(
|
||||
{
|
||||
sessionId: session.id,
|
||||
requestedCountryCode,
|
||||
currency: productInfo.currency,
|
||||
cardCountry: normalizedFallbackCardCountry,
|
||||
},
|
||||
'Validated localized card eligibility from subscription fallback after checkout.session.completed omitted payment_intent',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (fallbackChargeContext.paymentIntentId && fallbackChargeDetails.chargeId) {
|
||||
Logger.warn(
|
||||
{
|
||||
sessionId: session.id,
|
||||
userId: payment.userId,
|
||||
paymentIntentId: fallbackChargeContext.paymentIntentId,
|
||||
chargeId: fallbackChargeDetails.chargeId,
|
||||
requestedCountryCode,
|
||||
cardCountry: normalizedFallbackCardCountry,
|
||||
currency: productInfo.currency,
|
||||
},
|
||||
'Rejecting localized checkout because subscription fallback resolved to a card issued outside the requested country',
|
||||
);
|
||||
await this.rejectLocalizedCardPayment({
|
||||
session,
|
||||
payment,
|
||||
user,
|
||||
chargeDetails: fallbackChargeDetails,
|
||||
paymentIntentId: fallbackChargeContext.paymentIntentId,
|
||||
requestedCountryCode,
|
||||
cardCountry: normalizedFallbackCardCountry,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Logger.error(
|
||||
{
|
||||
sessionId: session.id,
|
||||
requestedCountryCode,
|
||||
currency: productInfo.currency,
|
||||
inferredPaymentMethodType,
|
||||
fallbackResolved: Boolean(fallbackChargeContext?.chargeDetails),
|
||||
fallbackPaymentIntentId: fallbackChargeContext?.paymentIntentId ?? null,
|
||||
fallbackPaymentMethodType: fallbackChargeContext?.chargeDetails?.paymentMethodType ?? null,
|
||||
fallbackCardCountry: fallbackChargeContext?.chargeDetails?.cardCountry ?? null,
|
||||
},
|
||||
'Localized checkout missing payment intent for card eligibility validation',
|
||||
);
|
||||
throw new StripeError('Localized checkout missing payment intent');
|
||||
}
|
||||
const chargeDetails = await this.getCheckoutChargeDetails(paymentIntentId);
|
||||
if (chargeDetails.paymentMethodType !== 'card') {
|
||||
return true;
|
||||
}
|
||||
const normalizedCardCountry = chargeDetails.cardCountry?.trim().toUpperCase() ?? null;
|
||||
if (normalizedCardCountry === requestedCountryCode) {
|
||||
return true;
|
||||
}
|
||||
Logger.warn(
|
||||
{
|
||||
sessionId: session.id,
|
||||
userId: payment.userId,
|
||||
paymentIntentId,
|
||||
chargeId: chargeDetails.chargeId,
|
||||
requestedCountryCode,
|
||||
cardCountry: normalizedCardCountry,
|
||||
currency: productInfo.currency,
|
||||
},
|
||||
'Rejecting localized checkout because card issuing country did not match requested country',
|
||||
);
|
||||
await this.rejectLocalizedCardPayment({
|
||||
session,
|
||||
payment,
|
||||
user,
|
||||
chargeDetails,
|
||||
paymentIntentId,
|
||||
requestedCountryCode,
|
||||
cardCountry: normalizedCardCountry,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
private async applyCheckoutSideEffects(context: CheckoutFulfilmentContext): Promise<CheckoutSideEffectResult> {
|
||||
const checkoutEffectsAppliedKey = this.getCheckoutEffectsAppliedKey(context.session.id);
|
||||
if (await this.cacheService.get<boolean>(checkoutEffectsAppliedKey)) {
|
||||
@@ -719,185 +583,6 @@ export class StripeCheckoutWebhookHandler {
|
||||
return latestServerSeq > initialServerSeq;
|
||||
}
|
||||
|
||||
private requiresLocalizedCardEligibility(productInfo: ProductInfo): boolean {
|
||||
return isLocalizedCurrency(productInfo.currency);
|
||||
}
|
||||
|
||||
private getDeclaredCheckoutPaymentMethodType(session: Stripe.Checkout.Session): string | null {
|
||||
const metadataPaymentMethod = session.metadata?.payment_method?.trim().toLowerCase() ?? null;
|
||||
if (metadataPaymentMethod) {
|
||||
return metadataPaymentMethod;
|
||||
}
|
||||
const paymentMethodTypes = session.payment_method_types ?? [];
|
||||
if (paymentMethodTypes.length === 1) {
|
||||
return paymentMethodTypes[0]?.trim().toLowerCase() ?? null;
|
||||
}
|
||||
if (paymentMethodTypes.length > 1 && !paymentMethodTypes.some((type) => type.toLowerCase() === 'card')) {
|
||||
return paymentMethodTypes[0]?.trim().toLowerCase() ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getLocalizedCheckoutChargeDetailsFromSubscription(session: Stripe.Checkout.Session): Promise<{
|
||||
paymentIntentId: string | null;
|
||||
chargeDetails: CheckoutChargeDetails | null;
|
||||
} | null> {
|
||||
if (!this.stripe) {
|
||||
return null;
|
||||
}
|
||||
const subscriptionId = extractId(session.subscription);
|
||||
if (!subscriptionId) {
|
||||
return null;
|
||||
}
|
||||
type StripeSubscriptionWithFallbackPaymentState = Stripe.Subscription & {
|
||||
default_payment_method?:
|
||||
| {
|
||||
id?: string;
|
||||
type?: string | null;
|
||||
card?: {
|
||||
country?: string | null;
|
||||
} | null;
|
||||
}
|
||||
| string
|
||||
| null;
|
||||
latest_invoice?: Stripe.Invoice | string | null;
|
||||
};
|
||||
try {
|
||||
const subscription = (await this.stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['default_payment_method', 'latest_invoice.payments.data.payment'],
|
||||
})) as StripeSubscriptionWithFallbackPaymentState;
|
||||
const latestInvoice =
|
||||
typeof subscription.latest_invoice === 'string' ? null : (subscription.latest_invoice ?? null);
|
||||
const invoicePaymentIntentId = getFirstInvoicePaymentIntentId(latestInvoice);
|
||||
if (invoicePaymentIntentId) {
|
||||
return {
|
||||
paymentIntentId: invoicePaymentIntentId,
|
||||
chargeDetails: await this.getCheckoutChargeDetails(invoicePaymentIntentId),
|
||||
};
|
||||
}
|
||||
const defaultPaymentMethod =
|
||||
typeof subscription.default_payment_method === 'string' ? null : subscription.default_payment_method;
|
||||
if (!defaultPaymentMethod) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
paymentIntentId: null,
|
||||
chargeDetails: {
|
||||
chargeId: null,
|
||||
paymentMethodType: defaultPaymentMethod.type ?? null,
|
||||
cardCountry: defaultPaymentMethod.card?.country ?? null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{
|
||||
error,
|
||||
sessionId: session.id,
|
||||
subscriptionId,
|
||||
},
|
||||
'Failed to load subscription fallback payment details for localized card eligibility',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getCheckoutChargeDetails(paymentIntentId: string): Promise<CheckoutChargeDetails> {
|
||||
if (!this.stripe) {
|
||||
throw new StripeError('Stripe client not available for localized card eligibility checks');
|
||||
}
|
||||
try {
|
||||
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||
expand: ['latest_charge'],
|
||||
});
|
||||
const latestCharge = paymentIntent.latest_charge;
|
||||
if (!latestCharge) {
|
||||
throw new StripeError('Payment intent missing latest charge');
|
||||
}
|
||||
const charge = typeof latestCharge === 'string' ? await this.stripe.charges.retrieve(latestCharge) : latestCharge;
|
||||
const paymentMethodDetails = charge.payment_method_details;
|
||||
return {
|
||||
chargeId: charge.id,
|
||||
paymentMethodType: this.getChargePaymentMethodType(paymentMethodDetails),
|
||||
cardCountry: paymentMethodDetails?.card?.country ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error({error, paymentIntentId}, 'Failed to load Stripe charge details for localized card eligibility');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private getChargePaymentMethodType(paymentMethodDetails: Stripe.Charge.PaymentMethodDetails | null): string | null {
|
||||
if (!paymentMethodDetails) {
|
||||
return null;
|
||||
}
|
||||
if (paymentMethodDetails.type) {
|
||||
return paymentMethodDetails.type;
|
||||
}
|
||||
if (paymentMethodDetails.card) {
|
||||
return 'card';
|
||||
}
|
||||
if ('pix' in paymentMethodDetails && paymentMethodDetails.pix) {
|
||||
return 'pix';
|
||||
}
|
||||
if ('upi' in paymentMethodDetails && paymentMethodDetails.upi) {
|
||||
return 'upi';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async rejectLocalizedCardPayment({
|
||||
session,
|
||||
payment,
|
||||
user,
|
||||
chargeDetails,
|
||||
paymentIntentId,
|
||||
requestedCountryCode,
|
||||
cardCountry,
|
||||
}: {
|
||||
session: Stripe.Checkout.Session;
|
||||
payment: Payment;
|
||||
user: User;
|
||||
chargeDetails: CheckoutChargeDetails;
|
||||
paymentIntentId: string;
|
||||
requestedCountryCode: string;
|
||||
cardCountry: string | null;
|
||||
}): Promise<void> {
|
||||
const subscriptionId = extractId(session.subscription);
|
||||
if (subscriptionId) {
|
||||
await this.cancelStripeSubscriptionById(subscriptionId, session.id, user.id.toString());
|
||||
}
|
||||
if (chargeDetails.chargeId) {
|
||||
await this.refundChargeForLocalizedCardMismatch({
|
||||
chargeId: chargeDetails.chargeId,
|
||||
checkoutSessionId: session.id,
|
||||
requestedCountryCode,
|
||||
cardCountry,
|
||||
});
|
||||
} else {
|
||||
Logger.warn(
|
||||
{
|
||||
sessionId: session.id,
|
||||
userId: user.id.toString(),
|
||||
paymentIntentId,
|
||||
requestedCountryCode,
|
||||
cardCountry,
|
||||
},
|
||||
'Skipping localized card refund because Stripe did not surface a charge id',
|
||||
);
|
||||
}
|
||||
await this.userRepository.updatePayment({
|
||||
...payment.toRow(),
|
||||
stripe_customer_id: extractId(session.customer),
|
||||
payment_intent_id: paymentIntentId,
|
||||
subscription_id: subscriptionId,
|
||||
invoice_id: typeof session.invoice === 'string' ? session.invoice : null,
|
||||
amount_cents: session.amount_total ?? payment.amountCents,
|
||||
currency: session.currency ?? payment.currency,
|
||||
status: 'failed',
|
||||
completed_at: payment.completedAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private async handleDonationCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const email = session.metadata?.donation_email?.trim().toLowerCase();
|
||||
if (!email) {
|
||||
@@ -1135,39 +820,6 @@ export class StripeCheckoutWebhookHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async refundChargeForLocalizedCardMismatch({
|
||||
chargeId,
|
||||
checkoutSessionId,
|
||||
requestedCountryCode,
|
||||
cardCountry,
|
||||
}: {
|
||||
chargeId: string;
|
||||
checkoutSessionId: string;
|
||||
requestedCountryCode: string;
|
||||
cardCountry: string | null;
|
||||
}): Promise<void> {
|
||||
if (!this.stripe) {
|
||||
throw new StripeError('Stripe client not available for localized card refund');
|
||||
}
|
||||
const refund = await this.stripe.refunds.create(
|
||||
{
|
||||
charge: chargeId,
|
||||
metadata: {
|
||||
checkout_session_id: checkoutSessionId,
|
||||
rejection_reason: 'localized_card_country_mismatch',
|
||||
expected_country: requestedCountryCode,
|
||||
actual_country: cardCountry ?? 'unknown',
|
||||
},
|
||||
},
|
||||
{idempotencyKey: `localized-card-country-refund:${checkoutSessionId}`},
|
||||
);
|
||||
try {
|
||||
await getBillingRepository().refunds.upsertFromStripe(refund);
|
||||
} catch (mirrorErr) {
|
||||
Logger.error({mirrorErr, refundId: refund.id}, 'Mirror upsert failed after Stripe write; reconciler will heal');
|
||||
}
|
||||
}
|
||||
|
||||
private isMissingOrCancelledSubscriptionError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlem
|
||||
import {getAcceptedWebhookSecrets} from '@app/api/stripe/BillingConfigCache';
|
||||
import type {ProductRegistry} from '@app/api/stripe/ProductRegistry';
|
||||
import type {AgeVerificationService} from '@app/api/stripe/services/AgeVerificationService';
|
||||
import type {StripeCheckoutService} from '@app/api/stripe/services/StripeCheckoutService';
|
||||
import {StripeCheckoutWebhookHandler} from '@app/api/stripe/services/StripeCheckoutWebhookHandler';
|
||||
import {StripeDisputeWebhookHandler} from '@app/api/stripe/services/StripeDisputeWebhookHandler';
|
||||
import {StripeGiftReversalHandler} from '@app/api/stripe/services/StripeGiftReversalHandler';
|
||||
@@ -47,7 +46,6 @@ export class StripeWebhookService {
|
||||
|
||||
constructor(
|
||||
private stripe: Stripe | null,
|
||||
private checkoutService: StripeCheckoutService,
|
||||
userRepository: IUserRepository,
|
||||
userCacheService: UserCacheService,
|
||||
sessionTerminator: ISessionTerminator,
|
||||
@@ -176,10 +174,6 @@ export class StripeWebhookService {
|
||||
await this.ageVerificationService.completeVerification(checkoutSession);
|
||||
break;
|
||||
}
|
||||
if (checkoutSession.metadata?.setup_type === 'localized_card_preapproval') {
|
||||
await this.checkoutService.completeLocalizedCardPreapproval(checkoutSession);
|
||||
break;
|
||||
}
|
||||
await this.checkoutHandler.handleCheckoutSessionCompleted(checkoutSession);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -489,16 +489,6 @@ describe('operator billing catalog', () => {
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions[0]?.line_items?.[0]?.price).toBe('price_opchfyearly');
|
||||
});
|
||||
|
||||
test('never offers the localized card preapproval flow', async () => {
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
.post('/stripe/checkout/subscription/preapproval')
|
||||
.body({price_id: 'price_opsekmonthly', country_code: 'SE'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.STRIPE_INVALID_PRODUCT_CONFIGURATION)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('rejects pix and upi for operator prices', async () => {
|
||||
const token = await createPurchaser();
|
||||
await createBuilder(harness, token)
|
||||
|
||||
@@ -144,8 +144,6 @@ type RouteMethod = 'GET' | 'POST' | 'DELETE';
|
||||
|
||||
const PURCHASE_ROUTES: ReadonlyArray<[RouteMethod, string]> = [
|
||||
['POST', '/stripe/checkout/subscription'],
|
||||
['POST', '/stripe/checkout/subscription/preapproval'],
|
||||
['POST', '/stripe/checkout/subscription/preapproval/continue'],
|
||||
['POST', '/stripe/checkout/gift'],
|
||||
['GET', '/premium/price-ids'],
|
||||
];
|
||||
|
||||
@@ -167,4 +167,37 @@ describe('StripeCheckoutCountryEnforcement', () => {
|
||||
expect(priceIds.currency).toBe('BRL');
|
||||
expect(priceIds.monthly).toBe(MOCK_PRICES.monthlyBrl);
|
||||
});
|
||||
test('offers pix and nothing else for a localized BRL subscription', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions[0].payment_method_types).toEqual(['pix']);
|
||||
});
|
||||
|
||||
test('forces pix even when the caller explicitly asks for card on a BRL subscription', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('BR'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR', payment_method: 'card'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions[0].payment_method_types).toEqual(['pix']);
|
||||
});
|
||||
|
||||
test('leaves payment methods open for a base-currency subscription', async () => {
|
||||
lookupGeoipMock.mockResolvedValue(geoipCountry('US'));
|
||||
const token = await createPurchaser();
|
||||
await createBuilder<{url: string}>(harness, token)
|
||||
.post('/stripe/checkout/subscription')
|
||||
.body({price_id: MOCK_PRICES.monthlyUsd, country_code: 'US'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions[0].payment_method_types).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -226,45 +226,6 @@ describe('StripeWebhookService - checkout.session.completed', () => {
|
||||
.execute();
|
||||
expect(user.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
});
|
||||
test('allows localized BRL checkout when the card is issued in Brazil', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const sessionId = 'cs_localized_brl_card_br';
|
||||
const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: sessionId,
|
||||
user_id: createUserID(BigInt(account.userId)),
|
||||
price_id: MOCK_PRICES.monthlyBrl,
|
||||
product_type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
status: 'pending',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
const eventData = createCheckoutCompletedEvent({
|
||||
sessionId,
|
||||
customerId: 'cus_localized_brl_card_br',
|
||||
subscriptionId: 'sub_localized_brl_card_br',
|
||||
paymentIntentId: 'pi_localized_brl_card_br',
|
||||
amountTotal: 1288,
|
||||
currency: 'brl',
|
||||
metadata: {country_code: 'BR'},
|
||||
});
|
||||
const result = await sendWebhook(eventData);
|
||||
expect(result.received).toBe(true);
|
||||
const updatedPayment = await userRepository.getPaymentByCheckoutSession(sessionId);
|
||||
expect(updatedPayment?.status).toBe('completed');
|
||||
expect(stripeHandlers.spies.createdRefunds).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.cancelledSubscriptions).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.retrievedPaymentIntents).toContain('pi_localized_brl_card_br');
|
||||
const user = await createBuilder<{
|
||||
premium_type: number;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(user.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
});
|
||||
test('allows localized BRL PIX subscription checkout when checkout.session.completed has no payment intent', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const sessionId = 'cs_localized_brl_pix_subscription';
|
||||
@@ -410,173 +371,6 @@ describe('StripeWebhookService - checkout.session.completed', () => {
|
||||
.execute();
|
||||
expect(user.premium_type).toBe(UserPremiumTypes.SUBSCRIPTION);
|
||||
});
|
||||
test('rejects localized BRL checkout when the card is issued outside Brazil', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const sessionId = 'cs_localized_brl_card_us';
|
||||
const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: sessionId,
|
||||
user_id: createUserID(BigInt(account.userId)),
|
||||
price_id: MOCK_PRICES.monthlyBrl,
|
||||
product_type: ProductType.MONTHLY_SUBSCRIPTION,
|
||||
status: 'pending',
|
||||
is_gift: false,
|
||||
created_at: new Date(),
|
||||
});
|
||||
const eventData = createCheckoutCompletedEvent({
|
||||
sessionId,
|
||||
customerId: 'cus_localized_brl_card_us',
|
||||
subscriptionId: 'sub_localized_brl_card_us',
|
||||
paymentIntentId: 'pi_localized_brl_card_us',
|
||||
amountTotal: 1288,
|
||||
currency: 'brl',
|
||||
metadata: {country_code: 'BR'},
|
||||
});
|
||||
const result = await sendWebhook(eventData);
|
||||
expect(result.received).toBe(true);
|
||||
const updatedPayment = await userRepository.getPaymentByCheckoutSession(sessionId);
|
||||
expect(updatedPayment?.status).toBe('failed');
|
||||
expect(updatedPayment?.subscriptionId).toBe('sub_localized_brl_card_us');
|
||||
expect(stripeHandlers.spies.createdRefunds).toHaveLength(1);
|
||||
expect(stripeHandlers.spies.cancelledSubscriptions).toContain('sub_localized_brl_card_us');
|
||||
const user = await createBuilder<{
|
||||
premium_type: number;
|
||||
}>(harness, account.token)
|
||||
.get('/users/@me')
|
||||
.execute();
|
||||
expect(user.premium_type).toBe(UserPremiumTypes.NONE);
|
||||
});
|
||||
test('continues localized card preapproval into paid checkout when the card matches the requested country', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
server.use(
|
||||
http.get('https://api.stripe.com/v1/subscriptions', () => {
|
||||
return HttpResponse.json({
|
||||
object: 'list',
|
||||
url: '/v1/subscriptions',
|
||||
has_more: false,
|
||||
data: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({email_verified: true})
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({stripe_customer_id: 'cus_test_existing'})
|
||||
.execute();
|
||||
const preapprovalResponse = await createBuilder<{
|
||||
url: string;
|
||||
}>(harness, account.token)
|
||||
.post('/stripe/checkout/subscription/preapproval')
|
||||
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
|
||||
.execute();
|
||||
const preapprovalSession = stripeHandlers.spies.createdCheckoutSessions[0];
|
||||
const successUrl = new URL(preapprovalSession?.success_url ?? 'https://example.com');
|
||||
const token = successUrl.searchParams.get('token');
|
||||
const preapprovalSessionId = preapprovalResponse.url.split('/').pop();
|
||||
expect(token).toBeTruthy();
|
||||
expect(preapprovalSessionId).toBeTruthy();
|
||||
if (!token || !preapprovalSessionId) {
|
||||
throw new Error('Expected localized card preapproval token and session id');
|
||||
}
|
||||
const webhookResult = await sendWebhook(
|
||||
createCheckoutCompletedEvent({
|
||||
sessionId: preapprovalSessionId,
|
||||
customerId: 'cus_test_existing',
|
||||
mode: 'setup',
|
||||
setupIntentId: 'seti_localized_brl_card_br',
|
||||
metadata: preapprovalSession?.metadata ?? {},
|
||||
}),
|
||||
);
|
||||
expect(webhookResult.received).toBe(true);
|
||||
const continueResponse = await createBuilder<{
|
||||
status: string;
|
||||
url?: string;
|
||||
}>(harness, '')
|
||||
.post('/stripe/checkout/subscription/preapproval/continue')
|
||||
.body({token})
|
||||
.execute();
|
||||
expect(continueResponse.status).toBe('ready');
|
||||
expect(continueResponse.url).toMatch(/^https:\/\/checkout\.stripe\.com/);
|
||||
expect(stripeHandlers.spies.retrievedSetupIntents).toContain('seti_localized_brl_card_br');
|
||||
expect(stripeHandlers.spies.updatedCustomers).toContainEqual({
|
||||
id: 'cus_test_existing',
|
||||
params: {
|
||||
invoice_settings: {
|
||||
default_payment_method: 'pm_localized_brl_card_br',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(2);
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions[1]?.mode).toBe('subscription');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions[1]?.customer).toBe('cus_test_existing');
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions[1]?.metadata?.country_code).toBe('BR');
|
||||
});
|
||||
test('keeps localized card preapproval rejected when the card country does not match', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
server.use(
|
||||
http.get('https://api.stripe.com/v1/subscriptions', () => {
|
||||
return HttpResponse.json({
|
||||
object: 'list',
|
||||
url: '/v1/subscriptions',
|
||||
has_more: false,
|
||||
data: [],
|
||||
});
|
||||
}),
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/security-flags`)
|
||||
.body({email_verified: true})
|
||||
.execute();
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({stripe_customer_id: 'cus_test_existing'})
|
||||
.execute();
|
||||
const preapprovalResponse = await createBuilder<{
|
||||
url: string;
|
||||
}>(harness, account.token)
|
||||
.post('/stripe/checkout/subscription/preapproval')
|
||||
.body({price_id: MOCK_PRICES.monthlyBrl, country_code: 'BR'})
|
||||
.execute();
|
||||
const preapprovalSession = stripeHandlers.spies.createdCheckoutSessions[0];
|
||||
const successUrl = new URL(preapprovalSession?.success_url ?? 'https://example.com');
|
||||
const token = successUrl.searchParams.get('token');
|
||||
const preapprovalSessionId = preapprovalResponse.url.split('/').pop();
|
||||
expect(token).toBeTruthy();
|
||||
expect(preapprovalSessionId).toBeTruthy();
|
||||
if (!token || !preapprovalSessionId) {
|
||||
throw new Error('Expected localized card preapproval token and session id');
|
||||
}
|
||||
const webhookResult = await sendWebhook(
|
||||
createCheckoutCompletedEvent({
|
||||
sessionId: preapprovalSessionId,
|
||||
customerId: 'cus_test_existing',
|
||||
mode: 'setup',
|
||||
setupIntentId: 'seti_localized_brl_card_us',
|
||||
metadata: preapprovalSession?.metadata ?? {},
|
||||
}),
|
||||
);
|
||||
expect(webhookResult.received).toBe(true);
|
||||
const continueResponse = await createBuilder<{
|
||||
status: string;
|
||||
reason?: string;
|
||||
actual_country?: string | null;
|
||||
}>(harness, '')
|
||||
.post('/stripe/checkout/subscription/preapproval/continue')
|
||||
.body({token})
|
||||
.execute();
|
||||
expect(continueResponse.status).toBe('rejected');
|
||||
expect(continueResponse.reason).toBe('country_mismatch');
|
||||
expect(continueResponse.actual_country).toBe('US');
|
||||
expect(stripeHandlers.spies.retrievedSetupIntents).toContain('seti_localized_brl_card_us');
|
||||
expect(stripeHandlers.spies.updatedCustomers).toHaveLength(0);
|
||||
expect(stripeHandlers.spies.createdCheckoutSessions).toHaveLength(1);
|
||||
});
|
||||
test('updates user with Stripe customer ID on first purchase', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const sessionId = 'cs_first_purchase_customer_123';
|
||||
@@ -799,38 +593,6 @@ describe('StripeWebhookService - checkout.session.completed', () => {
|
||||
expect(updatedPayment?.giftCode).not.toBeNull();
|
||||
expect(stripeHandlers.spies.createdRefunds).toHaveLength(0);
|
||||
});
|
||||
test('rejects localized BRL gift checkout when the card is issued outside Brazil', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const sessionId = 'cs_gift_brl_card_us';
|
||||
const {PaymentRepository} = await import('@app/api/user/repositories/PaymentRepository');
|
||||
const {UserRepository} = await import('@app/api/user/repositories/UserRepository');
|
||||
const paymentRepository = new PaymentRepository();
|
||||
const userRepository = new UserRepository();
|
||||
await paymentRepository.createPayment({
|
||||
checkout_session_id: sessionId,
|
||||
user_id: createUserID(BigInt(account.userId)),
|
||||
price_id: MOCK_PRICES.gift1MonthBrl,
|
||||
product_type: ProductType.GIFT_1_MONTH,
|
||||
status: 'pending',
|
||||
is_gift: true,
|
||||
created_at: new Date(),
|
||||
});
|
||||
const eventData = createCheckoutCompletedEvent({
|
||||
sessionId,
|
||||
customerId: 'cus_gift_brl_card_us',
|
||||
paymentIntentId: 'pi_localized_brl_card_us',
|
||||
amountTotal: 1288,
|
||||
currency: 'brl',
|
||||
mode: 'payment',
|
||||
metadata: {country_code: 'BR'},
|
||||
});
|
||||
const result = await sendWebhook(eventData);
|
||||
expect(result.received).toBe(true);
|
||||
const updatedPayment = await userRepository.getPaymentByCheckoutSession(sessionId);
|
||||
expect(updatedPayment?.status).toBe('failed');
|
||||
expect(updatedPayment?.giftCode).toBeNull();
|
||||
expect(stripeHandlers.spies.createdRefunds).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe('donation checkout', () => {
|
||||
test('handles donation without email gracefully', async () => {
|
||||
|
||||
@@ -414,7 +414,8 @@ export class JetStreamWorkerQueue {
|
||||
this.requireConsumerConfiguration(existing, lane.consumerName);
|
||||
const updated = await jsm.consumers.update(STREAM_NAME, lane.consumerName, config);
|
||||
this.requireConsumerConfiguration(updated, lane.consumerName);
|
||||
if (updated.created !== existing.created) {
|
||||
const current = await this.readConsumer(jsm, lane.consumerName);
|
||||
if (current?.created !== existing.created) {
|
||||
throw new Error(`Worker consumer ${lane.consumerName} was replaced during startup`);
|
||||
}
|
||||
Logger.info({lane: lane.name, consumer: lane.consumerName}, 'Consumer updated without resetting delivery state');
|
||||
|
||||
@@ -71,7 +71,6 @@ const processStripeWebhook: WorkerTaskHandler = async (payload, helpers) => {
|
||||
const refundService = new StripeRefundService(stripe, deps.userRepository, subscriptionService);
|
||||
const webhookService = new StripeWebhookService(
|
||||
stripe,
|
||||
checkoutService,
|
||||
deps.userRepository,
|
||||
deps.userCacheService,
|
||||
sessionTerminator,
|
||||
|
||||
+11
-173
@@ -3,7 +3,6 @@
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {
|
||||
BLIK_PAYMENT_METHOD,
|
||||
PAYMENT_PROVIDER_NAME,
|
||||
PIX_PAYMENT_METHOD,
|
||||
PREMIUM_PRODUCT_FULL_NAME,
|
||||
@@ -66,45 +65,6 @@ const USE_ALTERNATIVE_METHOD_BUTTON_DESCRIPTOR = msg({
|
||||
message: 'Use alternative method',
|
||||
comment: 'Plutonium subscription payment method picker button. Selects the alternative (non-card) method.',
|
||||
});
|
||||
const LOCAL_CARD_PROMPT_BRL_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
"Use local card to verify a card issued in Brazil before the paid BRL checkout. Choose other methods if you'd rather try {pixPaymentMethod} or another payment method {paymentProviderName} offers on the next screen.",
|
||||
comment:
|
||||
'Plutonium checkout pre-approval prompt for Brazil. Explains the card verification flow and the Pix alternative.',
|
||||
});
|
||||
const OTHER_METHODS_BUTTON_DESCRIPTOR = msg({
|
||||
message: 'Other methods',
|
||||
comment: 'Plutonium checkout pre-approval prompt secondary button. Opens the alternative payment methods flow.',
|
||||
});
|
||||
const LOCAL_CARD_PROMPT_INR_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
"Use local card to verify a card issued in India before the paid INR checkout. Choose other methods if you'd rather try {upiPaymentMethod} or another payment method {paymentProviderName} offers on the next screen.",
|
||||
comment:
|
||||
'Plutonium checkout pre-approval prompt for India. Explains the card verification flow and the UPI alternative.',
|
||||
});
|
||||
const LOCAL_CARD_PROMPT_PLN_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
"Use local card to verify a card issued in Poland before the paid PLN checkout. {blikPaymentMethod} does not support subscriptions in {paymentProviderName} checkout, so continue only if you want {paymentProviderName2}'s standard payment screen instead.",
|
||||
comment:
|
||||
'Plutonium checkout pre-approval prompt for Poland. Explains that BLIK is not supported for subscriptions; user must continue to the standard checkout.',
|
||||
});
|
||||
const CONTINUE_TO_CHECKOUT_BUTTON_DESCRIPTOR = msg({
|
||||
message: 'Continue to checkout',
|
||||
comment:
|
||||
'Plutonium checkout pre-approval prompt secondary button. Continues straight to the standard checkout screen.',
|
||||
});
|
||||
const LOCAL_CARD_PROMPT_TRY_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
"Use local card to verify a card issued in Türkiye before the paid TRY checkout. There isn't an app-based local subscription method here, so continuing will take you to {paymentProviderName}'s standard payment screen.",
|
||||
comment:
|
||||
'Plutonium checkout pre-approval prompt for Türkiye. Explains there is no local wallet method; continuing goes to the standard checkout.',
|
||||
});
|
||||
const LOCAL_CARD_PROMPT_DEFAULT_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Use local card to verify a card issued in your billing country before the paid checkout. Choose other methods for any other payment method {paymentProviderName} offers on the next screen.',
|
||||
comment:
|
||||
'Plutonium checkout pre-approval prompt default. Generic description for countries without specific localized payment guidance.',
|
||||
});
|
||||
const EMAIL_VERIFICATION_REQUIRED_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Verify your email first',
|
||||
comment: 'Title of the error modal shown when an unverified account tries to purchase Plutonium.',
|
||||
@@ -187,36 +147,19 @@ const PLAN_UNAVAILABLE_TOAST_DESCRIPTOR = msg({
|
||||
message: "This plan isn't available. Contact support.",
|
||||
comment: 'Error modal body shown when the selected Plutonium plan has no price ID configured.',
|
||||
});
|
||||
const VERIFY_CARD_MODAL_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Verify card',
|
||||
comment: 'Modal title for the mobile checkout confirmation when opening the localized card verification flow.',
|
||||
});
|
||||
const COMPLETE_PAYMENT_MODAL_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Complete payment',
|
||||
comment: 'Modal title for the mobile checkout confirmation when opening the payment provider in a browser.',
|
||||
});
|
||||
const VERIFY_CARD_MODAL_BODY_DESCRIPTOR = msg({
|
||||
message:
|
||||
"{paymentProviderName} will first verify that your card is eligible for localized pricing, then take you to payment. Return to {productName} once you've completed it.",
|
||||
comment: 'Modal body for the mobile card verification confirmation. Explains the two-step flow and the return path.',
|
||||
});
|
||||
const COMPLETE_PAYMENT_MODAL_BODY_DESCRIPTOR = msg({
|
||||
message:
|
||||
"You are now navigating to {paymentProviderName} to complete the payment. Return to {productName} once you've completed it.",
|
||||
comment: 'Modal body for the mobile checkout confirmation. Explains that the user is leaving the app to pay.',
|
||||
});
|
||||
const LOCALIZED_VERIFICATION_UNAVAILABLE_TOAST_DESCRIPTOR = msg({
|
||||
message: 'Localized card verification is not available right now. Try again later.',
|
||||
comment: 'Error modal body shown when the localized card pre-approval session cannot be started.',
|
||||
});
|
||||
const CHOOSE_PAYMENT_METHOD_MODAL_TITLE_DESCRIPTOR = msg({
|
||||
message: 'Choose payment method',
|
||||
comment: 'Modal title for the payment method picker (local card vs alternative method).',
|
||||
});
|
||||
const USE_LOCAL_CARD_BUTTON_DESCRIPTOR = msg({
|
||||
message: 'Use local card',
|
||||
comment: 'Modal primary button to start the localized card pre-approval verification flow.',
|
||||
});
|
||||
const USE_CARD_BUTTON_DESCRIPTOR = msg({
|
||||
message: 'Use card',
|
||||
comment: 'Modal secondary button to fall back to standard card checkout instead of an alternative payment method.',
|
||||
@@ -224,7 +167,6 @@ const USE_CARD_BUTTON_DESCRIPTOR = msg({
|
||||
const logger = new Logger('useCheckoutActions');
|
||||
|
||||
type Plan = 'monthly' | 'yearly' | 'gift_1_month' | 'gift_1_year';
|
||||
type CheckoutPromptKind = 'payment' | 'localized_card_preapproval';
|
||||
type PremiumPurchaseBlockedReason = 'lifetime' | 'existing_subscription' | 'purchase_disabled';
|
||||
|
||||
function getPremiumPurchaseBlockedStoreProvider(body: unknown): 'app_store' | 'google_play' | null {
|
||||
@@ -249,13 +191,7 @@ function getPremiumPurchaseBlockedReason(body: unknown): PremiumPurchaseBlockedR
|
||||
return null;
|
||||
}
|
||||
|
||||
function requiresLocalizedCardPreapproval(
|
||||
_plan: Plan,
|
||||
_currency: string | null | undefined,
|
||||
_isGift: boolean,
|
||||
): boolean {
|
||||
return false;
|
||||
}
|
||||
const MANDATORY_LOCAL_PAYMENT_CURRENCIES: ReadonlySet<string> = new Set(['BRL']);
|
||||
|
||||
function alternativePaymentMethodForCurrency(
|
||||
currency: string | null | undefined,
|
||||
@@ -325,52 +261,6 @@ export const useCheckoutActions = (
|
||||
},
|
||||
[i18n],
|
||||
);
|
||||
const getLocalizedCardPrompt = useCallback(
|
||||
(currency: string | null | undefined): {description: string; secondaryText: string} => {
|
||||
switch (currency) {
|
||||
case 'BRL':
|
||||
return {
|
||||
description: i18n._(LOCAL_CARD_PROMPT_BRL_DESCRIPTION_DESCRIPTOR, {
|
||||
pixPaymentMethod: PIX_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
secondaryText: i18n._(OTHER_METHODS_BUTTON_DESCRIPTOR),
|
||||
};
|
||||
case 'INR':
|
||||
return {
|
||||
description: i18n._(LOCAL_CARD_PROMPT_INR_DESCRIPTION_DESCRIPTOR, {
|
||||
upiPaymentMethod: UPI_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
secondaryText: i18n._(OTHER_METHODS_BUTTON_DESCRIPTOR),
|
||||
};
|
||||
case 'PLN':
|
||||
return {
|
||||
description: i18n._(LOCAL_CARD_PROMPT_PLN_DESCRIPTION_DESCRIPTOR, {
|
||||
blikPaymentMethod: BLIK_PAYMENT_METHOD,
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
paymentProviderName2: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
secondaryText: i18n._(CONTINUE_TO_CHECKOUT_BUTTON_DESCRIPTOR),
|
||||
};
|
||||
case 'TRY':
|
||||
return {
|
||||
description: i18n._(LOCAL_CARD_PROMPT_TRY_DESCRIPTION_DESCRIPTOR, {
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
secondaryText: i18n._(CONTINUE_TO_CHECKOUT_BUTTON_DESCRIPTOR),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
description: i18n._(LOCAL_CARD_PROMPT_DEFAULT_DESCRIPTION_DESCRIPTOR, {
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
}),
|
||||
secondaryText: i18n._(OTHER_METHODS_BUTTON_DESCRIPTOR),
|
||||
};
|
||||
}
|
||||
},
|
||||
[i18n],
|
||||
);
|
||||
const handleCheckoutError = useCallback(
|
||||
(error: unknown) => {
|
||||
logger.error('Failed to create checkout session', error);
|
||||
@@ -560,31 +450,17 @@ export const useCheckoutActions = (
|
||||
};
|
||||
const openCheckoutUrl = async (
|
||||
checkoutUrl: string,
|
||||
{
|
||||
promptKind = 'payment',
|
||||
skipMobilePrompt = false,
|
||||
}: {promptKind?: CheckoutPromptKind; skipMobilePrompt?: boolean} = {},
|
||||
{skipMobilePrompt = false}: {skipMobilePrompt?: boolean} = {},
|
||||
) => {
|
||||
if (mobileEnabled && !skipMobilePrompt) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={
|
||||
promptKind === 'localized_card_preapproval'
|
||||
? i18n._(VERIFY_CARD_MODAL_TITLE_DESCRIPTOR)
|
||||
: i18n._(COMPLETE_PAYMENT_MODAL_TITLE_DESCRIPTOR)
|
||||
}
|
||||
description={
|
||||
promptKind === 'localized_card_preapproval'
|
||||
? i18n._(VERIFY_CARD_MODAL_BODY_DESCRIPTOR, {
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
productName: PRODUCT_NAME,
|
||||
})
|
||||
: i18n._(COMPLETE_PAYMENT_MODAL_BODY_DESCRIPTOR, {
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
productName: PRODUCT_NAME,
|
||||
})
|
||||
}
|
||||
title={i18n._(COMPLETE_PAYMENT_MODAL_TITLE_DESCRIPTOR)}
|
||||
description={i18n._(COMPLETE_PAYMENT_MODAL_BODY_DESCRIPTOR, {
|
||||
paymentProviderName: PAYMENT_PROVIDER_NAME,
|
||||
productName: PRODUCT_NAME,
|
||||
})}
|
||||
primaryText={i18n._(OKAY_DESCRIPTOR)}
|
||||
primaryVariant="primary"
|
||||
secondaryText={i18n._(CANCEL_DESCRIPTOR)}
|
||||
@@ -616,55 +492,18 @@ export const useCheckoutActions = (
|
||||
isGift,
|
||||
paymentMethod,
|
||||
);
|
||||
await openCheckoutUrl(checkoutUrl, {promptKind: 'payment', skipMobilePrompt});
|
||||
await openCheckoutUrl(checkoutUrl, {skipMobilePrompt});
|
||||
} catch (error) {
|
||||
handleCheckoutError(error);
|
||||
} finally {
|
||||
setLoadingCheckout(false);
|
||||
}
|
||||
};
|
||||
const startLocalizedCardPreapproval = async ({skipMobilePrompt = false}: {skipMobilePrompt?: boolean} = {}) => {
|
||||
if (!countryCode) {
|
||||
showCheckoutPlanErrorModal(
|
||||
i18n._(LOCALIZED_VERIFICATION_UNAVAILABLE_TOAST_DESCRIPTOR),
|
||||
'app.plutonium.use-checkout-actions.localized-verification-unavailable.generic-error-modal',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLoadingCheckout(true);
|
||||
try {
|
||||
const checkoutUrl = await PremiumCommands.createLocalizedCardPreapprovalSession(priceId, countryCode);
|
||||
await openCheckoutUrl(checkoutUrl, {
|
||||
promptKind: 'localized_card_preapproval',
|
||||
skipMobilePrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
handleCheckoutError(error);
|
||||
} finally {
|
||||
setLoadingCheckout(false);
|
||||
}
|
||||
};
|
||||
if (requiresLocalizedCardPreapproval(plan, priceIds.currency, isGift)) {
|
||||
const localizedCardPrompt = getLocalizedCardPrompt(priceIds.currency);
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={i18n._(CHOOSE_PAYMENT_METHOD_MODAL_TITLE_DESCRIPTOR)}
|
||||
description={localizedCardPrompt.description}
|
||||
primaryText={i18n._(USE_LOCAL_CARD_BUTTON_DESCRIPTOR)}
|
||||
primaryVariant="primary"
|
||||
secondaryText={localizedCardPrompt.secondaryText}
|
||||
onPrimary={() => startLocalizedCardPreapproval({skipMobilePrompt: true})}
|
||||
onSecondary={() => {
|
||||
void startCheckout({skipMobilePrompt: true});
|
||||
}}
|
||||
data-flx="app.plutonium.use-checkout-actions.handle-select-plan.confirm-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
const altPaymentMethod = alternativePaymentMethodForCurrency(priceIds.currency, isGift, plan);
|
||||
if (altPaymentMethod && MANDATORY_LOCAL_PAYMENT_CURRENCIES.has(priceIds.currency ?? '')) {
|
||||
await startCheckout({paymentMethod: altPaymentMethod});
|
||||
return;
|
||||
}
|
||||
const altPaymentMethod = alternativePaymentMethodForCurrency(priceIds.currency, isGift, plan);
|
||||
if (altPaymentMethod) {
|
||||
const altPrompt = getAlternativePaymentMethodPrompt(priceIds.currency, altPaymentMethod);
|
||||
ModalCommands.push(
|
||||
@@ -694,7 +533,6 @@ export const useCheckoutActions = (
|
||||
loadingCheckout,
|
||||
priceIds,
|
||||
countryCode,
|
||||
getLocalizedCardPrompt,
|
||||
getAlternativePaymentMethodPrompt,
|
||||
isGiftSubscription,
|
||||
mobileEnabled,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {type DragItem, DragItemType, type DropResult} from '@app/features/app/co
|
||||
import {isCategory, isTextChannel} from '@app/features/app/components/layout/utils/ChannelOrganization';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {VoiceChannelUserCount} from '@app/features/app/components/layout/VoiceChannelUserCount';
|
||||
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import {useTextOverflow} from '@app/features/app/hooks/useTextOverflow';
|
||||
@@ -219,6 +220,12 @@ export const ChannelItem = observer(
|
||||
const channelIsCategory = isCategory(channel);
|
||||
const channelIsVoice = channelType === ChannelTypes.GUILD_VOICE;
|
||||
const channelIsText = isTextChannel(channel);
|
||||
const {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow} = useChannelHoverPreload({
|
||||
channel,
|
||||
guild,
|
||||
defaultHiddenForChannel: channelIsVoice,
|
||||
enabled: !channelIsCategory,
|
||||
});
|
||||
const draggingChannel = activeDragItem?.type === DragItemType.CHANNEL ? activeDragItem : null;
|
||||
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
|
||||
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
|
||||
@@ -394,11 +401,12 @@ export const ChannelItem = observer(
|
||||
const singleClickConnectsToVoice =
|
||||
channelIsVoice && !Accessibility.voiceChannelJoinRequiresDoubleClick && !isVoiceSelected;
|
||||
const navigateToChannel = useCallback(() => {
|
||||
preloadChannelNow();
|
||||
NavigationCommands.selectChannel(guild.id, channel.id);
|
||||
if (MobileLayout.isMobileLayout()) {
|
||||
LayoutCommands.updateMobileLayoutState(false, true);
|
||||
}
|
||||
}, [guild.id, channel.id]);
|
||||
}, [guild.id, channel.id, preloadChannelNow]);
|
||||
const collapseVoiceCallView = useCallback(() => {
|
||||
if (!channelIsVoice) return;
|
||||
CompactVoiceCallHeight.setExpandedForKey(getGuildVoiceCallExpansionKey(channel.id), false);
|
||||
@@ -496,10 +504,12 @@ export const ChannelItem = observer(
|
||||
const [isPointerHovered, setIsPointerHovered] = useState(false);
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsPointerHovered(true);
|
||||
}, []);
|
||||
scheduleChannelPreload();
|
||||
}, [scheduleChannelPreload]);
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsPointerHovered(false);
|
||||
}, []);
|
||||
cancelChannelPreload();
|
||||
}, [cancelChannelPreload]);
|
||||
const hoverAffordancesActive =
|
||||
allowHoverAffordances &&
|
||||
(contextMenuOpen || showKeyboardAffordances || shouldShowSelectedState || isPointerHovered);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {DragItemType} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
|
||||
import {GuildChannelListSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton';
|
||||
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import * as LinkChannelCommands from '@app/features/channel/commands/LinkChannelCommands';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
@@ -54,6 +55,7 @@ import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {FAVORITES_GUILD_ID, ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CaretDownIcon, PlusIcon, UserPlusIcon} from '@phosphor-icons/react';
|
||||
@@ -233,11 +235,17 @@ const FavoriteChannelResolvedItem = observer(
|
||||
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
|
||||
unreadBadgesLevel,
|
||||
});
|
||||
const {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow} = useChannelHoverPreload({
|
||||
channel,
|
||||
guild,
|
||||
defaultHiddenForChannel: channel.type === ChannelTypes.GUILD_VOICE,
|
||||
});
|
||||
const handleClick = () => {
|
||||
if (LinkChannelCommands.openLinkChannel(channel)) {
|
||||
return;
|
||||
}
|
||||
markDirectSelection(DirectSelectionSurface.FAVORITES_LIST);
|
||||
preloadChannelNow();
|
||||
NavigationCommands.selectChannel(FAVORITES_GUILD_ID, favoriteChannel.channelId);
|
||||
};
|
||||
const handleContextMenu = (event: React.MouseEvent) => {
|
||||
@@ -301,6 +309,8 @@ const FavoriteChannelResolvedItem = observer(
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onLongPress={() => {}}
|
||||
onMouseEnter={scheduleChannelPreload}
|
||||
onMouseLeave={cancelChannelPreload}
|
||||
data-flx="app.favorites-channel-list-content.favorite-channel-item.generic-channel-item.click"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1285,7 +1285,6 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
||||
[NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.VOICE_SESSION_RESTORE]: {tone: SkeletonNagbarTone.VOICE, hasActions: true},
|
||||
[NagbarType.TERMS_ACCEPTANCE]: {tone: SkeletonNagbarTone.LEGAL, hasActions: true},
|
||||
[NagbarType.LINUX_INPUT_ACCESS]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.SOFTWARE_ENCODER]: {tone: SkeletonNagbarTone.ENCODER, hasActions: true},
|
||||
[NagbarType.STREAMER_MODE]: {tone: SkeletonNagbarTone.STREAMER, hasActions: true},
|
||||
[NagbarType.DOMAIN_MOVED]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHo
|
||||
import * as VoiceStateCommands from '@app/features/devtools/commands/VoiceStateCommands';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import Keybind from '@app/features/input/state/InputKeybind';
|
||||
import {formatKeyCombo} from '@app/features/input/utils/KeybindUtils';
|
||||
import {getPushToTalkHoldLabel} from '@app/features/input/utils/PushToTalkHint';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import {SettingsContextMenu} from '@app/features/ui/action_menu/SettingsContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
@@ -205,8 +205,6 @@ const UserAreaInner = observer(
|
||||
};
|
||||
}, [hasVoiceConnection]);
|
||||
const wrapperClassName = styles.userAreaInnerWrapper;
|
||||
const pushToTalkCombo = Keybind.getByAction('voice_push_to_talk').combo;
|
||||
const pushToTalkHint = formatKeyCombo(i18n, pushToTalkCombo);
|
||||
const isPushToTalkEffective = Keybind.isPushToTalkEffective();
|
||||
const microphoneState = selectUserAreaMicrophoneState({
|
||||
effectiveAudioMuted: isMuted,
|
||||
@@ -226,7 +224,7 @@ const UserAreaInner = observer(
|
||||
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
||||
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
|
||||
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
|
||||
if (isPushToTalkEffective) return i18n._(PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR, {pushToTalkHint});
|
||||
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
|
||||
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
|
||||
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
|
||||
})();
|
||||
@@ -234,7 +232,7 @@ const UserAreaInner = observer(
|
||||
if (isGuildDeafened) return getVoiceDeafenedByModeratorsStatusLabel(i18n, true);
|
||||
if (isGuildMuted) return i18n._(VOICE_MUTED_BY_MODERATORS_DESCRIPTOR);
|
||||
if (isPermissionMuted || muteReason === 'permission') return i18n._(VOICE_NO_SPEAK_PERMISSION_DESCRIPTOR);
|
||||
if (isPushToTalkEffective) return i18n._(PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR, {pushToTalkHint});
|
||||
if (isPushToTalkEffective) return getPushToTalkHoldLabel(i18n, PUSH_TO_TALK_IS_ON_HOLD_TO_SPEAK_DESCRIPTOR);
|
||||
if (effectiveMuted) return i18n._(UNMUTE_MICROPHONE_DESCRIPTOR);
|
||||
return i18n._(MUTE_MICROPHONE_DESCRIPTOR);
|
||||
})();
|
||||
|
||||
@@ -18,7 +18,6 @@ import Channels from '@app/features/channel/state/Channels';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
|
||||
import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
|
||||
@@ -257,7 +256,6 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
startupVoiceSessionRestoreSnapshotKey && startupVoiceSessionRestoreSnapshotKey === voiceSessionRestoreSnapshotKey,
|
||||
);
|
||||
})();
|
||||
const canShowLinuxInputAccess = NativePermission.shouldShowLinuxInputAccessNagbar;
|
||||
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
||||
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
||||
const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar;
|
||||
@@ -328,7 +326,6 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
canShowVisionaryMfa,
|
||||
canShowVoiceSessionRestore,
|
||||
needsTermsAcceptance,
|
||||
canShowLinuxInputAccess,
|
||||
canShowSoftwareEncoder,
|
||||
canShowStreamerMode,
|
||||
canShowDesktopUpdateReady,
|
||||
@@ -446,12 +443,6 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.canShowDesktopNotification,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.LINUX_INPUT_ACCESS,
|
||||
priority: 8.5,
|
||||
visible: conditions.canShowLinuxInputAccess,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.DESKTOP_DOWNLOAD,
|
||||
priority: 9,
|
||||
|
||||
@@ -23,7 +23,6 @@ export const NagbarType = {
|
||||
VISIONARY_MFA: 'visionary-mfa',
|
||||
VOICE_SESSION_RESTORE: 'voice-session-restore',
|
||||
TERMS_ACCEPTANCE: 'terms-acceptance',
|
||||
LINUX_INPUT_ACCESS: 'linux-input-access',
|
||||
SOFTWARE_ENCODER: 'software-encoder',
|
||||
STREAMER_MODE: 'streamer-mode',
|
||||
DOMAIN_MOVED: 'domain-moved',
|
||||
@@ -63,7 +62,6 @@ export interface NagbarConditions {
|
||||
canShowVisionaryMfa: boolean;
|
||||
canShowVoiceSessionRestore: boolean;
|
||||
needsTermsAcceptance: boolean;
|
||||
canShowLinuxInputAccess: boolean;
|
||||
canShowSoftwareEncoder: boolean;
|
||||
canShowStreamerMode: boolean;
|
||||
canShowDomainMoved: boolean;
|
||||
|
||||
@@ -14,7 +14,6 @@ import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_l
|
||||
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
|
||||
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
|
||||
import {LegacyPriceOptInNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LegacyPriceOptInNagbar';
|
||||
import {LinuxInputAccessNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LinuxInputAccessNagbar';
|
||||
import {PremiumExpiredNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumExpiredNagbar';
|
||||
import {PremiumGracePeriodNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumGracePeriodNagbar';
|
||||
import {PremiumOnboardingNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumOnboardingNagbar';
|
||||
@@ -217,14 +216,6 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.voice-session-restore-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.LINUX_INPUT_ACCESS:
|
||||
return (
|
||||
<LinuxInputAccessNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.linux-input-access-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.SOFTWARE_ENCODER:
|
||||
return (
|
||||
<SoftwareEncoderNagbar
|
||||
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import KeybindManager from '@app/features/app/keybindings/KeybindManager';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
import {getUserSettingsTabLabel} from '@app/features/user/components/settings_utils/SettingsConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useState} from 'react';
|
||||
|
||||
const LINUX_INPUT_ACCESS_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Enable input access to use system-wide shortcuts on Wayland. After changing input access, fully quit and restart {productName} so shortcuts can use it.',
|
||||
comment: 'Description for a Linux Wayland input-access nagbar. {productName} is the app name.',
|
||||
});
|
||||
const LINUX_INPUT_ACCESS_RELOGIN_DESCRIPTOR = msg({
|
||||
message: 'Input access was changed. Fully quit and restart {productName} so system-wide shortcuts can use it.',
|
||||
comment:
|
||||
'Description for a Linux Wayland input-access nagbar after access was changed. {productName} is the app name.',
|
||||
});
|
||||
const LINUX_INPUT_ACCESS_ERROR_DESCRIPTOR = msg({
|
||||
message:
|
||||
'Input access could not be enabled. Try again from {settingsMenuName} settings, then fully quit and restart {productName}.',
|
||||
comment:
|
||||
'Description for a Linux Wayland input-access nagbar after automatic permission setup failed. {settingsMenuName} is the shared user settings tab label for shortcuts and {productName} is the app name.',
|
||||
});
|
||||
const LINUX_INPUT_ACCESS_ENABLE_DESCRIPTOR = msg({
|
||||
message: 'Enable',
|
||||
comment: 'Button label in the Linux Wayland input-access nagbar.',
|
||||
});
|
||||
const LINUX_INPUT_ACCESS_RECHECK_DESCRIPTOR = msg({
|
||||
message: 'Recheck',
|
||||
comment: 'Button label in the Linux Wayland input-access nagbar after the user changes OS permissions.',
|
||||
});
|
||||
export const LinuxInputAccessNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const needsRelogin = NativePermission.linuxInputAccessGrantNeedsRelogin;
|
||||
const hasError = NativePermission.linuxInputAccessGrantError !== null;
|
||||
const shortcutsSettingsMenuName = getUserSettingsTabLabel(i18n, 'keybinds');
|
||||
const handleEnable = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await NativePermission.grantLinuxInputAccess();
|
||||
await KeybindManager.reapplyGlobalShortcuts();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const handleRecheck = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await NativePermission.recheckLinuxInputAccess();
|
||||
await KeybindManager.reapplyGlobalShortcuts();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
const message = needsRelogin
|
||||
? i18n._(LINUX_INPUT_ACCESS_RELOGIN_DESCRIPTOR, {productName: PRODUCT_NAME})
|
||||
: hasError
|
||||
? i18n._(LINUX_INPUT_ACCESS_ERROR_DESCRIPTOR, {
|
||||
settingsMenuName: shortcutsSettingsMenuName,
|
||||
productName: PRODUCT_NAME,
|
||||
})
|
||||
: i18n._(LINUX_INPUT_ACCESS_DESCRIPTION_DESCRIPTOR, {productName: PRODUCT_NAME});
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={NativePermission.dismissLinuxInputAccessNagbar}
|
||||
data-flx="app.app-layout.nagbars.linux-input-access-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={NativePermission.dismissLinuxInputAccessNagbar}
|
||||
message={message}
|
||||
actions={
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
onClick={needsRelogin ? handleRecheck : handleEnable}
|
||||
submitting={submitting}
|
||||
data-flx="app.app-layout.nagbars.linux-input-access-nagbar.nagbar-button.enable"
|
||||
>
|
||||
{needsRelogin
|
||||
? i18n._(LINUX_INPUT_ACCESS_RECHECK_DESCRIPTOR)
|
||||
: i18n._(LINUX_INPUT_ACCESS_ENABLE_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.linux-input-access-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
+9
-9
@@ -1,14 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DirectSelectionSurface, markDirectSelection} from '@app/features/app/components/layout/DirectSelectionOrigin';
|
||||
import {useGuildListItemPreload} from '@app/features/app/components/layout/sidebar_nav/UseGuildListItemPreload';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import {useHover} from '@app/features/app/hooks/useHover';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import GuildCount from '@app/features/guild/state/GuildCount';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
|
||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import * as AvatarSourceUtils from '@app/features/user/utils/AvatarSourceUtils';
|
||||
@@ -65,12 +64,12 @@ export function useGuildListItemInteraction({
|
||||
}: UseGuildListItemInteractionOptions): GuildListItemInteraction {
|
||||
const [hoverRef, isHovering] = useHover();
|
||||
const contextMenuOpen = useContextMenuHoverState(itemRef, isDesktopLayout);
|
||||
const selectedChannelId = SelectedChannel.selectedChannelIds.get(guild.id) ?? null;
|
||||
useEffect(() => {
|
||||
if (isMobileExperience || isSortingList || !isHovering) return;
|
||||
const timeoutId = window.setTimeout(() => GuildCount.requestCounts(guild.id, {force: false}), 250);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [guild.id, isHovering, isMobileExperience, isSortingList]);
|
||||
const {preloadChannelNow, selectedChannelId} = useGuildListItemPreload({
|
||||
guild,
|
||||
isHovering,
|
||||
isMobileExperience,
|
||||
isSortingList,
|
||||
});
|
||||
const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false);
|
||||
const hoverIconURL = AvatarSourceUtils.getGuildIconURL(guild, true);
|
||||
const isAnimatableIcon = hoverIconURL !== iconURL;
|
||||
@@ -85,12 +84,13 @@ export function useGuildListItemInteraction({
|
||||
}, [contextMenuOpen, hoverIconURL, isAnimatableIcon, isHovering, loadedAnimatedURL]);
|
||||
const handleSelect = useCallback(() => {
|
||||
markDirectSelection(DirectSelectionSurface.GUILD_RAIL);
|
||||
preloadChannelNow();
|
||||
if (isMobileExperience || selectedChannelId == null) {
|
||||
NavigationCommands.selectGuild(guild.id);
|
||||
return;
|
||||
}
|
||||
NavigationCommands.selectGuild(guild.id, selectedChannelId);
|
||||
}, [guild.id, isMobileExperience, selectedChannelId]);
|
||||
}, [guild.id, isMobileExperience, preloadChannelNow, selectedChannelId]);
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
if (!isKeyboardActivationKey(event.key)) return;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import GuildCount from '@app/features/guild/state/GuildCount';
|
||||
import {filterViewableChannels} from '@app/features/messaging/utils/ChannelShared';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
|
||||
interface UseGuildListItemPreloadOptions {
|
||||
readonly guild: Guild;
|
||||
readonly isHovering: boolean;
|
||||
readonly isMobileExperience: boolean;
|
||||
readonly isSortingList: boolean;
|
||||
}
|
||||
|
||||
interface UseGuildListItemPreloadResult {
|
||||
readonly preloadChannelNow: () => void;
|
||||
readonly selectedChannelId: string | null;
|
||||
}
|
||||
|
||||
function resolveSelectedChannel(selectedChannelId: string | null): Channel | null {
|
||||
if (selectedChannelId == null) {
|
||||
return null;
|
||||
}
|
||||
return Channels.getChannel(selectedChannelId) ?? null;
|
||||
}
|
||||
|
||||
function resolveDefaultHiddenForChannel(channel: Channel | null): boolean {
|
||||
if (channel == null) {
|
||||
return false;
|
||||
}
|
||||
return channel.type === ChannelTypes.GUILD_VOICE;
|
||||
}
|
||||
|
||||
export function useGuildListItemPreload({
|
||||
guild,
|
||||
isHovering,
|
||||
isMobileExperience,
|
||||
isSortingList,
|
||||
}: UseGuildListItemPreloadOptions): UseGuildListItemPreloadResult {
|
||||
const storedChannelId = SelectedChannel.selectedChannelIds.get(guild.id);
|
||||
let selectedChannelId: string | null = null;
|
||||
if (storedChannelId != null) {
|
||||
selectedChannelId = storedChannelId;
|
||||
}
|
||||
const selectedChannel = resolveSelectedChannel(selectedChannelId);
|
||||
const guildChannels = Channels.getGuildChannels(guild.id);
|
||||
const preloadTargetChannel = useMemo(() => {
|
||||
if (
|
||||
selectedChannel != null &&
|
||||
selectedChannel.guildId === guild.id &&
|
||||
selectedChannel.type !== ChannelTypes.GUILD_CATEGORY &&
|
||||
selectedChannel.type !== ChannelTypes.GUILD_LINK
|
||||
) {
|
||||
return selectedChannel;
|
||||
}
|
||||
const firstTextChannel = filterViewableChannels(guildChannels)[0];
|
||||
if (firstTextChannel == null) {
|
||||
return null;
|
||||
}
|
||||
return firstTextChannel;
|
||||
}, [guild.id, guildChannels, selectedChannel]);
|
||||
const {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow} = useChannelHoverPreload({
|
||||
channel: preloadTargetChannel,
|
||||
guild,
|
||||
defaultHiddenForChannel: resolveDefaultHiddenForChannel(preloadTargetChannel),
|
||||
enabled: !guild.unavailable && !isSortingList,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (isMobileExperience || isSortingList || !isHovering) {
|
||||
cancelChannelPreload();
|
||||
return;
|
||||
}
|
||||
scheduleChannelPreload();
|
||||
return cancelChannelPreload;
|
||||
}, [cancelChannelPreload, isHovering, isMobileExperience, isSortingList, scheduleChannelPreload]);
|
||||
useEffect(() => {
|
||||
if (isMobileExperience || isSortingList || !isHovering) return;
|
||||
const timeoutId = window.setTimeout(() => GuildCount.requestCounts(guild.id, {force: false}), 250);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [guild.id, isHovering, isMobileExperience, isSortingList]);
|
||||
return {preloadChannelNow, selectedChannelId};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME, PRODUCT_NAME};
|
||||
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`;
|
||||
export const DESKTOP_ENTRY_NAME = 'Fluxer';
|
||||
export const CANARY_DESKTOP_ENTRY_NAME = 'Fluxer Canary';
|
||||
export const VOICE_PROVIDER_NAME = 'LiveKit';
|
||||
export const PAYMENT_PROVIDER_NAME = 'Stripe';
|
||||
export const BLUESKY_PROVIDER_NAME = 'Bluesky';
|
||||
|
||||
@@ -141,8 +141,6 @@ export const Endpoints = {
|
||||
PREMIUM_REFUND_ELIGIBILITY: '/premium/refund-eligibility',
|
||||
PREMIUM_REFUND_LATEST: '/premium/refund-latest',
|
||||
STRIPE_CHECKOUT_SUBSCRIPTION: '/stripe/checkout/subscription',
|
||||
STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL: '/stripe/checkout/subscription/preapproval',
|
||||
STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL_CONTINUE: '/stripe/checkout/subscription/preapproval/continue',
|
||||
STRIPE_CHECKOUT_GIFT: '/stripe/checkout/gift',
|
||||
AGE_VERIFICATION: '/users/@me/age-verification',
|
||||
SWISH_AVAILABLE: '/swish/available',
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import {ensureMembersForMessages} from '@app/features/messaging/commands/MessageCommands';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {useCallback, useEffect, useRef} from 'react';
|
||||
|
||||
const CHANNEL_HOVER_PRELOAD_DELAY_MS = 220;
|
||||
|
||||
interface UseChannelHoverPreloadOptions {
|
||||
channel: Channel | null | undefined;
|
||||
guild?: Guild | null;
|
||||
defaultHiddenForChannel?: boolean;
|
||||
enabled?: boolean;
|
||||
preloadMemberList?: boolean;
|
||||
preloadMessages?: boolean;
|
||||
}
|
||||
|
||||
export function ensureMembersForCachedChannelMessages(channelId: string): void {
|
||||
const messages = Messages.getCachedMessages(channelId);
|
||||
if (!messages || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
void ensureMembersForMessages(messages.toArray());
|
||||
}
|
||||
|
||||
export function useChannelHoverPreload({
|
||||
channel,
|
||||
guild = null,
|
||||
enabled = true,
|
||||
preloadMessages = true,
|
||||
}: UseChannelHoverPreloadOptions): {
|
||||
scheduleChannelPreload: () => void;
|
||||
cancelChannelPreload: () => void;
|
||||
preloadChannelNow: () => void;
|
||||
} {
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const cancelChannelPreload = useCallback(() => {
|
||||
if (timerRef.current == null || typeof window === 'undefined') {
|
||||
timerRef.current = null;
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}, []);
|
||||
const preloadChannelNow = useCallback(() => {
|
||||
cancelChannelPreload();
|
||||
if (
|
||||
!enabled ||
|
||||
!channel ||
|
||||
channel.type === ChannelTypes.GUILD_CATEGORY ||
|
||||
channel.type === ChannelTypes.GUILD_LINK
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (preloadMessages) {
|
||||
if (!Messages.preloadLatestPage(channel.id, guild?.id ?? channel.guildId ?? null)) {
|
||||
ensureMembersForCachedChannelMessages(channel.id);
|
||||
}
|
||||
}
|
||||
}, [cancelChannelPreload, channel, enabled, guild, preloadMessages]);
|
||||
const scheduleChannelPreload = useCallback(() => {
|
||||
if (!enabled || !channel || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
cancelChannelPreload();
|
||||
timerRef.current = window.setTimeout(preloadChannelNow, CHANNEL_HOVER_PRELOAD_DELAY_MS);
|
||||
}, [cancelChannelPreload, channel, enabled, preloadChannelNow]);
|
||||
useEffect(() => cancelChannelPreload, [cancelChannelPreload]);
|
||||
return {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow};
|
||||
}
|
||||
@@ -33,9 +33,11 @@ import {
|
||||
shouldSuppressLocalShortcutForModalFocus,
|
||||
shouldSuppressShortcutForFullscreenMedia,
|
||||
} from '@app/features/app/keybindings/utils/ModalSuppression';
|
||||
import {reactToPushToTalkModeChanges} from '@app/features/app/keybindings/utils/PushToTalkModeReconcile';
|
||||
import {
|
||||
buildCustomRuntimeKeybinds,
|
||||
buildDefaultRuntimeKeybinds,
|
||||
gamepadSourceIdForKeybind,
|
||||
HOLD_ACTIONS,
|
||||
HOLD_ACTIONS_FOR_PTT_MODE,
|
||||
HOLD_ACTIONS_FOR_VOICE_ACTIVITY_MODE,
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
hookShortcutIdForAction,
|
||||
hookShortcutIdForKeybind,
|
||||
type RuntimeKeybind,
|
||||
sourceIdForKeybind,
|
||||
} from '@app/features/app/keybindings/utils/RuntimeKeybinds';
|
||||
import {LOCAL_SHORTCUT_ACTION_PRIORITY} from '@app/features/app/keybindings/utils/ShortcutPriority';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
@@ -51,8 +54,10 @@ import Channels from '@app/features/channel/state/Channels';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import GuildList from '@app/features/guild/state/GuildList';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import GlobalShortcuts, {getGlobalShortcutsApi} from '@app/features/input/state/GlobalShortcuts';
|
||||
import Keybind, {
|
||||
type CustomKeybindEntry,
|
||||
isKeybindCommand,
|
||||
type KeybindCommand,
|
||||
type KeybindConfig,
|
||||
type KeyCombo,
|
||||
@@ -68,9 +73,7 @@ import * as NavigationCommands from '@app/features/navigation/commands/Navigatio
|
||||
import Navigation from '@app/features/navigation/state/Navigation';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
|
||||
import NativePermission, {
|
||||
type LinuxInputAccessNagbarReason,
|
||||
} from '@app/features/permissions/system/state/NativePermission';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
import {ensureMacPermission} from '@app/features/permissions/system/utils/MacPermissionGate';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
@@ -87,11 +90,19 @@ import CompactVoiceCallHeight, {
|
||||
} from '@app/features/voice/state/CompactVoiceCallHeight';
|
||||
import MockIncomingCall from '@app/features/voice/state/MockIncomingCall';
|
||||
import VoiceCallFullscreen from '@app/features/voice/state/VoiceCallFullscreen';
|
||||
import type {
|
||||
GlobalShortcutActionDefinition,
|
||||
GlobalShortcutBinding,
|
||||
GlobalShortcutCombo,
|
||||
GlobalShortcutEvent,
|
||||
GlobalShortcutsApi,
|
||||
GlobalShortcutsSyncPayload,
|
||||
} from '@app/types/electron.d';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import CombokeysImport from 'combokeys';
|
||||
import {autorun, reaction} from 'mobx';
|
||||
import {autorun, compareStructural, reaction} from 'mobx';
|
||||
|
||||
const normalizeKeyboardShortcutKey = (key: string): string => {
|
||||
if (key === ' ') return 'space';
|
||||
@@ -108,6 +119,86 @@ export {
|
||||
|
||||
const ROUTE_ALLOWED_ACTIONS = new Set<KeybindCommand>(['system_open_theme_studio_popout']);
|
||||
const GAMEPAD_POLL_INTERVAL_MS = 50;
|
||||
const PORTAL_SOURCE_ID_PREFIX = 'portal:';
|
||||
|
||||
interface HoldBindingEntry {
|
||||
action: HoldAction;
|
||||
sourceId: string;
|
||||
gamepadSourceId: string;
|
||||
combo: KeyCombo;
|
||||
}
|
||||
|
||||
interface GlobalHoldRouting {
|
||||
hooksActive: boolean;
|
||||
supportsMouseButtons: boolean;
|
||||
supportsModifierOnly: boolean;
|
||||
}
|
||||
|
||||
function isHoldAction(action: string): action is HoldAction {
|
||||
return HOLD_ACTIONS.includes(action as HoldAction);
|
||||
}
|
||||
|
||||
function comboHasModifiers(combo: GlobalShortcutCombo): boolean {
|
||||
return combo.ctrl || combo.alt || combo.shift || combo.meta;
|
||||
}
|
||||
|
||||
function toGlobalShortcutCombo(combo: KeyCombo): GlobalShortcutCombo | null {
|
||||
if (combo.gamepadButton != null) return null;
|
||||
const hasKey = (combo.key ?? '') !== '' || (combo.code ?? '') !== '';
|
||||
if (!hasKey && combo.mouseButton == null) return null;
|
||||
const isMacOS = isNativeMacOS();
|
||||
const result: GlobalShortcutCombo = {
|
||||
key: combo.key ?? '',
|
||||
ctrl: Boolean(combo.ctrl) || (!isMacOS && Boolean(combo.ctrlOrMeta)),
|
||||
alt: Boolean(combo.alt),
|
||||
shift: Boolean(combo.shift),
|
||||
meta: Boolean(combo.meta) || (isMacOS && Boolean(combo.ctrlOrMeta)),
|
||||
};
|
||||
if (combo.code) result.code = combo.code;
|
||||
if (combo.mouseButton != null) result.mouseButton = combo.mouseButton;
|
||||
if (combo.modifierOnly) result.modifierOnly = true;
|
||||
if (combo.modifierOnly && combo.bothSides) result.bothSides = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
function createHoldBindingRuntime(entry: HoldBindingEntry): HoldBindingRuntime {
|
||||
const {combo} = entry;
|
||||
return {
|
||||
action: entry.action,
|
||||
sourceId: entry.sourceId,
|
||||
gamepadSourceId: entry.gamepadSourceId,
|
||||
combo,
|
||||
keycode: null,
|
||||
keyName: null,
|
||||
physicalKeyName: null,
|
||||
mouseButton: combo.mouseButton ?? null,
|
||||
gamepadButton: combo.gamepadButton ?? null,
|
||||
isModifierOnly: Boolean(combo.modifierOnly),
|
||||
ctrlOrMeta: Boolean(combo.ctrlOrMeta),
|
||||
requireBothSides: Boolean(combo.modifierOnly && combo.bothSides),
|
||||
modifiers: {
|
||||
ctrl: Boolean(combo.ctrl),
|
||||
alt: Boolean(combo.alt),
|
||||
shift: Boolean(combo.shift),
|
||||
meta: Boolean(combo.meta),
|
||||
},
|
||||
routing: null,
|
||||
pressedKeycodes: new Set<number>(),
|
||||
localPressedCodes: new Set<string>(),
|
||||
localActiveCode: null,
|
||||
localMouseActive: false,
|
||||
globalMouseActive: false,
|
||||
localKeyDown: null,
|
||||
localKeyUp: null,
|
||||
localMouseDown: null,
|
||||
localMouseUp: null,
|
||||
gamepadHeld: false,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyHoldSourceId(binding: HoldBindingRuntime): string {
|
||||
return `legacy:${hookShortcutIdForAction(binding.action, binding.combo)}`;
|
||||
}
|
||||
|
||||
class KeybindManager {
|
||||
private handlers = new Map<KeybindCommand, KeybindHandler>();
|
||||
@@ -117,8 +208,11 @@ class KeybindManager {
|
||||
private disposers: Array<() => void> = [];
|
||||
private combokeys: CombokeysInstance | null = null;
|
||||
private inputMonitoringHookStatus: 'unknown' | 'granted' | 'denied' = 'unknown';
|
||||
pttReleaseTimer: NodeJS.Timeout | null = null;
|
||||
private registeredGlobalHookShortcutIds = new Set<string>();
|
||||
private globalShortcutsEventUnsubscribe: (() => void) | null = null;
|
||||
private syncedGlobalSourceIds = new Set<string>();
|
||||
private syncedSourceIdByHookId = new Map<string, string>();
|
||||
private syncedGlobalActions: Array<GlobalShortcutActionDefinition> = [];
|
||||
private globalKeyHookUnsubscribes: Array<() => void> = [];
|
||||
private globalKeybindTriggeredUnsubscribe: (() => void) | null = null;
|
||||
private globalKeyHookStarted = false;
|
||||
@@ -420,26 +514,29 @@ class KeybindManager {
|
||||
() => this.refreshLocalShortcuts(),
|
||||
),
|
||||
);
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
const desired = this.computeDesiredGlobalHookShortcuts();
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(desired));
|
||||
}),
|
||||
);
|
||||
GlobalShortcuts.attach();
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
this.attachGlobalShortcuts(globalShortcutsApi);
|
||||
} else {
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
const desired = this.computeDesiredGlobalHookShortcuts();
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(desired));
|
||||
}),
|
||||
);
|
||||
}
|
||||
this.disposers.push(reactToPushToTalkModeChanges((options) => MediaEngine.handlePushToTalkModeChange(options)));
|
||||
this.disposers.push(
|
||||
reaction(
|
||||
() => Keybind.transmitMode,
|
||||
() => {
|
||||
MediaEngine.handlePushToTalkModeChange();
|
||||
() => ({entries: this.buildHoldBindingEntries(), routing: this.getGlobalHoldRouting()}),
|
||||
({entries, routing}) => {
|
||||
const bindings = entries.map(createHoldBindingRuntime);
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(bindings, routing));
|
||||
},
|
||||
{equals: compareStructural, fireImmediately: true},
|
||||
),
|
||||
);
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
const bindings = this.buildHoldBindings();
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(bindings));
|
||||
}),
|
||||
);
|
||||
this.disposers.push(
|
||||
autorun(() => {
|
||||
const pathname = Navigation.pathname;
|
||||
@@ -461,79 +558,74 @@ class KeybindManager {
|
||||
return this.inputSyncQueue;
|
||||
}
|
||||
|
||||
private buildHoldBindings(): Array<HoldBindingRuntime> {
|
||||
const bindings: Array<HoldBindingRuntime> = [];
|
||||
private buildHoldBindingEntries(): Array<HoldBindingEntry> {
|
||||
const entries: Array<HoldBindingEntry> = [];
|
||||
const inPttMode = Keybind.isPushToTalkEffective();
|
||||
const eligibleActions = inPttMode ? HOLD_ACTIONS_FOR_PTT_MODE : HOLD_ACTIONS_FOR_VOICE_ACTIVITY_MODE;
|
||||
const customs: ReadonlyArray<CustomKeybindEntry> = Keybind.getCustomKeybinds();
|
||||
const pushBinding = (action: HoldAction, combo: KeyCombo): void => {
|
||||
if (!this.isActionAllowedForCurrentView(action)) return;
|
||||
const hasBinding = !!(combo.key || combo.code || combo.gamepadButton != null || combo.mouseButton != null);
|
||||
if (!hasBinding) return;
|
||||
bindings.push({
|
||||
action,
|
||||
combo,
|
||||
keycode: null,
|
||||
keyName: null,
|
||||
physicalKeyName: null,
|
||||
mouseButton: combo.mouseButton ?? null,
|
||||
gamepadButton: combo.gamepadButton ?? null,
|
||||
isModifierOnly: Boolean(combo.modifierOnly),
|
||||
ctrlOrMeta: Boolean(combo.ctrlOrMeta),
|
||||
requireBothSides: Boolean(combo.modifierOnly && combo.bothSides),
|
||||
modifiers: {
|
||||
ctrl: Boolean(combo.ctrl),
|
||||
alt: Boolean(combo.alt),
|
||||
shift: Boolean(combo.shift),
|
||||
meta: Boolean(combo.meta),
|
||||
},
|
||||
routing: null,
|
||||
pressedKeycodes: new Set<number>(),
|
||||
localPressedCodes: new Set<string>(),
|
||||
localActiveCode: null,
|
||||
localMouseActive: false,
|
||||
globalMouseActive: false,
|
||||
localKeyDown: null,
|
||||
localKeyUp: null,
|
||||
localMouseDown: null,
|
||||
localMouseUp: null,
|
||||
gamepadHeld: false,
|
||||
});
|
||||
};
|
||||
for (const action of eligibleActions) {
|
||||
for (const entry of customs) {
|
||||
if (!entry.enabled) continue;
|
||||
if (entry.action !== action) continue;
|
||||
pushBinding(action, entry.combo);
|
||||
const combo = entry.combo;
|
||||
const hasBinding = !!(combo.key || combo.code || combo.gamepadButton != null || combo.mouseButton != null);
|
||||
if (!hasBinding) continue;
|
||||
const keybind = {id: entry.id, action};
|
||||
entries.push({
|
||||
action,
|
||||
sourceId: sourceIdForKeybind(keybind),
|
||||
gamepadSourceId: gamepadSourceIdForKeybind(keybind),
|
||||
combo: {...combo},
|
||||
});
|
||||
}
|
||||
}
|
||||
return bindings;
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async applyHoldBindings(bindings: Array<HoldBindingRuntime>): Promise<void> {
|
||||
private getGlobalHoldRouting(): GlobalHoldRouting | null {
|
||||
if (!getGlobalShortcutsApi()) return null;
|
||||
const status = GlobalShortcuts.status;
|
||||
return {
|
||||
hooksActive: GlobalShortcuts.hooksActive && GlobalShortcuts.backend !== 'portal',
|
||||
supportsMouseButtons: status?.supportsMouseButtons === true,
|
||||
supportsModifierOnly: status?.supportsModifierOnly === true,
|
||||
};
|
||||
}
|
||||
|
||||
private canRouteHoldBindingGlobally(binding: HoldBindingRuntime, routing: GlobalHoldRouting): boolean {
|
||||
if (!routing.hooksActive) return false;
|
||||
if (binding.mouseButton !== null) return routing.supportsMouseButtons;
|
||||
if (binding.isModifierOnly) return routing.supportsModifierOnly;
|
||||
return true;
|
||||
}
|
||||
|
||||
private rebuildHoldBindings(): void {
|
||||
const bindings = this.buildHoldBindingEntries().map(createHoldBindingRuntime);
|
||||
const routing = this.getGlobalHoldRouting();
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(bindings, routing));
|
||||
}
|
||||
|
||||
private async applyHoldBindings(
|
||||
bindings: Array<HoldBindingRuntime>,
|
||||
routing: GlobalHoldRouting | null,
|
||||
): Promise<void> {
|
||||
this.detachLocalHoldListener();
|
||||
this.releaseGlobalHoldBindings();
|
||||
this.releaseGamepadHoldBindings();
|
||||
this.holdBindings = bindings;
|
||||
if (bindings.length === 0 || this.suspended || !this.initialized) {
|
||||
this.maybeStopGlobalKeyHook();
|
||||
if (routing === null) this.maybeStopGlobalKeyHook();
|
||||
this.refreshGamepadPolling();
|
||||
return;
|
||||
}
|
||||
const electronApi = getElectronAPI();
|
||||
const globalHookAvailable = !!electronApi?.globalKeyHookStart;
|
||||
const wantsGlobal = bindings.some((b) => {
|
||||
const hasGlobalRoutable = !!(b.combo.key || b.combo.code || b.mouseButton != null);
|
||||
return hasGlobalRoutable && (b.combo.global ?? false) && globalHookAvailable;
|
||||
});
|
||||
let globalReady = false;
|
||||
if (wantsGlobal) {
|
||||
globalReady = await this.startGlobalKeyHook('push-to-talk');
|
||||
}
|
||||
const legacyGlobalReady = routing === null && (await this.startLegacyGlobalHoldHook(bindings));
|
||||
let needsLocal = false;
|
||||
for (const binding of bindings) {
|
||||
const hasGlobalRoutable = !!(binding.combo.key || binding.combo.code || binding.mouseButton != null);
|
||||
if (globalReady && hasGlobalRoutable && (binding.combo.global ?? false)) {
|
||||
const wantsGlobal = hasGlobalRoutable && (binding.combo.global ?? false);
|
||||
if (wantsGlobal && routing !== null && this.canRouteHoldBindingGlobally(binding, routing)) {
|
||||
binding.routing = 'global';
|
||||
} else if (wantsGlobal && legacyGlobalReady) {
|
||||
binding.keycode = jsKeyToUiohookKeycode(binding.combo.code ?? binding.combo.key);
|
||||
binding.keyName = keyNameForGlobalHook(binding.combo);
|
||||
binding.physicalKeyName = physicalKeyNameForGlobalHook(binding.combo);
|
||||
@@ -545,13 +637,158 @@ class KeybindManager {
|
||||
binding.routing = null;
|
||||
}
|
||||
}
|
||||
this.maybeStopGlobalKeyHook();
|
||||
if (routing === null) this.maybeStopGlobalKeyHook();
|
||||
if (needsLocal) {
|
||||
this.attachLocalHoldListener();
|
||||
}
|
||||
this.refreshGamepadPolling();
|
||||
}
|
||||
|
||||
private async startLegacyGlobalHoldHook(bindings: ReadonlyArray<HoldBindingRuntime>): Promise<boolean> {
|
||||
if (!getElectronAPI()?.globalKeyHookStart) return false;
|
||||
const wantsGlobal = bindings.some((binding) => {
|
||||
const hasGlobalRoutable = !!(binding.combo.key || binding.combo.code || binding.mouseButton != null);
|
||||
return hasGlobalRoutable && (binding.combo.global ?? false);
|
||||
});
|
||||
if (!wantsGlobal) return false;
|
||||
return this.startGlobalKeyHook();
|
||||
}
|
||||
|
||||
private attachGlobalShortcuts(api: GlobalShortcutsApi): void {
|
||||
this.globalShortcutsEventUnsubscribe = api.onEvent((event) => this.handleGlobalShortcutEvent(event));
|
||||
void this.enqueueInputSync(() => api.setPaused(this.suspended));
|
||||
this.disposers.push(
|
||||
reaction(
|
||||
() => this.buildGlobalShortcutsSyncPayload(),
|
||||
(payload) => {
|
||||
void this.enqueueInputSync(() => this.syncGlobalShortcuts(api, payload));
|
||||
},
|
||||
{equals: compareStructural, fireImmediately: true},
|
||||
),
|
||||
reaction(
|
||||
() => this.canDedupeGlobalPresses(),
|
||||
() => this.activeGlobalShortcutPressIds.clear(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private detachGlobalShortcuts(api: GlobalShortcutsApi): void {
|
||||
this.globalShortcutsEventUnsubscribe?.();
|
||||
this.globalShortcutsEventUnsubscribe = null;
|
||||
const payload: GlobalShortcutsSyncPayload = {bindings: [], actions: this.syncedGlobalActions};
|
||||
this.syncedGlobalSourceIds = new Set();
|
||||
this.syncedSourceIdByHookId = new Map();
|
||||
void this.enqueueInputSync(() => api.sync(payload));
|
||||
const pushToTalkEngaged = Keybind.pushToTalkHeld;
|
||||
const pushToMuteEngaged = Keybind.pushToMuteHeld;
|
||||
Keybind.resetPushToTalkState();
|
||||
Keybind.resetPushToMuteState();
|
||||
if (pushToTalkEngaged) MediaEngine.applyPushToTalkHold(false);
|
||||
if (pushToMuteEngaged) MediaEngine.applyPushToMuteHold(false);
|
||||
}
|
||||
|
||||
private buildGlobalShortcutsSyncPayload(): GlobalShortcutsSyncPayload {
|
||||
const bindings: Array<GlobalShortcutBinding> = [];
|
||||
const seenHookIds = new Set<string>();
|
||||
for (const entry of this.resolvedKeybinds) {
|
||||
if (!entry.allowGlobal || !(entry.combo.global ?? false)) continue;
|
||||
const combo = toGlobalShortcutCombo(entry.combo);
|
||||
if (!combo) continue;
|
||||
const hookId = hookShortcutIdForKeybind(entry);
|
||||
if (hookId === null || seenHookIds.has(hookId)) continue;
|
||||
seenHookIds.add(hookId);
|
||||
bindings.push({
|
||||
sourceId: sourceIdForKeybind(entry),
|
||||
action: entry.action,
|
||||
combo,
|
||||
});
|
||||
}
|
||||
const actions: Array<GlobalShortcutActionDefinition> = [];
|
||||
for (const config of Keybind.getDefaults()) {
|
||||
if (!config.allowGlobal) continue;
|
||||
const hold = isHoldAction(config.action);
|
||||
const preferred = bindings.find(
|
||||
(binding) =>
|
||||
binding.action === config.action &&
|
||||
binding.combo.mouseButton === undefined &&
|
||||
!(hold && comboHasModifiers(binding.combo)),
|
||||
);
|
||||
actions.push({
|
||||
action: config.action,
|
||||
description: config.label,
|
||||
preferredCombo: preferred ? preferred.combo : null,
|
||||
});
|
||||
}
|
||||
return {bindings, actions};
|
||||
}
|
||||
|
||||
private async syncGlobalShortcuts(api: GlobalShortcutsApi, payload: GlobalShortcutsSyncPayload): Promise<void> {
|
||||
if (!this.initialized) return;
|
||||
if (payload.bindings.length > 0) {
|
||||
await this.checkInputMonitoringPermission();
|
||||
}
|
||||
const sourceIds = new Set(payload.bindings.map((binding) => binding.sourceId));
|
||||
const sourceIdByHookId = new Map<string, string>();
|
||||
for (const entry of this.resolvedKeybinds) {
|
||||
const sourceId = sourceIdForKeybind(entry);
|
||||
if (!sourceIds.has(sourceId)) continue;
|
||||
const hookId = hookShortcutIdForKeybind(entry);
|
||||
if (hookId !== null) sourceIdByHookId.set(hookId, sourceId);
|
||||
}
|
||||
this.syncedGlobalSourceIds = sourceIds;
|
||||
this.syncedSourceIdByHookId = sourceIdByHookId;
|
||||
this.syncedGlobalActions = payload.actions;
|
||||
await api.sync(payload);
|
||||
}
|
||||
|
||||
private isSyncedGlobalShortcutSource(event: GlobalShortcutEvent): boolean {
|
||||
if (event.sourceId.startsWith(PORTAL_SOURCE_ID_PREFIX)) {
|
||||
return this.syncedGlobalActions.some((definition) => definition.action === event.action);
|
||||
}
|
||||
return this.syncedGlobalSourceIds.has(event.sourceId);
|
||||
}
|
||||
|
||||
private handleGlobalShortcutEvent(event: GlobalShortcutEvent): void {
|
||||
if (!isKeybindCommand(event.action)) return;
|
||||
const action = event.action;
|
||||
const handler = this.handlers.get(action);
|
||||
if (!handler) return;
|
||||
const payload = {type: event.phase, source: 'global', sourceId: event.sourceId} as const;
|
||||
if (isHoldAction(action)) {
|
||||
if (event.phase === 'press' && (this.suspended || !this.isSyncedGlobalShortcutSource(event))) return;
|
||||
handler(payload);
|
||||
return;
|
||||
}
|
||||
if (event.phase === 'release') {
|
||||
if (!this.activeGlobalShortcutPressIds.delete(event.sourceId)) return;
|
||||
handler(payload);
|
||||
return;
|
||||
}
|
||||
if (!this.isSyncedGlobalShortcutSource(event)) return;
|
||||
if (this.activeGlobalShortcutPressIds.has(event.sourceId)) return;
|
||||
if (this.suspended) return;
|
||||
if (!this.isActionAllowedForCurrentView(action)) return;
|
||||
if (shouldSuppressShortcutForFullscreenMedia()) return;
|
||||
if (Keybind.isActionMuted(action)) return;
|
||||
this.activeGlobalShortcutPressIds.add(event.sourceId);
|
||||
handler(payload);
|
||||
}
|
||||
|
||||
private canDedupeGlobalPresses(): boolean {
|
||||
const backend = GlobalShortcuts.backend;
|
||||
return GlobalShortcuts.hooksActive && backend !== 'portal' && backend !== 'none';
|
||||
}
|
||||
|
||||
private globalPressDedupeId(entry: RuntimeKeybind): string | null {
|
||||
const hookId = hookShortcutIdForKeybind(entry);
|
||||
if (getGlobalShortcutsApi()) {
|
||||
if (!this.canDedupeGlobalPresses() || hookId === null) return null;
|
||||
return this.syncedSourceIdByHookId.get(hookId) ?? null;
|
||||
}
|
||||
if (!(entry.combo.global ?? false)) return null;
|
||||
return hookId !== null && this.registeredGlobalHookShortcutIds.has(hookId) ? hookId : null;
|
||||
}
|
||||
|
||||
private attachLocalHoldListener(): void {
|
||||
if (this.localHoldListenerAttached) {
|
||||
this.detachLocalHoldListener();
|
||||
@@ -566,14 +803,14 @@ class KeybindManager {
|
||||
binding.localPressedCodes.add(event.code);
|
||||
const required = this.requiredModifierKeyCount(binding);
|
||||
if (binding.localPressedCodes.size === required) {
|
||||
this.fireHoldHandler(binding, 'press', 'local');
|
||||
this.fireHoldHandler(binding, 'press', 'local', binding.sourceId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!this.localKeyEventMatchesBinding(binding, event)) continue;
|
||||
if (binding.localActiveCode === event.code) continue;
|
||||
binding.localActiveCode = event.code;
|
||||
this.fireHoldHandler(binding, 'press', 'local');
|
||||
this.fireHoldHandler(binding, 'press', 'local', binding.sourceId);
|
||||
}
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent): void => {
|
||||
@@ -585,13 +822,13 @@ class KeybindManager {
|
||||
const wasAtThreshold = binding.localPressedCodes.size === required;
|
||||
binding.localPressedCodes.delete(event.code);
|
||||
if (wasAtThreshold) {
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (binding.localActiveCode !== event.code) continue;
|
||||
binding.localActiveCode = null;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
};
|
||||
const onMouseDown = (event: MouseEvent): void => {
|
||||
@@ -602,7 +839,7 @@ class KeybindManager {
|
||||
if (!this.matchesModifiers(binding, event)) continue;
|
||||
if (binding.localMouseActive) continue;
|
||||
binding.localMouseActive = true;
|
||||
this.fireHoldHandler(binding, 'press', 'local');
|
||||
this.fireHoldHandler(binding, 'press', 'local', binding.sourceId);
|
||||
}
|
||||
};
|
||||
const onMouseUp = (event: MouseEvent): void => {
|
||||
@@ -612,7 +849,7 @@ class KeybindManager {
|
||||
if (event.button !== binding.mouseButton) continue;
|
||||
if (!binding.localMouseActive) continue;
|
||||
binding.localMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
};
|
||||
for (const binding of this.holdBindings) {
|
||||
@@ -626,19 +863,19 @@ class KeybindManager {
|
||||
if (binding.routing !== 'local') continue;
|
||||
if (binding.localActiveCode !== null) {
|
||||
binding.localActiveCode = null;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
if (binding.localPressedCodes.size > 0) {
|
||||
const required = this.requiredModifierKeyCount(binding);
|
||||
const wasAtThreshold = binding.localPressedCodes.size >= required;
|
||||
binding.localPressedCodes.clear();
|
||||
if (wasAtThreshold) {
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
}
|
||||
if (binding.localMouseActive) {
|
||||
binding.localMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -682,19 +919,19 @@ class KeybindManager {
|
||||
for (const binding of this.holdBindings) {
|
||||
if (binding.localActiveCode !== null) {
|
||||
binding.localActiveCode = null;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
if (binding.localPressedCodes.size > 0) {
|
||||
const required = this.requiredModifierKeyCount(binding);
|
||||
const wasAtThreshold = binding.localPressedCodes.size >= required;
|
||||
binding.localPressedCodes.clear();
|
||||
if (wasAtThreshold) {
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
}
|
||||
if (binding.localMouseActive) {
|
||||
binding.localMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.sourceId);
|
||||
}
|
||||
binding.localKeyDown = null;
|
||||
binding.localKeyUp = null;
|
||||
@@ -810,23 +1047,24 @@ class KeybindManager {
|
||||
if (type === 'press' && shouldSuppressLocalShortcutForModalFocus(binding, event.target ?? null)) continue;
|
||||
const id = hookShortcutIdForKeybind(binding);
|
||||
if (!id) continue;
|
||||
const isRegisteredGlobalShortcut = this.isHookShortcutRegistered(binding);
|
||||
const globalPressId = this.globalPressDedupeId(binding);
|
||||
const sourceId = sourceIdForKeybind(binding);
|
||||
if (type === 'release') {
|
||||
if (!this.activeLocalShortcutPressIds.delete(id)) continue;
|
||||
if (isRegisteredGlobalShortcut) {
|
||||
this.activeGlobalShortcutPressIds.delete(id);
|
||||
if (globalPressId !== null) {
|
||||
this.activeGlobalShortcutPressIds.delete(globalPressId);
|
||||
}
|
||||
this.fireShortcutHandler(binding, type, 'local', {shiftKey: event.shiftKey});
|
||||
this.fireShortcutHandler(binding, type, 'local', sourceId, {shiftKey: event.shiftKey});
|
||||
continue;
|
||||
}
|
||||
if (!this.comboModifiersMatch(combo, event)) continue;
|
||||
if (this.activeLocalShortcutPressIds.has(id)) continue;
|
||||
if (isRegisteredGlobalShortcut) {
|
||||
if (this.activeGlobalShortcutPressIds.has(id)) continue;
|
||||
this.activeGlobalShortcutPressIds.add(id);
|
||||
if (globalPressId !== null) {
|
||||
if (this.activeGlobalShortcutPressIds.has(globalPressId)) continue;
|
||||
this.activeGlobalShortcutPressIds.add(globalPressId);
|
||||
}
|
||||
this.activeLocalShortcutPressIds.add(id);
|
||||
this.fireShortcutHandler(binding, type, 'local', {shiftKey: event.shiftKey});
|
||||
this.fireShortcutHandler(binding, type, 'local', sourceId, {shiftKey: event.shiftKey});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,13 +1086,9 @@ class KeybindManager {
|
||||
for (const shortcut of comboToCombokeysStrings(entry.combo)) {
|
||||
this.activeLocalShortcutPressIds.delete(shortcut);
|
||||
}
|
||||
const registeredHookShortcutId = hookShortcutIdForKeybind(entry);
|
||||
if (
|
||||
(entry.combo.global ?? false) &&
|
||||
registeredHookShortcutId &&
|
||||
this.registeredGlobalHookShortcutIds.has(registeredHookShortcutId)
|
||||
) {
|
||||
this.activeGlobalShortcutPressIds.delete(registeredHookShortcutId);
|
||||
const globalPressId = this.globalPressDedupeId(entry);
|
||||
if (globalPressId !== null) {
|
||||
this.activeGlobalShortcutPressIds.delete(globalPressId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,7 +1118,7 @@ class KeybindManager {
|
||||
if (!keyboardEventStartsComboPress(entry.combo, event, {isMacOS})) continue;
|
||||
if (this.activeLocalShortcutPressIds.has(id)) {
|
||||
if (
|
||||
this.isHookShortcutRegistered(entry) ||
|
||||
this.globalPressDedupeId(entry) !== null ||
|
||||
!keyboardEventCanRecoverStaleMacMetaPress(entry.combo, event, {isMacOS})
|
||||
) {
|
||||
continue;
|
||||
@@ -1013,7 +1247,7 @@ class KeybindManager {
|
||||
for (const binding of this.holdBindings) {
|
||||
if (binding.gamepadHeld) {
|
||||
binding.gamepadHeld = false;
|
||||
this.fireHoldHandler(binding, 'release', 'local');
|
||||
this.fireHoldHandler(binding, 'release', 'local', binding.gamepadSourceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1021,7 +1255,7 @@ class KeybindManager {
|
||||
private releaseGamepadShortcutStates(): void {
|
||||
for (const {binding, pressed} of this.gamepadShortcutStates.values()) {
|
||||
if (pressed) {
|
||||
this.fireShortcutHandler(binding, 'release', 'local');
|
||||
this.fireShortcutHandler(binding, 'release', 'local', gamepadSourceIdForKeybind(binding));
|
||||
}
|
||||
}
|
||||
this.gamepadShortcutStates.clear();
|
||||
@@ -1058,7 +1292,7 @@ class KeybindManager {
|
||||
const pressed = this.isGamepadButtonPressed(pads, target);
|
||||
if (pressed === binding.gamepadHeld) continue;
|
||||
binding.gamepadHeld = pressed;
|
||||
this.fireHoldHandler(binding, pressed ? 'press' : 'release', 'local');
|
||||
this.fireHoldHandler(binding, pressed ? 'press' : 'release', 'local', binding.gamepadSourceId);
|
||||
}
|
||||
for (const binding of this.localGamepadShortcutKeybinds) {
|
||||
if (!this.isActionAllowedForCurrentView(binding.action)) continue;
|
||||
@@ -1069,7 +1303,7 @@ class KeybindManager {
|
||||
const previous = this.gamepadShortcutStates.get(id)?.pressed ?? false;
|
||||
if (pressed === previous) continue;
|
||||
this.gamepadShortcutStates.set(id, {binding, pressed});
|
||||
this.fireShortcutHandler(binding, pressed ? 'press' : 'release', 'local');
|
||||
this.fireShortcutHandler(binding, pressed ? 'press' : 'release', 'local', gamepadSourceIdForKeybind(binding));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1082,22 +1316,30 @@ class KeybindManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
private fireHoldHandler(binding: HoldBindingRuntime, type: 'press' | 'release', source: ShortcutSource): void {
|
||||
private fireHoldHandler(
|
||||
binding: HoldBindingRuntime,
|
||||
type: 'press' | 'release',
|
||||
source: ShortcutSource,
|
||||
sourceId: string,
|
||||
): void {
|
||||
const handler = this.handlers.get(binding.action);
|
||||
if (!handler) return;
|
||||
if (type === 'press') {
|
||||
if (this.suspended) return;
|
||||
if (!this.isActionAllowedForCurrentView(binding.action)) return;
|
||||
if (shouldSuppressShortcutForFullscreenMedia()) return;
|
||||
if (Keybind.isActionMuted(binding.action)) return;
|
||||
if (source === 'local') {
|
||||
if (!this.isActionAllowedForCurrentView(binding.action)) return;
|
||||
if (shouldSuppressShortcutForFullscreenMedia()) return;
|
||||
if (Keybind.isActionMuted(binding.action)) return;
|
||||
}
|
||||
}
|
||||
handler({type, source});
|
||||
handler({type, source, sourceId});
|
||||
}
|
||||
|
||||
private fireShortcutHandler(
|
||||
binding: RuntimeKeybind,
|
||||
type: 'press' | 'release',
|
||||
source: ShortcutSource,
|
||||
sourceId: string,
|
||||
options: {shiftKey?: boolean} = {},
|
||||
): void {
|
||||
if (this.suspended) return;
|
||||
@@ -1106,13 +1348,20 @@ class KeybindManager {
|
||||
if (Keybind.isActionMuted(binding.action)) return;
|
||||
const handler = this.handlers.get(binding.action);
|
||||
if (!handler) return;
|
||||
handler({type, source, shiftKey: options.shiftKey});
|
||||
handler({type, source, sourceId, shiftKey: options.shiftKey});
|
||||
}
|
||||
|
||||
async reapplyGlobalShortcuts() {
|
||||
if (!this.initialized) return;
|
||||
await this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
await this.enqueueInputSync(() => this.applyHoldBindings(this.buildHoldBindings()));
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
const payload = this.buildGlobalShortcutsSyncPayload();
|
||||
await this.enqueueInputSync(() => this.syncGlobalShortcuts(globalShortcutsApi, payload));
|
||||
} else {
|
||||
await this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
}
|
||||
this.rebuildHoldBindings();
|
||||
await this.inputSyncQueue;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@@ -1120,6 +1369,11 @@ class KeybindManager {
|
||||
this.initialized = false;
|
||||
this.disposers.forEach((dispose) => dispose());
|
||||
this.disposers = [];
|
||||
GlobalShortcuts.detach();
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
this.detachGlobalShortcuts(globalShortcutsApi);
|
||||
}
|
||||
if (this.globalKeybindTriggeredUnsubscribe) {
|
||||
this.globalKeybindTriggeredUnsubscribe();
|
||||
this.globalKeybindTriggeredUnsubscribe = null;
|
||||
@@ -1145,7 +1399,7 @@ class KeybindManager {
|
||||
this.combokeys = null;
|
||||
}
|
||||
|
||||
async startGlobalKeyHook(reason: LinuxInputAccessNagbarReason = 'global-hotkeys'): Promise<boolean> {
|
||||
private async startGlobalKeyHook(): Promise<boolean> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.globalKeyHookStart) return false;
|
||||
if (this.globalKeyHookStarted) return true;
|
||||
@@ -1153,19 +1407,8 @@ class KeybindManager {
|
||||
return false;
|
||||
}
|
||||
const started = await electronApi.globalKeyHookStart();
|
||||
if (!started) {
|
||||
if (NativePermission.isLinuxWaylandDesktop) {
|
||||
void NativePermission.recheckLinuxInputAccess();
|
||||
NativePermission.requestLinuxInputAccessNagbar(reason);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!started) return false;
|
||||
this.globalKeyHookStarted = true;
|
||||
if (NativePermission.isLinuxWaylandDesktop) {
|
||||
void NativePermission.recheckLinuxInputAccess().then((status) => {
|
||||
if (status === 'blocked') NativePermission.requestLinuxInputAccessNagbar(reason);
|
||||
});
|
||||
}
|
||||
const keyEventUnsub = electronApi.onGlobalKeyEvent?.((event) => {
|
||||
this.handleGlobalKeyEvent(
|
||||
event as {
|
||||
@@ -1222,11 +1465,15 @@ class KeybindManager {
|
||||
if (!this.isActionAllowedForCurrentView(keybind.action)) return;
|
||||
if (event.type === 'keydown' && shouldSuppressShortcutForFullscreenMedia()) return;
|
||||
if (Keybind.isActionMuted(keybind.action)) return;
|
||||
handler({type: event.type === 'keydown' ? 'press' : 'release', source: 'global'});
|
||||
handler({
|
||||
type: event.type === 'keydown' ? 'press' : 'release',
|
||||
source: 'global',
|
||||
sourceId: `legacy:${event.id}`,
|
||||
});
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
stopGlobalKeyHook(): void {
|
||||
private stopGlobalKeyHook(): void {
|
||||
const electronApi = getElectronAPI();
|
||||
this.globalKeyHookUnsubscribes.forEach((unsub) => unsub());
|
||||
this.globalKeyHookUnsubscribes = [];
|
||||
@@ -1251,7 +1498,7 @@ class KeybindManager {
|
||||
if (!keybind || !HOLD_ACTIONS.includes(keybind.action as HoldAction)) continue;
|
||||
const handler = this.handlers.get(keybind.action);
|
||||
if (handler) {
|
||||
handler({type: 'release', source: 'global'});
|
||||
handler({type: 'release', source: 'global', sourceId: `legacy:${id}`});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1265,11 +1512,11 @@ class KeybindManager {
|
||||
if (binding.routing !== 'global') continue;
|
||||
if (binding.pressedKeycodes.size > 0) {
|
||||
binding.pressedKeycodes.clear();
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
if (binding.globalMouseActive) {
|
||||
binding.globalMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1316,14 +1563,14 @@ class KeybindManager {
|
||||
if (binding.pressedKeycodes.has(event.keycode)) return;
|
||||
binding.pressedKeycodes.add(event.keycode);
|
||||
if (binding.pressedKeycodes.size === requiredCount) {
|
||||
this.fireHoldHandler(binding, 'press', 'global');
|
||||
this.fireHoldHandler(binding, 'press', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
} else {
|
||||
if (!binding.pressedKeycodes.has(event.keycode)) return;
|
||||
const wasAtThreshold = binding.pressedKeycodes.size === requiredCount;
|
||||
binding.pressedKeycodes.delete(event.keycode);
|
||||
if (wasAtThreshold) {
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -1332,13 +1579,13 @@ class KeybindManager {
|
||||
if (!this.globalKeyEventMatchesHoldBinding(binding, event)) return;
|
||||
if (event.type === 'keyup') {
|
||||
if (!binding.pressedKeycodes.delete(event.keycode)) return;
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
return;
|
||||
}
|
||||
if (!this.globalHoldModifiersMatch(binding, event)) return;
|
||||
if (binding.pressedKeycodes.has(event.keycode)) return;
|
||||
binding.pressedKeycodes.add(event.keycode);
|
||||
this.fireHoldHandler(binding, 'press', 'global');
|
||||
this.fireHoldHandler(binding, 'press', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
|
||||
private globalKeyEventMatchesHoldBinding(
|
||||
@@ -1422,33 +1669,48 @@ class KeybindManager {
|
||||
if (event.type === 'mouseup') {
|
||||
if (!binding.globalMouseActive) continue;
|
||||
binding.globalMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
continue;
|
||||
}
|
||||
if (!this.globalHoldModifiersMatch(binding, event)) continue;
|
||||
if (binding.globalMouseActive) continue;
|
||||
binding.globalMouseActive = true;
|
||||
this.fireHoldHandler(binding, 'press', 'global');
|
||||
this.fireHoldHandler(binding, 'press', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
}
|
||||
|
||||
suspend(): void {
|
||||
const wasSuspended = this.suspended;
|
||||
this.manualSuspendCount += 1;
|
||||
this.combokeys?.reset();
|
||||
this.releaseHoldBindingsForSuspension();
|
||||
this.detachLocalEditableShortcutCaptureListener();
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
if (!wasSuspended && this.initialized) {
|
||||
void this.enqueueInputSync(() => globalShortcutsApi.setPaused(true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
void this.enqueueInputSync(() => {
|
||||
this.stopGlobalKeyHook();
|
||||
});
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
const wasSuspended = this.suspended;
|
||||
this.manualSuspendCount = Math.max(0, this.manualSuspendCount - 1);
|
||||
if (!this.suspended) {
|
||||
this.refreshLocalShortcuts();
|
||||
if (this.suspended) return;
|
||||
this.refreshLocalShortcuts();
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (globalShortcutsApi) {
|
||||
if (wasSuspended && this.initialized) {
|
||||
void this.enqueueInputSync(() => globalShortcutsApi.setPaused(false));
|
||||
}
|
||||
} else {
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(this.buildHoldBindings()));
|
||||
}
|
||||
this.rebuildHoldBindings();
|
||||
}
|
||||
|
||||
isSuspended(): boolean {
|
||||
@@ -1458,18 +1720,21 @@ class KeybindManager {
|
||||
private setRouteSuspended(value: boolean): void {
|
||||
if (this.routeSuspended === value) return;
|
||||
this.routeSuspended = value;
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
if (value) {
|
||||
this.combokeys?.reset();
|
||||
this.releaseHoldBindingsForSuspension();
|
||||
this.detachLocalHoldListener();
|
||||
this.releaseGamepadHoldBindings();
|
||||
this.detachLocalEditableShortcutCaptureListener();
|
||||
void this.enqueueInputSync(() => {
|
||||
this.stopGlobalKeyHook();
|
||||
});
|
||||
}
|
||||
if (!this.suspended) {
|
||||
this.refreshLocalShortcuts();
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
void this.enqueueInputSync(() => this.applyHoldBindings(this.buildHoldBindings()));
|
||||
if (!globalShortcutsApi) {
|
||||
void this.enqueueInputSync(() => this.applyGlobalShortcuts(this.computeDesiredGlobalHookShortcuts()));
|
||||
}
|
||||
if (!this.localHoldListenerAttached && this.holdBindings.some((binding) => binding.routing === 'local')) {
|
||||
this.attachLocalHoldListener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1536,15 +1801,6 @@ class KeybindManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
private isHookShortcutRegistered(binding: RuntimeKeybind): boolean {
|
||||
return this.isHookShortcutIdRegistered(binding.action, binding.combo);
|
||||
}
|
||||
|
||||
private isHookShortcutIdRegistered(action: KeybindCommand, combo: KeyCombo): boolean {
|
||||
const id = hookShortcutIdForAction(action, combo);
|
||||
return id !== null && this.registeredGlobalHookShortcutIds.has(id);
|
||||
}
|
||||
|
||||
private computeDesiredGlobalHookShortcuts(): Map<string, KeyCombo> {
|
||||
const desiredCombos = new Map<string, KeyCombo>();
|
||||
for (const k of this.activeGlobalKeybinds) {
|
||||
@@ -1611,11 +1867,11 @@ class KeybindManager {
|
||||
if (hookShortcutIdForAction(binding.action, binding.combo) !== shortcutId) continue;
|
||||
if (binding.pressedKeycodes.size > 0) {
|
||||
binding.pressedKeycodes.clear();
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
if (binding.globalMouseActive) {
|
||||
binding.globalMouseActive = false;
|
||||
this.fireHoldHandler(binding, 'release', 'global');
|
||||
this.fireHoldHandler(binding, 'release', 'global', legacyHoldSourceId(binding));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1737,7 +1993,6 @@ class KeybindManager {
|
||||
const {combo, action} = entry;
|
||||
const requiresKeyboardMode = entry.requiresKeyboardMode ?? false;
|
||||
const requiresMessageFocus = entry.requiresMessageFocus ?? false;
|
||||
const registeredHookShortcutId = hookShortcutIdForKeybind(entry);
|
||||
const handler = this.handlers.get(action);
|
||||
if (!handler) return false;
|
||||
if (type === 'press' && event.repeat) return false;
|
||||
@@ -1748,11 +2003,6 @@ class KeybindManager {
|
||||
}
|
||||
if (this.shouldIgnoreLocalShortcutEvent(entry, event)) return false;
|
||||
if (Keybind.isActionMuted(action)) return false;
|
||||
const isRegisteredGlobalShortcut = Boolean(
|
||||
(combo.global ?? false) &&
|
||||
registeredHookShortcutId &&
|
||||
this.registeredGlobalHookShortcutIds.has(registeredHookShortcutId),
|
||||
);
|
||||
if (requiresKeyboardMode && !KeyboardMode.keyboardModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
@@ -1771,17 +2021,19 @@ class KeybindManager {
|
||||
}
|
||||
focusedChannel = MessageFocus.getFocusedChannel();
|
||||
}
|
||||
if (isRegisteredGlobalShortcut && registeredHookShortcutId) {
|
||||
const globalPressId = this.globalPressDedupeId(entry);
|
||||
if (globalPressId !== null) {
|
||||
if (type === 'press') {
|
||||
if (this.activeGlobalShortcutPressIds.has(registeredHookShortcutId)) return false;
|
||||
this.activeGlobalShortcutPressIds.add(registeredHookShortcutId);
|
||||
if (this.activeGlobalShortcutPressIds.has(globalPressId)) return false;
|
||||
this.activeGlobalShortcutPressIds.add(globalPressId);
|
||||
} else {
|
||||
this.activeGlobalShortcutPressIds.delete(registeredHookShortcutId);
|
||||
this.activeGlobalShortcutPressIds.delete(globalPressId);
|
||||
}
|
||||
}
|
||||
handler({
|
||||
type,
|
||||
source: 'local',
|
||||
sourceId: sourceIdForKeybind(entry),
|
||||
context: focusedMessage ? {focusedMessage, focusedChannel} : undefined,
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
|
||||
+33
-45
@@ -201,57 +201,45 @@ export function registerDefaultKeybindHandlers(host: HandlerHost, i18n: I18n): v
|
||||
if (type !== 'press') return;
|
||||
ThemeStudioCommands.openThemeStudioPopout();
|
||||
});
|
||||
host.register('voice_push_to_talk', ({type}) => {
|
||||
if (!Keybind.isPushToTalkEffective()) return;
|
||||
if (type === 'press') {
|
||||
showPushToTalkDeafenedModalIfNeeded(host, i18n);
|
||||
if (host.pttReleaseTimer) {
|
||||
clearTimeout(host.pttReleaseTimer);
|
||||
host.pttReleaseTimer = null;
|
||||
}
|
||||
const shouldUnmute = Keybind.handlePushToTalkPress();
|
||||
if (shouldUnmute) {
|
||||
MediaEngine.applyPushToTalkHold(true);
|
||||
}
|
||||
} else {
|
||||
const shouldMute = Keybind.handlePushToTalkRelease();
|
||||
if (shouldMute) {
|
||||
const delay = Keybind.pushToTalkReleaseDelay;
|
||||
host.pttReleaseTimer = setTimeout(() => {
|
||||
host.pttReleaseTimer = null;
|
||||
MediaEngine.applyPushToTalkHold(false);
|
||||
}, delay);
|
||||
}
|
||||
host.register('voice_push_to_talk', ({type, sourceId}) => {
|
||||
if (type === 'release') {
|
||||
Keybind.releaseHoldSource('voice_push_to_talk', sourceId, () => MediaEngine.applyPushToTalkHold(false));
|
||||
return;
|
||||
}
|
||||
});
|
||||
host.register('voice_push_to_mute', ({type}) => {
|
||||
if (Keybind.isPushToTalkEffective()) return;
|
||||
MediaEngine.applyPushToMuteHold(type === 'press');
|
||||
});
|
||||
host.register('voice_push_to_talk_priority', ({type}) => {
|
||||
if (!Keybind.isPushToTalkEffective()) return;
|
||||
if (type === 'press') {
|
||||
showPushToTalkDeafenedModalIfNeeded(host, i18n);
|
||||
if (host.pttReleaseTimer) {
|
||||
clearTimeout(host.pttReleaseTimer);
|
||||
host.pttReleaseTimer = null;
|
||||
}
|
||||
Keybind.handlePushToTalkPress();
|
||||
Keybind.setPrioritySpeakerHeld(true);
|
||||
showPushToTalkDeafenedModalIfNeeded(host, i18n);
|
||||
if (Keybind.pressHoldSource('voice_push_to_talk', sourceId)) {
|
||||
MediaEngine.applyPushToTalkHold(true);
|
||||
} else {
|
||||
Keybind.handlePushToTalkRelease();
|
||||
Keybind.setPrioritySpeakerHeld(false);
|
||||
const delay = Keybind.pushToTalkReleaseDelay;
|
||||
host.pttReleaseTimer = setTimeout(() => {
|
||||
host.pttReleaseTimer = null;
|
||||
MediaEngine.applyPushToTalkHold(false);
|
||||
}, delay);
|
||||
}
|
||||
});
|
||||
host.register('voice_priority_vad', ({type}) => {
|
||||
host.register('voice_push_to_mute', ({type, sourceId}) => {
|
||||
if (type === 'release') {
|
||||
Keybind.releaseHoldSource('voice_push_to_mute', sourceId, () => MediaEngine.applyPushToMuteHold(false));
|
||||
return;
|
||||
}
|
||||
if (Keybind.isPushToTalkEffective()) return;
|
||||
Keybind.setPrioritySpeakerHeld(type === 'press');
|
||||
if (Keybind.pressHoldSource('voice_push_to_mute', sourceId)) {
|
||||
MediaEngine.applyPushToMuteHold(true);
|
||||
}
|
||||
});
|
||||
host.register('voice_push_to_talk_priority', ({type, sourceId}) => {
|
||||
if (type === 'release') {
|
||||
Keybind.releaseHoldSource('voice_push_to_talk_priority', sourceId, () => MediaEngine.applyPushToTalkHold(false));
|
||||
return;
|
||||
}
|
||||
if (!Keybind.isPushToTalkEffective()) return;
|
||||
showPushToTalkDeafenedModalIfNeeded(host, i18n);
|
||||
if (Keybind.pressHoldSource('voice_push_to_talk_priority', sourceId)) {
|
||||
MediaEngine.applyPushToTalkHold(true);
|
||||
}
|
||||
});
|
||||
host.register('voice_priority_vad', ({type, sourceId}) => {
|
||||
if (type === 'release') {
|
||||
Keybind.releaseHoldSource('voice_priority_vad', sourceId);
|
||||
return;
|
||||
}
|
||||
if (Keybind.isPushToTalkEffective()) return;
|
||||
Keybind.pressHoldSource('voice_priority_vad', sourceId);
|
||||
});
|
||||
host.register('voice_toggle_vad', ({type}) => {
|
||||
if (type !== 'press') return;
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface HandlerHost {
|
||||
readonly logger: Logger;
|
||||
readonly currentChannelId: string | null;
|
||||
readonly currentGuildId: string | null;
|
||||
pttReleaseTimer: NodeJS.Timeout | null;
|
||||
navigateToChannel(guildId: string | null, channelId: string): void;
|
||||
navigateToDirectMessages(): void;
|
||||
navigateToLastCommunityChannel(): boolean;
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ShortcutSource = 'local' | 'global';
|
||||
export type KeybindHandler = (payload: {
|
||||
type: 'press' | 'release';
|
||||
source: ShortcutSource;
|
||||
sourceId: string;
|
||||
context?: {
|
||||
focusedMessage?: Message;
|
||||
focusedChannel?: Channel | null;
|
||||
@@ -44,6 +45,8 @@ export type KeybindHandler = (payload: {
|
||||
|
||||
export interface HoldBindingRuntime {
|
||||
action: HoldAction;
|
||||
sourceId: string;
|
||||
gamepadSourceId: string;
|
||||
combo: KeyCombo;
|
||||
keycode: number | null;
|
||||
keyName: string | null;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment happy-dom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
GlobalShortcutsApi,
|
||||
GlobalShortcutsBackend,
|
||||
GlobalShortcutsPortalState,
|
||||
GlobalShortcutsPortalStatus,
|
||||
GlobalShortcutsStatus,
|
||||
} from '@app/types/electron.d';
|
||||
import {afterAll, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({
|
||||
msg: (descriptor: unknown) => descriptor,
|
||||
t: (descriptor: unknown) => descriptor,
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/react/macro', () => ({
|
||||
Trans: () => null,
|
||||
useLingui: () => ({i18n: {_: (descriptor: {message?: string}) => descriptor.message ?? ''}}),
|
||||
}));
|
||||
|
||||
const BOOTSTRAP_ENDPOINT = 'https://primary.test/api';
|
||||
|
||||
(globalThis.window as unknown as Record<string, unknown>).__FLUXER_BOOTSTRAP__ = {
|
||||
config: {
|
||||
releaseChannel: 'stable',
|
||||
bootstrapApiEndpoint: BOOTSTRAP_ENDPOINT,
|
||||
bootstrapApiPublicEndpoint: BOOTSTRAP_ENDPOINT,
|
||||
},
|
||||
instance: {
|
||||
api_code_version: Number.MAX_SAFE_INTEGER,
|
||||
endpoints: {
|
||||
api: BOOTSTRAP_ENDPOINT,
|
||||
api_client: BOOTSTRAP_ENDPOINT,
|
||||
api_public: BOOTSTRAP_ENDPOINT,
|
||||
gateway: 'wss://gateway.primary.test',
|
||||
media: 'https://media.primary.test',
|
||||
static_cdn: 'https://cdn.primary.test',
|
||||
marketing: 'https://primary.test',
|
||||
admin: 'https://admin.primary.test',
|
||||
invite: 'https://primary.test/invite',
|
||||
gift: 'https://primary.test/gift',
|
||||
webapp: 'https://app.primary.test',
|
||||
upload_relay: 'https://upload.primary.test',
|
||||
},
|
||||
captcha: {provider: 'none'},
|
||||
features: {
|
||||
voice_enabled: false,
|
||||
stripe_enabled: false,
|
||||
self_hosted: false,
|
||||
presigned_attachment_uploads: false,
|
||||
emails_enabled: false,
|
||||
phone_verification_enabled: false,
|
||||
},
|
||||
gif: {provider: 'klipy', display_name: 'Klipy', attribution_required: false},
|
||||
sso: {enabled: false, enforced: false, display_name: null, redirect_uri: ''},
|
||||
registration: {mode: 'open', admin_registration_urls_enabled: true},
|
||||
community: {single_community: false, single_community_guild_id: null, direct_messages_disabled: false},
|
||||
services: {gif_enabled: true, youtube_enabled: false, bluesky_enabled: false},
|
||||
limits: undefined,
|
||||
push: {public_vapid_key: null},
|
||||
app_public: {
|
||||
branding: {
|
||||
product_name: 'Fluxer',
|
||||
icon_url: null,
|
||||
symbol_url: null,
|
||||
logo_url: null,
|
||||
wordmark_url: null,
|
||||
favicon_url: null,
|
||||
theme_color: null,
|
||||
},
|
||||
setup: {configured: true, admin_url: null},
|
||||
legal: {terms_url: null, privacy_url: null},
|
||||
registration: {collect_date_of_birth: true},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let statusListener: ((status: GlobalShortcutsStatus) => void) | null = null;
|
||||
|
||||
const globalShortcutsApi: Partial<GlobalShortcutsApi> = {
|
||||
sync: async () => {},
|
||||
getStatus: () => new Promise<GlobalShortcutsStatus>(() => {}),
|
||||
onStatus: (callback) => {
|
||||
statusListener = callback;
|
||||
return () => {
|
||||
statusListener = null;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('@app/features/ui/utils/NativeUtils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@app/features/ui/utils/NativeUtils')>()),
|
||||
getElectronAPI: () => ({globalShortcuts: globalShortcutsApi}),
|
||||
}));
|
||||
|
||||
const PORTAL_ASSIGNED_ACTIONS_KEY = 'GlobalShortcuts:portalAssignedActions:v1';
|
||||
|
||||
interface PortalStatusOptions {
|
||||
recovering?: boolean;
|
||||
trigger?: string | null;
|
||||
error?: string | null;
|
||||
backend?: GlobalShortcutsBackend;
|
||||
directInputEnabled?: boolean;
|
||||
}
|
||||
|
||||
function portalStatus(state: GlobalShortcutsPortalState, options: PortalStatusOptions = {}): GlobalShortcutsStatus {
|
||||
const trigger = options.trigger === undefined ? 'F13' : options.trigger;
|
||||
const portal: GlobalShortcutsPortalStatus = {
|
||||
state,
|
||||
version: 2,
|
||||
canConfigure: true,
|
||||
canRecheck: state === 'unsupported' || state === 'error',
|
||||
portalAppId: 'app.fluxer.FluxerDesktop',
|
||||
shortcuts: [
|
||||
{action: 'voice_push_to_talk', triggerDescription: state === 'bound' ? trigger : null},
|
||||
{action: 'voice_push_to_mute', triggerDescription: null},
|
||||
],
|
||||
error: options.error ?? (state === 'error' ? 'portal-unavailable' : null),
|
||||
recovering: options.recovering ?? false,
|
||||
};
|
||||
return {
|
||||
backend: options.backend ?? (state === 'unsupported' ? 'none' : 'portal'),
|
||||
platform: 'linux',
|
||||
linux: {
|
||||
session: 'wayland',
|
||||
sandbox: 'none',
|
||||
desktop: 'gnome',
|
||||
portal,
|
||||
directInput: {available: true, enabled: options.directInputEnabled ?? false, locked: false},
|
||||
},
|
||||
hooksActive: false,
|
||||
hookError: null,
|
||||
supportsMouseButtons: false,
|
||||
supportsModifierOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
type Modules = {
|
||||
GlobalShortcuts: typeof import('@app/features/input/state/GlobalShortcuts').default;
|
||||
Keybind: typeof import('@app/features/input/state/InputKeybind').default;
|
||||
AppStorage: typeof import('@app/features/platform/state/PersistentStorage').default;
|
||||
reactToPushToTalkModeChanges: typeof import('@app/features/app/keybindings/utils/PushToTalkModeReconcile').reactToPushToTalkModeChanges;
|
||||
};
|
||||
|
||||
let modules: Modules;
|
||||
|
||||
function emit(status: GlobalShortcutsStatus): void {
|
||||
expect(statusListener).not.toBeNull();
|
||||
statusListener?.(status);
|
||||
}
|
||||
|
||||
describe('push-to-talk mode reconcile', () => {
|
||||
beforeAll(async () => {
|
||||
const {default: AppStorage} = await import('@app/features/platform/state/PersistentStorage');
|
||||
AppStorage.setJSON(PORTAL_ASSIGNED_ACTIONS_KEY, ['voice_push_to_talk']);
|
||||
const {default: GlobalShortcuts} = await import('@app/features/input/state/GlobalShortcuts');
|
||||
const {default: Keybind} = await import('@app/features/input/state/InputKeybind');
|
||||
const {reactToPushToTalkModeChanges} = await import('@app/features/app/keybindings/utils/PushToTalkModeReconcile');
|
||||
modules = {GlobalShortcuts, Keybind, AppStorage, reactToPushToTalkModeChanges};
|
||||
Keybind.setTransmitMode('voice_push_to_talk');
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
modules.GlobalShortcuts.detach();
|
||||
const {default: Idle} = await import('@app/features/ui/state/Idle');
|
||||
Idle.destroy();
|
||||
});
|
||||
|
||||
it('restores the last settled portal assignment at startup before any status arrives', () => {
|
||||
const {GlobalShortcuts, Keybind} = modules;
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(false);
|
||||
GlobalShortcuts.attach();
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
emit(portalStatus('unknown'));
|
||||
emit(portalStatus('probing'));
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps push-to-talk effective through transient and error portal states', () => {
|
||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||
const reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
emit(portalStatus('bound'));
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual(['voice_push_to_talk']);
|
||||
for (const status of [
|
||||
portalStatus('bound', {recovering: true}),
|
||||
portalStatus('bound', {recovering: true, trigger: null}),
|
||||
portalStatus('declined', {recovering: true}),
|
||||
portalStatus('unknown', {recovering: true, backend: 'none'}),
|
||||
portalStatus('probing', {recovering: true}),
|
||||
portalStatus('binding'),
|
||||
portalStatus('error', {recovering: true}),
|
||||
portalStatus('error', {error: 'portal-unavailable'}),
|
||||
portalStatus('error', {error: 'timeout'}),
|
||||
portalStatus('error', {error: 'session-closed'}),
|
||||
portalStatus('error', {error: 'identity'}),
|
||||
portalStatus('error', {error: 'bind-failed'}),
|
||||
portalStatus('unsupported', {recovering: true}),
|
||||
portalStatus('not-set-up'),
|
||||
portalStatus('unknown'),
|
||||
portalStatus('bound'),
|
||||
]) {
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
}
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual(['voice_push_to_talk']);
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('keeps push-to-talk effective while a recovering session reopens with no listed triggers', () => {
|
||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||
const reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
emit(portalStatus('bound'));
|
||||
for (const status of [
|
||||
portalStatus('bound', {recovering: true}),
|
||||
portalStatus('bound', {recovering: true, trigger: null}),
|
||||
portalStatus('binding', {recovering: true}),
|
||||
portalStatus('bound'),
|
||||
]) {
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(true);
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual(['voice_push_to_talk']);
|
||||
}
|
||||
expect(reconcile).not.toHaveBeenCalled();
|
||||
dispose();
|
||||
});
|
||||
|
||||
it('flips push-to-talk only on settled states and preserves self-mute when it does', () => {
|
||||
const {Keybind, AppStorage, reactToPushToTalkModeChanges} = modules;
|
||||
const reconcile = vi.fn();
|
||||
const dispose = reactToPushToTalkModeChanges(reconcile);
|
||||
const expectFlip = (status: GlobalShortcutsStatus, effective: boolean): void => {
|
||||
reconcile.mockClear();
|
||||
emit(status);
|
||||
expect(Keybind.isPushToTalkEffective()).toBe(effective);
|
||||
expect(reconcile).toHaveBeenCalledTimes(1);
|
||||
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: true});
|
||||
};
|
||||
expectFlip(portalStatus('declined'), false);
|
||||
expect(AppStorage.getJSON(PORTAL_ASSIGNED_ACTIONS_KEY)).toEqual([]);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
expectFlip(portalStatus('unsupported'), false);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
expectFlip(portalStatus('unknown', {backend: 'evdev', directInputEnabled: true}), false);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
expectFlip(portalStatus('bound', {trigger: null}), false);
|
||||
expectFlip(portalStatus('bound'), true);
|
||||
reconcile.mockClear();
|
||||
Keybind.setTransmitMode('voice_activity');
|
||||
expect(reconcile).toHaveBeenLastCalledWith({preserveSelfMute: false});
|
||||
Keybind.setTransmitMode('voice_push_to_talk');
|
||||
dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Keybind from '@app/features/input/state/InputKeybind';
|
||||
import {compareStructural, reaction} from 'mobx';
|
||||
|
||||
export function reactToPushToTalkModeChanges(onChange: (options: {preserveSelfMute: boolean}) => void): () => void {
|
||||
return reaction(
|
||||
() => ({
|
||||
transmitMode: Keybind.transmitMode,
|
||||
pushToTalk: Keybind.isPushToTalkEffective(),
|
||||
pushToMute: Keybind.isPushToMuteEffective(),
|
||||
}),
|
||||
(current, previous) => onChange({preserveSelfMute: current.transmitMode === previous.transmitMode}),
|
||||
{equals: compareStructural},
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
} from '@app/features/app/keybindings/utils/HookShortcutIds';
|
||||
|
||||
export type RuntimeKeybind = KeybindConfig & {
|
||||
id: string | null;
|
||||
combo: KeyCombo;
|
||||
};
|
||||
export type RuntimeKeybindBaseResolver = (action: KeybindCommand) => KeybindConfig | null;
|
||||
@@ -34,6 +35,16 @@ export const HOLD_ACTIONS_FOR_VOICE_ACTIVITY_MODE: ReadonlyArray<HoldAction> = [
|
||||
'voice_priority_vad',
|
||||
];
|
||||
|
||||
export function sourceIdForKeybind(keybind: {id: string | null; action: KeybindCommand}): string {
|
||||
if (keybind.id === null) return `default:${keybind.action}`;
|
||||
return `custom:${keybind.id}`;
|
||||
}
|
||||
|
||||
export function gamepadSourceIdForKeybind(keybind: {id: string | null; action: KeybindCommand}): string {
|
||||
if (keybind.id === null) return `gamepad:default:${keybind.action}`;
|
||||
return `gamepad:${keybind.id}`;
|
||||
}
|
||||
|
||||
export function hasTriggerKey(combo: KeyCombo): boolean {
|
||||
return (combo.key ?? '') !== '' || (combo.code ?? '') !== '';
|
||||
}
|
||||
@@ -55,7 +66,7 @@ export function buildDefaultRuntimeKeybinds(
|
||||
if (overriddenActions.has(entry.action)) continue;
|
||||
const combo = entry.combo;
|
||||
if (!isEnabledDefaultCombo(combo)) continue;
|
||||
result.push({...entry, combo});
|
||||
result.push({...entry, id: null, combo});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -69,7 +80,7 @@ export function buildCustomRuntimeKeybinds(
|
||||
if (!custom.action || !isActiveCustomKeybind(custom)) continue;
|
||||
const base = getBaseByAction(custom.action);
|
||||
if (!base) continue;
|
||||
result.push({...base, combo: custom.combo});
|
||||
result.push({...base, id: custom.id, combo: custom.combo});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+5
@@ -39,6 +39,11 @@ export function useOAuthPublicApp(clientId: string | null): PublicAppState {
|
||||
const resp = await http.get<PublicAppData>(Endpoints.OAUTH_PUBLIC_APPLICATION(clientId));
|
||||
if (cancelled) return;
|
||||
const currentUser = resp.body.current_user;
|
||||
if (currentUserId && !currentUser) {
|
||||
logger.warn('OAuth public app fetch returned no current user for a signed-in account');
|
||||
setState({status: 'session_expired', data: null, error: null});
|
||||
return;
|
||||
}
|
||||
if (currentUser && currentUser.id === currentUserId) {
|
||||
const userData = authResponseUserToUserData(currentUser);
|
||||
if (userData) {
|
||||
|
||||
+7
@@ -29,6 +29,7 @@ import {
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
import type {BotPermissionOption} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -388,6 +389,12 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
|
||||
setSubmitting(null);
|
||||
setSubmitError(i18n._(AUTHORIZATION_FAILED_DESCRIPTOR));
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status === 401) {
|
||||
logger.warn('OAuth consent returned 401', err);
|
||||
setSubmitting(null);
|
||||
dispatch({type: 'INIT_SESSION_EXPIRED'});
|
||||
return;
|
||||
}
|
||||
logger.error('Authorization failed', err);
|
||||
setSubmitting(null);
|
||||
setSubmitError(failureMessage(err) ?? i18n._(AUTHORIZATION_FAILED_DESCRIPTOR));
|
||||
|
||||
@@ -5,6 +5,7 @@ import {LongPressable} from '@app/features/app/components/LongPressable';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {CustomStatusDisplay} from '@app/features/app/components/shared/custom_status_display/CustomStatusDisplay';
|
||||
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
|
||||
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import {UserTag} from '@app/features/channel/components/ChannelUserTag';
|
||||
import styles from '@app/features/channel/components/direct_message/DirectMessageList.module.css';
|
||||
@@ -198,9 +199,15 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
leaveGroup,
|
||||
i18n,
|
||||
});
|
||||
const {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow} = useChannelHoverPreload({
|
||||
channel,
|
||||
guild: null,
|
||||
preloadMemberList: false,
|
||||
});
|
||||
const handleNavigate = useCallback(() => {
|
||||
preloadChannelNow();
|
||||
handlers.navigateTo();
|
||||
}, [handlers.navigateTo]);
|
||||
}, [handlers.navigateTo, preloadChannelNow]);
|
||||
const mobileMenuGroups = buildMobileMenuGroups({
|
||||
channel,
|
||||
recipient,
|
||||
@@ -500,6 +507,8 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
)}
|
||||
onClick={handleNavigate}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={scheduleChannelPreload}
|
||||
onMouseLeave={cancelChannelPreload}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
data-dm-list-focus-item="true"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1632,6 +1632,9 @@
|
||||
{
|
||||
"msgid": "Capture entire system audio"
|
||||
},
|
||||
{
|
||||
"msgid": "Change in system settings"
|
||||
},
|
||||
{
|
||||
"msgid": "Change stream"
|
||||
},
|
||||
@@ -1755,6 +1758,9 @@
|
||||
{
|
||||
"msgid": "Characters in a single message"
|
||||
},
|
||||
{
|
||||
"msgid": "Check again"
|
||||
},
|
||||
{
|
||||
"msgid": "Checkout is blocked for this account."
|
||||
},
|
||||
@@ -1863,6 +1869,15 @@
|
||||
{
|
||||
"msgid": "Copy emoji"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy lines"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy lines for hyprland.conf"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy lines for hyprland.lua"
|
||||
},
|
||||
{
|
||||
"msgid": "Copy sticker"
|
||||
},
|
||||
@@ -2133,6 +2148,18 @@
|
||||
{
|
||||
"msgid": "Footnote about custom tags"
|
||||
},
|
||||
{
|
||||
"msgid": "For push-to-mute, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or the microphone can stay muted."
|
||||
},
|
||||
{
|
||||
"msgid": "For push-to-talk, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or the microphone can stay on."
|
||||
},
|
||||
{
|
||||
"msgid": "For this shortcut, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or the shortcut can stay active."
|
||||
},
|
||||
{
|
||||
"msgid": "For voice activity priority, use a single key without Ctrl, Alt or Shift. With a combination, let go of the main key first or priority can stay on."
|
||||
},
|
||||
{
|
||||
"msgid": "From {sourceGuildName}"
|
||||
},
|
||||
@@ -2217,9 +2244,18 @@
|
||||
{
|
||||
"msgid": "Hid the community owner crown."
|
||||
},
|
||||
{
|
||||
"msgid": "Holding Shift, Ctrl or Alt blocks this shortcut. Add alternates in your system settings."
|
||||
},
|
||||
{
|
||||
"msgid": "How much of the member's recent message history to delete."
|
||||
},
|
||||
{
|
||||
"msgid": "Hyprland has no shortcut dialog. Add the lines for your Hyprland version to your Hyprland config and replace KEY with the key you want."
|
||||
},
|
||||
{
|
||||
"msgid": "Hyprland has no shortcut dialog. Bind the keys in your Hyprland config."
|
||||
},
|
||||
{
|
||||
"msgid": "Ignore this channel's slowmode cooldown."
|
||||
},
|
||||
@@ -2271,6 +2307,9 @@
|
||||
{
|
||||
"msgid": "Keep attachments when clearing an edit"
|
||||
},
|
||||
{
|
||||
"msgid": "Keyboard access couldn't start. Shortcuts only work while {productName} is focused."
|
||||
},
|
||||
{
|
||||
"msgid": "Kicked {targetUserTag} from the community"
|
||||
},
|
||||
@@ -2298,6 +2337,12 @@
|
||||
{
|
||||
"msgid": "Lets other communities follow this channel and get copies of what you publish."
|
||||
},
|
||||
{
|
||||
"msgid": "Lets {productName} read your keyboard directly. Turn this off to set up shortcuts through your desktop instead."
|
||||
},
|
||||
{
|
||||
"msgid": "Lets {productName} read your keyboard directly. Your system already allows this."
|
||||
},
|
||||
{
|
||||
"msgid": "Lightweight classic filter, works on any device."
|
||||
},
|
||||
@@ -2472,6 +2517,9 @@
|
||||
{
|
||||
"msgid": "Not accepted"
|
||||
},
|
||||
{
|
||||
"msgid": "Not assigned"
|
||||
},
|
||||
{
|
||||
"msgid": "Not sent to followers yet."
|
||||
},
|
||||
@@ -2661,6 +2709,33 @@
|
||||
{
|
||||
"msgid": "Push relay notice"
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk has no system-wide key yet. To set one, go to Settings > Apps > {productName}."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk has no system-wide key yet. To set one, go to System Settings > Keyboard > Shortcuts > {productName}."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk has no system-wide key yet. To set one, open your desktop's keyboard shortcut settings."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk is on. Hold your push-to-talk key to speak."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk only works while {productName} is focused because your desktop didn't set up system-wide shortcuts."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk only works while {productName} is focused until you set up system-wide shortcuts."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk will use the key you set in {productName}. Set one first, or your microphone will use voice activity."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk works outside {productName} only after you bind it in your Hyprland config."
|
||||
},
|
||||
{
|
||||
"msgid": "Push-to-talk works outside {productName} only after you bind it in your Hyprland config. Add the line for your Hyprland version and replace KEY with the key you want."
|
||||
},
|
||||
{
|
||||
"msgid": "Read messages sent before they opened a channel. Without it, they only see messages that arrive while it is open."
|
||||
},
|
||||
@@ -2673,6 +2748,9 @@
|
||||
{
|
||||
"msgid": "Reason (optional)."
|
||||
},
|
||||
{
|
||||
"msgid": "Reconnecting to your desktop…"
|
||||
},
|
||||
{
|
||||
"msgid": "Redeem a gift code"
|
||||
},
|
||||
@@ -3087,6 +3165,9 @@
|
||||
{
|
||||
"msgid": "Set to send {resolution} at {frameRate} FPS with {codec}"
|
||||
},
|
||||
{
|
||||
"msgid": "Set up"
|
||||
},
|
||||
{
|
||||
"msgid": "Set up {productName}"
|
||||
},
|
||||
@@ -3312,6 +3393,15 @@
|
||||
{
|
||||
"msgid": "Syntax highlighting"
|
||||
},
|
||||
{
|
||||
"msgid": "System-wide shortcuts could not be set up."
|
||||
},
|
||||
{
|
||||
"msgid": "System-wide: Not assigned"
|
||||
},
|
||||
{
|
||||
"msgid": "System-wide: {trigger}"
|
||||
},
|
||||
{
|
||||
"msgid": "Talk in calls and test your input."
|
||||
},
|
||||
@@ -3438,6 +3528,9 @@
|
||||
{
|
||||
"msgid": "This community has published a lot of messages recently. Try again in {duration}."
|
||||
},
|
||||
{
|
||||
"msgid": "This desktop doesn't offer system-wide shortcuts. Shortcuts still work while {productName} is focused."
|
||||
},
|
||||
{
|
||||
"msgid": "This device could not keep up with {targetResolution} at {targetFrameRate} FPS last time, so your stream started at {resolution} at {frameRate} FPS."
|
||||
},
|
||||
@@ -3480,6 +3573,9 @@
|
||||
{
|
||||
"msgid": "This is your last passkey. Once it's gone you won't be able to use a passkey as your second factor."
|
||||
},
|
||||
{
|
||||
"msgid": "This key works while {productName} is focused. Your desktop sets the system-wide key, under {settingsTabName} > {sectionName}."
|
||||
},
|
||||
{
|
||||
"msgid": "This message can only be forwarded to age-restricted channels"
|
||||
},
|
||||
@@ -3507,6 +3603,15 @@
|
||||
{
|
||||
"msgid": "Timed out {targetUserTag}"
|
||||
},
|
||||
{
|
||||
"msgid": "To change the keys, go to Settings > Apps > {productName}."
|
||||
},
|
||||
{
|
||||
"msgid": "To change the keys, go to System Settings > Keyboard > Shortcuts > {productName}."
|
||||
},
|
||||
{
|
||||
"msgid": "To change the keys, open your desktop's keyboard shortcut settings."
|
||||
},
|
||||
{
|
||||
"msgid": "Transferred community ownership to {user}."
|
||||
},
|
||||
@@ -3576,6 +3681,9 @@
|
||||
{
|
||||
"msgid": "Update your passkey"
|
||||
},
|
||||
{
|
||||
"msgid": "Update {productName} to use system-wide shortcuts on Wayland."
|
||||
},
|
||||
{
|
||||
"msgid": "Updates are on their way!"
|
||||
},
|
||||
@@ -3594,6 +3702,9 @@
|
||||
{
|
||||
"msgid": "Use custom emojis and stickers in any community"
|
||||
},
|
||||
{
|
||||
"msgid": "Use direct input device access"
|
||||
},
|
||||
{
|
||||
"msgid": "Use emoji from other communities in this channel."
|
||||
},
|
||||
@@ -3651,6 +3762,9 @@
|
||||
{
|
||||
"msgid": "Volume sliders"
|
||||
},
|
||||
{
|
||||
"msgid": "Waiting for your desktop…"
|
||||
},
|
||||
{
|
||||
"msgid": "We could not renew your subscription. Update your payment method to keep your perks."
|
||||
},
|
||||
@@ -3693,6 +3807,9 @@
|
||||
{
|
||||
"msgid": "Wordmark"
|
||||
},
|
||||
{
|
||||
"msgid": "Works while {productName} is focused"
|
||||
},
|
||||
{
|
||||
"msgid": "You asked for {resolution} at {frameRate} FPS"
|
||||
},
|
||||
@@ -3729,6 +3846,18 @@
|
||||
{
|
||||
"msgid": "Your connection cannot keep up. Your stream is set to {frameRate} FPS at {resolution} and viewers are getting about {deliveredFrameRate} FPS."
|
||||
},
|
||||
{
|
||||
"msgid": "Your desktop didn't set up system-wide shortcuts."
|
||||
},
|
||||
{
|
||||
"msgid": "Your desktop keeps these keys for {productName}, so other apps can't use them."
|
||||
},
|
||||
{
|
||||
"msgid": "Your desktop keeps these keys for {productName}, so other apps can't use them. To use the same key in other apps, turn on direct input device access below."
|
||||
},
|
||||
{
|
||||
"msgid": "Your desktop runs these shortcuts even while {productName} is not focused."
|
||||
},
|
||||
{
|
||||
"msgid": "Your device cannot keep up. Viewers get about {deliveredFrameRate} of {frameRate} FPS at {resolution}."
|
||||
},
|
||||
@@ -3795,6 +3924,12 @@
|
||||
{
|
||||
"msgid": "greeting"
|
||||
},
|
||||
{
|
||||
"msgid": "hyprland.conf (before Hyprland 0.55)"
|
||||
},
|
||||
{
|
||||
"msgid": "hyprland.lua (Hyprland 0.55 and later)"
|
||||
},
|
||||
{
|
||||
"msgid": "macOS"
|
||||
},
|
||||
@@ -4371,6 +4506,21 @@
|
||||
{
|
||||
"msgid": "{productName} application logo"
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can ask your desktop to run its shortcuts while {productName} is not focused. Your desktop asks you to confirm and lets you pick the keys."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can no longer read your keyboard, so it listens for shortcuts through X11 instead."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can no longer read your keyboard, so your desktop's shortcuts are used instead."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can no longer read your keyboard. Shortcuts only work while {productName} is focused."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} can register its shortcuts with Hyprland so they run while {productName} is not focused. You then bind the keys in your Hyprland config."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} cannot start a safe desktop audio route while excluding {productName}'s call audio."
|
||||
},
|
||||
@@ -4398,6 +4548,9 @@
|
||||
{
|
||||
"msgid": "{productName} now lives at {host}. Install it from there."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} registered its shortcuts with Hyprland. Bind them in your Hyprland config to use them while {productName} is not focused."
|
||||
},
|
||||
{
|
||||
"msgid": "{productName} wordmark"
|
||||
},
|
||||
|
||||
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
@@ -4,15 +4,23 @@ import KeybindManager from '@app/features/app/keybindings/KeybindManager';
|
||||
import styles from '@app/features/input/components/KeybindRecorder.module.css';
|
||||
import {
|
||||
beginGlobalKeyCapture,
|
||||
beginShortcutCapture,
|
||||
globalKeyEventToCombo,
|
||||
isGlobalKeyEventModifierKey,
|
||||
isModifierKeyCode,
|
||||
shortcutCaptureKeyToCombo,
|
||||
shortcutCaptureMouseToCombo,
|
||||
} from '@app/features/input/components/KeybindRecorderCapture';
|
||||
import GlobalShortcuts, {getGlobalShortcutsApi} from '@app/features/input/state/GlobalShortcuts';
|
||||
import type {KeybindCommand, KeyCombo} from '@app/features/input/state/InputKeybind';
|
||||
import {isGamepadButtonPressed} from '@app/features/input/utils/GamepadButtonUtils';
|
||||
import {isKeybindModifierKey} from '@app/features/input/utils/KeybindComboUtils';
|
||||
import {formatKeyCombo} from '@app/features/input/utils/KeybindUtils';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import type {GlobalKeyEvent} from '@app/features/platform/types/Electron';
|
||||
import type {
|
||||
GlobalKeyEvent,
|
||||
GlobalShortcutCaptureEvent,
|
||||
GlobalShortcutsApi,
|
||||
} from '@app/features/platform/types/Electron';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
@@ -76,6 +84,8 @@ const MODIFIER_BOTH_SIDES_PAIRS: ReadonlyArray<readonly [string, string]> = [
|
||||
['MetaLeft', 'MetaRight'],
|
||||
];
|
||||
const RECORDABLE_MOUSE_BUTTONS = new Set([0, 1, 2, 3, 4]);
|
||||
const CAPTURE_ONLY_MOUSE_BUTTONS = new Set([1, 3, 4]);
|
||||
const HOOK_BACKENDS = new Set(['windows', 'macos', 'x11', 'evdev']);
|
||||
const normalizeKeyForCombo = (key: string): string => {
|
||||
if (key === 'Spacebar') return ' ';
|
||||
if (key === 'Break') return 'Pause';
|
||||
@@ -134,6 +144,12 @@ const modifierOnlyCombo = (event: KeyboardEvent): KeyCombo => ({
|
||||
modifierOnly: true,
|
||||
});
|
||||
|
||||
function canCaptureFromHook(api: GlobalShortcutsApi): boolean {
|
||||
if (typeof api.startCapture !== 'function') return false;
|
||||
const backend = GlobalShortcuts.backend;
|
||||
return backend !== null && HOOK_BACKENDS.has(backend) && GlobalShortcuts.hookError === null;
|
||||
}
|
||||
|
||||
interface KeybindEditorPopoutProps {
|
||||
value: KeyCombo;
|
||||
defaultValue: KeyCombo | null;
|
||||
@@ -182,6 +198,10 @@ const KeybindEditorPopout: React.FC<KeybindEditorPopoutProps> = ({
|
||||
setPreviewCombo(null);
|
||||
setRecording(true);
|
||||
}, []);
|
||||
const onSaveRef = useRef(onSave);
|
||||
onSaveRef.current = onSave;
|
||||
const globalRef = useRef(value.global);
|
||||
globalRef.current = value.global;
|
||||
useEffect(() => {
|
||||
if (!recording) return;
|
||||
let committed = false;
|
||||
@@ -194,10 +214,10 @@ const KeybindEditorPopout: React.FC<KeybindEditorPopoutProps> = ({
|
||||
committed = true;
|
||||
const savedCombo: KeyCombo = {
|
||||
...combo,
|
||||
global: value.global,
|
||||
global: globalRef.current,
|
||||
enabled: true,
|
||||
};
|
||||
onSave(savedCombo);
|
||||
onSaveRef.current(savedCombo);
|
||||
finishRecording(savedCombo);
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -250,17 +270,16 @@ const KeybindEditorPopout: React.FC<KeybindEditorPopoutProps> = ({
|
||||
}
|
||||
commit(combo);
|
||||
};
|
||||
const handleGlobalKeyEvent = (event: GlobalKeyEvent) => {
|
||||
const handleGlobalKey = (type: 'keydown' | 'keyup', baseCombo: KeyCombo) => {
|
||||
if (committed) return;
|
||||
const baseCombo = globalKeyEventToCombo(event);
|
||||
if (!baseCombo) return;
|
||||
const comboCode = baseCombo.code ?? baseCombo.key;
|
||||
if (event.type === 'keydown') {
|
||||
const isModifier = isModifierKeyCode(baseCombo.code);
|
||||
if (type === 'keydown') {
|
||||
if (baseCombo.key === 'Escape') {
|
||||
cancelRecording();
|
||||
return;
|
||||
}
|
||||
if (isGlobalKeyEventModifierKey(event)) {
|
||||
if (isModifier) {
|
||||
heldModifierCodes.add(comboCode);
|
||||
seenModifierCodes.add(comboCode);
|
||||
lastModifierCombo = {...baseCombo, modifierOnly: true};
|
||||
@@ -277,12 +296,11 @@ const KeybindEditorPopout: React.FC<KeybindEditorPopoutProps> = ({
|
||||
commit(combo);
|
||||
return;
|
||||
}
|
||||
if (!isGlobalKeyEventModifierKey(event)) return;
|
||||
if (!isModifier) return;
|
||||
if (sawNonModifier) return;
|
||||
heldModifierCodes.delete(comboCode);
|
||||
if (heldModifierCodes.size > 0) return;
|
||||
const combo = lastModifierCombo ?? globalKeyEventToCombo(event, {modifierOnly: true});
|
||||
if (!combo) return;
|
||||
const combo = lastModifierCombo ?? {...baseCombo, modifierOnly: true};
|
||||
if (!combo.key && !combo.code) return;
|
||||
const modifierFlagCount =
|
||||
(combo.shift ? 1 : 0) +
|
||||
@@ -300,6 +318,25 @@ const KeybindEditorPopout: React.FC<KeybindEditorPopoutProps> = ({
|
||||
}
|
||||
commit(combo);
|
||||
};
|
||||
const handleGlobalKeyEvent = (event: GlobalKeyEvent) => {
|
||||
const baseCombo = globalKeyEventToCombo(event);
|
||||
if (baseCombo) handleGlobalKey(event.type, baseCombo);
|
||||
};
|
||||
const handleCaptureEvent = (event: GlobalShortcutCaptureEvent) => {
|
||||
if (committed) return;
|
||||
if (event.type === 'mousedown') {
|
||||
if (event.button === null || !CAPTURE_ONLY_MOUSE_BUTTONS.has(event.button)) return;
|
||||
if (GlobalShortcuts.status?.supportsMouseButtons !== true) return;
|
||||
const combo = shortcutCaptureMouseToCombo(event);
|
||||
if (!combo) return;
|
||||
sawNonModifier = true;
|
||||
commit(combo);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'mouseup') return;
|
||||
const baseCombo = shortcutCaptureKeyToCombo(event);
|
||||
if (baseCombo) handleGlobalKey(event.type, baseCombo);
|
||||
};
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (!RECORDABLE_MOUSE_BUTTONS.has(event.button)) return;
|
||||
event.preventDefault();
|
||||
@@ -347,22 +384,25 @@ const KeybindEditorPopout: React.FC<KeybindEditorPopoutProps> = ({
|
||||
rafId = requestAnimationFrame(pollGamepads);
|
||||
};
|
||||
rafId = requestAnimationFrame(pollGamepads);
|
||||
const cancelGlobalCapture = beginGlobalKeyCapture(getElectronAPI(), (event) => {
|
||||
handleGlobalKeyEvent(event);
|
||||
});
|
||||
const globalShortcutsApi = getGlobalShortcutsApi();
|
||||
const cancelGlobalCapture = globalShortcutsApi
|
||||
? canCaptureFromHook(globalShortcutsApi)
|
||||
? beginShortcutCapture(globalShortcutsApi, handleCaptureEvent)
|
||||
: null
|
||||
: beginGlobalKeyCapture(getElectronAPI(), handleGlobalKeyEvent);
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
window.addEventListener('keyup', handleKeyUp, true);
|
||||
window.addEventListener('mousedown', handleMouseDown, true);
|
||||
window.addEventListener('contextmenu', handleContextMenu, true);
|
||||
return () => {
|
||||
cancelGlobalCapture();
|
||||
cancelGlobalCapture?.();
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('keyup', handleKeyUp, true);
|
||||
window.removeEventListener('mousedown', handleMouseDown, true);
|
||||
window.removeEventListener('contextmenu', handleContextMenu, true);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [recording, onSave, cancelRecording, finishRecording, value.global]);
|
||||
}, [recording, cancelRecording, finishRecording]);
|
||||
const handleClear = () => {
|
||||
setPreviewCombo(null);
|
||||
onClear?.();
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {KeyCombo} from '@app/features/input/state/InputKeybind';
|
||||
import type {GlobalKeyEvent} from '@app/features/platform/types/Electron';
|
||||
import type {
|
||||
GlobalKeyEvent,
|
||||
GlobalShortcutCaptureEvent,
|
||||
GlobalShortcutsApi,
|
||||
} from '@app/features/platform/types/Electron';
|
||||
|
||||
const GLOBAL_KEY_NAME_ALIASES: Readonly<Record<string, string>> = {
|
||||
Esc: 'Escape',
|
||||
@@ -198,23 +202,21 @@ const modifierStateToCombo = (
|
||||
};
|
||||
};
|
||||
|
||||
export const isGlobalKeyEventModifierKey = (event: Pick<GlobalKeyEvent, 'keyName'>): boolean => {
|
||||
const code = codeForGlobalKeyName(event.keyName);
|
||||
return (
|
||||
code === 'ShiftLeft' ||
|
||||
code === 'ShiftRight' ||
|
||||
code === 'ControlLeft' ||
|
||||
code === 'ControlRight' ||
|
||||
code === 'AltLeft' ||
|
||||
code === 'AltRight' ||
|
||||
code === 'MetaLeft' ||
|
||||
code === 'MetaRight'
|
||||
);
|
||||
};
|
||||
const MODIFIER_CODES: ReadonlySet<string> = new Set([
|
||||
'ShiftLeft',
|
||||
'ShiftRight',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'MetaLeft',
|
||||
'MetaRight',
|
||||
]);
|
||||
|
||||
export const isModifierKeyCode = (code: string | undefined): boolean => code !== undefined && MODIFIER_CODES.has(code);
|
||||
|
||||
export const globalKeyEventToCombo = (
|
||||
event: Pick<GlobalKeyEvent, 'altKey' | 'ctrlKey' | 'keyName' | 'metaKey' | 'shiftKey'>,
|
||||
options: {modifierOnly?: boolean} = {},
|
||||
): KeyCombo | null => {
|
||||
const code = codeForGlobalKeyName(event.keyName);
|
||||
if (!code) return null;
|
||||
@@ -222,7 +224,56 @@ export const globalKeyEventToCombo = (
|
||||
key: keyForGlobalCode(code, event.shiftKey),
|
||||
code,
|
||||
...modifierStateToCombo(event),
|
||||
modifierOnly: options.modifierOnly || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const captureModifierState = (
|
||||
event: GlobalShortcutCaptureEvent,
|
||||
): Pick<GlobalKeyEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'> => ({
|
||||
altKey: event.alt,
|
||||
ctrlKey: event.ctrl,
|
||||
metaKey: event.meta,
|
||||
shiftKey: event.shift,
|
||||
});
|
||||
|
||||
export const shortcutCaptureKeyToCombo = (event: GlobalShortcutCaptureEvent): KeyCombo | null => {
|
||||
const code = event.code ?? (event.key ? codeForGlobalKeyName(event.key) : null);
|
||||
if (!code) return null;
|
||||
const key = event.key !== null && event.key.length === 1 ? event.key : keyForGlobalCode(code, event.shift);
|
||||
return {key, code, ...modifierStateToCombo(captureModifierState(event))};
|
||||
};
|
||||
|
||||
export const shortcutCaptureMouseToCombo = (event: GlobalShortcutCaptureEvent): KeyCombo | null => {
|
||||
if (event.button === null) return null;
|
||||
return {key: '', mouseButton: event.button, ...modifierStateToCombo(captureModifierState(event))};
|
||||
};
|
||||
|
||||
const beginCapture = <E, T>(
|
||||
start: () => Promise<T | null>,
|
||||
stop: (token: T) => Promise<void>,
|
||||
subscribe: (callback: (event: E) => void) => () => void,
|
||||
onEvent: (event: E) => void,
|
||||
): (() => void) => {
|
||||
let cancelled = false;
|
||||
let unsubscribe: (() => void) | null = subscribe(onEvent);
|
||||
const releaseSubscription = (): void => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
};
|
||||
const started = start().then(
|
||||
(token) => token,
|
||||
() => null,
|
||||
);
|
||||
void started.then((token) => {
|
||||
if (token === null) releaseSubscription();
|
||||
});
|
||||
return () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
releaseSubscription();
|
||||
void started.then((token) => {
|
||||
if (token !== null) void stop(token);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -236,29 +287,25 @@ export const beginGlobalKeyCapture = (
|
||||
api: GlobalKeyCaptureApi | null | undefined,
|
||||
onEvent: (event: GlobalKeyEvent) => void,
|
||||
): (() => void) => {
|
||||
if (!api?.globalKeyHookStart || !api.globalKeyHookStop || !api.onGlobalKeyEvent) {
|
||||
const {globalKeyHookStart, globalKeyHookStop, onGlobalKeyEvent} = api ?? {};
|
||||
if (!globalKeyHookStart || !globalKeyHookStop || !onGlobalKeyEvent) {
|
||||
return () => {};
|
||||
}
|
||||
const stopHook = api.globalKeyHookStop;
|
||||
let cancelled = false;
|
||||
let unsubscribe: (() => void) | null = api.onGlobalKeyEvent(onEvent);
|
||||
const releaseSubscription = (): void => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
};
|
||||
const started = api.globalKeyHookStart().then(
|
||||
(ok) => ok,
|
||||
() => false,
|
||||
return beginCapture(
|
||||
async () => ((await globalKeyHookStart()) ? true : null),
|
||||
() => globalKeyHookStop(),
|
||||
onGlobalKeyEvent,
|
||||
onEvent,
|
||||
);
|
||||
void started.then((ok) => {
|
||||
if (!ok) releaseSubscription();
|
||||
});
|
||||
return () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
releaseSubscription();
|
||||
void started.then((ok) => {
|
||||
if (ok) void stopHook();
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const beginShortcutCapture = (
|
||||
api: Pick<GlobalShortcutsApi, 'startCapture' | 'stopCapture' | 'onCapture'>,
|
||||
onEvent: (event: GlobalShortcutCaptureEvent) => void,
|
||||
): (() => void) =>
|
||||
beginCapture(
|
||||
() => api.startCapture(),
|
||||
(captureId) => api.stopCapture(captureId),
|
||||
(callback) => api.onCapture(callback),
|
||||
onEvent,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import AppStorage from '@app/features/platform/state/PersistentStorage';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
|
||||
import type {
|
||||
GlobalShortcutsApi,
|
||||
GlobalShortcutsBackend,
|
||||
GlobalShortcutsLinuxStatus,
|
||||
GlobalShortcutsPortalState,
|
||||
GlobalShortcutsPortalStatus,
|
||||
GlobalShortcutsStatus,
|
||||
} from '@app/types/electron.d';
|
||||
import {makeAutoObservable, runInAction} from 'mobx';
|
||||
|
||||
const KNOWN_BACKENDS: ReadonlySet<GlobalShortcutsBackend> = new Set([
|
||||
'portal',
|
||||
'x11',
|
||||
'evdev',
|
||||
'windows',
|
||||
'macos',
|
||||
'none',
|
||||
]);
|
||||
const KNOWN_PORTAL_STATES: ReadonlySet<GlobalShortcutsPortalState> = new Set([
|
||||
'unknown',
|
||||
'probing',
|
||||
'unsupported',
|
||||
'not-set-up',
|
||||
'binding',
|
||||
'bound',
|
||||
'declined',
|
||||
'error',
|
||||
]);
|
||||
|
||||
export type GlobalShortcutsPendingAction = 'set-up' | 'configure' | 'direct-input' | 'recheck';
|
||||
|
||||
const PORTAL_ASSIGNED_ACTIONS_KEY = 'GlobalShortcuts:portalAssignedActions:v1';
|
||||
|
||||
const logger = new Logger('GlobalShortcuts');
|
||||
|
||||
function isTransientPortal(portal: GlobalShortcutsPortalStatus): boolean {
|
||||
return portal.recovering === true || portal.state === 'probing' || portal.state === 'binding';
|
||||
}
|
||||
|
||||
function settledPortalAssignment(status: GlobalShortcutsStatus): ReadonlyArray<string> | null {
|
||||
if (status.platform !== 'linux' || status.linux === null) return [];
|
||||
const portal = status.linux.portal;
|
||||
if (portal === null) return [];
|
||||
if (portal.recovering === true) return null;
|
||||
if (status.backend !== 'portal' && !(status.backend === 'none' && portal.state === 'unsupported')) return [];
|
||||
switch (portal.state) {
|
||||
case 'bound':
|
||||
return portal.shortcuts
|
||||
.filter((entry) => status.linux?.desktop === 'hyprland' || entry.triggerDescription !== null)
|
||||
.map((entry) => entry.action);
|
||||
case 'declined':
|
||||
case 'unsupported':
|
||||
return [];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredPortalAssignment(): ReadonlyArray<string> {
|
||||
try {
|
||||
const stored = AppStorage.getJSON<unknown>(PORTAL_ASSIGNED_ACTIONS_KEY);
|
||||
if (!Array.isArray(stored)) return [];
|
||||
return stored.filter((action): action is string => typeof action === 'string');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read the stored system-wide shortcut assignment', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function storePortalAssignment(actions: ReadonlyArray<string>): void {
|
||||
try {
|
||||
AppStorage.setJSON(PORTAL_ASSIGNED_ACTIONS_KEY, actions);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to store the system-wide shortcut assignment', error);
|
||||
}
|
||||
}
|
||||
|
||||
function sameActions(a: ReadonlyArray<string>, b: ReadonlyArray<string>): boolean {
|
||||
return a.length === b.length && a.every((action) => b.includes(action));
|
||||
}
|
||||
|
||||
function findPortalTrigger(portal: GlobalShortcutsPortalStatus, action: string): string | null {
|
||||
return portal.shortcuts.find((entry) => entry.action === action)?.triggerDescription ?? null;
|
||||
}
|
||||
|
||||
export function getGlobalShortcutsApi(): GlobalShortcutsApi | null {
|
||||
const api = getElectronAPI()?.globalShortcuts;
|
||||
if (!api || typeof api.sync !== 'function') return null;
|
||||
return api;
|
||||
}
|
||||
|
||||
class GlobalShortcuts {
|
||||
status: GlobalShortcutsStatus | null = null;
|
||||
legacyWaylandNeedsUpdate = false;
|
||||
pendingAction: GlobalShortcutsPendingAction | null = null;
|
||||
private settledPortal: GlobalShortcutsPortalStatus | null = null;
|
||||
private portalAssignedActions: ReadonlyArray<string> = [];
|
||||
private attached = false;
|
||||
private statusRevision = 0;
|
||||
private statusUnsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable<this, 'attached' | 'statusRevision' | 'statusUnsubscribe'>(
|
||||
this,
|
||||
{attached: false, statusRevision: false, statusUnsubscribe: false},
|
||||
{autoBind: true},
|
||||
);
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
if (this.attached) return;
|
||||
this.attached = true;
|
||||
const api = getGlobalShortcutsApi();
|
||||
if (!api) {
|
||||
void this.probeLegacyDesktop();
|
||||
return;
|
||||
}
|
||||
this.portalAssignedActions = readStoredPortalAssignment();
|
||||
this.statusUnsubscribe = api.onStatus((status) => {
|
||||
this.statusRevision += 1;
|
||||
this.applyStatus(status);
|
||||
});
|
||||
const revision = this.statusRevision;
|
||||
void api.getStatus().then(
|
||||
(status) => {
|
||||
if (!this.attached || this.statusRevision !== revision) return;
|
||||
this.applyStatus(status);
|
||||
},
|
||||
(error) => {
|
||||
logger.warn('Failed to read global shortcuts status', error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.attached) return;
|
||||
this.attached = false;
|
||||
this.statusRevision += 1;
|
||||
this.statusUnsubscribe?.();
|
||||
this.statusUnsubscribe = null;
|
||||
}
|
||||
|
||||
private applyStatus(status: GlobalShortcutsStatus): void {
|
||||
runInAction(() => {
|
||||
this.status = status;
|
||||
const portal = status.platform === 'linux' ? status.linux?.portal : null;
|
||||
if (!portal || !KNOWN_PORTAL_STATES.has(portal.state) || portal.state === 'unknown') {
|
||||
this.settledPortal = null;
|
||||
} else if (!isTransientPortal(portal) || (this.settledPortal === null && portal.state === 'bound')) {
|
||||
this.settledPortal = portal;
|
||||
}
|
||||
const assigned = settledPortalAssignment(status);
|
||||
if (assigned === null || sameActions(assigned, this.portalAssignedActions)) return;
|
||||
this.portalAssignedActions = assigned;
|
||||
storePortalAssignment(assigned);
|
||||
});
|
||||
}
|
||||
|
||||
private async probeLegacyDesktop(): Promise<void> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
try {
|
||||
const desktopInfo = await electronApi.getDesktopInfo();
|
||||
if (!desktopInfo.waylandSession) return;
|
||||
const evdevStatus = await electronApi.linuxEvdevStatus?.();
|
||||
const needsUpdate = evdevStatus?.hasAccess !== true;
|
||||
runInAction(() => {
|
||||
this.legacyWaylandNeedsUpdate = needsUpdate;
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read legacy desktop input status', error);
|
||||
}
|
||||
}
|
||||
|
||||
get backend(): GlobalShortcutsBackend | null {
|
||||
const backend = this.status?.backend;
|
||||
if (backend === undefined || !KNOWN_BACKENDS.has(backend)) return null;
|
||||
return backend;
|
||||
}
|
||||
|
||||
get hooksActive(): boolean {
|
||||
return this.status?.hooksActive === true;
|
||||
}
|
||||
|
||||
get hookError(): 'permission' | 'start-failed' | null {
|
||||
return this.status?.hookError ?? null;
|
||||
}
|
||||
|
||||
get linux(): GlobalShortcutsLinuxStatus | null {
|
||||
if (this.status?.platform !== 'linux') return null;
|
||||
return this.status.linux;
|
||||
}
|
||||
|
||||
private get livePortal(): GlobalShortcutsPortalStatus | null {
|
||||
const portal = this.linux?.portal;
|
||||
if (!portal || !KNOWN_PORTAL_STATES.has(portal.state)) return null;
|
||||
return portal;
|
||||
}
|
||||
|
||||
get portal(): GlobalShortcutsPortalStatus | null {
|
||||
const portal = this.livePortal;
|
||||
if (portal === null) return null;
|
||||
if ((portal.recovering === true || portal.state === 'probing') && this.settledPortal !== null) {
|
||||
return this.settledPortal;
|
||||
}
|
||||
return portal;
|
||||
}
|
||||
|
||||
get portalRecovering(): boolean {
|
||||
return this.livePortal?.recovering === true;
|
||||
}
|
||||
|
||||
get isPortalBackend(): boolean {
|
||||
return this.backend === 'portal' && this.portal !== null;
|
||||
}
|
||||
|
||||
getPortalTrigger(action: string): string | null {
|
||||
const portal = this.portal;
|
||||
if (!this.isPortalBackend || portal?.state !== 'bound') return null;
|
||||
return findPortalTrigger(portal, action);
|
||||
}
|
||||
|
||||
isPortalActionAssigned(action: string): boolean {
|
||||
return this.portalAssignedActions.includes(action);
|
||||
}
|
||||
|
||||
async setUp(): Promise<void> {
|
||||
await this.runAction('set-up', (api) => api.setUp());
|
||||
}
|
||||
|
||||
async configure(): Promise<void> {
|
||||
await this.runAction('configure', async (api) => {
|
||||
await api.configure();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async setDirectInputEnabled(enabled: boolean): Promise<void> {
|
||||
await this.runAction('direct-input', (api) => api.setDirectInputEnabled(enabled));
|
||||
}
|
||||
|
||||
async recheck(): Promise<void> {
|
||||
await this.runAction('recheck', (api) => api.recheck());
|
||||
}
|
||||
|
||||
private async runAction(
|
||||
action: GlobalShortcutsPendingAction,
|
||||
run: (api: GlobalShortcutsApi) => Promise<GlobalShortcutsStatus | null>,
|
||||
): Promise<void> {
|
||||
const api = getGlobalShortcutsApi();
|
||||
if (!api || this.pendingAction !== null) return;
|
||||
runInAction(() => {
|
||||
this.pendingAction = action;
|
||||
});
|
||||
try {
|
||||
const status = await run(api);
|
||||
if (status && this.attached) {
|
||||
this.statusRevision += 1;
|
||||
this.applyStatus(status);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Global shortcuts action ${action} failed`, error);
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
this.pendingAction = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new GlobalShortcuts();
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {HoldSources} from '@app/features/input/state/HoldSources';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const RELEASE_DELAY_MS = 250;
|
||||
|
||||
describe('HoldSources', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps push-to-talk open while a portal source is held after the DOM source is released, for example on window blur', () => {
|
||||
const sources = new HoldSources(() => RELEASE_DELAY_MS);
|
||||
const onReleased = vi.fn();
|
||||
expect(sources.press('voice_push_to_talk', 'custom:a')).toBe(true);
|
||||
expect(sources.press('voice_push_to_talk', 'portal:voice_push_to_talk')).toBe(false);
|
||||
sources.release('voice_push_to_talk', 'custom:a', onReleased);
|
||||
vi.advanceTimersByTime(RELEASE_DELAY_MS * 4);
|
||||
expect(onReleased).not.toHaveBeenCalled();
|
||||
sources.release('voice_push_to_talk', 'portal:voice_push_to_talk', onReleased);
|
||||
vi.advanceTimersByTime(RELEASE_DELAY_MS - 1);
|
||||
expect(onReleased).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(onReleased).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cancels the pending release when a source is pressed again', () => {
|
||||
const sources = new HoldSources(() => RELEASE_DELAY_MS);
|
||||
const onReleased = vi.fn();
|
||||
sources.press('voice_push_to_talk', 'custom:a');
|
||||
sources.release('voice_push_to_talk', 'custom:a', onReleased);
|
||||
expect(sources.press('voice_push_to_talk_priority', 'custom:b')).toBe(true);
|
||||
expect(sources.prioritySpeakerHeld).toBe(true);
|
||||
vi.advanceTimersByTime(RELEASE_DELAY_MS * 4);
|
||||
expect(onReleased).not.toHaveBeenCalled();
|
||||
sources.release('voice_push_to_talk_priority', 'custom:b', onReleased);
|
||||
expect(sources.prioritySpeakerHeld).toBe(false);
|
||||
vi.advanceTimersByTime(RELEASE_DELAY_MS);
|
||||
expect(onReleased).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores releases for sources that were never pressed', () => {
|
||||
const sources = new HoldSources(() => RELEASE_DELAY_MS);
|
||||
const onReleased = vi.fn();
|
||||
sources.release('voice_push_to_talk', 'custom:unknown', onReleased);
|
||||
sources.release('voice_push_to_mute', 'custom:unknown', onReleased);
|
||||
sources.press('voice_push_to_talk', 'custom:a');
|
||||
sources.release('voice_push_to_talk', 'custom:unknown', onReleased);
|
||||
vi.advanceTimersByTime(RELEASE_DELAY_MS * 4);
|
||||
expect(onReleased).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears held sources and the pending release on reset', () => {
|
||||
const sources = new HoldSources(() => RELEASE_DELAY_MS);
|
||||
const onReleased = vi.fn();
|
||||
sources.press('voice_push_to_talk', 'custom:a');
|
||||
sources.press('voice_push_to_talk', 'custom:b');
|
||||
sources.release('voice_push_to_talk', 'custom:a', onReleased);
|
||||
sources.release('voice_push_to_talk', 'custom:b', onReleased);
|
||||
sources.resetPushToTalk();
|
||||
vi.advanceTimersByTime(RELEASE_DELAY_MS * 4);
|
||||
expect(onReleased).not.toHaveBeenCalled();
|
||||
sources.press('voice_push_to_talk', 'custom:a');
|
||||
sources.resetPushToTalk();
|
||||
sources.release('voice_push_to_talk', 'custom:a', onReleased);
|
||||
vi.advanceTimersByTime(RELEASE_DELAY_MS * 4);
|
||||
expect(onReleased).not.toHaveBeenCalled();
|
||||
expect(sources.press('voice_push_to_talk', 'custom:a')).toBe(true);
|
||||
});
|
||||
|
||||
it('releases push-to-mute immediately when its last source is released', () => {
|
||||
const sources = new HoldSources(() => RELEASE_DELAY_MS);
|
||||
const onReleased = vi.fn();
|
||||
expect(sources.press('voice_push_to_mute', 'custom:a')).toBe(true);
|
||||
expect(sources.press('voice_push_to_mute', 'gamepad:a')).toBe(false);
|
||||
sources.release('voice_push_to_mute', 'custom:a', onReleased);
|
||||
expect(onReleased).not.toHaveBeenCalled();
|
||||
sources.release('voice_push_to_mute', 'gamepad:a', onReleased);
|
||||
expect(onReleased).toHaveBeenCalledTimes(1);
|
||||
sources.press('voice_push_to_mute', 'custom:a');
|
||||
sources.resetPushToMute();
|
||||
sources.release('voice_push_to_mute', 'custom:a', onReleased);
|
||||
expect(onReleased).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {HoldAction} from '@app/features/app/keybindings/utils/RuntimeKeybinds';
|
||||
|
||||
export class HoldSources {
|
||||
private readonly pttSources = new Set<string>();
|
||||
private readonly prioritySources = new Set<string>();
|
||||
private readonly ptmSources = new Set<string>();
|
||||
private readonly priorityVadSources = new Set<string>();
|
||||
private releaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private readonly getReleaseDelay: () => number) {}
|
||||
|
||||
get prioritySpeakerHeld(): boolean {
|
||||
return this.prioritySources.size > 0 || this.priorityVadSources.size > 0;
|
||||
}
|
||||
|
||||
press(action: HoldAction, sourceId: string): boolean {
|
||||
switch (action) {
|
||||
case 'voice_push_to_talk':
|
||||
case 'voice_push_to_talk_priority': {
|
||||
if (this.pttSources.has(sourceId)) return false;
|
||||
const wasEmpty = this.pttSources.size === 0;
|
||||
this.pttSources.add(sourceId);
|
||||
if (action === 'voice_push_to_talk_priority') this.prioritySources.add(sourceId);
|
||||
this.clearReleaseTimer();
|
||||
return wasEmpty;
|
||||
}
|
||||
case 'voice_push_to_mute':
|
||||
return this.addSource(this.ptmSources, sourceId);
|
||||
case 'voice_priority_vad':
|
||||
return this.addSource(this.priorityVadSources, sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
release(action: HoldAction, sourceId: string, onReleased?: () => void): void {
|
||||
switch (action) {
|
||||
case 'voice_push_to_talk':
|
||||
case 'voice_push_to_talk_priority': {
|
||||
if (!this.pttSources.delete(sourceId)) return;
|
||||
this.prioritySources.delete(sourceId);
|
||||
if (this.pttSources.size > 0) return;
|
||||
this.clearReleaseTimer();
|
||||
this.releaseTimer = setTimeout(() => {
|
||||
this.releaseTimer = null;
|
||||
onReleased?.();
|
||||
}, this.getReleaseDelay());
|
||||
return;
|
||||
}
|
||||
case 'voice_push_to_mute':
|
||||
if (this.removeSource(this.ptmSources, sourceId)) onReleased?.();
|
||||
return;
|
||||
case 'voice_priority_vad':
|
||||
if (this.removeSource(this.priorityVadSources, sourceId)) onReleased?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
resetPushToTalk(): void {
|
||||
this.pttSources.clear();
|
||||
this.prioritySources.clear();
|
||||
this.clearReleaseTimer();
|
||||
}
|
||||
|
||||
resetPushToMute(): void {
|
||||
this.ptmSources.clear();
|
||||
this.priorityVadSources.clear();
|
||||
}
|
||||
|
||||
private addSource(sources: Set<string>, sourceId: string): boolean {
|
||||
if (sources.has(sourceId)) return false;
|
||||
sources.add(sourceId);
|
||||
return sources.size === 1;
|
||||
}
|
||||
|
||||
private removeSource(sources: Set<string>, sourceId: string): boolean {
|
||||
if (!sources.delete(sourceId)) return false;
|
||||
return sources.size === 0;
|
||||
}
|
||||
|
||||
private clearReleaseTimer(): void {
|
||||
if (this.releaseTimer === null) return;
|
||||
clearTimeout(this.releaseTimer);
|
||||
this.releaseTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {HoldAction} from '@app/features/app/keybindings/utils/RuntimeKeybinds';
|
||||
import {COPY_TEXT_DESCRIPTOR, DELETE_MESSAGE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import GlobalShortcuts from '@app/features/input/state/GlobalShortcuts';
|
||||
import {HoldSources} from '@app/features/input/state/HoldSources';
|
||||
import {
|
||||
ADD_REACTION_DESCRIPTOR,
|
||||
ANSWER_THE_INCOMING_CALL_DESCRIPTOR,
|
||||
@@ -211,7 +214,7 @@ const KEYBIND_COMMAND_VALUES = [
|
||||
export type KeybindCommand = (typeof KEYBIND_COMMAND_VALUES)[number];
|
||||
|
||||
const KEYBIND_COMMAND_SET = new Set<string>(KEYBIND_COMMAND_VALUES);
|
||||
const isKeybindCommand = (value: unknown): value is KeybindCommand =>
|
||||
export const isKeybindCommand = (value: unknown): value is KeybindCommand =>
|
||||
typeof value === 'string' && KEYBIND_COMMAND_SET.has(value);
|
||||
|
||||
export interface KeyCombo {
|
||||
@@ -1041,9 +1044,10 @@ class Keybind {
|
||||
private i18n: I18n | null = null;
|
||||
private initialized = false;
|
||||
private keyboardShortcutsOverlayCombo: KeyCombo = {...DEFAULT_KEYBOARD_SHORTCUTS_OVERLAY_COMBO};
|
||||
private holdSources = new HoldSources(() => this.pushToTalkReleaseDelay);
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
makeAutoObservable<this, 'holdSources'>(this, {holdSources: false}, {autoBind: true});
|
||||
void this.initPersistence();
|
||||
}
|
||||
|
||||
@@ -1353,7 +1357,12 @@ class Keybind {
|
||||
}
|
||||
|
||||
hasPushToTalkKeybind(): boolean {
|
||||
return this.hasActiveBindingFor('voice_push_to_talk') || this.hasActiveBindingFor('voice_push_to_talk_priority');
|
||||
return (
|
||||
this.hasActiveBindingFor('voice_push_to_talk') ||
|
||||
this.hasActiveBindingFor('voice_push_to_talk_priority') ||
|
||||
GlobalShortcuts.isPortalActionAssigned('voice_push_to_talk') ||
|
||||
GlobalShortcuts.isPortalActionAssigned('voice_push_to_talk_priority')
|
||||
);
|
||||
}
|
||||
|
||||
isPushToTalkEffective(): boolean {
|
||||
@@ -1367,24 +1376,21 @@ class Keybind {
|
||||
});
|
||||
}
|
||||
|
||||
handlePushToTalkPress(): boolean {
|
||||
runInAction(() => {
|
||||
this.pushToTalkHeld = true;
|
||||
});
|
||||
return true;
|
||||
pressHoldSource(action: HoldAction, sourceId: string): boolean {
|
||||
const activated = this.holdSources.press(action, sourceId);
|
||||
this.prioritySpeakerHeld = this.holdSources.prioritySpeakerHeld;
|
||||
return activated;
|
||||
}
|
||||
|
||||
handlePushToTalkRelease(): boolean {
|
||||
runInAction(() => {
|
||||
this.pushToTalkHeld = false;
|
||||
});
|
||||
return true;
|
||||
releaseHoldSource(action: HoldAction, sourceId: string, onReleased?: () => void): void {
|
||||
this.holdSources.release(action, sourceId, onReleased);
|
||||
this.prioritySpeakerHeld = this.holdSources.prioritySpeakerHeld;
|
||||
}
|
||||
|
||||
resetPushToTalkState(): void {
|
||||
runInAction(() => {
|
||||
this.pushToTalkHeld = false;
|
||||
});
|
||||
this.holdSources.resetPushToTalk();
|
||||
this.pushToTalkHeld = false;
|
||||
this.prioritySpeakerHeld = this.holdSources.prioritySpeakerHeld;
|
||||
}
|
||||
|
||||
setPushToMuteHeld(held: boolean): void {
|
||||
@@ -1394,7 +1400,9 @@ class Keybind {
|
||||
}
|
||||
|
||||
hasPushToMuteKeybind(): boolean {
|
||||
return this.hasActiveBindingFor('voice_push_to_mute');
|
||||
return (
|
||||
this.hasActiveBindingFor('voice_push_to_mute') || GlobalShortcuts.isPortalActionAssigned('voice_push_to_mute')
|
||||
);
|
||||
}
|
||||
|
||||
isPushToMuteEffective(): boolean {
|
||||
@@ -1402,19 +1410,9 @@ class Keybind {
|
||||
}
|
||||
|
||||
resetPushToMuteState(): void {
|
||||
runInAction(() => {
|
||||
this.pushToMuteHeld = false;
|
||||
});
|
||||
}
|
||||
|
||||
setPrioritySpeakerHeld(held: boolean): void {
|
||||
runInAction(() => {
|
||||
this.prioritySpeakerHeld = held;
|
||||
});
|
||||
}
|
||||
|
||||
resetPrioritySpeakerState(): void {
|
||||
this.setPrioritySpeakerHeld(false);
|
||||
this.holdSources.resetPushToMute();
|
||||
this.pushToMuteHeld = false;
|
||||
this.prioritySpeakerHeld = this.holdSources.prioritySpeakerHeld;
|
||||
}
|
||||
|
||||
muteActions(actions: Iterable<KeybindCommand>): void {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import GlobalShortcuts from '@app/features/input/state/GlobalShortcuts';
|
||||
import Keybind from '@app/features/input/state/InputKeybind';
|
||||
import {formatKeyCombo} from '@app/features/input/utils/KeybindUtils';
|
||||
import type {I18n, MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
const PUSH_TO_TALK_ACTIONS = ['voice_push_to_talk', 'voice_push_to_talk_priority'] as const;
|
||||
|
||||
const PUSH_TO_TALK_ON_WITHOUT_KEY_DESCRIPTOR = msg({
|
||||
message: 'Push-to-talk is on. Hold your push-to-talk key to speak.',
|
||||
comment:
|
||||
'Tooltip and screen reader label on the mic button when push-to-talk is on but the key has no name to show, for example when the Linux desktop assigns it.',
|
||||
});
|
||||
|
||||
function getPushToTalkHint(i18n: I18n): string | null {
|
||||
for (const action of PUSH_TO_TALK_ACTIONS) {
|
||||
const trigger = GlobalShortcuts.getPortalTrigger(action);
|
||||
if (trigger) return trigger;
|
||||
const formatted = formatKeyCombo(i18n, Keybind.getByAction(action).combo);
|
||||
if (formatted) return formatted;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPushToTalkHoldLabel(i18n: I18n, descriptor: MessageDescriptor): string {
|
||||
const pushToTalkHint = getPushToTalkHint(i18n);
|
||||
if (pushToTalkHint === null) return i18n._(PUSH_TO_TALK_ON_WITHOUT_KEY_DESCRIPTOR);
|
||||
return i18n._(descriptor, {pushToTalkHint});
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function createSpecialMentionPayload(kind: SpecialMentionKind): ComposerI
|
||||
}
|
||||
|
||||
export function areSpecialMentionsAllowed(
|
||||
channel: Pick<Channel, 'guildId' | 'isPersonalNotes'> | null,
|
||||
channel: Pick<Channel, 'guildId' | 'isGroupDM'> | null,
|
||||
allowSpecialMentions: boolean | undefined,
|
||||
allowedTriggers: ReadonlyArray<TriggerType> | undefined,
|
||||
canMentionEveryone: boolean,
|
||||
@@ -34,7 +34,7 @@ export function areSpecialMentionsAllowed(
|
||||
if (channel == null || allowSpecialMentions === false || !isAutocompleteTriggerAllowed('mention', allowedTriggers)) {
|
||||
return false;
|
||||
}
|
||||
return channel.guildId == null ? !channel.isPersonalNotes() : canMentionEveryone;
|
||||
return channel.guildId == null ? channel.isGroupDM() : canMentionEveryone;
|
||||
}
|
||||
|
||||
export function hasOpenCodeFence(text: string): boolean {
|
||||
|
||||
@@ -206,6 +206,26 @@ class Messages {
|
||||
return messages.ready && messages.length > 0 && !messages.cached;
|
||||
}
|
||||
|
||||
shouldPreloadLatestPage(channelId: string): boolean {
|
||||
if (!GatewayConnection.isConnected || !Channels.getChannel(channelId)) {
|
||||
return false;
|
||||
}
|
||||
const messages = ChannelMessages.get(channelId);
|
||||
if (!messages) return true;
|
||||
if (messages.loadingMore || ChannelMessages.isRetained(channelId)) return false;
|
||||
return messages.length === 0 ? !messages.ready : messages.cached;
|
||||
}
|
||||
|
||||
preloadLatestPage(channelId: string, guildId?: string | null): boolean {
|
||||
if (!this.shouldPreloadLatestPage(channelId)) {
|
||||
return false;
|
||||
}
|
||||
const channel = Channels.getChannel(channelId);
|
||||
const resolvedGuildId = guildId ?? channel?.guildId ?? (channel?.isPrivate() ? ME : undefined);
|
||||
this.handleChannelSelect({guildId: resolvedGuildId ?? undefined, channelId});
|
||||
return true;
|
||||
}
|
||||
|
||||
getMessage(channelId: string, messageId: string): Message | undefined {
|
||||
return ChannelMessages.getOrCreate(channelId).get(messageId);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function isMentioned(user: User, message: Message): boolean {
|
||||
return false;
|
||||
}
|
||||
const suppressEveryone = UserGuildSettings.isEveryoneMentionSuppressed(channel.guildId ?? null);
|
||||
const mentionEveryone = message.mentionEveryone && !suppressEveryone;
|
||||
const mentionEveryone = message.mentionEveryone && !channel.isDM() && !suppressEveryone;
|
||||
if (mentionEveryone) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import MacPermissions from '@app/features/permissions/system/state/MacPermissions';
|
||||
import type {NativePermissionResult} from '@app/features/permissions/system/utils/NativePermissions';
|
||||
import AppStorage from '@app/features/platform/state/PersistentStorage';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {getElectronAPI, getNativePlatform, isDesktop, type NativePlatform} from '@app/features/ui/utils/NativeUtils';
|
||||
import {makeAutoObservable, runInAction} from 'mobx';
|
||||
|
||||
export type LinuxInputAccessNagbarReason = 'global-hotkeys' | 'push-to-talk' | 'settings';
|
||||
export type LinuxInputAccessStatus = 'unknown' | 'granted' | 'blocked';
|
||||
|
||||
const LINUX_INPUT_ACCESS_NAGBAR_DISMISSED_STORAGE_KEY = 'NativePermission:linuxInputAccessNagbarDismissed';
|
||||
const logger = new Logger('NativePermission');
|
||||
|
||||
class NativePermission {
|
||||
@@ -19,14 +13,6 @@ class NativePermission {
|
||||
private _isDesktop = false;
|
||||
private _platform: NativePlatform = 'unknown';
|
||||
private _waylandSession = false;
|
||||
private _linuxFlatpak = false;
|
||||
private _linuxInputAccessStatus: LinuxInputAccessStatus = 'unknown';
|
||||
private _linuxInputAccessNagbarRequested = false;
|
||||
private _linuxInputAccessNagbarDismissed =
|
||||
AppStorage.getItem(LINUX_INPUT_ACCESS_NAGBAR_DISMISSED_STORAGE_KEY) === 'true';
|
||||
private _linuxInputAccessNagbarReason: LinuxInputAccessNagbarReason | null = null;
|
||||
private _linuxInputAccessGrantNeedsRelogin = false;
|
||||
private _linuxInputAccessGrantError: string | null = null;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -37,16 +23,10 @@ class NativePermission {
|
||||
const desktop = isDesktop();
|
||||
const platform = await getNativePlatform();
|
||||
let waylandSession = false;
|
||||
let linuxFlatpak = false;
|
||||
let linuxInputAccessStatus: LinuxInputAccessStatus = 'unknown';
|
||||
if (desktop && platform === 'linux') {
|
||||
try {
|
||||
const electronApi = getElectronAPI();
|
||||
const desktopInfo = await electronApi?.getDesktopInfo();
|
||||
const desktopInfo = await getElectronAPI()?.getDesktopInfo();
|
||||
waylandSession = Boolean(desktopInfo?.waylandSession);
|
||||
linuxFlatpak = Boolean(desktopInfo?.flatpak);
|
||||
const linuxInputAccess = await electronApi?.linuxEvdevStatus?.();
|
||||
linuxInputAccessStatus = linuxInputAccess?.hasAccess ? 'granted' : 'blocked';
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read desktop session type', error);
|
||||
}
|
||||
@@ -55,17 +35,11 @@ class NativePermission {
|
||||
desktop,
|
||||
platform,
|
||||
waylandSession,
|
||||
linuxFlatpak,
|
||||
});
|
||||
runInAction(() => {
|
||||
this._isDesktop = desktop;
|
||||
this._platform = platform;
|
||||
this._waylandSession = waylandSession;
|
||||
this._linuxFlatpak = linuxFlatpak;
|
||||
this._linuxInputAccessStatus = linuxInputAccessStatus;
|
||||
if (linuxInputAccessStatus === 'granted') {
|
||||
this.resolveLinuxInputAccessNagbar();
|
||||
}
|
||||
this._initialized = true;
|
||||
});
|
||||
}
|
||||
@@ -90,10 +64,6 @@ class NativePermission {
|
||||
return this._isDesktop && this._platform === 'linux' && this._waylandSession;
|
||||
}
|
||||
|
||||
get isLinuxFlatpakDesktop(): boolean {
|
||||
return this._isDesktop && this._platform === 'linux' && this._linuxFlatpak;
|
||||
}
|
||||
|
||||
get platform(): NativePlatform {
|
||||
return this._platform;
|
||||
}
|
||||
@@ -106,56 +76,6 @@ class NativePermission {
|
||||
return MacPermissions.statuses['input-monitoring'] === 'granted';
|
||||
}
|
||||
|
||||
get linuxInputAccessStatus(): LinuxInputAccessStatus {
|
||||
return this._linuxInputAccessStatus;
|
||||
}
|
||||
|
||||
get linuxInputAccessGrantNeedsRelogin(): boolean {
|
||||
return this._linuxInputAccessGrantNeedsRelogin;
|
||||
}
|
||||
|
||||
get linuxInputAccessGrantError(): string | null {
|
||||
return this._linuxInputAccessGrantError;
|
||||
}
|
||||
|
||||
get linuxInputAccessNagbarReason(): LinuxInputAccessNagbarReason | null {
|
||||
return this._linuxInputAccessNagbarReason;
|
||||
}
|
||||
|
||||
get shouldShowLinuxInputAccessNagbar(): boolean {
|
||||
return (
|
||||
this._linuxInputAccessNagbarRequested &&
|
||||
!this._linuxInputAccessNagbarDismissed &&
|
||||
this._isDesktop &&
|
||||
this._platform === 'linux' &&
|
||||
this._waylandSession &&
|
||||
this._linuxInputAccessStatus !== 'granted'
|
||||
);
|
||||
}
|
||||
|
||||
requestLinuxInputAccessNagbar(reason: LinuxInputAccessNagbarReason): void {
|
||||
if (this._linuxInputAccessNagbarDismissed) return;
|
||||
this._linuxInputAccessNagbarRequested = true;
|
||||
this._linuxInputAccessNagbarReason = reason;
|
||||
}
|
||||
|
||||
dismissLinuxInputAccessNagbar(): void {
|
||||
this._linuxInputAccessNagbarDismissed = true;
|
||||
AppStorage.setItem(LINUX_INPUT_ACCESS_NAGBAR_DISMISSED_STORAGE_KEY, 'true');
|
||||
this.clearLinuxInputAccessNagbarRequest();
|
||||
}
|
||||
|
||||
private clearLinuxInputAccessNagbarRequest(): void {
|
||||
this._linuxInputAccessNagbarRequested = false;
|
||||
this._linuxInputAccessNagbarReason = null;
|
||||
}
|
||||
|
||||
private resolveLinuxInputAccessNagbar(): void {
|
||||
this.clearLinuxInputAccessNagbarRequest();
|
||||
this._linuxInputAccessNagbarDismissed = false;
|
||||
AppStorage.removeItem(LINUX_INPUT_ACCESS_NAGBAR_DISMISSED_STORAGE_KEY);
|
||||
}
|
||||
|
||||
async recheckInputMonitoring(): Promise<NativePermissionResult> {
|
||||
return MacPermissions.refreshKind('input-monitoring');
|
||||
}
|
||||
@@ -163,56 +83,6 @@ class NativePermission {
|
||||
setInputMonitoringStatus(status: NativePermissionResult): void {
|
||||
MacPermissions.applyPermissionResult('input-monitoring', status);
|
||||
}
|
||||
|
||||
async recheckLinuxInputAccess(): Promise<LinuxInputAccessStatus> {
|
||||
if (!this._isDesktop || this._platform !== 'linux' || !this._waylandSession) {
|
||||
return 'granted';
|
||||
}
|
||||
const status = await getElectronAPI()?.linuxEvdevStatus?.();
|
||||
const nextStatus: LinuxInputAccessStatus = status?.hasAccess ? 'granted' : 'blocked';
|
||||
runInAction(() => {
|
||||
this._linuxInputAccessStatus = nextStatus;
|
||||
if (nextStatus === 'granted') {
|
||||
this._linuxInputAccessGrantNeedsRelogin = false;
|
||||
this._linuxInputAccessGrantError = null;
|
||||
this.resolveLinuxInputAccessNagbar();
|
||||
}
|
||||
});
|
||||
logger.debug('Rechecked Linux input access', {status: nextStatus});
|
||||
return nextStatus;
|
||||
}
|
||||
|
||||
async grantLinuxInputAccess(): Promise<{success: boolean; needsRelogin: boolean; error?: string}> {
|
||||
if (!this._isDesktop || this._platform !== 'linux' || !this._waylandSession) {
|
||||
return {success: false, needsRelogin: false, error: 'Not a Linux Wayland session'};
|
||||
}
|
||||
if (this._linuxFlatpak) {
|
||||
return {
|
||||
success: false,
|
||||
needsRelogin: false,
|
||||
error: `Enable Flatpak input device access, then restart ${PRODUCT_NAME}`,
|
||||
};
|
||||
}
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.linuxEvdevGrantAccess) {
|
||||
return {success: false, needsRelogin: false, error: 'Input access helper is unavailable'};
|
||||
}
|
||||
runInAction(() => {
|
||||
this._linuxInputAccessGrantNeedsRelogin = false;
|
||||
this._linuxInputAccessGrantError = null;
|
||||
});
|
||||
const result = await electronApi.linuxEvdevGrantAccess();
|
||||
const nextStatus = result.success && !result.needsRelogin ? await this.recheckLinuxInputAccess() : 'blocked';
|
||||
runInAction(() => {
|
||||
this._linuxInputAccessGrantNeedsRelogin = Boolean(result.success && result.needsRelogin);
|
||||
this._linuxInputAccessGrantError = result.success ? null : (result.error ?? 'Input access could not be enabled');
|
||||
this._linuxInputAccessStatus = nextStatus;
|
||||
if (nextStatus === 'granted') {
|
||||
this.resolveLinuxInputAccessNagbar();
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export default new NativePermission();
|
||||
|
||||
@@ -414,11 +414,7 @@ export interface ElectronAPI {
|
||||
readableEventDevices: number;
|
||||
inInputGroup: boolean;
|
||||
}>;
|
||||
linuxEvdevGrantAccess: () => Promise<{
|
||||
success: boolean;
|
||||
needsRelogin: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
globalShortcuts?: GlobalShortcutsApi;
|
||||
onGlobalKeyEvent: (callback: (event: GlobalKeyEvent) => void) => () => void;
|
||||
onGlobalMouseEvent: (callback: (event: GlobalMouseEvent) => void) => () => void;
|
||||
onGlobalKeybindTriggered: (callback: (event: GlobalKeybindTriggeredEvent) => void) => () => void;
|
||||
@@ -615,6 +611,113 @@ export interface GlobalKeybindTriggeredEvent {
|
||||
type: 'keydown' | 'keyup';
|
||||
}
|
||||
|
||||
export type GlobalShortcutsBackend = 'portal' | 'x11' | 'evdev' | 'windows' | 'macos' | 'none';
|
||||
|
||||
export type GlobalShortcutsPortalState =
|
||||
| 'unknown'
|
||||
| 'probing'
|
||||
| 'unsupported'
|
||||
| 'not-set-up'
|
||||
| 'binding'
|
||||
| 'bound'
|
||||
| 'declined'
|
||||
| 'error';
|
||||
|
||||
export interface GlobalShortcutsPortalShortcut {
|
||||
action: string;
|
||||
triggerDescription: string | null;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutsPortalStatus {
|
||||
state: GlobalShortcutsPortalState;
|
||||
version: number | null;
|
||||
canConfigure: boolean;
|
||||
canRecheck: boolean;
|
||||
portalAppId: string | null;
|
||||
shortcuts: Array<GlobalShortcutsPortalShortcut>;
|
||||
error: string | null;
|
||||
recovering: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutsLinuxStatus {
|
||||
session: 'wayland' | 'x11' | 'unknown';
|
||||
sandbox: 'flatpak' | 'none';
|
||||
desktop: 'kde' | 'gnome' | 'hyprland' | 'other';
|
||||
portal: GlobalShortcutsPortalStatus | null;
|
||||
directInput: {available: boolean; enabled: boolean; locked: boolean};
|
||||
}
|
||||
|
||||
export interface GlobalShortcutsStatus {
|
||||
backend: GlobalShortcutsBackend;
|
||||
platform: 'linux' | 'windows' | 'macos';
|
||||
linux: GlobalShortcutsLinuxStatus | null;
|
||||
hooksActive: boolean;
|
||||
hookError: 'permission' | 'start-failed' | null;
|
||||
supportsMouseButtons: boolean;
|
||||
supportsModifierOnly: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutCombo {
|
||||
code?: string;
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
meta: boolean;
|
||||
mouseButton?: number;
|
||||
modifierOnly?: boolean;
|
||||
bothSides?: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutBinding {
|
||||
sourceId: string;
|
||||
action: string;
|
||||
combo: GlobalShortcutCombo;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutActionDefinition {
|
||||
action: string;
|
||||
description: string;
|
||||
preferredCombo: GlobalShortcutCombo | null;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutsSyncPayload {
|
||||
bindings: Array<GlobalShortcutBinding>;
|
||||
actions: Array<GlobalShortcutActionDefinition>;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutEvent {
|
||||
action: string;
|
||||
sourceId: string;
|
||||
phase: 'press' | 'release';
|
||||
}
|
||||
|
||||
export interface GlobalShortcutCaptureEvent {
|
||||
type: 'keydown' | 'keyup' | 'mousedown' | 'mouseup';
|
||||
code: string | null;
|
||||
key: string | null;
|
||||
button: number | null;
|
||||
ctrl: boolean;
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
meta: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutsApi {
|
||||
sync: (payload: GlobalShortcutsSyncPayload) => Promise<void>;
|
||||
setPaused: (paused: boolean) => Promise<void>;
|
||||
getStatus: () => Promise<GlobalShortcutsStatus>;
|
||||
onStatus: (callback: (status: GlobalShortcutsStatus) => void) => () => void;
|
||||
onEvent: (callback: (event: GlobalShortcutEvent) => void) => () => void;
|
||||
setUp: () => Promise<GlobalShortcutsStatus>;
|
||||
configure: () => Promise<void>;
|
||||
setDirectInputEnabled: (enabled: boolean) => Promise<GlobalShortcutsStatus>;
|
||||
recheck: () => Promise<GlobalShortcutsStatus>;
|
||||
startCapture: () => Promise<number | null>;
|
||||
stopCapture: (captureId: number) => Promise<void>;
|
||||
onCapture: (callback: (event: GlobalShortcutCaptureEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
export type SpellcheckEngine = 'auto' | 'hunspell' | 'system';
|
||||
|
||||
export interface SpellcheckState {
|
||||
|
||||
@@ -8,7 +8,6 @@ import PremiumState from '@app/features/premium/state/PremiumState';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import type {
|
||||
CurrentSubscriptionPriceResponse,
|
||||
LocalizedCardPreapprovalContinueResponse,
|
||||
PremiumStateResponse,
|
||||
PriceIdsResponse,
|
||||
SelfServeRefundEligibilityResponse,
|
||||
@@ -75,19 +74,6 @@ function checkoutSessionBody(
|
||||
};
|
||||
}
|
||||
|
||||
function preapprovalSessionBody(priceId: string, countryCode: string): Record<string, string> {
|
||||
const normalized = normalizedCountryCode(countryCode) ?? countryCode;
|
||||
return {
|
||||
price_id: priceId,
|
||||
country_code: normalized,
|
||||
client_geoip_country_code: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function tokenBody(token: string): {token: string} {
|
||||
return {token};
|
||||
}
|
||||
|
||||
async function postAndInvalidate(endpoint: string, body?: Record<string, string>): Promise<void> {
|
||||
await http.post(endpoint, body ? {body} : undefined);
|
||||
invalidateCurrentSubscriptionPriceCache();
|
||||
@@ -266,35 +252,6 @@ export async function createCheckoutSession(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLocalizedCardPreapprovalSession(priceId: string, countryCode: string): Promise<string> {
|
||||
try {
|
||||
const response = await http.post<UrlResponse>(Endpoints.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL, {
|
||||
body: preapprovalSessionBody(priceId, countryCode),
|
||||
});
|
||||
logger.info('Localized card preapproval session created', {priceId, countryCode});
|
||||
return response.body.url;
|
||||
} catch (error) {
|
||||
logger.error('Localized card preapproval session creation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function continueLocalizedCardPreapproval(
|
||||
token: string,
|
||||
): Promise<LocalizedCardPreapprovalContinueResponse> {
|
||||
try {
|
||||
const response = await http.post<LocalizedCardPreapprovalContinueResponse>(
|
||||
Endpoints.STRIPE_CHECKOUT_SUBSCRIPTION_PREAPPROVAL_CONTINUE,
|
||||
{body: tokenBody(token)},
|
||||
);
|
||||
logger.debug('Localized card preapproval continuation polled', response.body);
|
||||
return response.body;
|
||||
} catch (error) {
|
||||
logger.error('Localized card preapproval continuation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelSubscriptionAtPeriodEnd(): Promise<void> {
|
||||
try {
|
||||
await postAndInvalidate(Endpoints.PREMIUM_CANCEL_SUBSCRIPTION);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user