| |
| |
| |
| |
|
|
| export interface ActivityState { |
| |
| seenIds: Set<string>; |
| |
| firstSeenTime: Map<string, number>; |
| |
| newCount: number; |
| |
| lastInteraction: number; |
| } |
|
|
| |
| export const NEW_TAG_DURATION_MS = 2 * 60 * 1000; |
|
|
| |
| |
| |
| |
| |
| |
| |
| export const READ_STATE_KEY = 'wm-read-state-v1'; |
|
|
| |
| const READ_STATE_PERSIST_INTERVAL_MS = 30 * 1000; |
|
|
| interface PersistedReadState { |
| v: 1; |
| lastVisitAt: number; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function getSafeLocalStorage(): Storage | null { |
| try { |
| return typeof localStorage === 'undefined' ? null : localStorage; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| export const HIGHLIGHT_DURATION_MS = 30 * 1000; |
|
|
| class ActivityTracker { |
| private panels: Map<string, ActivityState> = new Map(); |
| private observers: Map<string, IntersectionObserver> = new Map(); |
| private onChangeCallbacks: Map<string, (newCount: number) => void> = new Map(); |
| |
| private previousVisitAt = 0; |
| private lastPersistAt = 0; |
| |
| |
| |
| |
| |
| |
| |
| private lastInteractionAt: number | null = null; |
| private lifecycleInstalled = false; |
|
|
| constructor() { |
| |
| |
| |
| |
| this.loadReadState(); |
| } |
|
|
| private installLifecycleListeners(): void { |
| if (this.lifecycleInstalled || typeof window === 'undefined') return; |
| this.lifecycleInstalled = true; |
| |
| |
| |
| |
| window.addEventListener('beforeunload', () => this.persistLastVisit(true)); |
| document.addEventListener('visibilitychange', () => { |
| if (document.visibilityState === 'hidden') this.persistLastVisit(true); |
| }); |
| |
| |
| |
| |
| window.addEventListener('wm:cloud-prefs-applied', (event) => { |
| const keys = (event as CustomEvent<{ keys?: string[] }>).detail?.keys; |
| if (Array.isArray(keys) && keys.includes(READ_STATE_KEY)) { |
| this.refreshPreviousVisitFromStorage(); |
| } |
| }); |
| } |
|
|
| |
| private refreshPreviousVisitFromStorage(): void { |
| const before = this.previousVisitAt; |
| const current = this.previousVisitAt; |
| this.previousVisitAt = 0; |
| this.loadReadState(); |
| |
| |
| if (this.previousVisitAt < current) this.previousVisitAt = current; |
| if (this.previousVisitAt !== before) this.lastPersistAt = 0; |
| } |
|
|
| private loadReadState(): void { |
| const storage = getSafeLocalStorage(); |
| if (!storage) return; |
| try { |
| const raw = storage.getItem(READ_STATE_KEY); |
| if (!raw) return; |
| const parsed = JSON.parse(raw) as Partial<PersistedReadState>; |
| if (parsed && typeof parsed.lastVisitAt === 'number' && Number.isFinite(parsed.lastVisitAt)) { |
| |
| |
| |
| const maxPlausible = Date.now() + 10 * 60 * 1000; |
| if (parsed.lastVisitAt <= maxPlausible) { |
| this.previousVisitAt = parsed.lastVisitAt; |
| } |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| getPreviousVisitTime(): number { |
| return this.previousVisitAt; |
| } |
|
|
| private persistLastVisit(force = false): void { |
| |
| |
| |
| if (this.lastInteractionAt === null) return; |
| const storage = getSafeLocalStorage(); |
| if (!storage) return; |
| const now = Date.now(); |
| if (!force && now - this.lastPersistAt < READ_STATE_PERSIST_INTERVAL_MS) return; |
| this.lastPersistAt = now; |
| try { |
| const state: PersistedReadState = { v: 1, lastVisitAt: this.lastInteractionAt }; |
| storage.setItem(READ_STATE_KEY, JSON.stringify(state)); |
| } catch { |
| |
| } |
| } |
|
|
| |
| _reloadReadStateForTests(): void { |
| this.previousVisitAt = 0; |
| this.lastPersistAt = 0; |
| this.lastInteractionAt = null; |
| this.loadReadState(); |
| } |
|
|
| |
| |
| |
| register(panelId: string): void { |
| this.installLifecycleListeners(); |
| |
| |
| |
| this.refreshPreviousVisitFromStorage(); |
| if (!this.panels.has(panelId)) { |
| this.panels.set(panelId, { |
| seenIds: new Set(), |
| firstSeenTime: new Map(), |
| newCount: 0, |
| lastInteraction: Date.now(), |
| }); |
| } |
| } |
|
|
| |
| |
| |
| |
| updateItems(panelId: string, itemIds: string[]): string[] { |
| this.register(panelId); |
| const state = this.panels.get(panelId)!; |
| const now = Date.now(); |
| const newItems: string[] = []; |
|
|
| for (const id of itemIds) { |
| |
| if (!state.firstSeenTime.has(id)) { |
| state.firstSeenTime.set(id, now); |
| } |
|
|
| |
| if (!state.seenIds.has(id)) { |
| newItems.push(id); |
| } |
| } |
|
|
| |
| state.newCount = newItems.length; |
|
|
| |
| const callback = this.onChangeCallbacks.get(panelId); |
| if (callback) { |
| callback(state.newCount); |
| } |
|
|
| |
| const currentIds = new Set(itemIds); |
| for (const id of state.firstSeenTime.keys()) { |
| if (!currentIds.has(id)) { |
| state.firstSeenTime.delete(id); |
| state.seenIds.delete(id); |
| } |
| } |
|
|
| return newItems; |
| } |
|
|
| |
| |
| |
| markAsSeen(panelId: string): void { |
| const state = this.panels.get(panelId); |
| if (!state) return; |
|
|
| |
| for (const id of state.firstSeenTime.keys()) { |
| state.seenIds.add(id); |
| } |
|
|
| state.newCount = 0; |
| state.lastInteraction = Date.now(); |
| this.lastInteractionAt = Date.now(); |
| this.persistLastVisit(); |
|
|
| |
| const callback = this.onChangeCallbacks.get(panelId); |
| if (callback) { |
| callback(0); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| markItemsSeen(panelId: string, itemIds: string[]): void { |
| const state = this.panels.get(panelId); |
| if (!state) return; |
|
|
| for (const id of itemIds) { |
| state.seenIds.add(id); |
| } |
| let unseen = 0; |
| for (const id of state.firstSeenTime.keys()) { |
| if (!state.seenIds.has(id)) unseen++; |
| } |
| state.newCount = unseen; |
| state.lastInteraction = Date.now(); |
| |
| |
|
|
| const callback = this.onChangeCallbacks.get(panelId); |
| if (callback) { |
| callback(state.newCount); |
| } |
| } |
|
|
| |
| |
| |
| getNewCount(panelId: string): number { |
| return this.panels.get(panelId)?.newCount ?? 0; |
| } |
|
|
| |
| |
| |
| isNewItem(panelId: string, itemId: string): boolean { |
| const state = this.panels.get(panelId); |
| if (!state) return false; |
|
|
| const firstSeen = state.firstSeenTime.get(itemId); |
| if (!firstSeen) return false; |
|
|
| return Date.now() - firstSeen < NEW_TAG_DURATION_MS; |
| } |
|
|
| |
| |
| |
| shouldHighlight(panelId: string, itemId: string): boolean { |
| const state = this.panels.get(panelId); |
| if (!state) return false; |
|
|
| |
| if (state.seenIds.has(itemId)) return false; |
|
|
| const firstSeen = state.firstSeenTime.get(itemId); |
| if (!firstSeen) return false; |
|
|
| return Date.now() - firstSeen < HIGHLIGHT_DURATION_MS; |
| } |
|
|
| |
| |
| |
| getRelativeTime(panelId: string, itemId: string): string { |
| const state = this.panels.get(panelId); |
| if (!state) return ''; |
|
|
| const firstSeen = state.firstSeenTime.get(itemId); |
| if (!firstSeen) return ''; |
|
|
| const elapsed = Date.now() - firstSeen; |
|
|
| if (elapsed < 60000) { |
| return 'just now'; |
| } else if (elapsed < 3600000) { |
| const mins = Math.floor(elapsed / 60000); |
| return `${mins}m ago`; |
| } else { |
| const hours = Math.floor(elapsed / 3600000); |
| return `${hours}h ago`; |
| } |
| } |
|
|
| |
| |
| |
| onChange(panelId: string, callback: (newCount: number) => void): void { |
| this.onChangeCallbacks.set(panelId, callback); |
| } |
|
|
| |
| |
| |
| observePanel(panelId: string, element: HTMLElement): void { |
| |
| this.observers.get(panelId)?.disconnect(); |
|
|
| const observer = new IntersectionObserver( |
| (entries) => { |
| for (const entry of entries) { |
| if (entry.isIntersecting && entry.intersectionRatio > 0.5) { |
| |
| this.markAsSeen(panelId); |
| } |
| } |
| }, |
| { threshold: 0.5 } |
| ); |
|
|
| observer.observe(element); |
| this.observers.set(panelId, observer); |
| } |
|
|
| |
| |
| |
| unobservePanel(panelId: string): void { |
| this.observers.get(panelId)?.disconnect(); |
| this.observers.delete(panelId); |
| } |
|
|
| |
| |
| |
| unregister(panelId: string): void { |
| this.unobservePanel(panelId); |
| this.onChangeCallbacks.delete(panelId); |
| this.panels.delete(panelId); |
| } |
|
|
| |
| |
| |
| clear(): void { |
| for (const observer of this.observers.values()) { |
| observer.disconnect(); |
| } |
| this.observers.clear(); |
| this.panels.clear(); |
| this.onChangeCallbacks.clear(); |
| } |
| } |
|
|
| |
| export const activityTracker = new ActivityTracker(); |
|
|