Refactor API and enhance Angular integration
- Removed `ciTargetName` from `nx.json`. - Updated `package.json` to include new dependencies: `@types/seedrandom`, `fast-check`, `happy-dom`, and `@nestjs/schedule`. - Modified `pnpm-lock.yaml` to reflect the addition of new packages and their versions. - Improved project documentation in `PROJECT_CONTEXT.md` to clarify the use of Zod schemas and Angular framework decisions. - Introduced new Angular components and patterns in the `.agents/skills/frontend-angular` directory, including examples and reference materials for Angular 21+ features.
This commit is contained in:
@@ -62,8 +62,10 @@
|
||||
"executor": "@nx/eslint:lint"
|
||||
},
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {}
|
||||
"executor": "@nx/vitest:test",
|
||||
"options": {
|
||||
"configFile": "apps/overlay/vitest.config.mts"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
"continuous": true,
|
||||
|
||||
@@ -2,9 +2,12 @@ import {
|
||||
ApplicationConfig,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { appRoutes } from './app.routes';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { authInterceptor } from './ebs/auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideBrowserGlobalErrorListeners(), provideRouter(appRoutes)],
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
import { NxWelcome } from './nx-welcome';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App, NxWelcome],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should render title', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('h1')?.textContent).toContain(
|
||||
'Welcome overlay',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { NxWelcome } from './nx-welcome';
|
||||
import { PanelShellComponent } from './panel/panel-shell.component';
|
||||
|
||||
@Component({
|
||||
imports: [NxWelcome, RouterModule],
|
||||
imports: [PanelShellComponent],
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css',
|
||||
template: `<app-panel-shell />`,
|
||||
})
|
||||
export class App {
|
||||
protected title = 'overlay';
|
||||
}
|
||||
export class App {}
|
||||
|
||||
68
apps/overlay/src/app/ebs/auth.interceptor.spec.ts
Normal file
68
apps/overlay/src/app/ebs/auth.interceptor.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
import { EbsApiService, EBS_BASE_URL } from './ebs-api.service';
|
||||
|
||||
function makeJwt(): string {
|
||||
const payload = btoa(JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 }))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
||||
}
|
||||
|
||||
describe('authInterceptor', () => {
|
||||
let controller: HttpTestingController;
|
||||
let authService: TwitchAuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: EBS_BASE_URL, useValue: 'https://test.local' },
|
||||
],
|
||||
});
|
||||
controller = TestBed.inject(HttpTestingController);
|
||||
authService = TestBed.inject(TwitchAuthService);
|
||||
});
|
||||
|
||||
afterEach(() => controller.verify());
|
||||
|
||||
it('omits Authorization header when not yet authorized', () => {
|
||||
const svc = TestBed.inject(EbsApiService);
|
||||
svc.getMissionState().subscribe();
|
||||
|
||||
const req = controller.expectOne('https://test.local/missions/state');
|
||||
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||
req.flush(null);
|
||||
});
|
||||
|
||||
it('attaches Bearer token after onAuthorized fires', () => {
|
||||
const jwt = makeJwt();
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: (cb: (a: TwitchAuth) => void) =>
|
||||
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' }),
|
||||
onContext: () => undefined,
|
||||
onVisibilityChanged: () => undefined,
|
||||
listen: () => undefined,
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
authService.init();
|
||||
|
||||
const svc = TestBed.inject(EbsApiService);
|
||||
svc.getMissionState().subscribe();
|
||||
|
||||
const req = controller.expectOne('https://test.local/missions/state');
|
||||
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${jwt}`);
|
||||
req.flush(null);
|
||||
|
||||
delete (window as Window & { Twitch?: unknown }).Twitch;
|
||||
});
|
||||
});
|
||||
9
apps/overlay/src/app/ebs/auth.interceptor.ts
Normal file
9
apps/overlay/src/app/ebs/auth.interceptor.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const token = inject(TwitchAuthService).auth()?.token;
|
||||
if (!token) return next(req);
|
||||
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }));
|
||||
};
|
||||
78
apps/overlay/src/app/ebs/ebs-api.service.spec.ts
Normal file
78
apps/overlay/src/app/ebs/ebs-api.service.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EbsApiService, EBS_BASE_URL } from './ebs-api.service';
|
||||
|
||||
const BASE = 'https://test.local';
|
||||
|
||||
describe('EbsApiService', () => {
|
||||
let service: EbsApiService;
|
||||
let controller: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: EBS_BASE_URL, useValue: BASE },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(EbsApiService);
|
||||
controller = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => controller.verify());
|
||||
|
||||
describe('getMissionState', () => {
|
||||
it('GETs /missions/state and returns null for no active mission', () => {
|
||||
let result: unknown;
|
||||
service.getMissionState().subscribe((v) => (result = v));
|
||||
|
||||
controller.expectOne(`${BASE}/missions/state`).flush(null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('throws ZodError when server returns invalid shape', () => {
|
||||
let error: unknown;
|
||||
service.getMissionState().subscribe({ error: (e) => (error = e) });
|
||||
|
||||
controller.expectOne(`${BASE}/missions/state`).flush({ bad: 'data' });
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startMission', () => {
|
||||
it('POSTs to /missions/start with difficulty', () => {
|
||||
service.startMission({ difficulty: 2 }).subscribe({ error: () => undefined });
|
||||
|
||||
const req = controller.expectOne(`${BASE}/missions/start`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({ difficulty: 2 });
|
||||
req.flush({ bad: 'shape' });
|
||||
});
|
||||
|
||||
it('throws ZodError for invalid difficulty before sending request', () => {
|
||||
expect(() => service.startMission({ difficulty: 99 })).toThrow();
|
||||
controller.expectNone(`${BASE}/missions/start`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('choosePerk', () => {
|
||||
it('POSTs to /missions/choose-perk with perkKey', () => {
|
||||
service.choosePerk({ perkKey: 'iron_will' }).subscribe({ error: () => undefined });
|
||||
|
||||
const req = controller.expectOne(`${BASE}/missions/choose-perk`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual({ perkKey: 'iron_will' });
|
||||
req.flush({ bad: 'shape' });
|
||||
});
|
||||
|
||||
it('throws ZodError for empty perkKey before sending request', () => {
|
||||
expect(() => service.choosePerk({ perkKey: '' })).toThrow();
|
||||
controller.expectNone(`${BASE}/missions/choose-perk`);
|
||||
});
|
||||
});
|
||||
});
|
||||
41
apps/overlay/src/app/ebs/ebs-api.service.ts
Normal file
41
apps/overlay/src/app/ebs/ebs-api.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable, InjectionToken } from '@angular/core';
|
||||
import {
|
||||
ChoosePerkRequest,
|
||||
ChoosePerkRequestSchema,
|
||||
MissionStateResponse,
|
||||
MissionStateResponseSchema,
|
||||
MissionSchema,
|
||||
StartMissionRequest,
|
||||
StartMissionRequestSchema,
|
||||
SurvivorSchema,
|
||||
} from '@fog-explorer/api-interfaces';
|
||||
import { map, Observable } from 'rxjs';
|
||||
|
||||
export const EBS_BASE_URL = new InjectionToken<string>('EBS_BASE_URL');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EbsApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = inject(EBS_BASE_URL, { optional: true }) ?? 'https://localhost:3000';
|
||||
|
||||
getMissionState(): Observable<MissionStateResponse> {
|
||||
return this.http
|
||||
.get(`${this.baseUrl}/missions/state`)
|
||||
.pipe(map((body) => MissionStateResponseSchema.parse(body)));
|
||||
}
|
||||
|
||||
startMission(req: StartMissionRequest) {
|
||||
StartMissionRequestSchema.parse(req);
|
||||
return this.http
|
||||
.post(`${this.baseUrl}/missions/start`, req)
|
||||
.pipe(map((body) => MissionSchema.parse(body)));
|
||||
}
|
||||
|
||||
choosePerk(req: ChoosePerkRequest) {
|
||||
ChoosePerkRequestSchema.parse(req);
|
||||
return this.http
|
||||
.post(`${this.baseUrl}/missions/choose-perk`, req)
|
||||
.pipe(map((body) => SurvivorSchema.parse(body)));
|
||||
}
|
||||
}
|
||||
159
apps/overlay/src/app/mission/mission-state.store.spec.ts
Normal file
159
apps/overlay/src/app/mission/mission-state.store.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EBS_BASE_URL } from '../ebs/ebs-api.service';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
import { MissionStateStore } from './mission-state.store';
|
||||
|
||||
const BASE = 'https://test.local';
|
||||
const STATE_URL = `${BASE}/missions/state`;
|
||||
|
||||
function makeJwt(): string {
|
||||
const payload = btoa(
|
||||
JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
|
||||
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
||||
}
|
||||
|
||||
describe('MissionStateStore', () => {
|
||||
let store: MissionStateStore;
|
||||
let authService: TwitchAuthService;
|
||||
let controller: HttpTestingController;
|
||||
let visibilityCallback: ((visible: boolean, ctx: TwitchContext) => void) | undefined;
|
||||
let pubsubCallbacks: Record<string, (t: string, ct: string, msg: string) => void>;
|
||||
|
||||
function mountTwitchExt(alreadyAuthorized = true) {
|
||||
const jwt = makeJwt();
|
||||
pubsubCallbacks = {};
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: (cb: (a: TwitchAuth) => void) => {
|
||||
if (alreadyAuthorized) {
|
||||
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' });
|
||||
}
|
||||
},
|
||||
onContext: () => undefined,
|
||||
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => {
|
||||
visibilityCallback = cb;
|
||||
},
|
||||
listen: (target: string, cb: (t: string, ct: string, msg: string) => void) => {
|
||||
pubsubCallbacks[target] = cb;
|
||||
},
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function removeTwitchExt() {
|
||||
delete (window as Window & { Twitch?: unknown }).Twitch;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
visibilityCallback = undefined;
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: EBS_BASE_URL, useValue: BASE },
|
||||
],
|
||||
});
|
||||
store = TestBed.inject(MissionStateStore);
|
||||
authService = TestBed.inject(TwitchAuthService);
|
||||
controller = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.verify();
|
||||
removeTwitchExt();
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('does not fetch state when not yet authorized', () => {
|
||||
mountTwitchExt(false);
|
||||
authService.init();
|
||||
store.init();
|
||||
|
||||
controller.expectNone(STATE_URL);
|
||||
});
|
||||
|
||||
it('fetches state immediately when authorized', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
expect(store.state()).toBeNull();
|
||||
});
|
||||
|
||||
it('sets loading true during fetch and false after', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
|
||||
expect(store.loading()).toBe(true);
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('sets error signal when request fails', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
|
||||
controller.expectOne(STATE_URL).flush('server error', {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
});
|
||||
|
||||
expect(store.error()).toBeTruthy();
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PubSub', () => {
|
||||
it('re-fetches when a broadcast arrives while visible', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
|
||||
pubsubCallbacks['broadcast']('broadcast', 'json', '{}');
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
});
|
||||
|
||||
it('ignores broadcast when overlay is hidden', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
|
||||
visibilityCallback!(false, {} as TwitchContext);
|
||||
pubsubCallbacks['broadcast']('broadcast', 'json', '{}');
|
||||
|
||||
controller.expectNone(STATE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('re-fetches state when called', () => {
|
||||
mountTwitchExt();
|
||||
authService.init();
|
||||
store.init();
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
|
||||
store.refresh();
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
});
|
||||
|
||||
it('is a no-op when not yet authorized', () => {
|
||||
mountTwitchExt(false);
|
||||
authService.init();
|
||||
store.refresh();
|
||||
controller.expectNone(STATE_URL);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
apps/overlay/src/app/mission/mission-state.store.ts
Normal file
58
apps/overlay/src/app/mission/mission-state.store.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { inject, Injectable, DestroyRef, signal } from '@angular/core';
|
||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||
import { EbsApiService } from '../ebs/ebs-api.service';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MissionStateStore {
|
||||
private readonly ebs = inject(EbsApiService);
|
||||
private readonly authService = inject(TwitchAuthService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private readonly _state = signal<MissionStateResponse>(null);
|
||||
private readonly _loading = signal(false);
|
||||
private readonly _error = signal<unknown>(null);
|
||||
|
||||
readonly state = this._state.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly error = this._error.asReadonly();
|
||||
|
||||
init(): void {
|
||||
this.fetchState();
|
||||
this.subscribePubSub();
|
||||
}
|
||||
|
||||
/** Call when the overlay becomes visible after being hidden to reconcile missed ticks. */
|
||||
refresh(): void {
|
||||
this.fetchState();
|
||||
}
|
||||
|
||||
private fetchState(): void {
|
||||
if (!this.authService.auth()) return;
|
||||
|
||||
this._loading.set(true);
|
||||
this.ebs.getMissionState().subscribe({
|
||||
next: (state) => {
|
||||
this._state.set(state);
|
||||
this._loading.set(false);
|
||||
this._error.set(null);
|
||||
},
|
||||
error: (err) => {
|
||||
this._loading.set(false);
|
||||
this._error.set(err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private subscribePubSub(): void {
|
||||
if (!window.Twitch?.ext) return;
|
||||
|
||||
const listener = (_target: string, _contentType: string, _message: string): void => {
|
||||
if (!this.authService.isVisible()) return;
|
||||
this.fetchState();
|
||||
};
|
||||
|
||||
window.Twitch.ext.listen('broadcast', listener);
|
||||
this.destroyRef.onDestroy(() => window.Twitch?.ext.unlisten('broadcast', listener));
|
||||
}
|
||||
}
|
||||
52
apps/overlay/src/app/panel/ambient-event.component.ts
Normal file
52
apps/overlay/src/app/panel/ambient-event.component.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
|
||||
export interface AmbientEventData {
|
||||
type: 'injury' | 'sacrifice' | 'mission-complete' | 'perk-acquired';
|
||||
description: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-ambient-event',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.panel {
|
||||
width: 290px;
|
||||
height: 92px;
|
||||
background: rgba(15, 18, 22, 0.88);
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.event-type {
|
||||
font-family: sans-serif;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #6a7080;
|
||||
}
|
||||
.event-type.injury, .event-type.downed { color: #B8842E; }
|
||||
.event-type.sacrifice { color: #C03A3A; }
|
||||
.event-type.mission-complete { color: #E8A547; }
|
||||
.event-desc {
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: #c8ccd0;
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<div class="panel" (click)="dismissed.emit()">
|
||||
<span class="event-type" [class]="event().type">{{ event().type }}</span>
|
||||
<span class="event-desc">{{ event().description }}</span>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AmbientEventComponent {
|
||||
event = input.required<AmbientEventData>();
|
||||
dismissed = output<void>();
|
||||
}
|
||||
129
apps/overlay/src/app/panel/expanded-panel.component.ts
Normal file
129
apps/overlay/src/app/panel/expanded-panel.component.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-expanded-panel',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.panel {
|
||||
width: 320px;
|
||||
height: 440px;
|
||||
background: rgba(15, 18, 22, 0.95);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6a7080;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.survivor-name {
|
||||
font-family: 'Cormorant', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e0e4e8;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.survivor-state {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #E8A547;
|
||||
}
|
||||
.survivor-state.injured, .survivor-state.downed { color: #B8842E; }
|
||||
.survivor-state.sacrificed { color: #C03A3A; }
|
||||
.mission-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.difficulty { color: #E8A547; letter-spacing: 0.1em; font-size: 12px; }
|
||||
.tick { font-family: monospace; font-size: 11px; color: #6a7080; margin-left: auto; }
|
||||
.log {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.log-entry {
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #c8ccd0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.perk-slots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.perk {
|
||||
font-size: 10px;
|
||||
background: rgba(232, 165, 71, 0.12);
|
||||
color: #E8A547;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.perk-empty { font-size: 11px; color: #6a7080; font-style: italic; }
|
||||
`],
|
||||
template: `
|
||||
<div class="panel" style="position:relative">
|
||||
<button class="close-btn" (click)="close.emit()">✕</button>
|
||||
@if (survivor(); as s) {
|
||||
<div>
|
||||
<div class="survivor-name">{{ s.name }}</div>
|
||||
<div class="survivor-state" [class]="s.state">{{ s.state }}</div>
|
||||
<div class="perk-slots">
|
||||
@for (perk of s.perkSlots; track perk.id) {
|
||||
<span class="perk">{{ perk.name }}</span>
|
||||
} @empty {
|
||||
<span class="perk-empty">No perks equipped</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (mission(); as m) {
|
||||
<div class="mission-strip">
|
||||
<span class="difficulty">{{ difficultyGlyphs() }}</span>
|
||||
<span class="tick">T+{{ m.tickIndex }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="log">
|
||||
@for (entry of recentLog(); track entry.tickIndex) {
|
||||
<div class="log-entry">{{ entry.logText }}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ExpandedPanelComponent {
|
||||
missionState = input.required<NonNullable<MissionStateResponse>>();
|
||||
close = output<void>();
|
||||
|
||||
protected mission = computed(() => this.missionState().mission);
|
||||
protected survivor = computed(() => this.missionState().survivors[0] ?? null);
|
||||
protected recentLog = computed(() => [...this.missionState().recentLog].reverse());
|
||||
protected difficultyGlyphs = computed(() =>
|
||||
'◆'.repeat(this.missionState().mission.difficulty)
|
||||
);
|
||||
}
|
||||
78
apps/overlay/src/app/panel/minimised-panel.component.ts
Normal file
78
apps/overlay/src/app/panel/minimised-panel.component.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-minimised-panel',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 290px;
|
||||
height: 56px;
|
||||
background: rgba(15, 18, 22, 0.88);
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lantern {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #1a1e24;
|
||||
border: 3px solid #E8A547;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lantern.injured { border-color: #B8842E; }
|
||||
.lantern.downed, .lantern.sacrificed { border-color: #C03A3A; }
|
||||
.ticker {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log-line {
|
||||
display: block;
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: #c8ccd0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.idle { color: #6a7080; font-style: italic; }
|
||||
`],
|
||||
template: `
|
||||
<div class="panel">
|
||||
<div class="lantern" [class]="survivorState()" (click)="lanternClick.emit()"></div>
|
||||
<div class="ticker">
|
||||
@if (latestLogLine(); as line) {
|
||||
<span class="log-line">{{ line }}</span>
|
||||
} @else {
|
||||
<span class="log-line idle">The fog stirs…</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class MinimisedPanelComponent {
|
||||
missionState = input.required<NonNullable<MissionStateResponse>>();
|
||||
lanternClick = output<void>();
|
||||
|
||||
protected survivorState = computed(
|
||||
() => this.missionState().survivors[0]?.state ?? 'idle'
|
||||
);
|
||||
|
||||
protected latestLogLine = computed(() => {
|
||||
const log = this.missionState().recentLog;
|
||||
return log.length ? log[log.length - 1].logText : null;
|
||||
});
|
||||
}
|
||||
253
apps/overlay/src/app/panel/panel-shell.component.spec.ts
Normal file
253
apps/overlay/src/app/panel/panel-shell.component.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MissionSchema, MissionStateResponse, SurvivorSchema } from '@fog-explorer/api-interfaces';
|
||||
import { EBS_BASE_URL } from '../ebs/ebs-api.service';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
import { AmbientEventData } from './ambient-event.component';
|
||||
import { PanelShellComponent } from './panel-shell.component';
|
||||
|
||||
// Stub child components using @Input()/@Output() — JIT doesn't recognise signal inputs
|
||||
@Component({ selector: 'app-minimised-panel', standalone: true, template: '' })
|
||||
class MinimisedPanelStub {
|
||||
@Input() missionState!: NonNullable<MissionStateResponse>;
|
||||
@Output() lanternClick = new EventEmitter<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-ambient-event', standalone: true, template: '' })
|
||||
class AmbientEventStub {
|
||||
@Input() event!: AmbientEventData;
|
||||
@Output() dismissed = new EventEmitter<void>();
|
||||
}
|
||||
|
||||
@Component({ selector: 'app-expanded-panel', standalone: true, template: '' })
|
||||
class ExpandedPanelStub {
|
||||
@Input() missionState!: NonNullable<MissionStateResponse>;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
}
|
||||
|
||||
const BASE = 'https://test.local';
|
||||
const STATE_URL = `${BASE}/missions/state`;
|
||||
|
||||
function makeJwt(): string {
|
||||
const payload = btoa(
|
||||
JSON.stringify({ opaque_user_id: 'U123', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
|
||||
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
||||
}
|
||||
|
||||
function makeAnonymousJwt(): string {
|
||||
const payload = btoa(
|
||||
JSON.stringify({ opaque_user_id: 'Aanon', channel_id: 'ch1', role: 'viewer', exp: 9999999999 })
|
||||
).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${payload}.sig`;
|
||||
}
|
||||
|
||||
const SURVIVOR = SurvivorSchema.parse({
|
||||
id: 'a1b2c3d4-e5f6-4789-a012-b3c4d5e6f701',
|
||||
opaqueUserId: 'U123',
|
||||
channelId: 'ch1',
|
||||
name: 'Hana',
|
||||
state: 'active',
|
||||
stats: { objectives: 5, survival: 5, altruism: 5 },
|
||||
perkSlots: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const MISSION = MissionSchema.parse({
|
||||
id: 'b2c3d4e5-f6a7-4890-b123-c4d5e6f7a801',
|
||||
groupId: null,
|
||||
participants: [{ survivorId: SURVIVOR.id, state: 'active', hookCount: 0 }],
|
||||
difficulty: 2,
|
||||
status: 'active',
|
||||
encounterLibraryVersion: '1.0.0',
|
||||
nextTickAt: new Date(Date.now() + 60000).toISOString(),
|
||||
tickIndex: 3,
|
||||
startedAt: new Date().toISOString(),
|
||||
endedAt: null,
|
||||
});
|
||||
|
||||
const MISSION_STATE = { mission: MISSION, survivors: [SURVIVOR], recentLog: [] };
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [PanelShellComponent],
|
||||
template: `<app-panel-shell />`,
|
||||
})
|
||||
class HostComponent {}
|
||||
|
||||
describe('PanelShellComponent', () => {
|
||||
let controller: HttpTestingController;
|
||||
let authService: TwitchAuthService;
|
||||
let visibilityCallback: ((v: boolean, ctx: TwitchContext) => void) | undefined;
|
||||
|
||||
function mountTwitchExt(jwt = makeJwt()) {
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: (cb: (a: TwitchAuth) => void) =>
|
||||
cb({ channelId: 'ch1', clientId: 'c1', token: jwt, userId: 'U123' }),
|
||||
onContext: () => undefined,
|
||||
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => {
|
||||
visibilityCallback = cb;
|
||||
},
|
||||
listen: () => undefined,
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createComponent() {
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
visibilityCallback = undefined;
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: EBS_BASE_URL, useValue: BASE },
|
||||
],
|
||||
});
|
||||
TestBed.overrideComponent(PanelShellComponent, {
|
||||
set: {
|
||||
imports: [MinimisedPanelStub, AmbientEventStub, ExpandedPanelStub],
|
||||
},
|
||||
});
|
||||
controller = TestBed.inject(HttpTestingController);
|
||||
authService = TestBed.inject(TwitchAuthService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.verify();
|
||||
delete (window as Window & { Twitch?: unknown }).Twitch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders nothing before onAuthorized fires', () => {
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: () => undefined,
|
||||
onContext: () => undefined,
|
||||
onVisibilityChanged: (cb: (v: boolean, ctx: TwitchContext) => void) => { visibilityCallback = cb; },
|
||||
listen: () => undefined,
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
const fixture = createComponent();
|
||||
// Angular @if leaves comment nodes; check no element children are rendered
|
||||
const shell = fixture.nativeElement.querySelector('app-panel-shell') as HTMLElement;
|
||||
expect(shell.querySelectorAll('*').length).toBe(0);
|
||||
});
|
||||
|
||||
it('shows onboarding when authorized but no active mission', () => {
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('The fog stirs');
|
||||
});
|
||||
|
||||
it('shows anonymous panel for A-prefixed opaque_user_id', () => {
|
||||
mountTwitchExt(makeAnonymousJwt());
|
||||
const fixture = createComponent();
|
||||
|
||||
controller.expectOne(STATE_URL).flush(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.anon-panel')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows minimised panel when mission state is available', () => {
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
|
||||
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('toggles to expanded on lantern click and back on close', () => {
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Emit lanternClick from the stub component
|
||||
fixture.debugElement.query(By.directive(MinimisedPanelStub))
|
||||
.componentInstance.lanternClick.emit();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-expanded-panel')).not.toBeNull();
|
||||
|
||||
// Emit close from the expanded stub
|
||||
fixture.debugElement.query(By.directive(ExpandedPanelStub))
|
||||
.componentInstance.close.emit();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows ambient event on injury and auto-dismisses after 4s', async () => {
|
||||
vi.useFakeTimers();
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
||||
fixture.detectChanges();
|
||||
|
||||
const injuredState = {
|
||||
...MISSION_STATE,
|
||||
survivors: [{ ...SURVIVOR, state: 'injured' as const }],
|
||||
};
|
||||
controller.expectNone(STATE_URL);
|
||||
|
||||
// Simulate a PubSub tick arriving that updates state to injured
|
||||
controller.expectNone(STATE_URL);
|
||||
// Directly set state by triggering a second fetch with an injured survivor
|
||||
// (In production this comes from PubSub → store.refresh() → REST)
|
||||
// We trigger via store.refresh() here
|
||||
TestBed.inject(
|
||||
(await import('../mission/mission-state.store')).MissionStateStore
|
||||
).refresh();
|
||||
controller.expectOne(STATE_URL).flush(injuredState);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-ambient-event')).not.toBeNull();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('drops ambient event when overlay is hidden', async () => {
|
||||
mountTwitchExt();
|
||||
const fixture = createComponent();
|
||||
controller.expectOne(STATE_URL).flush(MISSION_STATE);
|
||||
fixture.detectChanges();
|
||||
|
||||
visibilityCallback!(false, {} as TwitchContext);
|
||||
|
||||
const injuredState = {
|
||||
...MISSION_STATE,
|
||||
survivors: [{ ...SURVIVOR, state: 'injured' as const }],
|
||||
};
|
||||
TestBed.inject(
|
||||
(await import('../mission/mission-state.store')).MissionStateStore
|
||||
).refresh();
|
||||
controller.expectOne(STATE_URL).flush(injuredState);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Still minimised — ambient event dropped while hidden
|
||||
expect(fixture.nativeElement.querySelector('app-minimised-panel')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
177
apps/overlay/src/app/panel/panel-shell.component.ts
Normal file
177
apps/overlay/src/app/panel/panel-shell.component.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { MissionStateResponse } from '@fog-explorer/api-interfaces';
|
||||
import { distinctUntilChanged, filter, skip } from 'rxjs';
|
||||
import { EbsApiService } from '../ebs/ebs-api.service';
|
||||
import { MissionStateStore } from '../mission/mission-state.store';
|
||||
import { TwitchAuthService } from '../twitch/twitch-auth.service';
|
||||
import { AmbientEventComponent, AmbientEventData } from './ambient-event.component';
|
||||
import { ExpandedPanelComponent } from './expanded-panel.component';
|
||||
import { MinimisedPanelComponent } from './minimised-panel.component';
|
||||
|
||||
type OverlayView = 'minimised' | 'ambient' | 'expanded';
|
||||
|
||||
function detectAmbientEvent(
|
||||
prev: NonNullable<MissionStateResponse>,
|
||||
curr: NonNullable<MissionStateResponse>
|
||||
): AmbientEventData | null {
|
||||
if (curr.mission.status === 'success' && prev.mission.status !== 'success') {
|
||||
return { type: 'mission-complete', description: 'Mission complete.' };
|
||||
}
|
||||
if (curr.mission.status === 'sacrifice' && prev.mission.status !== 'sacrifice') {
|
||||
return { type: 'sacrifice', description: 'Sacrificed to the fog.' };
|
||||
}
|
||||
for (const survivor of curr.survivors) {
|
||||
const prevSurvivor = prev.survivors.find((s) => s.id === survivor.id);
|
||||
if (prevSurvivor && survivor.state !== prevSurvivor.state) {
|
||||
if (survivor.state === 'injured' || survivor.state === 'downed') {
|
||||
return { type: 'injury', description: `${survivor.name} is injured.` };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-panel-shell',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MinimisedPanelComponent, AmbientEventComponent, ExpandedPanelComponent],
|
||||
template: `
|
||||
@if (authService.auth()) {
|
||||
@if (!authService.isLoggedIn()) {
|
||||
<div class="anon-panel">
|
||||
<div class="lantern-desaturated"></div>
|
||||
</div>
|
||||
} @else if (!store.state()) {
|
||||
<div class="onboarding-panel">
|
||||
<p class="onboarding-text">The fog stirs. Awaiting a survivor.</p>
|
||||
</div>
|
||||
} @else {
|
||||
@switch (view()) {
|
||||
@case ('minimised') {
|
||||
<app-minimised-panel
|
||||
[missionState]="store.state()!"
|
||||
(lanternClick)="onLanternClick()"
|
||||
/>
|
||||
}
|
||||
@case ('ambient') {
|
||||
<app-ambient-event
|
||||
[event]="pendingEvent()!"
|
||||
(dismissed)="onAmbientDismiss()"
|
||||
/>
|
||||
}
|
||||
@case ('expanded') {
|
||||
<app-expanded-panel
|
||||
[missionState]="store.state()!"
|
||||
(close)="onLanternClick()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: [`
|
||||
:host { display: block; position: fixed; bottom: 16px; left: 16px; z-index: 9999; }
|
||||
.anon-panel, .onboarding-panel {
|
||||
width: 290px;
|
||||
min-height: 56px;
|
||||
background: rgba(15, 18, 22, 0.88);
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lantern-desaturated {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #2a2e34;
|
||||
border: 3px solid #4a4e54;
|
||||
}
|
||||
.onboarding-text {
|
||||
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: #6a7080;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PanelShellComponent implements OnInit {
|
||||
protected readonly authService = inject(TwitchAuthService);
|
||||
protected readonly store = inject(MissionStateStore);
|
||||
// EbsApiService eagerly resolved to ensure EBS_BASE_URL token is
|
||||
// available before any child component triggers a request.
|
||||
private readonly _ebs = inject(EbsApiService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly view = signal<OverlayView>('minimised');
|
||||
protected readonly pendingEvent = signal<AmbientEventData | null>(null);
|
||||
|
||||
private prevState: MissionStateResponse = null;
|
||||
private ambientTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
// Visibility restore → reconcile missed ticks.
|
||||
// skip(1) prevents a double-fetch on startup (initial isVisible value is true).
|
||||
toObservable(this.authService.isVisible).pipe(
|
||||
distinctUntilChanged(),
|
||||
skip(1),
|
||||
filter(Boolean),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
).subscribe(() => this.store.refresh());
|
||||
|
||||
// Detect significant state changes and trigger ambient events.
|
||||
effect(() => {
|
||||
const current = this.store.state();
|
||||
untracked(() => this.handleStateChange(current));
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.authService.init();
|
||||
this.store.init();
|
||||
}
|
||||
|
||||
protected onLanternClick(): void {
|
||||
this.view.update((v) => (v === 'expanded' ? 'minimised' : 'expanded'));
|
||||
}
|
||||
|
||||
protected onAmbientDismiss(): void {
|
||||
this.clearAmbientTimer();
|
||||
this.view.set('minimised');
|
||||
this.pendingEvent.set(null);
|
||||
}
|
||||
|
||||
private handleStateChange(current: MissionStateResponse): void {
|
||||
const prev = this.prevState;
|
||||
this.prevState = current;
|
||||
|
||||
if (!prev || !current) return;
|
||||
if (this.view() === 'expanded') return; // Don't interrupt expanded view
|
||||
if (!this.authService.isVisible()) return; // Drop events while hidden
|
||||
|
||||
const event = detectAmbientEvent(prev, current);
|
||||
if (!event) return;
|
||||
|
||||
this.clearAmbientTimer();
|
||||
this.pendingEvent.set(event);
|
||||
this.view.set('ambient');
|
||||
this.ambientTimer = setTimeout(() => this.onAmbientDismiss(), 4000);
|
||||
}
|
||||
|
||||
private clearAmbientTimer(): void {
|
||||
if (this.ambientTimer !== null) {
|
||||
clearTimeout(this.ambientTimer);
|
||||
this.ambientTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
107
apps/overlay/src/app/twitch/twitch-auth.service.spec.ts
Normal file
107
apps/overlay/src/app/twitch/twitch-auth.service.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { TwitchAuthService, TwitchJwtPayload } from './twitch-auth.service';
|
||||
|
||||
function makeJwt(payload: Partial<TwitchJwtPayload> = {}): string {
|
||||
const full: TwitchJwtPayload = {
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
opaque_user_id: 'U12345678',
|
||||
user_id: '12345678',
|
||||
channel_id: '987654321',
|
||||
role: 'viewer',
|
||||
...payload,
|
||||
};
|
||||
const encoded = btoa(JSON.stringify(full))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
return `eyJhbGciOiJIUzI1NiJ9.${encoded}.sig`;
|
||||
}
|
||||
|
||||
function makeTwitchAuth(payload: Partial<TwitchJwtPayload> = {}): TwitchAuth {
|
||||
return {
|
||||
channelId: payload.channel_id ?? '987654321',
|
||||
clientId: 'test_client',
|
||||
token: makeJwt(payload),
|
||||
userId: payload.opaque_user_id ?? 'U12345678',
|
||||
};
|
||||
}
|
||||
|
||||
describe('TwitchAuthService', () => {
|
||||
let service: TwitchAuthService;
|
||||
let extCallbacks: {
|
||||
onAuthorized?: (auth: TwitchAuth) => void;
|
||||
onContext?: (ctx: TwitchContext, changed: (keyof TwitchContext)[]) => void;
|
||||
onVisibilityChanged?: (visible: boolean, ctx: TwitchContext) => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
extCallbacks = {};
|
||||
(window as Window & { Twitch?: unknown }).Twitch = {
|
||||
ext: {
|
||||
onAuthorized: (cb: (auth: TwitchAuth) => void) => { extCallbacks.onAuthorized = cb; },
|
||||
onContext: (cb: (ctx: TwitchContext, changed: (keyof TwitchContext)[]) => void) => { extCallbacks.onContext = cb; },
|
||||
onVisibilityChanged: (cb: (visible: boolean, ctx: TwitchContext) => void) => { extCallbacks.onVisibilityChanged = cb; },
|
||||
listen: () => undefined,
|
||||
unlisten: () => undefined,
|
||||
send: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
service = new TwitchAuthService();
|
||||
service.init();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as Window & { Twitch?: unknown }).Twitch;
|
||||
});
|
||||
|
||||
it('exposes null auth before onAuthorized fires', () => {
|
||||
expect(service.auth()).toBeNull();
|
||||
expect(service.jwtPayload()).toBeNull();
|
||||
expect(service.isLoggedIn()).toBe(false);
|
||||
expect(service.channelId()).toBeNull();
|
||||
});
|
||||
|
||||
it('sets auth and decoded payload when onAuthorized fires', () => {
|
||||
extCallbacks.onAuthorized!(makeTwitchAuth());
|
||||
|
||||
expect(service.auth()).not.toBeNull();
|
||||
expect(service.jwtPayload()?.opaque_user_id).toBe('U12345678');
|
||||
expect(service.jwtPayload()?.channel_id).toBe('987654321');
|
||||
expect(service.channelId()).toBe('987654321');
|
||||
});
|
||||
|
||||
it('reports isLoggedIn true for U-prefixed opaque_user_id', () => {
|
||||
extCallbacks.onAuthorized!(makeTwitchAuth({ opaque_user_id: 'U99999999' }));
|
||||
expect(service.isLoggedIn()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports isLoggedIn false for A-prefixed opaque_user_id', () => {
|
||||
extCallbacks.onAuthorized!(makeTwitchAuth({ opaque_user_id: 'Aanonymous' }));
|
||||
expect(service.isLoggedIn()).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults isVisible to true', () => {
|
||||
expect(service.isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('updates isVisible when onVisibilityChanged fires', () => {
|
||||
const ctx = {} as TwitchContext;
|
||||
extCallbacks.onVisibilityChanged!(false, ctx);
|
||||
expect(service.isVisible()).toBe(false);
|
||||
|
||||
extCallbacks.onVisibilityChanged!(true, ctx);
|
||||
expect(service.isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('updates context when onContext fires', () => {
|
||||
const ctx = { game: 'test-game' } as TwitchContext;
|
||||
extCallbacks.onContext!(ctx, ['game']);
|
||||
expect(service.context()?.game).toBe('test-game');
|
||||
});
|
||||
|
||||
it('updates context from onVisibilityChanged', () => {
|
||||
const ctx = { isFullScreen: true } as TwitchContext;
|
||||
extCallbacks.onVisibilityChanged!(false, ctx);
|
||||
expect(service.context()?.isFullScreen).toBe(true);
|
||||
});
|
||||
});
|
||||
87
apps/overlay/src/app/twitch/twitch-auth.service.ts
Normal file
87
apps/overlay/src/app/twitch/twitch-auth.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { computed, Injectable, isDevMode, signal } from '@angular/core';
|
||||
|
||||
export interface TwitchJwtPayload {
|
||||
exp: number;
|
||||
opaque_user_id: string;
|
||||
user_id?: string;
|
||||
channel_id: string;
|
||||
role: 'viewer' | 'broadcaster' | 'external';
|
||||
is_unlinked?: boolean;
|
||||
pubsub_perms?: { listen?: string[]; send?: string[] };
|
||||
}
|
||||
|
||||
function decodeJwtPayload(token: string): TwitchJwtPayload | null {
|
||||
try {
|
||||
const part = token.split('.')[1];
|
||||
return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDevAuth(): TwitchAuth {
|
||||
const payload: TwitchJwtPayload = {
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
opaque_user_id: 'UDEV000001',
|
||||
user_id: 'dev_user_1',
|
||||
channel_id: 'dev_channel_1',
|
||||
role: 'viewer',
|
||||
};
|
||||
const encoded = btoa(JSON.stringify(payload))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
return {
|
||||
channelId: payload.channel_id,
|
||||
clientId: 'dev_client_id',
|
||||
token: `eyJhbGciOiJIUzI1NiJ9.${encoded}.dev`,
|
||||
userId: payload.opaque_user_id,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TwitchAuthService {
|
||||
private readonly _auth = signal<TwitchAuth | null>(null);
|
||||
private readonly _context = signal<TwitchContext | null>(null);
|
||||
private readonly _isVisible = signal(true);
|
||||
|
||||
readonly auth = this._auth.asReadonly();
|
||||
readonly context = this._context.asReadonly();
|
||||
readonly isVisible = this._isVisible.asReadonly();
|
||||
|
||||
readonly jwtPayload = computed(() => {
|
||||
const auth = this._auth();
|
||||
return auth ? decodeJwtPayload(auth.token) : null;
|
||||
});
|
||||
|
||||
readonly isLoggedIn = computed(
|
||||
() => this.jwtPayload()?.opaque_user_id.startsWith('U') ?? false,
|
||||
);
|
||||
|
||||
readonly channelId = computed(() => this.jwtPayload()?.channel_id ?? null);
|
||||
|
||||
init(): void {
|
||||
if (isDevMode() && !window.Twitch?.ext) {
|
||||
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
||||
console.warn('[TwitchAuth] Twitch SDK not found — using dev auth');
|
||||
}
|
||||
this._auth.set(buildDevAuth());
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.Twitch) {
|
||||
window.Twitch.ext.onAuthorized((auth) => {
|
||||
this._auth.set(auth);
|
||||
});
|
||||
|
||||
window.Twitch.ext.onContext((context) => {
|
||||
this._context.set(context);
|
||||
});
|
||||
|
||||
window.Twitch.ext.onVisibilityChanged((isVisible, context) => {
|
||||
this._isVisible.set(isVisible);
|
||||
this._context.set(context);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
11
apps/overlay/src/test-setup.ts
Normal file
11
apps/overlay/src/test-setup.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import '@angular/compiler';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting,
|
||||
} from '@angular/platform-browser/testing';
|
||||
|
||||
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
47
apps/overlay/src/twitch-ext.d.ts
vendored
Normal file
47
apps/overlay/src/twitch-ext.d.ts
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
interface TwitchAuth {
|
||||
channelId: string;
|
||||
clientId: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
helixToken?: string;
|
||||
}
|
||||
|
||||
interface TwitchContext {
|
||||
game: string;
|
||||
language: string;
|
||||
mode: 'viewer' | 'dashboard' | 'config';
|
||||
isFullScreen: boolean;
|
||||
isPaused: boolean;
|
||||
theme: 'light' | 'dark';
|
||||
arePlayerControlsVisible: boolean;
|
||||
bitrate: number;
|
||||
bufferSize: number;
|
||||
displayResolution: string;
|
||||
videoCurrentTime: number;
|
||||
videoDuration: number;
|
||||
videoResolution: string;
|
||||
hlsLatencyBroadcaster: number;
|
||||
}
|
||||
|
||||
interface TwitchExt {
|
||||
onAuthorized(callback: (auth: TwitchAuth) => void): void;
|
||||
onContext(
|
||||
callback: (context: TwitchContext, changed: (keyof TwitchContext)[]) => void
|
||||
): void;
|
||||
onVisibilityChanged(
|
||||
callback: (isVisible: boolean, context: TwitchContext) => void
|
||||
): void;
|
||||
listen(
|
||||
target: string,
|
||||
callback: (target: string, contentType: string, message: string) => void
|
||||
): void;
|
||||
unlisten(
|
||||
target: string,
|
||||
callback: (target: string, contentType: string, message: string) => void
|
||||
): void;
|
||||
send(target: string, contentType: string, message: object): void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
Twitch?: { ext: TwitchExt };
|
||||
}
|
||||
21
apps/overlay/vitest.config.mts
Normal file
21
apps/overlay/vitest.config.mts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/apps/overlay',
|
||||
plugins: [nxViteTsPaths()],
|
||||
test: {
|
||||
name: 'overlay',
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../coverage/apps/overlay',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user