feat: alive UI wave, galaxy presence, spread and fleet enhancements
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
This commit is contained in:
@@ -9,8 +9,12 @@ type AmbientMusicContextValue = {
|
||||
enabled: boolean;
|
||||
playing: boolean;
|
||||
volume: number;
|
||||
pageIntensity: number;
|
||||
modalDuckActive: boolean;
|
||||
setEnabled: (v: boolean) => void;
|
||||
setVolume: (v: number) => void;
|
||||
setPageIntensity: (v: number) => void;
|
||||
registerModalDuck: () => () => void;
|
||||
togglePlay: () => void;
|
||||
};
|
||||
|
||||
@@ -20,6 +24,8 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
|
||||
const [enabled, setEnabledState] = useState(loadBgmEnabled);
|
||||
const [playing, setPlaying] = useState(() => ambientMusicPlayer.isPlaying());
|
||||
const [volume, setVolumeState] = useState(loadBgmVolume);
|
||||
const [pageIntensity, setPageIntensityState] = useState(() => ambientMusicPlayer.getPageIntensity());
|
||||
const [modalDuckActive, setModalDuckActive] = useState(() => ambientMusicPlayer.isModalDuckActive());
|
||||
|
||||
const setEnabled = useCallback((v: boolean) => {
|
||||
ambientMusicPlayer.setEnabled(v);
|
||||
@@ -32,6 +38,20 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
|
||||
setVolumeState(ambientMusicPlayer.getVolume());
|
||||
}, []);
|
||||
|
||||
const setPageIntensity = useCallback((v: number) => {
|
||||
ambientMusicPlayer.setPageIntensity(v);
|
||||
setPageIntensityState(ambientMusicPlayer.getPageIntensity());
|
||||
}, []);
|
||||
|
||||
const registerModalDuck = useCallback(() => {
|
||||
setModalDuckActive(true);
|
||||
const unregister = ambientMusicPlayer.registerModalDuck();
|
||||
return () => {
|
||||
unregister();
|
||||
setModalDuckActive(ambientMusicPlayer.isModalDuckActive());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
ambientMusicPlayer.unlock();
|
||||
ambientMusicPlayer.togglePlay();
|
||||
@@ -59,8 +79,19 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, playing, volume, setEnabled, setVolume, togglePlay }),
|
||||
[enabled, playing, volume, setEnabled, setVolume, togglePlay]
|
||||
() => ({
|
||||
enabled,
|
||||
playing,
|
||||
volume,
|
||||
pageIntensity,
|
||||
modalDuckActive,
|
||||
setEnabled,
|
||||
setVolume,
|
||||
setPageIntensity,
|
||||
registerModalDuck,
|
||||
togglePlay,
|
||||
}),
|
||||
[enabled, playing, volume, pageIntensity, modalDuckActive, setEnabled, setVolume, setPageIntensity, registerModalDuck, togglePlay]
|
||||
);
|
||||
|
||||
return <AmbientMusicContext.Provider value={value}>{children}</AmbientMusicContext.Provider>;
|
||||
@@ -70,8 +101,12 @@ const noopAmbient: AmbientMusicContextValue = {
|
||||
enabled: false,
|
||||
playing: false,
|
||||
volume: 0,
|
||||
pageIntensity: 1,
|
||||
modalDuckActive: false,
|
||||
setEnabled: () => {},
|
||||
setVolume: () => {},
|
||||
setPageIntensity: () => {},
|
||||
registerModalDuck: () => () => {},
|
||||
togglePlay: () => {},
|
||||
};
|
||||
|
||||
@@ -79,3 +114,12 @@ export function useAmbientMusic() {
|
||||
const ctx = useContext(AmbientMusicContext);
|
||||
return ctx ?? noopAmbient;
|
||||
}
|
||||
|
||||
/** Duck ambient music while `open` is true; swells back when closed. */
|
||||
export function useModalAmbientDuck(open: boolean) {
|
||||
const { registerModalDuck } = useAmbientMusic();
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
return registerModalDuck();
|
||||
}, [open, registerModalDuck]);
|
||||
}
|
||||
|
||||
138
server/web/src/context/PresenceContext.tsx
Normal file
138
server/web/src/context/PresenceContext.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getStoredUsername } from '../api/auth';
|
||||
import { useWebSocketContext } from './WebSocketContext';
|
||||
import {
|
||||
comradesOnPage,
|
||||
initialPresenceState,
|
||||
onlineComrades,
|
||||
reducePresence,
|
||||
type ComradePresence,
|
||||
type NotesTyping,
|
||||
} from './presenceReducer';
|
||||
|
||||
interface PresenceContextValue {
|
||||
selfUser: string | null;
|
||||
comrades: ComradePresence[];
|
||||
othersOnline: boolean;
|
||||
comradesHere: (page: string) => ComradePresence[];
|
||||
notesTyping: NotesTyping | null;
|
||||
sendNotesTyping: (active: boolean) => void;
|
||||
}
|
||||
|
||||
const PresenceContext = createContext<PresenceContextValue>({
|
||||
selfUser: null,
|
||||
comrades: [],
|
||||
othersOnline: false,
|
||||
comradesHere: () => [],
|
||||
notesTyping: null,
|
||||
sendNotesTyping: () => {},
|
||||
});
|
||||
|
||||
const TYPING_STALE_MS = 4000;
|
||||
|
||||
export function PresenceProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isConnected, latestMessage, sendDashboardMessage } = useWebSocketContext();
|
||||
const location = useLocation();
|
||||
const [state, dispatch] = useReducer(reducePresence, initialPresenceState);
|
||||
const lastPageRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'set_self', user: getStoredUsername() });
|
||||
const onAuth = () => dispatch({ type: 'set_self', user: getStoredUsername() });
|
||||
window.addEventListener('aetherforge-auth', onAuth);
|
||||
return () => window.removeEventListener('aetherforge-auth', onAuth);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestMessage) return;
|
||||
switch (latestMessage.type) {
|
||||
case 'presence_snapshot': {
|
||||
const p = latestMessage.payload as { comrades?: ComradePresence[] };
|
||||
if (Array.isArray(p?.comrades)) {
|
||||
dispatch({ type: 'presence_snapshot', comrades: p.comrades });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'presence_update': {
|
||||
const p = latestMessage.payload as {
|
||||
user?: string;
|
||||
page?: string;
|
||||
online?: boolean;
|
||||
ts?: number;
|
||||
};
|
||||
if (p?.user) {
|
||||
dispatch({
|
||||
type: 'presence_update',
|
||||
user: p.user,
|
||||
page: p.page ?? '/dashboard',
|
||||
online: p.online !== false,
|
||||
ts: p.ts ?? Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'notes_typing': {
|
||||
const p = latestMessage.payload as { user?: string; active?: boolean; ts?: number };
|
||||
if (p?.user) {
|
||||
dispatch({
|
||||
type: 'notes_typing',
|
||||
user: p.user,
|
||||
active: !!p.active,
|
||||
ts: p.ts ?? Date.now(),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [latestMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) {
|
||||
lastPageRef.current = '';
|
||||
return;
|
||||
}
|
||||
const page = location.pathname || '/dashboard';
|
||||
if (page === lastPageRef.current) return;
|
||||
lastPageRef.current = page;
|
||||
sendDashboardMessage('presence_page', { page });
|
||||
}, [isConnected, location.pathname, sendDashboardMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.notesTyping?.active) return;
|
||||
const age = Date.now() - state.notesTyping.ts;
|
||||
const delay = Math.max(0, TYPING_STALE_MS - age);
|
||||
const t = setTimeout(() => dispatch({ type: 'clear_notes_typing' }), delay);
|
||||
return () => clearTimeout(t);
|
||||
}, [state.notesTyping]);
|
||||
|
||||
const sendNotesTyping = useCallback(
|
||||
(active: boolean) => {
|
||||
sendDashboardMessage('notes_typing', { active });
|
||||
},
|
||||
[sendDashboardMessage],
|
||||
);
|
||||
|
||||
const comrades = useMemo(() => onlineComrades(state), [state]);
|
||||
const comradesHere = useCallback((page: string) => comradesOnPage(state, page), [state]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
selfUser: state.selfUser,
|
||||
comrades,
|
||||
othersOnline: comrades.length > 0,
|
||||
comradesHere,
|
||||
notesTyping: state.notesTyping,
|
||||
sendNotesTyping,
|
||||
}),
|
||||
[state.selfUser, comrades, comradesHere, state.notesTyping, sendNotesTyping],
|
||||
);
|
||||
|
||||
return <PresenceContext.Provider value={value}>{children}</PresenceContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePresence(): PresenceContextValue {
|
||||
return useContext(PresenceContext);
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export const SFX_INTERACTIVE_SELECTOR = [
|
||||
'.pt-agent-card:not([style*="cursor: not-allowed"])',
|
||||
'.endpoint-chip:not(:disabled)',
|
||||
'.fleet-group-chip:not(:disabled)',
|
||||
'.forge-mission-wizard-pill:not(:disabled)',
|
||||
'.forge-mission-op-chip:not(:disabled)',
|
||||
'.operator-interactive',
|
||||
'.operator-interactive-btn',
|
||||
].join(', ');
|
||||
|
||||
/** Elements that emit hover highlight SFX (debounced). */
|
||||
@@ -39,6 +43,9 @@ export const HOVER_INTERACTIVE_SELECTOR = [
|
||||
SFX_INTERACTIVE_SELECTOR,
|
||||
'.neon-card',
|
||||
'.card',
|
||||
'.operator-deck-card',
|
||||
'.operator-interactive',
|
||||
'.operator-interactive-btn',
|
||||
'a[href]:not([data-sfx="off"])',
|
||||
].join(', ');
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('WebSocketContext', () => {
|
||||
agentLogs: { 'agent-001-uuid': 'log line' },
|
||||
commandResults: [{ agent_id: 'a1', action: 'pause', success: true, _seq: 1 }],
|
||||
latestMessage: null,
|
||||
sendDashboardMessage: () => {},
|
||||
};
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
import type { WSCommandResult } from '../types/ws';
|
||||
import type { WSCommandResult, WSPolicyAck } from '../types/ws';
|
||||
|
||||
/**
|
||||
* WSCommandResult with a monotonic sequence number attached by the provider.
|
||||
@@ -10,6 +10,8 @@ import type { WSCommandResult } from '../types/ws';
|
||||
*/
|
||||
export type SeqCommandResult = WSCommandResult & { _seq: number };
|
||||
|
||||
export type SeqPolicyAck = WSPolicyAck & { _seq: number };
|
||||
|
||||
export interface WebSocketContextValue {
|
||||
isConnected: boolean;
|
||||
agents: Agent[];
|
||||
@@ -19,8 +21,10 @@ export interface WebSocketContextValue {
|
||||
aiActivity: AIActivityEntry[];
|
||||
agentLogs: Record<string, string>;
|
||||
commandResults: SeqCommandResult[];
|
||||
policyAcks: SeqPolicyAck[];
|
||||
/** @deprecated Use commandResults instead. */
|
||||
latestMessage: WSMessage | null;
|
||||
sendDashboardMessage: (type: string, payload: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const WebSocketContext = createContext<WebSocketContextValue>({
|
||||
@@ -32,7 +36,9 @@ export const WebSocketContext = createContext<WebSocketContextValue>({
|
||||
aiActivity: [],
|
||||
agentLogs: {},
|
||||
commandResults: [],
|
||||
policyAcks: [],
|
||||
latestMessage: null,
|
||||
sendDashboardMessage: () => {},
|
||||
});
|
||||
|
||||
export function useWebSocketContext(): WebSocketContextValue {
|
||||
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
WSStatsUpdate,
|
||||
WSCommandResult,
|
||||
WSAgentLog,
|
||||
WSPolicyAck,
|
||||
} from '../types/ws';
|
||||
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
|
||||
import { WebSocketContext } from './WebSocketContext';
|
||||
import type { SeqCommandResult } from './WebSocketContext';
|
||||
import type { SeqPolicyAck } from './WebSocketContext';
|
||||
import { authHeaders, getStoredAuth } from '../api/auth';
|
||||
|
||||
/**
|
||||
@@ -28,9 +30,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
|
||||
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
|
||||
const [commandResults, setCommandResults] = useState<SeqCommandResult[]>([]);
|
||||
const [policyAcks, setPolicyAcks] = useState<SeqPolicyAck[]>([]);
|
||||
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
|
||||
// Monotonic counter so consumers can detect new entries even after the ring buffer trims old ones
|
||||
const cmdSeqRef = useRef(0);
|
||||
const policyAckSeqRef = useRef(0);
|
||||
|
||||
const sendDashboardMessage = useCallback((type: string, payload: Record<string, unknown>) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({ type, payload }));
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (unmounted.current) return;
|
||||
@@ -231,11 +241,35 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'policy_ack': {
|
||||
const ack = msg.payload as WSPolicyAck;
|
||||
policyAckSeqRef.current += 1;
|
||||
setPolicyAcks((prev) => [...prev.slice(-49), { ...ack, _seq: policyAckSeqRef.current }]);
|
||||
break;
|
||||
}
|
||||
case 'agent_log': {
|
||||
const { agent_id, content } = msg.payload as WSAgentLog;
|
||||
if (agent_id) setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
|
||||
break;
|
||||
}
|
||||
case 'agent_capabilities': {
|
||||
const { agent_id, capabilities } = msg.payload as {
|
||||
agent_id: string;
|
||||
capabilities: Agent['capabilities'];
|
||||
};
|
||||
if (!agent_id || !capabilities) break;
|
||||
setAgents((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === agent_id
|
||||
? {
|
||||
...a,
|
||||
capabilities: { ...a.capabilities, ...capabilities },
|
||||
}
|
||||
: a
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WebSocket message:', err);
|
||||
@@ -263,7 +297,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<WebSocketContext.Provider value={{
|
||||
isConnected, agents, recentShares, fleetAlerts, poolStatus,
|
||||
aiActivity, agentLogs, commandResults, latestMessage,
|
||||
aiActivity, agentLogs, commandResults, policyAcks, latestMessage, sendDashboardMessage,
|
||||
}}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
|
||||
144
server/web/src/context/presenceReducer.test.ts
Normal file
144
server/web/src/context/presenceReducer.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
comradesOnPage,
|
||||
initialPresenceState,
|
||||
onlineComrades,
|
||||
reducePresence,
|
||||
} from './presenceReducer';
|
||||
|
||||
describe('reducePresence', () => {
|
||||
it('tracks self user and excludes from comrades', () => {
|
||||
let state = reducePresence(initialPresenceState, { type: 'set_self', user: 'india' });
|
||||
state = reducePresence(state, {
|
||||
type: 'presence_update',
|
||||
user: 'india',
|
||||
page: '/crucible',
|
||||
online: true,
|
||||
ts: 1,
|
||||
});
|
||||
state = reducePresence(state, {
|
||||
type: 'presence_update',
|
||||
user: 'comrade',
|
||||
page: '/emberwake',
|
||||
online: true,
|
||||
ts: 2,
|
||||
});
|
||||
expect(onlineComrades(state).map((c) => c.user)).toEqual(['comrade']);
|
||||
expect(state.comrades.comrade.page).toBe('/emberwake');
|
||||
});
|
||||
|
||||
it('hydrates from presence_snapshot', () => {
|
||||
const state = reducePresence(
|
||||
{ ...initialPresenceState, selfUser: 'india' },
|
||||
{
|
||||
type: 'presence_snapshot',
|
||||
comrades: [
|
||||
{ user: 'india', page: '/dashboard', online: true, ts: 1 },
|
||||
{ user: 'comrade', page: '/crucible', online: true, ts: 2 },
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(onlineComrades(state)).toHaveLength(1);
|
||||
expect(comradesOnPage(state, '/crucible')[0]?.user).toBe('comrade');
|
||||
});
|
||||
|
||||
it('removes comrade on offline presence_update', () => {
|
||||
let state = reducePresence(initialPresenceState, {
|
||||
type: 'presence_update',
|
||||
user: 'comrade',
|
||||
page: '/forge',
|
||||
online: true,
|
||||
ts: 1,
|
||||
});
|
||||
state = reducePresence(state, {
|
||||
type: 'presence_update',
|
||||
user: 'comrade',
|
||||
page: '',
|
||||
online: false,
|
||||
ts: 2,
|
||||
});
|
||||
expect(onlineComrades(state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('tracks notes_typing from other users only', () => {
|
||||
let state = reducePresence(initialPresenceState, { type: 'set_self', user: 'india' });
|
||||
state = reducePresence(state, {
|
||||
type: 'notes_typing',
|
||||
user: 'comrade',
|
||||
active: true,
|
||||
ts: 100,
|
||||
});
|
||||
expect(state.notesTyping?.user).toBe('comrade');
|
||||
|
||||
state = reducePresence(state, {
|
||||
type: 'notes_typing',
|
||||
user: 'india',
|
||||
active: true,
|
||||
ts: 101,
|
||||
});
|
||||
expect(state.notesTyping?.user).toBe('comrade');
|
||||
|
||||
state = reducePresence(state, {
|
||||
type: 'notes_typing',
|
||||
user: 'comrade',
|
||||
active: false,
|
||||
ts: 102,
|
||||
});
|
||||
expect(state.notesTyping).toBeNull();
|
||||
});
|
||||
|
||||
it('clears notes_typing via clear_notes_typing', () => {
|
||||
let state = reducePresence(initialPresenceState, {
|
||||
type: 'notes_typing',
|
||||
user: 'comrade',
|
||||
active: true,
|
||||
ts: 1,
|
||||
});
|
||||
state = reducePresence(state, { type: 'clear_notes_typing' });
|
||||
expect(state.notesTyping).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces stale notes_typing when another user types', () => {
|
||||
let state = reducePresence(initialPresenceState, {
|
||||
type: 'notes_typing',
|
||||
user: 'alpha',
|
||||
active: true,
|
||||
ts: 1,
|
||||
});
|
||||
state = reducePresence(state, {
|
||||
type: 'notes_typing',
|
||||
user: 'bravo',
|
||||
active: true,
|
||||
ts: 2,
|
||||
});
|
||||
expect(state.notesTyping?.user).toBe('bravo');
|
||||
});
|
||||
|
||||
it('normalizes page paths for comradesOnPage', () => {
|
||||
let state = reducePresence(initialPresenceState, {
|
||||
type: 'presence_update',
|
||||
user: 'comrade',
|
||||
page: 'crucible',
|
||||
online: true,
|
||||
ts: 1,
|
||||
});
|
||||
expect(comradesOnPage(state, '/crucible')).toHaveLength(1);
|
||||
expect(comradesOnPage(state, 'crucible')).toHaveLength(1);
|
||||
expect(comradesOnPage(state, '/emberwake')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('snapshot skips offline comrades and self', () => {
|
||||
const state = reducePresence(
|
||||
{ ...initialPresenceState, selfUser: 'india' },
|
||||
{
|
||||
type: 'presence_snapshot',
|
||||
comrades: [
|
||||
{ user: 'india', page: '/dashboard', online: true, ts: 1 },
|
||||
{ user: 'ghost', page: '/forge', online: false, ts: 2 },
|
||||
{ user: 'comrade', page: '/crucible', online: true, ts: 3 },
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(onlineComrades(state).map((c) => c.user)).toEqual(['comrade']);
|
||||
});
|
||||
});
|
||||
92
server/web/src/context/presenceReducer.ts
Normal file
92
server/web/src/context/presenceReducer.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export interface ComradePresence {
|
||||
user: string;
|
||||
page: string;
|
||||
online: boolean;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface NotesTyping {
|
||||
user: string;
|
||||
active: boolean;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface PresenceState {
|
||||
comrades: Record<string, ComradePresence>;
|
||||
notesTyping: NotesTyping | null;
|
||||
selfUser: string | null;
|
||||
}
|
||||
|
||||
export const initialPresenceState: PresenceState = {
|
||||
comrades: {},
|
||||
notesTyping: null,
|
||||
selfUser: null,
|
||||
};
|
||||
|
||||
export type PresenceAction =
|
||||
| { type: 'set_self'; user: string | null }
|
||||
| { type: 'presence_snapshot'; comrades: ComradePresence[] }
|
||||
| { type: 'presence_update'; user: string; page: string; online: boolean; ts: number }
|
||||
| { type: 'notes_typing'; user: string; active: boolean; ts: number }
|
||||
| { type: 'clear_notes_typing' };
|
||||
|
||||
export function reducePresence(state: PresenceState, action: PresenceAction): PresenceState {
|
||||
switch (action.type) {
|
||||
case 'set_self':
|
||||
return { ...state, selfUser: action.user };
|
||||
case 'presence_snapshot': {
|
||||
const comrades: Record<string, ComradePresence> = {};
|
||||
for (const c of action.comrades) {
|
||||
if (!c.user || !c.online) continue;
|
||||
if (state.selfUser && c.user === state.selfUser) continue;
|
||||
comrades[c.user] = { ...c, online: true };
|
||||
}
|
||||
return { ...state, comrades };
|
||||
}
|
||||
case 'presence_update': {
|
||||
const next = { ...state.comrades };
|
||||
if (!action.online || (state.selfUser && action.user === state.selfUser)) {
|
||||
delete next[action.user];
|
||||
return { ...state, comrades: next };
|
||||
}
|
||||
next[action.user] = {
|
||||
user: action.user,
|
||||
page: action.page || '/dashboard',
|
||||
online: true,
|
||||
ts: action.ts,
|
||||
};
|
||||
return { ...state, comrades: next };
|
||||
}
|
||||
case 'notes_typing': {
|
||||
if (state.selfUser && action.user === state.selfUser) {
|
||||
return state;
|
||||
}
|
||||
if (!action.active) {
|
||||
if (state.notesTyping?.user === action.user) {
|
||||
return { ...state, notesTyping: null };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
notesTyping: { user: action.user, active: true, ts: action.ts },
|
||||
};
|
||||
}
|
||||
case 'clear_notes_typing':
|
||||
return { ...state, notesTyping: null };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function onlineComrades(state: PresenceState): ComradePresence[] {
|
||||
return Object.values(state.comrades).filter((c) => c.online);
|
||||
}
|
||||
|
||||
export function comradesOnPage(state: PresenceState, page: string): ComradePresence[] {
|
||||
const normalized = page.startsWith('/') ? page : `/${page}`;
|
||||
return onlineComrades(state).filter((c) => {
|
||||
const p = c.page.startsWith('/') ? c.page : `/${c.page}`;
|
||||
return p === normalized;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user