Skip to content

Commit 47a1637

Browse files
committed
fix(profile): expire dismissed session banner
1 parent 26a0000 commit 47a1637

File tree

7 files changed

+320
-21
lines changed

7 files changed

+320
-21
lines changed
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Divine Login Banner Dismissal TTL Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Make the profile session-expired banner snooze for 30 days instead of forever, and clear the snooze when Divine OAuth recovery succeeds.
6+
7+
**Architecture:** Add a tiny shared helper for the dismissal key and TTL logic, then reuse it from the profile header and auth service. Cover the behavior with one widget regression slice and one auth regression slice before implementation.
8+
9+
**Tech Stack:** Flutter, Riverpod, SharedPreferences, Flutter widget tests, auth service unit tests
10+
11+
---
12+
13+
## Chunk 1: TTL Read/Write Behavior
14+
15+
### Task 1: Add the failing widget regressions
16+
17+
**Files:**
18+
- Modify: `mobile/test/widgets/profile/profile_header_widget_test.dart`
19+
20+
- [ ] **Step 1: Write the failing tests**
21+
22+
Add tests for:
23+
- a recent dismissal timestamp hides the expired-session banner
24+
- a dismissal older than 30 days shows the banner again
25+
26+
- [ ] **Step 2: Run the widget test file to verify the new case fails**
27+
28+
Run: `cd mobile && flutter test test/widgets/profile/profile_header_widget_test.dart`
29+
Expected: the new TTL expectation fails because the current code treats dismissal as permanent.
30+
31+
### Task 2: Implement the TTL helper and widget update
32+
33+
**Files:**
34+
- Create: `mobile/lib/utils/divine_login_banner_dismissal.dart`
35+
- Modify: `mobile/lib/widgets/profile/profile_header_widget.dart`
36+
37+
- [ ] **Step 3: Add the minimal helper**
38+
39+
Add:
40+
- per-pubkey preference key builder
41+
- 30-day TTL constant
42+
- `isDismissed(...)`
43+
- `dismiss(...)`
44+
- `clear(...)`
45+
46+
- [ ] **Step 4: Update the profile header to use the helper**
47+
48+
Read the active dismissal via the helper and write a timestamp on dismiss instead of a boolean.
49+
50+
- [ ] **Step 5: Re-run the widget test file**
51+
52+
Run: `cd mobile && flutter test test/widgets/profile/profile_header_widget_test.dart`
53+
Expected: the TTL tests pass.
54+
55+
## Chunk 2: Shared Helper Coverage And Auth Reset
56+
57+
### Task 3: Add the failing helper regression
58+
59+
**Files:**
60+
- Create: `mobile/test/utils/divine_login_banner_dismissal_test.dart`
61+
62+
- [ ] **Step 6: Write the failing helper test**
63+
64+
Cover:
65+
- dismissal remains active within 30 days
66+
- dismissal expires after 30 days
67+
- clear removes the stored dismissal
68+
69+
- [ ] **Step 7: Run the helper test file to verify it fails**
70+
71+
Run: `cd mobile && flutter test test/utils/divine_login_banner_dismissal_test.dart`
72+
Expected: compilation or test failure because the helper does not exist yet.
73+
74+
### Task 4: Clear dismissal on auth recovery
75+
76+
**Files:**
77+
- Modify: `mobile/lib/services/auth_service.dart`
78+
- Reuse: `mobile/lib/utils/divine_login_banner_dismissal.dart`
79+
80+
- [ ] **Step 8: Add the minimal auth-side reset**
81+
82+
Clear the stored dismissal when:
83+
- a silent refresh succeeds
84+
- Divine OAuth sign-in establishes the session for a pubkey
85+
86+
- [ ] **Step 9: Re-run the helper test file**
87+
88+
Run: `cd mobile && flutter test test/utils/divine_login_banner_dismissal_test.dart`
89+
Expected: the helper regression passes, and the auth service compiles cleanly against it.
90+
91+
## Chunk 3: Focused Verification
92+
93+
### Task 5: Verify the final touched set
94+
95+
**Files:**
96+
- Verify: `mobile/lib/widgets/profile/profile_header_widget.dart`
97+
- Verify: `mobile/lib/services/auth_service.dart`
98+
- Verify: `mobile/lib/utils/divine_login_banner_dismissal.dart`
99+
- Verify: `mobile/test/widgets/profile/profile_header_widget_test.dart`
100+
- Verify: `mobile/test/utils/divine_login_banner_dismissal_test.dart`
101+
102+
- [ ] **Step 10: Run focused analyzer**
103+
104+
Run: `cd mobile && flutter analyze --no-pub lib/widgets/profile/profile_header_widget.dart lib/services/auth_service.dart lib/utils/divine_login_banner_dismissal.dart test/widgets/profile/profile_header_widget_test.dart test/utils/divine_login_banner_dismissal_test.dart`
105+
Expected: no issues found.
106+
107+
- [ ] **Step 11: Run focused tests together**
108+
109+
Run: `cd mobile && flutter test test/utils/divine_login_banner_dismissal_test.dart test/widgets/profile/profile_header_widget_test.dart`
110+
Expected: all tests pass.
111+
112+
- [ ] **Step 12: Commit**
113+
114+
```bash
115+
git add docs/superpowers/specs/2026-03-22-divine-login-banner-dismissal-ttl-design.md \
116+
docs/superpowers/plans/2026-03-22-divine-login-banner-dismissal-ttl.md \
117+
mobile/lib/utils/divine_login_banner_dismissal.dart \
118+
mobile/lib/widgets/profile/profile_header_widget.dart \
119+
mobile/lib/services/auth_service.dart \
120+
mobile/test/widgets/profile/profile_header_widget_test.dart \
121+
mobile/test/utils/divine_login_banner_dismissal_test.dart
122+
git commit -m "fix(profile): expire dismissed session banner"
123+
```
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Divine Login Banner Dismissal TTL Design
2+
3+
## Goal
4+
5+
Make the dismissible "Session Expired" banner on the profile page come back after 30 days if the account is still expired, and clear the dismissal as soon as the user successfully restores their Divine OAuth session.
6+
7+
## Current Problem
8+
9+
PR `#2214` stores a permanent boolean dismissal keyed by pubkey. Once dismissed, the banner never appears again for that account because nothing resets the preference when auth recovers or when enough time passes.
10+
11+
## Design
12+
13+
Store dismissal as a timestamp instead of a boolean. The profile banner should be hidden only when:
14+
15+
- the current account has an expired OAuth session, and
16+
- a stored dismissal timestamp exists for that account, and
17+
- that dismissal is less than 30 days old.
18+
19+
If the dismissal is missing or older than 30 days, the banner should render again.
20+
21+
Use one shared helper for:
22+
23+
- generating the per-pubkey preference key
24+
- reading whether the dismissal is still active
25+
- writing the current dismissal timestamp
26+
- clearing the dismissal after auth recovery
27+
28+
## Reset Behavior
29+
30+
Clear the dismissal when the app successfully restores a valid Divine OAuth session:
31+
32+
- after a successful silent refresh path
33+
- after a successful Divine OAuth sign-in path
34+
35+
This keeps dismissal scoped to the current expired-session incident rather than muting future incidents forever.
36+
37+
## Tests
38+
39+
Add regression coverage for:
40+
41+
- dismissal hides the banner within 30 days
42+
- dismissal older than 30 days allows the banner to show again
43+
- successful expired-session refresh clears the stored dismissal key

