Skip to content

Commit 1cedbf4

Browse files
authored
Merge pull request #304 from Mosas2000/fix/notification-state-scoping
Fix/notification state scoping
2 parents 627527f + d271143 commit 1cedbf4

File tree

6 files changed

+593
-5
lines changed

6 files changed

+593
-5
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
### Added
1010

11+
- Notification state scoping by wallet address and network:
12+
- Notification read state now scoped per wallet address and network
13+
- Each wallet maintains independent unread notification counts
14+
- Network switching preserves separate read states
15+
- Automatic migration from legacy single-key storage
16+
- State resets correctly on wallet disconnect/reconnect
17+
- Comprehensive tests for multi-account scenarios
18+
- Documentation in docs/NOTIFICATION_STATE.md
19+
1120
- Frontend environment and contract configuration validation (Issue #289):
1221
- Startup validation blocks application launch when config is invalid
1322
- CI validation script fails fast on misconfigured deployments

docs/NOTIFICATION_STATE.md

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# Notification State Scoping
2+
3+
## Overview
4+
5+
Notification read state is now scoped per wallet address and network to ensure accurate unread counts when users switch between wallets or networks.
6+
7+
## Storage Key Format
8+
9+
```
10+
tipstream_last_seen_{network}_{address}
11+
```
12+
13+
Examples:
14+
- `tipstream_last_seen_mainnet_SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T`
15+
- `tipstream_last_seen_testnet_ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM`
16+
17+
## Behavior
18+
19+
### Address Switching
20+
When a user switches wallets:
21+
- Previous wallet's notification state is preserved
22+
- New wallet loads its own notification state
23+
- Unread counts are isolated per wallet
24+
25+
### Network Switching
26+
When network changes:
27+
- Notification state is scoped to the new network
28+
- Each network maintains separate read state
29+
- Example: mainnet tips remain unread when viewing testnet
30+
31+
### Session Management
32+
- **Logout**: State resets to 0 when address becomes null
33+
- **Reconnect**: Loads saved state for reconnected address
34+
- **Fresh wallet**: Starts with 0 last-seen timestamp
35+
36+
## Migration
37+
38+
### Legacy State
39+
Old installations used a single key:
40+
```
41+
tipstream_last_seen_tip_ts
42+
```
43+
44+
### Migration Process
45+
1. On first load with an address, check for legacy key
46+
2. If legacy value exists and no scoped value, copy to scoped key
47+
3. Legacy key remains for backward compatibility
48+
4. Migration runs once per address-network pair
49+
50+
### Example
51+
```javascript
52+
// Legacy (before)
53+
localStorage.getItem('tipstream_last_seen_tip_ts'); // 1234567890
54+
55+
// After migration for address SP123... on mainnet
56+
localStorage.getItem('tipstream_last_seen_mainnet_SP123...'); // 1234567890
57+
58+
// Original key unchanged
59+
localStorage.getItem('tipstream_last_seen_tip_ts'); // 1234567890
60+
```
61+
62+
## API
63+
64+
### Storage Functions
65+
66+
```javascript
67+
import {
68+
getNotificationStorageKey,
69+
getLastSeenTimestamp,
70+
setLastSeenTimestamp,
71+
migrateLegacyNotificationState
72+
} from './lib/notificationStorage';
73+
74+
// Generate storage key
75+
const key = getNotificationStorageKey(address, network);
76+
77+
// Get last seen timestamp
78+
const timestamp = getLastSeenTimestamp(address, network);
79+
80+
// Save last seen timestamp
81+
setLastSeenTimestamp(address, network, timestamp);
82+
83+
// Migrate legacy state (automatic, called by hook)
84+
const migrated = migrateLegacyNotificationState(address, network);
85+
```
86+
87+
### Hook Usage
88+
89+
```javascript
90+
import { useNotifications } from './hooks/useNotifications';
91+
92+
function MyComponent() {
93+
const { notifications, unreadCount, markAllRead } = useNotifications(userAddress);
94+
95+
// unreadCount is scoped to current address and network
96+
// markAllRead saves to scoped storage
97+
}
98+
```
99+
100+
## Testing
101+
102+
### Multi-Account Tests
103+
Tests verify isolation between different wallets:
104+
```javascript
105+
// Different addresses have independent state
106+
setLastSeenTimestamp('SP111...', 'mainnet', 1000);
107+
setLastSeenTimestamp('SP222...', 'mainnet', 2000);
108+
109+
getLastSeenTimestamp('SP111...', 'mainnet'); // 1000
110+
getLastSeenTimestamp('SP222...', 'mainnet'); // 2000
111+
```
112+
113+
### Network Isolation Tests
114+
Tests verify network-specific state:
115+
```javascript
116+
// Same address on different networks
117+
setLastSeenTimestamp('SP123...', 'mainnet', 1000);
118+
setLastSeenTimestamp('SP123...', 'testnet', 2000);
119+
120+
getLastSeenTimestamp('SP123...', 'mainnet'); // 1000
121+
getLastSeenTimestamp('SP123...', 'testnet'); // 2000
122+
```
123+
124+
## Backward Compatibility
125+
126+
- Existing users with legacy key continue to work
127+
- Legacy state migrates automatically on first load
128+
- No data loss during migration
129+
- Legacy key not deleted for safety
130+
131+
## Edge Cases
132+
133+
### Null Address
134+
```javascript
135+
getLastSeenTimestamp(null, 'mainnet'); // Returns 0
136+
setLastSeenTimestamp(null, 'mainnet', 1000); // No-op
137+
```
138+
139+
### Null Network
140+
```javascript
141+
getLastSeenTimestamp('SP123...', null); // Returns 0
142+
setLastSeenTimestamp('SP123...', null, 1000); // No-op
143+
```
144+
145+
### Invalid Data
146+
```javascript
147+
// Non-numeric value in storage
148+
localStorage.setItem(key, 'invalid');
149+
getLastSeenTimestamp(address, network); // Returns 0
150+
```
151+
152+
## Troubleshooting
153+
154+
### Unread count incorrect after wallet switch
155+
- Check that userAddress prop changed
156+
- Verify hook re-rendered with new address
157+
- Inspect localStorage for scoped keys
158+
159+
### Migration not working
160+
- Ensure legacy key exists
161+
- Check console for errors
162+
- Verify address and network are valid
163+
164+
### State not persisting
165+
- Check localStorage is enabled
166+
- Verify no quota errors in console
167+
- Confirm markAllRead was called

frontend/src/hooks/useNotifications.js

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
22
import { useTipContext } from '../context/TipContext';
3-
4-
const STORAGE_KEY = 'tipstream_last_seen_tip_ts';
3+
import { NETWORK_NAME } from '../config/contracts';
4+
import {
5+
getLastSeenTimestamp,
6+
setLastSeenTimestamp as saveLastSeenTimestamp,
7+
migrateLegacyNotificationState
8+
} from '../lib/notificationStorage';
59

610
/**
711
* useNotifications -- derives incoming-tip notifications from the shared
@@ -12,9 +16,34 @@ const STORAGE_KEY = 'tipstream_last_seen_tip_ts';
1216
export function useNotifications(userAddress) {
1317
const { events, eventsLoading } = useTipContext();
1418
const [unreadCount, setUnreadCount] = useState(0);
15-
const initialLastSeen = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10);
19+
const network = NETWORK_NAME;
20+
21+
const migratedValue = useMemo(() => {
22+
if (!userAddress || !network) return null;
23+
return migrateLegacyNotificationState(userAddress, network);
24+
}, [userAddress, network]);
25+
26+
const initialLastSeen = useMemo(() => {
27+
if (!userAddress || !network) return 0;
28+
const migrated = migrateLegacyNotificationState(userAddress, network);
29+
if (migrated !== null) return migrated;
30+
return getLastSeenTimestamp(userAddress, network);
31+
}, [userAddress, network]);
32+
1633
const lastSeenRef = useRef(initialLastSeen);
1734
const [lastSeenTimestamp, setLastSeenTimestamp] = useState(initialLastSeen);
35+
36+
useEffect(() => {
37+
if (!userAddress || !network) {
38+
lastSeenRef.current = 0;
39+
setLastSeenTimestamp(0);
40+
return;
41+
}
42+
43+
const timestamp = getLastSeenTimestamp(userAddress, network);
44+
lastSeenRef.current = timestamp;
45+
setLastSeenTimestamp(timestamp);
46+
}, [userAddress, network]);
1847

1948
/** Derive received tips from the shared event cache. */
2049
const notifications = useMemo(() => {
@@ -39,12 +68,14 @@ export function useNotifications(userAddress) {
3968
}, [notifications]);
4069

4170
const markAllRead = useCallback(() => {
71+
if (!userAddress || !network) return;
72+
4273
const now = Math.floor(Date.now() / 1000);
4374
lastSeenRef.current = now;
4475
setLastSeenTimestamp(now);
45-
localStorage.setItem(STORAGE_KEY, String(now));
76+
saveLastSeenTimestamp(userAddress, network, now);
4677
setUnreadCount(0);
47-
}, []);
78+
}, [userAddress, network]);
4879

