feat: Add game activity status (Experimental)
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 5m14s
Queue Release Build / build-windows (push) Successful in 16m18s
Queue Release Build / build-linux (push) Successful in 29m20s
Queue Release Build / finalize (push) Successful in 36s
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
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 (error) {
|
||||
console.warn('[GameActivity] Failed to scan running processes', error);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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(':');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { GameActivity } from '../../../shared-kernel';
|
||||
|
||||
export type CurrentGameActivity = GameActivity | null;
|
||||
3
toju-app/src/app/domains/game-activity/index.ts
Normal file
3
toju-app/src/app/domains/game-activity/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './application/game-activity.service';
|
||||
export * from './domain/game-activity.models';
|
||||
export * from './domain/game-activity-time';
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
28
toju-app/src/app/shared-kernel/game-activity.models.ts
Normal file
28
toju-app/src/app/shared-kernel/game-activity.models.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }>(),
|
||||
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user