Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
eed580c
Add notification storage key scoping utilities
Mosas2000 Mar 20, 2026
0f8c4a0
Add getter and setter for scoped notification timestamps
Mosas2000 Mar 20, 2026
4b7e62a
Import notification storage utilities and network config
Mosas2000 Mar 20, 2026
ce3bc54
Initialize last seen timestamp with migration support
Mosas2000 Mar 20, 2026
92d3b0b
Update markAllRead to use scoped storage setter
Mosas2000 Mar 20, 2026
4259fd5
Fix naming conflict in storage setter import
Mosas2000 Mar 20, 2026
6b3f1b5
Add effect to reset state on address or network change
Mosas2000 Mar 20, 2026
aa64b9d
Add comprehensive tests for notification storage
Mosas2000 Mar 20, 2026
d896eb6
Add tests for multi-account notification isolation
Mosas2000 Mar 20, 2026
beddb86
Add documentation for notification state scoping
Mosas2000 Mar 20, 2026
d05fa90
Update CHANGELOG for notification scoping feature
Mosas2000 Mar 20, 2026
7fb8dd7
Add type validation for storage key parameters
Mosas2000 Mar 20, 2026
87413a3
Add timestamp validation in setter
Mosas2000 Mar 20, 2026
263e3b5
Add error handling and NaN check in getter
Mosas2000 Mar 20, 2026
312c4bf
Add utility to retrieve all scoped notification keys
Mosas2000 Mar 20, 2026
ad7564e
Add function to clear all notification state
Mosas2000 Mar 20, 2026
71c2e7c
Add function to get all notification states for address
Mosas2000 Mar 20, 2026
c3be938
Add tests for new utility functions
Mosas2000 Mar 20, 2026
be90462
Update imports for utility function tests
Mosas2000 Mar 20, 2026
d271143
Filter out legacy key from scoped notification keys
Mosas2000 Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- Notification state scoping by wallet address and network:
- Notification read state now scoped per wallet address and network
- Each wallet maintains independent unread notification counts
- Network switching preserves separate read states
- Automatic migration from legacy single-key storage
- State resets correctly on wallet disconnect/reconnect
- Comprehensive tests for multi-account scenarios
- Documentation in docs/NOTIFICATION_STATE.md

