feat: Add user statuses and cards
This commit is contained in:
166
toju-app/src/app/core/services/user-status.service.ts
Normal file
166
toju-app/src/app/core/services/user-status.service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user