167 lines
5.0 KiB
TypeScript
167 lines
5.0 KiB
TypeScript
import {
|
|
Injectable,
|
|
OnDestroy,
|
|
NgZone,
|
|
inject
|
|
} from '@angular/core';
|
|
import { Store } from '@ngrx/store';
|
|
import { UsersActions } from '../../store/users/users.actions';
|
|
import { selectManualStatus, selectCurrentUser } from '../../store/users/users.selectors';
|
|
import { RealtimeSessionFacade } from '../realtime';
|
|
import { NotificationAudioService } from './notification-audio.service';
|
|
import { UserStatus } from '../../shared-kernel';
|
|
|
|
const BROWSER_IDLE_POLL_MS = 10_000;
|
|
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
|
|
|
|
/**
|
|
* Orchestrates user status based on idle detection (Electron powerMonitor
|
|
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
|
|
*
|
|
* Manual status always takes priority over automatic idle detection.
|
|
* When manual status is cleared, the service falls back to automatic.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class UserStatusService implements OnDestroy {
|
|
private store = inject(Store);
|
|
private zone = inject(NgZone);
|
|
private webrtc = inject(RealtimeSessionFacade);
|
|
private audio = inject(NotificationAudioService);
|
|
|
|
private electronCleanup: (() => void) | null = null;
|
|
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
private lastActivityTimestamp = Date.now();
|
|
private browserActivityListeners: (() => void)[] = [];
|
|
private currentAutoStatus: UserStatus = 'online';
|
|
private started = false;
|
|
|
|
start(): void {
|
|
if (this.started)
|
|
return;
|
|
|
|
this.started = true;
|
|
|
|
if ((window as any).electronAPI?.onIdleStateChanged) {
|
|
this.startElectronIdleDetection();
|
|
} else {
|
|
this.startBrowserIdleDetection();
|
|
}
|
|
}
|
|
|
|
/** Set a manual status override (e.g. DND = 'busy'). Pass `null` to clear. */
|
|
setManualStatus(status: UserStatus | null): void {
|
|
this.store.dispatch(UsersActions.setManualStatus({ status }));
|
|
this.audio.dndMuted.set(status === 'busy');
|
|
this.broadcastStatus(this.resolveEffectiveStatus(status));
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.cleanup();
|
|
}
|
|
|
|
private cleanup(): void {
|
|
this.electronCleanup?.();
|
|
this.electronCleanup = null;
|
|
|
|
if (this.browserPollTimer) {
|
|
clearInterval(this.browserPollTimer);
|
|
this.browserPollTimer = null;
|
|
}
|
|
|
|
for (const remove of this.browserActivityListeners) {
|
|
remove();
|
|
}
|
|
|
|
this.browserActivityListeners = [];
|
|
this.started = false;
|
|
}
|
|
|
|
private startElectronIdleDetection(): void {
|
|
const api = (window as { electronAPI?: {
|
|
onIdleStateChanged: (cb: (state: 'active' | 'idle') => void) => () => void;
|
|
getIdleState: () => Promise<'active' | 'idle'>;
|
|
}; }).electronAPI!;
|
|
|
|
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
|
|
this.zone.run(() => {
|
|
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
|
|
this.applyAutoStatusIfAllowed();
|
|
});
|
|
});
|
|
|
|
// Check initial state
|
|
api.getIdleState().then((idleState: 'active' | 'idle') => {
|
|
this.zone.run(() => {
|
|
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
|
|
this.applyAutoStatusIfAllowed();
|
|
});
|
|
});
|
|
}
|
|
|
|
private startBrowserIdleDetection(): void {
|
|
this.lastActivityTimestamp = Date.now();
|
|
|
|
const onActivity = () => {
|
|
this.lastActivityTimestamp = Date.now();
|
|
const wasAway = this.currentAutoStatus === 'away';
|
|
|
|
if (wasAway) {
|
|
this.currentAutoStatus = 'online';
|
|
this.zone.run(() => this.applyAutoStatusIfAllowed());
|
|
}
|
|
};
|
|
const events = [
|
|
'mousemove',
|
|
'keydown',
|
|
'mousedown',
|
|
'touchstart',
|
|
'scroll'
|
|
] as const;
|
|
|
|
for (const evt of events) {
|
|
document.addEventListener(evt, onActivity, { passive: true });
|
|
this.browserActivityListeners.push(() =>
|
|
document.removeEventListener(evt, onActivity)
|
|
);
|
|
}
|
|
|
|
this.zone.runOutsideAngular(() => {
|
|
this.browserPollTimer = setInterval(() => {
|
|
const idle = Date.now() - this.lastActivityTimestamp >= BROWSER_IDLE_THRESHOLD_MS;
|
|
|
|
if (idle && this.currentAutoStatus !== 'away') {
|
|
this.currentAutoStatus = 'away';
|
|
this.zone.run(() => this.applyAutoStatusIfAllowed());
|
|
}
|
|
}, BROWSER_IDLE_POLL_MS);
|
|
});
|
|
}
|
|
|
|
private applyAutoStatusIfAllowed(): void {
|
|
const manualStatus = this.store.selectSignal(selectManualStatus)();
|
|
|
|
// Manual status overrides automatic
|
|
if (manualStatus)
|
|
return;
|
|
|
|
const currentUser = this.store.selectSignal(selectCurrentUser)();
|
|
|
|
if (currentUser?.status !== this.currentAutoStatus) {
|
|
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
|
|
this.store.dispatch(UsersActions.updateCurrentUser({ updates: { status: this.currentAutoStatus } }));
|
|
this.broadcastStatus(this.currentAutoStatus);
|
|
}
|
|
}
|
|
|
|
private resolveEffectiveStatus(manualStatus: UserStatus | null): UserStatus {
|
|
return manualStatus ?? this.currentAutoStatus;
|
|
}
|
|
|
|
private broadcastStatus(status: UserStatus): void {
|
|
this.webrtc.sendRawMessage({
|
|
type: 'status_update',
|
|
status
|
|
});
|
|
}
|
|
}
|