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/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
import 'package:zapstore/utils/nostr_route.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.
|
/// 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('view_publisher', Icons.person, 'View publisher'),
|
||||||
_menuItem('open_browser', Icons.open_in_browser, 'Open in browser'),
|
_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) ...[
|
if (app != null && isInstalled) ...[
|
||||||
_menuItem('open', Icons.open_in_new, 'Open'),
|
_menuItem('open', Icons.open_in_new, 'Open'),
|
||||||
_menuItem('delete', Icons.delete_outline, 'Delete'),
|
_menuItem('delete', Icons.delete_outline, 'Delete'),
|
||||||
@@ -120,6 +123,8 @@ class FloatingOverflowMenu extends HookConsumerWidget {
|
|||||||
_openApp(context, ref);
|
_openApp(context, ref);
|
||||||
case 'delete':
|
case 'delete':
|
||||||
_uninstallApp(context, ref);
|
_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