Add NIP-56 app reporting from the overflow menu

This commit is contained in:
franzap
2026-06-28 12:44:27 -03:00
parent 788b371f32
commit 0f0c4c0ffc
5 changed files with 400 additions and 0 deletions
+251
View File
@@ -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<PartialApp>();
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>[
ReportType.malware,
ReportType.impersonation,
ReportType.spam,
ReportType.illegal,
ReportType.other,
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedType = useState<ReportType?>(null);
final descriptionController = useTextEditingController();
final isPublishing = useState(false);
final submissionError = useState<String?>(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<ReportType>(
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<void> _publish({
required BuildContext context,
required WidgetRef ref,
required App app,
required ReportType violationType,
required String description,
required ValueNotifier<bool> isPublishing,
required ValueNotifier<String?> 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;
}
}
}
+5
View File
@@ -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!);
}
}
@@ -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.
+44
View File
@@ -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.
+51
View File
@@ -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,
);
});
});
}