mobile/lib/services/auth_service.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'package:openvine/services/crash_reporting_service.dart';
1818
import 'package:openvine/services/pending_verification_service.dart';
1919
import 'package:openvine/services/relay_discovery_service.dart';
2020
import 'package:openvine/services/user_data_cleanup_service.dart';
21+
import 'package:openvine/utils/divine_login_banner_dismissal.dart';
2122
import 'package:openvine/utils/nostr_key_utils.dart';
2223
import 'package:openvine/utils/nostr_timestamp.dart';
2324
import 'package:openvine/utils/unified_logger.dart';
@@ -295,6 +296,7 @@ class AuthService implements BackgroundAwareService {
295296
category: LogCategory.auth,
296297
);
297298
await refreshed.save(_flutterSecureStorage);
299+
await _clearDismissedDivineLoginBannerForCurrentUser();
298300
await signInWithDivineOAuth(refreshed);
299301
return true;
300302
}
@@ -308,6 +310,18 @@ class AuthService implements BackgroundAwareService {
308310
return false;
309311
}
310312

313+
Future<void> _clearDismissedDivineLoginBannerForCurrentUser([
314+
String? publicKeyHex,
315+
]) async {
316+
final prefs = await SharedPreferences.getInstance();
317+
final targetPubkey =
318+
publicKeyHex ?? prefs.getString('current_user_pubkey_hex');
319+
if (targetPubkey == null || targetPubkey.isEmpty) {
320+
return;
321+
}
322+
await clearDivineLoginBannerDismissal(prefs, targetPubkey);
323+
}
324+
311325
/// Get discovered user relays (NIP-65)
312326
List<DiscoveredRelay> get userRelays => List.unmodifiable(_userRelays);
313327

