From 0f0c4c0ffc3226c176a966ef5c5067e79deac1be Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 28 Jun 2026 12:44:27 -0300 Subject: [PATCH] Add NIP-56 app reporting from the overflow menu --- lib/widgets/app_report_sheet.dart | 251 ++++++++++++++++++ lib/widgets/floating_overflow_menu.dart | 5 + spec/features/FEAT-009-nip56-app-reporting.md | 49 ++++ spec/work/WORK-015-nip56-app-reporting.md | 44 +++ test/widgets/app_report_sheet_test.dart | 51 ++++ 5 files changed, 400 insertions(+) create mode 100644 lib/widgets/app_report_sheet.dart create mode 100644 spec/features/FEAT-009-nip56-app-reporting.md create mode 100644 spec/work/WORK-015-nip56-app-reporting.md create mode 100644 test/widgets/app_report_sheet_test.dart diff --git a/lib/widgets/app_report_sheet.dart b/lib/widgets/app_report_sheet.dart new file mode 100644 index 0000000..12ce2ed --- /dev/null +++ b/lib/widgets/app_report_sheet.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/notification_service.dart'; + +const kMinimumReportDescriptionLength = 20; +final _hexIdentifier = RegExp(r'^[0-9a-f]{64}$', caseSensitive: false); + +bool canSubmitAppReport({ + required ReportType? violationType, + required String description, + required bool isPublishing, +}) => + !isPublishing && + violationType != null && + description.trim().length >= kMinimumReportDescriptionLength; + +bool canReportApp(App app) => + reportableAppEventId(app) != null && _hexIdentifier.hasMatch(app.pubkey); + +String? reportableAppEventId(App app) { + if (_hexIdentifier.hasMatch(app.event.id)) return app.event.id; + + try { + final partial = app.toPartial(); + partial.event.pubkey = app.pubkey; + final recomputedId = partial.event.id; + if (recomputedId != null && _hexIdentifier.hasMatch(recomputedId)) { + return recomputedId; + } + } catch (_) { + return null; + } + + return null; +} + +/// Opens the NIP-56 report flow for an app listing. +void showAppReportSheet(BuildContext context, App app) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => AppReportSheet(app: app), + ); +} + +class AppReportSheet extends HookConsumerWidget { + const AppReportSheet({super.key, required this.app}); + + final App app; + + static const _reportTypes = [ + ReportType.malware, + ReportType.impersonation, + ReportType.spam, + ReportType.illegal, + ReportType.other, + ]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedType = useState(null); + final descriptionController = useTextEditingController(); + final isPublishing = useState(false); + final submissionError = useState(null); + useListenable(descriptionController); + + final description = descriptionController.text.trim(); + final canSubmit = canSubmitAppReport( + violationType: selectedType.value, + description: description, + isPublishing: isPublishing.value, + ); + + return PopScope( + canPop: !isPublishing.value, + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 16, + bottom: MediaQuery.of(context).viewInsets.bottom + 16, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Report ${app.name ?? 'app'}', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + tooltip: 'Close', + onPressed: isPublishing.value + ? null + : () => Navigator.pop(context), + icon: const Icon(Icons.close), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Report only violations of Zapstore’s reporting policy, such ' + 'as malicious software or a deceptive listing. This is not ' + 'for reviews or disagreements.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: selectedType.value, + decoration: const InputDecoration( + labelText: 'Policy violation', + border: OutlineInputBorder(), + ), + hint: const Text('Select a violation'), + items: [ + for (final type in _reportTypes) + DropdownMenuItem(value: type, child: Text(_labelFor(type))), + ], + onChanged: isPublishing.value + ? null + : (type) { + selectedType.value = type; + submissionError.value = null; + }, + ), + const SizedBox(height: 12), + TextField( + controller: descriptionController, + enabled: !isPublishing.value, + minLines: 4, + maxLines: 7, + maxLength: 1000, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: 'Describe the violation', + hintText: 'Explain what makes this listing violate policy.', + border: const OutlineInputBorder(), + errorText: + description.isNotEmpty && + description.length < kMinimumReportDescriptionLength + ? 'Use at least $kMinimumReportDescriptionLength characters.' + : null, + ), + onChanged: (_) => submissionError.value = null, + ), + if (submissionError.value case final error?) ...[ + const SizedBox(height: 8), + Text( + error, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + const SizedBox(height: 8), + Text( + 'Your report will be public and signed by your Nostr identity.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: canSubmit + ? () => _publish( + context: context, + ref: ref, + app: app, + violationType: selectedType.value!, + description: description, + isPublishing: isPublishing, + submissionError: submissionError, + ) + : null, + child: isPublishing.value + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Publish report'), + ), + ), + ], + ), + ), + ), + ); + } + + static String _labelFor(ReportType type) => switch (type) { + ReportType.malware => 'Malicious software', + ReportType.impersonation => 'Impersonation', + ReportType.spam => 'Spam or deceptive listing', + ReportType.illegal => 'Potentially illegal content', + ReportType.other => 'Other policy violation', + _ => type.displayName, + }; + + static Future _publish({ + required BuildContext context, + required WidgetRef ref, + required App app, + required ReportType violationType, + required String description, + required ValueNotifier isPublishing, + required ValueNotifier submissionError, + }) async { + final appEventId = reportableAppEventId(app); + if (appEventId == null) { + submissionError.value = 'This app listing cannot be reported.'; + return; + } + + final signer = ref.read(Signer.activeSignerProvider); + if (signer == null) { + submissionError.value = 'Sign in with Amber to publish a report.'; + return; + } + + isPublishing.value = true; + submissionError.value = null; + try { + final report = await PartialReport.forContent( + contentId: appEventId, + authorPubkey: app.pubkey, + violationType: violationType, + reason: description, + ).signWith(signer); + + await report.save(); + await report.publish(relays: 'AppCatalog'); + + if (context.mounted) { + Navigator.pop(context); + context.showInfo('Report published'); + } + } catch (_) { + submissionError.value = + 'Could not publish the report. Check your connection and retry.'; + } finally { + isPublishing.value = false; + } + } +} diff --git a/lib/widgets/floating_overflow_menu.dart b/lib/widgets/floating_overflow_menu.dart index e6f7013..68f02d2 100644 --- a/lib/widgets/floating_overflow_menu.dart +++ b/lib/widgets/floating_overflow_menu.dart @@ -10,6 +10,7 @@ import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/nostr_route.dart'; +import 'package:zapstore/widgets/app_report_sheet.dart'; /// Floating three-dot overflow menu reusable across detail screens. /// @@ -69,6 +70,8 @@ class FloatingOverflowMenu extends HookConsumerWidget { ), _menuItem('view_publisher', Icons.person, 'View publisher'), _menuItem('open_browser', Icons.open_in_browser, 'Open in browser'), + if (app != null && canReportApp(app!)) + _menuItem('report_app', Icons.flag_outlined, 'Report app'), if (app != null && isInstalled) ...[ _menuItem('open', Icons.open_in_new, 'Open'), _menuItem('delete', Icons.delete_outline, 'Delete'), @@ -120,6 +123,8 @@ class FloatingOverflowMenu extends HookConsumerWidget { _openApp(context, ref); case 'delete': _uninstallApp(context, ref); + case 'report_app': + showAppReportSheet(context, app!); } } diff --git a/spec/features/FEAT-009-nip56-app-reporting.md b/spec/features/FEAT-009-nip56-app-reporting.md new file mode 100644 index 0000000..fca8527 --- /dev/null +++ b/spec/features/FEAT-009-nip56-app-reporting.md @@ -0,0 +1,49 @@ +# FEAT-009 — NIP-56 App Reporting + +## Goal + +Let signed-in users publish a NIP-56 report when an app listing violates +Zapstore's reporting policy, so malicious or deceptive listings can be +identified without making reporting a prominent app-detail action. + +## Non-Goals + +- App reviews, dissatisfaction, or requests for support +- Automatic moderation, warning labels, or removal of reported apps +- Publishing reports outside the AppCatalog relay group +- Reporting individual APK blobs or releases + +## User-Visible Behavior + +- The app-detail overflow menu includes a de-emphasized **Report app** action. +- The report sheet explains that reports are public, signed Nostr events and + are only for policy violations. +- The user must choose a violation category and describe the specific + violation before publishing. +- A report targets the app listing event and its event author, and is + published only to `AppCatalog`. +- The sheet shows a publishing state, a success confirmation, and a clear + failure with a retry path that preserves the entered report. +- A signed-in Nostr identity is required. The report flow must not use the + device key. + +## Edge Cases + +- Signing or relay publishing fails: retain the selected category and + description, then show an actionable error and permit retry. +- No active signer: explain that Amber sign-in is required and do not create + or publish an event. +- Unknown or malformed app event data: do not offer a report if the event ID + or author pubkey is missing or invalid. +- The sheet can be dismissed while no publish is in progress; it cannot be + dismissed through the submit action while publish is in progress. + +## Acceptance Criteria + +- [ ] Users can publish a valid NIP-56 kind `1984` report for an app listing. +- [ ] Every report has a supported violation type and non-empty description. +- [ ] Reports publish only through the `AppCatalog` relay group using the + active user signer. +- [ ] Signing and publish failures are visible, recoverable, and do not + discard form input. +- [ ] Valid NIP-56 three-element report tags parse their violation type. diff --git a/spec/work/WORK-015-nip56-app-reporting.md b/spec/work/WORK-015-nip56-app-reporting.md new file mode 100644 index 0000000..99b8508 --- /dev/null +++ b/spec/work/WORK-015-nip56-app-reporting.md @@ -0,0 +1,44 @@ +# WORK-015 — NIP-56 App Reporting + +**Feature:** FEAT-009-nip56-app-reporting.md +**Status:** Complete + +## Tasks + +- [x] 1. Correct NIP-56 report tag parsing and cover valid tag structure. + - Files: `../models/lib/src/models/reporting.dart`, + `../models/test/models/reporting_test.dart` +- [x] 2. Add an app-report sheet with required policy category and description. + - Files: `lib/widgets/app_report_sheet.dart` +- [x] 3. Add the report action to the app-detail overflow menu. + - Files: `lib/widgets/floating_overflow_menu.dart` +- [x] 4. Verify model tests, Flutter analysis, and focused Flutter tests. + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| Valid report | Kind 1984 event targets app event and author | [x] | +| Missing category or description | Submit remains unavailable | [x] | +| No active signer | Clear error and no event published | [ ] | +| Signing/publish failure | Input remains available for retry | [ ] | + +## Decisions + +### 2026-07-10 — Report transport and identity + +**Context:** Reports need a first relay destination and an accountable identity. +**Options:** Publish to all user relays, AppCatalog only, or use the device key. +**Decision:** Publish only to AppCatalog with the active user signer. +**Rationale:** This matches the current moderation destination decision and +keeps a public report distinct from device-local app state. + +## Spec Issues + +_None_ + +## Progress Notes + +**2026-07-10:** Feature spec authorized by the product owner. Implemented the +report sheet and AppCatalog-only NIP-56 publishing. Model and focused Flutter +tests pass; focused Flutter analysis is clean. diff --git a/test/widgets/app_report_sheet_test.dart b/test/widgets/app_report_sheet_test.dart new file mode 100644 index 0000000..ad6e82e --- /dev/null +++ b/test/widgets/app_report_sheet_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/widgets/app_report_sheet.dart'; + +void main() { + group('canSubmitAppReport', () { + test('requires a policy violation', () { + expect( + canSubmitAppReport( + violationType: null, + description: 'The APK contains a known malicious payload.', + isPublishing: false, + ), + isFalse, + ); + }); + + test('requires a substantive description', () { + expect( + canSubmitAppReport( + violationType: ReportType.malware, + description: 'Malware', + isPublishing: false, + ), + isFalse, + ); + }); + + test('accepts a category and substantive description', () { + expect( + canSubmitAppReport( + violationType: ReportType.malware, + description: 'The APK contains a known malicious payload.', + isPublishing: false, + ), + isTrue, + ); + }); + + test('disables submission while publishing', () { + expect( + canSubmitAppReport( + violationType: ReportType.malware, + description: 'The APK contains a known malicious payload.', + isPublishing: true, + ), + isFalse, + ); + }); + }); +}