- Frontend environment and contract configuration validation (Issue #289):
- Startup validation blocks application launch when config is invalid
- CI validation script fails fast on misconfigured deployments
Expand Down
167 changes: 167 additions & 0 deletions docs/NOTIFICATION_STATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Notification State Scoping

## Overview

Notification read state is now scoped per wallet address and network to ensure accurate unread counts when users switch between wallets or networks.

## Storage Key Format

```
tipstream_last_seen_{network}_{address}
```

Examples:
- `tipstream_last_seen_mainnet_SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T`
- `tipstream_last_seen_testnet_ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM`

## Behavior

### Address Switching
When a user switches wallets:
- Previous wallet's notification state is preserved
- New wallet loads its own notification state
- Unread counts are isolated per wallet

### Network Switching
When network changes:
- Notification state is scoped to the new network
- Each network maintains separate read state
- Example: mainnet tips remain unread when viewing testnet

### Session Management
- **Logout**: State resets to 0 when address becomes null
- **Reconnect**: Loads saved state for reconnected address
- **Fresh wallet**: Starts with 0 last-seen timestamp

## Migration

### Legacy State
Old installations used a single key:
```
tipstream_last_seen_tip_ts
```

### Migration Process
1. On first load with an address, check for legacy key
2. If legacy value exists and no scoped value, copy to scoped key
3. Legacy key remains for backward compatibility
4. Migration runs once per address-network pair

### Example
```javascript
// Legacy (before)
localStorage.getItem('tipstream_last_seen_tip_ts'); // 1234567890

// After migration for address SP123... on mainnet
localStorage.getItem('tipstream_last_seen_mainnet_SP123...'); // 1234567890

// Original key unchanged
localStorage.getItem('tipstream_last_seen_tip_ts'); // 1234567890
```

## API

### Storage Functions

```javascript
import {
getNotificationStorageKey,
getLastSeenTimestamp,
setLastSeenTimestamp,
migrateLegacyNotificationState
} from './lib/notificationStorage';

// Generate storage key
const key = getNotificationStorageKey(address, network);

// Get last seen timestamp
const timestamp = getLastSeenTimestamp(address, network);

// Save last seen timestamp
setLastSeenTimestamp(address, network, timestamp);

// Migrate legacy state (automatic, called by hook)
const migrated = migrateLegacyNotificationState(address, network);
```

### Hook Usage

```javascript
import { useNotifications } from './hooks/useNotifications';

function MyComponent() {
const { notifications, unreadCount, markAllRead } = useNotifications(userAddress);

// unreadCount is scoped to current address and network
// markAllRead saves to scoped storage
}
```

## Testing

### Multi-Account Tests
Tests verify isolation between different wallets:
```javascript
// Different addresses have independent state
setLastSeenTimestamp('SP111...', 'mainnet', 1000);
setLastSeenTimestamp('SP222...', 'mainnet', 2000);

getLastSeenTimestamp('SP111...', 'mainnet'); // 1000
getLastSeenTimestamp('SP222...', 'mainnet'); // 2000
```

### Network Isolation Tests
Tests verify network-specific state:
```javascript
// Same address on different networks
setLastSeenTimestamp('SP123...', 'mainnet', 1000);
setLastSeenTimestamp('SP123...', 'testnet', 2000);

getLastSeenTimestamp('SP123...', 'mainnet'); // 1000
getLastSeenTimestamp('SP123...', 'testnet'); // 2000
```

## Backward Compatibility

- Existing users with legacy key continue to work
- Legacy state migrates automatically on first load
- No data loss during migration
- Legacy key not deleted for safety

## Edge Cases

### Null Address
```javascript
getLastSeenTimestamp(null, 'mainnet'); // Returns 0
setLastSeenTimestamp(null, 'mainnet', 1000); // No-op
```

### Null Network
```javascript
getLastSeenTimestamp('SP123...', null); // Returns 0
setLastSeenTimestamp('SP123...', null, 1000); // No-op
```

### Invalid Data
```javascript
// Non-numeric value in storage
localStorage.setItem(key, 'invalid');
getLastSeenTimestamp(address, network); // Returns 0
```

## Troubleshooting

### Unread count incorrect after wallet switch
- Check that userAddress prop changed
- Verify hook re-rendered with new address
- Inspect localStorage for scoped keys

### Migration not working
- Ensure legacy key exists
- Check console for errors
- Verify address and network are valid

### State not persisting
- Check localStorage is enabled
- Verify no quota errors in console
- Confirm markAllRead was called
41 changes: 36 additions & 5 deletions frontend/src/hooks/useNotifications.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useTipContext } from '../context/TipContext';

const STORAGE_KEY = 'tipstream_last_seen_tip_ts';
import { NETWORK_NAME } from '../config/contracts';
import {
getLastSeenTimestamp,
setLastSeenTimestamp as saveLastSeenTimestamp,
migrateLegacyNotificationState
} from '../lib/notificationStorage';

