mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ca458dada | ||
|
|
0aeff01c2d | ||
|
|
2ac164d5b8 | ||
|
|
14d475df9a | ||
|
|
f3c777b244 | ||
|
|
1544e58e76 | ||
|
|
5d0c9c7cbe | ||
|
|
8f58fcc4c4 | ||
|
|
5799ef705d | ||
|
|
0de7dde1ce | ||
|
|
7b39e5a79d | ||
|
|
583c791016 | ||
|
|
bc5dcdfe21 |
@@ -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',
|
||||
|
||||
@@ -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')},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
const QUOTE_CHARS = new Set(['"', '“', '”', '„', '‟', '″', '«', '»', '"']);
|
||||
|
||||
function extractQuotedPhrases(input: string): {rest: string; phrases: Array<string>} {
|
||||
const phrases: Array<string> = [];
|
||||
let rest = '';
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
if (!QUOTE_CHARS.has(input[i])) {
|
||||
rest += input[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
let phrase = '';
|
||||
while (i < input.length && !QUOTE_CHARS.has(input[i])) {
|
||||
phrase += input[i];
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
const trimmed = phrase.trim();
|
||||
if (trimmed) phrases.push(trimmed);
|
||||
rest += ' ';
|
||||
}
|
||||
return {rest: rest.replace(/\s+/g, ' ').trim(), phrases};
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(phrase: string): string {
|
||||
let start = 0;
|
||||
let end = phrase.length;
|
||||
while (start < end && QUOTE_CHARS.has(phrase[start])) start++;
|
||||
while (end > start && QUOTE_CHARS.has(phrase[end - 1])) end--;
|
||||
return phrase.slice(start, end).trim();
|
||||
}
|
||||
|
||||
export function normalizeQuotedPhrases<T extends {content?: string; exact_phrases?: Array<string>}>(params: T): T {
|
||||
const exactPhrases = (params.exact_phrases ?? []).map(stripWrappingQuotes).filter(Boolean);
|
||||
let content = params.content;
|
||||
if (content) {
|
||||
const extracted = extractQuotedPhrases(content);
|
||||
exactPhrases.push(...extracted.phrases);
|
||||
content = extracted.rest || undefined;
|
||||
}
|
||||
return {
|
||||
...params,
|
||||
content,
|
||||
exact_phrases: exactPhrases.length > 0 ? [...new Set(exactPhrases)] : undefined,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {GuildService} from '@app/api/guild/services/GuildService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {GlobalSearchService} from '@app/api/search/GlobalSearchService';
|
||||
import {normalizeQuotedPhrases} from '@app/api/search/SearchQuotedPhrases';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -58,7 +59,8 @@ export class SearchService {
|
||||
data: GlobalSearchMessagesRequest;
|
||||
}): Promise<MessageSearchResponse> {
|
||||
const {userId, requestCache, data} = params;
|
||||
const {channel_id, channel_ids, context_channel_id, context_guild_id, ...searchParams} = data;
|
||||
const {channel_id, channel_ids, context_channel_id, context_guild_id, ...rawSearchParams} = data;
|
||||
const searchParams = normalizeQuotedPhrases(rawSearchParams);
|
||||
const contextChannelId = context_channel_id ? createChannelID(context_channel_id) : null;
|
||||
const contextGuildId = context_guild_id ? createGuildID(context_guild_id) : null;
|
||||
const channelIds = (channel_ids ?? channel_id)?.map((id) => createChannelID(id)) ?? [];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -145,6 +145,28 @@ describe('Message Search Exact Phrases', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
test('typographic quotes in content are treated as exact phrases', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Smart Quote Guild');
|
||||
const channelId = guild.system_channel_id!;
|
||||
const timestamp = Date.now();
|
||||
const tag = `sq-${timestamp}`;
|
||||
await sendMessage(harness, owner.token, channelId, `${tag} hello world`);
|
||||
await sendMessage(harness, owner.token, channelId, `${tag} world hello`);
|
||||
await markChannelAsIndexed(harness, channelId);
|
||||
const result = await createBuilder<MessageSearchResponse>(harness, owner.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
content: `${tag} \u201chello world\u201d`,
|
||||
context_channel_id: channelId,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(isSearchResult(result)).toBe(true);
|
||||
if (isSearchResult(result)) {
|
||||
expect(result.messages.map((m) => m.content)).toEqual([`${tag} hello world`]);
|
||||
}
|
||||
});
|
||||
test('exact_phrases combined with author_id filter', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Author Filter Phrase Guild');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -10,6 +10,10 @@ import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErro
|
||||
import {failureCode, failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {
|
||||
ACCOUNT_LIMITED_NOTICE_DESCRIPTOR,
|
||||
ACCOUNT_LIMITED_TITLE_DESCRIPTOR,
|
||||
} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
@@ -72,11 +76,15 @@ export function resolveDmActionErrorContent(
|
||||
): {title: string; message: string} {
|
||||
switch (code) {
|
||||
case APIErrorCodes.NEW_CONVERSATIONS_LIMITED:
|
||||
case APIErrorCodes.ACCOUNT_LIMITED:
|
||||
return {
|
||||
title: i18nGlobal._(GENERIC_TITLE_DESCRIPTOR),
|
||||
message: apiMessage || i18nGlobal._(GENERIC_ERROR_BODY_DESCRIPTOR),
|
||||
};
|
||||
case APIErrorCodes.ACCOUNT_LIMITED:
|
||||
return {
|
||||
title: i18nGlobal._(ACCOUNT_LIMITED_TITLE_DESCRIPTOR),
|
||||
message: apiMessage || i18nGlobal._(ACCOUNT_LIMITED_NOTICE_DESCRIPTOR),
|
||||
};
|
||||
case APIErrorCodes.CANNOT_SEND_MESSAGES_TO_USER:
|
||||
return {
|
||||
title: i18nGlobal._(CANNOT_MESSAGE_USER_TITLE_DESCRIPTOR),
|
||||
|
||||
@@ -1271,6 +1271,7 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
|
||||
[NagbarType.SCHEDULED_MAINTENANCE]: {tone: SkeletonNagbarTone.MAINTENANCE_SCHEDULED, hasActions: true},
|
||||
[NagbarType.UNCLAIMED_ACCOUNT]: {tone: SkeletonNagbarTone.ALERT, hasActions: true},
|
||||
[NagbarType.EMAIL_VERIFICATION]: {tone: SkeletonNagbarTone.ALERT, hasActions: true},
|
||||
[NagbarType.ACCOUNT_LIMITED]: {tone: SkeletonNagbarTone.NEUTRAL, hasActions: false},
|
||||
[NagbarType.DESKTOP_NOTIFICATION]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
|
||||
[NagbarType.PREMIUM_GRACE_PERIOD]: {tone: SkeletonNagbarTone.PREMIUM, hasActions: true},
|
||||
[NagbarType.PREMIUM_EXPIRED]: {tone: SkeletonNagbarTone.DANGER, hasActions: true},
|
||||
@@ -1284,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;
|
||||
@@ -307,6 +305,11 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
: nagbarState.forceEmailVerification
|
||||
? true
|
||||
: Boolean(RuntimeConfig.emailsEnabled && user?.isClaimed() && !user.verified),
|
||||
canShowAccountLimited: nagbarState.forceHideAccountLimited
|
||||
? false
|
||||
: nagbarState.forceAccountLimited
|
||||
? true
|
||||
: user?.accountLimited === true,
|
||||
canShowDesktopNotification: nagbarState.forceHideDesktopNotification
|
||||
? false
|
||||
: nagbarState.forceDesktopNotification
|
||||
@@ -323,7 +326,6 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
canShowVisionaryMfa,
|
||||
canShowVoiceSessionRestore,
|
||||
needsTermsAcceptance,
|
||||
canShowLinuxInputAccess,
|
||||
canShowSoftwareEncoder,
|
||||
canShowStreamerMode,
|
||||
canShowDesktopUpdateReady,
|
||||
@@ -375,6 +377,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
|
||||
visible: conditions.userNeedsVerification,
|
||||
dismissible: false,
|
||||
},
|
||||
{
|
||||
type: NagbarType.ACCOUNT_LIMITED,
|
||||
priority: -3.75,
|
||||
visible: conditions.canShowAccountLimited,
|
||||
dismissible: false,
|
||||
},
|
||||
{
|
||||
type: NagbarType.PREMIUM_EXPIRED,
|
||||
priority: 0,
|
||||
@@ -435,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,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const NagbarType = {
|
||||
SCHEDULED_MAINTENANCE: 'scheduled-maintenance',
|
||||
UNCLAIMED_ACCOUNT: 'unclaimed-account',
|
||||
EMAIL_VERIFICATION: 'email-verification',
|
||||
ACCOUNT_LIMITED: 'account-limited',
|
||||
DESKTOP_NOTIFICATION: 'desktop-notification',
|
||||
PREMIUM_GRACE_PERIOD: 'premium-grace-period',
|
||||
PREMIUM_EXPIRED: 'premium-expired',
|
||||
@@ -22,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',
|
||||
@@ -48,6 +48,7 @@ export interface NagbarConditions {
|
||||
canShowScheduledMaintenance: boolean;
|
||||
userIsUnclaimed: boolean;
|
||||
userNeedsVerification: boolean;
|
||||
canShowAccountLimited: boolean;
|
||||
canShowDesktopNotification: boolean;
|
||||
canShowPremiumGracePeriod: boolean;
|
||||
canShowPremiumExpired: boolean;
|
||||
@@ -61,7 +62,6 @@ export interface NagbarConditions {
|
||||
canShowVisionaryMfa: boolean;
|
||||
canShowVoiceSessionRestore: boolean;
|
||||
needsTermsAcceptance: boolean;
|
||||
canShowLinuxInputAccess: boolean;
|
||||
canShowSoftwareEncoder: boolean;
|
||||
canShowStreamerMode: boolean;
|
||||
canShowDomainMoved: boolean;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {type NagbarState, NagbarType} from '@app/features/app/components/layout/app_layout/AppLayoutTypes';
|
||||
import styles from '@app/features/app/components/layout/app_layout/NagbarContainer.module.css';
|
||||
import {AccountLimitedNagbar} from '@app/features/app/components/layout/app_layout/nagbars/AccountLimitedNagbar';
|
||||
import {BuildEnvironmentNagbar} from '@app/features/app/components/layout/app_layout/nagbars/BuildEnvironmentNagbar';
|
||||
import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ConnectionNagbar';
|
||||
import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
|
||||
@@ -13,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';
|
||||
@@ -105,6 +105,14 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
data-flx="app.app-layout.nagbar-container.email-verification-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.ACCOUNT_LIMITED:
|
||||
return (
|
||||
<AccountLimitedNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.account-limited-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.DESKTOP_NOTIFICATION:
|
||||
return (
|
||||
<DesktopNotificationNagbar
|
||||
@@ -208,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
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {ACCOUNT_LIMITED_NOTICE_DESCRIPTOR} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
export const AccountLimitedNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.NEUTRAL].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.NEUTRAL].textColor}
|
||||
data-flx="app.app-layout.nagbars.account-limited-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
message={
|
||||
<span data-flx="app.app-layout.nagbars.account-limited-nagbar.message">
|
||||
{i18n._(ACCOUNT_LIMITED_NOTICE_DESCRIPTOR)}
|
||||
</span>
|
||||
}
|
||||
data-flx="app.app-layout.nagbars.account-limited-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
});
|
||||
-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,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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const MIN_CHECK_INTERVAL_MS = 60 * 1000;
|
||||
const MANUAL_DOWNLOAD_REFRESH_TIMEOUT_MS = 5 * 1000;
|
||||
const WEB_CHECK_TIMEOUT_MS = 15 * 1000;
|
||||
const NATIVE_CHECK_TIMEOUT_MS = 30 * 1000;
|
||||
const VERSION_ENDPOINT = '/version.json';
|
||||
const CURRENT_BUILD_VERSION = Config.PUBLIC_BUILD_VERSION ?? null;
|
||||
const ALLOWED_WEB_UPDATE_HOSTS = new Set([
|
||||
@@ -157,6 +158,7 @@ class Updater {
|
||||
private unsubscribeNativeEvents: (() => void) | null = null;
|
||||
private updateReadyNagbarDismissedVersion: string | null = null;
|
||||
private pendingManualDownloadRefreshes = 0;
|
||||
private checkInProgress = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {}, {autoBind: true});
|
||||
@@ -195,10 +197,6 @@ class Updater {
|
||||
return this.snapshot.context.nativeManualDownloadOptions;
|
||||
}
|
||||
|
||||
private get checkInProgress(): boolean {
|
||||
return this.snapshot.context.checkInProgress;
|
||||
}
|
||||
|
||||
private get nativeCheckFailed(): boolean {
|
||||
return this.snapshot.context.nativeCheckFailed;
|
||||
}
|
||||
@@ -495,6 +493,7 @@ class Updater {
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkInProgress = true;
|
||||
this.transition({type: 'check.started'});
|
||||
|
||||
const checkContext: 'user' | 'background' = userInitiated ? 'user' : 'background';
|
||||
@@ -518,6 +517,7 @@ class Updater {
|
||||
pushUpdateCheckFailedModal();
|
||||
}
|
||||
} finally {
|
||||
this.checkInProgress = false;
|
||||
this.transition({type: failed ? 'check.failed' : 'check.finished', now: Date.now()});
|
||||
}
|
||||
}
|
||||
@@ -525,12 +525,17 @@ class Updater {
|
||||
private async checkNativeUpdate(context: 'user' | 'background'): Promise<boolean> {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return false;
|
||||
let timeoutId: number | undefined;
|
||||
const timedOut = new Promise<false>((resolve) => {
|
||||
timeoutId = window.setTimeout(() => resolve(false), NATIVE_CHECK_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
await electronApi.updaterCheck(context);
|
||||
return true;
|
||||
return await Promise.race([electronApi.updaterCheck(context).then(() => true), timedOut]);
|
||||
} catch (error) {
|
||||
logger.debug('Native update check failed silently:', error);
|
||||
return false;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,6 +697,7 @@ class Updater {
|
||||
if (this.checkInProgress) {
|
||||
return option;
|
||||
}
|
||||
this.checkInProgress = true;
|
||||
this.transition({type: 'check.started'});
|
||||
let timeoutId: number | undefined;
|
||||
const timedOut = new Promise<boolean>((resolve) => {
|
||||
@@ -706,6 +712,7 @@ class Updater {
|
||||
return this.nativeManualDownloadOptions.find((candidate) => candidate.format === option.format) ?? option;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
this.checkInProgress = false;
|
||||
this.transition({type: 'check.finished', now: Date.now()});
|
||||
}
|
||||
}
|
||||
|
||||
+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
@@ -2,10 +2,10 @@
|
||||
|
||||
import i18n from '@app/app/I18n';
|
||||
import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModal';
|
||||
import {failureCode, failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {ACCOUNT_LIMITED_NOTICE_DESCRIPTOR} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
@@ -36,6 +36,7 @@ const INVITE_LINK_FAILED_MESSAGE_DESCRIPTOR = msg({
|
||||
});
|
||||
|
||||
export function showGroupRecipientAddFailedModal(error: unknown): void {
|
||||
if (handleAccountLimitedError(error)) return;
|
||||
const code = failureCode(error);
|
||||
ModalCommands.push(
|
||||
modal(() => {
|
||||
@@ -47,9 +48,6 @@ export function showGroupRecipientAddFailedModal(error: unknown): void {
|
||||
case APIErrorCodes.RATE_LIMITED:
|
||||
message = i18n._(RATE_LIMITED_DESCRIPTOR);
|
||||
break;
|
||||
case APIErrorCodes.ACCOUNT_LIMITED:
|
||||
message = failureMessage(error) || i18n._(ACCOUNT_LIMITED_NOTICE_DESCRIPTOR);
|
||||
break;
|
||||
default:
|
||||
message = i18n._(ADD_TO_GROUP_FAILED_MESSAGE_DESCRIPTOR);
|
||||
break;
|
||||
@@ -65,7 +63,8 @@ export function showGroupRecipientAddFailedModal(error: unknown): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function showGroupInviteCreateFailedModal(): void {
|
||||
export function showGroupInviteCreateFailedModal(error: unknown): void {
|
||||
if (handleAccountLimitedError(error)) return;
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<GenericErrorModal
|
||||
|
||||
@@ -14,6 +14,7 @@ import {TRY_AGAIN_IN_A_MOMENT_DESCRIPTOR} from '@app/features/i18n/utils/CommonM
|
||||
import * as InviteCommands from '@app/features/invite/commands/InviteCommands';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import {blockIfAccountLimited} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {MS_PER_DAY} from '@fluxer/date_utils/src/DateConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useCallback, useMemo, useRef, useState} from 'react';
|
||||
@@ -74,6 +75,7 @@ export function useAddFriendsToGroupModalLogic(channelId: string): State & Handl
|
||||
[remainingSlotsCount],
|
||||
);
|
||||
const handleAddFriends = useCallback(async () => {
|
||||
if (blockIfAccountLimited()) return;
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const promises = selectedUserIds.map((userId) =>
|
||||
@@ -109,7 +111,7 @@ export function useAddFriendsToGroupModalLogic(channelId: string): State & Handl
|
||||
return fullUrl;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate invite:', error);
|
||||
showGroupInviteCreateFailedModal();
|
||||
showGroupInviteCreateFailedModal(error);
|
||||
return null;
|
||||
} finally {
|
||||
setIsGeneratingInvite(false);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {blockIfAccountLimited} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
@@ -220,6 +221,7 @@ export function useCreateDMModalLogic(
|
||||
const createChannel = useCallback(
|
||||
async (userIds: Array<string>) => {
|
||||
if (restriction) return;
|
||||
if (userIds.length !== 1 && blockIfAccountLimited()) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const channel =
|
||||
|
||||
@@ -10,6 +10,7 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as FailureInspect from '@app/features/platform/utils/ResponseInspection';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import * as UserProfileCommands from '@app/features/user/commands/UserProfileCommands';
|
||||
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import type {ConnectionType} from '@fluxer/constants/src/ConnectionConstants';
|
||||
import type {
|
||||
ConnectionListResponse,
|
||||
@@ -86,6 +87,7 @@ function successToast(i18n: I18n, message: MessageDescriptor): void {
|
||||
}
|
||||
|
||||
function showErrorModal(i18n: I18n, error: unknown, fallbackMessage: MessageDescriptor): void {
|
||||
if (handleAccountLimitedError(error)) return;
|
||||
const errorMessage = FailureInspect.failureMessage(error);
|
||||
showGenericErrorModal({
|
||||
title: () => i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ACCOUNT_LIMITED_DESCRIPTOR} from '@app/features/channel/components/channel_header_components/developer_tools/OptionPresets';
|
||||
import type {Nagbar, NagbarToggleKey} from '@app/features/ui/state/Nagbar';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -164,6 +165,22 @@ export const getNagbarControls = (): Array<NagbarControlDefinition> => [
|
||||
forceShowDisabled: (state) => state.forceEmailVerification,
|
||||
forceHideDisabled: (state) => state.forceHideEmailVerification,
|
||||
},
|
||||
{
|
||||
key: 'forceAccountLimited',
|
||||
label: ACCOUNT_LIMITED_DESCRIPTOR,
|
||||
forceKey: 'forceAccountLimited',
|
||||
forceHideKey: 'forceHideAccountLimited',
|
||||
resetKeys: ['forceAccountLimited'],
|
||||
status: (state) =>
|
||||
state.forceAccountLimited
|
||||
? FORCE_ENABLED
|
||||
: state.forceHideAccountLimited
|
||||
? FORCE_DISABLED
|
||||
: USING_ACTUAL_ACCOUNT_STATE,
|
||||
useActualDisabled: (state) => !state.forceAccountLimited && !state.forceHideAccountLimited,
|
||||
forceShowDisabled: (state) => state.forceAccountLimited,
|
||||
forceHideDisabled: (state) => state.forceHideAccountLimited,
|
||||
},
|
||||
{
|
||||
key: 'forceDesktopNotification',
|
||||
label: DESKTOP_NOTIFICATION_NAGBAR_DESCRIPTOR,
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as NavigationCommands from '@app/features/navigation/commands/Navigatio
|
||||
import {failureCode, failureMessage} from '@app/features/platform/utils/ResponseInspection';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {showAccountLimitedModal} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {blockIfAccountLimited, showAccountLimitedModal} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
|
||||
@@ -116,6 +116,7 @@ export async function joinDiscoveryGuild(
|
||||
guildId: string,
|
||||
target?: {channelId: string; messageId?: string},
|
||||
): Promise<boolean> {
|
||||
if (blockIfAccountLimited()) return false;
|
||||
try {
|
||||
await DiscoveryCommands.joinGuild(guildId);
|
||||
if (target) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as MessageCommands from '@app/features/messaging/commands/MessageComman
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {blockIfAccountLimited} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {useCopyLinkHandler} from '@app/lib/copy-link';
|
||||
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -51,6 +52,7 @@ export const GiftSendToFriendModal = observer(function GiftSendToFriendModal({co
|
||||
const handleCopy = useCopyLinkHandler(giftUrl, true);
|
||||
const handleSendGift = useCallback(
|
||||
async (item: RecipientItem) => {
|
||||
if (blockIfAccountLimited()) return;
|
||||
const userId = item.type === 'group_dm' ? item.id : item.user.id;
|
||||
setSendingTo((previous) => new Set(previous).add(userId));
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimAccountModal';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {TemplateSerializedGuild} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
|
||||
@@ -80,6 +81,9 @@ export function parseTemplateCode(input: string): string | null {
|
||||
}
|
||||
|
||||
export function handleGuildCreationError(error: unknown): never {
|
||||
if (handleAccountLimitedError(error)) {
|
||||
throw new DOMException('Guild create skipped', 'AbortError');
|
||||
}
|
||||
if (failureCode(error) === APIErrorCodes.UNCLAIMED_ACCOUNT_CANNOT_CREATE_GUILDS) {
|
||||
openClaimAccountModal({force: true});
|
||||
}
|
||||
|
||||
+1
-2
@@ -40,7 +40,6 @@ import {useRoleHierarchy} from '@app/features/permissions/hooks/useRoleHierarchy
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as RelationshipCommands from '@app/features/relationship/commands/RelationshipCommands';
|
||||
import Relationships from '@app/features/relationship/state/Relationships';
|
||||
import * as RelationshipActionUtils from '@app/features/relationship/utils/RelationshipActionUtils';
|
||||
import {
|
||||
@@ -204,7 +203,7 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
|
||||
);
|
||||
};
|
||||
const handleSendFriendRequest = () => {
|
||||
RelationshipCommands.sendFriendRequest(user.id);
|
||||
void RelationshipActionUtils.sendFriendRequest(i18n, user.id);
|
||||
onClose();
|
||||
};
|
||||
const handleAcceptFriendRequest = () => {
|
||||
|
||||
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});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {GenericErrorModal} from '@app/features/app/components/alerts/GenericErro
|
||||
import {TemporaryInviteRequiresPresenceModal} from '@app/features/app/components/alerts/TemporaryInviteRequiresPresenceModal';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import {isAbortError} from '@app/features/auth/state/SudoPrompt';
|
||||
import {GuildAtCapacityModal} from '@app/features/guild/components/alerts/GuildAtCapacityModal';
|
||||
import {MaxGuildsModal} from '@app/features/guild/components/alerts/MaxGuildsModal';
|
||||
import {InviteAcceptFailedModal} from '@app/features/invite/components/alerts/InviteAcceptFailedModal';
|
||||
@@ -23,7 +24,7 @@ import {failureCode, failureMessage} from '@app/features/platform/utils/Response
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {showAccountLimitedModal} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {blockIfAccountLimited, showAccountLimitedModal} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -169,6 +170,9 @@ export async function fetchWithCoalescing(code: string): Promise<Invite> {
|
||||
}
|
||||
|
||||
const accept = async (code: string): Promise<Invite> => {
|
||||
if (blockIfAccountLimited()) {
|
||||
throw new DOMException('Invite accept skipped', 'AbortError');
|
||||
}
|
||||
try {
|
||||
logger.debug(`Accepting invite with code ${code}`);
|
||||
const response = await http.post<Invite>(Endpoints.INVITE(code), {body: ACCEPT_INVITE_BODY});
|
||||
@@ -220,6 +224,7 @@ export async function acceptAndTransitionToChannel(code: string, i18n: I18n): Pr
|
||||
);
|
||||
NavigationCommands.selectChannel(guildId, targetChannelId);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error;
|
||||
const responseErr = error instanceof HttpError ? error : null;
|
||||
const errorCode = failureCode(error);
|
||||
logger.error(`Failed to accept invite and transition for code ${code}:`, error);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal';
|
||||
import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands';
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {CopyLinkSection} from '@app/features/app/components/dialogs/shared/CopyLinkSection';
|
||||
@@ -32,6 +33,7 @@ import styles from '@app/features/invite/components/modals/InviteModal.module.cs
|
||||
import * as InviteUtils from '@app/features/invite/utils/InviteUtils';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
@@ -41,6 +43,7 @@ import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel';
|
||||
import {blockIfAccountLimited} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {useCopyLinkHandler} from '@app/lib/copy-link';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_INVITE_AGE_SECONDS, MAX_INVITE_USES} from '@fluxer/constants/src/LimitConstants';
|
||||
@@ -285,15 +288,13 @@ const InviteModalContent = observer(function InviteModalContent({
|
||||
const displayedInviteUrl = hideInviteLinks && inviteUrl ? i18n._(LINK_HIDDEN_WHILE_SHARING_DESCRIPTOR) : inviteUrl;
|
||||
const handleCopy = useCopyLinkHandler(inviteUrl, true);
|
||||
const handleSendInvite = async (item: RecipientItem) => {
|
||||
if (blockIfAccountLimited()) return;
|
||||
const userId = item.type === 'group_dm' ? item.id : item.user.id;
|
||||
setSendingTo((prev) => new Set(prev).add(userId));
|
||||
let targetChannelId: string;
|
||||
if (item.channelId) {
|
||||
targetChannelId = item.channelId;
|
||||
} else {
|
||||
targetChannelId = await PrivateChannelCommands.ensureDMChannel(item.user.id);
|
||||
}
|
||||
try {
|
||||
const targetChannelId = item.channelId
|
||||
? item.channelId
|
||||
: await PrivateChannelCommands.ensureDMChannel(item.user.id);
|
||||
const result = await MessageCommands.send(targetChannelId, {
|
||||
content: inviteUrl,
|
||||
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
|
||||
@@ -303,6 +304,10 @@ const InviteModalContent = observer(function InviteModalContent({
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to send invite:', error);
|
||||
if (failureCode(error)) {
|
||||
showDmActionErrorModal(error);
|
||||
return;
|
||||
}
|
||||
showGenericErrorModal({
|
||||
title: () => i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR),
|
||||
message: () => i18n._(FAILED_TO_SEND_INVITE_DESCRIPTOR),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal';
|
||||
import {FeatureTemporarilyDisabledModal} from '@app/features/app/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands';
|
||||
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
@@ -61,7 +62,7 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||
import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {blockIfAccountLimited, handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {MessageFlags, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {JumpType} from '@fluxer/constants/src/JumpConstants';
|
||||
@@ -715,6 +716,10 @@ function showEditFailureModal(error: unknown): void {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (failureCode(error) === APIErrorCodes.NEW_CONVERSATIONS_LIMITED) {
|
||||
showDmActionErrorModal(error);
|
||||
return;
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-commands.message-edit-failed-modal" />),
|
||||
);
|
||||
@@ -903,6 +908,7 @@ function showCrosspostFailure(i18n: I18n, error: unknown): void {
|
||||
|
||||
export async function crosspost(i18n: I18n, channelId: string, messageId: string): Promise<boolean> {
|
||||
logger.debug(`Publishing message ${messageId} in channel ${channelId}`);
|
||||
if (blockIfAccountLimited()) return false;
|
||||
try {
|
||||
await http.post<WireMessage>(Endpoints.CHANNEL_MESSAGE_CROSSPOST(channelId, messageId), {
|
||||
mode: 'strict',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal';
|
||||
import {FeatureTemporarilyDisabledModal} from '@app/features/app/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
@@ -60,6 +61,10 @@ const checkReactionResponse = (i18n: I18n, error: HttpError): boolean => {
|
||||
showAccountLimitedModal(failureMessage(error));
|
||||
return true;
|
||||
}
|
||||
if (errorCode === APIErrorCodes.NEW_CONVERSATIONS_LIMITED) {
|
||||
showDmActionErrorModal(error);
|
||||
return true;
|
||||
}
|
||||
if (errorCode === APIErrorCodes.COMMUNICATION_DISABLED) {
|
||||
logger.debug('Communication disabled while timed out, not retrying');
|
||||
ToastCommands.createToast({
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
|
||||
import * as ThemeCommands from '@app/features/theme/commands/ThemeCommands';
|
||||
import linkRendererStyles from '@app/features/theme/styles/LinkRenderer.module.css';
|
||||
import markupStyles from '@app/features/theme/styles/Markup.module.css';
|
||||
import * as ThemeUtils from '@app/features/theme/utils/ThemeUtils';
|
||||
import TrustedDomain from '@app/features/trusted_domain/state/TrustedDomain';
|
||||
@@ -66,6 +67,8 @@ import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {APP_PROTOCOL_PREFIX, APP_PROTOCOL_SCHEME, isAppProtocolUrl} from '@app/features/ui/utils/AppProtocol';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import {
|
||||
@@ -81,7 +84,7 @@ import {isProbablyAValidSnowflake} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react';
|
||||
import {CaretRightIcon, ChatTeardropIcon, LockIcon} from '@phosphor-icons/react';
|
||||
import {CaretRightIcon, ChatTeardropIcon, LockIcon, WarningIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
@@ -995,6 +998,8 @@ export const LinkRenderer = observer(function LinkRenderer({
|
||||
const shouldShowAccessDeniedModal = Boolean(jumpTarget && !jumpChannel);
|
||||
let isInternal = false;
|
||||
let handleClick: ((e: React.MouseEvent) => void) | undefined;
|
||||
let isExternalDestination = false;
|
||||
let isTrustedExternalDestination = false;
|
||||
if (userProfileId) {
|
||||
handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
@@ -1150,6 +1155,8 @@ export const LinkRenderer = observer(function LinkRenderer({
|
||||
}
|
||||
if (!isInternal && !inviteCode) {
|
||||
const isTrusted = TrustedDomain.isTrustedDomain(parsed.hostname);
|
||||
isExternalDestination = true;
|
||||
isTrustedExternalDestination = isTrusted;
|
||||
if (!isTrusted) {
|
||||
handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -1170,7 +1177,8 @@ export const LinkRenderer = observer(function LinkRenderer({
|
||||
}
|
||||
const href = AttachmentUrlRefresher.fresh(url);
|
||||
const warmAttachmentUrl = () => AttachmentUrlRefresher.warm(url);
|
||||
return (
|
||||
const showDestinationTooltip = isExternalDestination && text != null;
|
||||
const linkElement = (
|
||||
<FocusRing key={id} offset={-2} data-flx="messaging.markdown.renderers.link-renderer.focus-ring--2">
|
||||
<a
|
||||
href={href}
|
||||
@@ -1204,4 +1212,22 @@ export const LinkRenderer = observer(function LinkRenderer({
|
||||
</a>
|
||||
</FocusRing>
|
||||
);
|
||||
if (!showDestinationTooltip) {
|
||||
return linkElement;
|
||||
}
|
||||
const destinationTooltipText = MobileLayout.enabled
|
||||
? url
|
||||
: () => (
|
||||
<span className={linkRendererStyles.destination}>
|
||||
{!isTrustedExternalDestination && (
|
||||
<WarningIcon size={16} weight="fill" className={linkRendererStyles.destinationWarningIcon} />
|
||||
)}
|
||||
<span>{url}</span>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Tooltip text={destinationTooltipText} type="normal" position="bottom" maxWidth="xl">
|
||||
{linkElement}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal';
|
||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import type {ForwardDestination} from '@app/features/app/components/dialogs/shared/ForwardDefaultDestinations';
|
||||
import {MAX_FORWARD_DESTINATIONS} from '@app/features/app/components/dialogs/shared/ForwardDestinationSelection';
|
||||
@@ -31,6 +32,7 @@ import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegment
|
||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {shouldDisableAutofocusOnMobile} from '@app/features/platform/utils/AutofocusUtils';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Checkbox} from '@app/features/ui/checkbox/Checkbox';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -334,6 +336,10 @@ export const ForwardModal = observer(
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to forward message:', error);
|
||||
if (failureCode(error)) {
|
||||
showDmActionErrorModal(error);
|
||||
return;
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<MessageForwardFailedModal data-flx="messaging.forward-modal.handle-forward.message-forward-failed-modal" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import i18n from '@app/app/I18n';
|
||||
import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal';
|
||||
import {FeatureTemporarilyDisabledModal} from '@app/features/app/components/alerts/FeatureTemporarilyDisabledModal';
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
|
||||
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import type {RestResponse} from '@app/features/platform/types/TransportTypes';
|
||||
@@ -1278,6 +1280,12 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
||||
) {
|
||||
const systemMessage = createSystemMessage(channelId, conversationLimit.message);
|
||||
MessageCommands.createOptimistic(channelId, systemMessage.toJSON());
|
||||
if (
|
||||
conversationLimit.code === APIErrorCodes.NEW_CONVERSATIONS_LIMITED &&
|
||||
channelId !== SelectedChannel.currentChannelId
|
||||
) {
|
||||
showDmActionErrorModal(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (getApiErrorBody(error)?.code === APIErrorCodes.CONTENT_BLOCKED) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user