fix(mobile): preserve offline section deletion

Persist and display section removal before best-effort filter cleanup. Unfiltered sections skip filter publication; cleanup failures leave an unreachable preference rather than blocking deletion. Exercise the real section provider and persistent storage while filter preferences are read-only.

Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-10-20 09:30:52 -07:00
parent 7c214f19af
commit 2698bd456d
5 changed files with 126 additions and 18 deletions

View File

@@ -100,9 +100,40 @@ class ChannelFilterManager extends ChangeNotifier {
ChannelFilterMode mode, {
int? windowSeconds,
bool removeWindow = false,
}) => _save(
group,
mode,
windowSeconds: windowSeconds,
removeWindow: removeWindow,
);
/// Best-effort cleanup after local deletion. Section IDs are never reused;
/// failure leaves an unreachable preference, never blocks the local action.
Future<void> clearDeletedSection(String sectionId) async {
final group = 'section:$sectionId';
if (store.modeFor(group) == ChannelFilterMode.all &&
!(store.toJson()['activeWindowSeconds'] as Map? ?? {}).containsKey(
group,
)) {
return;
}
await _save(
group,
ChannelFilterMode.all,
removeWindow: true,
deletedSection: true,
);
}
Future<void> _save(
String group,
ChannelFilterMode mode, {
int? windowSeconds,
bool removeWindow = false,
bool deletedSection = false,
}) async {
if (_disposed || !ready || busy) throw StateError('Filters are read-only');
if (isGroupLive?.call(group) == false) {
if (!deletedSection && isGroupLive?.call(group) == false) {
throw StateError('Section no longer exists');
}
busy = true;
@@ -118,7 +149,7 @@ class ChannelFilterManager extends ChangeNotifier {
);
_notify();
final fresh = await _read();
if (isGroupLive?.call(group) == false) {
if (!deletedSection && isGroupLive?.call(group) == false) {
throw StateError('Section no longer exists');
}
final next = fresh.edit(

View File

@@ -104,7 +104,12 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
void renameSection(String sectionId, String newName) =>
_manager?.renameSection(sectionId, newName);
void deleteSection(String sectionId) => _manager?.deleteSection(sectionId);
void deleteSection(String sectionId) {
final manager = _manager;
if (manager == null) return;
manager.deleteSection(sectionId);
_emitManagerState(manager);
}
void moveSectionUp(String sectionId) => _manager?.moveSectionUp(sectionId);

View File

@@ -418,32 +418,25 @@ class _SliverChannelsList extends HookConsumerWidget {
],
),
);
if (confirmed == true) {
if (confirmed == true && context.mounted) {
// Local section removal must work without filter-read/write
// authority. Cleanup is optional for this never-reused ID.
ref
.read(channelSectionsProvider.notifier)
.deleteSection(section.id);
try {
if (filters == null) {
throw StateError('Filters unavailable');
}
await filters.save(
sectionSortGroupKey(section.id),
ChannelFilterMode.all,
removeWindow: true,
);
await filters?.clearDeletedSection(section.id);
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Could not reset the section filter. Retry deleting the section.',
'Section deleted. Its saved filter could not be cleared.',
),
),
);
}
return;
}
if (!context.mounted) return;
ref
.read(channelSectionsProvider.notifier)
.deleteSection(section.id);
}
},
onMoveUp: () => ref

View File

@@ -4460,6 +4460,7 @@ class _CompletedPairingNotifier extends PairingNotifier {
ChannelFilterManager _filterTestManager({
ChannelFilterStore? cached,
bool failPublish = false,
VoidCallback? onPublish,
}) {
final keys = nostr.Keys.generate();
final crypto = ChannelSortCrypto(keys.nsec, keys.public);
@@ -4471,6 +4472,7 @@ ChannelFilterManager _filterTestManager({
cache: (_) {},
fetch: () async => history,
publish: (content, at) async {
onPublish?.call();
if (failPublish) throw StateError('rejected');
history = [
NostrEvent.fromJson(

View File

@@ -3,6 +3,76 @@ part of '../channels_page_test.dart';
void _filterFeedbackTests(
Widget Function({required List<Override> overrides}) buildTestable,
) {
testWidgets(
'deleting an unfiltered section persists offline without filter publication',
(tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
const relayUrl = 'https://offline.example';
final storage = ChannelSectionsStorage(prefs);
storage.write(
keys.public,
relayUrl,
const ChannelSectionStore(
sections: [
ChannelSection(id: 'offline', name: 'Offline section', order: 0),
],
),
);
var publications = 0;
final manager = _filterTestManager(onPublish: () => publications++);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(
() => _FakeNotifier([
Channel(
id: 'channel',
name: 'General',
channelType: 'stream',
visibility: 'open',
description: '',
memberCount: 1,
createdBy: 'pk',
createdAt: DateTime(2025),
isMember: true,
),
]),
),
relayConfigProvider.overrideWith(
() => _FilterFeedbackConfig(
RelayConfig(baseUrl: relayUrl, nsec: keys.nsec),
),
),
relaySessionProvider.overrideWith(
() => _ReconnectingRelaySession(
initialStatus: SessionStatus.disconnected,
),
),
savedPrefsProvider.overrideWithValue(prefs),
channelFilterProvider.overrideWithValue(manager),
],
),
);
await tester.pumpAndSettle();
expect(manager.ready, isFalse);
expect(find.text('Offline section'), findsOneWidget);
await tester.tap(find.byKey(const Key('section-menu-offline')));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Delete section'));
await tester.tap(find.text('Delete section'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(find.text('Offline section'), findsNothing);
expect(storage.read(keys.public, relayUrl).sections, isEmpty);
expect(publications, 0);
await tester.pumpWidget(const SizedBox());
manager.dispose();
},
);
testWidgets('Unreads agrees with highlights in the pre-seed frame', (
tester,
) async {
@@ -77,3 +147,10 @@ void _filterFeedbackTests(
manager.dispose();
});
}
class _FilterFeedbackConfig extends RelayConfigNotifier {
_FilterFeedbackConfig(this.config);
final RelayConfig config;
@override
RelayConfig build() => config;
}