mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user