@@ -2276,6 +2290,7 @@ class AuthService implements BackgroundAwareService {
22762290

22772291
final prefs = await SharedPreferences.getInstance();
22782292
await prefs.setString('current_user_pubkey_hex', publicKeyHex);
2293+
await _clearDismissedDivineLoginBannerForCurrentUser(publicKeyHex);
22792294

22802295
Log.info(
22812296
'✅ Divine oauth listener setting auth state to authenticated.',
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import 'package:shared_preferences/shared_preferences.dart';
2+
3+
const Duration divineLoginBannerDismissalTtl = Duration(days: 30);
4+
const _dismissedDivineLoginBannerPrefix = 'dismissed_divine_login_banner_';
5+
6+
String divineLoginBannerDismissalKey(String userIdHex) =>
7+
'$_dismissedDivineLoginBannerPrefix$userIdHex';
8+
9+
bool isDivineLoginBannerDismissed(
10+
SharedPreferences prefs,
11+
String userIdHex, {
12+
DateTime? now,
13+
}) {
14+
final rawValue = prefs.get(divineLoginBannerDismissalKey(userIdHex));
15+
if (rawValue is! int) {
16+
return false;
17+
}
18+
19+
final dismissedAt = DateTime.fromMillisecondsSinceEpoch(rawValue);
20+
final comparisonTime = now ?? DateTime.now();
21+
return comparisonTime.difference(dismissedAt) < divineLoginBannerDismissalTtl;
22+
}
23+
24+
Future<void> dismissDivineLoginBanner(
25+
SharedPreferences prefs,
26+
String userIdHex, {
27+
DateTime? now,
28+
}) {
29+
final dismissedAt = now ?? DateTime.now();
30+
return prefs.setInt(
31+
divineLoginBannerDismissalKey(userIdHex),
32+
dismissedAt.millisecondsSinceEpoch,
33+
);
34+
}
35+
36+
Future<void> clearDivineLoginBannerDismissal(
37+
SharedPreferences prefs,
38+
String userIdHex,
39+
) {
40+
return prefs.remove(divineLoginBannerDismissalKey(userIdHex));
41+
}

mobile/lib/widgets/profile/profile_header_widget.dart

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'package:openvine/screens/auth/secure_account_screen.dart';
1818
import 'package:openvine/screens/auth/welcome_screen.dart';
1919
import 'package:openvine/services/nip05_verification_service.dart';
2020
import 'package:openvine/utils/clipboard_utils.dart';
21+
import 'package:openvine/utils/divine_login_banner_dismissal.dart';
2122
import 'package:openvine/utils/nostr_key_utils.dart';
2223
import 'package:openvine/utils/user_profile_utils.dart';
2324
import 'package:openvine/widgets/profile/profile_followers_stat.dart';
@@ -28,9 +29,6 @@ import 'package:openvine/widgets/user_name.dart';
2829

2930
/// Profile header widget displaying avatar, stats, name, and bio.
3031
class ProfileHeaderWidget extends ConsumerWidget {
31-
static const _dismissedDivineLoginBannerPrefix =
32-
'dismissed_divine_login_banner_';
33-
3432
const ProfileHeaderWidget({
3533
required this.userIdHex,
3634
required this.isOwnProfile,
@@ -94,10 +92,10 @@ class ProfileHeaderWidget extends ConsumerWidget {
9492
final isAnonymous = authService.isAnonymous;
9593
final hasExpiredSession = authService.hasExpiredOAuthSession;
9694
final prefs = ref.watch(sharedPreferencesProvider);
97-
final dismissedDivineLoginBannerKey =
98-
'$_dismissedDivineLoginBannerPrefix$userIdHex';
99-
final isDivineLoginBannerDismissed =
100-
prefs.getBool(dismissedDivineLoginBannerKey) ?? false;
95+
final isDivineLoginBannerHidden = isDivineLoginBannerDismissed(
96+
prefs,
97+
userIdHex,
98+
);
10199

102100
// Use profile color as header background (like original Vine)
103101
// Color covers avatar/stats, then fades to dark for name/bio readability
@@ -139,9 +137,9 @@ class ProfileHeaderWidget extends ConsumerWidget {
139137
// profile) — prompts re-login instead of "Secure Your Account"
140138
if (isOwnProfile &&
141139
hasExpiredSession &&
142-
!isDivineLoginBannerDismissed)
140+
!isDivineLoginBannerHidden)
143141
_SessionExpiredBanner(
144-
dismissedPreferenceKey: dismissedDivineLoginBannerKey,
142+
userIdHex: userIdHex,
145143
)
146144
// Secure account banner for anonymous users (only on own
147145
// profile)
@@ -433,9 +431,9 @@ class _IdentityNotRecoverableBanner extends StatelessWidget {
433431
/// Prompts the user to sign in again instead of showing "Secure Your Account".
434432
/// Attempts a silent token refresh first; navigates to login only if that fails.
435433
class _SessionExpiredBanner extends ConsumerStatefulWidget {
436-
const _SessionExpiredBanner({required this.dismissedPreferenceKey});
434+
const _SessionExpiredBanner({required this.userIdHex});
437435

438-
final String dismissedPreferenceKey;
436+
final String userIdHex;
439437

440438
@override
441439
ConsumerState<_SessionExpiredBanner> createState() =>
@@ -463,7 +461,7 @@ class _SessionExpiredBannerState extends ConsumerState<_SessionExpiredBanner> {
463461
Future<void> _dismissBanner() async {
464462
setState(() => _isDismissed = true);
465463
final prefs = ref.read(sharedPreferencesProvider);
466-
await prefs.setBool(widget.dismissedPreferenceKey, true);
464+
await dismissDivineLoginBanner(prefs, widget.userIdHex);
467465
}
468466

469467
@override
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:openvine/utils/divine_login_banner_dismissal.dart';
3+
import 'package:shared_preferences/shared_preferences.dart';
4+
5+
void main() {
6+
const pubkey = 'test_pubkey_hex';
7+
8+
setUp(() async {
9+
SharedPreferences.setMockInitialValues({});
10+
});
11+
12+
test('dismissal remains active within 30 days', () async {
13+
final prefs = await SharedPreferences.getInstance();
14+
15+
await dismissDivineLoginBanner(
16+
prefs,
17+
pubkey,
18+
now: DateTime(2026, 3, 22),
19+
);
20+
21+
expect(
22+
isDivineLoginBannerDismissed(
23+
prefs,
24+
pubkey,
25+
now: DateTime(2026, 4, 20),
26+
),
27+
isTrue,
28+
);
29+
});
30+
31+
test('dismissal expires after 30 days', () async {
32+
final prefs = await SharedPreferences.getInstance();
33+
34+
await dismissDivineLoginBanner(
35+
prefs,
36+
pubkey,
37+
now: DateTime(2026, 3, 22),
38+
);
39+
40+
expect(
41+
isDivineLoginBannerDismissed(
42+
prefs,
43+
pubkey,
44+
now: DateTime(2026, 4, 22),
45+
),
46+
isFalse,
47+
);
48+
});
49+
50+
test('clear removes stored dismissal', () async {
51+
final prefs = await SharedPreferences.getInstance();
52+
53+
await dismissDivineLoginBanner(prefs, pubkey, now: DateTime(2026, 3, 22));
54+
await clearDivineLoginBannerDismissal(prefs, pubkey);
55+
56+
expect(prefs.get(divineLoginBannerDismissalKey(pubkey)), isNull);
57+
});
58+
}

0 commit comments

Comments
 (0)