improved reliability and error handling for background notification actions by migrating to a top-level entry point for stable AOT callback resolution; implemented failure notifications to preserve undelivered reply text and surface technical errors; resolved a race condition that could resurrect dismissed notifications during silent re-renders; updated the Android manifest with the required broadcast receiver for notification actions; and refactored internal OCS methods to return detailed success/failure records for improved logging and field debugging.
This commit is contained in:
@@ -59,6 +59,14 @@
|
|||||||
android:name="com.google.firebase.messaging.default_notification_color"
|
android:name="com.google.firebase.messaging.default_notification_color"
|
||||||
android:resource="@color/notification_accent" />
|
android:resource="@color/notification_accent" />
|
||||||
|
|
||||||
|
<!-- flutter_local_notifications liefert Notification-Action-Taps (Talk:
|
||||||
|
Antworten / Gelesen) als expliziten Broadcast an diesen Receiver.
|
||||||
|
Das Plugin deklariert ihn NICHT selbst — ohne diesen Eintrag kommt
|
||||||
|
der Tap nirgends an und der Background-Handler startet nie. -->
|
||||||
|
<receiver
|
||||||
|
android:name="com.dexterous.flutterlocalnotifications.ActionBroadcastReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- Receiver classes live at the package root (NOT under .widgets) because
|
<!-- Receiver classes live at the package root (NOT under .widgets) because
|
||||||
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
||||||
<receiver
|
<receiver
|
||||||
|
|||||||
@@ -52,8 +52,9 @@ class NotificationService {
|
|||||||
await flutterLocalNotificationsPlugin.initialize(
|
await flutterLocalNotificationsPlugin.initialize(
|
||||||
settings: initializationSettings,
|
settings: initializationSettings,
|
||||||
onDidReceiveNotificationResponse: PushTapRouter.handleResponse,
|
onDidReceiveNotificationResponse: PushTapRouter.handleResponse,
|
||||||
onDidReceiveBackgroundNotificationResponse:
|
// Top-level function, NOT the static method: AOT callback resolution
|
||||||
PushActions.handleBackgroundResponse,
|
// has failed for static class members (see pushActionBackgroundHandler).
|
||||||
|
onDidReceiveBackgroundNotificationResponse: pushActionBackgroundHandler,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-34
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
@@ -18,10 +19,34 @@ import 'push_renderer.dart';
|
|||||||
const String kTalkReplyActionId = 'TALK_REPLY';
|
const String kTalkReplyActionId = 'TALK_REPLY';
|
||||||
const String kTalkMarkReadActionId = 'TALK_MARK_READ';
|
const String kTalkMarkReadActionId = 'TALK_MARK_READ';
|
||||||
|
|
||||||
|
/// Top-level FLN background entry point. A PLAIN FUNCTION with the pragma is
|
||||||
|
/// the reliable AOT form (same lesson as [pushOnBackgroundMessage]): resolving
|
||||||
|
/// static class members from a callback handle has failed in release builds
|
||||||
|
/// even with annotations present.
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
Future<void> pushActionBackgroundHandler(NotificationResponse response) =>
|
||||||
|
PushActions.handleBackgroundResponse(response);
|
||||||
|
|
||||||
|
/// Logs to the developer log AND to stdout: `dart:developer` messages are
|
||||||
|
/// invisible in release logcat, but field debugging of the notification
|
||||||
|
/// action isolates needs `adb logcat -s flutter` to show what happened.
|
||||||
|
void _plog(String message) {
|
||||||
|
log(message);
|
||||||
|
debugPrint('PushActions: $message');
|
||||||
|
}
|
||||||
|
|
||||||
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
|
/// Handles Talk notification actions (inline reply, mark-as-read). Runs in the
|
||||||
/// background isolate spawned by flutter_local_notifications, so it may not
|
/// background isolate spawned by flutter_local_notifications, so it may not
|
||||||
/// share any app state — it reads credentials straight from secure storage via
|
/// share any app state — it reads credentials straight from secure storage via
|
||||||
/// the [AccountData] singleton after awaiting population.
|
/// the [AccountData] singleton after awaiting population.
|
||||||
|
///
|
||||||
|
/// The class-level `vm:entry-point` pragma is REQUIRED in addition to the one
|
||||||
|
/// on [handleBackgroundResponse]: the callback is resolved via
|
||||||
|
/// `PluginUtilities.getCallbackFromHandle`, and in AOT builds resolving a
|
||||||
|
/// static method needs its enclosing class to be an entry point too —
|
||||||
|
/// otherwise the lookup fails with "To access ... PushActions from native
|
||||||
|
/// code, it must be annotated" and no action ever reaches Dart.
|
||||||
|
@pragma('vm:entry-point')
|
||||||
class PushActions {
|
class PushActions {
|
||||||
/// Background entry point for notification actions. Must be a top-level or
|
/// Background entry point for notification actions. Must be a top-level or
|
||||||
/// static function annotated with `vm:entry-point` so AOT keeps it alive.
|
/// static function annotated with `vm:entry-point` so AOT keeps it alive.
|
||||||
@@ -36,38 +61,101 @@ class PushActions {
|
|||||||
// runs forever.
|
// runs forever.
|
||||||
DartPluginRegistrant.ensureInitialized();
|
DartPluginRegistrant.ensureInitialized();
|
||||||
|
|
||||||
|
_plog(
|
||||||
|
'action=${response.actionId} payload=${response.payload} '
|
||||||
|
'hasInput=${response.input?.isNotEmpty ?? false}',
|
||||||
|
);
|
||||||
final chatToken = _chatTokenFrom(response.payload);
|
final chatToken = _chatTokenFrom(response.payload);
|
||||||
if (chatToken == null) return;
|
if (chatToken == null) return;
|
||||||
switch (response.actionId) {
|
switch (response.actionId) {
|
||||||
case kTalkReplyActionId:
|
case kTalkReplyActionId:
|
||||||
final text = response.input?.trim();
|
final text = response.input?.trim();
|
||||||
final sent = text != null && text.isNotEmpty
|
final result = text == null || text.isEmpty
|
||||||
? await sendReply(chatToken, text)
|
? (
|
||||||
: false;
|
ok: false,
|
||||||
if (sent) {
|
detail: 'Keine Texteingabe empfangen (RemoteInput leer).',
|
||||||
|
)
|
||||||
|
: await sendReply(chatToken, text);
|
||||||
|
// Local cleanup FIRST, mark-read AFTER: markRead makes the server
|
||||||
|
// dismiss its notifications and emit delete-pushes. If those arrive
|
||||||
|
// in the FCM isolate while the thread history still exists here, the
|
||||||
|
// delete handler re-renders the thread and resurrects the just-
|
||||||
|
// cancelled notification (with Android re-attaching the pending
|
||||||
|
// inline reply on top).
|
||||||
|
await finishReply(chatToken: chatToken, sent: result.ok);
|
||||||
|
if (result.ok) {
|
||||||
|
_cleanupNidEntry(response);
|
||||||
// The user has evidently seen the chat — set the read marker like
|
// The user has evidently seen the chat — set the read marker like
|
||||||
// the mark-read action does.
|
// the mark-read action does. Best effort: a failure here must not
|
||||||
|
// fail the already-delivered reply.
|
||||||
await markRead(chatToken);
|
await markRead(chatToken);
|
||||||
}
|
}
|
||||||
await finishReply(chatToken: chatToken, sent: sent);
|
if (!result.ok) {
|
||||||
_cleanupNidEntry(response);
|
// Never swallow the typed message: surface the failure (and the
|
||||||
|
// undelivered text) as its own notification. The nid mapping stays
|
||||||
|
// alive — the thread notification is still in the tray.
|
||||||
|
await _showActionError(
|
||||||
|
chatToken: chatToken,
|
||||||
|
title: 'Antwort nicht gesendet',
|
||||||
|
body: actionFailureBody(lostText: text, detail: result.detail),
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case kTalkMarkReadActionId:
|
case kTalkMarkReadActionId:
|
||||||
await markRead(chatToken);
|
// Optimistic like the in-app read-marker: clean up locally first so
|
||||||
|
// the server's delete-pushes (triggered by markRead) can never race a
|
||||||
|
// still-present thread history into a resurrected notification.
|
||||||
await _cleanupChat(chatToken);
|
await _cleanupChat(chatToken);
|
||||||
_cleanupNidEntry(response);
|
_cleanupNidEntry(response);
|
||||||
|
final result = await markRead(chatToken);
|
||||||
|
if (!result.ok) {
|
||||||
|
await _showActionError(
|
||||||
|
chatToken: chatToken,
|
||||||
|
title: 'Als gelesen markieren fehlgeschlagen',
|
||||||
|
body: actionFailureBody(detail: result.detail),
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the reply interaction: Android keeps the RemoteInput spinner alive
|
/// Body text of an action-failure notification: the undelivered reply (if
|
||||||
/// until the notification is UPDATED or REMOVED. Success removes the whole
|
/// any) followed by the technical reason.
|
||||||
/// notification (history cleared + cancel — the chat is read); failure
|
static String actionFailureBody({String? lostText, required String detail}) {
|
||||||
/// re-renders the unchanged thread silently so the spinner stops (the error
|
return [
|
||||||
/// itself is only loggable on notification level). Injectable seams so
|
if (lostText != null && lostText.isNotEmpty) 'Deine Nachricht: „$lostText“',
|
||||||
/// tests can observe the flow without platform channels.
|
'Grund: $detail',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _showActionError({
|
||||||
|
required String chatToken,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await PushRenderer().renderTalkActionError(
|
||||||
|
chatToken: chatToken,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
} on Object catch (e) {
|
||||||
|
_plog('Push action error notification failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends the reply interaction. The card itself is already gone — the
|
||||||
|
/// action's native `cancelNotification` removed it the moment the reply
|
||||||
|
/// fired (a Dart-side cancel instead does NOT work everywhere: MIUI/HyperOS
|
||||||
|
/// ignores app cancels while an inline reply is pending). What remains:
|
||||||
|
/// success clears the stacked history so the next push starts fresh (plus a
|
||||||
|
/// redundant defensive cancel); failure re-renders silently, which the
|
||||||
|
/// renderer's active-probe turns into a no-op when the card is really gone
|
||||||
|
/// — the failure surface is the error card posted by the caller.
|
||||||
|
/// Injectable seams so tests can observe the flow without platform
|
||||||
|
/// channels.
|
||||||
static Future<void> finishReply({
|
static Future<void> finishReply({
|
||||||
required String chatToken,
|
required String chatToken,
|
||||||
required bool sent,
|
required bool sent,
|
||||||
@@ -100,21 +188,31 @@ class PushActions {
|
|||||||
/// Sends the inline reply. Success = any 2xx (the Talk chat POST answers
|
/// Sends the inline reply. Success = any 2xx (the Talk chat POST answers
|
||||||
/// 201 Created). Path/body match the app's working send path
|
/// 201 Created). Path/body match the app's working send path
|
||||||
/// (`SendMessage`: `v1/chat/{token}`, form field `message`).
|
/// (`SendMessage`: `v1/chat/{token}`, form field `message`).
|
||||||
static Future<bool> sendReply(String chatToken, String message) => _ocsPost(
|
static Future<({bool ok, String detail})> sendReply(
|
||||||
'apps/spreed/api/v1/chat/$chatToken',
|
String chatToken,
|
||||||
body: {'message': message},
|
String message,
|
||||||
);
|
) => _ocsPost('apps/spreed/api/v1/chat/$chatToken', body: {'message': message});
|
||||||
|
|
||||||
static Future<bool> markRead(String chatToken) =>
|
static Future<({bool ok, String detail})> markRead(String chatToken) =>
|
||||||
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
|
_ocsPost('apps/spreed/api/v1/chat/$chatToken/read');
|
||||||
|
|
||||||
static Future<bool> _ocsPost(String path, {Map<String, String>? body}) async {
|
static Future<({bool ok, String detail})> _ocsPost(
|
||||||
|
String path, {
|
||||||
|
Map<String, String>? body,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Bounded: a hanging population (e.g. keystore issue) must fail the
|
// Bounded: a hanging population (e.g. keystore issue) must fail the
|
||||||
// action instead of leaving the notification spinner running forever.
|
// action instead of leaving the notification spinner running forever.
|
||||||
await AccountData().waitForPopulation().timeout(
|
final populated = await AccountData().waitForPopulation().timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
);
|
);
|
||||||
|
if (!populated) {
|
||||||
|
_plog('Push action $path aborted: credentials unreadable in isolate');
|
||||||
|
return (
|
||||||
|
ok: false,
|
||||||
|
detail: 'Zugangsdaten im Hintergrund-Prozess nicht lesbar.',
|
||||||
|
);
|
||||||
|
}
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
NextcloudOcs.uri(path),
|
NextcloudOcs.uri(path),
|
||||||
headers: NextcloudOcs.headers(),
|
headers: NextcloudOcs.headers(),
|
||||||
@@ -122,18 +220,22 @@ class PushActions {
|
|||||||
);
|
);
|
||||||
final ok = response.statusCode >= 200 && response.statusCode < 300;
|
final ok = response.statusCode >= 200 && response.statusCode < 300;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
log('Push action $path -> HTTP ${response.statusCode}');
|
_plog('Push action $path -> HTTP ${response.statusCode}');
|
||||||
} else {
|
return (ok: true, detail: 'HTTP ${response.statusCode}');
|
||||||
final preview = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
||||||
log(
|
|
||||||
'Push action $path -> HTTP ${response.statusCode} '
|
|
||||||
'body=${preview.length > 300 ? '${preview.substring(0, 300)}…' : preview}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return ok;
|
final preview = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
final trimmed = preview.length > 200
|
||||||
|
? '${preview.substring(0, 200)}…'
|
||||||
|
: preview;
|
||||||
|
_plog('Push action $path -> HTTP ${response.statusCode} body=$trimmed');
|
||||||
|
return (
|
||||||
|
ok: false,
|
||||||
|
detail:
|
||||||
|
'HTTP ${response.statusCode}${trimmed.isEmpty ? '' : ' – $trimmed'}',
|
||||||
|
);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
log('Push action $path failed: $e');
|
_plog('Push action $path failed: $e');
|
||||||
return false;
|
return (ok: false, detail: e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +245,7 @@ class PushActions {
|
|||||||
try {
|
try {
|
||||||
await ChatThreadStore().clearChat(chatToken);
|
await ChatThreadStore().clearChat(chatToken);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
log('Push action thread cleanup failed: $e');
|
_plog('Push action thread cleanup failed: $e');
|
||||||
}
|
}
|
||||||
await _cancelChatNotification(chatToken);
|
await _cancelChatNotification(chatToken);
|
||||||
}
|
}
|
||||||
@@ -155,7 +257,7 @@ class PushActions {
|
|||||||
tag: chatNotificationTag(chatToken),
|
tag: chatNotificationTag(chatToken),
|
||||||
);
|
);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
log('Push action cancel failed: $e');
|
_plog('Push action cancel failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +269,7 @@ class PushActions {
|
|||||||
.delete(nid)
|
.delete(nid)
|
||||||
.then(
|
.then(
|
||||||
(_) {},
|
(_) {},
|
||||||
onError: (Object e) => log('Push action nid cleanup failed: $e'),
|
onError: (Object e) => _plog('Push action nid cleanup failed: $e'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
import '../notification/notification_service.dart';
|
import '../notification/notification_service.dart';
|
||||||
@@ -146,6 +147,17 @@ class PushRenderer {
|
|||||||
bool alert = true,
|
bool alert = true,
|
||||||
}) async {
|
}) async {
|
||||||
if (messages.isEmpty) return;
|
if (messages.isEmpty) return;
|
||||||
|
// A silent render only ever UPDATES an existing card (delete-push shrunk
|
||||||
|
// the thread, late avatar arrived, failed reply needs its spinner
|
||||||
|
// stopped). If the card is verifiably gone meanwhile (reply/mark-read
|
||||||
|
// cleanup cancelled it, or the user swiped it away), re-posting would
|
||||||
|
// resurrect it — and Android would re-attach a pending inline reply on
|
||||||
|
// top. Probe failure (null) still renders: stopping a possible reply
|
||||||
|
// spinner outweighs a rare resurrection.
|
||||||
|
if (!alert && await _isChatNotificationActive(chatToken) == false) {
|
||||||
|
debugPrint('PushRenderer: skip silent re-render, card gone ($chatToken)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final tag = chatNotificationTag(chatToken);
|
final tag = chatNotificationTag(chatToken);
|
||||||
final id = stableChatNotificationId(chatToken);
|
final id = stableChatNotificationId(chatToken);
|
||||||
final latest = messages.last;
|
final latest = messages.last;
|
||||||
@@ -261,12 +273,18 @@ class PushRenderer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both actions keep cancelNotification: true (the default): the plugin's
|
||||||
|
// Java receiver then removes the card NATIVELY the moment the action fires.
|
||||||
|
// The reply action must not rely on our Dart-side cancel instead — MIUI/
|
||||||
|
// HyperOS ignores an app-issued cancel while an inline reply is pending, so
|
||||||
|
// the card would stay behind showing the reply as an attached "Ich" row.
|
||||||
|
// If sending subsequently fails, the error card (with the typed text)
|
||||||
|
// replaces the lost thread card.
|
||||||
static const _talkActions = [
|
static const _talkActions = [
|
||||||
AndroidNotificationAction(
|
AndroidNotificationAction(
|
||||||
kTalkReplyActionId,
|
kTalkReplyActionId,
|
||||||
'Antworten',
|
'Antworten',
|
||||||
showsUserInterface: false,
|
showsUserInterface: false,
|
||||||
cancelNotification: false,
|
|
||||||
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
|
inputs: [AndroidNotificationActionInput(label: 'Nachricht')],
|
||||||
),
|
),
|
||||||
AndroidNotificationAction(
|
AndroidNotificationAction(
|
||||||
@@ -276,6 +294,34 @@ class PushRenderer {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Renders a failure card for a Talk notification action (reply/mark-read).
|
||||||
|
/// Separate tag per chat, so it neither replaces the thread notification
|
||||||
|
/// nor stacks across repeated failures. Carries no payload — tapping it
|
||||||
|
/// just opens the app.
|
||||||
|
Future<void> renderTalkActionError({
|
||||||
|
required String chatToken,
|
||||||
|
required String title,
|
||||||
|
required String body,
|
||||||
|
}) async {
|
||||||
|
await _plugin.show(
|
||||||
|
id: stableChatNotificationId(chatToken),
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: NotificationDetails(
|
||||||
|
android: AndroidNotificationDetails(
|
||||||
|
talkChannelId,
|
||||||
|
talkChannelName,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
color: _accentColor,
|
||||||
|
tag: 'talk_error_$chatToken',
|
||||||
|
styleInformation: BigTextStyleInformation(body),
|
||||||
|
),
|
||||||
|
iOS: const DarwinNotificationDetails(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _renderGeneric(PushSubject subject) async {
|
Future<void> _renderGeneric(PushSubject subject) async {
|
||||||
final nid = subject.nid ?? _fallbackId(subject.subject);
|
final nid = subject.nid ?? _fallbackId(subject.subject);
|
||||||
const androidDetails = AndroidNotificationDetails(
|
const androidDetails = AndroidNotificationDetails(
|
||||||
|
|||||||
@@ -3,6 +3,26 @@ import 'package:marianum_mobile/push/chat_thread_store.dart';
|
|||||||
import 'package:marianum_mobile/push/push_actions.dart';
|
import 'package:marianum_mobile/push/push_actions.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
group('PushActions.actionFailureBody', () {
|
||||||
|
test('includes the undelivered text and the reason', () {
|
||||||
|
expect(
|
||||||
|
PushActions.actionFailureBody(lostText: 'Hallo du', detail: 'HTTP 401'),
|
||||||
|
'Deine Nachricht: „Hallo du“\nGrund: HTTP 401',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits the text line when there is none', () {
|
||||||
|
expect(
|
||||||
|
PushActions.actionFailureBody(detail: 'HTTP 404'),
|
||||||
|
'Grund: HTTP 404',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
PushActions.actionFailureBody(lostText: '', detail: 'HTTP 404'),
|
||||||
|
'Grund: HTTP 404',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('PushActions.finishReply', () {
|
group('PushActions.finishReply', () {
|
||||||
const token = 'chat1';
|
const token = 'chat1';
|
||||||
final thread = [
|
final thread = [
|
||||||
|
|||||||
Reference in New Issue
Block a user