Compare commits

...
11 changed files with 116 additions and 26 deletions

No files matched your search

@@ -39,6 +39,7 @@ import {
} from '@app/features/gateway/transport/GatewaySocket';
import SessionManager, {type Account} from '@app/features/platform/state/AuthSession';
import {Logger} from '@app/features/platform/utils/AppLogger';
import Idle from '@app/features/ui/state/Idle';
import {DEFAULT_API_VERSION} from '@fluxer/constants/src/AppConstants';
import {GatewayCloseCodes, GatewayIdentifyFlags} from '@fluxer/constants/src/GatewayConstants';
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
@@ -299,7 +300,8 @@ export class BackgroundGatewaySession {
}
refreshPresence(): void {
this.socket?.updatePresence(this.identifyPresence().status, false, false);
const presence = this.identifyPresence();
this.socket?.updatePresence(presence.status, presence.afk, presence.mobile);
}
probeAfterResume(): void {
@@ -309,7 +311,7 @@ export class BackgroundGatewaySession {
private identifyPresence(): GatewayPresence {
return {
status: BackgroundAccountPresence.appearOffline ? StatusTypes.INVISIBLE : StatusTypes.ONLINE,
afk: false,
afk: Idle.isAfk(),
mobile: false,
};
}
@@ -19,6 +19,7 @@ import {AccountScopedWork} from '@app/features/platform/state/AccountScopedWork'
import SessionManager, {type Account} from '@app/features/platform/state/AuthSession';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {getGatewayClientProperties} from '@app/features/platform/utils/ClientInfo';
import Idle from '@app/features/ui/state/Idle';
import {compareStructural, reaction} from 'mobx';
const logger = new Logger('BackgroundGatewaySessionManager');
@@ -91,12 +92,13 @@ export class BackgroundGatewaySessionManager {
this.disposers.push(
installDeferredReaction(() =>
reaction(
() => BackgroundAccountPresence.appearOffline,
() => [BackgroundAccountPresence.appearOffline, Idle.isAfk()],
() => {
for (const session of BackgroundGatewaySessions.values()) {
session.refreshPresence();
}
},
{equals: compareStructural},
),
),
);
@@ -16,7 +16,7 @@ import {
MAX_DEFERRED_GATEWAY_EMIT_BYTES,
MAX_DEFERRED_GATEWAY_EMITS,
} from '@app/features/gateway/transport/GatewaySocket';
import {GatewayCloseCodes} from '@fluxer/constants/src/GatewayConstants';
import {GatewayCloseCodes, GatewayOpcodes} from '@fluxer/constants/src/GatewayConstants';
import {initSync} from '@pkgs/libfluxcore/libfluxcore';
import {afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
@@ -45,6 +45,7 @@ interface SocketInternals {
openSocket: () => void;
handleSocketMessage: (event: {data: string | ArrayBuffer}) => Promise<void>;
handleSocketClose: (event: {code: number; reason: string; wasClean: boolean}) => void;
sendIdentify: () => void;
}
function internals(socket: GatewaySocket): SocketInternals {
@@ -150,6 +151,39 @@ describe('voice state payloads', () => {
});
});
function presenceFrames(sent: Array<string>): Array<unknown> {
return sent
.map((frame) => JSON.parse(frame) as {op: number; d: unknown})
.filter((payload) => payload.op === GatewayOpcodes.PRESENCE_UPDATE || payload.op === GatewayOpcodes.IDENTIFY)
.map((payload) =>
payload.op === GatewayOpcodes.IDENTIFY ? (payload.d as {presence: unknown}).presence : payload.d,
);
}
describe('presence across reconnects', () => {
test('resends the presence dropped while disconnected once the session resumes', async () => {
const socket = createSocket({presence: {status: 'online', afk: false, mobile: false}});
const sent = attachOpenTransport(socket);
socket.updatePresence('online', true, false, null);
expect(sent).toHaveLength(0);
await internals(socket).handleSocketMessage({data: JSON.stringify({op: 0, t: 'RESUMED', s: 1, d: {}})});
expect(presenceFrames(sent)).toEqual([{status: 'online', afk: true, mobile: false, custom_status: null}]);
});
test('identifies with the latest presence instead of the one it was created with', () => {
const socket = createSocket({presence: {status: 'online', afk: false, mobile: false}});
const sent = attachOpenTransport(socket);
socket.updatePresence('dnd', true, false);
internals(socket).sendIdentify();
expect(presenceFrames(sent)).toEqual([{status: 'dnd', afk: true, mobile: false}]);
});
});
describe('dispatch delivery', () => {
test('carries the event, the retained byte size and an active receipt', async () => {
const socket = createSocket();
@@ -581,6 +581,13 @@ export class GatewaySocket extends EventEmitter<GatewaySocketEvents> {
mobile?: boolean,
customStatus?: GatewayCustomStatusPayload | null,
): void {
const current = this.options.presence;
this.options.presence = {
status,
afk: afk ?? current?.afk ?? false,
mobile: mobile ?? current?.mobile ?? false,
custom_status: customStatus === undefined ? current?.custom_status : customStatus,
};
if (!this.isConnected()) return;
this.sendPayload({
op: GatewayOpcodes.PRESENCE_UPDATE,
@@ -593,6 +600,12 @@ export class GatewaySocket extends EventEmitter<GatewaySocketEvents> {
});
}
private resendPresence(): void {
const presence = this.options.presence;
if (!presence) return;
this.sendPayload({op: GatewayOpcodes.PRESENCE_UPDATE, d: presence});
}
private buildVoiceStatePayload(params: GatewayVoiceStateUpdateParams): GatewayPayload {
const {latitude, longitude} = this.options.geo();
return {
@@ -986,6 +999,7 @@ export class GatewaySocket extends EventEmitter<GatewaySocketEvents> {
this.updateState(GatewayState.Connected);
this.resetBackoffUnlessDispatchesKeepFailing();
this.log.info('Gateway session resumed');
this.resendPresence();
this.logDispatchTimings('RESUMED', payload.d);
this.emitDeferred('resumed', payload.d);
break;
@@ -55,6 +55,10 @@ class LocalPresence {
() => Idle.isIdle(),
() => this.updatePresence(),
);
reaction(
() => Idle.isAfk(),
() => this.updatePresence(),
);
});
}
@@ -76,7 +80,7 @@ class LocalPresence {
this.restoredIntent = null;
const userStatus = settings.status;
const idleSince = Idle.getIdleSince();
const afk = this.computeAfk(idleSince, isMobile, settings);
const afk = this.syncAfk(isMobile, settings);
const effectiveStatus = userStatus === StatusTypes.ONLINE && idleSince > 0 ? StatusTypes.IDLE : userStatus;
const normalizedCustomStatus = normalizeCustomStatus(settings.getCustomStatus());
this.customStatus = normalizedCustomStatus ? {...normalizedCustomStatus} : null;
@@ -142,10 +146,9 @@ class LocalPresence {
return `hydrated:${hydrated}|${this.status}|${customStatusToKey(this.customStatus)}|afk:${afk}|mobile:${mobile}`;
}
private computeAfk(idleSince: number, isMobile: boolean, settings: LocalPresenceUserSettings | null): boolean {
if (isMobile || idleSince <= 0) return false;
const afkTimeout = settings?.getAfkTimeout() ?? 600;
return Date.now() - idleSince > afkTimeout * 1000;
private syncAfk(isMobile: boolean, settings: LocalPresenceUserSettings | null): boolean {
Idle.setAfkTimeoutMs((settings?.getAfkTimeout() ?? 600) * 1000);
return !isMobile && Idle.isAfk();
}
private applyRestoredIntent(isMobile: boolean, settings: LocalPresenceUserSettings | null): void {
@@ -158,7 +161,7 @@ class LocalPresence {
this.customStatus = this.restoredIntent.customStatus ? {...this.restoredIntent.customStatus} : null;
this.status = effectiveStatus;
this.since = idleSince;
this.afk = this.computeAfk(idleSince, isMobile, settings);
this.afk = this.syncAfk(isMobile, settings);
this.mobile = isMobile;
}
+22 -2
View File
@@ -19,12 +19,12 @@ vi.mock('@app/features/user/state/CustomStatus', () => ({
toGatewayCustomStatus: () => null,
}));
function hydratedOnlineSettings() {
function hydratedOnlineSettings(afkTimeoutSeconds = 600) {
return {
status: StatusTypes.ONLINE,
isHydrated: () => true,
markSessionChanging: () => {},
getAfkTimeout: () => 600,
getAfkTimeout: () => afkTimeoutSeconds,
getCustomStatus: () => null,
getStatusResetsAt: () => null,
getStatusResetsTo: () => null,
@@ -72,4 +72,24 @@ describe('Idle', () => {
expect(LocalPresence.getStatus()).toBe(StatusTypes.ONLINE);
expect(LocalPresence.getPresence().since).toBe(0);
});
it('reports afk once the user afk timeout passes', async () => {
const {default: LocalPresence, setLocalPresenceUserSettings} = await import(
'@app/features/presence/state/LocalPresence'
);
const {default: Idle} = await import('@app/features/ui/state/Idle');
setLocalPresenceUserSettings(hydratedOnlineSettings(60));
LocalPresence.updatePresence();
await vi.advanceTimersByTimeAsync(0);
expect(LocalPresence.getPresence().afk).toBe(false);
await vi.advanceTimersByTimeAsync(45 * 1000);
expect(LocalPresence.getPresence().afk).toBe(false);
await vi.advanceTimersByTimeAsync(45 * 1000);
expect(LocalPresence.getPresence().afk).toBe(true);
Idle.recordActivity();
expect(LocalPresence.getPresence().afk).toBe(false);
});
});
+17 -7
View File
@@ -29,6 +29,8 @@ function normalizeIdleTimeMs(value: number): number | null {
class Idle {
idle = false;
afk = false;
private afkThresholdMs = IDLE_DURATION_MS;
private lastLocalActivityTime = Date.now();
private lastSystemActivityTime = 0;
private checkInterval: NodeJS.Timeout | null = null;
@@ -56,15 +58,23 @@ class Idle {
recordActivity(): void {
this.lastLocalActivityTime = Date.now();
if (this.idle) {
this.applyIdleState(false);
}
this.applyInactiveDuration(0);
}
isIdle(): boolean {
return this.idle;
}
isAfk(): boolean {
return this.afk;
}
setAfkTimeoutMs(afkThresholdMs: number): void {
if (afkThresholdMs === this.afkThresholdMs) return;
this.afkThresholdMs = afkThresholdMs;
this.updateIdleStateFromLocalActivity();
}
getIdleSince(): number {
return this.idle ? this.getLastActivityTime() : 0;
}
@@ -91,8 +101,7 @@ class Idle {
}
private updateIdleStateFromLocalActivity(): void {
const now = Date.now();
this.applyIdleState(this.getInactiveDurationMs(now) >= IDLE_DURATION_MS);
this.applyInactiveDuration(this.getInactiveDurationMs());
}
private async updateIdleStateFromSystem(desktopIdleApi: Required<DesktopIdleApi>): Promise<void> {
@@ -112,8 +121,9 @@ class Idle {
this.updateIdleStateFromLocalActivity();
}
private applyIdleState(idle: boolean): void {
this.idle = idle;
private applyInactiveDuration(inactiveDurationMs: number): void {
this.idle = inactiveDurationMs >= IDLE_DURATION_MS;
this.afk = inactiveDurationMs >= this.afkThresholdMs;
}
}
@@ -651,6 +651,7 @@ class UserSettingsState {
const {hydrate = true} = options;
const previousStatus = this.status;
const previousCustomStatus = this.customStatus;
const previousAfkTimeout = this.afkTimeout;
const wasHydrated = this.hydrated;
if (userSettings === null || userSettings === undefined) {
return;
@@ -744,13 +745,11 @@ class UserSettingsState {
) {
this.mergeIncomingSyncedPreferences(decodeSyncedPreferencesLenient(incomingSyncedPreferences));
}
if (normalizedStatus !== previousStatus) {
const presence = LocalPresence.getPresence();
if (!presence.afk) {
LocalPresence.updatePresence();
}
}
if (!isEqual(this.customStatus, previousCustomStatus)) {
if (
normalizedStatus !== previousStatus ||
!isEqual(this.customStatus, previousCustomStatus) ||
this.afkTimeout !== previousAfkTimeout
) {
LocalPresence.updatePresence();
}
if (hydrate && !wasHydrated) {
@@ -40,6 +40,8 @@ export function useWindowEventListeners(): void {
window.addEventListener('mousedown', handleImmediateActivity);
window.addEventListener('keydown', handleUserActivity);
window.addEventListener('input', handleUserActivity);
window.addEventListener('pointermove', handleUserActivity, {passive: true});
window.addEventListener('wheel', handleUserActivity, {passive: true});
window.addEventListener('resize', handleResize);
window.addEventListener('touchstart', handleImmediateActivity);
document.addEventListener('touchstart', preventPinchZoom, {passive: false});
@@ -52,6 +54,8 @@ export function useWindowEventListeners(): void {
window.removeEventListener('mousedown', handleImmediateActivity);
window.removeEventListener('keydown', handleUserActivity);
window.removeEventListener('input', handleUserActivity);
window.removeEventListener('pointermove', handleUserActivity);
window.removeEventListener('wheel', handleUserActivity);
window.removeEventListener('resize', handleResize);
window.removeEventListener('touchstart', handleImmediateActivity);
document.removeEventListener('touchstart', preventPinchZoom);
@@ -24,6 +24,7 @@ finish-args:
- --talk-name=org.ayatana.indicator.application
- --talk-name=com.canonical.indicator.application
- --talk-name=org.freedesktop.secrets
- --talk-name=org.gnome.Mutter.IdleMonitor
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
build-options:
strip: false
@@ -24,6 +24,7 @@ finish-args:
- --talk-name=org.ayatana.indicator.application
- --talk-name=com.canonical.indicator.application
- --talk-name=org.freedesktop.secrets
- --talk-name=org.gnome.Mutter.IdleMonitor
- --env=XCURSOR_PATH=/run/host/user-share/icons:/run/host/share/icons
build-options:
strip: false