mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Add NIP-56 app reporting from the overflow menu
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user