mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab0b483fbe | ||
|
|
6e2f90b03c | ||
|
|
5e0806f479 |
@@ -2,6 +2,7 @@
|
||||
|
||||
export const GatewayRpcMethodErrorCodes = {
|
||||
OVERLOADED: 'overloaded',
|
||||
GUILD_OVERLOADED: 'guild_overloaded',
|
||||
INTERNAL_ERROR: 'internal_error',
|
||||
TIMEOUT: 'timeout',
|
||||
NO_RESPONDERS: 'no_responders',
|
||||
|
||||
@@ -296,6 +296,9 @@ export class GatewayService {
|
||||
if (error.code === GatewayRpcMethodErrorCodes.TIMEOUT) {
|
||||
return new GatewayTimeoutError();
|
||||
}
|
||||
if (error.code === GatewayRpcMethodErrorCodes.GUILD_OVERLOADED) {
|
||||
return new ServiceUnavailableError({headers: {'Retry-After': '1'}});
|
||||
}
|
||||
if (error.code === GatewayRpcMethodErrorCodes.OVERLOADED) {
|
||||
return new ServiceUnavailableError();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTran
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
@@ -70,4 +71,30 @@ describe('GatewayService gateway error mapping', () => {
|
||||
expect((error as BadRequestError).status).toBe(400);
|
||||
expect((error as BadRequestError).code).toBe(APIErrorCodes.INVALID_FORM_BODY);
|
||||
});
|
||||
|
||||
it('returns 503 with Retry-After for an overloaded guild', async () => {
|
||||
const service = serviceRaising(GatewayRpcMethodErrorCodes.GUILD_OVERLOADED);
|
||||
const error = await service
|
||||
.getUserPermissions({guildId: createGuildID(1n), userId: createUserID(2n)})
|
||||
.catch((raised: unknown) => raised);
|
||||
expect(error).toBeInstanceOf(ServiceUnavailableError);
|
||||
const response = (error as ServiceUnavailableError).getResponse();
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get('Retry-After')).toBe('1');
|
||||
});
|
||||
|
||||
it('does not retry a guild overload response', async () => {
|
||||
let calls = 0;
|
||||
const client = GatewayRpcClient.createForTests({
|
||||
async call(): Promise<unknown> {
|
||||
calls += 1;
|
||||
throw new GatewayRpcMethodError(GatewayRpcMethodErrorCodes.GUILD_OVERLOADED);
|
||||
},
|
||||
async destroy(): Promise<void> {},
|
||||
});
|
||||
await expect(client.call('guild.dispatch', {guild_id: '1'})).rejects.toMatchObject({
|
||||
code: GatewayRpcMethodErrorCodes.GUILD_OVERLOADED,
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {StripePremiumService} from '@app/api/stripe/services/StripePremiumS
|
||||
import type {StripeSubscriptionReconciler} from '@app/api/stripe/services/StripeSubscriptionReconciler';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {PaymentRepository} from '@app/api/user/repositories/PaymentRepository';
|
||||
import {getPremiumPaymentRecoveryGraceMs, PREMIUM_GRACE_PERIOD_MS} from '@app/api/user/UserHelpers';
|
||||
import {getPremiumPaymentRecoveryGraceMs} from '@app/api/user/UserHelpers';
|
||||
import {mapUserToPrivateResponse} from '@app/api/user/UserMappers';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {StripeError} from '@fluxer/errors/src/domains/payment/StripeError';
|
||||
@@ -674,11 +674,11 @@ export class StripeSubscriptionWebhookHandler {
|
||||
updates.premium_grace_ends_at = new Date(
|
||||
lapseStart.getTime() + getPremiumPaymentRecoveryGraceMs(billingCycle),
|
||||
);
|
||||
} else if (cancelledBeforePeriodEnd) {
|
||||
updates.premium_until = subscriptionEndedAt;
|
||||
updates.premium_grace_ends_at = subscriptionEndedAt;
|
||||
} else {
|
||||
updates.premium_grace_ends_at = new Date(subscriptionEndedAt.getTime() + PREMIUM_GRACE_PERIOD_MS);
|
||||
if (cancelledBeforePeriodEnd) {
|
||||
updates.premium_until = subscriptionEndedAt;
|
||||
}
|
||||
updates.premium_grace_ends_at = subscriptionEndedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
|
||||
const {checkHasActivePaidPremium} = await import('@app/api/user/UserHelpers');
|
||||
expect(checkHasActivePaidPremium(afterUser!)).toBe(false);
|
||||
});
|
||||
test('grants standard grace when the subscription is cancelled at the end of its paid period', async () => {
|
||||
test('ends premium without grace when the subscription is cancelled at the end of its paid period', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const userId = createUserID(BigInt(account.userId));
|
||||
const subscriptionId = 'sub_test_cancel_natural';
|
||||
@@ -599,10 +599,9 @@ describe('Stripe Webhook Subscription Lifecycle', () => {
|
||||
expect(result.received).toBe(true);
|
||||
const afterUser = await userRepository.findUnique(userId);
|
||||
expect(afterUser?.premiumUntil?.getTime()).toBe(premiumUntil.getTime());
|
||||
expect(afterUser?.premiumGraceEndsAt).not.toBeNull();
|
||||
expect(afterUser!.premiumGraceEndsAt!.getTime()).toBe(endedAt * 1000 + PREMIUM_GRACE_PERIOD_MS);
|
||||
expect(afterUser?.premiumGraceEndsAt?.getTime()).toBe(endedAt * 1000);
|
||||
const {checkHasActivePaidPremium} = await import('@app/api/user/UserHelpers');
|
||||
expect(checkHasActivePaidPremium(afterUser!)).toBe(true);
|
||||
expect(checkHasActivePaidPremium(afterUser!)).toBe(false);
|
||||
});
|
||||
test('keeps the payment recovery deadline when Stripe cancels for non-payment', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {TrackBitrateInfo} from './PCTransport.ts';
|
||||
import {
|
||||
applyVideoStartBitrate,
|
||||
collectStereoMids,
|
||||
conformBundledCodecFmtp,
|
||||
ensureAudioNackAndStereo,
|
||||
ensureOpusFmtp,
|
||||
ensureVideoDDExtension,
|
||||
@@ -282,15 +283,63 @@ describe('placeholderMidsFromTransceivers', () => {
|
||||
return {mid, currentDirection, sender: {track}} as unknown as RTCRtpTransceiver;
|
||||
}
|
||||
|
||||
it('keeps the trackless recvonly sections that still hold an m-line', () => {
|
||||
it('keeps unused trackless sections that still hold an m-line', () => {
|
||||
const mids = placeholderMidsFromTransceivers([
|
||||
transceiver('3', null, 'recvonly'),
|
||||
transceiver('3', null, 'inactive'),
|
||||
transceiver('4', null, null),
|
||||
transceiver('7', {} as MediaStreamTrack, 'sendonly'),
|
||||
]);
|
||||
expect(mids).toEqual(new Set(['3']));
|
||||
expect(mids).toEqual(new Set(['3', '4']));
|
||||
});
|
||||
|
||||
it('drops a transceiver that unpublish stopped so its recycled m-section is not fmtp-conformed', () => {
|
||||
expect(placeholderMidsFromTransceivers([transceiver('7', null, 'stopped')])).toEqual(new Set());
|
||||
});
|
||||
|
||||
it.each(['recvonly', 'sendrecv'] as const)(
|
||||
'preserves a negotiated %s receiver without a local sender',
|
||||
(direction) => {
|
||||
expect(placeholderMidsFromTransceivers([transceiver('0', null, direction)])).toEqual(new Set());
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['sendonly', 'sendrecv', undefined] as const)(
|
||||
'preserves a receiver activated by a remote %s answer before currentDirection updates',
|
||||
(direction) => {
|
||||
const incoming = opusMedia('useinbandfec=1');
|
||||
incoming.direction = direction;
|
||||
expect(placeholderMidsFromTransceivers([transceiver('0', null, null)], [incoming])).toEqual(new Set());
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps inactive and rejected remote sections eligible for placeholder conformance', () => {
|
||||
const inactive = opusMedia('useinbandfec=1', '0');
|
||||
inactive.direction = 'inactive';
|
||||
const rejected = opusMedia('useinbandfec=1', '1');
|
||||
rejected.direction = 'sendonly';
|
||||
rejected.port = 0;
|
||||
expect(
|
||||
placeholderMidsFromTransceivers(
|
||||
[transceiver('0', null, 'inactive'), transceiver('1', null, null)],
|
||||
[inactive, rejected],
|
||||
),
|
||||
).toEqual(new Set(['0', '1']));
|
||||
});
|
||||
|
||||
it('starting a stereo share preserves incoming microphone fmtp while conforming unused placeholders', () => {
|
||||
const microphone = opusMedia('useinbandfec=1;maxaveragebitrate=64000', '0');
|
||||
const placeholder = opusMedia('useinbandfec=1;usedtx=1', '1');
|
||||
const screenShare = opusMedia('useinbandfec=1', '2');
|
||||
ensureOpusFmtp(screenShare, 128000, true);
|
||||
const microphoneConfig = opusConfig(microphone);
|
||||
const mids = placeholderMidsFromTransceivers([
|
||||
transceiver('0', null, 'recvonly'),
|
||||
transceiver('1', null, 'inactive'),
|
||||
transceiver('2', {} as MediaStreamTrack, 'sendonly'),
|
||||
]);
|
||||
conformBundledCodecFmtp([microphone, placeholder, screenShare], (media) => mids.has(String(media.mid)));
|
||||
expect(opusConfig(microphone)).toBe(microphoneConfig);
|
||||
expect(opusConfig(screenShare)).toContain('stereo=1');
|
||||
expect(opusConfig(placeholder)).toBe(opusConfig(screenShare));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,7 +257,7 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
}
|
||||
}
|
||||
});
|
||||
const placeholderMids = this.getPlaceholderMids();
|
||||
const placeholderMids = this.getPlaceholderMids(sdpParsed.media);
|
||||
if (placeholderMids.size > 0) {
|
||||
conformBundledCodecFmtp(sdpParsed.media, (media) => placeholderMids.has(getMidString(media.mid!)));
|
||||
}
|
||||
@@ -461,8 +461,11 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
|
||||
}
|
||||
}
|
||||
|
||||
private getPlaceholderMids(): Set<string> {
|
||||
return placeholderMidsFromTransceivers(this._pc?.getTransceivers() ?? []);
|
||||
private getPlaceholderMids(remoteMedia?: ReadonlyArray<MediaDescription>): Set<string> {
|
||||
return placeholderMidsFromTransceivers(
|
||||
this._pc?.getTransceivers() ?? [],
|
||||
remoteMedia ?? parse(this._pc?.remoteDescription?.sdp ?? '').media,
|
||||
);
|
||||
}
|
||||
|
||||
createDataChannel(label: string, dataChannelDict: RTCDataChannelInit) {
|
||||
@@ -853,10 +856,28 @@ export function collectStereoMids(
|
||||
return stereoMids;
|
||||
}
|
||||
|
||||
export function placeholderMidsFromTransceivers(transceivers: ReadonlyArray<RTCRtpTransceiver>): Set<string> {
|
||||
export function placeholderMidsFromTransceivers(
|
||||
transceivers: ReadonlyArray<RTCRtpTransceiver>,
|
||||
remoteMedia: ReadonlyArray<MediaDescription> = [],
|
||||
): Set<string> {
|
||||
const receivingMids = new Set(
|
||||
remoteMedia
|
||||
.filter(
|
||||
(media) =>
|
||||
media.mid !== undefined &&
|
||||
media.port !== 0 &&
|
||||
(media.direction === 'sendonly' || media.direction === 'sendrecv' || media.direction === undefined),
|
||||
)
|
||||
.map((media) => getMidString(media.mid!)),
|
||||
);
|
||||
const mids = new Set<string>();
|
||||
for (const transceiver of transceivers) {
|
||||
if (transceiver.currentDirection === 'stopped') {
|
||||
if (
|
||||
transceiver.currentDirection === 'stopped' ||
|
||||
transceiver.currentDirection === 'recvonly' ||
|
||||
transceiver.currentDirection === 'sendrecv' ||
|
||||
(transceiver.mid !== null && receivingMids.has(transceiver.mid))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (transceiver.mid && !transceiver.sender.track) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import {type AddTrackRequest, AudioTrackFeature, ParticipantPermission, TrackInfo} from '@livekit/protocol';
|
||||
import {EventEmitter} from 'events';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {SignalConnectionState} from '../../api/SignalClient.ts';
|
||||
import {publishDefaults, roomOptionDefaults} from '../defaults.ts';
|
||||
import {TrackEvent} from '../events.ts';
|
||||
import type RTCEngine from '../RTCEngine.ts';
|
||||
import LocalAudioTrack from '../track/LocalAudioTrack.ts';
|
||||
import {Track} from '../track/Track.ts';
|
||||
import LocalParticipant from './LocalParticipant.ts';
|
||||
|
||||
class TestMediaStreamTrack extends EventTarget {
|
||||
readonly id = 'microphone-input';
|
||||
readonly kind = 'audio';
|
||||
enabled = true;
|
||||
readyState: MediaStreamTrackState = 'live';
|
||||
|
||||
constructor(private readonly channelCount: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
getSettings(): MediaTrackSettings {
|
||||
return {channelCount: this.channelCount, echoCancellation: true};
|
||||
}
|
||||
|
||||
getConstraints(): MediaTrackConstraints {
|
||||
return {};
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.readyState = 'ended';
|
||||
}
|
||||
}
|
||||
|
||||
class TestMediaStream {
|
||||
constructor(private readonly tracks: Array<MediaStreamTrack>) {}
|
||||
|
||||
getTracks() {
|
||||
return this.tracks;
|
||||
}
|
||||
}
|
||||
|
||||
function createPublisher() {
|
||||
const setTrackCodecBitrate = vi.fn();
|
||||
const sendUpdateLocalAudioTrack = vi.fn();
|
||||
const sender = {replaceTrack: vi.fn(async () => undefined)} as unknown as RTCRtpSender;
|
||||
const transceiver = {sender} as RTCRtpTransceiver;
|
||||
const addTrack = vi.fn(
|
||||
async (request: AddTrackRequest) =>
|
||||
new TrackInfo({
|
||||
sid: 'TR_microphone',
|
||||
name: request.name,
|
||||
type: request.type,
|
||||
source: request.source,
|
||||
audioFeatures: request.audioFeatures,
|
||||
}),
|
||||
);
|
||||
const engine = Object.assign(new EventEmitter(), {
|
||||
isClosed: false,
|
||||
logContext: {},
|
||||
client: {currentState: SignalConnectionState.CONNECTED, sendUpdateLocalAudioTrack},
|
||||
pcManager: {publisher: {getTransceivers: () => [transceiver], setTrackCodecBitrate}},
|
||||
createSender: vi.fn(async () => sender),
|
||||
negotiate: vi.fn(async () => undefined),
|
||||
addTrack,
|
||||
});
|
||||
type ParticipantArgs = ConstructorParameters<typeof LocalParticipant>;
|
||||
const participant = new LocalParticipant(
|
||||
'PA_publisher',
|
||||
'publisher',
|
||||
engine as unknown as RTCEngine,
|
||||
{...roomOptionDefaults, publishDefaults},
|
||||
{} as ParticipantArgs[4],
|
||||
{} as ParticipantArgs[5],
|
||||
{} as ParticipantArgs[6],
|
||||
{} as ParticipantArgs[7],
|
||||
);
|
||||
participant.permissions = new ParticipantPermission({canPublish: true});
|
||||
return {participant, addTrack, setTrackCodecBitrate, sendUpdateLocalAudioTrack};
|
||||
}
|
||||
|
||||
describe('microphone publication stereo metadata', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('MediaStreamTrack', TestMediaStreamTrack);
|
||||
vi.stubGlobal('MediaStream', TestMediaStream);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{name: 'mono policy on a two-channel device', channelCount: 2, forceStereo: false, stereo: false},
|
||||
{name: 'automatic stereo on a two-channel device', channelCount: 2, forceStereo: undefined, stereo: true},
|
||||
{name: 'forced stereo on a one-channel device', channelCount: 1, forceStereo: true, stereo: true},
|
||||
{name: 'automatic mono on a one-channel device', channelCount: 1, forceStereo: undefined, stereo: false},
|
||||
])(
|
||||
'keeps the request, SDP policy, and feature updates consistent for $name',
|
||||
async ({channelCount, forceStereo, stereo}) => {
|
||||
const {participant, addTrack, setTrackCodecBitrate, sendUpdateLocalAudioTrack} = createPublisher();
|
||||
const source = new TestMediaStreamTrack(channelCount);
|
||||
const track = new LocalAudioTrack(source as unknown as MediaStreamTrack);
|
||||
track.source = Track.Source.Microphone;
|
||||
await track.runWithTrackChangeLock(async () => undefined);
|
||||
const publication = await participant.publishTrack(track, {
|
||||
audioPreset: {maxBitrate: 64000},
|
||||
forceStereo,
|
||||
});
|
||||
|
||||
expect(addTrack).toHaveBeenCalledTimes(1);
|
||||
const request = addTrack.mock.calls[0]![0];
|
||||
expect(request.stereo).toBe(stereo);
|
||||
expect(request.audioFeatures.includes(AudioTrackFeature.TF_STEREO)).toBe(stereo);
|
||||
expect(request.audioFeatures).toContain(AudioTrackFeature.TF_ECHO_CANCELLATION);
|
||||
expect(setTrackCodecBitrate).toHaveBeenCalledWith(expect.objectContaining({codec: 'opus', maxbr: 64, stereo}));
|
||||
|
||||
expect(publication.getTrackFeatures().includes(AudioTrackFeature.TF_STEREO)).toBe(stereo);
|
||||
track.emit(TrackEvent.AudioTrackFeatureUpdate, track, AudioTrackFeature.TF_ECHO_CANCELLATION, true);
|
||||
expect(sendUpdateLocalAudioTrack).toHaveBeenCalledTimes(1);
|
||||
const [sid, features] = sendUpdateLocalAudioTrack.mock.calls[0]!;
|
||||
expect(sid).toBe('TR_microphone');
|
||||
expect(features.includes(AudioTrackFeature.TF_STEREO)).toBe(stereo);
|
||||
expect(features).toContain(AudioTrackFeature.TF_ECHO_CANCELLATION);
|
||||
track.stop();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -966,7 +966,7 @@ export default class LocalParticipant extends Participant {
|
||||
if (settings.noiseSuppression) {
|
||||
audioFeatures.push(AudioTrackFeature.TF_NOISE_SUPPRESSION);
|
||||
}
|
||||
if (settings.channelCount && settings.channelCount > 1) {
|
||||
if (isStereo) {
|
||||
audioFeatures.push(AudioTrackFeature.TF_STEREO);
|
||||
}
|
||||
if (disableDtx) {
|
||||
|
||||
@@ -90,7 +90,7 @@ export default class LocalTrackPublication extends TrackPublication {
|
||||
if (settings.noiseSuppression) {
|
||||
features.add(AudioTrackFeature.TF_NOISE_SUPPRESSION);
|
||||
}
|
||||
if (settings.channelCount && settings.channelCount > 1) {
|
||||
if (this.options?.forceStereo ?? (settings.channelCount !== undefined && settings.channelCount > 1)) {
|
||||
features.add(AudioTrackFeature.TF_STEREO);
|
||||
}
|
||||
if (!this.options?.dtx) {
|
||||
|
||||
@@ -135,8 +135,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
|
||||
title={<Trans>Cancel subscription?</Trans>}
|
||||
description={
|
||||
<Trans>
|
||||
You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep
|
||||
your subscriber history.
|
||||
You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history.
|
||||
</Trans>
|
||||
}
|
||||
primaryText={<Trans>Cancel subscription</Trans>}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {handleGuildCountsUpdate} from '@app/features/guild/events/GuildCountsUpd
|
||||
import {handleGuildCreate} from '@app/features/guild/events/GuildCreate';
|
||||
import {handleGuildDelete} from '@app/features/guild/events/GuildDelete';
|
||||
import {handleGuildEmojisUpdate} from '@app/features/guild/events/GuildEmojisUpdate';
|
||||
import {handleGuildHealthUpdate} from '@app/features/guild/events/GuildHealthUpdate';
|
||||
import {handleGuildMemberAdd} from '@app/features/guild/events/GuildMemberAdd';
|
||||
import {handleGuildMemberListUpdate} from '@app/features/guild/events/GuildMemberListUpdate';
|
||||
import {handleGuildMemberRemove} from '@app/features/guild/events/GuildMemberRemove';
|
||||
@@ -112,6 +113,7 @@ export function createHandlerRegistry(): GatewayHandlerRegistry {
|
||||
registry.set('GUILD_MEMBERS_CHUNK', handleGuildMembersChunk as GatewayEventHandler);
|
||||
registry.set('GUILD_MEMBER_LIST_UPDATE', handleGuildMemberListUpdate as GatewayEventHandler);
|
||||
registry.set('GUILD_COUNTS_UPDATE', handleGuildCountsUpdate as GatewayEventHandler);
|
||||
registry.set('GUILD_HEALTH_UPDATE', handleGuildHealthUpdate as GatewayEventHandler);
|
||||
registry.set('CHANNEL_MEMBER_COUNTS_UPDATE', handleChannelMemberCountsUpdate as GatewayEventHandler);
|
||||
registry.set('GUILD_ROLE_CREATE', handleGuildRoleCreate as GatewayEventHandler);
|
||||
registry.set('GUILD_ROLE_UPDATE', handleGuildRoleUpdate as GatewayEventHandler);
|
||||
|
||||
@@ -23,4 +23,5 @@ export type GuildReadyData = Readonly<{
|
||||
joined_at: string;
|
||||
unavailable?: boolean;
|
||||
unavailable_hidden?: boolean;
|
||||
degraded?: boolean;
|
||||
}>;
|
||||
|
||||
@@ -48,6 +48,9 @@ export function handleGuildCreate(data: GuildReadyData, _context: GatewayHandler
|
||||
return;
|
||||
}
|
||||
GuildAvailability.setGuildAvailable(data.id);
|
||||
if (!isSync || data.degraded !== undefined) {
|
||||
GuildAvailability.setGuildDegraded(data.id, data.degraded === true);
|
||||
}
|
||||
Guilds.handleGuildCreate(data);
|
||||
GuildCount.handleGuildCreate(data);
|
||||
if (!isSync) {
|
||||
|
||||
@@ -32,6 +32,7 @@ interface GuildDeletePayload {
|
||||
}
|
||||
|
||||
export function handleGuildDelete(data: GuildDeletePayload, _context: GatewayHandlerContext): void {
|
||||
GuildAvailability.setGuildDegraded(data.id, false);
|
||||
GuildAvailability.handleGuildAvailability(data.id, data.unavailable, data.unavailable_hidden);
|
||||
Guilds.handleGuildDelete({guildId: data.id, unavailable: data.unavailable});
|
||||
GuildList.handleGuildDelete(data.id, data.unavailable);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import GuildAvailability from '@app/features/guild/state/GuildAvailability';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
|
||||
export function handleGuildHealthUpdate(data: {guild_id: string; degraded: boolean}): void {
|
||||
if (Guilds.getGuild(data.guild_id)) {
|
||||
GuildAvailability.setGuildDegraded(data.guild_id, data.degraded);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import {makeAutoObservable, observable} from 'mobx';
|
||||
|
||||
class GuildAvailability {
|
||||
unavailableGuilds: Set<string> = observable.set();
|
||||
degradedGuilds: Set<string> = observable.set();
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
unavailableGuilds: false,
|
||||
degradedGuilds: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
);
|
||||
@@ -23,11 +25,20 @@ class GuildAvailability {
|
||||
}
|
||||
|
||||
setGuildUnavailable(guildId: string): void {
|
||||
this.degradedGuilds.delete(guildId);
|
||||
if (!this.unavailableGuilds.has(guildId)) {
|
||||
this.unavailableGuilds.add(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
setGuildDegraded(guildId: string, degraded: boolean): void {
|
||||
if (degraded) {
|
||||
this.degradedGuilds.add(guildId);
|
||||
} else {
|
||||
this.degradedGuilds.delete(guildId);
|
||||
}
|
||||
}
|
||||
|
||||
handleGuildAvailability(guildId: string, unavailable = false, unavailableHidden = false): void {
|
||||
if (unavailable && !unavailableHidden) {
|
||||
this.setGuildUnavailable(guildId);
|
||||
@@ -38,7 +49,11 @@ class GuildAvailability {
|
||||
|
||||
loadUnavailableGuilds(guilds: ReadonlyArray<GuildReadyData>): void {
|
||||
this.unavailableGuilds.clear();
|
||||
this.degradedGuilds.clear();
|
||||
for (const guild of guilds) {
|
||||
if (guild.degraded && !guild.unavailable) {
|
||||
this.degradedGuilds.add(guild.id);
|
||||
}
|
||||
if (guild.unavailable && !guild.unavailable_hidden) {
|
||||
this.unavailableGuilds.add(guild.id);
|
||||
}
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "إلغاء التبديل المجدول"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "إلغاء الاشتراك"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "تاريخ الانتهاء"
|
||||
msgid "End grace now"
|
||||
msgstr "إنهاء فترة السماح الآن"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "إنهاء فترة السماح"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "إنهاء فترة السماح الآن؟"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "خطاب الكراهية"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "هل لديك رمز هدية؟ استبدله لفتح {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "الاحتفاظ بالمرفقات عند إفراغ نص الرسال
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "حافظ على المستويات المجانية والمدفوعة. يمكنك تخصيص المستويات، أو البيع عبر Stripe، أو توزيع رموز هدايا لاحقًا من لوحة التحكم."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "الاحتفاظ بفترة السماح"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "إبقاء Neko ثابتًا"
|
||||
msgid "Keep running"
|
||||
msgstr "مواصلة التشغيل"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "الاحتفاظ بالاشتراك"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "استرداد لنفسك"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "استرداد رمز الهدية"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "افتح الرموز التعبيرية المخصصة في الرسا
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "افتح مشاركة الشاشة عالية الدقة باستخدام {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "احصل على حدود أعلى وميزات حصرية مع دعم منصة تواصل مستقلة."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "لقد ضغطت على Tab للتو. وضع لوحة المفاتيح قيد التشغيل الآن حتى تتمكن من التنقل في {productName} دون استخدام الماوس."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "ستحتفظ بمزاياك حتى تاريخ التجديد التالي، ثم تحصل على فترة سماح مدتها 3 أيام لإعادة الاشتراك والاحتفاظ بسجل اشتراكك."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "ستحتفظ بمزاياك حتى تاريخ التجديد التالي. أعد تفعيل اشتراكك قبل ذلك للاحتفاظ بسجل اشتراكك."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "ستفقد جميع مزايا {PREMIUM_PRODUCT_NAME} على الفور وستتم إعادة تعيين سجل اشتراكك. لا يمكن التراجع عن هذا الإجراء."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Отказ на планираната промяна"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Отказ от абонамент"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Крайна дата"
|
||||
msgid "End grace now"
|
||||
msgstr "Прекратяване сега"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Край на гратисния период"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Да се прекрати ли гратисният период сега?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Език на омразата"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Имате код за подарък? Осребрете го, за да отключите {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Запазване на прикачени файлове при изч
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Запазете безплатни и премиум нива. Можете да персонализирате нивата, да продавате премиум чрез Stripe или по-късно да раздавате кодове за подаръци от административния панел."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Запазване на гратисния период"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Спиране на движението на Неко"
|
||||
msgid "Keep running"
|
||||
msgstr "Работа във фонов режим"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Запазване на абонамента"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Осребри за себе си"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Осребряване на код за подарък"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Отключи персонализирани емоджита в DM с
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Отключи споделяне на екран в HD с {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Отключи по-високи лимити и ексклузивни функции, докато подкрепяш независима комуникационна платформа."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Току-що натисна Tab. Режимът за навигация с клавиатура вече е включен, за да можеш да използваш {productName} без мишка."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Запазваш предимствата си до следващата дата на подновяване, след което имаш 3-дневен гратисен период, за да се абонираш отново и да запазиш историята си като абонат."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Запазваш предимствата си до следващата дата на подновяване. Активирай абонамента отново преди това, за да запазиш историята си като абонат."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Веднага губиш всички предимства на {PREMIUM_PRODUCT_NAME} и историята ти като абонат се нулира. Това не може да бъде отменено."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Zrušit plánovanou změnu"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Zrušit předplatné"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Datum konce"
|
||||
msgid "End grace now"
|
||||
msgstr "Ukončit dodatečnou lhůtu hned"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Ukončit dodatečnou lhůtu"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Ukončit dodatečnou lhůtu hned?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Nenávistné projevy"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Máte dárkový kód? Uplatněte ho a odemkněte {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Ponechat přílohy při vymazání textu během úprav"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Ponechte bezplatnou i prémiovou úroveň. Úrovně můžete přizpůsobit, prodávat prémiové členství přes Stripe nebo později rozdávat dárkové kódy z administračního panelu."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Ponechat dodatečnou lhůtu"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Ponechat Neko na místě"
|
||||
msgid "Keep running"
|
||||
msgstr "Pokračovat v běhu"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Ponechat předplatné"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Uplatnit pro sebe"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Uplatnit dárkový kód"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Odemkněte vlastní emoji v soukromých konverzacích s {premiumProductN
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Odemkněte sdílení obrazovky v HD rozlišení s {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Odemkněte vyšší limity a exkluzivní funkce a zároveň podpořte nezávislou komunikační platformu."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Právě jste stiskli klávesu Tab. Režim klávesnice je nyní zapnutý, takže můžete procházet aplikaci {productName} bez myši."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Výhody si ponecháte do data příštího obnovení. Poté máte dodatečnou lhůtu 3 dnů na obnovení předplatného a zachování jeho historie."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Výhody si ponecháte do data příštího obnovení. Pokud předplatné do té doby znovu aktivujete, zachováte si jeho historii."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Okamžitě ztratíte všechny výhody {PREMIUM_PRODUCT_NAME} a historie vašeho předplatného se resetuje. Tuto akci nelze vrátit zpět."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Annuller planlagt skift"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Opsig abonnement"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Slutdato"
|
||||
msgid "End grace now"
|
||||
msgstr "Afslut overgangsperiode nu"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Afslut overgangsperiode"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Afslut overgangsperiode nu?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Hadefuld tale"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Har du en gavekode? Indløs den for at låse op for {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Behold vedhæftede filer, når du rydder en redigering"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Behold gratis og premium-niveauer. Du kan tilpasse niveauerne, sælge premium via Stripe eller uddele gavekoder senere fra adminpanelet."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Behold overgangsperioden"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Hold Neko stille"
|
||||
msgid "Keep running"
|
||||
msgstr "Fortsæt med at køre"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Behold abonnement"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Indløs til dig selv"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Indløs gavekode"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Lås op for tilpassede emojis i direkte beskeder med {premiumProductName
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Lås op for HD-skærmdeling med {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Lås op for højere grænser og eksklusive funktioner, og støt samtidig en uafhængig kommunikationsplatform."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Du har lige trykket på Tab. Tastaturtilstand er nu slået til, så du kan navigere i {productName} uden en mus."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Du beholder dine fordele indtil din næste fornyelsesdato og har derefter en overgangsperiode på 3 dage til at tegne abonnement igen og bevare din abonnementshistorik."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Du beholder dine fordele indtil din næste fornyelsesdato. Genaktiver inden da for at bevare din abonnementshistorik."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Du mister alle dine {PREMIUM_PRODUCT_NAME}-fordele med det samme, og din abonnementshistorik nulstilles. Dette kan ikke fortrydes."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Geplante Umstellung abbrechen"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Abo kündigen"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Enddatum"
|
||||
msgid "End grace now"
|
||||
msgstr "Übergangsfrist jetzt beenden"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Übergangsfrist beenden"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Übergangsfrist jetzt beenden?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Hassrede"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Hast du einen Geschenke-Code? Löse ihn ein, um {PREMIUM_PRODUCT_NAME} freizuschalten."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Anhänge beim Löschen einer Bearbeitung beibehalten"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Behalte kostenlose und Premium-Stufen bei. Du kannst die Stufen anpassen, Premium über Stripe verkaufen oder später Geschenke-Codes im Admin-Panel verteilen."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Übergangsfrist beibehalten"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Neko festhalten"
|
||||
msgid "Keep running"
|
||||
msgstr "Weiter ausführen"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Abo behalten"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Für dich einlösen"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Geschenkcode einlösen"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Schalte benutzerdefinierte Emojis in DMs mit {premiumProductName} frei"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "HD-Bildschirmfreigabe mit {premiumProductName} freischalten"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Schalte höhere Limits und exklusive Funktionen frei und unterstütze gleichzeitig eine unabhängige Kommunikationsplattform."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Du hast gerade die Tab-Taste gedrückt. Der Tastaturmodus ist jetzt aktiviert, damit du {productName} ohne Maus navigieren kannst."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Du behältst deine Vorteile bis zum nächsten Verlängerungsdatum. Danach hast du eine Übergangsfrist von 3 Tagen, um erneut zu abonnieren und deinen Abo-Verlauf zu behalten."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Du behältst deine Vorteile bis zum nächsten Verlängerungsdatum. Reaktiviere dein Abo vorher, um deinen Abo-Verlauf zu behalten."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Du verlierst sofort alle {PREMIUM_PRODUCT_NAME}-Vorteile und dein Abonnentenverlauf wird zurückgesetzt. Dies kann nicht rückgängig gemacht werden."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Ακύρωση προγραμματισμένης αλλαγής"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Ακύρωση συνδρομής"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Ημερομηνία λήξης"
|
||||
msgid "End grace now"
|
||||
msgstr "Τερματισμός περιόδου χάριτος τώρα"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Τερματισμός περιόδου χάριτος"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Να τερματιστεί τώρα η περίοδος χάριτος;"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Ρητορική μίσους"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Έχετε κωδικό δώρου; Εξαργυρώστε τον για να ξεκλειδώσετε το {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Διατήρηση συνημμένων κατά την εκκαθάρι
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Διατήρησε δωρεάν και premium βαθμίδες. Μπορείς να προσαρμόσεις τις βαθμίδες, να πουλήσεις premium μέσω Stripe ή να διανείμεις κωδικούς δώρου αργότερα από τον πίνακα διαχειριστή."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Διατήρηση περιόδου χάριτος"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Κράτα το Neko ακίνητο"
|
||||
msgid "Keep running"
|
||||
msgstr "Παραμονή σε λειτουργία"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Διατήρηση συνδρομής"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Εξαργύρωση για εσάς"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Εξαργύρωση κωδικού δώρου"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Ξεκλειδώστε προσαρμοσμένα emoji σε DM με τ
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Ξεκλειδώστε την κοινή χρήση οθόνης HD με το {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Ξεκλειδώστε υψηλότερα όρια και αποκλειστικές λειτουργίες, υποστηρίζοντας ταυτόχρονα μια ανεξάρτητη πλατφόρμα επικοινωνίας."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Μόλις πατήσατε Tab. Η λειτουργία πληκτρολογίου είναι πλέον ενεργή, ώστε να μπορείτε να περιηγηθείτε στο {productName} χωρίς ποντίκι."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Διατηρείτε τα προνόμιά σας μέχρι την επόμενη ημερομηνία ανανέωσης και, στη συνέχεια, έχετε μια περίοδο χάριτος 3 ημερών για να εγγραφείτε ξανά και να διατηρήσετε το ιστορικό συνδρομών σας."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Διατηρείτε τα προνόμιά σας μέχρι την επόμενη ημερομηνία ανανέωσης. Ενεργοποιήστε ξανά τη συνδρομή σας πριν από τότε για να διατηρήσετε το ιστορικό συνδρομών σας."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Χάνετε όλα τα προνόμια {PREMIUM_PRODUCT_NAME} αμέσως και το ιστορικό συνδρομών σας μηδενίζεται. Αυτό δεν μπορεί να αναιρεθεί."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Cancel scheduled switch"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Cancel subscription"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "End date"
|
||||
msgid "End grace now"
|
||||
msgstr "End grace now"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "End grace period"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "End grace period now?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Hate speech"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Keep attachments when clearing an edit"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Keep free and premium tiers. You can customise the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Keep grace period"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Keep Neko still"
|
||||
msgid "Keep running"
|
||||
msgstr "Keep running"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Keep subscription"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Redeem for yourself"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Redeem gift code"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Unlock custom emojis in DMs with {premiumProductName}"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Unlock HD screen share with {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "You just pressed Tab. Keyboard mode is now on so you can navigate {productName} without a mouse."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Cancel scheduled switch"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Cancel subscription"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "End date"
|
||||
msgid "End grace now"
|
||||
msgstr "End grace now"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "End grace period"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "End grace period now?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Hate speech"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Keep attachments when clearing an edit"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Keep grace period"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Keep Neko still"
|
||||
msgid "Keep running"
|
||||
msgstr "Keep running"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Keep subscription"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Redeem for yourself"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Redeem gift code"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Unlock custom emojis in DMs with {premiumProductName}"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Unlock HD screen share with {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "You just pressed Tab. Keyboard mode is now on so you can navigate {productName} without a mouse."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Cancelar cambio programado"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Cancelar suscripción"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Fecha de finalización"
|
||||
msgid "End grace now"
|
||||
msgstr "Terminar período de gracia ahora"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Finalizar período de gracia"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "¿Terminar período de gracia ahora?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Discurso de odio"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "¿Tienes un código de regalo? Canjéalo para desbloquear {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Conservar archivos adjuntos al borrar el texto de una edición"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Mantén los niveles gratuito y premium. Puedes personalizar los niveles, vender premium a través de Stripe o entregar códigos de regalo más tarde desde el panel de administración."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Mantener período de gracia"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Mantener a Neko quieto"
|
||||
msgid "Keep running"
|
||||
msgstr "Seguir ejecutándose"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Mantener suscripción"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Canjear para ti"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Canjear código de regalo"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Desbloquea los emojis personalizados en los MD con {premiumProductName}"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Desbloquea la pantalla compartida en HD con {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Desbloquea límites más altos y funciones exclusivas mientras apoyas una plataforma de comunicación independiente."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Acabas de presionar Tab. El modo de teclado está activado para que puedas navegar por {productName} sin mouse."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Conservarás tus beneficios hasta la próxima fecha de renovación y luego tendrás un período de gracia de 3 días para volver a suscribirte y mantener tu historial de suscriptor."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Conservarás tus beneficios hasta la próxima fecha de renovación. Reactiva tu suscripción antes de esa fecha para mantener tu historial de suscriptor."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Perderás todos los beneficios de {PREMIUM_PRODUCT_NAME} inmediatamente y tu historial de suscripción se restablecerá. Esto no se puede deshacer."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Cancelar cambio programado"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Cancelar suscripción"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Fecha de finalización"
|
||||
msgid "End grace now"
|
||||
msgstr "Finalizar período de gracia ahora"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Finalizar período de gracia"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "¿Finalizar el período de gracia ahora?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Discurso de odio"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "¿Tienes un código de regalo? Canjéalo para desbloquear {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Conservar archivos adjuntos al borrar el texto de una edición"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Mantén los niveles gratuito y premium. Puedes personalizar los niveles, vender premium a través de Stripe o entregar códigos de regalo más tarde desde el panel de administración."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Mantener período de gracia"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Mantener a Neko quieto"
|
||||
msgid "Keep running"
|
||||
msgstr "Seguir ejecutando"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Mantener suscripción"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Canjear para mí"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Canjear código de regalo"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Desbloquea los emojis personalizados en los MD con {premiumProductName}"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Desbloquea la pantalla compartida en HD con {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Desbloquea límites más altos y funciones exclusivas mientras apoyas una plataforma de comunicación independiente."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Acabas de pulsar Tab. El modo de teclado está activado para que puedas navegar por {productName} sin ratón."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Conservarás tus ventajas hasta la próxima fecha de renovación y, después, tendrás un periodo de gracia de 3 días para volver a suscribirte y mantener tu historial de suscriptor."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Conservarás tus ventajas hasta la próxima fecha de renovación. Reactiva tu suscripción antes de esa fecha para mantener tu historial de suscriptor."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Perderás todas las ventajas de {PREMIUM_PRODUCT_NAME} inmediatamente y tu historial de suscripción se restablecerá. Esto no se puede deshacer."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Peruuta ajoitettu vaihto"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Peruuta tilaus"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Päättymispäivä"
|
||||
msgid "End grace now"
|
||||
msgstr "Lopeta armonaika nyt"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Lopeta armonaika"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Päätetäänkö armonaika nyt?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Vihapuhe"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Onko sinulla lahjakoodi? Lunasta se avataksesi {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Säilytä liitteet muokkauksen tyhjentämisen yhteydessä"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Pidä ilmaiset ja premium-tasot. Voit muokata tasoja, myydä premiumia Stripellä tai jakaa lahjakoodeja myöhemmin ylläpitopaneelista."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Säilytä armonaika"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Pidä Neko paikallaan"
|
||||
msgid "Keep running"
|
||||
msgstr "Pidä käynnissä"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Säilytä tilaus"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Lunasta itsellesi"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Lunasta lahjakoodi"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Avaa mukautetut emojit yksityisviesteissä {premiumProductName}-tilaukse
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Avaa HD-näytönjako käyttöön {premiumProductName}-tilauksella"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Saat käyttöösi suuremmat rajat ja vain tilaajille tarkoitetut ominaisuudet sekä tuet samalla riippumatonta viestintäalustaa."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Painoit juuri sarkainta. Näppäimistötila on nyt käytössä, joten voit liikkua {productName}-sovelluksessa ilman hiirtä."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Pidät etusi seuraavaan uusimispäivään asti, minkä jälkeen sinulla on kolmen päivän lisäaika tilata uudelleen ja säilyttää tilaushistoriasi."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Pidät etusi seuraavaan uusimispäivään asti. Aktivoi tilaus uudelleen ennen sitä, niin säilytät tilaushistoriasi."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Menetät kaikki {PREMIUM_PRODUCT_NAME}-edut välittömästi ja tilaushistoriasi nollataan. Tätä ei voi kumota."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Annuler le changement programmé"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Résilier l’abonnement"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Date de fin"
|
||||
msgid "End grace now"
|
||||
msgstr "Mettre fin à la période de grâce"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Mettre fin à la période de grâce"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Mettre fin à la période de grâce maintenant ?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Discours haineux"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Vous avez un code cadeau ? Échangez-le pour débloquer {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Conserver les pièces jointes lors de la suppression d'une modification"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Conservez les niveaux gratuit et premium. Vous pourrez personnaliser les niveaux, vendre des abonnements premium via Stripe, ou distribuer des codes cadeaux plus tard depuis le panneau d'administration."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Conserver la période de grâce"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Garder Neko immobile"
|
||||
msgid "Keep running"
|
||||
msgstr "Continuer à fonctionner"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Garder l'abonnement"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Utiliser pour soi"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Utiliser un code cadeau"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Débloquez les emojis personnalisés dans les MP avec {premiumProductNam
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Débloquez le partage d'écran HD avec {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Débloquez des limites plus élevées et des fonctionnalités exclusives tout en soutenant une plateforme de communication indépendante."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Vous venez d'appuyer sur Tab. Le mode clavier est maintenant activé pour que vous puissiez naviguer dans {productName} sans souris."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Vous conservez vos avantages jusqu’à la date de votre prochain renouvellement, puis vous disposez d’une période de grâce de 3 jours pour vous réabonner et conserver votre historique d’abonné."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Vous conservez vos avantages jusqu’à la date de votre prochain renouvellement. Réactivez votre abonnement avant cette date pour conserver votre historique d’abonné."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Vous perdez immédiatement tous les avantages {PREMIUM_PRODUCT_NAME} et votre historique d’abonné est réinitialisé. Cette action est irréversible."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "ביטול המעבר המתוזמן"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "ביטול מינוי"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "תאריך סיום"
|
||||
msgid "End grace now"
|
||||
msgstr "סיים את תקופת החסד עכשיו"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "סיום תקופת החסד"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "לסיים את תקופת החסד עכשיו?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "דברי שטנה"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "יש לך קוד מתנה? הפק אותו כדי לפתוח את {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "שמור קבצים מצורפים בעת ניקוי עריכה"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "שמרו על רמות חינמיות ופרימיום. תוכלו להתאים אישית את הרמות, למכור פרימיום דרך Stripe, או לחלק קודי מתנה מאוחר יותר מלוח הבקרה של המנהל."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "השארת תקופת החסד"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "השאר את ניקו במקום"
|
||||
msgid "Keep running"
|
||||
msgstr "המשך לפעול"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "השארת המנוי"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "ממש לעצמך"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "מימוש קוד מתנה"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "קבל גישה לאימוג'ים מותאמים אישית בהודעו
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "קבל גישה לשיתוף מסך באיכות HD עם {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "קבל גישה למגבלות גבוהות יותר ולתכונות בלעדיות, ותמוך בפלטפורמת תקשורת עצמאית."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "לחצת על Tab. מצב מקלדת מופעל כעת, כך שתוכל לנווט ב-{productName} ללא עכבר."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "ההטבות שלך יישמרו עד לתאריך החידוש הבא שלך, ולאחר מכן תהיה לך תקופת חסד של 3 ימים כדי להירשם מחדש ולשמור את היסטוריית המנוי שלך."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "ההטבות שלך יישמרו עד לתאריך החידוש הבא שלך. יש להפעיל מחדש את המנוי לפני כן כדי לשמור את היסטוריית המנוי שלך."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "תאבד את כל ההטבות של {PREMIUM_PRODUCT_NAME} באופן מיידי והיסטוריית המנוי שלך תתאפס. לא ניתן לבטל פעולה זו."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "निर्धारित स्विच रद्द करें"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "सदस्यता रद्द करें"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "अंतिम तिथि"
|
||||
msgid "End grace now"
|
||||
msgstr "ग्रेस पीरियड अभी खत्म करें"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "ग्रेस पीरियड खत्म करें"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "ग्रेस पीरियड अभी खत्म करें?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "नफ़रत फैलाने वाला भाषण"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "गिफ्ट कोड है? {PREMIUM_PRODUCT_NAME} को अनलॉक करने के लिए इसे रिडीम करें।"
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "एडिट साफ़ करते समय अटैचमें
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "मुफ़्त और प्रीमियम टियर रखें। आप टियर को कस्टमाइज़ कर सकते हैं, स्ट्राइप के ज़रिए प्रीमियम बेच सकते हैं, या बाद में एडमिन पैनल से गिफ्ट कोड दे सकते हैं।"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "ग्रेस पीरियड रखें"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "नेको को स्थिर रखें"
|
||||
msgid "Keep running"
|
||||
msgstr "चालू रखें"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "सदस्यता रखें"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "अपने लिए रिडीम करें"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "उपहार कोड रिडीम करें"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "{premiumProductName} के साथ DM में कस्टम इ
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "{premiumProductName} के साथ HD स्क्रीन शेयर अनलॉक करें"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "एक स्वतंत्र संचार प्लेटफ़ॉर्म को सपोर्ट करते हुए ज़्यादा बड़ी सीमाएँ और खास सुविधाएँ अनलॉक करें।"
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "आपने अभी Tab दबाया है। कीबोर्ड मोड अब चालू है, ताकि आप माउस के बिना {productName} को नेविगेट कर सकें।"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "आपके पर्क आपकी अगली रिन्यूअल तारीख तक बने रहेंगे, फिर दोबारा सदस्यता लेने और अपनी सब्सक्राइबर हिस्ट्री बनाए रखने के लिए 3 दिन का ग्रेस पीरियड मिलेगा।"
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "आपके पर्क आपकी अगली रिन्यूअल तारीख तक बने रहेंगे। अपनी सब्सक्राइबर हिस्ट्री बनाए रखने के लिए उससे पहले सदस्यता फिर से सक्रिय करें।"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "आप तुरंत सभी {PREMIUM_PRODUCT_NAME} पर्क खो देंगे और आपकी सब्सक्राइबर हिस्ट्री रीसेट हो जाएगी। इसे पूर्ववत नहीं किया जा सकता।"
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Poništi zakazanu promjenu"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Otkaži pretplatu"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Datum završetka"
|
||||
msgid "End grace now"
|
||||
msgstr "Završi poček sada"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Završi razdoblje počeka"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Završiti poček sada?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Govor mržnje"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Imaš poklon-kod? Iskoristi ga za otključavanje {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Zadrži privitke pri brisanju izmjene"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Zadržite besplatnu i premium razinu. Možete prilagoditi razine, prodavati premium putem Stripe-a ili kasnije dijeliti poklon-kodove iz administratorske ploče."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Zadrži poček"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Neka Neko miruje"
|
||||
msgid "Keep running"
|
||||
msgstr "Nastavi raditi"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Zadrži pretplatu"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Iskoristi za sebe"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Iskoristi kôd poklona"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Otključajte prilagođene emojije u izravnim porukama uz {premiumProduct
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Otključajte HD dijeljenje zaslona uz {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Otključajte veća ograničenja i ekskluzivne značajke, a istovremeno podržite neovisnu komunikacijsku platformu."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Upravo ste pritisnuli tipku Tab. Način rada tipkovnice sada je uključen, tako da se možete kretati kroz {productName} bez miša."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Zadržavate svoje pogodnosti do sljedećeg datuma obnove, a zatim imate razdoblje od 3 dana za ponovnu pretplatu i zadržavanje povijesti pretplate."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Zadržavate svoje pogodnosti do sljedećeg datuma obnove. Ponovno aktivirajte pretplatu prije toga kako biste zadržali povijest pretplate."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Odmah gubite sve pogodnosti {PREMIUM_PRODUCT_NAME} i vaša se povijest pretplate poništava. Ovo se ne može poništiti."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Ütemezett váltás visszavonása"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Előfizetés lemondása"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Befejezés dátuma"
|
||||
msgid "End grace now"
|
||||
msgstr "Türelmi időszak befejezése most"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Türelmi időszak befejezése"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Befejezed most a türelmi időszakot?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Gyűlöletbeszéd"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Van ajándékkódod? Váltsd be, hogy feloldd a(z) {PREMIUM_PRODUCT_NAME} funkciót."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Mellékletek megtartása a szerkesztett szöveg törlésekor"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Tartsd meg az ingyenes és a prémium szinteket. Testre szabhatod a szinteket, prémiumot adhatsz el a Stripe-on keresztül, vagy később ajándékkódokat osztogathatsz az admin panelből."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Türelmi időszak megtartása"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Tartsd mozdulatlanul Nekót"
|
||||
msgid "Keep running"
|
||||
msgstr "Futtatás folytatása"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Előfizetés megtartása"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Beváltás magadnak"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Ajándékkód beváltása"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Egyéni emojik feloldása közvetlen beszélgetésekben a(z) {premiumPro
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "HD-képernyőmegosztás feloldása a(z) {premiumProductName} csomaggal"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Oldj fel magasabb limiteket és exkluzív funkciókat, miközben támogatsz egy független kommunikációs platformot."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Megnyomtad a Tab billentyűt. Bekapcsolt a billentyűzetes mód, így egér nélkül is navigálhatsz a(z) {productName} alkalmazásban."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Az előnyöket a következő megújítási dátumig megtartod. Ezután 3 napos türelmi időszak áll rendelkezésedre, hogy újra előfizess és megőrizd az előfizetői előzményeidet."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Az előnyöket a következő megújítási dátumig megtartod. Ha addig újraaktiválod az előfizetést, megőrzöd az előfizetői előzményeidet."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Azonnal elveszíted a(z) {PREMIUM_PRODUCT_NAME} összes előnyét, és az előfizetői előzményeid lenullázódnak. Ez nem vonható vissza."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Batalkan perubahan terjadwal"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Batalkan langganan"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Tanggal akhir"
|
||||
msgid "End grace now"
|
||||
msgstr "Akhiri masa tenggang sekarang"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Akhiri masa tenggang"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Akhiri masa tenggang sekarang?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Ujaran kebencian"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Punya kode hadiah? Tukarkan untuk membuka {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Pertahankan lampiran saat kolom edit dikosongkan"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Pertahankan tingkatan gratis dan premium. Anda dapat menyesuaikan tingkatan, menjual premium melalui Stripe, atau membagikan kode hadiah nanti dari panel admin."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Pertahankan masa tenggang"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Diamkan Neko"
|
||||
msgid "Keep running"
|
||||
msgstr "Tetap berjalan"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Tetap berlangganan"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Tukarkan untuk diri sendiri"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Tukarkan kode hadiah"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Buka emoji kustom di DM dengan {premiumProductName}"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Buka berbagi layar HD dengan {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Dapatkan batas lebih tinggi dan fitur eksklusif sambil mendukung platform komunikasi independen."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Kamu baru saja menekan Tab. Mode keyboard kini aktif sehingga kamu bisa menavigasi {productName} tanpa mouse."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Kamu tetap mendapat semua keuntungan hingga tanggal perpanjangan berikutnya, lalu ada masa tenggang 3 hari untuk berlangganan lagi dan mempertahankan riwayat langgananmu."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Kamu tetap mendapat semua keuntungan hingga tanggal perpanjangan berikutnya. Aktifkan lagi langgananmu sebelum tanggal itu untuk mempertahankan riwayat langgananmu."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Kamu langsung kehilangan semua keuntungan {PREMIUM_PRODUCT_NAME} dan riwayat langgananmu diatur ulang. Ini tidak dapat dibatalkan."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Annulla cambio programmato"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Disdici abbonamento"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Data di fine"
|
||||
msgid "End grace now"
|
||||
msgstr "Termina periodo di tolleranza"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Termina periodo di tolleranza"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Terminare subito il periodo di tolleranza?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Incitamento all'odio"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Hai un codice regalo? Riscatta per sbloccare {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Mantieni gli allegati durante la cancellazione di una modifica"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Mantieni i livelli gratuiti e premium. Potrai personalizzare i livelli, vendere il premium tramite Stripe o distribuire codici regalo in seguito dal pannello di amministrazione."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Mantieni periodo di tolleranza"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Mantieni Neko fermo"
|
||||
msgid "Keep running"
|
||||
msgstr "Continua l’esecuzione"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Mantieni abbonamento"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Riscatta per te"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Riscatta codice regalo"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Sblocca le emoji personalizzate nei DM con {premiumProductName}"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Sblocca la condivisione schermo HD con {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Sblocca limiti più elevati e funzionalità esclusive, supportando al contempo una piattaforma di comunicazione indipendente."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Hai appena premuto Tab. La modalità tastiera è ora attiva, così puoi navigare in {productName} senza mouse."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Manterrai i tuoi vantaggi fino alla prossima data di rinnovo, poi avrai un periodo di tolleranza di 3 giorni per riabbonarti e mantenere la tua cronologia di abbonato."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Manterrai i tuoi vantaggi fino alla prossima data di rinnovo. Riattiva l’abbonamento prima di allora per mantenere la tua cronologia di abbonato."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Perderai immediatamente tutti i vantaggi di {PREMIUM_PRODUCT_NAME} e la cronologia dell'abbonamento verrà reimpostata. Questa azione non può essere annullata."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "予約済みの切り替えをキャンセル"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "サブスクリプションをキャンセル"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "終了日"
|
||||
msgid "End grace now"
|
||||
msgstr "猶予期間を終了"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "猶予期間を終了"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "猶予期間を今すぐ終了しますか?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "ヘイトスピーチ"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "ギフトコードをお持ちですか?{PREMIUM_PRODUCT_NAME}をアンロックするためにコードを入力してください。"
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "編集をクリアしても添付ファイルを保持する"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "無料版とプレミアム版を残します。ティアをカスタマイズしたり、Stripe で"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "猶予期間を保持"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "ネコを固定する"
|
||||
msgid "Keep running"
|
||||
msgstr "常駐"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "サブスクリプションを継続"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "自分で引き換える"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "ギフトコードを引き換える"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "{premiumProductName}でDMのカスタム絵文字をアンロック"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "{premiumProductName}でHD画面共有をアンロック"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "独立したコミュニケーションプラットフォームを支援しながら、上限アップや限定機能をご利用いただけます。"
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Tabキーを押しましたね。キーボードモードがオンになり、マウスなしで{productName}を操作できます。"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "特典は次回の更新日まで保持され、その後3日間の猶予期間中に再登録すると、サブスクリプション履歴を引き継ぐことができます。"
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "特典は次回の更新日まで保持されます。サブスクリプション履歴を引き継ぐには、それまでに再開してください。"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "{PREMIUM_PRODUCT_NAME}のすべての特典が直ちに失われ、サブスクリプション履歴がリセットされます。この操作は元に戻せません。"
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "예약된 전환 취소"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "구독 취소"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "종료일"
|
||||
msgid "End grace now"
|
||||
msgstr "유예 기간 지금 종료"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "유예 기간 종료"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "유예 기간을 지금 종료할까요?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "혐오 발언"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "선물 코드가 있으신가요? {PREMIUM_PRODUCT_NAME}을(를) 잠금 해제하려면 코드를 사용하세요."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "편집 내용을 지울 때 첨부 파일 유지"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "무료 및 프리미엄 등급을 유지하세요. 등급을 맞춤 설정하거나, Stripe를 통해 프리미엄을 판매하거나, 나중에 관리자 패널에서 선물 코드를 배포할 수 있습니다."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "유예 기간 유지"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "네코 고정하기"
|
||||
msgid "Keep running"
|
||||
msgstr "계속 실행"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "구독 유지"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "내 계정으로 사용"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "선물 코드 사용"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "{premiumProductName}으로 DM에서 맞춤 이모티콘을 잠금 해제
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "{premiumProductName}으로 HD 화면 공유를 잠금 해제하세요"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "독립적인 커뮤니케이션 플랫폼을 후원하면서 더 높은 한도와 전용 기능을 이용해 보세요."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Tab 키를 누르셨습니다. 이제 키보드 모드가 켜져 마우스 없이 {productName}을(를) 탐색할 수 있습니다."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "다음 갱신일까지 혜택이 유지되며, 3일의 유예 기간 동안 재구독하여 구독 기록을 유지할 수 있습니다."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "다음 갱신일까지 혜택이 유지됩니다. 구독 기록을 유지하려면 그 전에 재활성화하세요."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "모든 {PREMIUM_PRODUCT_NAME} 혜택이 즉시 사라지며 구독 기록이 초기화됩니다. 이 작업은 되돌릴 수 없습니다."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Atšaukti suplanuotą keitimą"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Atšaukti prenumeratą"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Pabaigos data"
|
||||
msgid "End grace now"
|
||||
msgstr "Baigti lengvatinį laikotarpį dabar"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Baigti lengvatinį laikotarpį"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Baigti lengvatinį laikotarpį dabar?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Neapykantos kalba"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Turite dovanų kodą? Aktyvuokite jį, kad atrakintumėte {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18927,7 +18927,7 @@ msgstr "Išsaugoti priedus išvalius redagavimą"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Pasilikite nemokamą ir mokamą lygius. Vėliau galėsite tinkinti lygius, parduoti mokamą turinį per „Stripe“ arba dalyti dovanų kodus iš administratoriaus skydelio."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Palikti lengvatinį laikotarpį"
|
||||
|
||||
@@ -18941,7 +18941,7 @@ msgstr "Neleisti Neko judėti"
|
||||
msgid "Keep running"
|
||||
msgstr "Palikti veikti"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Palikti prenumeratą"
|
||||
|
||||
@@ -26377,7 +26377,7 @@ msgstr "Išpirkti sau"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Išpirkti dovanos kodą"
|
||||
|
||||
@@ -35866,7 +35866,7 @@ msgstr "Atrakinkite pasirinktinius jaustukus DM pokalbiuose su „{premiumProduc
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Atrakinkite HD ekrano bendrinimą su „{premiumProductName}“"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Atrakinkite didesnius limitus ir išskirtines funkcijas, kartu palaikydami nepriklausomą komunikacijos platformą."
|
||||
|
||||
@@ -39494,10 +39494,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Ką tik paspaudėte „Tab“. Įjungtas klaviatūros režimas, kad galėtumėte naršyti {productName} be pelės."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Privilegijas išsaugosite iki kito atnaujinimo datos, o tada turėsite 3 dienų pereinamąjį laikotarpį, kad vėl užsiprenumeruotumėte ir išsaugotumėte prenumeratos istoriją."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Privilegijas išsaugosite iki kito atnaujinimo datos. Suaktyvinkite prenumeratą iš naujo iki tol, kad išsaugotumėte prenumeratos istoriją."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Iškart prarasite visas „{PREMIUM_PRODUCT_NAME}“ privilegijas, o jūsų prenumeratos istorija bus nustatyta iš naujo. Šio veiksmo anuliuoti negalima."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Geplande wijziging annuleren"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Abonnement opzeggen"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Einddatum"
|
||||
msgid "End grace now"
|
||||
msgstr "Respijtperiode nu beëindigen"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Respijtperiode beëindigen"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Respijtperiode nu beëindigen?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Haatzaaierij"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Heb je een cadeaubon? Wissel deze in om {PREMIUM_PRODUCT_NAME} te ontgrendelen."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Bijlagen behouden wanneer je de tekst van een bewerkt bericht wist"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Houd gratis en premium niveaus aan. Je kunt de niveaus aanpassen, premium verkopen via Stripe, of later cadeaucodes uitdelen vanuit het adminpaneel."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Respijtperiode behouden"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Neko stilhouden"
|
||||
msgid "Keep running"
|
||||
msgstr "Actief blijven"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Abonnement behouden"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Voor jezelf inwisselen"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Cadeaucode inwisselen"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Ontgrendel aangepaste emoji's in DM's met {premiumProductName}"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Ontgrendel schermdelen in HD met {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Ontgrendel hogere limieten en exclusieve functies, en steun tegelijkertijd een onafhankelijk communicatieplatform."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Je hebt zojuist op Tab gedrukt. De toetsenbordmodus is nu ingeschakeld, zodat je {productName} zonder muis kunt navigeren."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Je behoudt je voordelen tot je volgende verlengingsdatum, daarna heb je een respijtperiode van 3 dagen om je opnieuw te abonneren en je abonnementsgeschiedenis te behouden."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Je behoudt je voordelen tot je volgende verlengingsdatum. Activeer je abonnement vóór die datum opnieuw om je abonnementsgeschiedenis te behouden."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Je verliest onmiddellijk alle {PREMIUM_PRODUCT_NAME}-voordelen en je abonnementsgeschiedenis wordt gereset. Dit kan niet ongedaan worden gemaakt."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Avbryt planlagt bytte"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Si opp abonnement"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Sluttdato"
|
||||
msgid "End grace now"
|
||||
msgstr "Avslutt respittperiode nå"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Avslutt respittperiode"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Avslutte respittperioden nå?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Hatefulle ytringer"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Har du en gavekode? Løs den inn for å låse opp {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Behold vedlegg når meldingsteksten tømmes under redigering"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Behold gratis- og premiumnivåer. Du kan tilpasse nivåene, selge premium via Stripe, eller dele ut gavekoder senere fra administrasjonspanelet."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Behold respittperioden"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Hold Neko stille"
|
||||
msgid "Keep running"
|
||||
msgstr "Fortsett å kjøre"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Behold abonnementet"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Løs inn på egen konto"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Løs inn gavekode"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Lås opp egendefinerte emojier i direktemeldinger med {premiumProductNam
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Lås opp HD-skjermdeling med {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Lås opp høyere grenser og eksklusive funksjoner, samtidig som du støtter en uavhengig kommunikasjonsplattform."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Du trykket nettopp på Tab. Tastaturmodus er nå på, slik at du kan navigere i {productName} uten mus."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Du beholder fordelene dine frem til neste fornyelsesdato, og har deretter en respittperiode på 3 dager til å abonnere på nytt og beholde abonnementshistorikken din."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Du beholder fordelene dine frem til neste fornyelsesdato. Reaktiver før den tid for å beholde abonnementshistorikken din."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Du mister alle {PREMIUM_PRODUCT_NAME}-fordeler umiddelbart, og abonnementshistorikken din nullstilles. Dette kan ikke angres."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Anuluj zaplanowaną zmianę"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Anuluj subskrypcję"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Data końcowa"
|
||||
msgid "End grace now"
|
||||
msgstr "Zakończ okres karencji teraz"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Zakończ okres karencji"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Zakończyć okres karencji teraz?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Mowa nienawiści"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Masz kod podarunkowy? Aktywuj go, aby odblokować {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Zachowaj załączniki po wyczyszczeniu edycji"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Zachowaj darmowe i premium poziomy. Poziomy możesz dostosować, sprzedawać premium przez Stripe lub później rozdawać kody podarunkowe z panelu administratora."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Zachowaj okres karencji"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Pozostaw Neko w bezruchu"
|
||||
msgid "Keep running"
|
||||
msgstr "Kontynuuj działanie"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Zachowaj subskrypcję"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Odbierz dla siebie"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Wykorzystaj kod podarunkowy"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Odblokuj niestandardowe emoji w wiadomościach prywatnych dzięki {premi
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Odblokuj udostępnianie ekranu w jakości HD dzięki {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Odblokuj wyższe limity i ekskluzywne funkcje, wspierając jednocześnie niezależną platformę komunikacyjną."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Właśnie naciśnięto klawisz Tab. Tryb klawiatury jest teraz włączony, więc możesz nawigować po {productName} bez myszy."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Zachowasz korzyści do następnej daty odnowienia. Potem masz 3-dniowy okres karencji na ponowne wykupienie subskrypcji i zachowanie historii subskrypcji."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Zachowasz korzyści do następnej daty odnowienia. Reaktywuj subskrypcję przed tym terminem, aby zachować historię subskrypcji."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Natychmiast tracisz wszystkie korzyści {PREMIUM_PRODUCT_NAME}, a historia subskrypcji zostaje zresetowana. Tej operacji nie można cofnąć."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Cancelar mudança agendada"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Cancelar assinatura"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Data final"
|
||||
msgid "End grace now"
|
||||
msgstr "Encerrar período de carência agora"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Encerrar período de carência"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Encerrar período de carência agora?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Discurso de ódio"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Tem um código de presente? Resgate-o para desbloquear {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Manter anexos ao limpar uma edição"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Mantenha os níveis gratuito e premium. Você pode personalizar os níveis, vender o premium via Stripe ou distribuir códigos de presente mais tarde no painel de administração."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Manter período de carência"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Manter Neko parado"
|
||||
msgid "Keep running"
|
||||
msgstr "Continuar em execução"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Manter assinatura"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Resgatar para você"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Resgatar código de presente"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Desbloqueie emojis personalizados em mensagens diretas com o {premiumPro
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Desbloqueie o compartilhamento de tela HD com {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Desbloqueie limites maiores e recursos exclusivos enquanto apoia uma plataforma de comunicação independente."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Você acabou de pressionar Tab. O modo de teclado está ativado para que você possa navegar no {productName} sem um mouse."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Você mantém seus benefícios até a próxima data de renovação e, em seguida, tem um período de carência de 3 dias para assinar novamente e manter seu histórico de assinante."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Você mantém seus benefícios até a próxima data de renovação. Reative sua assinatura antes disso para manter seu histórico de assinante."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Você perde todos os benefícios do {PREMIUM_PRODUCT_NAME} imediatamente e seu histórico de assinante é redefinido. Isso não pode ser desfeito."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Anulează schimbarea programată"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Anulează abonamentul"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Data de încheiere"
|
||||
msgid "End grace now"
|
||||
msgstr "Încheie perioada de grație acum"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Încheie perioada de grație"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Închei perioada de grație acum?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Discurs instigator la ură"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Ai un cod cadou? Valorifică-l pentru a debloca {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Păstrează atașamentele la ștergerea unei editări"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Păstrează nivelurile gratuit și premium. Poți personaliza nivelurile, poți vinde premium prin Stripe sau poți oferi ulterior coduri cadou din panoul de administrare."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Păstrează perioada de grație"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Ține Neko nemișcat"
|
||||
msgid "Keep running"
|
||||
msgstr "Rulează în fundal"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Păstrează abonamentul"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Valorifică pentru tine"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Valorifică codul cadou"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Deblochează emoji-urile personalizate în DM-uri cu {premiumProductName
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Deblochează partajarea ecranului HD cu {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Deblochează limite mai mari și funcții exclusive, susținând în același timp o platformă de comunicare independentă."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Tocmai ai apăsat Tab. Modul tastatură este acum activ, ca să poți naviga în {productName} fără mouse."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Îți păstrezi beneficiile până la următoarea dată de reînnoire, apoi ai o perioadă de grație de 3 zile pentru a te reabona și a-ți păstra istoricul de abonat."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Îți păstrezi beneficiile până la următoarea dată de reînnoire. Reactivează abonamentul înainte de această dată pentru a-ți păstra istoricul de abonat."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Pierzi imediat toate beneficiile {PREMIUM_PRODUCT_NAME}, iar istoricul tău de abonat se resetează. Această acțiune nu poate fi anulată."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Отменить запланированный переход"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Отменить подписку"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Дата окончания"
|
||||
msgid "End grace now"
|
||||
msgstr "Завершить льготный период"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Завершить льготный период"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Завершить льготный период сейчас?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Разжигание ненависти"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Есть подарочный код? Активируйте его, чтобы разблокировать {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Сохранять вложения при очистке редакти
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Сохраняйте бесплатные и премиум-уровни. Вы можете настроить уровни, продавать премиум через Stripe или позже раздавать подарочные коды из панели администратора."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Сохранить льготный период"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Остановить Неко"
|
||||
msgid "Keep running"
|
||||
msgstr "Работать в фоне"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Оставить подписку"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Активировать для себя"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Активировать подарочный код"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Разблокируйте пользовательские эмодзи
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Разблокируйте демонстрацию экрана в HD с {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Откройте более высокие лимиты и эксклюзивные функции, поддерживая независимую платформу для общения."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Вы только что нажали Tab. Режим клавиатуры включен, теперь вы можете перемещаться по {productName} без мыши."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Вы сохраните свои преимущества до следующей даты продления, а затем у вас будет 3 дня льготного периода, чтобы повторно подписаться и сохранить историю подписок."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Вы сохраните свои преимущества до следующей даты продления. Возобновите подписку до этой даты, чтобы сохранить историю подписок."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Вы немедленно потеряете все преимущества {PREMIUM_PRODUCT_NAME}, а история вашей подписки будет сброшена. Это действие невозможно отменить."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Avbryt schemalagt byte"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Säg upp abonnemanget"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Slutdatum"
|
||||
msgid "End grace now"
|
||||
msgstr "Avsluta respitperioden nu"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Avsluta respitperiod"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Avsluta respitperioden nu?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Hatiska uttalanden"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Har du en presentkod? Lös in den för att låsa upp {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Behåll bilagor vid rensning av en redigering"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Behåll gratis- och premiumnivåer. Du kan anpassa nivåerna, sälja premium via Stripe eller dela ut presentkoder senare från administrationspanelen."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Behåll respitperiod"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Håll Neko stilla"
|
||||
msgid "Keep running"
|
||||
msgstr "Fortsätt köra"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Behåll abonnemanget"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Lös in själv"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Lös in presentkod"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Lås upp anpassade emojier i DM med {premiumProductName}"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Lås upp HD-skärmdelning med {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Lås upp högre gränser och exklusiva funktioner samtidigt som du stöder en oberoende kommunikationsplattform."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Du tryckte precis på Tab. Tangentbordsläget är nu aktiverat så att du kan navigera i {productName} utan mus."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Du behåller dina förmåner till nästa förnyelsedatum och har sedan en respitperiod på 3 dagar för att teckna abonnemang igen och behålla din abonnemangshistorik."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Du behåller dina förmåner till nästa förnyelsedatum. Återaktivera innan dess för att behålla din abonnemangshistorik."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Du förlorar alla {PREMIUM_PRODUCT_NAME}-förmåner direkt och din abonnemangshistorik återställs. Detta kan inte ångras."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "ยกเลิกการเปลี่ยนที่กำหนด
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "ยกเลิกการสมัครสมาชิก"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "วันที่สิ้นสุด"
|
||||
msgid "End grace now"
|
||||
msgstr "สิ้นสุดช่วงผ่อนผันตอนนี้"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "สิ้นสุดช่วงผ่อนผัน"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "สิ้นสุดช่วงผ่อนผันตอนนี้ใช่ไหม"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "คำพูดที่สร้างความเกลียดชัง"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "มีรหัสของขวัญไหม? ใช้แลกรับ {PREMIUM_PRODUCT_NAME}"
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "เก็บไฟล์แนบเมื่อล้างการแ
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "คงระดับฟรีและระดับพรีเมียมไว้ คุณสามารถปรับแต่งระดับต่างๆ ขายแบบพรีเมียมผ่าน Stripe หรือแจกโค้ดของขวัญในภายหลังจากแผงผู้ดูแลระบบ"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "คงช่วงผ่อนผัน"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "ตรึง Neko ไว้"
|
||||
msgid "Keep running"
|
||||
msgstr "ทำงานเบื้องหลัง"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "คงการสมัครสมาชิก"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "แลกให้ตัวเอง"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "แลกรหัสของขวัญ"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "ปลดล็อกอิโมจิแบบกำหนดเอง
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "ปลดล็อกการแชร์หน้าจอแบบ HD ด้วย {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "ปลดล็อกขีดจำกัดที่สูงขึ้นและฟีเจอร์พิเศษ พร้อมสนับสนุนแพลตฟอร์มการสื่อสารอิสระ"
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "คุณเพิ่งกด Tab ตอนนี้เปิดโหมดแป้นพิมพ์แล้ว คุณจึงสามารถไปยังส่วนต่างๆ ของ {productName} ได้โดยไม่ต้องใช้เมาส์"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "คุณจะยังคงได้รับสิทธิประโยชน์จนถึงวันต่ออายุครั้งถัดไป จากนั้นจะมีระยะเวลาผ่อนผัน 3 วันในการสมัครสมาชิกอีกครั้งเพื่อรักษาประวัติการเป็นสมาชิกของคุณไว้"
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "คุณจะยังคงได้รับสิทธิประโยชน์จนถึงวันต่ออายุครั้งถัดไป เปิดใช้งานการสมัครสมาชิกอีกครั้งก่อนวันดังกล่าวเพื่อรักษาประวัติการเป็นสมาชิกของคุณไว้"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "คุณจะสูญเสียสิทธิประโยชน์ทั้งหมดของ {PREMIUM_PRODUCT_NAME} ทันที และประวัติการเป็นสมาชิกของคุณจะถูกรีเซ็ต การดำเนินการนี้ไม่สามารถยกเลิกได้"
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Planlanan değişikliği iptal et"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Aboneliği iptal et"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Bitiş tarihi"
|
||||
msgid "End grace now"
|
||||
msgstr "Ödemesiz dönemi şimdi bitir"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Ödemesiz dönemi bitir"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Ödemesiz dönemi şimdi bitirilsin mi?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Nefret söylemi"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Hediye kodun mu var? {PREMIUM_PRODUCT_NAME}'ın kilidini açmak için kullan."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Bir düzenlemeyi temizlerken ekleri sakla"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Ücretsiz ve premium katmanları sakla. Katmanları özelleştirebilir, Stripe üzerinden premium satabilir veya daha sonra yönetici panelinden hediye kodları verebilirsin."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Ödemesiz dönemi koru"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Neko'yu sabit tut"
|
||||
msgid "Keep running"
|
||||
msgstr "Çalışmaya devam et"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Aboneliği sürdür"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Kendiniz için kullan"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Hediye kodunu kullan"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "{premiumProductName} ile DM'lerde özel emojilerin kilidini açın"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "{premiumProductName} ile HD ekran paylaşımının kilidini açın"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Bağımsız bir iletişim platformunu desteklerken daha yüksek limitlerin ve ayrıcalıklı özelliklerin kilidini açın."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Az önce Tab tuşuna bastınız. Klavye modu artık açık, böylece {productName} uygulamasında fare kullanmadan gezinebilirsiniz."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Avantajlarınız bir sonraki yenileme tarihinize kadar devam eder, ardından yeniden abone olup abone geçmişinizi korumak için 3 günlük ek süreniz olur."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Avantajlarınız bir sonraki yenileme tarihinize kadar devam eder. Abone geçmişinizi korumak için aboneliğinizi bu tarihten önce yeniden etkinleştirin."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Tüm {PREMIUM_PRODUCT_NAME} avantajlarınızı anında kaybedersiniz ve abone geçmişiniz sıfırlanır. Bu işlem geri alınamaz."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Скасувати заплановану зміну"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Скасувати підписку"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Дата закінчення"
|
||||
msgid "End grace now"
|
||||
msgstr "Завершити пільговий період зараз"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Завершити пільговий період"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Завершити пільговий період зараз?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Мова ворожнечі"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Є подарунковий код? Активуйте його, щоб розблокувати {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Зберігати вкладення, якщо очистити тек
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Зберігайте безкоштовний і преміум-рівні. Ви можете налаштувати рівні, продавати преміум через Stripe або роздавати подарункові коди пізніше з панелі адміністратора."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Залишити пільговий період"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Залишити Неко нерухомим"
|
||||
msgid "Keep running"
|
||||
msgstr "Працювати у фоні"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Залишити підписку"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Активувати для себе"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Активувати подарунковий код"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Розблокуйте власні емодзі у приватних
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Розблокуйте демонстрацію екрана в HD з {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Розблокуйте вищі ліміти та ексклюзивні функції, підтримуючи незалежну платформу для спілкування."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Ви щойно натиснули Tab. Режим клавіатури ввімкнено, тож ви можете переміщатися в {productName} без миші."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Ви зберігаєте свої переваги до наступної дати поновлення, а потім маєте 3-денний пільговий період, щоб повторно підписатися та зберегти історію підписок."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Ви зберігаєте свої переваги до наступної дати поновлення. Поновіть підписку до цієї дати, щоб зберегти історію підписок."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Ви одразу втратите всі переваги {PREMIUM_PRODUCT_NAME}, а історію підписок буде скинуто. Це неможливо скасувати."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "Hủy chuyển đổi đã lên lịch"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "Hủy gói đăng ký"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "Ngày kết thúc"
|
||||
msgid "End grace now"
|
||||
msgstr "Kết thúc thời gian ân hạn ngay"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "Kết thúc thời gian ân hạn"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "Kết thúc thời gian ân hạn ngay bây giờ?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "Ngôn từ gây thù ghét"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "Có mã quà tặng? Hãy đổi mã để mở khóa {PREMIUM_PRODUCT_NAME}."
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "Giữ tệp đính kèm khi xóa hết nội dung chỉnh sửa"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "Giữ nguyên các gói miễn phí và cao cấp. Bạn có thể tùy chỉnh các gói, bán gói cao cấp qua Stripe hoặc phát mã quà tặng sau từ bảng quản trị."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "Giữ thời gian ân hạn"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "Giữ Neko đứng yên"
|
||||
msgid "Keep running"
|
||||
msgstr "Tiếp tục chạy"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "Giữ gói đăng ký"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "Đổi cho bản thân"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "Đổi mã quà tặng"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "Mở khóa biểu tượng cảm xúc tùy chỉnh trong tin nhắn tr
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "Mở khóa chia sẻ màn hình HD với {premiumProductName}"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "Mở khóa giới hạn cao hơn và các tính năng độc quyền, đồng thời ủng hộ một nền tảng liên lạc độc lập."
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "Bạn vừa nhấn phím Tab. Chế độ bàn phím hiện đã bật để bạn có thể điều hướng {productName} mà không cần chuột."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "Bạn giữ các đặc quyền của mình cho đến ngày gia hạn tiếp theo, sau đó có thời gian ân hạn 3 ngày để đăng ký lại và giữ lịch sử đăng ký của mình."
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "Bạn giữ các đặc quyền của mình cho đến ngày gia hạn tiếp theo. Hãy kích hoạt lại gói đăng ký trước ngày đó để giữ lịch sử đăng ký của mình."
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "Bạn sẽ mất tất cả các đặc quyền của {PREMIUM_PRODUCT_NAME} ngay lập tức và lịch sử đăng ký của bạn sẽ được đặt lại. Thao tác này không thể hoàn tác."
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "取消已安排的切换"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "取消订阅"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "结束日期"
|
||||
msgid "End grace now"
|
||||
msgstr "立即结束宽限期"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "结束宽限期"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "立即结束宽限期?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "仇恨言论"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "有兑换码?兑换它以解锁 {PREMIUM_PRODUCT_NAME}。"
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "清除编辑时保留附件"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "保留免费和高级套餐。您可以自定义套餐,通过 Stripe 出售高级服务,或稍后从管理面板发放礼品码。"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "保留宽限期"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "保持 Neko 静止"
|
||||
msgid "Keep running"
|
||||
msgstr "继续运行"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "保留订阅"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "为自己兑换"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "兑换礼品码"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "使用 {premiumProductName} 解锁私信中的自定义表情"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "使用 {premiumProductName} 解锁高清屏幕共享"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "解锁更高上限和专属功能,同时支持独立的通讯平台。"
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "你刚刚按下了 Tab 键。键盘模式已开启,现在你无需鼠标即可在 {productName} 中导航。"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "你可以保留福利直到下一个续订日期,之后有 3 天的宽限期来重新订阅并保留你的订阅历史记录。"
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "你可以保留福利直到下一个续订日期。请在此之前重新激活订阅,以保留你的订阅历史记录。"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "你将立即失去所有 {PREMIUM_PRODUCT_NAME} 福利,并且你的订阅历史记录将被重置。此操作无法撤销。"
|
||||
|
||||
|
||||
@@ -6491,7 +6491,7 @@ msgstr "取消排定的切換"
|
||||
#. Billing button that starts subscription cancellation.
|
||||
#. Settings search synonym. Used to match this term when the user types it in the settings search bar.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:860
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:142
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:141
|
||||
#: src/features/user/components/settings_utils/search_index/PlutoniumIndex.ts:31
|
||||
msgid "Cancel subscription"
|
||||
msgstr "取消訂閱"
|
||||
@@ -13881,11 +13881,11 @@ msgstr "結束日期"
|
||||
msgid "End grace now"
|
||||
msgstr "立即結束寬限期"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:164
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:163
|
||||
msgid "End grace period"
|
||||
msgstr "結束寬限期"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:157
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:156
|
||||
msgid "End grace period now?"
|
||||
msgstr "要現在結束寬限期嗎?"
|
||||
|
||||
@@ -16826,7 +16826,7 @@ msgid "Hate speech"
|
||||
msgstr "仇恨言論"
|
||||
|
||||
#. Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:297
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:296
|
||||
msgid "Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}."
|
||||
msgstr "有禮物代碼嗎?兌換它以解鎖 {PREMIUM_PRODUCT_NAME}。"
|
||||
|
||||
@@ -18926,7 +18926,7 @@ msgstr "清除編輯時保留附件"
|
||||
msgid "Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel."
|
||||
msgstr "保留免費和進階方案。您可以自訂方案、透過 Stripe 出售進階方案,或稍後從管理面板發放禮物代碼。"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:166
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:165
|
||||
msgid "Keep grace period"
|
||||
msgstr "保留寬限期"
|
||||
|
||||
@@ -18940,7 +18940,7 @@ msgstr "讓 Neko 保持不動"
|
||||
msgid "Keep running"
|
||||
msgstr "繼續執行"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:144
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:143
|
||||
msgid "Keep subscription"
|
||||
msgstr "保留訂閱"
|
||||
|
||||
@@ -26376,7 +26376,7 @@ msgstr "兌換給自己"
|
||||
#. Billing button for entering a premium gift code.
|
||||
#. Billing button for entering a premium gift code.
|
||||
#: src/features/app/components/dialogs/components/plutonium/SubscriptionCard.tsx:735
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:307
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:306
|
||||
msgid "Redeem gift code"
|
||||
msgstr "兌換禮物代碼"
|
||||
|
||||
@@ -35865,7 +35865,7 @@ msgstr "使用 {premiumProductName} 在私訊中解鎖自訂表情符號"
|
||||
msgid "Unlock HD screen share with {premiumProductName}"
|
||||
msgstr "使用 {premiumProductName} 解鎖高畫質螢幕分享"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:217
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:216
|
||||
msgid "Unlock higher limits and exclusive features while supporting an independent communication platform."
|
||||
msgstr "解鎖更高上限和獨家功能,同時支持獨立通訊平台。"
|
||||
|
||||
@@ -39493,10 +39493,10 @@ msgid "You just pressed Tab. Keyboard mode is now on so you can navigate {produc
|
||||
msgstr "您剛才按了 Tab 鍵。鍵盤模式現已開啟,您無需滑鼠即可瀏覽 {productName}。"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:137
|
||||
msgid "You keep your perks until your next renewal date, then have a 3-day grace period to resubscribe and keep your subscriber history."
|
||||
msgstr "您會保留福利到下一個續訂日期,之後有 3 天寬限期可以重新訂閱並保留您的訂閱者歷史記錄。"
|
||||
msgid "You keep your perks until your next renewal date. Reactivate before then to keep your subscriber history."
|
||||
msgstr "您會保留福利到下一個續訂日期。請在此之前重新啟用訂閱,以保留您的訂閱者歷史記錄。"
|
||||
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:159
|
||||
#: src/features/app/components/dialogs/components/PlutoniumContent.tsx:158
|
||||
msgid "You lose all {PREMIUM_PRODUCT_NAME} perks immediately and your subscriber history resets. This cannot be undone."
|
||||
msgstr "您會立即失去所有 {PREMIUM_PRODUCT_NAME} 福利,且您的訂閱者歷史記錄將會重設。此動作無法復原。"
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ export const VoiceTab: React.FC<VoiceTabProps> = observer(({voiceSettings, autoR
|
||||
value: backend,
|
||||
label: getNoiseSuppressionChoiceLabel(i18n, backend),
|
||||
}));
|
||||
const stereoMicrophoneAvailable = isStereoMicrophoneChoiceAvailable();
|
||||
const stereoMicrophoneAvailable = isStereoMicrophoneChoiceAvailable(activeInputLabel);
|
||||
const setPushToTalkEnabled = (enabled: boolean) => {
|
||||
const mode = enabled ? 'voice_push_to_talk' : 'voice_activity';
|
||||
if (enabled && !isNativeDesktop) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type {VoiceProcessingMode} from '@app/features/voice/utils/VoiceProcessingProfile';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {deviceModes, voiceSettings} = vi.hoisted(() => ({
|
||||
deviceModes: new Map<string, VoiceProcessingMode>(),
|
||||
voiceSettings: {
|
||||
voiceProcessingMode: 'voice' as VoiceProcessingMode,
|
||||
echoCancellation: true,
|
||||
autoGainControl: false,
|
||||
getNoiseSuppressionBackend: () => 'none',
|
||||
getStereoMicrophone: () => false,
|
||||
getVoiceProcessingModeForDeviceLabel: vi.fn<(label: string | null | undefined) => VoiceProcessingMode>(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/features/voice/state/VoiceSettings', () => ({default: voiceSettings}));
|
||||
vi.mock('@app/features/voice/commands/VoiceSettingsCommands', () => ({update: vi.fn()}));
|
||||
vi.mock('@app/features/voice/engine/VoiceDevicePermissionState', () => ({
|
||||
default: {getState: () => ({inputDevices: []})},
|
||||
}));
|
||||
vi.mock('@app/features/voice/utils/VoiceDeviceManager', () => ({resolveEffectiveDeviceId: () => null}));
|
||||
|
||||
import {isStereoMicrophoneChoiceAvailable} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionChoices';
|
||||
|
||||
describe('stereo microphone choice for the selected device', () => {
|
||||
beforeEach(() => {
|
||||
deviceModes.clear();
|
||||
voiceSettings.voiceProcessingMode = 'voice';
|
||||
voiceSettings.echoCancellation = true;
|
||||
voiceSettings.autoGainControl = false;
|
||||
voiceSettings.getVoiceProcessingModeForDeviceLabel.mockReset();
|
||||
voiceSettings.getVoiceProcessingModeForDeviceLabel.mockImplementation(
|
||||
(label) => (label ? deviceModes.get(label) : undefined) ?? voiceSettings.voiceProcessingMode,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the switch for a device using direct input when the global mode is focused voice', () => {
|
||||
deviceModes.set('USB audio interface', 'studio');
|
||||
expect(isStereoMicrophoneChoiceAvailable('USB audio interface')).toBe(true);
|
||||
expect(voiceSettings.getVoiceProcessingModeForDeviceLabel).toHaveBeenCalledWith('USB audio interface');
|
||||
});
|
||||
|
||||
it('hides the switch for a device with custom processing when the global mode is direct input', () => {
|
||||
voiceSettings.voiceProcessingMode = 'studio';
|
||||
deviceModes.set('Headset microphone', 'custom');
|
||||
expect(isStereoMicrophoneChoiceAvailable('Headset microphone')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the switch for a custom device profile with processing disabled', () => {
|
||||
deviceModes.set('USB audio interface', 'custom');
|
||||
voiceSettings.echoCancellation = false;
|
||||
expect(isStereoMicrophoneChoiceAvailable('USB audio interface')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{mode: 'voice' as const, available: false},
|
||||
{mode: 'studio' as const, available: true},
|
||||
])('uses the global $mode profile when the device has no override', ({mode, available}) => {
|
||||
voiceSettings.voiceProcessingMode = mode;
|
||||
expect(isStereoMicrophoneChoiceAvailable('Unconfigured microphone')).toBe(available);
|
||||
expect(isStereoMicrophoneChoiceAvailable(null)).toBe(available);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
readNoiseSuppressionRuntimeCapabilities,
|
||||
supportsStereoCapture,
|
||||
} from '@app/features/voice/utils/noise_suppression/NoiseSuppressionSelection';
|
||||
import {resolveVoiceProcessingFromState} from '@app/features/voice/utils/VoiceProcessingProfile';
|
||||
import {resolveVoiceProcessingFromStateForDeviceLabel} from '@app/features/voice/utils/VoiceProcessingProfile';
|
||||
|
||||
export function getNoiseSuppressionChoiceValues(): ReadonlyArray<VoiceNoiseSuppressionBackend> {
|
||||
const capabilities = readNoiseSuppressionRuntimeCapabilities();
|
||||
@@ -29,8 +29,8 @@ export function setNoiseSuppressionChoice(backend: VoiceNoiseSuppressionBackend)
|
||||
VoiceSettingsCommands.update({noiseSuppressionBackend: backend});
|
||||
}
|
||||
|
||||
export function isStereoMicrophoneChoiceAvailable(): boolean {
|
||||
return supportsStereoCapture(resolveVoiceProcessingFromState(VoiceSettings));
|
||||
export function isStereoMicrophoneChoiceAvailable(deviceLabel: string | null): boolean {
|
||||
return supportsStereoCapture(resolveVoiceProcessingFromStateForDeviceLabel(VoiceSettings, deviceLabel));
|
||||
}
|
||||
|
||||
export function isStereoMicrophoneEnabled(): boolean {
|
||||
|
||||
@@ -749,8 +749,7 @@ The window this operation ends runs to `premium_grace_ends_at`. [Receive Stripe
|
||||
| --- | --- |
|
||||
| A renewal payment fails | The start of the unpaid period plus 7 days for a monthly plan or 14 days for a yearly plan |
|
||||
| The subscription was cancelled for non-payment | The same deadline, never extended |
|
||||
| The subscription ran to its recorded premium end | The subscription end plus 3 days |
|
||||
| The subscription ended early | The subscription end itself, which leaves no window for this route to close |
|
||||
| The subscription ended for any other reason | The subscription end itself, which leaves no window for this route to close |
|
||||
| The early cancellation is already recorded | Nothing |
|
||||
|
||||
This route requires an explicit `premium_grace_ends_at`. It does not end the default 3-day grace period used when that field is absent.
|
||||
|
||||
@@ -90,6 +90,8 @@ role_specs(presence, _Role) ->
|
||||
];
|
||||
role_specs(guilds, _Role) ->
|
||||
[
|
||||
child_spec(gateway_clock_offset, gateway_clock_offset),
|
||||
child_spec(guild_health, guild_health),
|
||||
child_spec(guild_counts_cache, guild_counts_cache),
|
||||
child_spec(guild_manager, guild_manager),
|
||||
child_spec(voice_state_counts_sync, voice_state_counts_sync)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_clock_offset).
|
||||
-typing([eqwalizer]).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([start_link/0, start_link/1, offset/1, sample/3]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
||||
|
||||
-define(TABLE, gateway_clock_offset).
|
||||
-define(PROBE_INTERVAL_MS, 10_000).
|
||||
-define(PROBE_TIMEOUT_MS, 1_000).
|
||||
-define(MAX_PROBE_RTT_MS, 100).
|
||||
|
||||
-type read_fun() :: fun((node()) -> integer()).
|
||||
-type options() :: #{
|
||||
read_fun => read_fun(),
|
||||
nodes_fun => fun(() -> [node()]),
|
||||
interval_ms => pos_integer()
|
||||
}.
|
||||
-type state() :: #{
|
||||
read_fun := read_fun(),
|
||||
nodes_fun := fun(() -> [node()]),
|
||||
interval_ms := pos_integer(),
|
||||
probes := #{node() => reference()}
|
||||
}.
|
||||
|
||||
-spec start_link() -> gen_server:start_ret().
|
||||
start_link() ->
|
||||
start_link(#{}).
|
||||
|
||||
-spec start_link(options()) -> gen_server:start_ret().
|
||||
start_link(Options) when is_map(Options) ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, Options, []).
|
||||
|
||||
-spec offset(node()) -> integer() | undefined.
|
||||
offset(Node) when Node =:= node() ->
|
||||
0;
|
||||
offset(Node) ->
|
||||
try ets:lookup(?TABLE, Node) of
|
||||
[{Node, Offset}] when is_integer(Offset) -> Offset;
|
||||
_ -> undefined
|
||||
catch
|
||||
error:badarg -> undefined
|
||||
end.
|
||||
|
||||
-spec sample(integer(), integer(), integer()) -> {ok, integer()} | discard.
|
||||
sample(RemoteMs, LocalAfterMs, RttMs) when RttMs >= 0, RttMs =< ?MAX_PROBE_RTT_MS ->
|
||||
{ok, RemoteMs - LocalAfterMs};
|
||||
sample(_RemoteMs, _LocalAfterMs, _RttMs) ->
|
||||
discard.
|
||||
|
||||
-spec init(options()) -> {ok, state()}.
|
||||
init(Options) ->
|
||||
_ = ets:new(?TABLE, [named_table, protected, set, {read_concurrency, true}]),
|
||||
_ = net_kernel:monitor_nodes(true),
|
||||
self() ! probe_all,
|
||||
{ok, #{
|
||||
read_fun => maps:get(read_fun, Options, fun read_remote_clock/1),
|
||||
nodes_fun => maps:get(nodes_fun, Options, fun erlang:nodes/0),
|
||||
interval_ms => maps:get(interval_ms, Options, ?PROBE_INTERVAL_MS),
|
||||
probes => #{}
|
||||
}}.
|
||||
|
||||
-spec handle_call(term(), gen_server:from(), state()) -> {reply, ok, state()}.
|
||||
handle_call(_Request, _From, State) ->
|
||||
{reply, ok, State}.
|
||||
|
||||
-spec handle_cast(term(), state()) -> {noreply, state()}.
|
||||
handle_cast(_Msg, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec handle_info(term(), state()) -> {noreply, state()}.
|
||||
handle_info(probe_all, #{nodes_fun := NodesFun, interval_ms := IntervalMs} = State) ->
|
||||
erlang:send_after(IntervalMs, self(), probe_all),
|
||||
{noreply, lists:foldl(fun spawn_probe/2, State, NodesFun())};
|
||||
handle_info({nodeup, Node}, State) when is_atom(Node) ->
|
||||
{noreply, spawn_probe(Node, State)};
|
||||
handle_info({nodedown, Node}, #{probes := Probes} = State) when is_atom(Node) ->
|
||||
ets:delete(?TABLE, Node),
|
||||
{noreply, State#{probes := maps:remove(Node, Probes)}};
|
||||
handle_info({clock_offset, Node, Ref, Offset}, #{probes := Probes} = State) when
|
||||
is_atom(Node), is_reference(Ref), is_integer(Offset)
|
||||
->
|
||||
case maps:get(Node, Probes, undefined) of
|
||||
Ref -> ets:insert(?TABLE, {Node, Offset});
|
||||
_ -> ok
|
||||
end,
|
||||
{noreply, State};
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec terminate(term(), state()) -> ok.
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
-spec code_change(term(), state(), term()) -> {ok, state()}.
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
-spec spawn_probe(node(), state()) -> state().
|
||||
spawn_probe(Node, #{read_fun := ReadFun, probes := Probes} = State) ->
|
||||
Server = self(),
|
||||
Ref = make_ref(),
|
||||
_ = spawn(fun() -> probe(Server, Node, Ref, ReadFun) end),
|
||||
State#{probes := Probes#{Node => Ref}}.
|
||||
|
||||
-spec probe(pid(), node(), reference(), read_fun()) -> ok.
|
||||
probe(Server, Node, Ref, ReadFun) ->
|
||||
Start = erlang:monotonic_time(millisecond),
|
||||
try ReadFun(Node) of
|
||||
RemoteMs when is_integer(RemoteMs) ->
|
||||
End = erlang:monotonic_time(millisecond),
|
||||
report(Server, Node, Ref, sample(RemoteMs, End, End - Start))
|
||||
catch
|
||||
_:_ -> ok
|
||||
end.
|
||||
|
||||
-spec report(pid(), node(), reference(), {ok, integer()} | discard) -> ok.
|
||||
report(Server, Node, Ref, {ok, Offset}) ->
|
||||
Server ! {clock_offset, Node, Ref, Offset},
|
||||
ok;
|
||||
report(_Server, _Node, _Ref, discard) ->
|
||||
ok.
|
||||
|
||||
-spec read_remote_clock(node()) -> integer().
|
||||
read_remote_clock(Node) ->
|
||||
erpc:call(Node, erlang, monotonic_time, [millisecond], ?PROBE_TIMEOUT_MS).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
sample_retains_the_full_return_trip_uncertainty_test() ->
|
||||
?assertEqual({ok, 4990}, sample(15000, 10010, 20)),
|
||||
?assertEqual({ok, -5000}, sample(5000, 10000, 0)),
|
||||
?assertEqual(discard, sample(15000, 10000, 101)),
|
||||
?assertEqual(discard, sample(15000, 10000, -1)).
|
||||
|
||||
local_clock_does_not_need_a_probe_test() ->
|
||||
?assertEqual(0, offset(node())).
|
||||
|
||||
-endif.
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
-export([
|
||||
with_guild/2, with_guild/3,
|
||||
with_guild_unchecked/2,
|
||||
with_voice_server/2,
|
||||
ensure_guild_pid/1,
|
||||
get_guild_pid/1,
|
||||
@@ -47,7 +48,29 @@ with_guild(GuildId, Fun, NotFoundError) ->
|
||||
-spec run_with_guild_pid(integer(), pid(), fun((pid()) -> T), binary()) -> T when
|
||||
T :: term().
|
||||
run_with_guild_pid(GuildId, Pid, Fun, NotFoundError) ->
|
||||
run_with_guild_pid_guard(GuildId, Pid, fun() -> Fun(Pid) end, Fun, NotFoundError).
|
||||
Checked = fun(CurrentPid) ->
|
||||
ensure_responsive(CurrentPid),
|
||||
Fun(CurrentPid)
|
||||
end,
|
||||
run_with_guild_pid_guard(GuildId, Pid, fun() -> Checked(Pid) end, Checked, NotFoundError).
|
||||
|
||||
-spec with_guild_unchecked(integer(), fun((pid()) -> T)) -> T when T :: term().
|
||||
with_guild_unchecked(GuildId, Fun) ->
|
||||
case ensure_guild_pid(GuildId) of
|
||||
{ok, Pid} ->
|
||||
run_with_guild_pid_guard(
|
||||
GuildId, Pid, fun() -> Fun(Pid) end, Fun, <<"guild_not_found">>
|
||||
);
|
||||
error ->
|
||||
gateway_rpc_error:raise(<<"guild_not_found">>)
|
||||
end.
|
||||
|
||||
-spec ensure_responsive(pid()) -> ok.
|
||||
ensure_responsive(Pid) ->
|
||||
case guild_health:is_degraded(Pid) of
|
||||
true -> gateway_rpc_error:raise(<<"guild_overloaded">>);
|
||||
false -> ok
|
||||
end.
|
||||
|
||||
-spec with_voice_server(integer(), fun((pid(), pid()) -> T)) -> T when T :: term().
|
||||
with_voice_server(GuildId, Fun) ->
|
||||
@@ -352,6 +375,7 @@ safe_gen_server_call(Pid, Request, Timeout) ->
|
||||
-spec safe_guild_call(integer(), pid(), {atom(), map()}, pos_integer()) ->
|
||||
{ok, term()} | error.
|
||||
safe_guild_call(GuildId, Pid, Request, Timeout) ->
|
||||
ensure_responsive(Pid),
|
||||
try guild_query_handler:call(Pid, Request, Timeout) of
|
||||
Reply -> {ok, Reply}
|
||||
catch
|
||||
@@ -385,6 +409,7 @@ retry_guild_call(GuildId, Request, Timeout) ->
|
||||
-spec retry_guild_call_pid(integer(), pid(), {atom(), map()}, pos_integer()) ->
|
||||
{ok, term()} | error.
|
||||
retry_guild_call_pid(GuildId, NewPid, Request, Timeout) ->
|
||||
ensure_responsive(NewPid),
|
||||
try guild_query_handler:call(NewPid, Request, Timeout) of
|
||||
Reply -> {ok, Reply}
|
||||
catch
|
||||
@@ -401,6 +426,29 @@ retry_guild_call_pid(GuildId, NewPid, Request, Timeout) ->
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
overloaded_guild_is_rejected_before_enqueuing_work_test() ->
|
||||
ok = guild_ets_owner:ensure_table(guild_health_status, [named_table, public, set]),
|
||||
Pid = spawn(fun() ->
|
||||
receive
|
||||
stop -> ok
|
||||
end
|
||||
end),
|
||||
true = ets:insert(guild_health_status, {Pid, 42, true, undefined, undefined}),
|
||||
try
|
||||
?assertError(
|
||||
{gateway_rpc_error, <<"guild_overloaded">>},
|
||||
safe_guild_call(42, Pid, {get_viewer_counts, #{user_id => 200}}, 4000)
|
||||
),
|
||||
?assertError(
|
||||
{gateway_rpc_error, <<"guild_overloaded">>},
|
||||
retry_guild_call_pid(42, Pid, {get_viewer_counts, #{user_id => 200}}, 4000)
|
||||
),
|
||||
?assertEqual({message_queue_len, 0}, process_info(Pid, message_queue_len))
|
||||
after
|
||||
ets:delete(guild_health_status, Pid),
|
||||
Pid ! stop
|
||||
end.
|
||||
|
||||
guild_start_backoff_delay_exponential_test() ->
|
||||
Delay1 = guild_start_backoff_delay(1),
|
||||
?assert(Delay1 >= ?GUILD_START_BASE_MS),
|
||||
|
||||
@@ -25,7 +25,7 @@ handle(<<"guild.shutdown">>, P) -> handle_shutdown(P).
|
||||
-spec handle_dispatch(map()) -> term().
|
||||
handle_dispatch(#{<<"guild_id">> := GuildIdBin, <<"event">> := Event, <<"data">> := Data}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
gateway_rpc_guild_infra:with_guild(GuildId, fun(Pid) ->
|
||||
gateway_rpc_guild_infra:with_guild_unchecked(GuildId, fun(Pid) ->
|
||||
EventAtom = constants:dispatch_event_atom(Event),
|
||||
IsAlive = gateway_rpc_guild_infra:is_cached_guild_pid_alive(Pid),
|
||||
logger:debug(
|
||||
@@ -40,26 +40,14 @@ handle_dispatch(#{<<"guild_id">> := GuildIdBin, <<"event">> := Event, <<"data">>
|
||||
handle_get_data(#{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = optional_user_id(UserIdBin),
|
||||
gateway_rpc_guild_infra:with_guild(
|
||||
GuildId,
|
||||
fun(Pid) ->
|
||||
get_data_from_guild(Pid, UserId)
|
||||
end,
|
||||
<<"guild_not_found">>
|
||||
).
|
||||
get_data_from_guild(GuildId, UserId).
|
||||
|
||||
-spec handle_get_auth_context(map()) -> term().
|
||||
handle_get_auth_context(#{<<"guild_id">> := GuildIdBin, <<"user_id">> := UserIdBin} = Params) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
UserId = optional_user_id(UserIdBin),
|
||||
ChannelId = optional_channel_id(maps:get(<<"channel_id">>, Params, null)),
|
||||
gateway_rpc_guild_infra:with_guild(
|
||||
GuildId,
|
||||
fun(Pid) ->
|
||||
get_auth_context_from_guild(Pid, UserId, ChannelId)
|
||||
end,
|
||||
<<"guild_not_found">>
|
||||
).
|
||||
get_auth_context_from_guild(GuildId, UserId, ChannelId).
|
||||
|
||||
-spec optional_channel_id(term()) -> integer() | null.
|
||||
optional_channel_id(Value) ->
|
||||
@@ -69,10 +57,10 @@ optional_channel_id(Value) ->
|
||||
_ -> gateway_rpc_error:raise(validation_invalid_params)
|
||||
end.
|
||||
|
||||
-spec get_auth_context_from_guild(pid(), integer() | null, integer() | null) -> term().
|
||||
get_auth_context_from_guild(Pid, UserId, ChannelId) ->
|
||||
-spec get_auth_context_from_guild(integer(), integer() | null, integer() | null) -> term().
|
||||
get_auth_context_from_guild(GuildId, UserId, ChannelId) ->
|
||||
Request = {get_guild_auth_context, #{user_id => UserId, channel_id => ChannelId}},
|
||||
case guild_query_handler:call(Pid, Request, ?GUILD_CALL_TIMEOUT) of
|
||||
case read_guild(GuildId, Request) of
|
||||
#{auth_context := null} ->
|
||||
gateway_rpc_error:raise(<<"forbidden">>);
|
||||
#{auth_context := AuthContext} ->
|
||||
@@ -89,10 +77,10 @@ optional_user_id(Value) ->
|
||||
_ -> gateway_rpc_error:raise(validation_invalid_params)
|
||||
end.
|
||||
|
||||
-spec get_data_from_guild(pid(), integer() | null) -> term().
|
||||
get_data_from_guild(Pid, UserId) ->
|
||||
-spec get_data_from_guild(integer(), integer() | null) -> term().
|
||||
get_data_from_guild(GuildId, UserId) ->
|
||||
Request = {get_guild_data, #{user_id => UserId}},
|
||||
case guild_query_handler:call(Pid, Request, ?GUILD_CALL_TIMEOUT) of
|
||||
case read_guild(GuildId, Request) of
|
||||
#{guild_data := null, error_reason := <<"forbidden">>} ->
|
||||
gateway_rpc_error:raise(<<"forbidden">>);
|
||||
#{guild_data := null} ->
|
||||
@@ -103,6 +91,17 @@ get_data_from_guild(Pid, UserId) ->
|
||||
gateway_rpc_error:raise(<<"guild_data_error">>)
|
||||
end.
|
||||
|
||||
-spec read_guild(integer(), {atom(), map()}) -> term().
|
||||
read_guild(GuildId, Request) ->
|
||||
case guild_read_model:query(GuildId, Request) of
|
||||
{ok, Reply} ->
|
||||
Reply;
|
||||
miss ->
|
||||
gateway_rpc_guild_infra:with_guild(GuildId, fun(Pid) ->
|
||||
guild_query_handler:call(Pid, Request, ?GUILD_CALL_TIMEOUT)
|
||||
end)
|
||||
end.
|
||||
|
||||
-spec handle_start(map()) -> true.
|
||||
handle_start(#{<<"guild_id">> := GuildIdBin}) ->
|
||||
GuildId = validation:snowflake_or_throw(<<"guild_id">>, GuildIdBin),
|
||||
|
||||
@@ -157,22 +157,10 @@ has_member_from_guild(GuildId, Pid, Msg) ->
|
||||
integer(), integer()
|
||||
) -> {ok, map() | undefined} | {error, guild_not_found} | error.
|
||||
get_member_cached_or_rpc(GuildId, UserId) ->
|
||||
case guild_permission_cache:get_member(GuildId, UserId) of
|
||||
{ok, MemberData} when is_map(MemberData) ->
|
||||
maybe_refresh_member(GuildId, UserId, MemberData);
|
||||
{ok, MemberOrUndefined} ->
|
||||
{ok, MemberOrUndefined};
|
||||
{error, not_found} ->
|
||||
get_member_via_rpc(GuildId, UserId)
|
||||
end.
|
||||
|
||||
-spec maybe_refresh_member(integer(), integer(), map()) ->
|
||||
{ok, map() | undefined} | {error, guild_not_found} | error.
|
||||
maybe_refresh_member(GuildId, UserId, MemberData) ->
|
||||
Key = <<"communication_disabled_until">>,
|
||||
case maps:is_key(Key, MemberData) of
|
||||
true -> {ok, MemberData};
|
||||
false -> get_member_via_rpc(GuildId, UserId)
|
||||
case guild_read_model:query(GuildId, {get_guild_member, #{user_id => UserId}}) of
|
||||
{ok, #{success := true, member_data := Member}} -> {ok, Member};
|
||||
{ok, #{success := false}} -> {ok, undefined};
|
||||
miss -> get_member_via_rpc(GuildId, UserId)
|
||||
end.
|
||||
|
||||
-spec get_member_via_rpc(
|
||||
|
||||
@@ -43,30 +43,62 @@ init(GuildState) ->
|
||||
State3 = guild_init:init_caches_and_timers(State2),
|
||||
State4 = guild_init:init_voice_server(State3),
|
||||
erlang:garbage_collect(),
|
||||
{ok, State4, ?HIBERNATE_TIMEOUT}.
|
||||
State5 = guild_health:register_guild(State4),
|
||||
ok = guild_read_model:put_state(State5),
|
||||
{ok, State5, ?HIBERNATE_TIMEOUT}.
|
||||
|
||||
-spec handle_call(term(), gen_server:from(), guild_state()) -> call_reply().
|
||||
handle_call({session_connect, Request}, {CallerPid, _}, State) ->
|
||||
handle_call(Msg, From, State) ->
|
||||
Result = handle_call_internal(Msg, From, State),
|
||||
ok = publish_read_model(Result, State),
|
||||
Result.
|
||||
|
||||
-spec handle_cast(term(), guild_state()) -> cast_reply().
|
||||
handle_cast(Msg, State) ->
|
||||
Result = handle_cast_internal(Msg, State),
|
||||
ok = publish_read_model(Result, State),
|
||||
Result.
|
||||
|
||||
-spec handle_info(term(), guild_state()) -> info_reply().
|
||||
handle_info(Msg, State) ->
|
||||
Result = handle_info_internal(Msg, State),
|
||||
ok = publish_read_model(Result, State),
|
||||
Result.
|
||||
|
||||
-spec publish_read_model(tuple(), guild_state()) -> ok.
|
||||
publish_read_model({reply, _Reply, NewState}, OldState) ->
|
||||
guild_read_model:update(OldState, NewState);
|
||||
publish_read_model({noreply, NewState}, OldState) ->
|
||||
guild_read_model:update(OldState, NewState);
|
||||
publish_read_model({noreply, NewState, _Timeout}, OldState) ->
|
||||
guild_read_model:update(OldState, NewState);
|
||||
publish_read_model(_Result, _OldState) ->
|
||||
ok.
|
||||
|
||||
-spec handle_call_internal(term(), gen_server:from(), guild_state()) -> call_reply().
|
||||
handle_call_internal({session_connect, Request}, {CallerPid, _}, State) ->
|
||||
handle_session_connect_call(Request, CallerPid, State);
|
||||
handle_call(export_handoff_state, _From, State) ->
|
||||
handle_call_internal(export_handoff_state, _From, State) ->
|
||||
{reply, {ok, guild_handoff:export_handoff_state(State)}, State};
|
||||
handle_call({get_guild_id}, _From, State) ->
|
||||
handle_call_internal({get_guild_id}, _From, State) ->
|
||||
{reply, maps:get(id, State, undefined), State};
|
||||
handle_call({get_voice_guild_state}, _From, State) ->
|
||||
handle_call_internal({get_voice_guild_state}, _From, State) ->
|
||||
{reply, voice_guild_state(State), State};
|
||||
handle_call({dispatch, Request}, _From, State) ->
|
||||
handle_call_internal({dispatch, Request}, _From, State) ->
|
||||
handle_dispatch_call(Request, State);
|
||||
handle_call({reload, NewData}, _From, State) ->
|
||||
handle_call_internal({reload, NewData}, _From, State) ->
|
||||
handle_reload_call(NewData, State);
|
||||
handle_call(get_voice_server_pid, _From, State) ->
|
||||
handle_call_internal(get_voice_server_pid, _From, State) ->
|
||||
guild_voice_lifecycle:reply_voice_server_pid(State);
|
||||
handle_call({released_push_holds, SessionIds}, _From, State) when is_list(SessionIds) ->
|
||||
handle_call_internal({released_push_holds, SessionIds}, _From, State) when
|
||||
is_list(SessionIds)
|
||||
->
|
||||
{reply, guild_sessions:released_push_holds(SessionIds, State), State};
|
||||
handle_call({terminate}, _From, State) ->
|
||||
handle_call_internal({terminate}, _From, State) ->
|
||||
{stop, normal, ok, State};
|
||||
handle_call(Msg, From, State) when is_tuple(Msg) ->
|
||||
handle_call_internal(Msg, From, State) when is_tuple(Msg) ->
|
||||
route_call(element(1, Msg), Msg, From, State);
|
||||
handle_call(_, _From, State) ->
|
||||
handle_call_internal(_, _From, State) ->
|
||||
{reply, ok, State}.
|
||||
|
||||
-spec route_call(atom(), term(), gen_server:from(), guild_state()) -> call_reply().
|
||||
@@ -143,38 +175,40 @@ voice_call_handler(Tag) -> subscription_call_handler(Tag).
|
||||
subscription_call_handler(lazy_subscribe) -> subscription;
|
||||
subscription_call_handler(_) -> undefined.
|
||||
|
||||
-spec handle_cast(term(), guild_state()) -> cast_reply().
|
||||
handle_cast({dispatch, Request}, State) ->
|
||||
-spec handle_cast_internal(term(), guild_state()) -> cast_reply().
|
||||
handle_cast_internal({dispatch, Request}, State) ->
|
||||
handle_dispatch_cast(Request, State);
|
||||
handle_cast(
|
||||
handle_cast_internal(
|
||||
{session_connect_async,
|
||||
#{guild_id := GuildId, attempt := Attempt, request := Request} = Msg},
|
||||
State
|
||||
) ->
|
||||
handle_session_connect_async_cast(GuildId, Attempt, Request, Msg, State);
|
||||
handle_cast({session_connect_worker_done, SessionId, Attempt, Result0, Computed}, State) ->
|
||||
handle_cast_internal(
|
||||
{session_connect_worker_done, SessionId, Attempt, Result0, Computed}, State
|
||||
) ->
|
||||
handle_session_connect_worker_done_cast(SessionId, Attempt, Result0, Computed, State);
|
||||
handle_cast({set_session_active, SessionId}, State) ->
|
||||
handle_cast_internal({set_session_active, SessionId}, State) ->
|
||||
handle_set_session_active_cast(SessionId, State);
|
||||
handle_cast({set_session_passive, SessionId}, State) ->
|
||||
handle_cast_internal({set_session_passive, SessionId}, State) ->
|
||||
handle_set_session_passive_cast(SessionId, State);
|
||||
handle_cast({drop_session_member_lists, SessionId}, State) when is_binary(SessionId) ->
|
||||
handle_cast_internal({drop_session_member_lists, SessionId}, State) when is_binary(SessionId) ->
|
||||
{noreply, guild_member_list:unsubscribe_session(SessionId, State)};
|
||||
handle_cast({set_session_typing_override, SessionId, TypingFlag}, State) ->
|
||||
handle_cast_internal({set_session_typing_override, SessionId, TypingFlag}, State) ->
|
||||
handle_set_session_typing_override_cast(SessionId, TypingFlag, State);
|
||||
handle_cast({set_session_push_hold, SessionId, Hold}, State) when
|
||||
handle_cast_internal({set_session_push_hold, SessionId, Hold}, State) when
|
||||
is_binary(SessionId), is_boolean(Hold)
|
||||
->
|
||||
{noreply, guild_sessions:set_session_push_hold(SessionId, Hold, State)};
|
||||
handle_cast({send_guild_sync, SessionId}, State) ->
|
||||
handle_cast_internal({send_guild_sync, SessionId}, State) ->
|
||||
handle_send_guild_sync_cast(SessionId, State);
|
||||
handle_cast({send_members_chunk, SessionId, ChunkData}, State) ->
|
||||
handle_cast_internal({send_members_chunk, SessionId, ChunkData}, State) ->
|
||||
handle_send_members_chunk_cast(SessionId, ChunkData, State);
|
||||
handle_cast({patch_everyone_perms, Bit}, State) when is_integer(Bit), Bit > 0 ->
|
||||
handle_cast_internal({patch_everyone_perms, Bit}, State) when is_integer(Bit), Bit > 0 ->
|
||||
{noreply, guild_maintenance:apply_everyone_perm_bit(Bit, State)};
|
||||
handle_cast(Msg, State) when is_tuple(Msg) ->
|
||||
handle_cast_internal(Msg, State) when is_tuple(Msg) ->
|
||||
route_cast(element(1, Msg), Msg, State);
|
||||
handle_cast(_, State) ->
|
||||
handle_cast_internal(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec route_cast(atom(), term(), guild_state()) -> cast_reply().
|
||||
@@ -197,47 +231,52 @@ cast_handler(update_member_subscriptions) -> subscription;
|
||||
cast_handler(update_dm_partners) -> dm_partners;
|
||||
cast_handler(_) -> undefined.
|
||||
|
||||
-spec handle_info(term(), guild_state()) -> info_reply().
|
||||
handle_info({presence, UserId, Payload}, State) ->
|
||||
-spec handle_info_internal(term(), guild_state()) -> info_reply().
|
||||
handle_info_internal({guild_health_probe, Ref}, State) when is_reference(Ref) ->
|
||||
gen_server:cast(guild_health, {pong, self(), Ref, erlang:monotonic_time(millisecond)}),
|
||||
{noreply, State};
|
||||
handle_info_internal(guild_health_register, State) ->
|
||||
{noreply, guild_health:register_guild(State)};
|
||||
handle_info_internal({presence, UserId, Payload}, State) ->
|
||||
handle_presence_info(UserId, Payload, State);
|
||||
handle_info({'EXIT', Pid, Reason}, State) ->
|
||||
handle_info_internal({'EXIT', Pid, Reason}, State) ->
|
||||
handle_exit_info(Pid, Reason, State);
|
||||
handle_info({'DOWN', Ref, process, _Pid, Reason}, State) ->
|
||||
handle_info_internal({'DOWN', Ref, process, _Pid, Reason}, State) ->
|
||||
handle_down_info(Ref, Reason, State);
|
||||
handle_info(count_cache_refresh, State) ->
|
||||
handle_info_internal(count_cache_refresh, State) ->
|
||||
State1 = update_counts(State),
|
||||
_ = guild_maintenance:schedule_count_cache_refresh(State1),
|
||||
{noreply, State1};
|
||||
handle_info(availability_recheck, State) ->
|
||||
handle_info_internal(availability_recheck, State) ->
|
||||
{noreply, guild_availability:handle_availability_recheck(State)};
|
||||
handle_info(passive_sync, State) ->
|
||||
handle_info_internal(passive_sync, State) ->
|
||||
guild_passive_sync:handle_passive_sync(State);
|
||||
handle_info(presence_reconcile, State) ->
|
||||
handle_info_internal(presence_reconcile, State) ->
|
||||
guild_presence_reconcile:start_async(State),
|
||||
_ = guild_presence_reconcile:schedule(),
|
||||
{noreply, State};
|
||||
handle_info({presence_reconcile_apply, Mismatches}, State) when is_list(Mismatches) ->
|
||||
handle_info_internal({presence_reconcile_apply, Mismatches}, State) when is_list(Mismatches) ->
|
||||
{noreply, guild_presence_reconcile:apply_mismatches(Mismatches, State)};
|
||||
handle_info({reconcile_user_presence, UserId}, State) ->
|
||||
handle_info_internal({reconcile_user_presence, UserId}, State) ->
|
||||
{noreply, guild_presence_reconcile:reconcile_user(UserId, State)};
|
||||
handle_info({clear_stale_cached_voice_states, ConnectionIds}, State) ->
|
||||
handle_info_internal({clear_stale_cached_voice_states, ConnectionIds}, State) ->
|
||||
handle_clear_stale_cached_voice_states_info(ConnectionIds, State);
|
||||
handle_info(flush_lazy_subscribe_buffer, State) ->
|
||||
handle_info_internal(flush_lazy_subscribe_buffer, State) ->
|
||||
guild_subscription_handler:handle_info(flush_lazy_subscribe_buffer, State);
|
||||
handle_info(flush_member_list_sync_batch, State) ->
|
||||
handle_info_internal(flush_member_list_sync_batch, State) ->
|
||||
{noreply, guild_member_list:flush_pending_member_list_syncs(State)};
|
||||
handle_info({check_auto_stop_empty, Token}, State) ->
|
||||
handle_info_internal({check_auto_stop_empty, Token}, State) ->
|
||||
handle_auto_stop_info(Token, State);
|
||||
handle_info(check_auto_stop_empty, State) ->
|
||||
handle_info_internal(check_auto_stop_empty, State) ->
|
||||
{noreply, State};
|
||||
handle_info({timeout, TimerRef, member_list_sync_item_cache_rotate}, State) when
|
||||
handle_info_internal({timeout, TimerRef, member_list_sync_item_cache_rotate}, State) when
|
||||
is_reference(TimerRef)
|
||||
->
|
||||
ok = guild_member_list_subscribe:handle_sync_item_cache_timeout(TimerRef),
|
||||
{noreply, State};
|
||||
handle_info(timeout, State) ->
|
||||
handle_info_internal(timeout, State) ->
|
||||
{noreply, State, hibernate};
|
||||
handle_info(_, State) ->
|
||||
handle_info_internal(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec handle_session_connect_call(term(), pid(), guild_state()) -> call_reply().
|
||||
@@ -403,6 +442,10 @@ handle_auto_stop(Token, State) ->
|
||||
|
||||
-spec terminate(term(), guild_state() | term()) -> ok.
|
||||
terminate(Reason, State) when is_map(State) ->
|
||||
safe_cleanup(
|
||||
fun() -> guild_read_model:delete(maps:get(id, State, undefined)) end,
|
||||
"read_model_delete"
|
||||
),
|
||||
safe_cleanup(
|
||||
fun() ->
|
||||
PresenceSubs = presence_subscriptions(State),
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
-type guild_id() :: integer().
|
||||
|
||||
-define(CONNECT_SNAPSHOT_HEAVY_MEMBER_KEYS, [
|
||||
<<"members">>, members_normalized, <<"member_role_index">>, members_sorted_ids
|
||||
<<"members">>,
|
||||
members_normalized,
|
||||
<<"member_role_index">>,
|
||||
members_sorted_ids,
|
||||
member_list_revision
|
||||
]).
|
||||
-define(CONNECT_SNAPSHOT_HEAVY_SESSION_KEYS, [active_guilds, user_roles, viewable_channels]).
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ normalize_map(Data) ->
|
||||
Data0#{
|
||||
<<"members">> => MemberMap,
|
||||
members_normalized => MemberMap,
|
||||
member_list_revision => make_ref(),
|
||||
members_sorted_ids => lists:sort(maps:keys(MemberMap)),
|
||||
<<"roles">> => Roles,
|
||||
<<"channels">> => Channels,
|
||||
@@ -215,4 +216,19 @@ ensure_list_test() ->
|
||||
?assertEqual([1, 2], ensure_list([1, 2])),
|
||||
?assertEqual([], ensure_list(not_a_list)).
|
||||
|
||||
member_list_revision_rotates_on_every_member_writer_test() ->
|
||||
Member = #{<<"user">> => #{<<"id">> => 1}},
|
||||
Data0 = normalize_map(#{<<"members">> => [Member]}),
|
||||
Data1 = normalize_map(Data0),
|
||||
Data2 = put_member(Member#{<<"nick">> => <<"renamed">>}, Data1),
|
||||
Data3 = put_member_map(member_map(Data2), Data2),
|
||||
Data4 = put_member_list(member_list(Data3), Data3),
|
||||
Data5 = remove_member(1, Data4),
|
||||
Revisions = [
|
||||
maps:get(member_list_revision, D)
|
||||
|| D <- [Data0, Data1, Data2, Data3, Data4, Data5]
|
||||
],
|
||||
?assert(lists:all(fun is_reference/1, Revisions)),
|
||||
?assertEqual(length(Revisions), length(lists:usort(Revisions))).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -147,6 +147,7 @@ put_member(Member, Data) when is_map(Member), is_map(Data) ->
|
||||
Data1 = Data#{
|
||||
<<"members">> => NewMemberMap,
|
||||
members_normalized => NewMemberMap,
|
||||
member_list_revision => make_ref(),
|
||||
<<"member_role_index">> => RoleIndex2
|
||||
},
|
||||
invalidate_sorted_member_ids(Data1)
|
||||
@@ -160,6 +161,7 @@ put_member_map(MemberMap, Data) when is_map(MemberMap), is_map(Data) ->
|
||||
Data#{
|
||||
<<"members">> => NormalizedMemberMap,
|
||||
members_normalized => NormalizedMemberMap,
|
||||
member_list_revision => make_ref(),
|
||||
members_sorted_ids => lists:sort(maps:keys(NormalizedMemberMap)),
|
||||
<<"member_role_index">> => build_member_role_index(NormalizedMemberMap)
|
||||
};
|
||||
@@ -184,6 +186,7 @@ remove_member(UserId, Data) when is_integer(UserId), is_map(Data) ->
|
||||
Data1 = Data#{
|
||||
<<"members">> => RemovedMap,
|
||||
members_normalized => RemovedMap,
|
||||
member_list_revision => make_ref(),
|
||||
<<"member_role_index">> => RoleIndex1
|
||||
},
|
||||
invalidate_sorted_member_ids(Data1);
|
||||
|
||||
@@ -158,6 +158,7 @@ named_key_kind(<<"recipient_ids">>) -> drop;
|
||||
named_key_kind(<<"role_index">>) -> drop;
|
||||
named_key_kind(<<"channel_index">>) -> drop;
|
||||
named_key_kind(<<"member_role_index">>) -> drop;
|
||||
named_key_kind(<<"member_list_revision">>) -> drop;
|
||||
named_key_kind(<<"role_perms_cache">>) -> drop;
|
||||
named_key_kind(<<"overwrite_perms_cache">>) -> drop;
|
||||
named_key_kind(_) -> unknown.
|
||||
@@ -267,6 +268,7 @@ sample_keys() ->
|
||||
<<"role_index">>,
|
||||
<<"channel_index">>,
|
||||
<<"member_role_index">>,
|
||||
<<"member_list_revision">>,
|
||||
<<"role_perms_cache">>,
|
||||
<<"overwrite_perms_cache">>,
|
||||
<<"guild_folders">>,
|
||||
@@ -347,6 +349,7 @@ reference_keep_payload_field(Key) ->
|
||||
<<"role_index">>,
|
||||
<<"channel_index">>,
|
||||
<<"member_role_index">>,
|
||||
<<"member_list_revision">>,
|
||||
<<"role_perms_cache">>,
|
||||
<<"overwrite_perms_cache">>
|
||||
]).
|
||||
@@ -520,4 +523,11 @@ reference_path_push(Key, Path) ->
|
||||
reference_has_any_path(Keys, Path) ->
|
||||
lists:any(fun(Key) -> lists:member(Key, Path) end, Keys).
|
||||
|
||||
member_list_revision_is_internal_at_every_depth_test() ->
|
||||
Data = #{
|
||||
member_list_revision => make_ref(),
|
||||
<<"nested">> => [#{<<"member_list_revision">> => make_ref(), <<"id">> => 9}]
|
||||
},
|
||||
?assertEqual(#{<<"nested">> => [#{<<"id">> => <<"9">>}]}, payload(Data)).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -590,7 +590,8 @@ send_compact_scanned_push(MessageData, GuildId, Scan, State) ->
|
||||
|
||||
-spec compact_format_data(map(), map()) -> map().
|
||||
compact_format_data(Data, FormatMembers) ->
|
||||
Data#{
|
||||
WithoutRevision = maps:remove(member_list_revision, Data),
|
||||
WithoutRevision#{
|
||||
<<"members">> => FormatMembers,
|
||||
members_normalized => FormatMembers,
|
||||
members_sorted_ids => lists:sort(maps:keys(FormatMembers)),
|
||||
@@ -1904,9 +1905,14 @@ compact_push_carries_large_guild_metadata_test() ->
|
||||
end.
|
||||
|
||||
compact_format_data_restricts_member_map_test() ->
|
||||
Data = #{<<"guild">> => #{}, <<"members">> => #{1 => #{}, 2 => #{}, 3 => #{}}},
|
||||
Data = #{
|
||||
<<"guild">> => #{},
|
||||
<<"members">> => #{1 => #{}, 2 => #{}, 3 => #{}},
|
||||
member_list_revision => make_ref()
|
||||
},
|
||||
FormatMembers = #{2 => #{<<"roles">> => [<<"200">>]}},
|
||||
Result = compact_format_data(Data, FormatMembers),
|
||||
?assertNot(maps:is_key(member_list_revision, Result)),
|
||||
?assertEqual(FormatMembers, maps:get(<<"members">>, Result)),
|
||||
?assertEqual(FormatMembers, maps:get(members_normalized, Result)),
|
||||
?assertEqual([2], maps:get(members_sorted_ids, Result)),
|
||||
|
||||
@@ -100,6 +100,8 @@ ensure_core_tables() ->
|
||||
{guild_unavailability_cache, RO},
|
||||
{guild_circuit_breaker, RW},
|
||||
{guild_permission_cache, RO},
|
||||
{guild_read_model, RO},
|
||||
{guild_health_status, RO},
|
||||
{voice_update_queue, RW},
|
||||
{voice_update_rate_limit, RW}
|
||||
]).
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(guild_health).
|
||||
-typing([eqwalizer]).
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([
|
||||
start_link/0,
|
||||
is_degraded/1,
|
||||
register_guild/1,
|
||||
put_session/2,
|
||||
remove_session/2,
|
||||
send_current/2
|
||||
]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
||||
|
||||
-define(TABLE, guild_health_status).
|
||||
-define(INTERVAL_MS, 250).
|
||||
-define(DEGRADED_MS, 2000).
|
||||
-define(RECOVERED_MS, 500).
|
||||
-define(REMOTE_LOOKUP_TIMEOUT_MS, 250).
|
||||
|
||||
-spec start_link() -> gen_server:start_ret().
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
||||
|
||||
-spec is_degraded(pid()) -> boolean().
|
||||
is_degraded(Pid) when node(Pid) =:= node() ->
|
||||
case lookup(Pid) of
|
||||
{Pid, _GuildId, Degraded, _Targets, _Pending} -> Degraded;
|
||||
undefined -> false
|
||||
end;
|
||||
is_degraded(Pid) ->
|
||||
try erpc:call(node(Pid), ?MODULE, is_degraded, [Pid], ?REMOTE_LOOKUP_TIMEOUT_MS) of
|
||||
true -> true;
|
||||
_ -> false
|
||||
catch
|
||||
_:_ -> false
|
||||
end.
|
||||
|
||||
-spec register_guild(map()) -> map().
|
||||
register_guild(State) ->
|
||||
case maps:get(id, State, undefined) of
|
||||
GuildId when is_integer(GuildId), GuildId > 0 ->
|
||||
State1 = ensure_targets(State),
|
||||
Targets = maps:get(guild_health_sessions, State1),
|
||||
try ets:insert_new(?TABLE, {self(), GuildId, false, Targets, undefined}) of
|
||||
_ -> gen_server:cast(?MODULE, {register, self()})
|
||||
catch
|
||||
error:badarg -> ok
|
||||
end,
|
||||
State1;
|
||||
_ ->
|
||||
State
|
||||
end.
|
||||
|
||||
-spec ensure_targets(map()) -> map().
|
||||
ensure_targets(#{guild_health_sessions := Tab} = State) ->
|
||||
case table_owner(Tab) of
|
||||
Pid when Pid =:= self() -> State;
|
||||
_ -> ensure_targets(maps:remove(guild_health_sessions, State))
|
||||
end;
|
||||
ensure_targets(State) ->
|
||||
Tab = ets:new(guild_health_sessions, [set, protected, {read_concurrency, true}]),
|
||||
State1 = State#{guild_health_sessions => Tab},
|
||||
maps:foreach(
|
||||
fun(SessionId, _) -> put_session(SessionId, State1) end, maps:get(sessions, State, #{})
|
||||
),
|
||||
State1.
|
||||
|
||||
-spec table_owner(ets:table()) -> pid() | undefined.
|
||||
table_owner(Tab) ->
|
||||
try ets:info(Tab, owner) of
|
||||
Owner -> Owner
|
||||
catch
|
||||
error:badarg -> undefined
|
||||
end.
|
||||
|
||||
-spec put_session(term(), map()) -> ok.
|
||||
put_session(SessionId, #{guild_health_sessions := Tab} = State) ->
|
||||
case maps:get(SessionId, maps:get(sessions, State, #{}), undefined) of
|
||||
#{pid := Pid} when is_pid(Pid) ->
|
||||
ets:insert(Tab, {SessionId, Pid}),
|
||||
ok;
|
||||
_ ->
|
||||
ok
|
||||
end;
|
||||
put_session(_, _) ->
|
||||
ok.
|
||||
|
||||
-spec remove_session(term(), map()) -> ok.
|
||||
remove_session(SessionId, #{guild_health_sessions := Tab}) ->
|
||||
ets:delete(Tab, SessionId),
|
||||
ok;
|
||||
remove_session(_, _) ->
|
||||
ok.
|
||||
|
||||
-spec send_current(pid(), pid()) -> ok.
|
||||
send_current(GuildPid, SessionPid) ->
|
||||
gen_server:cast({?MODULE, node(GuildPid)}, {current, GuildPid, SessionPid}).
|
||||
|
||||
-spec init([]) -> {ok, map()}.
|
||||
init([]) ->
|
||||
ok = guild_ets_owner:ensure_table(?TABLE, [
|
||||
named_table, public, set, {read_concurrency, true}
|
||||
]),
|
||||
State = lists:foldl(
|
||||
fun({Pid, _, _, _, _}, Acc) -> track_guild(Pid, Acc) end, #{}, ets:tab2list(?TABLE)
|
||||
),
|
||||
discover_guilds(),
|
||||
schedule_tick(),
|
||||
{ok, State}.
|
||||
|
||||
-spec handle_call(term(), gen_server:from(), map()) -> {reply, ok, map()}.
|
||||
handle_call(_, _, State) ->
|
||||
{reply, ok, State}.
|
||||
|
||||
-spec handle_cast(term(), map()) -> {noreply, map()}.
|
||||
handle_cast({register, Pid}, State) when is_pid(Pid), node(Pid) =:= node() ->
|
||||
{noreply, track_guild(Pid, State)};
|
||||
handle_cast({pong, Pid, Ref, HandledAt}, State) when is_pid(Pid), is_integer(HandledAt) ->
|
||||
case lookup(Pid) of
|
||||
{Pid, GuildId, Degraded, Targets, {Ref, SentAt}} ->
|
||||
NewDegraded = next_degraded(Degraded, max(0, HandledAt - SentAt)),
|
||||
update({Pid, GuildId, Degraded, Targets, undefined}, NewDegraded),
|
||||
{noreply, State};
|
||||
_ ->
|
||||
{noreply, State}
|
||||
end;
|
||||
handle_cast({current, Pid, SessionPid}, State) when is_pid(Pid), is_pid(SessionPid) ->
|
||||
case lookup(Pid) of
|
||||
{Pid, GuildId, Degraded, _Targets, _Pending} ->
|
||||
notify_session(SessionPid, GuildId, Pid, Degraded);
|
||||
_ ->
|
||||
ok
|
||||
end,
|
||||
{noreply, State};
|
||||
handle_cast(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec handle_info(term(), map()) -> {noreply, map()}.
|
||||
handle_info(tick, State) ->
|
||||
Now = erlang:monotonic_time(millisecond),
|
||||
maps:foreach(fun(Pid, _) -> check_guild(Pid, Now) end, State),
|
||||
schedule_tick(),
|
||||
{noreply, State};
|
||||
handle_info({'DOWN', Ref, process, Pid, _}, State) ->
|
||||
case maps:get(Pid, State, undefined) of
|
||||
Ref ->
|
||||
case lookup(Pid) of
|
||||
{Pid, GuildId, _, _, _} -> guild_read_model:delete(GuildId, Pid);
|
||||
_ -> ok
|
||||
end,
|
||||
ets:delete(?TABLE, Pid),
|
||||
{noreply, maps:remove(Pid, State)};
|
||||
_ ->
|
||||
{noreply, State}
|
||||
end;
|
||||
handle_info(_, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec terminate(term(), map()) -> ok.
|
||||
terminate(_, _) -> ok.
|
||||
|
||||
-spec code_change(term(), map(), term()) -> {ok, map()}.
|
||||
code_change(_, State, _) -> {ok, State}.
|
||||
|
||||
-spec lookup(pid()) -> tuple() | undefined.
|
||||
lookup(Pid) ->
|
||||
try ets:lookup(?TABLE, Pid) of
|
||||
[Entry] -> Entry;
|
||||
[] -> undefined
|
||||
catch
|
||||
error:badarg -> undefined
|
||||
end.
|
||||
|
||||
-spec track_guild(pid(), map()) -> map().
|
||||
track_guild(Pid, State) ->
|
||||
case maps:is_key(Pid, State) orelse lookup(Pid) =:= undefined of
|
||||
true -> State;
|
||||
false -> State#{Pid => erlang:monitor(process, Pid)}
|
||||
end.
|
||||
|
||||
-spec check_guild(pid(), integer()) -> ok.
|
||||
check_guild(Pid, Now) ->
|
||||
case lookup(Pid) of
|
||||
{Pid, GuildId, Degraded, Targets, undefined} ->
|
||||
case should_probe(Pid, Degraded) of
|
||||
true ->
|
||||
Ref = make_ref(),
|
||||
ets:insert(?TABLE, {Pid, GuildId, Degraded, Targets, {Ref, Now}}),
|
||||
Pid ! {guild_health_probe, Ref},
|
||||
ok;
|
||||
false ->
|
||||
ok
|
||||
end;
|
||||
{Pid, _, Degraded, _, {_Ref, SentAt}} = Entry ->
|
||||
update(Entry, Degraded orelse Now - SentAt > ?DEGRADED_MS);
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec should_probe(pid(), boolean()) -> boolean().
|
||||
should_probe(_Pid, true) ->
|
||||
true;
|
||||
should_probe(Pid, false) ->
|
||||
case process_info(Pid, [message_queue_len, status]) of
|
||||
[{message_queue_len, Len}, {status, Status}] -> Len > 0 orelse Status =:= running;
|
||||
undefined -> false
|
||||
end.
|
||||
|
||||
-spec next_degraded(boolean(), non_neg_integer()) -> boolean().
|
||||
next_degraded(_Degraded, Delay) when Delay > ?DEGRADED_MS -> true;
|
||||
next_degraded(_Degraded, Delay) when Delay < ?RECOVERED_MS -> false;
|
||||
next_degraded(Degraded, _Delay) -> Degraded.
|
||||
|
||||
-spec update(tuple(), boolean()) -> ok.
|
||||
update({Pid, GuildId, Previous, Targets, Pending}, Degraded) ->
|
||||
ets:insert(?TABLE, {Pid, GuildId, Degraded, Targets, Pending}),
|
||||
case Previous =/= Degraded of
|
||||
true -> notify_targets(Targets, GuildId, Pid, Degraded);
|
||||
false -> ok
|
||||
end.
|
||||
|
||||
-spec notify_targets(ets:table(), integer(), pid(), boolean()) -> ok.
|
||||
notify_targets(Targets, GuildId, Pid, Degraded) ->
|
||||
try ets:tab2list(Targets) of
|
||||
Rows ->
|
||||
lists:foreach(
|
||||
fun({_SessionId, SessionPid}) ->
|
||||
notify_session(SessionPid, GuildId, Pid, Degraded)
|
||||
end,
|
||||
Rows
|
||||
)
|
||||
catch
|
||||
error:badarg -> ok
|
||||
end.
|
||||
|
||||
-spec notify_session(pid(), integer(), pid(), boolean()) -> ok.
|
||||
notify_session(SessionPid, GuildId, GuildPid, Degraded) ->
|
||||
gen_server:cast(SessionPid, {guild_health, GuildId, GuildPid, Degraded}).
|
||||
|
||||
-spec discover_guilds() -> ok.
|
||||
discover_guilds() ->
|
||||
try ets:tab2list(guild_pid_cache) of
|
||||
Rows ->
|
||||
lists:foreach(
|
||||
fun
|
||||
({_GuildId, Pid}) when is_pid(Pid), node(Pid) =:= node() ->
|
||||
Pid ! guild_health_register;
|
||||
(_) ->
|
||||
ok
|
||||
end,
|
||||
Rows
|
||||
)
|
||||
catch
|
||||
error:badarg -> ok
|
||||
end.
|
||||
|
||||
-spec schedule_tick() -> reference().
|
||||
schedule_tick() -> erlang:send_after(?INTERVAL_MS, self(), tick).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
hysteresis_test() ->
|
||||
?assert(next_degraded(false, 2001)),
|
||||
?assertNot(next_degraded(false, 2000)),
|
||||
?assert(next_degraded(true, 500)),
|
||||
?assertNot(next_degraded(true, 499)),
|
||||
?assert(next_degraded(true, 3000)).
|
||||
|
||||
unreachable_remote_pid_is_unknown_test() ->
|
||||
Pid = binary_to_term(<<131, 88, 119, 12, "fake@nowhere", 1:32, 0:32, 1:32>>),
|
||||
?assertNotEqual(node(), node(Pid)),
|
||||
{ElapsedUs, Result} = timer:tc(?MODULE, is_degraded, [Pid]),
|
||||
?assertNot(Result),
|
||||
?assert(ElapsedUs < 1000000).
|
||||
|
||||
pending_probe_is_bounded_and_recovers_only_after_fresh_reply_test() ->
|
||||
ok = guild_ets_owner:ensure_table(?TABLE, [named_table, public, set]),
|
||||
Targets = ets:new(health_test_targets, [set]),
|
||||
ets:insert(Targets, {<<"session">>, self()}),
|
||||
Pid = spawn(fun() ->
|
||||
receive
|
||||
stop -> ok
|
||||
end
|
||||
end),
|
||||
Entry = {Pid, 42, false, Targets, undefined},
|
||||
ets:insert(?TABLE, Entry),
|
||||
try
|
||||
Pid ! queued_work,
|
||||
check_guild(Pid, 100),
|
||||
{Pid, 42, false, Targets, {Ref, 100}} = lookup(Pid),
|
||||
check_guild(Pid, 2201),
|
||||
?assert(is_degraded(Pid)),
|
||||
receive
|
||||
{'$gen_cast', {guild_health, 42, Pid, true}} -> ok
|
||||
after 100 -> ?assert(false)
|
||||
end,
|
||||
check_guild(Pid, 5000),
|
||||
?assertEqual({message_queue_len, 2}, process_info(Pid, message_queue_len)),
|
||||
{noreply, #{}} = handle_cast({pong, Pid, Ref, 5000}, #{}),
|
||||
?assert(is_degraded(Pid)),
|
||||
check_guild(Pid, 5100),
|
||||
{Pid, 42, true, Targets, {Ref2, 5100}} = lookup(Pid),
|
||||
{noreply, #{}} = handle_cast({pong, Pid, Ref, 5110}, #{}),
|
||||
?assert(is_degraded(Pid)),
|
||||
{noreply, #{}} = handle_cast({pong, Pid, Ref2, 5110}, #{}),
|
||||
?assertNot(is_degraded(Pid)),
|
||||
receive
|
||||
{'$gen_cast', {guild_health, 42, Pid, false}} -> ok
|
||||
after 100 -> ?assert(false)
|
||||
end
|
||||
after
|
||||
Pid ! stop,
|
||||
ets:delete(?TABLE, Pid),
|
||||
ets:delete(Targets)
|
||||
end.
|
||||
|
||||
session_targets_follow_replacement_and_removal_test() ->
|
||||
Tab = ets:new(health_test_targets, [set]),
|
||||
State = #{guild_health_sessions => Tab, sessions => #{<<"s">> => #{pid => self()}}},
|
||||
ok = put_session(<<"s">>, State),
|
||||
?assertEqual([{<<"s">>, self()}], ets:tab2list(Tab)),
|
||||
Other = spawn(fun() ->
|
||||
receive
|
||||
stop -> ok
|
||||
end
|
||||
end),
|
||||
ok = put_session(<<"s">>, State#{sessions => #{<<"s">> => #{pid => Other}}}),
|
||||
?assertEqual([{<<"s">>, Other}], ets:tab2list(Tab)),
|
||||
ok = remove_session(<<"s">>, State),
|
||||
?assertEqual([], ets:tab2list(Tab)),
|
||||
Other ! stop,
|
||||
ets:delete(Tab).
|
||||
|
||||
idle_guild_is_not_probed_test() ->
|
||||
ok = guild_ets_owner:ensure_table(?TABLE, [named_table, public, set]),
|
||||
Targets = ets:new(health_idle_targets, [set]),
|
||||
Parent = self(),
|
||||
Pid = spawn(fun() ->
|
||||
Parent ! {idle, self()},
|
||||
receive
|
||||
stop -> ok
|
||||
end
|
||||
end),
|
||||
receive
|
||||
{idle, Pid} -> ok
|
||||
end,
|
||||
ets:insert(?TABLE, {Pid, 43, false, Targets, undefined}),
|
||||
try
|
||||
check_guild(Pid, 100),
|
||||
check_guild(Pid, 1000),
|
||||
?assertEqual({message_queue_len, 0}, process_info(Pid, message_queue_len))
|
||||
after
|
||||
Pid ! stop,
|
||||
ets:delete(?TABLE, Pid),
|
||||
ets:delete(Targets)
|
||||
end.
|
||||
|
||||
foreign_target_table_is_rebuilt_test() ->
|
||||
Parent = self(),
|
||||
Owner = spawn(fun() ->
|
||||
Tab = ets:new(health_foreign_targets, [set, public]),
|
||||
Parent ! {foreign, Tab},
|
||||
receive
|
||||
stop -> ok
|
||||
end
|
||||
end),
|
||||
receive
|
||||
{foreign, Foreign} ->
|
||||
State = ensure_targets(#{
|
||||
guild_health_sessions => Foreign, sessions => #{<<"s">> => #{pid => self()}}
|
||||
}),
|
||||
Local = maps:get(guild_health_sessions, State),
|
||||
?assertNotEqual(Foreign, Local),
|
||||
?assertEqual(self(), ets:info(Local, owner)),
|
||||
?assertEqual([{<<"s">>, self()}], ets:tab2list(Local)),
|
||||
ets:delete(Local)
|
||||
end,
|
||||
Owner ! stop.
|
||||
|
||||
-endif.
|
||||
@@ -18,7 +18,8 @@
|
||||
get_all_item_keys/1,
|
||||
get_sorted_user_ids/1,
|
||||
index_of/2,
|
||||
is_member_online/2
|
||||
is_member_online/2,
|
||||
version/1
|
||||
]).
|
||||
|
||||
-export([info/1]).
|
||||
@@ -42,6 +43,7 @@ new() ->
|
||||
{hoisted_role_ids, []},
|
||||
{total_count, 0},
|
||||
{online_count, 0},
|
||||
{version, 0},
|
||||
{{section_count, ?ONLINE_IDX}, 0},
|
||||
{{section_count, ?OFFLINE_IDX}, 0}
|
||||
]),
|
||||
@@ -86,7 +88,7 @@ bulk_load(Ref, Members, HoistedRoleIds) ->
|
||||
guild_member_list_oset:from_sorted(OSet, Keys),
|
||||
store_counts(Ref, Total, Online),
|
||||
store_section_counts(Ref, SC),
|
||||
ok.
|
||||
bump_version(Ref).
|
||||
|
||||
-spec bulk_load_member(
|
||||
{integer(), binary(), [integer()], boolean()},
|
||||
@@ -130,7 +132,7 @@ add_member(Ref, UserId, SortKey, RoleIds, IsOnline) ->
|
||||
_ = ets:update_counter(Ref, {section_count, SIdx}, 1),
|
||||
_ = ets:update_counter(Ref, total_count, 1),
|
||||
ok = adjust_online_counter(Ref, IsOnline, 1),
|
||||
ok.
|
||||
bump_version(Ref).
|
||||
|
||||
-spec remove_member(ets:table(), integer()) -> ok.
|
||||
remove_member(Ref, UserId) ->
|
||||
@@ -276,7 +278,7 @@ do_remove(Ref, UserId) ->
|
||||
_ = ets:update_counter(Ref, {section_count, SIdx}, -1),
|
||||
_ = ets:update_counter(Ref, total_count, -1),
|
||||
ok = adjust_online_counter(Ref, IsOnline, -1),
|
||||
ok;
|
||||
bump_version(Ref);
|
||||
[] ->
|
||||
ok
|
||||
end.
|
||||
@@ -326,6 +328,20 @@ move_section(Ref, OSet, ITab, UserId, SortKey, OldSIdx, RoleIds, IsOnline) ->
|
||||
false -> -1
|
||||
end,
|
||||
_ = ets:update_counter(Ref, online_count, OnlineDelta),
|
||||
bump_version(Ref).
|
||||
|
||||
-spec version(ets:table()) -> non_neg_integer() | undefined.
|
||||
version(Ref) ->
|
||||
try ets:lookup_element(Ref, version, 2) of
|
||||
Version when is_integer(Version) -> Version;
|
||||
_ -> undefined
|
||||
catch
|
||||
error:badarg -> undefined
|
||||
end.
|
||||
|
||||
-spec bump_version(ets:table()) -> ok.
|
||||
bump_version(Ref) ->
|
||||
_ = ets:update_counter(Ref, version, 1, {version, 0}),
|
||||
ok.
|
||||
|
||||
-spec adjust_online_counter(ets:table(), boolean(), integer()) -> ok.
|
||||
@@ -410,7 +426,7 @@ do_set_hoisted_roles(Ref, NewHoistedRoleIds) ->
|
||||
{hoisted_role_ids, NewHoistedRoleIds}
|
||||
]),
|
||||
store_section_counts(Ref, SC),
|
||||
ok.
|
||||
bump_version(Ref).
|
||||
|
||||
-spec rebuild_members_into(
|
||||
ets:table(), guild_member_list_oset:oset(), #{integer() => non_neg_integer()}
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
member_list_snapshot/2,
|
||||
snapshot/2,
|
||||
hydrate_engine_items/2,
|
||||
with_member_item_memo/1,
|
||||
note_presence_write/1,
|
||||
get_members_cursor/2
|
||||
]).
|
||||
|
||||
@@ -27,12 +29,73 @@
|
||||
list_id := list_id(),
|
||||
hide_offline := boolean(),
|
||||
member_map := map(),
|
||||
member_revision := term(),
|
||||
presence_context := map(),
|
||||
state := guild_state()
|
||||
}.
|
||||
|
||||
-export_type([guild_state/0, list_id/0, range/0, group_item/0, list_item/0, store_item/0]).
|
||||
|
||||
-define(MEMBER_ITEM_MEMO_KEY, guild_member_list_member_item_memo).
|
||||
-define(PRESENCE_WRITES_KEY, guild_member_list_presence_writes).
|
||||
-define(PRESENCE_WRITES_KEPT, 256).
|
||||
|
||||
-type range_stamp() :: {non_neg_integer(), boolean(), reference(), ets:tid()}.
|
||||
-type range_entry() :: {range_stamp(), non_neg_integer(), map(), [term()], map(), map()}.
|
||||
|
||||
-spec note_presence_write(integer()) -> ok.
|
||||
note_presence_write(UserId) ->
|
||||
{Seq, Kept, Log} = presence_writes(),
|
||||
{Kept1, Log1} =
|
||||
case Kept >= 2 * ?PRESENCE_WRITES_KEPT of
|
||||
true -> {?PRESENCE_WRITES_KEPT, lists:sublist(Log, ?PRESENCE_WRITES_KEPT)};
|
||||
false -> {Kept, Log}
|
||||
end,
|
||||
_ = erlang:put(?PRESENCE_WRITES_KEY, {Seq + 1, Kept1 + 1, [{Seq + 1, UserId} | Log1]}),
|
||||
ok.
|
||||
|
||||
-spec presence_writes() -> {non_neg_integer(), non_neg_integer(), [{pos_integer(), integer()}]}.
|
||||
presence_writes() ->
|
||||
case erlang:get(?PRESENCE_WRITES_KEY) of
|
||||
{Seq, Kept, Log} when is_integer(Seq), is_integer(Kept), is_list(Log) ->
|
||||
{Seq, Kept, Log};
|
||||
_ ->
|
||||
{0, 0, []}
|
||||
end.
|
||||
|
||||
-spec presence_written_since(non_neg_integer()) -> {ok, [integer()]} | stale.
|
||||
presence_written_since(Since) ->
|
||||
{Seq, _Kept, Log} = presence_writes(),
|
||||
case Seq - Since of
|
||||
0 ->
|
||||
{ok, []};
|
||||
Count when Count > 0 ->
|
||||
Written = [
|
||||
UserId
|
||||
|| {WriteSeq, UserId} <- lists:sublist(Log, Count), WriteSeq > Since
|
||||
],
|
||||
case length(Written) =:= Count of
|
||||
true -> {ok, Written};
|
||||
false -> stale
|
||||
end;
|
||||
_ ->
|
||||
stale
|
||||
end.
|
||||
|
||||
-spec with_member_item_memo(fun(() -> T)) -> T.
|
||||
with_member_item_memo(Fun) ->
|
||||
case erlang:get(?MEMBER_ITEM_MEMO_KEY) of
|
||||
undefined ->
|
||||
_ = erlang:put(?MEMBER_ITEM_MEMO_KEY, #{}),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
erlang:erase(?MEMBER_ITEM_MEMO_KEY)
|
||||
end;
|
||||
_ ->
|
||||
Fun()
|
||||
end.
|
||||
|
||||
-spec get_member_groups(list_id(), guild_state()) -> [group_item()].
|
||||
get_member_groups(ListId, State) ->
|
||||
case store_ref_for(ListId, State) of
|
||||
@@ -131,6 +194,7 @@ read_context(ListId, StoreGroups, State) ->
|
||||
list_id => ListId,
|
||||
hide_offline => offline_hidden(StoreGroups),
|
||||
member_map => guild_data_index:member_map(Data),
|
||||
member_revision => maps:get(member_list_revision, Data, undefined),
|
||||
presence_context => guild_member_list_connected:presence_context(State),
|
||||
state => State
|
||||
}.
|
||||
@@ -142,8 +206,156 @@ read_context(ListId, StoreGroups, State) ->
|
||||
read_context()
|
||||
) -> map().
|
||||
range_sync_op_from_context(Ref, {Start, End} = Range, StoreGroups, ReadCtx) ->
|
||||
case range_stamp(Ref, ReadCtx) of
|
||||
undefined ->
|
||||
{_Keys, Op} = build_range_sync_op(Ref, Range, StoreGroups, ReadCtx),
|
||||
Op;
|
||||
Stamp ->
|
||||
Key = {sync_range, Ref, Start, End},
|
||||
Entry = cached_range_entry(Key, Stamp, Ref, Range, StoreGroups, ReadCtx),
|
||||
ok = guild_member_list_subscribe:sync_cache_store(Key, Entry),
|
||||
element(5, Entry)
|
||||
end.
|
||||
|
||||
-spec range_stamp(guild_member_list_store:store_ref(), read_context()) ->
|
||||
range_stamp() | undefined.
|
||||
range_stamp(Ref, #{hide_offline := HideOffline, member_revision := Revision} = ReadCtx) ->
|
||||
#{presence_context := PresenceCtx} = ReadCtx,
|
||||
Presence = maps:get(member_presence, PresenceCtx, undefined),
|
||||
case is_reference(Revision) andalso presence_writes_seen_here(Presence) of
|
||||
true ->
|
||||
case guild_member_list_engine:version(Ref) of
|
||||
Version when is_integer(Version) -> {Version, HideOffline, Revision, Presence};
|
||||
_ -> undefined
|
||||
end;
|
||||
false ->
|
||||
undefined
|
||||
end.
|
||||
|
||||
-spec presence_writes_seen_here(term()) -> boolean().
|
||||
presence_writes_seen_here(Presence) when is_reference(Presence) ->
|
||||
try ets:info(eqwalizer:dynamic_cast(Presence), owner) of
|
||||
Owner -> Owner =:= self()
|
||||
catch
|
||||
_:_ -> false
|
||||
end;
|
||||
presence_writes_seen_here(_) ->
|
||||
false.
|
||||
|
||||
-spec cached_range_entry(
|
||||
term(),
|
||||
range_stamp(),
|
||||
guild_member_list_store:store_ref(),
|
||||
range(),
|
||||
[{binary(), non_neg_integer()}],
|
||||
read_context()
|
||||
) -> range_entry().
|
||||
cached_range_entry(Key, Stamp, Ref, Range, StoreGroups, ReadCtx) ->
|
||||
case guild_member_list_subscribe:sync_cache_find(Key) of
|
||||
{ok, {Stamp0, Seq0, Users, Keys, Op, Connected0}} ->
|
||||
Connected = range_connected_users(Users, ReadCtx),
|
||||
case changed_range_users(Stamp0, Stamp, Seq0, Users, Connected0, Connected) of
|
||||
{ok, Changed} ->
|
||||
patch_range_entry(Stamp, Users, Keys, Op, Changed, Connected, ReadCtx);
|
||||
rebuild ->
|
||||
new_range_entry(Stamp, Ref, Range, StoreGroups, ReadCtx)
|
||||
end;
|
||||
_ ->
|
||||
new_range_entry(Stamp, Ref, Range, StoreGroups, ReadCtx)
|
||||
end.
|
||||
|
||||
-spec changed_range_users(term(), range_stamp(), term(), term(), map(), map()) ->
|
||||
{ok, #{integer() => true}} | rebuild.
|
||||
changed_range_users(Stamp, Stamp, Seq0, Users, Connected0, Connected) when
|
||||
is_integer(Seq0), is_map(Users)
|
||||
->
|
||||
case presence_written_since(Seq0) of
|
||||
{ok, Written} ->
|
||||
Rewritten = maps:from_list([{U, true} || U <- Written, maps:is_key(U, Users)]),
|
||||
Reconnected =
|
||||
case Connected0 =:= Connected of
|
||||
true ->
|
||||
#{};
|
||||
false ->
|
||||
maps:filter(
|
||||
fun(UserId, _) ->
|
||||
maps:is_key(UserId, Connected0) =/=
|
||||
maps:is_key(UserId, Connected)
|
||||
end,
|
||||
Users
|
||||
)
|
||||
end,
|
||||
{ok, maps:merge(Rewritten, Reconnected)};
|
||||
stale ->
|
||||
rebuild
|
||||
end;
|
||||
changed_range_users(_Stamp0, _Stamp, _Seq0, _Users, _Connected0, _Connected) ->
|
||||
rebuild.
|
||||
|
||||
-spec range_connected_users(map(), read_context()) -> map().
|
||||
range_connected_users(Users, #{presence_context := PresenceCtx}) ->
|
||||
Connected = maps:get(connected_user_ids, PresenceCtx, undefined),
|
||||
maps:filter(fun(UserId, _) -> is_connected(UserId, Connected) end, Users).
|
||||
|
||||
-spec is_connected(integer(), term()) -> boolean().
|
||||
is_connected(UserId, Connected) ->
|
||||
try
|
||||
sets:is_element(UserId, eqwalizer:dynamic_cast(Connected))
|
||||
catch
|
||||
_:_ -> false
|
||||
end.
|
||||
|
||||
-spec patch_range_entry(
|
||||
range_stamp(), map(), [term()], map(), #{integer() => true}, map(), read_context()
|
||||
) -> range_entry().
|
||||
patch_range_entry(Stamp, Users, Keys, Op, Changed, Connected, _ReadCtx) when
|
||||
map_size(Changed) =:= 0
|
||||
->
|
||||
{Stamp, presence_seq(), Users, Keys, Op, Connected};
|
||||
patch_range_entry(
|
||||
Stamp, Users, Keys, #{<<"items">> := Items} = Op, Changed, Connected, ReadCtx
|
||||
) ->
|
||||
#{member_map := MemberMap, presence_context := PresenceCtx} = ReadCtx,
|
||||
Patched = lists:zipwith(
|
||||
fun
|
||||
(UserId, _Item) when is_map_key(UserId, Changed) ->
|
||||
{true, New} = hydrate_member_ref(UserId, MemberMap, PresenceCtx),
|
||||
New;
|
||||
(_Key, Item) ->
|
||||
Item
|
||||
end,
|
||||
Keys,
|
||||
Items
|
||||
),
|
||||
{Stamp, presence_seq(), Users, Keys, Op#{<<"items">> => Patched}, Connected}.
|
||||
|
||||
-spec new_range_entry(
|
||||
range_stamp(),
|
||||
guild_member_list_store:store_ref(),
|
||||
range(),
|
||||
[{binary(), non_neg_integer()}],
|
||||
read_context()
|
||||
) -> range_entry().
|
||||
new_range_entry(Stamp, Ref, Range, StoreGroups, ReadCtx) ->
|
||||
Seq = presence_seq(),
|
||||
{Keys, Op} = build_range_sync_op(Ref, Range, StoreGroups, ReadCtx),
|
||||
Users = maps:from_list([{UserId, true} || UserId <- Keys, is_integer(UserId)]),
|
||||
{Stamp, Seq, Users, Keys, Op, range_connected_users(Users, ReadCtx)}.
|
||||
|
||||
-spec presence_seq() -> non_neg_integer().
|
||||
presence_seq() ->
|
||||
element(1, presence_writes()).
|
||||
|
||||
-spec build_range_sync_op(
|
||||
guild_member_list_store:store_ref(),
|
||||
range(),
|
||||
[{binary(), non_neg_integer()}],
|
||||
read_context()
|
||||
) -> {[term()], map()}.
|
||||
build_range_sync_op(Ref, {Start, End} = Range, StoreGroups, ReadCtx) ->
|
||||
StoreItems = store_range_items_from_context(Ref, StoreGroups, Start, End, ReadCtx),
|
||||
sync_op(Range, hydrate_engine_items_from_context(StoreItems, ReadCtx)).
|
||||
Keyed = hydrate_keyed_items_from_context(StoreItems, ReadCtx),
|
||||
{[K || {K, _} <- Keyed], sync_op(Range, [Item || {_, Item} <- Keyed])}.
|
||||
|
||||
-spec store_range_items_from_context(
|
||||
guild_member_list_store:store_ref(),
|
||||
@@ -194,17 +406,34 @@ hydrate_engine_items(ListId, StoreItems, StoreGroups, State) ->
|
||||
HideOffline = offline_hidden(StoreGroups),
|
||||
hydrate_engine_items_with_context(StoreItems, ListId, HideOffline, MemberMap, State).
|
||||
|
||||
-spec hydrate_engine_items_from_context([store_item()], read_context()) -> [list_item()].
|
||||
hydrate_engine_items_from_context(StoreItems, #{
|
||||
-spec hydrate_keyed_items_from_context([store_item()], read_context()) ->
|
||||
[{integer() | group, list_item()}].
|
||||
hydrate_keyed_items_from_context(StoreItems, #{
|
||||
list_id := ListId,
|
||||
hide_offline := HideOffline,
|
||||
member_map := MemberMap,
|
||||
presence_context := PresenceCtx,
|
||||
state := State
|
||||
}) ->
|
||||
hydrate_engine_items_with_context(
|
||||
StoreItems, ListId, HideOffline, MemberMap, PresenceCtx, State
|
||||
).
|
||||
{Keyed, _Section} = lists:foldl(
|
||||
fun(StoreItem, {Acc, Section}) ->
|
||||
case
|
||||
hydrate_store_item(
|
||||
StoreItem, ListId, HideOffline, MemberMap, PresenceCtx, State, {[], Section}
|
||||
)
|
||||
of
|
||||
{[Item], Section1} -> {[{store_item_key(StoreItem), Item} | Acc], Section1};
|
||||
{[], Section1} -> {Acc, Section1}
|
||||
end
|
||||
end,
|
||||
{[], undefined},
|
||||
StoreItems
|
||||
),
|
||||
lists:reverse(Keyed).
|
||||
|
||||
-spec store_item_key(store_item()) -> integer() | group.
|
||||
store_item_key({member, UserId}) -> UserId;
|
||||
store_item_key({group, _Id, _Count}) -> group.
|
||||
|
||||
-spec hydrate_engine_items_with_context(
|
||||
[store_item()], list_id(), boolean(), map(), guild_state()
|
||||
@@ -265,14 +494,39 @@ hydrate_member_ref(UserId, MemberMap, PresenceCtx) ->
|
||||
error ->
|
||||
false;
|
||||
{ok, Member} ->
|
||||
{true, #{
|
||||
<<"member">> =>
|
||||
guild_member_list_connected:add_presence_to_member(
|
||||
Member, UserId, PresenceCtx
|
||||
)
|
||||
}}
|
||||
{true, member_item(UserId, Member, PresenceCtx)}
|
||||
end.
|
||||
|
||||
-spec member_item(integer(), map(), map()) -> list_item().
|
||||
member_item(UserId, Member, PresenceCtx) ->
|
||||
Presence = maps:get(member_presence, PresenceCtx, undefined),
|
||||
case {erlang:get(?MEMBER_ITEM_MEMO_KEY), is_reference(Presence)} of
|
||||
{Items, true} when is_map(Items) ->
|
||||
Connected = is_connected(
|
||||
UserId, maps:get(connected_user_ids, PresenceCtx, undefined)
|
||||
),
|
||||
Stamp = {Presence, Connected, presence_seq()},
|
||||
case maps:find(UserId, Items) of
|
||||
{ok, {Member, Stamp, Item}} ->
|
||||
Item;
|
||||
_ ->
|
||||
Item = build_member_item(UserId, Member, PresenceCtx),
|
||||
_ = erlang:put(?MEMBER_ITEM_MEMO_KEY, Items#{
|
||||
UserId => {Member, Stamp, Item}
|
||||
}),
|
||||
Item
|
||||
end;
|
||||
_ ->
|
||||
build_member_item(UserId, Member, PresenceCtx)
|
||||
end.
|
||||
|
||||
-spec build_member_item(integer(), map(), map()) -> list_item().
|
||||
build_member_item(UserId, Member, PresenceCtx) ->
|
||||
#{
|
||||
<<"member">> =>
|
||||
guild_member_list_connected:add_presence_to_member(Member, UserId, PresenceCtx)
|
||||
}.
|
||||
|
||||
-spec get_members_cursor(map(), guild_state()) -> {reply, map(), guild_state()}.
|
||||
get_members_cursor(Request, State) ->
|
||||
guild_member_list_read_cursor:get_members_cursor(Request, State).
|
||||
@@ -396,4 +650,365 @@ build_sync_response_without_store_still_emits_sync_windows_test() ->
|
||||
maps:get(<<"ops">>, Resp)
|
||||
).
|
||||
|
||||
memo_member(UserId, Username) ->
|
||||
#{<<"user">> => #{<<"id">> => integer_to_binary(UserId), <<"username">> => Username}}.
|
||||
|
||||
memo_presence_ctx(Tab, Connected) ->
|
||||
#{member_presence => Tab, connected_user_ids => sets:from_list(Connected)}.
|
||||
|
||||
with_memo_presence_tab(Fun) ->
|
||||
Tab = ets:new(memo_presence, [set, public]),
|
||||
try
|
||||
true = ets:insert(Tab, [
|
||||
{1, #{<<"status">> => <<"online">>, <<"mobile">> => true, <<"afk">> => false}},
|
||||
{2, #{<<"status">> => <<"idle">>, <<"mobile">> => false, <<"afk">> => true}}
|
||||
]),
|
||||
Fun(Tab)
|
||||
after
|
||||
ets:delete(Tab)
|
||||
end.
|
||||
|
||||
member_item_memo_shares_items_within_scope_test() ->
|
||||
with_memo_presence_tab(fun(Tab) ->
|
||||
MemberMap = #{1 => memo_member(1, <<"one">>), 2 => memo_member(2, <<"two">>)},
|
||||
Ctx = memo_presence_ctx(Tab, [1, 2]),
|
||||
Unscoped1 = hydrate_member_ref(1, MemberMap, Ctx),
|
||||
Unscoped2 = hydrate_member_ref(1, MemberMap, Ctx),
|
||||
?assertNot(erts_debug:same(element(2, Unscoped1), element(2, Unscoped2))),
|
||||
{Scoped1, Scoped2, Other} = with_member_item_memo(fun() ->
|
||||
{
|
||||
hydrate_member_ref(1, MemberMap, Ctx),
|
||||
hydrate_member_ref(1, MemberMap, memo_presence_ctx(Tab, [1, 2])),
|
||||
hydrate_member_ref(2, MemberMap, Ctx)
|
||||
}
|
||||
end),
|
||||
?assertEqual(Unscoped1, Scoped1),
|
||||
?assertEqual(Scoped1, Scoped2),
|
||||
?assert(erts_debug:same(element(2, Scoped1), element(2, Scoped2))),
|
||||
?assertEqual(hydrate_member_ref(2, MemberMap, Ctx), Other),
|
||||
?assertEqual(
|
||||
false, with_member_item_memo(fun() -> hydrate_member_ref(3, MemberMap, Ctx) end)
|
||||
),
|
||||
?assertEqual(undefined, erlang:get(?MEMBER_ITEM_MEMO_KEY))
|
||||
end).
|
||||
|
||||
member_item_memo_rebuilds_changed_member_test() ->
|
||||
with_memo_presence_tab(fun(Tab) ->
|
||||
Ctx = memo_presence_ctx(Tab, [1]),
|
||||
Before = #{1 => memo_member(1, <<"one">>)},
|
||||
After = #{1 => memo_member(1, <<"renamed">>)},
|
||||
{Old, New} = with_member_item_memo(fun() ->
|
||||
{hydrate_member_ref(1, Before, Ctx), hydrate_member_ref(1, After, Ctx)}
|
||||
end),
|
||||
?assertEqual(hydrate_member_ref(1, Before, Ctx), Old),
|
||||
?assertEqual(hydrate_member_ref(1, After, Ctx), New),
|
||||
?assertNotEqual(Old, New)
|
||||
end).
|
||||
|
||||
member_item_memo_rebuilds_on_presence_context_change_test() ->
|
||||
with_memo_presence_tab(fun(Tab) ->
|
||||
MemberMap = #{1 => memo_member(1, <<"one">>)},
|
||||
Connected = memo_presence_ctx(Tab, [1]),
|
||||
Disconnected = memo_presence_ctx(Tab, []),
|
||||
{Online, Offline, OnlineAgain} = with_member_item_memo(fun() ->
|
||||
{
|
||||
hydrate_member_ref(1, MemberMap, Connected),
|
||||
hydrate_member_ref(1, MemberMap, Disconnected),
|
||||
hydrate_member_ref(1, MemberMap, Connected)
|
||||
}
|
||||
end),
|
||||
{true, #{<<"member">> := #{<<"presence">> := OnlinePresence}}} = Online,
|
||||
{true, #{<<"member">> := #{<<"presence">> := OfflinePresence}}} = Offline,
|
||||
?assertEqual(<<"online">>, maps:get(<<"status">>, OnlinePresence)),
|
||||
?assertEqual(true, maps:get(<<"mobile">>, OnlinePresence)),
|
||||
?assertEqual(guild_member_list_connected:default_presence(), OfflinePresence),
|
||||
?assertEqual(Online, OnlineAgain)
|
||||
end).
|
||||
|
||||
member_item_memo_scope_nests_and_clears_on_error_test() ->
|
||||
with_memo_presence_tab(fun(Tab) ->
|
||||
MemberMap = #{1 => memo_member(1, <<"one">>)},
|
||||
Ctx = memo_presence_ctx(Tab, [1]),
|
||||
with_member_item_memo(fun() ->
|
||||
Outer = hydrate_member_ref(1, MemberMap, Ctx),
|
||||
Inner = with_member_item_memo(fun() -> hydrate_member_ref(1, MemberMap, Ctx) end),
|
||||
?assert(erts_debug:same(element(2, Outer), element(2, Inner))),
|
||||
?assertNotEqual(undefined, erlang:get(?MEMBER_ITEM_MEMO_KEY))
|
||||
end),
|
||||
?assertEqual(undefined, erlang:get(?MEMBER_ITEM_MEMO_KEY)),
|
||||
?assertError(boom, with_member_item_memo(fun() -> erlang:error(boom) end)),
|
||||
?assertEqual(undefined, erlang:get(?MEMBER_ITEM_MEMO_KEY))
|
||||
end).
|
||||
|
||||
member_item_memo_sync_responses_match_unscoped_test() ->
|
||||
with_memo_presence_tab(fun(Tab) ->
|
||||
Ref = guild_member_list_engine:new(),
|
||||
Other = guild_member_list_engine:new(),
|
||||
try
|
||||
ok = guild_member_list_engine:bulk_load(
|
||||
Ref, [{1, <<"one">>, [], true}, {2, <<"two">>, [], false}], []
|
||||
),
|
||||
ok = guild_member_list_engine:bulk_load(Other, [{1, <<"one">>, [], true}], []),
|
||||
State = #{
|
||||
id => 9,
|
||||
data => #{
|
||||
<<"members">> => #{
|
||||
1 => memo_member(1, <<"one">>), 2 => memo_member(2, <<"two">>)
|
||||
}
|
||||
},
|
||||
member_presence => Tab,
|
||||
connected_user_ids => sets:from_list([1]),
|
||||
channel_member_list_engines => #{<<"500">> => Ref, <<"600">> => Other}
|
||||
},
|
||||
Build = fun() ->
|
||||
[
|
||||
build_sync_response(9, ListId, [{0, 99}], State)
|
||||
|| ListId <- [<<"500">>, <<"600">>, <<"500">>]
|
||||
]
|
||||
end,
|
||||
?assertEqual(Build(), with_member_item_memo(Build))
|
||||
after
|
||||
guild_member_list_engine:destroy(Ref),
|
||||
guild_member_list_engine:destroy(Other)
|
||||
end
|
||||
end).
|
||||
|
||||
range_presence(Status) ->
|
||||
#{
|
||||
<<"status">> => Status,
|
||||
<<"mobile">> => false,
|
||||
<<"afk">> => false,
|
||||
<<"custom_status">> => null
|
||||
}.
|
||||
|
||||
with_range_world(Fun) ->
|
||||
Tab = ets:new(range_presence, [set, public]),
|
||||
Ref = guild_member_list_engine:new(),
|
||||
_ = erlang:erase(guild_member_list_sync_item_cache),
|
||||
_ = erlang:erase(?PRESENCE_WRITES_KEY),
|
||||
try
|
||||
Users = lists:seq(1, 6),
|
||||
true = ets:insert(Tab, [{U, range_presence(<<"online">>)} || U <- Users]),
|
||||
ok = guild_member_list_engine:bulk_load(
|
||||
Ref,
|
||||
[{U, <<"u", (integer_to_binary(U))/binary>>, [], U =< 4} || U <- Users],
|
||||
[]
|
||||
),
|
||||
State = #{
|
||||
id => 9,
|
||||
data => #{
|
||||
member_list_revision => make_ref(),
|
||||
<<"members">> => maps:from_list([
|
||||
{U, memo_member(U, <<"u", (integer_to_binary(U))/binary>>)}
|
||||
|| U <- Users
|
||||
])
|
||||
},
|
||||
member_presence => Tab,
|
||||
connected_user_ids => sets:from_list([1, 2, 3, 4]),
|
||||
channel_member_list_engines => #{<<"500">> => Ref}
|
||||
},
|
||||
Fun(State, Tab, Ref)
|
||||
after
|
||||
_ = erlang:erase(guild_member_list_sync_item_cache),
|
||||
_ = erlang:erase(?PRESENCE_WRITES_KEY),
|
||||
guild_member_list_engine:destroy(Ref),
|
||||
ets:delete(Tab),
|
||||
flush_range_timers()
|
||||
end.
|
||||
|
||||
flush_range_timers() ->
|
||||
receive
|
||||
{timeout, _, member_list_sync_item_cache_rotate} -> flush_range_timers()
|
||||
after 0 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
range_op(State) ->
|
||||
[Op] = maps:get(<<"ops">>, build_sync_response(9, <<"500">>, [{0, 99}], State)),
|
||||
Op.
|
||||
|
||||
uncached_range_op(State) ->
|
||||
Parent = self(),
|
||||
Pid = spawn(fun() -> Parent ! {range_op, self(), range_op(State)} end),
|
||||
receive
|
||||
{range_op, Pid, Op} -> Op
|
||||
after 5000 ->
|
||||
erlang:error(uncached_range_op_timeout)
|
||||
end.
|
||||
|
||||
range_items_by_user(Op) ->
|
||||
maps:from_list([
|
||||
{maps:get(<<"id">>, maps:get(<<"user">>, M)), I}
|
||||
|| #{<<"member">> := M} = I <- maps:get(<<"items">>, Op)
|
||||
]).
|
||||
|
||||
range_cache_reuses_unchanged_range_test() ->
|
||||
with_range_world(fun(State, _Tab, _Ref) ->
|
||||
First = range_op(State),
|
||||
Second = range_op(State),
|
||||
?assert(erts_debug:same(First, Second)),
|
||||
?assertEqual(uncached_range_op(State), Second),
|
||||
ok = note_presence_write(77),
|
||||
?assert(erts_debug:same(First, range_op(State)))
|
||||
end).
|
||||
|
||||
range_cache_patches_only_the_written_member_test() ->
|
||||
with_range_world(fun(State, Tab, _Ref) ->
|
||||
Before = range_items_by_user(range_op(State)),
|
||||
true = ets:insert(Tab, {2, range_presence(<<"dnd">>)}),
|
||||
ok = note_presence_write(2),
|
||||
Op = range_op(State),
|
||||
?assertEqual(uncached_range_op(State), Op),
|
||||
After = range_items_by_user(Op),
|
||||
?assertNotEqual(maps:get(<<"2">>, Before), maps:get(<<"2">>, After)),
|
||||
[
|
||||
?assert(erts_debug:same(maps:get(Id, Before), maps:get(Id, After)))
|
||||
|| Id <- maps:keys(Before), Id =/= <<"2">>
|
||||
]
|
||||
end).
|
||||
|
||||
range_cache_follows_presence_deletes_test() ->
|
||||
with_range_world(fun(State, Tab, _Ref) ->
|
||||
_ = range_op(State),
|
||||
true = ets:delete(Tab, 3),
|
||||
ok = note_presence_write(3),
|
||||
?assertEqual(uncached_range_op(State), range_op(State))
|
||||
end).
|
||||
|
||||
range_cache_rebuilds_after_engine_change_test() ->
|
||||
with_range_world(fun(State, _Tab, Ref) ->
|
||||
First = range_op(State),
|
||||
ok = guild_member_list_engine:set_online(Ref, 2, false),
|
||||
Op = range_op(State),
|
||||
?assertNotEqual(First, Op),
|
||||
?assertEqual(uncached_range_op(State), Op)
|
||||
end).
|
||||
|
||||
range_cache_patches_connectivity_change_test() ->
|
||||
with_range_world(fun(State, _Tab, _Ref) ->
|
||||
_ = range_op(State),
|
||||
Disconnected = State#{connected_user_ids => sets:from_list([1, 3, 4])},
|
||||
Op = range_op(Disconnected),
|
||||
?assertEqual(uncached_range_op(Disconnected), Op),
|
||||
?assertEqual(uncached_range_op(State), range_op(State))
|
||||
end).
|
||||
|
||||
range_cache_rebuilds_on_member_change_test() ->
|
||||
with_range_world(fun(State, _Tab, _Ref) ->
|
||||
_ = range_op(State),
|
||||
#{data := Data} = State,
|
||||
Renamed = State#{
|
||||
data => guild_data_index:put_member(memo_member(5, <<"renamed">>), Data)
|
||||
},
|
||||
?assertEqual(uncached_range_op(Renamed), range_op(Renamed))
|
||||
end).
|
||||
|
||||
range_cache_rebuilds_when_write_log_overflows_test() ->
|
||||
with_range_world(fun(State, Tab, _Ref) ->
|
||||
_ = range_op(State),
|
||||
true = ets:insert(Tab, {1, range_presence(<<"idle">>)}),
|
||||
ok = note_presence_write(1),
|
||||
[ok = note_presence_write(1000 + N) || N <- lists:seq(1, 3 * ?PRESENCE_WRITES_KEPT)],
|
||||
?assertEqual(stale, presence_written_since(0)),
|
||||
?assertEqual(uncached_range_op(State), range_op(State))
|
||||
end).
|
||||
|
||||
range_cache_is_off_when_disabled_test() ->
|
||||
with_range_world(fun(State, _Tab, _Ref) ->
|
||||
application:set_env(fluxer_gateway, member_list_sync_item_cache_enabled, false),
|
||||
try
|
||||
First = range_op(State),
|
||||
?assertNot(erts_debug:same(First, range_op(State))),
|
||||
?assertEqual(undefined, erlang:get(guild_member_list_sync_item_cache))
|
||||
after
|
||||
application:unset_env(fluxer_gateway, member_list_sync_item_cache_enabled)
|
||||
end
|
||||
end).
|
||||
|
||||
presence_written_since_reports_writes_in_order_test() ->
|
||||
_ = erlang:erase(?PRESENCE_WRITES_KEY),
|
||||
try
|
||||
?assertEqual({ok, []}, presence_written_since(0)),
|
||||
ok = note_presence_write(5),
|
||||
ok = note_presence_write(6),
|
||||
?assertEqual({ok, [6, 5]}, presence_written_since(0)),
|
||||
?assertEqual({ok, [6]}, presence_written_since(1)),
|
||||
?assertEqual({ok, []}, presence_written_since(2)),
|
||||
?assertEqual(stale, presence_written_since(3))
|
||||
after
|
||||
erlang:erase(?PRESENCE_WRITES_KEY)
|
||||
end.
|
||||
|
||||
range_cache_requires_a_member_revision_test() ->
|
||||
with_range_world(fun(State, _Tab, _Ref) ->
|
||||
#{data := Data} = State,
|
||||
lists:foreach(
|
||||
fun(LegacyData) ->
|
||||
Legacy = State#{data => LegacyData},
|
||||
First = range_op(Legacy),
|
||||
?assertEqual(uncached_range_op(Legacy), First),
|
||||
?assertNot(erts_debug:same(First, range_op(Legacy))),
|
||||
?assertEqual(undefined, erlang:get(guild_member_list_sync_item_cache))
|
||||
end,
|
||||
[maps:remove(member_list_revision, Data), Data#{member_list_revision => invalid}]
|
||||
)
|
||||
end).
|
||||
|
||||
range_cache_does_not_retain_unrendered_members_or_connections_test() ->
|
||||
with_range_world(fun(State, _Tab, _Ref) ->
|
||||
#{data := Data} = State,
|
||||
Members = guild_data_index:member_map(Data),
|
||||
SmallData = guild_data_index:put_member_map(Members, Data),
|
||||
Small = State#{data => SmallData},
|
||||
SmallOp = range_op(Small),
|
||||
SmallSize = erts_debug:flat_size(erlang:get(guild_member_list_sync_item_cache)),
|
||||
Extra = maps:from_list([{U, memo_member(U, <<"extra">>)} || U <- lists:seq(7, 5000)]),
|
||||
LargeData = guild_data_index:put_member_map(maps:merge(Members, Extra), SmallData),
|
||||
Large = Small#{
|
||||
data => LargeData,
|
||||
connected_user_ids => sets:from_list([1, 2, 3, 4] ++ lists:seq(7, 5000))
|
||||
},
|
||||
_ = erlang:erase(guild_member_list_sync_item_cache),
|
||||
?assertEqual(SmallOp, range_op(Large)),
|
||||
?assertEqual(
|
||||
SmallSize, erts_debug:flat_size(erlang:get(guild_member_list_sync_item_cache))
|
||||
)
|
||||
end).
|
||||
|
||||
range_cache_does_not_retain_map_backed_presence_test() ->
|
||||
with_range_world(fun(State, Tab, _Ref) ->
|
||||
MapState = State#{member_presence => maps:from_list(ets:tab2list(Tab))},
|
||||
First = range_op(MapState),
|
||||
?assertEqual(uncached_range_op(MapState), First),
|
||||
?assertNot(erts_debug:same(First, range_op(MapState))),
|
||||
?assertEqual(undefined, erlang:get(guild_member_list_sync_item_cache))
|
||||
end).
|
||||
|
||||
member_item_memo_does_not_retain_unrelated_connections_test() ->
|
||||
with_memo_presence_tab(fun(Tab) ->
|
||||
MemberMap = #{1 => memo_member(1, <<"one">>)},
|
||||
Small = memo_presence_ctx(Tab, [1]),
|
||||
Large = memo_presence_ctx(Tab, lists:seq(1, 5000)),
|
||||
with_member_item_memo(fun() ->
|
||||
{true, First} = hydrate_member_ref(1, MemberMap, Small),
|
||||
SmallSize = erts_debug:flat_size(erlang:get(?MEMBER_ITEM_MEMO_KEY)),
|
||||
{true, Second} = hydrate_member_ref(1, MemberMap, Large),
|
||||
?assert(erts_debug:same(First, Second)),
|
||||
?assertEqual(SmallSize, erts_debug:flat_size(erlang:get(?MEMBER_ITEM_MEMO_KEY)))
|
||||
end)
|
||||
end).
|
||||
|
||||
member_item_memo_follows_presence_writes_in_scope_test() ->
|
||||
with_memo_presence_tab(fun(Tab) ->
|
||||
MemberMap = #{1 => memo_member(1, <<"one">>)},
|
||||
Ctx = memo_presence_ctx(Tab, [1]),
|
||||
with_member_item_memo(fun() ->
|
||||
Before = hydrate_member_ref(1, MemberMap, Ctx),
|
||||
true = ets:insert(Tab, {1, range_presence(<<"dnd">>)}),
|
||||
ok = note_presence_write(1),
|
||||
After = hydrate_member_ref(1, MemberMap, Ctx),
|
||||
?assertNotEqual(Before, After)
|
||||
end)
|
||||
end).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
send_member_list_update_to_sessions/5,
|
||||
dispatch_sync_to_subscribed_list/7,
|
||||
dispatch_sync_to_subscribed_sessions/6,
|
||||
handle_sync_item_cache_timeout/1
|
||||
handle_sync_item_cache_timeout/1,
|
||||
sync_cache_find/1,
|
||||
sync_cache_store/2
|
||||
]).
|
||||
|
||||
-type guild_state() :: map().
|
||||
@@ -270,13 +272,37 @@ encode_sync_payload(Payload) ->
|
||||
-spec encode_sync_payload_cached(map(), list()) -> {pre_encoded, binary()}.
|
||||
encode_sync_payload_cached(Payload, Ops) ->
|
||||
{TimerRef, Current, Previous} = sync_item_cache(),
|
||||
{FragmentOps, {Current1, Previous1}} =
|
||||
lists:mapfoldl(fun fragment_op/2, {Current, Previous}, Ops),
|
||||
Key = sync_payload_key(Payload, Ops),
|
||||
{Encoded, {Current1, Previous1}} =
|
||||
case Current of
|
||||
#{Key := {Payload, Cached}} ->
|
||||
{Cached, {Current, Previous}};
|
||||
_ ->
|
||||
encode_sync_payload_fragments(Key, Payload, Ops, {Current, Previous})
|
||||
end,
|
||||
_ = erlang:put(?SYNC_ITEM_CACHE_KEY, {TimerRef, Current1, Previous1}),
|
||||
Encoded.
|
||||
|
||||
-spec encode_sync_payload_fragments(term(), map(), list(), {map(), map()}) ->
|
||||
{{pre_encoded, binary()}, {map(), map()}}.
|
||||
encode_sync_payload_fragments(Key, Payload, Ops, Generations) ->
|
||||
{FragmentOps, Generations1} = lists:mapfoldl(fun fragment_op/2, Generations, Ops),
|
||||
WirePayload = eqwalizer:dynamic_cast(
|
||||
guild_data_wire:payload(Payload#{<<"ops">> => FragmentOps})
|
||||
),
|
||||
{pre_encoded, iolist_to_binary(json:encode(WirePayload, fun encode_fragment_value/2))}.
|
||||
Encoded =
|
||||
{pre_encoded, iolist_to_binary(json:encode(WirePayload, fun encode_fragment_value/2))},
|
||||
{Encoded, cache_fragment(Key, {Payload, Encoded}, Generations1)}.
|
||||
|
||||
-spec sync_payload_key(map(), list()) -> term().
|
||||
sync_payload_key(Payload, Ops) ->
|
||||
{sync_payload, maps:get(<<"id">>, Payload, undefined), [op_range(Op) || Op <- Ops]}.
|
||||
|
||||
-spec op_range(term()) -> term().
|
||||
op_range(#{<<"range">> := Range}) ->
|
||||
Range;
|
||||
op_range(_Op) ->
|
||||
undefined.
|
||||
|
||||
-spec fragment_op(term(), {map(), map()}) -> {term(), {map(), map()}}.
|
||||
fragment_op(#{<<"items">> := Items} = Op, Generations) when is_list(Items) ->
|
||||
@@ -309,8 +335,7 @@ previous_or_encoded_fragment(Id, Item, Previous) ->
|
||||
{json_fragment, iolist_to_binary(json:encode(guild_data_wire:payload(Item)))}
|
||||
end.
|
||||
|
||||
-spec cache_fragment(term(), {term(), {json_fragment, binary()}}, {map(), map()}) ->
|
||||
{map(), map()}.
|
||||
-spec cache_fragment(term(), {term(), term()}, {map(), map()}) -> {map(), map()}.
|
||||
cache_fragment(Id, Entry, {Current, _Previous}) when
|
||||
map_size(Current) >= ?SYNC_ITEM_CACHE_MAX_ENTRIES
|
||||
->
|
||||
@@ -324,6 +349,26 @@ encode_fragment_value({json_fragment, Encoded}, _Encode) ->
|
||||
encode_fragment_value(Value, Encode) ->
|
||||
json:encode_value(Value, Encode).
|
||||
|
||||
-spec sync_cache_find(term()) -> {ok, term()} | error.
|
||||
sync_cache_find(Key) ->
|
||||
case {sync_item_cache_enabled(), erlang:get(?SYNC_ITEM_CACHE_KEY)} of
|
||||
{true, {_TimerRef, #{Key := Value}, _Previous}} -> {ok, Value};
|
||||
{true, {_TimerRef, _Current, #{Key := Value}}} -> {ok, Value};
|
||||
_ -> error
|
||||
end.
|
||||
|
||||
-spec sync_cache_store(term(), term()) -> ok.
|
||||
sync_cache_store(Key, Value) ->
|
||||
case sync_item_cache_enabled() of
|
||||
true ->
|
||||
{TimerRef, Current, Previous} = sync_item_cache(),
|
||||
{Current1, Previous1} = cache_fragment(Key, Value, {Current, Previous}),
|
||||
_ = erlang:put(?SYNC_ITEM_CACHE_KEY, {TimerRef, Current1, Previous1}),
|
||||
ok;
|
||||
false ->
|
||||
ok = erase_sync_item_cache()
|
||||
end.
|
||||
|
||||
-spec sync_item_cache() -> {reference(), map(), map()}.
|
||||
sync_item_cache() ->
|
||||
case erlang:get(?SYNC_ITEM_CACHE_KEY) of
|
||||
@@ -528,6 +573,7 @@ sync_item_cache_keeps_entries_touched_each_generation_test() ->
|
||||
?assertEqual(8, map_size(previous_entries())),
|
||||
assert_matches_uncached(Payload),
|
||||
?assertEqual(8, map_size(current_entries())),
|
||||
?assertEqual(1, map_size(payload_entries())),
|
||||
rotate_now(),
|
||||
?assertEqual(8, map_size(previous_entries())),
|
||||
rotate_now(),
|
||||
@@ -570,6 +616,52 @@ dispatch_sync_group_sends_uncached_bytes_test() ->
|
||||
?assertEqual([Expected, Expected], received_dispatches())
|
||||
end).
|
||||
|
||||
sync_payload_equal_content_reuses_encoded_bytes_test() ->
|
||||
with_clean_cache(fun() ->
|
||||
Members = [test_member(N) || N <- lists:seq(1, 30)],
|
||||
Payload = sync_payload(<<"500">>, [{0, 99}], Members),
|
||||
assert_matches_uncached(Payload),
|
||||
[Key] = maps:keys(payload_entries()),
|
||||
Sentinel = {pre_encoded, <<"sentinel">>},
|
||||
{TimerRef, Current, Previous} = erlang:get(?SYNC_ITEM_CACHE_KEY),
|
||||
_ = erlang:put(
|
||||
?SYNC_ITEM_CACHE_KEY, {TimerRef, Current#{Key => {Payload, Sentinel}}, Previous}
|
||||
),
|
||||
Copy = binary_to_term(term_to_binary(Payload)),
|
||||
?assertEqual(Sentinel, encode_sync_payload(Copy)),
|
||||
[First | Rest] = Members,
|
||||
Afk = put_in(First, [<<"member">>, <<"presence">>, <<"afk">>], true),
|
||||
assert_matches_uncached(sync_payload(<<"500">>, [{0, 99}], [Afk | Rest])),
|
||||
?assertEqual([Key], maps:keys(payload_entries()))
|
||||
end).
|
||||
|
||||
sync_payload_cache_is_keyed_by_list_and_ranges_test() ->
|
||||
with_clean_cache(fun() ->
|
||||
Members = [test_member(N) || N <- lists:seq(1, 40)],
|
||||
Payloads = [
|
||||
sync_payload(<<"500">>, [{0, 99}], Members),
|
||||
sync_payload(<<"600">>, [{0, 99}], Members),
|
||||
sync_payload(<<"500">>, [{0, 19}], Members),
|
||||
sync_payload(<<"500">>, [{0, 19}, {20, 39}], Members)
|
||||
],
|
||||
[assert_matches_uncached(P) || P <- Payloads ++ lists:reverse(Payloads)],
|
||||
?assertEqual(4, map_size(payload_entries())),
|
||||
Header = (hd(Payloads))#{<<"online_count">> => 1},
|
||||
assert_matches_uncached(Header),
|
||||
?assertEqual(4, map_size(payload_entries()))
|
||||
end).
|
||||
|
||||
sync_payload_flag_off_ignores_cached_payload_test() ->
|
||||
with_clean_cache(fun() ->
|
||||
Payload = sync_payload(<<"500">>, [{0, 99}], [test_member(N) || N <- lists:seq(1, 5)]),
|
||||
assert_matches_uncached(Payload),
|
||||
?assertEqual(1, map_size(payload_entries())),
|
||||
with_cache_flag(false, fun() ->
|
||||
assert_matches_uncached(Payload),
|
||||
?assertEqual(#{}, payload_entries())
|
||||
end)
|
||||
end).
|
||||
|
||||
assert_matches_uncached(Payload) ->
|
||||
Expected = uncached(Payload),
|
||||
Actual = encode_sync_payload(Payload),
|
||||
@@ -686,13 +778,22 @@ flush_rotation_timers() ->
|
||||
|
||||
current_entries() ->
|
||||
case erlang:get(?SYNC_ITEM_CACHE_KEY) of
|
||||
{_, Current, _} -> Current;
|
||||
{_, Current, _} -> fragment_entries(Current);
|
||||
_ -> #{}
|
||||
end.
|
||||
|
||||
previous_entries() ->
|
||||
case erlang:get(?SYNC_ITEM_CACHE_KEY) of
|
||||
{_, _, Previous} -> Previous;
|
||||
{_, _, Previous} -> fragment_entries(Previous);
|
||||
_ -> #{}
|
||||
end.
|
||||
|
||||
fragment_entries(Generation) ->
|
||||
maps:filter(fun(Key, _) -> is_binary(Key) end, Generation).
|
||||
|
||||
payload_entries() ->
|
||||
case erlang:get(?SYNC_ITEM_CACHE_KEY) of
|
||||
{_, Current, _} -> maps:filter(fun(Key, _) -> not is_binary(Key) end, Current);
|
||||
_ -> #{}
|
||||
end.
|
||||
|
||||
|
||||
@@ -94,9 +94,11 @@ dispatch_pending_syncs(ListIds, State) ->
|
||||
dispatch_pending_syncs(ListIds, State, SubsTab) ->
|
||||
_ = guild_member_list_write_context:with_guild_id(State, fun(GuildId) ->
|
||||
Sessions = maps:get(sessions, State, #{}),
|
||||
dispatch_pending_syncs_for_guild(
|
||||
GuildId, lists:sort(ListIds), Sessions, State, SubsTab
|
||||
),
|
||||
guild_member_list_read:with_member_item_memo(fun() ->
|
||||
dispatch_pending_syncs_for_guild(
|
||||
GuildId, lists:sort(ListIds), Sessions, State, SubsTab
|
||||
)
|
||||
end),
|
||||
{ok, State}
|
||||
end),
|
||||
State.
|
||||
|
||||
@@ -221,15 +221,17 @@ fold_connection_change_lists(GuildId, UserId, Mark, State, SubsTab) ->
|
||||
dispatch_user_change_to_subscribed_lists(
|
||||
UserId, OldMember, NewMember, SubsTab, State
|
||||
) ->
|
||||
lists:foldl(
|
||||
fun(ListId, AccState) ->
|
||||
dispatch_user_change_to_subscribed_list(
|
||||
UserId, OldMember, NewMember, ListId, AccState
|
||||
)
|
||||
end,
|
||||
State,
|
||||
guild_member_list_subs:list_ids(SubsTab)
|
||||
).
|
||||
guild_member_list_read:with_member_item_memo(fun() ->
|
||||
lists:foldl(
|
||||
fun(ListId, AccState) ->
|
||||
dispatch_user_change_to_subscribed_list(
|
||||
UserId, OldMember, NewMember, ListId, AccState
|
||||
)
|
||||
end,
|
||||
State,
|
||||
guild_member_list_subs:list_ids(SubsTab)
|
||||
)
|
||||
end).
|
||||
|
||||
-spec dispatch_user_change_to_subscribed_list(
|
||||
user_id(),
|
||||
|
||||
@@ -380,6 +380,7 @@ find_member_by_user_id(UserId, State) ->
|
||||
store_member_presence(UserId, PresenceMap, State) ->
|
||||
Tab = maps:get(member_presence, State),
|
||||
ets:insert(Tab, {UserId, PresenceMap}),
|
||||
ok = guild_member_list_read:note_presence_write(UserId),
|
||||
State.
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
@@ -19,21 +19,31 @@
|
||||
-spec call(pid(), {atom(), map()}, pos_integer()) -> term().
|
||||
call(GuildPid, {Tag, Request}, Timeout) ->
|
||||
Deadline = os:system_time(millisecond) + Timeout,
|
||||
gen_server:call(GuildPid, {Tag, Request#{deadline => Deadline}}, Timeout).
|
||||
MonotonicDeadline = erlang:monotonic_time(millisecond) + Timeout,
|
||||
gen_server:call(
|
||||
GuildPid,
|
||||
{Tag, Request#{deadline => Deadline, deadline_monotonic => MonotonicDeadline}},
|
||||
Timeout
|
||||
).
|
||||
|
||||
-spec handle_call(term(), gen_server:from(), guild_state()) ->
|
||||
{reply, term(), guild_state()}
|
||||
| {noreply, guild_state()}.
|
||||
handle_call(Msg, From, State) ->
|
||||
case is_expired(Msg) of
|
||||
case is_expired(Msg, From) of
|
||||
true -> {noreply, State};
|
||||
false -> handle_query(Msg, From, State)
|
||||
end.
|
||||
|
||||
-spec is_expired(term()) -> boolean().
|
||||
is_expired({_Tag, #{deadline := Deadline}}) when is_integer(Deadline) ->
|
||||
os:system_time(millisecond) > Deadline;
|
||||
is_expired(_Msg) ->
|
||||
-spec is_expired(term(), gen_server:from()) -> boolean().
|
||||
is_expired({_Tag, #{deadline_monotonic := Deadline}}, {Caller, _ReplyTag}) when
|
||||
is_integer(Deadline)
|
||||
->
|
||||
case gateway_clock_offset:offset(node(Caller)) of
|
||||
undefined -> false;
|
||||
Offset -> erlang:monotonic_time(millisecond) > Deadline - Offset
|
||||
end;
|
||||
is_expired(_Msg, _From) ->
|
||||
false.
|
||||
|
||||
-spec handle_query(term(), gen_server:from(), guild_state()) ->
|
||||
@@ -455,3 +465,36 @@ resolve_data_payload(#{<<"members">> := _} = State) ->
|
||||
State;
|
||||
resolve_data_payload(_State) ->
|
||||
undefined.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
monotonic_deadline_ignores_the_legacy_wall_clock_test() ->
|
||||
From = {self(), make_ref()},
|
||||
Now = erlang:monotonic_time(millisecond),
|
||||
?assertNot(
|
||||
is_expired({get_data, #{deadline => 0, deadline_monotonic => Now + 5000}}, From)
|
||||
),
|
||||
?assert(
|
||||
is_expired(
|
||||
{get_data, #{deadline => 9999999999999, deadline_monotonic => Now - 1}}, From
|
||||
)
|
||||
),
|
||||
?assertNot(is_expired({get_data, #{deadline => 0}}, From)).
|
||||
|
||||
call_keeps_the_legacy_deadline_and_adds_a_monotonic_deadline_test() ->
|
||||
Guild = spawn(fun() ->
|
||||
receive
|
||||
{'$gen_call', From, {get_data, Request}} -> gen_server:reply(From, Request)
|
||||
end
|
||||
end),
|
||||
WallBefore = os:system_time(millisecond),
|
||||
MonotonicBefore = erlang:monotonic_time(millisecond),
|
||||
#{deadline := WallDeadline, deadline_monotonic := MonotonicDeadline} =
|
||||
call(Guild, {get_data, #{}}, 2000),
|
||||
?assert(WallDeadline >= WallBefore + 2000),
|
||||
?assert(WallDeadline =< os:system_time(millisecond) + 2000),
|
||||
?assert(MonotonicDeadline >= MonotonicBefore + 2000),
|
||||
?assert(MonotonicDeadline =< erlang:monotonic_time(millisecond) + 2000).
|
||||
|
||||
-endif.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(guild_read_model).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([put_state/1, update/2, delete/1, delete/2, query/2]).
|
||||
|
||||
-define(TABLE, guild_read_model).
|
||||
-define(STATE_KEYS, [id, member_count, member_list_engine, virtual_channel_access]).
|
||||
-define(DATA_KEYS, [
|
||||
<<"guild">>,
|
||||
<<"roles">>,
|
||||
<<"channels">>,
|
||||
<<"channel_index">>,
|
||||
<<"emojis">>,
|
||||
<<"stickers">>,
|
||||
role_perms_cache,
|
||||
overwrite_perms_cache,
|
||||
members_ets
|
||||
]).
|
||||
|
||||
-spec put_state(map()) -> ok.
|
||||
put_state(#{id := GuildId, data := Data} = State) when is_integer(GuildId), is_map(Data) ->
|
||||
case maps:get(disable_permission_cache_updates, State, false) of
|
||||
true -> ok;
|
||||
false -> store(GuildId, State, Data)
|
||||
end;
|
||||
put_state(_) ->
|
||||
ok.
|
||||
|
||||
-spec store(integer(), map(), map()) -> ok.
|
||||
store(GuildId, State, Data) ->
|
||||
ok = guild_ets_utils:ensure_table(?TABLE, [
|
||||
named_table, public, set, {read_concurrency, true}
|
||||
]),
|
||||
ReadData = maps:with(?DATA_KEYS, Data),
|
||||
Members =
|
||||
case maps:get(members_ets, Data, undefined) of
|
||||
Tab when is_reference(Tab) -> #{};
|
||||
_ -> guild_data_index:member_map(Data)
|
||||
end,
|
||||
Snapshot = (maps:with(?STATE_KEYS, State))#{
|
||||
data => ReadData#{<<"members">> => Members, members_normalized => Members}
|
||||
},
|
||||
true = ets:insert(?TABLE, {GuildId, self(), Snapshot}),
|
||||
ok.
|
||||
|
||||
-spec update(map(), map()) -> ok.
|
||||
update(OldState, NewState) ->
|
||||
Keys = [data | ?STATE_KEYS],
|
||||
case
|
||||
lists:all(
|
||||
fun(Key) ->
|
||||
erts_debug:same(
|
||||
maps:get(Key, OldState, undefined), maps:get(Key, NewState, undefined)
|
||||
)
|
||||
end,
|
||||
Keys
|
||||
)
|
||||
of
|
||||
true -> ok;
|
||||
false -> put_state(NewState)
|
||||
end.
|
||||
|
||||
-spec delete(term()) -> ok.
|
||||
delete(GuildId) ->
|
||||
delete(GuildId, self()).
|
||||
|
||||
-spec delete(term(), pid()) -> ok.
|
||||
delete(GuildId, Owner) ->
|
||||
try ets:match_delete(?TABLE, {GuildId, Owner, '_'}) of
|
||||
true -> ok
|
||||
catch
|
||||
error:badarg -> ok
|
||||
end.
|
||||
|
||||
-spec query(integer(), {atom(), map()}) -> {ok, map()} | miss.
|
||||
query(GuildId, Request) ->
|
||||
try
|
||||
case ets:lookup(?TABLE, GuildId) of
|
||||
[{GuildId, Owner, Snapshot}] ->
|
||||
case is_process_alive(Owner) of
|
||||
true -> read(Request, Snapshot);
|
||||
false -> miss
|
||||
end;
|
||||
[] ->
|
||||
miss
|
||||
end
|
||||
catch
|
||||
error:badarg -> miss;
|
||||
error:{badmatch, false} -> miss
|
||||
end.
|
||||
|
||||
-spec read({atom(), map()}, map()) -> {ok, map()} | miss.
|
||||
read({Tag, Request}, Snapshot) ->
|
||||
State = materialize_member(maps:get(user_id, Request, null), Snapshot),
|
||||
case Tag of
|
||||
get_guild_data -> reply(guild_data:get_guild_data(Request, State));
|
||||
get_guild_auth_context -> reply(guild_data:get_auth_context(Request, State));
|
||||
get_guild_member -> reply(guild_data:get_guild_member(Request, State));
|
||||
has_member -> reply(guild_data:has_member(Request, State));
|
||||
_ -> miss
|
||||
end.
|
||||
|
||||
-spec materialize_member(integer() | null, map()) -> map().
|
||||
materialize_member(UserId, #{data := #{members_ets := Tab} = Data} = Snapshot) ->
|
||||
MemberCount = ets:info(Tab, size),
|
||||
true = is_integer(MemberCount),
|
||||
Members =
|
||||
case ets:lookup(Tab, UserId) of
|
||||
[{UserId, Member}] -> #{UserId => Member};
|
||||
[] -> #{}
|
||||
end,
|
||||
Snapshot#{
|
||||
member_count => MemberCount,
|
||||
data => Data#{<<"members">> => Members, members_normalized => Members}
|
||||
};
|
||||
materialize_member(_UserId, Snapshot) ->
|
||||
Snapshot.
|
||||
|
||||
-spec reply({reply, map(), map()}) -> {ok, map()}.
|
||||
reply({reply, Reply, _State}) ->
|
||||
{ok, Reply}.
|
||||
@@ -100,19 +100,34 @@ spawn_fetch_worker(Self, Tag, GuildId, GuildPid, UserId) ->
|
||||
|
||||
-spec worker(pid(), reference(), integer(), pid(), integer()) -> ok.
|
||||
worker(Parent, Tag, GuildId, GuildPid, UserId) ->
|
||||
Request = {get_viewer_counts, #{user_id => UserId}},
|
||||
Result =
|
||||
try guild_query_handler:call(GuildPid, Request, ?GUILD_CALL_TIMEOUT_MS) of
|
||||
#{member_count := MemberCount, online_count := OnlineCount} ->
|
||||
{ok, MemberCount, OnlineCount};
|
||||
_ ->
|
||||
error
|
||||
catch
|
||||
_:_ -> error
|
||||
end,
|
||||
Parent ! {Tag, GuildId, Result},
|
||||
Parent ! {Tag, GuildId, fetch_counts(GuildPid, UserId)},
|
||||
ok.
|
||||
|
||||
-spec fetch_counts(pid(), integer()) -> {ok, non_neg_integer(), non_neg_integer()} | error.
|
||||
fetch_counts(GuildPid, UserId) ->
|
||||
Request = {get_viewer_counts, #{user_id => UserId}},
|
||||
try guild_query_handler:call(GuildPid, Request, ?GUILD_CALL_TIMEOUT_MS) of
|
||||
ok -> fetch_legacy_counts(GuildPid, UserId);
|
||||
Reply -> counts_result(Reply)
|
||||
catch
|
||||
_:_ -> error
|
||||
end.
|
||||
|
||||
-spec fetch_legacy_counts(pid(), integer()) ->
|
||||
{ok, non_neg_integer(), non_neg_integer()} | error.
|
||||
fetch_legacy_counts(GuildPid, UserId) ->
|
||||
try gen_server:call(GuildPid, {get_user_counts, UserId}, ?GUILD_CALL_TIMEOUT_MS) of
|
||||
Reply -> counts_result(Reply)
|
||||
catch
|
||||
_:_ -> error
|
||||
end.
|
||||
|
||||
-spec counts_result(term()) -> {ok, non_neg_integer(), non_neg_integer()} | error.
|
||||
counts_result(#{member_count := MemberCount, online_count := OnlineCount}) ->
|
||||
{ok, MemberCount, OnlineCount};
|
||||
counts_result(_) ->
|
||||
error.
|
||||
|
||||
-spec collect_responses(non_neg_integer(), reference(), integer(), [map()]) -> [map()].
|
||||
collect_responses(0, _Tag, _Deadline, Acc) ->
|
||||
lists:reverse(Acc);
|
||||
@@ -254,6 +269,91 @@ handle_request_fetches_viewer_counts_with_deadline_test() ->
|
||||
?assert(false)
|
||||
end.
|
||||
|
||||
legacy_guild(Replies) ->
|
||||
spawn(fun() -> legacy_guild_loop(Replies) end).
|
||||
|
||||
legacy_guild_loop(Replies) ->
|
||||
receive
|
||||
{'$gen_call', From, {get_viewer_counts, #{user_id := 100, deadline := D}}} when
|
||||
is_integer(D)
|
||||
->
|
||||
gen_server:reply(From, ok),
|
||||
legacy_guild_loop(Replies);
|
||||
{'$gen_call', From, {get_user_counts, 100}} ->
|
||||
[Reply | Rest] = Replies,
|
||||
gen_server:reply(From, Reply),
|
||||
legacy_guild_loop(Rest)
|
||||
after 5000 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
request_counts_payload(Guilds) ->
|
||||
Self = self(),
|
||||
SessionState = #{session_pid => Self, user_id => <<"100">>, guilds => Guilds},
|
||||
GuildIds = [integer_to_binary(Id) || Id <- maps:keys(Guilds)],
|
||||
ok = handle_request(#{<<"guild_ids">> => GuildIds}, Self, SessionState),
|
||||
receive
|
||||
{'$gen_cast', {dispatch, guild_counts_update, Payload}} -> Payload
|
||||
after 1000 ->
|
||||
error(no_dispatch)
|
||||
end.
|
||||
|
||||
handle_request_falls_back_to_user_counts_on_legacy_guild_test() ->
|
||||
Guild = legacy_guild([#{member_count => 50, online_count => 10}]),
|
||||
Payload = request_counts_payload(#{7 => {Guild, make_ref()}}),
|
||||
?assertEqual([build_entry(7, 50, 10)], maps:get(<<"counts">>, Payload)).
|
||||
|
||||
handle_request_omits_guild_when_legacy_fallback_fails_test() ->
|
||||
Guild = legacy_guild([ok]),
|
||||
Payload = request_counts_payload(#{7 => {Guild, make_ref()}}),
|
||||
?assertEqual([], maps:get(<<"counts">>, Payload)).
|
||||
|
||||
handle_request_mixes_legacy_and_current_guilds_test() ->
|
||||
Legacy = legacy_guild([#{member_count => 50, online_count => 10}]),
|
||||
Current = spawn(fun() ->
|
||||
receive
|
||||
{'$gen_call', From, {get_viewer_counts, #{user_id := 100}}} ->
|
||||
gen_server:reply(From, #{member_count => 80, online_count => 20})
|
||||
end,
|
||||
receive
|
||||
{'$gen_call', From2, _} -> gen_server:reply(From2, unexpected)
|
||||
after 500 -> ok
|
||||
end
|
||||
end),
|
||||
Payload = request_counts_payload(#{7 => {Legacy, make_ref()}, 9 => {Current, make_ref()}}),
|
||||
?assertEqual(
|
||||
[build_entry(7, 50, 10), build_entry(9, 80, 20)],
|
||||
lists:sort(maps:get(<<"counts">>, Payload))
|
||||
).
|
||||
|
||||
fetch_counts_does_not_fall_back_on_current_guild_test() ->
|
||||
Self = self(),
|
||||
Guild = spawn(fun() ->
|
||||
receive
|
||||
{'$gen_call', From, {get_viewer_counts, #{user_id := 100}}} ->
|
||||
gen_server:reply(From, #{member_count => 3, online_count => 1})
|
||||
end,
|
||||
receive
|
||||
{'$gen_call', From2, Msg} ->
|
||||
Self ! {unexpected_call, Msg},
|
||||
gen_server:reply(From2, ok)
|
||||
after 300 -> ok
|
||||
end
|
||||
end),
|
||||
?assertEqual({ok, 3, 1}, fetch_counts(Guild, 100)),
|
||||
receive
|
||||
{unexpected_call, Msg} -> ?assertEqual(none, Msg)
|
||||
after 400 -> ok
|
||||
end.
|
||||
|
||||
fetch_counts_errors_on_malformed_reply_test() ->
|
||||
Guild = spawn(fun() ->
|
||||
receive
|
||||
{'$gen_call', From, {get_viewer_counts, _}} -> gen_server:reply(From, #{})
|
||||
end
|
||||
end),
|
||||
?assertEqual(error, fetch_counts(Guild, 100)).
|
||||
|
||||
parse_nonce_test() ->
|
||||
?assertEqual(<<"x">>, parse_nonce(<<"x">>)),
|
||||
?assertEqual(<<"abc">>, parse_nonce(<<"abc">>)),
|
||||
|
||||
@@ -351,6 +351,7 @@ remove_session_id(undefined, Sessions) ->
|
||||
|
||||
-spec put_session_ref(session_id(), term(), guild_state()) -> guild_state().
|
||||
put_session_ref(SessionId, Ref, State) when is_reference(Ref) ->
|
||||
ok = guild_health:put_session(SessionId, State),
|
||||
Refs = maps:get(guild_session_refs, State, #{}),
|
||||
State#{guild_session_refs => Refs#{Ref => SessionId}};
|
||||
put_session_ref(_SessionId, _Ref, State) ->
|
||||
@@ -359,6 +360,7 @@ put_session_ref(_SessionId, _Ref, State) ->
|
||||
-spec remove_session_ref(term(), guild_state()) -> guild_state().
|
||||
remove_session_ref(Ref, State) when is_reference(Ref) ->
|
||||
Refs = maps:get(guild_session_refs, State, #{}),
|
||||
ok = guild_health:remove_session(maps:get(Ref, Refs, undefined), State),
|
||||
State#{guild_session_refs => maps:remove(Ref, Refs)};
|
||||
remove_session_ref(_Ref, State) ->
|
||||
State.
|
||||
|
||||
@@ -98,6 +98,7 @@ handle_user_offline(UserId, State) ->
|
||||
remove_member_presence(UserId, State) ->
|
||||
Tab = maps:get(member_presence, State),
|
||||
ets:delete(Tab, UserId),
|
||||
ok = guild_member_list_read:note_presence_write(UserId),
|
||||
State.
|
||||
|
||||
-spec maybe_send_cached_presence(user_id(), guild_state()) -> guild_state().
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
offline_timer => {reference(), reference()} | undefined,
|
||||
guilds => #{guild_id() => guild_ref()},
|
||||
active_guilds => sets:set(guild_id()),
|
||||
guild_health => #{guild_id() => {pid(), boolean()}},
|
||||
calls => #{channel_id() => call_ref()},
|
||||
channels => #{channel_id() => map()},
|
||||
ready => map() | undefined,
|
||||
@@ -161,6 +162,10 @@ handle_cast_presences(Presences, State) ->
|
||||
|
||||
-spec handle_cast_guild_or_lifecycle(term(), session_state()) ->
|
||||
{noreply, session_state()} | {stop, normal, session_state()}.
|
||||
handle_cast_guild_or_lifecycle({guild_health, GuildId, GuildPid, Degraded}, State) when
|
||||
is_integer(GuildId), is_pid(GuildPid), is_boolean(Degraded)
|
||||
->
|
||||
session_guild_health:handle_update(GuildId, GuildPid, Degraded, State);
|
||||
handle_cast_guild_or_lifecycle(handoff_fence, State) ->
|
||||
session_lifecycle:handle_handoff_fence(State);
|
||||
handle_cast_guild_or_lifecycle({reconnect_drain, SocketPid}, State) when
|
||||
|
||||
@@ -459,8 +459,10 @@ finalize_guild_monitor(GuildId, GuildPid, Guilds0, State, ReadyFun) ->
|
||||
session_state()
|
||||
) -> session_result().
|
||||
apply_ready_fun(GuildId, GuildPid, ReadyFun, State) ->
|
||||
case ReadyFun(State) of
|
||||
State1 = session_guild_health:forget(GuildId, State),
|
||||
case ReadyFun(State1) of
|
||||
{noreply, ReadyState} ->
|
||||
ok = guild_health:send_current(GuildPid, self()),
|
||||
ReplayedState = maybe_replay_guild_subscriptions(GuildId, GuildPid, ReadyState),
|
||||
{noreply, session_dm_partners:register_guild(GuildId, GuildPid, ReplayedState)};
|
||||
{stop, normal, ReadyState} ->
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(session_guild_health).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([handle_update/4, dispatch_ready/1, forget/2]).
|
||||
|
||||
-spec handle_update(integer(), pid(), boolean(), map()) -> {noreply, map()}.
|
||||
handle_update(GuildId, GuildPid, Degraded, State) ->
|
||||
case maps:get(GuildId, maps:get(guilds, State, #{}), undefined) of
|
||||
{GuildPid, _Ref} -> apply_update(GuildId, GuildPid, Degraded, State);
|
||||
_ -> {noreply, State}
|
||||
end.
|
||||
|
||||
-spec apply_update(integer(), pid(), boolean(), map()) -> {noreply, map()}.
|
||||
apply_update(GuildId, GuildPid, Degraded, State) ->
|
||||
Health = maps:get(guild_health, State, #{}),
|
||||
Previous =
|
||||
case maps:get(GuildId, Health, undefined) of
|
||||
{_OldPid, Value} -> Value;
|
||||
_ -> false
|
||||
end,
|
||||
State1 = State#{guild_health => Health#{GuildId => {GuildPid, Degraded}}},
|
||||
case Previous =/= Degraded andalso maps:get(ready, State, undefined) =:= undefined of
|
||||
true -> {noreply, dispatch(GuildId, Degraded, State1)};
|
||||
false -> {noreply, State1}
|
||||
end.
|
||||
|
||||
-spec dispatch_ready(map()) -> map().
|
||||
dispatch_ready(State) ->
|
||||
Guilds = maps:get(guilds, State, #{}),
|
||||
maps:fold(
|
||||
fun
|
||||
(GuildId, {Pid, true}, Acc) ->
|
||||
case maps:get(GuildId, Guilds, undefined) of
|
||||
{Pid, _Ref} -> dispatch(GuildId, true, Acc);
|
||||
_ -> Acc
|
||||
end;
|
||||
(_, _, Acc) ->
|
||||
Acc
|
||||
end,
|
||||
State,
|
||||
maps:get(guild_health, State, #{})
|
||||
).
|
||||
|
||||
-spec forget(integer(), map()) -> map().
|
||||
forget(GuildId, State) ->
|
||||
State#{guild_health => maps:remove(GuildId, maps:get(guild_health, State, #{}))}.
|
||||
|
||||
-spec dispatch(integer(), boolean(), map()) -> map().
|
||||
dispatch(GuildId, Degraded, State) ->
|
||||
Payload = #{<<"guild_id">> => integer_to_binary(GuildId), <<"degraded">> => Degraded},
|
||||
{noreply, NewState} = session_dispatch:handle_dispatch(guild_health_update, Payload, State),
|
||||
NewState.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
health_state() ->
|
||||
#{
|
||||
id => <<"health">>,
|
||||
user_id => 100,
|
||||
seq => 0,
|
||||
buffer => [],
|
||||
guilds => #{42 => {self(), make_ref()}},
|
||||
socket_pid => undefined,
|
||||
channels => #{},
|
||||
relationships => #{},
|
||||
ready => undefined,
|
||||
suppress_presence_updates => false,
|
||||
pending_presences => [],
|
||||
presence_pid => undefined,
|
||||
ignored_events => #{},
|
||||
debounce_reactions => false
|
||||
}.
|
||||
|
||||
health_transitions_replay_without_reconnect_test() ->
|
||||
State = health_state(),
|
||||
{noreply, Degraded} = handle_update(42, self(), true, State),
|
||||
{noreply, Duplicate} = handle_update(42, self(), true, Degraded),
|
||||
?assertEqual(1, maps:get(seq, Duplicate)),
|
||||
{noreply, Recovered} = handle_update(42, self(), false, Duplicate),
|
||||
?assertEqual(maps:get(guilds, State), maps:get(guilds, Recovered)),
|
||||
Events = limited_deque:to_list(maps:get(buffer, Recovered)),
|
||||
?assertEqual([guild_health_update, guild_health_update], [maps:get(event, E) || E <- Events]),
|
||||
?assertEqual([true, false], [maps:get(<<"degraded">>, maps:get(data, E)) || E <- Events]).
|
||||
|
||||
health_waits_for_ready_test() ->
|
||||
State = (health_state())#{ready => #{}},
|
||||
{noreply, Degraded} = handle_update(42, self(), true, State),
|
||||
?assertEqual(0, maps:get(seq, Degraded)),
|
||||
Ready = dispatch_ready(Degraded#{ready => undefined}),
|
||||
?assertEqual(1, maps:get(seq, Ready)).
|
||||
|
||||
stale_guild_owner_is_ignored_test() ->
|
||||
Other = spawn(fun() ->
|
||||
receive
|
||||
stop -> ok
|
||||
end
|
||||
end),
|
||||
State = health_state(),
|
||||
?assertEqual({noreply, State}, handle_update(42, Other, true, State)),
|
||||
Other ! stop.
|
||||
|
||||
healthy_reconnect_clears_previous_owner_state_test() ->
|
||||
State = (health_state())#{guild_health => #{42 => {old_owner, true}}},
|
||||
{noreply, Recovered} = handle_update(42, self(), false, State),
|
||||
?assertEqual(1, maps:get(seq, Recovered)),
|
||||
?assertEqual({self(), false}, maps:get(42, maps:get(guild_health, Recovered))).
|
||||
|
||||
same_owner_reconnect_resends_degraded_state_test() ->
|
||||
{noreply, Degraded} = handle_update(42, self(), true, health_state()),
|
||||
Reconnected = forget(42, Degraded),
|
||||
{noreply, Resynced} = handle_update(42, self(), true, Reconnected),
|
||||
?assertEqual(2, maps:get(seq, Resynced)),
|
||||
Events = limited_deque:to_list(maps:get(buffer, Resynced)),
|
||||
?assertEqual([true, true], [maps:get(<<"degraded">>, maps:get(data, E)) || E <- Events]).
|
||||
|
||||
-endif.
|
||||
@@ -24,7 +24,9 @@ handle_guild_leave(GuildId, #{guilds := Guilds} = State) ->
|
||||
DeleteData = #{<<"id">> => integer_to_binary(GuildId)},
|
||||
{noreply, DispatchedState} =
|
||||
session_dispatch:handle_dispatch(guild_delete, DeleteData, State),
|
||||
State1 = DispatchedState#{guilds => maps:remove(GuildId, Guilds)},
|
||||
State1 = session_guild_health:forget(GuildId, DispatchedState#{
|
||||
guilds => maps:remove(GuildId, Guilds)
|
||||
}),
|
||||
{noreply, remove_guild_subscription_state(GuildId, State1)};
|
||||
_ ->
|
||||
{noreply, State}
|
||||
@@ -39,7 +41,10 @@ handle_forced_unavailable_guild_leave(GuildId, UnavailableHidden, #{guilds := Gu
|
||||
{noreply, State1} =
|
||||
session_dispatch:handle_dispatch(guild_delete, GuildDeleteData, State),
|
||||
self() ! {guild_connect, GuildId, 0},
|
||||
{noreply, State1#{guilds => Guilds#{GuildId => cached_unavailable}}}.
|
||||
{noreply,
|
||||
session_guild_health:forget(GuildId, State1#{
|
||||
guilds => Guilds#{GuildId => cached_unavailable}
|
||||
})}.
|
||||
|
||||
-spec demonitor_guild_if_connected(term()) -> ok.
|
||||
demonitor_guild_if_connected({Pid, Ref}) when is_pid(Pid), is_reference(Ref) ->
|
||||
|
||||
@@ -259,6 +259,7 @@ extract_extra_fields(D, Ready) ->
|
||||
collected_sessions => maps:get(collected_sessions, D, []),
|
||||
collected_presences => maps:get(collected_presences, D, []),
|
||||
guild_subscription_state => maps:get(guild_subscription_state, D, #{}),
|
||||
guild_health => maps:get(guild_health, D, #{}),
|
||||
relationships => maps:get(relationships, D, load_relationships(Ready)),
|
||||
suppress_presence_updates => true,
|
||||
pending_presences => [],
|
||||
|
||||
@@ -608,6 +608,7 @@ serialize_state(State) ->
|
||||
collected_guild_states => maps:get(collected_guild_states, State),
|
||||
collected_sessions => maps:get(collected_sessions, State),
|
||||
collected_presences => maps:get(collected_presences, State, []),
|
||||
guild_health => maps:get(guild_health, State, #{}),
|
||||
guild_subscription_state => maps:get(guild_subscription_state, State, #{})
|
||||
}.
|
||||
|
||||
@@ -657,6 +658,7 @@ serialize_transfer_runtime(State) ->
|
||||
collected_guild_states => maps:get(collected_guild_states, State, []),
|
||||
collected_sessions => maps:get(collected_sessions, State, []),
|
||||
collected_presences => maps:get(collected_presences, State, []),
|
||||
guild_health => maps:get(guild_health, State, #{}),
|
||||
guild_subscription_state => maps:get(guild_subscription_state, State, #{})
|
||||
}.
|
||||
|
||||
|
||||
@@ -69,8 +69,9 @@ dispatch_ready_to_socket(State) ->
|
||||
StateAfterGuilds = dispatch_bot_guild_creates(
|
||||
IsBot, CollectedGuilds, Guilds, StateAfterReady
|
||||
),
|
||||
schedule_call_creates(StateAfterGuilds, SessionId),
|
||||
StateAfterPresences = release_pending_presences(StateAfterGuilds, CollectedPresences),
|
||||
StateAfterHealth = session_guild_health:dispatch_ready(StateAfterGuilds),
|
||||
schedule_call_creates(StateAfterHealth, SessionId),
|
||||
StateAfterPresences = release_pending_presences(StateAfterHealth, CollectedPresences),
|
||||
FinalState = StateAfterPresences#{
|
||||
ready => undefined,
|
||||
collected_guild_states => [],
|
||||
|
||||
@@ -87,6 +87,7 @@ guild_event_map() ->
|
||||
<<"GUILD_CREATE">> => guild_create,
|
||||
<<"GUILD_DELETE">> => guild_delete,
|
||||
<<"GUILD_EMOJIS_UPDATE">> => guild_emojis_update,
|
||||
<<"GUILD_HEALTH_UPDATE">> => guild_health_update,
|
||||
<<"GUILD_MEMBER_ADD">> => guild_member_add,
|
||||
<<"GUILD_MEMBER_LIST_UPDATE">> => guild_member_list_update,
|
||||
<<"GUILD_MEMBER_REMOVE">> => guild_member_remove,
|
||||
|
||||
@@ -118,6 +118,7 @@ init_guilds_role_includes_only_guild_state_children_test() ->
|
||||
?assert(lists:member(presence_bus, Ids)),
|
||||
?assert(lists:member(guild_manager, Ids)),
|
||||
?assert(lists:member(guild_counts_cache, Ids)),
|
||||
?assert(lists:member(gateway_clock_offset, Ids)),
|
||||
?assert(lists:member(voice_state_counts_sync, Ids)),
|
||||
?assert(lists:member(gateway_cluster_handoff, Ids)),
|
||||
?assertNot(lists:member(session_manager, Ids)),
|
||||
@@ -137,6 +138,7 @@ init_sessions_role_includes_cluster_handoff_test() ->
|
||||
?assert(lists:member(session_state_transfer, Ids)),
|
||||
?assert(lists:member(gateway_cluster_handoff, Ids)),
|
||||
?assertNot(lists:member(guild_manager, Ids)),
|
||||
?assertNot(lists:member(gateway_clock_offset, Ids)),
|
||||
?assertNot(lists:member(presence_manager, Ids)),
|
||||
persistent_term:erase({fluxer_gateway, runtime_config}).
|
||||
|
||||
|
||||
@@ -5,6 +5,124 @@
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
read_model_preserves_query_results_test() ->
|
||||
State = read_model_state(),
|
||||
try
|
||||
ok = guild_read_model:put_state(State),
|
||||
lists:foreach(
|
||||
fun({Tag, Request, Handler}) ->
|
||||
{reply, Expected, _} = Handler(Request, State),
|
||||
?assertEqual({ok, Expected}, guild_read_model:query(100, {Tag, Request}))
|
||||
end,
|
||||
[
|
||||
{get_guild_data, #{user_id => 200}, fun guild_data:get_guild_data/2},
|
||||
{get_guild_data, #{user_id => 999}, fun guild_data:get_guild_data/2},
|
||||
{get_guild_data, #{user_id => null}, fun guild_data:get_guild_data/2},
|
||||
{get_guild_auth_context, #{user_id => 200, channel_id => 500},
|
||||
fun guild_data:get_auth_context/2},
|
||||
{get_guild_auth_context, #{user_id => 999, channel_id => null},
|
||||
fun guild_data:get_auth_context/2},
|
||||
{get_guild_member, #{user_id => 200}, fun guild_data:get_guild_member/2},
|
||||
{get_guild_member, #{user_id => 999}, fun guild_data:get_guild_member/2},
|
||||
{has_member, #{user_id => 200}, fun guild_data:has_member/2}
|
||||
]
|
||||
)
|
||||
after
|
||||
cleanup_read_model(State)
|
||||
end.
|
||||
|
||||
read_model_uses_live_member_rows_without_copying_members_test() ->
|
||||
State = read_model_state(),
|
||||
#{data := #{members_ets := Tab}} = State,
|
||||
try
|
||||
ok = guild_read_model:put_state(State),
|
||||
[{100, _, #{data := SnapshotData}}] = ets:lookup(guild_read_model, 100),
|
||||
?assertEqual(#{}, maps:get(<<"members">>, SnapshotData)),
|
||||
[{200, Member}] = ets:lookup(Tab, 200),
|
||||
Updated = Member#{
|
||||
<<"nick">> => <<"new nickname">>, <<"communication_disabled_until">> => null
|
||||
},
|
||||
ets:insert(Tab, {200, Updated}),
|
||||
?assertEqual(
|
||||
{ok, #{success => true, member_data => Updated}},
|
||||
guild_read_model:query(100, {get_guild_member, #{user_id => 200}})
|
||||
),
|
||||
ets:delete(Tab, 200),
|
||||
?assertEqual(
|
||||
{ok, #{has_member => false}},
|
||||
guild_read_model:query(100, {has_member, #{user_id => 200}})
|
||||
),
|
||||
?assertMatch(
|
||||
{ok, #{guild_data := null}},
|
||||
guild_read_model:query(100, {get_guild_data, #{user_id => 200}})
|
||||
)
|
||||
after
|
||||
cleanup_read_model(State)
|
||||
end.
|
||||
|
||||
read_model_observes_role_and_collection_changes_test() ->
|
||||
State = read_model_state(),
|
||||
try
|
||||
ok = guild_read_model:put_state(State),
|
||||
Data = maps:get(data, State),
|
||||
UpdatedData = guild_data_index:normalize_map(Data#{
|
||||
<<"roles">> => [#{<<"id">> => 100, <<"permissions">> => 0}],
|
||||
<<"emojis">> => [#{<<"id">> => 700, <<"name">> => <<"new">>}]
|
||||
}),
|
||||
Updated = State#{data => UpdatedData},
|
||||
ok = guild_read_model:update(State, Updated),
|
||||
{reply, Expected, _} = guild_data:get_guild_data(#{user_id => 200}, Updated),
|
||||
?assertEqual(
|
||||
{ok, Expected}, guild_read_model:query(100, {get_guild_data, #{user_id => 200}})
|
||||
),
|
||||
#{guild_data := GuildData} = Expected,
|
||||
?assertEqual([], maps:get(<<"channels">>, GuildData))
|
||||
after
|
||||
cleanup_read_model(State)
|
||||
end.
|
||||
|
||||
read_model_survives_blocked_owner_and_rejects_dead_tables_test() ->
|
||||
State = read_model_state(),
|
||||
Self = self(),
|
||||
Owner = spawn(fun() ->
|
||||
ok = guild_read_model:put_state(State),
|
||||
Self ! published,
|
||||
receive
|
||||
stop -> ok
|
||||
end
|
||||
end),
|
||||
try
|
||||
receive
|
||||
published -> ok
|
||||
after 1000 -> error(publish_timeout)
|
||||
end,
|
||||
?assertMatch(
|
||||
{ok, #{auth_context := #{}}},
|
||||
guild_read_model:query(
|
||||
100, {get_guild_auth_context, #{user_id => 200, channel_id => 500}}
|
||||
)
|
||||
),
|
||||
?assertEqual({message_queue_len, 0}, process_info(Owner, message_queue_len)),
|
||||
ets:delete(maps:get(members_ets, maps:get(data, State))),
|
||||
?assertEqual(miss, guild_read_model:query(100, {has_member, #{user_id => 200}}))
|
||||
after
|
||||
Owner ! stop,
|
||||
cleanup_read_model(State)
|
||||
end.
|
||||
|
||||
read_model_state() ->
|
||||
State = test_state(),
|
||||
Data = guild_data_index:normalize_map(maps:get(data, State)),
|
||||
Members = guild_data_index:member_map(Data),
|
||||
Tab = ets:new(read_model_members, [set, public]),
|
||||
ets:insert(Tab, maps:to_list(Members)),
|
||||
State#{member_count => map_size(Members), data => Data#{members_ets => Tab}}.
|
||||
|
||||
cleanup_read_model(#{data := Data} = State) ->
|
||||
guild_read_model:delete(100),
|
||||
catch ets:delete(maps:get(members_ets, Data)),
|
||||
ets:delete(maps:get(member_presence, State)).
|
||||
|
||||
get_guild_data_membership_gate_test() ->
|
||||
State = test_state(),
|
||||
{reply, Reply1, _} = guild_data:get_guild_data(#{user_id => 999}, State),
|
||||
|
||||
@@ -551,3 +551,53 @@ engine_roles_for_test_member(UserId) when UserId rem 5 =:= 0 ->
|
||||
[20];
|
||||
engine_roles_for_test_member(_UserId) ->
|
||||
[].
|
||||
|
||||
version_bumps_on_every_mutation_test() ->
|
||||
Ref = guild_member_list_engine:new(),
|
||||
try
|
||||
V0 = guild_member_list_engine:version(Ref),
|
||||
ok = guild_member_list_engine:bulk_load(Ref, [{1, <<"a">>, [], true}], []),
|
||||
V1 = guild_member_list_engine:version(Ref),
|
||||
ok = guild_member_list_engine:add_member(Ref, 2, <<"b">>, [], false),
|
||||
V2 = guild_member_list_engine:version(Ref),
|
||||
ok = guild_member_list_engine:update_member(Ref, 2, <<"c">>, [], false),
|
||||
V3 = guild_member_list_engine:version(Ref),
|
||||
ok = guild_member_list_engine:set_online(Ref, 2, true),
|
||||
V4 = guild_member_list_engine:version(Ref),
|
||||
changed = guild_member_list_engine:set_hoisted_roles(Ref, [7]),
|
||||
V5 = guild_member_list_engine:version(Ref),
|
||||
ok = guild_member_list_engine:remove_member(Ref, 1),
|
||||
V6 = guild_member_list_engine:version(Ref),
|
||||
Versions = [V0, V1, V2, V3, V4, V5, V6],
|
||||
?assertEqual(Versions, lists:usort(Versions)),
|
||||
?assertEqual(7, length(lists:usort(Versions)))
|
||||
after
|
||||
guild_member_list_engine:destroy(Ref)
|
||||
end.
|
||||
|
||||
version_is_stable_for_noop_mutations_test() ->
|
||||
Ref = guild_member_list_engine:new(),
|
||||
try
|
||||
ok = guild_member_list_engine:add_member(Ref, 1, <<"a">>, [], true),
|
||||
V = guild_member_list_engine:version(Ref),
|
||||
ok = guild_member_list_engine:set_online(Ref, 1, true),
|
||||
ok = guild_member_list_engine:set_online(Ref, 99, false),
|
||||
ok = guild_member_list_engine:remove_member(Ref, 99),
|
||||
unchanged = guild_member_list_engine:set_hoisted_roles(Ref, []),
|
||||
_ = guild_member_list_engine:get_items(Ref, 0, 10),
|
||||
?assertEqual(V, guild_member_list_engine:version(Ref))
|
||||
after
|
||||
guild_member_list_engine:destroy(Ref)
|
||||
end.
|
||||
|
||||
version_survives_tables_without_a_version_row_test() ->
|
||||
Ref = guild_member_list_engine:new(),
|
||||
try
|
||||
true = ets:delete(Ref, version),
|
||||
?assertEqual(undefined, guild_member_list_engine:version(Ref)),
|
||||
ok = guild_member_list_engine:add_member(Ref, 1, <<"a">>, [], true),
|
||||
?assertEqual(1, guild_member_list_engine:version(Ref))
|
||||
after
|
||||
guild_member_list_engine:destroy(Ref)
|
||||
end,
|
||||
?assertEqual(undefined, guild_member_list_engine:version(Ref)).
|
||||
|
||||
Reference in New Issue
Block a user