feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 23s
Deploy Web Apps / deploy (push) Successful in 5m54s
Queue Release Build / build-windows (push) Successful in 16m19s
Queue Release Build / build-linux (push) Successful in 30m13s
Queue Release Build / finalize (push) Successful in 47s

This commit is contained in:
2026-04-27 05:46:33 +02:00
parent 3858beb28e
commit 53389ed3ad
50 changed files with 2007 additions and 104 deletions

View File

@@ -34,6 +34,7 @@ import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { UserStatusService } from './core/services/user-status.service';
import { GameActivityService } from './domains/game-activity';
import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
@@ -95,6 +96,7 @@ export class App implements OnInit, OnDestroy {
readonly externalLinks = inject(ExternalLinkService);
readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService);
readonly gameActivity = inject(GameActivityService);
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
@@ -246,6 +248,7 @@ export class App implements OnInit, OnDestroy {
await this.setupDesktopDeepLinks();
this.userStatus.start();
this.gameActivity.start();
const currentUrl = this.getCurrentRouteUrl();
if (!currentUserId) {

View File

@@ -175,6 +175,7 @@ export interface ElectronApi {
closeWindow: () => void;
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getRunningProcessNames: () => Promise<string[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;

View File

@@ -13,6 +13,7 @@ infrastructure adapters and UI.
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
| **game-activity** | Local game detection, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |

View File

@@ -0,0 +1,261 @@
import {
Injector,
NgZone,
runInInjectionContext,
signal
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Subject, of } from 'rxjs';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../server-directory';
import { UsersActions } from '../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import type {
ChatEvent,
GameActivity,
GameMatchResponse,
MatchedGame,
User
} from '../../../shared-kernel';
import { GameActivityService } from './game-activity.service';
const alice = createUser('alice-id', 'alice-oder', 'Alice');
const bob = createUser('bob-id', 'bob-oder', 'Bob');
const carol = createUser('carol-id', 'carol-oder', 'Carol');
let contexts: ServiceContext[] = [];
describe('GameActivityService sync', () => {
beforeEach(() => {
contexts = [];
installLocalStorageMock();
});
afterEach(() => {
for (const context of contexts) {
context.service.ngOnDestroy();
}
});
it('subscribes to incoming activity on browser clients without local process scanning', () => {
const context = createServiceContext({
currentUser: bob,
allUsers: [alice, bob],
electronApi: null
});
context.service.start();
context.incomingMessages.next({
type: 'game-activity',
fromPeerId: alice.oderId,
oderId: alice.oderId,
displayName: alice.displayName,
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
} as ChatEvent);
expect(context.store.dispatch).toHaveBeenCalledWith(UsersActions.updateGameActivity({
userId: alice.id,
gameActivity: createActivity('game-1', 'Deep Rock Galactic')
}));
});
it('broadcasts local activity changes to peers already online', async () => {
const matchedGame = createMatchedGame('game-2', 'Stardew Valley', 'StardewValley.exe');
const context = createServiceContext({
currentUser: alice,
allUsers: [alice, bob],
processNames: ['StardewValley.exe'],
gameMatchResponse: { games: [matchedGame] }
});
context.service.start();
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'game-activity',
oderId: alice.oderId,
displayName: alice.displayName,
gameActivity: expect.objectContaining({
id: matchedGame.id,
name: matchedGame.name,
iconUrl: matchedGame.iconUrl,
store: matchedGame.store
})
})));
});
it('sends current activity directly to peers that connect after the status was set', async () => {
const matchedGame = createMatchedGame('game-3', 'Hades', 'Hades.exe');
const context = createServiceContext({
currentUser: alice,
allUsers: [
alice,
bob,
carol
],
processNames: ['Hades.exe'],
gameMatchResponse: { games: [matchedGame] }
});
context.service.start();
await vi.waitFor(() => expect(context.realtime.broadcastMessage).toHaveBeenCalled());
context.realtime.sendToPeer.mockClear();
context.peerConnected.next(carol.oderId);
expect(context.realtime.sendToPeer).toHaveBeenCalledWith(carol.oderId, expect.objectContaining({
type: 'game-activity',
oderId: alice.oderId,
displayName: alice.displayName,
gameActivity: expect.objectContaining({
id: matchedGame.id,
name: matchedGame.name,
iconUrl: matchedGame.iconUrl,
store: matchedGame.store
})
}));
});
});
interface ServiceContextOptions {
currentUser: User;
allUsers: User[];
electronApi?: { getRunningProcessNames: () => Promise<string[]> } | null;
processNames?: string[];
gameMatchResponse?: GameMatchResponse;
}
interface ServiceContext {
incomingMessages: Subject<ChatEvent>;
peerConnected: Subject<string>;
realtime: {
broadcastMessage: ReturnType<typeof vi.fn>;
sendToPeer: ReturnType<typeof vi.fn>;
};
service: GameActivityService;
store: {
dispatch: ReturnType<typeof vi.fn>;
};
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
const currentUser = signal<User | null>(options.currentUser);
const allUsers = signal<User[]>(options.allUsers);
const incomingMessages = new Subject<ChatEvent>();
const peerConnected = new Subject<string>();
const realtime = {
onMessageReceived: incomingMessages.asObservable(),
onPeerConnected: peerConnected.asObservable(),
broadcastMessage: vi.fn(),
sendToPeer: vi.fn()
};
const store = {
dispatch: vi.fn(),
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentUser) {
return currentUser;
}
if (selector === selectAllUsers) {
return allUsers;
}
throw new Error('Unexpected selector requested by GameActivityService test.');
})
};
const electronApi = options.electronApi === undefined
? { getRunningProcessNames: vi.fn(async () => options.processNames ?? []) }
: options.electronApi;
const injector = Injector.create({
providers: [
{
provide: ElectronBridgeService,
useValue: { getApi: () => electronApi }
},
{
provide: HttpClient,
useValue: {
post: vi.fn(() => of(options.gameMatchResponse ?? { games: [] }))
}
},
{
provide: NgZone,
useValue: {
run: (fn: () => void) => fn(),
runOutsideAngular: (fn: () => void) => fn()
}
},
{
provide: RealtimeSessionFacade,
useValue: realtime
},
{
provide: ServerDirectoryFacade,
useValue: { getApiBaseUrl: () => 'http://localhost:3001/api' }
},
{
provide: Store,
useValue: store
}
]
});
const service = runInInjectionContext(injector, () => new GameActivityService());
const context = {
incomingMessages,
peerConnected,
realtime,
service,
store
};
contexts.push(context);
return context;
}
function createUser(id: string, oderId: string, displayName: string): User {
return {
id,
oderId,
username: displayName.toLowerCase(),
displayName,
status: 'online',
role: 'member',
joinedAt: 1
};
}
function createActivity(id: string, name: string): GameActivity {
return {
id,
name,
startedAt: 1_000
};
}
function createMatchedGame(id: string, name: string, processName: string): MatchedGame {
return {
id,
name,
iconUrl: `https://img.example.test/${id}.jpg`,
store: {
name: 'Steam',
slug: 'steam',
url: `https://store.steampowered.com/search/?term=${encodeURIComponent(name)}`
},
processName
};
}
function installLocalStorageMock(): void {
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear()
});
}