4980
return { notifications, unreadCount, lastSeenTimestamp, loading: eventsLoading, markAllRead, refetch: () => {} };
5081
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { renderHook, act } from '@testing-library/react';
3+
import { useNotifications } from './useNotifications';
4+
import * as notificationStorage from '../lib/notificationStorage';
5+
6+
vi.mock('../context/TipContext', () => ({
7+
useTipContext: () => ({
8+
events: [],
9+
eventsLoading: false
10+
})
11+
}));
12+
13+
vi.mock('../config/contracts', () => ({
14+
NETWORK_NAME: 'mainnet'
15+
}));
16+
17+
describe('useNotifications - multi-account isolation', () => {
18+
beforeEach(() => {
19+
localStorage.clear();
20+
vi.clearAllMocks();
21+
});
22+
23+
it('should isolate notification state by address', () => {
24+
const address1 = 'SP111111111111111111111111111111111';
25+
const address2 = 'SP222222222222222222222222222222222';
26+
27+
notificationStorage.setLastSeenTimestamp(address1, 'mainnet', 1000);
28+
notificationStorage.setLastSeenTimestamp(address2, 'mainnet', 2000);
29+
30+
const { result: result1 } = renderHook(() => useNotifications(address1));
31+
const { result: result2 } = renderHook(() => useNotifications(address2));
32+
33+
expect(result1.current.lastSeenTimestamp).toBe(1000);
34+
expect(result2.current.lastSeenTimestamp).toBe(2000);
35+
});
36+
37+
it('should not share state between different addresses', () => {
38+
const address1 = 'SP111111111111111111111111111111111';
39+
const address2 = 'SP222222222222222222222222222222222';
40+
41+
const { result: result1 } = renderHook(() => useNotifications(address1));
42+
43+
act(() => {
44+
result1.current.markAllRead();
45+
});
46+
47+
const { result: result2 } = renderHook(() => useNotifications(address2));
48+
49+
expect(result2.current.lastSeenTimestamp).toBe(0);
50+
});
51+
52+
it('should reset state when address changes', () => {
53+
const address1 = 'SP111111111111111111111111111111111';
54+
const address2 = 'SP222222222222222222222222222222222';
55+
56+
notificationStorage.setLastSeenTimestamp(address1, 'mainnet', 1000);
57+
notificationStorage.setLastSeenTimestamp(address2, 'mainnet', 2000);
58+
59+
const { result, rerender } = renderHook(
60+
({ addr }) => useNotifications(addr),
61+
{ initialProps: { addr: address1 } }
62+
);
63+
64+
expect(result.current.lastSeenTimestamp).toBe(1000);
65+
66+
rerender({ addr: address2 });
67+
68+
expect(result.current.lastSeenTimestamp).toBe(2000);
69+
});
70+
71+
it('should reset to zero when disconnecting wallet', () => {
72+
const address = 'SP111111111111111111111111111111111';
73+
74+
notificationStorage.setLastSeenTimestamp(address, 'mainnet', 1000);
75+
76+
const { result, rerender } = renderHook(
77+
({ addr }) => useNotifications(addr),
78+
{ initialProps: { addr: address } }
79+
);
80+
81+
expect(result.current.lastSeenTimestamp).toBe(1000);
82+
83+
rerender({ addr: null });
84+
85+
expect(result.current.lastSeenTimestamp).toBe(0);
86+
});
87+
});

0 commit comments

Comments
 (0)