mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e177a1c90 | ||
|
|
78471d8a85 |
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user