View File

@@ -0,0 +1,581 @@
import {
Injectable,
NgZone,
OnDestroy,
inject
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Subscription, firstValueFrom } from 'rxjs';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../server-directory';
import { UsersActions } from '../../../store/users/users.actions';
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import type {
ChatEvent,
GameActivity,
GameStoreLink,
GameMatchResponse,
MatchedGame,
User
} from '../../../shared-kernel';
const DEFAULT_SCAN_INTERVAL_MS = 10_000;
const MIN_SCAN_INTERVAL_MS = 5_000;
const MAX_SCAN_INTERVAL_MS = 60_000;
const MAX_PROCESS_NAMES_PER_REQUEST = 256;
const MAX_CANDIDATE_PROCESSES_PER_REQUEST = 12;
const POSITIVE_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const NEGATIVE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_LOCAL_CACHE_ENTRIES = 128;
const SCAN_INTERVAL_STORAGE_KEY = 'metoyou_game_scan_interval_ms';
const GAME_MATCH_CACHE_STORAGE_KEY = 'metoyou_game_match_cache_v1';
interface CachedGameMatch {
expiresAt: number;
game: MatchedGame | null;
}
interface CandidateProcess {
processName: string;
score: number;
}
const IGNORED_PROCESS_NAMES = new Set([
'agent',
'bash',
'baloorunner',
'chrome',
'code',
'conhost',
'cursor',
'csrss',
'dbus daemon',
'discord',
'dwm',
'electron',
'explorer',
'firefox',
'gameoverlayui',
'gamemoded',
'gamescopereaper',
'gnome shell',
'metoyou',
'node',
'npm',
'powershell',
'pulseaudio',
'steam',
'steamwebhelper',
'systemd',
'taskhostw',
'wininit',
'winlogon',
'xorg'
]);
const IGNORED_PROCESS_PATTERNS = [
new RegExp('(^|\\s)(agent|browser|daemon|desktop|helper|indexer|launcher|monitor|renderer|runner)(\\s|$)'),
new RegExp('(^|\\s)(service|settings|shell|tray|updater|utility|watcher|worker)(\\s|$)'),
new RegExp('(^|\\s)(audio|bluetooth|clipboard|crash|dbus|file|gpu|input|network|notification)(\\s|$)'),
new RegExp('(^|\\s)(portal|proxy|screen|session|sync|system|tracker|web|window)(\\s|$)'),
/^(appimage|at spi|baloo|dconf|gvfs|ibus|kde|kworker)/,
/^(pipewire|plasmashell|pulseaudio|xdg|xwayland|zeitgeist)/,
/(helper|service|daemon|runner|tracker|portal|updater|worker)$/
];
@Injectable({ providedIn: 'root' })
export class GameActivityService implements OnDestroy {
private readonly electron = inject(ElectronBridgeService);
private readonly http = inject(HttpClient);
private readonly ngZone = inject(NgZone);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly allUsers = this.store.selectSignal(selectAllUsers);
private readonly subscriptions = new Subscription();
private scanTimer: ReturnType<typeof setInterval> | null = null;
private lastProcessHash = '';
private currentActivity: GameActivity | null = null;
private scanInFlight = false;
private started = false;
start(): void {
if (this.started) {
return;
}
this.started = true;
this.subscriptions.add(
this.webrtc.onMessageReceived.subscribe((event) => this.handlePeerEvent(event))
);
this.subscriptions.add(
this.webrtc.onPeerConnected.subscribe((peerId) => this.sendCurrentActivityToPeer(peerId))
);
const api = this.electron.getApi();
if (!api?.getRunningProcessNames) {
return;
}
this.ngZone.runOutsideAngular(() => {
this.scanTimer = setInterval(() => {
void this.scanRunningProcesses();
}, this.getScanIntervalMs());
});
void this.scanRunningProcesses();
}
ngOnDestroy(): void {
this.stop();
}
private stop(): void {
if (this.scanTimer) {
clearInterval(this.scanTimer);
this.scanTimer = null;
}
this.subscriptions.unsubscribe();
this.started = false;
}
private async scanRunningProcesses(): Promise<void> {
if (this.scanInFlight || !this.currentUser()) {
return;
}
const api = this.electron.getApi();
if (!api?.getRunningProcessNames) {
return;
}
this.scanInFlight = true;
try {
const processNames = (await api.getRunningProcessNames()).slice(0, MAX_PROCESS_NAMES_PER_REQUEST);
const processHash = this.buildProcessHash(processNames);
if (processHash === this.lastProcessHash) {
return;
}
this.lastProcessHash = processHash;
const matchedGame = await this.matchRunningGame(processNames);
this.ngZone.run(() => this.applyMatchedGame(matchedGame));
} catch {
return;
} finally {
this.scanInFlight = false;
}
}
private async matchRunningGame(processes: string[]): Promise<MatchedGame | null> {
const candidates = this.selectCandidateProcesses(processes);
const cachedGame = this.findCachedGame(candidates);
if (cachedGame !== undefined) {
return cachedGame;
}
const unknownCandidates = candidates
.filter((candidate) => !this.hasFreshCacheEntry(candidate.processName))
.slice(0, MAX_CANDIDATE_PROCESSES_PER_REQUEST);
if (unknownCandidates.length === 0) {
return null;
}
const apiBase = this.serverDirectory.getApiBaseUrl();
const currentUser = this.currentUser();
const response = await firstValueFrom(
this.http.post<GameMatchResponse>(`${apiBase}/games/match`, {
processes: unknownCandidates.map((candidate) => candidate.processName),
userId: currentUser?.id ?? currentUser?.oderId
})
);
this.storeMatchResponse(unknownCandidates, response);
return response.games[0] ?? null;
}
private selectCandidateProcesses(processes: string[]): CandidateProcess[] {
const candidates = new Map<string, CandidateProcess>();
for (const processName of processes.slice(0, MAX_PROCESS_NAMES_PER_REQUEST)) {
const normalized = this.normalizeProcessName(processName);
if (!normalized) {
continue;
}
const cacheKey = this.normalizeCacheKey(normalized);
const existing = candidates.get(cacheKey);
const candidate = {
processName,
score: this.scoreCandidateProcess(processName, normalized)
};
if (!existing || candidate.score > existing.score) {
candidates.set(cacheKey, candidate);
}
}
return Array.from(candidates.values())
.sort((left, right) => right.score - left.score || left.processName.localeCompare(right.processName));
}
private normalizeProcessName(value: string): string {
const normalized = value.trim()
.replace(/\.exe$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const cacheKey = this.normalizeCacheKey(normalized);
if (normalized.length < 4 || normalized.length > 96 || this.shouldIgnoreProcessName(cacheKey)) {
return '';
}
return normalized;
}
private shouldIgnoreProcessName(cacheKey: string): boolean {
return IGNORED_PROCESS_NAMES.has(cacheKey)
|| IGNORED_PROCESS_PATTERNS.some((pattern) => pattern.test(cacheKey));
}
private scoreCandidateProcess(rawValue: string, normalized: string): number {
let score = 0;
if (/\.exe$/i.test(rawValue.trim())) {
score += 12;
}
if (/[A-Z]/.test(normalized) && /[a-z]/.test(normalized)) {
score += 4;
}
if (/\d/.test(normalized)) {
score += 1;
}
if (normalized.length >= 5 && normalized.length <= 32) {
score += 2;
}
if (normalized.includes(' ')) {
score -= 2;
}
return score;
}
private findCachedGame(candidates: CandidateProcess[]): MatchedGame | null | undefined {
if (candidates.length === 0) {
return null;
}
let hasCachedMissForEveryCandidate = true;
for (const candidate of candidates) {
const cached = this.getCachedMatch(candidate.processName);
if (cached === undefined) {
hasCachedMissForEveryCandidate = false;
continue;
}
if (cached) {
return cached;
}
}
return hasCachedMissForEveryCandidate ? null : undefined;
}
private storeMatchResponse(candidates: CandidateProcess[], response: GameMatchResponse): void {
for (const game of response.games) {
this.setCachedMatch(game.processName, game, POSITIVE_CACHE_TTL_MS);
}
if (response.rateLimited) {
return;
}
const matchedProcessKeys = new Set(response.games.map((game) => this.normalizeCacheKey(game.processName)));
for (const candidate of candidates) {
if (!matchedProcessKeys.has(this.normalizeCacheKey(candidate.processName))) {
this.setCachedMatch(candidate.processName, null, NEGATIVE_CACHE_TTL_MS);
}
}
}
private hasFreshCacheEntry(processName: string): boolean {
return this.getCachedMatch(processName) !== undefined;
}
private getCachedMatch(processName: string): MatchedGame | null | undefined {
const cache = this.readMatchCache();
const cacheKey = this.normalizeCacheKey(processName);
const cached = cache[cacheKey];
if (!cached) {
return undefined;
}
if (cached.expiresAt <= Date.now()) {
this.writeMatchCache(Object.fromEntries(
Object.entries(cache).filter(([key]) => key !== cacheKey)
));
return undefined;
}
return cached.game;
}
private setCachedMatch(processName: string, game: MatchedGame | null, ttlMs: number): void {
const cache = this.readMatchCache();
cache[this.normalizeCacheKey(processName)] = {
expiresAt: Date.now() + ttlMs,
game
};
this.writeMatchCache(cache);
}
private readMatchCache(): Record<string, CachedGameMatch> {
try {
const parsed = JSON.parse(localStorage.getItem(GAME_MATCH_CACHE_STORAGE_KEY) ?? '{}') as unknown;
return this.normalizeMatchCache(parsed);
} catch {
return {};
}
}
private normalizeMatchCache(value: unknown): Record<string, CachedGameMatch> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const cache: Record<string, CachedGameMatch> = {};
for (const [key, entry] of Object.entries(value)) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
continue;
}
const cached = entry as Partial<CachedGameMatch>;
if (typeof cached.expiresAt === 'number') {
cache[key] = {
expiresAt: cached.expiresAt,
game: this.normalizeCachedGame(cached.game)
};
}
}
return cache;
}
private normalizeCachedGame(value: unknown): MatchedGame | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const game = value as Partial<MatchedGame>;
if (typeof game.id !== 'string' || typeof game.name !== 'string' || typeof game.processName !== 'string') {
return null;
}
return {
id: game.id,
name: game.name,
iconUrl: typeof game.iconUrl === 'string' ? game.iconUrl : undefined,
store: this.normalizeGameStore(game.store),
processName: game.processName
};
}
private normalizeGameStore(value: unknown): GameStoreLink | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
const store = value as Partial<GameStoreLink>;
if (typeof store.name !== 'string' || typeof store.url !== 'string' || !this.isExternalUrl(store.url)) {
return undefined;
}
return {
id: typeof store.id === 'string' ? store.id : undefined,
name: store.name,
slug: typeof store.slug === 'string' ? store.slug : undefined,
domain: typeof store.domain === 'string' ? store.domain : undefined,
url: store.url
};
}
private writeMatchCache(cache: Record<string, CachedGameMatch>): void {
const entries = Object.entries(cache)
.filter(([, entry]) => entry.expiresAt > Date.now())
.sort((left, right) => right[1].expiresAt - left[1].expiresAt)
.slice(0, MAX_LOCAL_CACHE_ENTRIES);
localStorage.setItem(GAME_MATCH_CACHE_STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)));
}
private normalizeCacheKey(value: string): string {
return value.trim()
.replace(/\.exe$/i, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
private applyMatchedGame(game: MatchedGame | null): void {
if (!game) {
this.setCurrentActivity(null);
return;
}
const previous = this.currentActivity;
const activity: GameActivity = {
id: game.id,
name: game.name,
iconUrl: game.iconUrl,
store: game.store,
startedAt: previous?.id === game.id ? previous.startedAt : Date.now()
};
this.setCurrentActivity(activity);
}
private setCurrentActivity(activity: GameActivity | null): void {
if (this.isSameActivity(this.currentActivity, activity)) {
return;
}
this.currentActivity = activity;
const user = this.currentUser();
if (user) {
this.store.dispatch(UsersActions.updateGameActivity({
userId: user.id,
gameActivity: activity
}));
}
this.webrtc.broadcastMessage({
type: 'game-activity',
oderId: user?.oderId || user?.id,
displayName: user?.displayName || 'User',
gameActivity: activity
});
}
private handlePeerEvent(event: ChatEvent): void {
if (event.type !== 'game-activity') {
return;
}
const peerIdentifier = event.fromPeerId ?? event.oderId;
if (!peerIdentifier) {
return;
}
const currentUser = this.currentUser();
if (peerIdentifier === currentUser?.id || peerIdentifier === currentUser?.oderId) {
return;
}
const user = this.findUser(peerIdentifier);
if (!user) {
return;
}
this.store.dispatch(UsersActions.updateGameActivity({
userId: user.id,
gameActivity: this.normalizeIncomingActivity(event.gameActivity)
}));
}
private sendCurrentActivityToPeer(peerId: string): void {
const user = this.currentUser();
if (!user) {
return;
}
this.webrtc.sendToPeer(peerId, {
type: 'game-activity',
oderId: user.oderId || user.id,
displayName: user.displayName || 'User',
gameActivity: this.currentActivity
});
}
private findUser(identifier: string): User | null {
return this.allUsers().find((user) => user.id === identifier || user.oderId === identifier) ?? null;
}
private normalizeIncomingActivity(value: GameActivity | null | undefined): GameActivity | null {
if (!value || typeof value.id !== 'string' || typeof value.name !== 'string' || typeof value.startedAt !== 'number') {
return null;
}
return {
id: value.id,
name: value.name,
iconUrl: typeof value.iconUrl === 'string' ? value.iconUrl : undefined,
store: this.normalizeGameStore(value.store),
startedAt: value.startedAt
};
}
private isSameActivity(previous: GameActivity | null, next: GameActivity | null): boolean {
return previous?.id === next?.id
&& previous?.name === next?.name
&& previous?.iconUrl === next?.iconUrl
&& previous?.store?.url === next?.store?.url
&& previous?.startedAt === next?.startedAt;
}
private isExternalUrl(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://');
}
private buildProcessHash(processNames: string[]): string {
return processNames.map((name) => name.trim().toLowerCase())
.sort()
.join('|');
}
private getScanIntervalMs(): number {
const storedValue = Number.parseInt(localStorage.getItem(SCAN_INTERVAL_STORAGE_KEY) ?? '', 10);
const interval = Number.isFinite(storedValue) ? storedValue : DEFAULT_SCAN_INTERVAL_MS;
return Math.min(Math.max(interval, MIN_SCAN_INTERVAL_MS), MAX_SCAN_INTERVAL_MS);
}
}

View File

@@ -0,0 +1,14 @@
export function formatGameActivityElapsed(startedAt: number, now = Date.now()): string {
const elapsedSeconds = Math.max(0, Math.floor((now - startedAt) / 1000));
const hours = Math.floor(elapsedSeconds / 3600);
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
const seconds = elapsedSeconds % 60;
return [
hours,
minutes,
seconds
]
.map((value) => value.toString().padStart(2, '0'))
.join(':');
}

View File

@@ -0,0 +1,3 @@
import type { GameActivity } from '../../../shared-kernel';
export type CurrentGameActivity = GameActivity | null;

View File

@@ -0,0 +1,3 @@
export * from './application/game-activity.service';
export * from './domain/game-activity.models';
export * from './domain/game-activity-time';

View File

@@ -157,6 +157,14 @@ describe('ServerEndpointStateService', () => {
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true);
});
it('resolves legacy https source URLs to the local http default endpoint on the same host', () => {
const defaultServer = getConfiguredDefaultServer('default');
const service = createService();
const legacyHttpsUrl = defaultServer.url?.replace(/^http:\/\//, 'https://') ?? '';
expect(service.findServerByUrl(legacyHttpsUrl)?.url).toBe(defaultServer.url);
});
it('persists turning a configured default endpoint off and back on', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
const service = createService();

View File

@@ -117,8 +117,9 @@ export class ServerEndpointStateService {
findServerByUrl(url: string): ServerEndpoint | undefined {
const sanitisedUrl = this.sanitiseUrl(url);
const exactEndpoint = this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
return exactEndpoint ?? this.findHttpEndpointForHttpsUrl(sanitisedUrl);
}
resolveCanonicalEndpoint(endpoint: ServerEndpoint | null | undefined): ServerEndpoint | null {
@@ -447,4 +448,28 @@ export class ServerEndpointStateService {
return false;
}
}
private findHttpEndpointForHttpsUrl(url: string): ServerEndpoint | undefined {
const requestedUrl = this.parseUrl(url);
if (requestedUrl?.protocol !== 'https:') {
return undefined;
}
return this._servers().find((endpoint) => {
const endpointUrl = this.parseUrl(endpoint.url);
return endpointUrl?.protocol === 'http:'
&& endpointUrl.hostname === requestedUrl.hostname
&& endpointUrl.port === requestedUrl.port;
});
}
private parseUrl(url: string): URL | null {
try {
return new URL(url);
} catch {
return null;
}
}
}

View File

@@ -8,7 +8,11 @@ import {
of,
throwError
} from 'rxjs';
import { catchError, map, scan } from 'rxjs/operators';
import {
catchError,
map,
scan
} from 'rxjs/operators';
import {
ChannelPermissionOverride,
type Channel,
@@ -32,6 +36,19 @@ import type {
} from '../../domain/models/server-directory.model';
import type { RoomSignalSourceInput } from '../../domain/logic/room-signal-source.logic';
interface ServerLookupError {
status?: number;
error?: {
errorCode?: unknown;
};
}
function isServerNotFoundError(error: unknown): boolean {
const lookupError = error as ServerLookupError;
return lookupError?.status === 404 && lookupError.error?.errorCode === 'SERVER_NOT_FOUND';
}
@Injectable({ providedIn: 'root' })
export class ServerDirectoryApiService {
private readonly http = inject(HttpClient);
@@ -90,6 +107,10 @@ export class ServerDirectoryApiService {
return this.http.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`).pipe(
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => {
if (isServerNotFoundError(error)) {
return of(null);
}
console.error('Failed to get server:', error);
return of(null);
})

View File

@@ -277,6 +277,29 @@
/>
<div class="flex-1 min-w-0">
<p class="text-sm text-foreground truncate">{{ currentUser()?.displayName }}</p>
@if (currentUser()?.gameActivity; as activity) {
<p class="mt-0.5 flex items-center gap-1 truncate text-[10px] text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-2.5 w-2.5 shrink-0"
/>
@if (activity.store?.url) {
<button
type="button"
class="truncate text-left hover:text-foreground hover:underline"
(click)="openGameStore($event, activity)"
(dblclick)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
>
Playing {{ activity.name }}
</button>
} @else {
<span class="truncate">Playing {{ activity.name }}</span>
}
<span class="shrink-0">{{ gameActivityElapsed(currentUser()) }}</span>
</p>
}
<div class="flex items-center gap-2">
@if (currentUser()?.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">
@@ -340,6 +363,29 @@
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded font-medium">Mod</span>
}
</div>
@if (user.gameActivity; as activity) {
<p class="mt-0.5 flex items-center gap-1 truncate text-[10px] text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-2.5 w-2.5 shrink-0"
/>
@if (activity.store?.url) {
<button
type="button"
class="truncate text-left hover:text-foreground hover:underline"
(click)="openGameStore($event, activity)"
(dblclick)="$event.stopPropagation()"
(keydown.enter)="$event.stopPropagation()"
(keydown.space)="$event.stopPropagation()"
>
Playing {{ activity.name }}
</button>
} @else {
<span class="truncate">Playing {{ activity.name }}</span>
}
<span class="shrink-0">{{ gameActivityElapsed(user) }}</span>
</p>
}
<div class="flex items-center gap-2">
@if (user.voiceState?.isConnected) {
<p class="text-[10px] text-muted-foreground flex items-center gap-1">

View File

@@ -4,6 +4,7 @@ import {
inject,
computed,
input,
OnDestroy,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
@@ -22,7 +23,8 @@ import {
lucideHash,
lucideUsers,
lucidePlus,
lucideVolumeX
lucideVolumeX,
lucideGamepad2
} from '@ng-icons/lucide';
import { selectOnlineUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import {
@@ -46,6 +48,8 @@ import {
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
import { DirectMessageService } from '../../../domains/direct-message';
import { VoicePlaybackService } from '../../../domains/voice-connection';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
import {
@@ -64,6 +68,7 @@ import {
import {
Channel,
ChatEvent,
GameActivity,
RoomMember,
Room,
User
@@ -98,12 +103,13 @@ type PanelMode = 'channels' | 'users';
lucideHash,
lucideUsers,
lucidePlus,
lucideVolumeX
lucideVolumeX,
lucideGamepad2
})
],
templateUrl: './rooms-side-panel.component.html'
})
export class RoomsSidePanelComponent {
export class RoomsSidePanelComponent implements OnDestroy {
private store = inject(Store);
private router = inject(Router);
private realtime = inject(RealtimeSessionFacade);
@@ -115,9 +121,11 @@ export class RoomsSidePanelComponent {
private voicePlayback = inject(VoicePlaybackService);
private profileCard = inject(ProfileCardService);
private directMessages = inject(DirectMessageService);
private readonly externalLinks = inject(ExternalLinkService);
private readonly voiceActivity = inject(VoiceActivityService);
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
readonly panelMode = input<PanelMode>('channels');
readonly showVoiceControls = input(true);
@@ -198,6 +206,26 @@ export class RoomsSidePanelComponent {
volumeMenuDisplayName = signal('');
draggedVoiceUserId = signal<string | null>(null);
dragTargetVoiceChannelId = signal<string | null>(null);
activityNow = signal(Date.now());
ngOnDestroy(): void {
clearInterval(this.activityTimer);
this.cancelQueuedProfileCardOpen();
}
gameActivityElapsed(user: User | null | undefined): string {
const activity = user?.gameActivity;
return activity ? formatGameActivityElapsed(activity.startedAt, this.activityNow()) : '';
}
openGameStore(event: Event, activity: GameActivity): void {
event.stopPropagation();
if (activity.store?.url) {
this.externalLinks.open(activity.store.url);
}
}
openProfileCard(event: Event, user: User, editable: boolean): void {
event.stopPropagation();

View File

@@ -28,6 +28,7 @@
(keydown.space)="$event.stopPropagation()"
role="dialog"
aria-modal="true"
aria-labelledby="settings-modal-title"
tabindex="-1"
>
<!-- Side Navigation -->
@@ -36,7 +37,12 @@
class="flex w-56 flex-shrink-0 flex-col border-r border-border bg-card"
>
<div class="border-b border-border px-3 py-3">
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
<h2
id="settings-modal-title"
class="text-lg font-semibold text-foreground"
>
Settings
</h2>
</div>
<div class="flex-1 overflow-y-auto py-2">

View File

@@ -13,6 +13,7 @@ require coordination.
| `message.models.ts` | `Message`, `Reaction`, `DELETED_MESSAGE_CONTENT` |
| `moderation.models.ts` | `BanEntry` |
| `voice-state.models.ts` | `VoiceState`, `ScreenShareState` |
| `game-activity.models.ts` | `GameActivity`, `MatchedGame`, game-match API response contract |
| `chat-events.ts` | `ChatEventType`, `ChatEvent`, `ChatInventoryItem` |
| `direct-message-contracts.ts` | `DirectMessage`, delivery status, P2P DM event payloads |
| `media-preferences.ts` | `LatencyProfile`, `ScreenShareQuality`, quality presets |

View File

@@ -7,6 +7,7 @@ import type {
Channel
} from './room.models';
import type { VoiceState } from './voice-state.models';
import type { GameActivity } from './game-activity.models';
import type { BanEntry } from './moderation.models';
import type { ChatAttachmentAnnouncement, ChatAttachmentMeta } from './attachment-contracts';
import type {
@@ -66,6 +67,7 @@ export interface ChatEventBase {
settings?: Partial<RoomSettings>;
permissions?: Partial<RoomPermissions>;
voiceState?: Partial<VoiceState>;
gameActivity?: GameActivity | null;
isScreenSharing?: boolean;
isCameraEnabled?: boolean;
icon?: string;
@@ -237,6 +239,11 @@ export interface CameraStateEvent extends ChatEventBase {
isCameraEnabled: boolean;
}
export interface GameActivityEvent extends ChatEventBase {
type: 'game-activity';
gameActivity: GameActivity | null;
}
export interface VoiceStateRequestEvent extends ChatEventBase {
type: 'voice-state-request';
}
@@ -410,6 +417,7 @@ export type ChatEvent =
| VoiceChannelMoveEvent
| ScreenStateEvent
| CameraStateEvent
| GameActivityEvent
| VoiceStateRequestEvent
| StateRequestEvent
| ScreenShareRequestEvent

View File

@@ -0,0 +1,28 @@
export interface GameActivity {
id: string;
name: string;
iconUrl?: string;
store?: GameStoreLink;
startedAt: number;
}
export interface GameStoreLink {
id?: string;
name: string;
slug?: string;
domain?: string;
url: string;
}
export interface MatchedGame {
id: string;
name: string;
iconUrl?: string;
store?: GameStoreLink;
processName: string;
}
export interface GameMatchResponse {
games: MatchedGame[];
rateLimited?: boolean;
}

View File

@@ -4,6 +4,7 @@ export * from './access-control.models';
export * from './message.models';
export * from './moderation.models';
export * from './voice-state.models';
export * from './game-activity.models';
export * from './direct-message-contracts';
export * from './chat-events';
export * from './media-preferences';

View File

@@ -3,6 +3,7 @@ import type {
VoiceState,
ScreenShareState
} from './voice-state.models';
import type { GameActivity } from './game-activity.models';
export type UserStatus = 'online' | 'away' | 'busy' | 'offline' | 'disconnected';
@@ -30,6 +31,7 @@ export interface User {
voiceState?: VoiceState;
screenShareState?: ScreenShareState;
cameraState?: CameraState;
gameActivity?: GameActivity;
}
export interface RoomMember {

View File

@@ -66,6 +66,26 @@
}
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
@if (profileUser.gameActivity; as activity) {
<p class="mt-1 flex items-center gap-1 truncate text-xs text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-3 w-3 shrink-0"
/>
@if (activity.store?.url) {
<button
type="button"
class="truncate text-left hover:text-foreground hover:underline"
(click)="openGameStore(activity, $event)"
>
Playing {{ activity.name }}
</button>
} @else {
<span class="truncate">Playing {{ activity.name }}</span>
}
<span class="shrink-0">{{ gameActivityElapsed() }}</span>
</p>
}
</div>
<div>
@@ -92,11 +112,79 @@
</button>
}
</div>
@if (profileUser.gameActivity; as activity) {
<div class="flex items-center gap-2 rounded-md border border-border bg-background/40 px-2.5 py-2">
@if (activity.iconUrl) {
<img
class="h-9 w-9 rounded-md object-cover"
[src]="activity.iconUrl"
[alt]="activity.name"
/>
} @else {
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-4 w-4"
/>
</div>
}
<div class="min-w-0 flex-1">
<p class="truncate text-xs text-muted-foreground">Playing</p>
@if (activity.store?.url) {
<button
type="button"
class="block max-w-full truncate text-left text-sm font-medium text-foreground hover:underline"
(click)="openGameStore(activity, $event)"
>
{{ activity.name }}
</button>
} @else {
<p class="truncate text-sm font-medium text-foreground">{{ activity.name }}</p>
}
<p class="text-xs text-muted-foreground">{{ gameActivityElapsed() }}</p>
</div>
</div>
}
</div>
} @else {
<p class="truncate text-base font-semibold text-foreground">{{ profileUser.displayName }}</p>
<p class="truncate text-sm text-muted-foreground">{{ profileUser.username }}</p>
@if (profileUser.gameActivity; as activity) {
<div class="mt-3 flex items-center gap-2 rounded-md border border-border bg-background/40 px-2.5 py-2">
@if (activity.iconUrl) {
<img
class="h-9 w-9 rounded-md object-cover"
[src]="activity.iconUrl"
[alt]="activity.name"
/>
} @else {
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-muted-foreground">
<ng-icon
name="lucideGamepad2"
class="h-4 w-4"
/>
</div>
}
<div class="min-w-0 flex-1">
<p class="truncate text-xs text-muted-foreground">Playing</p>
@if (activity.store?.url) {
<button
type="button"
class="block max-w-full truncate text-left text-sm font-medium text-foreground hover:underline"
(click)="openGameStore(activity, $event)"
>
{{ activity.name }}
</button>
} @else {
<p class="truncate text-sm font-medium text-foreground">{{ activity.name }}</p>
}
<p class="text-xs text-muted-foreground">{{ gameActivityElapsed() }}</p>
</div>
</div>
}
@if (profileUser.description) {
<p class="mt-2 whitespace-pre-line text-sm leading-5 text-muted-foreground">{{ profileUser.description }}</p>
}

View File

@@ -3,15 +3,24 @@ import {
computed,
effect,
inject,
OnDestroy,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideCheck, lucideChevronDown } from '@ng-icons/lucide';
import {
lucideCheck,
lucideChevronDown,
lucideGamepad2
} from '@ng-icons/lucide';
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
import { UserStatusService } from '../../../core/services/user-status.service';
import { User, UserStatus } from '../../../shared-kernel';
import {
GameActivity,
User,
UserStatus
} from '../../../shared-kernel';
import {
EditableProfileAvatarSource,
ProfileAvatarFacade,
@@ -22,6 +31,8 @@ import {
import { UsersActions } from '../../../store/users/users.actions';
import { selectUsersEntities } from '../../../store/users/users.selectors';
import { ThemeNodeDirective } from '../../../domains/theme';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
@Component({
selector: 'app-profile-card',
@@ -32,10 +43,10 @@ import { ThemeNodeDirective } from '../../../domains/theme';
UserAvatarComponent,
ThemeNodeDirective
],
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown })],
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown, lucideGamepad2 })],
templateUrl: './profile-card.component.html'
})
export class ProfileCardComponent {
export class ProfileCardComponent implements OnDestroy {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly displayedUser = computed(() => {
const snapshot = this.user();
@@ -52,6 +63,7 @@ export class ProfileCardComponent {
readonly editingField = signal<'displayName' | 'description' | null>(null);
readonly displayNameDraft = signal('');
readonly descriptionDraft = signal('');
readonly activityNow = signal(Date.now());
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
{ value: null, label: 'Online', color: 'bg-green-500' },
@@ -65,6 +77,8 @@ export class ProfileCardComponent {
private readonly userStatus = inject(UserStatusService);
private readonly profileAvatar = inject(ProfileAvatarFacade);
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
private readonly externalLinks = inject(ExternalLinkService);
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
private readonly syncProfileDrafts = effect(
() => {
const user = this.displayedUser();
@@ -115,6 +129,24 @@ export class ProfileCardComponent {
}
}
gameActivityElapsed(): string {
const activity = this.displayedUser().gameActivity;
return activity ? formatGameActivityElapsed(activity.startedAt, this.activityNow()) : '';
}
openGameStore(activity: GameActivity, event: Event): void {
event.stopPropagation();
if (activity.store?.url) {
this.externalLinks.open(activity.store.url);
}
}
ngOnDestroy(): void {
clearInterval(this.activityTimer);
}
toggleStatusMenu(): void {
this.showStatusMenu.update((isOpen) => !isOpen);
}

View File

@@ -12,7 +12,8 @@ import {
BanEntry,
VoiceState,
ScreenShareState,
CameraState
CameraState,
GameActivity
} from '../../shared-kernel';
export const UsersActions = createActionGroup({
@@ -65,6 +66,7 @@ export const UsersActions = createActionGroup({
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),
'Update Screen Share State': props<{ userId: string; screenShareState: Partial<ScreenShareState> }>(),
'Update Camera State': props<{ userId: string; cameraState: Partial<CameraState> }>(),
'Update Game Activity': props<{ userId: string; gameActivity: GameActivity | null }>(),
'Set Manual Status': props<{ status: UserStatus | null }>(),
'Update Remote User Status': props<{ userId: string; status: UserStatus }>(),

View File

@@ -242,7 +242,8 @@ function buildPresenceRemovalChanges(
status: isOnline ? (user.status !== 'offline' ? user.status : 'online') : 'offline',
voiceState: shouldClearLiveState ? buildDisconnectedVoiceState(user) : user.voiceState,
screenShareState: shouldClearLiveState ? buildInactiveScreenShareState(user) : user.screenShareState,
cameraState: shouldClearLiveState ? buildInactiveCameraState(user) : user.cameraState
cameraState: shouldClearLiveState ? buildInactiveCameraState(user) : user.cameraState,
gameActivity: isOnline ? user.gameActivity : undefined
};
}
@@ -555,6 +556,23 @@ export const usersReducer = createReducer(
state
);
}),
on(UsersActions.updateGameActivity, (state, { userId, gameActivity }) => {
const existingUser = state.entities[userId];
if (!existingUser) {
return state;
}
return usersAdapter.updateOne(
{
id: userId,
changes: {
gameActivity: gameActivity ?? undefined
}
},
state
);
}),
on(UsersActions.syncUsers, (state, { users }) =>
usersAdapter.upsertMany(users, state)
),