/**
* useNotifications -- derives incoming-tip notifications from the shared
Expand All @@ -12,9 +16,34 @@ const STORAGE_KEY = 'tipstream_last_seen_tip_ts';
export function useNotifications(userAddress) {
const { events, eventsLoading } = useTipContext();
const [unreadCount, setUnreadCount] = useState(0);
const initialLastSeen = parseInt(localStorage.getItem(STORAGE_KEY) || '0', 10);
const network = NETWORK_NAME;

const migratedValue = useMemo(() => {
if (!userAddress || !network) return null;
return migrateLegacyNotificationState(userAddress, network);
}, [userAddress, network]);

const initialLastSeen = useMemo(() => {
if (!userAddress || !network) return 0;
const migrated = migrateLegacyNotificationState(userAddress, network);
if (migrated !== null) return migrated;
return getLastSeenTimestamp(userAddress, network);
}, [userAddress, network]);

const lastSeenRef = useRef(initialLastSeen);
const [lastSeenTimestamp, setLastSeenTimestamp] = useState(initialLastSeen);

useEffect(() => {
if (!userAddress || !network) {
lastSeenRef.current = 0;
setLastSeenTimestamp(0);
return;
}

const timestamp = getLastSeenTimestamp(userAddress, network);
lastSeenRef.current = timestamp;
setLastSeenTimestamp(timestamp);
}, [userAddress, network]);

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

const markAllRead = useCallback(() => {
if (!userAddress || !network) return;

const now = Math.floor(Date.now() / 1000);
lastSeenRef.current = now;
setLastSeenTimestamp(now);
localStorage.setItem(STORAGE_KEY, String(now));
saveLastSeenTimestamp(userAddress, network, now);
setUnreadCount(0);
}, []);
}, [userAddress, network]);

return { notifications, unreadCount, lastSeenTimestamp, loading: eventsLoading, markAllRead, refetch: () => {} };
}
87 changes: 87 additions & 0 deletions frontend/src/hooks/useNotifications.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useNotifications } from './useNotifications';
import * as notificationStorage from '../lib/notificationStorage';

vi.mock('../context/TipContext', () => ({
useTipContext: () => ({
events: [],
eventsLoading: false
})
}));

vi.mock('../config/contracts', () => ({
NETWORK_NAME: 'mainnet'
}));

describe('useNotifications - multi-account isolation', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
});

it('should isolate notification state by address', () => {
const address1 = 'SP111111111111111111111111111111111';
const address2 = 'SP222222222222222222222222222222222';

notificationStorage.setLastSeenTimestamp(address1, 'mainnet', 1000);
notificationStorage.setLastSeenTimestamp(address2, 'mainnet', 2000);

const { result: result1 } = renderHook(() => useNotifications(address1));
const { result: result2 } = renderHook(() => useNotifications(address2));

expect(result1.current.lastSeenTimestamp).toBe(1000);
expect(result2.current.lastSeenTimestamp).toBe(2000);
});

it('should not share state between different addresses', () => {
const address1 = 'SP111111111111111111111111111111111';
const address2 = 'SP222222222222222222222222222222222';

const { result: result1 } = renderHook(() => useNotifications(address1));

act(() => {
result1.current.markAllRead();
});

const { result: result2 } = renderHook(() => useNotifications(address2));

expect(result2.current.lastSeenTimestamp).toBe(0);
});

it('should reset state when address changes', () => {
const address1 = 'SP111111111111111111111111111111111';
const address2 = 'SP222222222222222222222222222222222';

notificationStorage.setLastSeenTimestamp(address1, 'mainnet', 1000);
notificationStorage.setLastSeenTimestamp(address2, 'mainnet', 2000);

const { result, rerender } = renderHook(
({ addr }) => useNotifications(addr),
{ initialProps: { addr: address1 } }
);

expect(result.current.lastSeenTimestamp).toBe(1000);

rerender({ addr: address2 });

expect(result.current.lastSeenTimestamp).toBe(2000);
});

it('should reset to zero when disconnecting wallet', () => {
const address = 'SP111111111111111111111111111111111';

notificationStorage.setLastSeenTimestamp(address, 'mainnet', 1000);

const { result, rerender } = renderHook(
({ addr }) => useNotifications(addr),
{ initialProps: { addr: address } }
);

expect(result.current.lastSeenTimestamp).toBe(1000);

rerender({ addr: null });

expect(result.current.lastSeenTimestamp).toBe(0);
});
});
Loading
Loading