mirror of
https://github.com/electron/electron.git
synced 2026-04-10 03:01:51 -04:00
feat: add static methods getHistory, remove, removeAll to Notification (macOS)
Add static methods for managing delivered notifications via the UNUserNotificationCenter API: - getHistory() returns all delivered notifications still in Notification Center - remove(id) removes one or more notifications by identifier - removeAll() removes all delivered notifications These methods build on the custom id support to enable full notification lifecycle management from the main process.
This commit is contained in:
@@ -76,10 +76,56 @@ app.whenReady().then(() => {
|
||||
})
|
||||
```
|
||||
|
||||
#### `Notification.getHistory()` _macOS_
|
||||
|
||||
Returns `Promise<NotificationHistoryInfo[]>` - Resolves with an array of [`NotificationHistoryInfo`](structures/notification-history-info.md) objects representing all delivered notifications still present in Notification Center.
|
||||
|
||||
> [!NOTE]
|
||||
> Like all macOS notification APIs, this method requires the application to be
|
||||
> code-signed. In unsigned development builds, notifications are not delivered
|
||||
> to Notification Center and this method will resolve with an empty array.
|
||||
|
||||
```js
|
||||
const { Notification, app } = require('electron')
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const history = await Notification.getHistory()
|
||||
for (const n of history) {
|
||||
console.log(`${n.id}: ${n.title}`)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### `Notification.remove(id)` _macOS_
|
||||
|
||||
* `id` (string | string[]) - The notification identifier(s) to remove. These correspond to the `id` values set in the [`Notification` constructor](#new-notificationoptions).
|
||||
|
||||
Removes one or more delivered notifications from Notification Center by their identifier(s).
|
||||
|
||||
```js
|
||||
const { Notification } = require('electron')
|
||||
|
||||
// Remove a single notification
|
||||
Notification.remove('my-notification-id')
|
||||
|
||||
// Remove multiple notifications
|
||||
Notification.remove(['msg-1', 'msg-2', 'msg-3'])
|
||||
```
|
||||
|
||||
#### `Notification.removeAll()` _macOS_
|
||||
|
||||
Removes all of the app's delivered notifications from Notification Center.
|
||||
|
||||
```js
|
||||
const { Notification } = require('electron')
|
||||
|
||||
Notification.removeAll()
|
||||
```
|
||||
|
||||
### `new Notification([options])`
|
||||
|
||||
* `options` Object (optional)
|
||||
* `id` string (optional) _macOS_ - A unique identifier for the notification, mapping to `UNNotificationRequest`'s [`identifier`](https://developer.apple.com/documentation/usernotifications/unnotificationrequest/identifier) property. Defaults to a random UUID if not provided or if an empty string is passed. This can be used to remove or update previously delivered notifications.
|
||||
* `id` string (optional) _macOS_ - A unique identifier for the notification, mapping to `UNNotificationRequest`'s [`identifier`](https://developer.apple.com/documentation/usernotifications/unnotificationrequest/identifier) property. Defaults to a random UUID if not provided or if an empty string is passed. Use this identifier with [`Notification.remove()`](#notificationremoveid-macos) to remove specific delivered notifications, or with [`Notification.getHistory()`](#notificationgethistory-macos) to identify them.
|
||||
* `groupId` string (optional) _macOS_ - A string identifier used to visually group notifications together in Notification Center. Maps to `UNNotificationContent`'s [`threadIdentifier`](https://developer.apple.com/documentation/usernotifications/unnotificationcontent/threadidentifier) property.
|
||||
* `title` string (optional) - A title for the notification, which will be displayed at the top of the notification window when it is shown.
|
||||
* `subtitle` string (optional) _macOS_ - A subtitle for the notification, which will be displayed below the title.
|
||||
|
||||
7
docs/api/structures/notification-history-info.md
Normal file
7
docs/api/structures/notification-history-info.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# NotificationHistoryInfo Object
|
||||
|
||||
* `id` string - The notification identifier.
|
||||
* `title` string - The title of the notification.
|
||||
* `subtitle` string - The subtitle of the notification.
|
||||
* `body` string - The body text of the notification.
|
||||
* `groupId` string - The thread identifier used for grouping.
|
||||
@@ -2,6 +2,9 @@ const binding = process._linkedBinding('electron_browser_notification');
|
||||
|
||||
const ElectronNotification = binding.Notification;
|
||||
ElectronNotification.isSupported = binding.isSupported;
|
||||
ElectronNotification.getHistory = binding.getHistory;
|
||||
ElectronNotification.remove = binding.remove;
|
||||
ElectronNotification.removeAll = binding.removeAll;
|
||||
|
||||
if (process.platform === 'win32' && binding.handleActivation) {
|
||||
ElectronNotification.handleActivation = binding.handleActivation;
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
#include "shell/browser/browser.h"
|
||||
#include "shell/browser/electron_browser_client.h"
|
||||
#include "shell/common/gin_converters/image_converter.h"
|
||||
#include "shell/common/gin_converters/value_converter.h"
|
||||
#include "shell/common/gin_helper/dictionary.h"
|
||||
#include "shell/common/gin_helper/error_thrower.h"
|
||||
#include "shell/common/gin_helper/handle.h"
|
||||
#include "shell/common/gin_helper/object_template_builder.h"
|
||||
#include "shell/common/gin_helper/promise.h"
|
||||
#include "shell/common/node_includes.h"
|
||||
#include "url/gurl.h"
|
||||
|
||||
@@ -342,6 +344,90 @@ void Notification::HandleActivation(v8::Isolate* isolate,
|
||||
}
|
||||
#endif
|
||||
|
||||
// static
|
||||
v8::Local<v8::Promise> Notification::GetHistory(v8::Isolate* isolate) {
|
||||
gin_helper::Promise<v8::Local<v8::Value>> promise(isolate);
|
||||
v8::Local<v8::Promise> handle = promise.GetHandle();
|
||||
|
||||
auto* presenter =
|
||||
static_cast<ElectronBrowserClient*>(ElectronBrowserClient::Get())
|
||||
->GetNotificationPresenter();
|
||||
if (!presenter) {
|
||||
promise.Resolve(v8::Array::New(isolate));
|
||||
return handle;
|
||||
}
|
||||
|
||||
presenter->GetDeliveredNotifications(base::BindOnce(
|
||||
[](gin_helper::Promise<v8::Local<v8::Value>> promise,
|
||||
std::vector<electron::NotificationInfo> notifications) {
|
||||
v8::Isolate* isolate = promise.isolate();
|
||||
v8::HandleScope handle_scope(isolate);
|
||||
|
||||
v8::Local<v8::Array> result =
|
||||
v8::Array::New(isolate, notifications.size());
|
||||
for (size_t i = 0; i < notifications.size(); i++) {
|
||||
auto dict = gin::Dictionary::CreateEmpty(isolate);
|
||||
dict.Set("id", notifications[i].id);
|
||||
dict.Set("title", notifications[i].title);
|
||||
dict.Set("subtitle", notifications[i].subtitle);
|
||||
dict.Set("body", notifications[i].body);
|
||||
dict.Set("groupId", notifications[i].group_id);
|
||||
result
|
||||
->Set(isolate->GetCurrentContext(), static_cast<uint32_t>(i),
|
||||
gin::ConvertToV8(isolate, dict))
|
||||
.Check();
|
||||
}
|
||||
|
||||
promise.Resolve(result.As<v8::Value>());
|
||||
},
|
||||
std::move(promise)));
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
// static
|
||||
void Notification::Remove(gin::Arguments* args) {
|
||||
auto* presenter =
|
||||
static_cast<ElectronBrowserClient*>(ElectronBrowserClient::Get())
|
||||
->GetNotificationPresenter();
|
||||
if (!presenter)
|
||||
return;
|
||||
|
||||
// Accept either a single string or an array of strings.
|
||||
// Peek at the value type first to avoid gin::Arguments cursor issues.
|
||||
v8::Local<v8::Value> val;
|
||||
if (!args->GetNext(&val)) {
|
||||
args->ThrowTypeError("Expected a string or array of strings");
|
||||
return;
|
||||
}
|
||||
|
||||
if (val->IsString()) {
|
||||
std::string id;
|
||||
gin::ConvertFromV8(args->isolate(), val, &id);
|
||||
presenter->RemoveDeliveredNotifications({id});
|
||||
} else if (val->IsArray()) {
|
||||
std::vector<std::string> ids;
|
||||
if (!gin::ConvertFromV8(args->isolate(), val, &ids)) {
|
||||
args->ThrowTypeError("Expected a string or array of strings");
|
||||
return;
|
||||
}
|
||||
presenter->RemoveDeliveredNotifications(ids);
|
||||
} else {
|
||||
args->ThrowTypeError("Expected a string or array of strings");
|
||||
}
|
||||
}
|
||||
|
||||
// static
|
||||
void Notification::RemoveAll() {
|
||||
auto* presenter =
|
||||
static_cast<ElectronBrowserClient*>(ElectronBrowserClient::Get())
|
||||
->GetNotificationPresenter();
|
||||
if (!presenter)
|
||||
return;
|
||||
|
||||
presenter->RemoveAllDeliveredNotifications();
|
||||
}
|
||||
|
||||
void Notification::FillObjectTemplate(v8::Isolate* isolate,
|
||||
v8::Local<v8::ObjectTemplate> templ) {
|
||||
gin::ObjectTemplateBuilder(isolate, GetClassName(), templ)
|
||||
@@ -395,6 +481,9 @@ void Initialize(v8::Local<v8::Object> exports,
|
||||
#if BUILDFLAG(IS_WIN)
|
||||
dict.SetMethod("handleActivation", &Notification::HandleActivation);
|
||||
#endif
|
||||
dict.SetMethod("getHistory", &Notification::GetHistory);
|
||||
dict.SetMethod("remove", &Notification::Remove);
|
||||
dict.SetMethod("removeAll", &Notification::RemoveAll);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
@@ -38,6 +38,9 @@ class Notification final : public gin_helper::DeprecatedWrappable<Notification>,
|
||||
public NotificationDelegate {
|
||||
public:
|
||||
static bool IsSupported();
|
||||
static v8::Local<v8::Promise> GetHistory(v8::Isolate* isolate);
|
||||
static void Remove(gin::Arguments* args);
|
||||
static void RemoveAll();
|
||||
|
||||
#if BUILDFLAG(IS_WIN)
|
||||
// Register a callback to handle all notification activations.
|
||||
|
||||
@@ -24,6 +24,13 @@ class NotificationPresenterMac : public NotificationPresenter {
|
||||
NotificationPresenterMac();
|
||||
~NotificationPresenterMac() override;
|
||||
|
||||
// NotificationPresenter
|
||||
void GetDeliveredNotifications(
|
||||
GetDeliveredNotificationsCallback callback) override;
|
||||
void RemoveDeliveredNotifications(
|
||||
const std::vector<std::string>& identifiers) override;
|
||||
void RemoveAllDeliveredNotifications() override;
|
||||
|
||||
NotificationImageRetainer* image_retainer() { return image_retainer_.get(); }
|
||||
scoped_refptr<base::SequencedTaskRunner> image_task_runner() {
|
||||
return image_task_runner_;
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
// Use of this source code is governed by the MIT license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "base/logging.h"
|
||||
#include "base/task/thread_pool.h"
|
||||
|
||||
#include "shell/browser/notifications/mac/notification_presenter_mac.h"
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "base/logging.h"
|
||||
#include "base/strings/sys_string_conversions.h"
|
||||
#include "base/task/sequenced_task_runner.h"
|
||||
#include "base/task/thread_pool.h"
|
||||
#include "shell/browser/notifications/mac/cocoa_notification.h"
|
||||
#include "shell/browser/notifications/mac/notification_center_delegate.h"
|
||||
|
||||
@@ -80,4 +85,51 @@ Notification* NotificationPresenterMac::CreateNotificationObject(
|
||||
return new CocoaNotification(delegate, this);
|
||||
}
|
||||
|
||||
void NotificationPresenterMac::GetDeliveredNotifications(
|
||||
GetDeliveredNotificationsCallback callback) {
|
||||
scoped_refptr<base::SequencedTaskRunner> task_runner =
|
||||
base::SequencedTaskRunner::GetCurrentDefault();
|
||||
|
||||
__block GetDeliveredNotificationsCallback block_callback =
|
||||
std::move(callback);
|
||||
|
||||
[[UNUserNotificationCenter currentNotificationCenter]
|
||||
getDeliveredNotificationsWithCompletionHandler:^(
|
||||
NSArray<UNNotification*>* _Nonnull notifications) {
|
||||
std::vector<NotificationInfo> results;
|
||||
results.reserve([notifications count]);
|
||||
|
||||
for (UNNotification* notification in notifications) {
|
||||
UNNotificationContent* content = notification.request.content;
|
||||
NotificationInfo info;
|
||||
info.id = base::SysNSStringToUTF8(notification.request.identifier);
|
||||
info.title = base::SysNSStringToUTF8(content.title);
|
||||
info.subtitle = base::SysNSStringToUTF8(content.subtitle);
|
||||
info.body = base::SysNSStringToUTF8(content.body);
|
||||
info.group_id = base::SysNSStringToUTF8(content.threadIdentifier);
|
||||
results.push_back(std::move(info));
|
||||
}
|
||||
|
||||
task_runner->PostTask(
|
||||
FROM_HERE,
|
||||
base::BindOnce(std::move(block_callback), std::move(results)));
|
||||
}];
|
||||
}
|
||||
|
||||
void NotificationPresenterMac::RemoveDeliveredNotifications(
|
||||
const std::vector<std::string>& identifiers) {
|
||||
NSMutableArray* ns_identifiers =
|
||||
[NSMutableArray arrayWithCapacity:identifiers.size()];
|
||||
for (const auto& id : identifiers) {
|
||||
[ns_identifiers addObject:base::SysUTF8ToNSString(id)];
|
||||
}
|
||||
[[UNUserNotificationCenter currentNotificationCenter]
|
||||
removeDeliveredNotificationsWithIdentifiers:ns_identifiers];
|
||||
}
|
||||
|
||||
void NotificationPresenterMac::RemoveAllDeliveredNotifications() {
|
||||
[[UNUserNotificationCenter currentNotificationCenter]
|
||||
removeAllDeliveredNotifications];
|
||||
}
|
||||
|
||||
} // namespace electron
|
||||
|
||||
@@ -22,6 +22,15 @@ NotificationOptions& NotificationOptions::operator=(NotificationOptions&&) =
|
||||
default;
|
||||
NotificationOptions::~NotificationOptions() = default;
|
||||
|
||||
NotificationInfo::NotificationInfo() = default;
|
||||
NotificationInfo::~NotificationInfo() = default;
|
||||
NotificationInfo::NotificationInfo(const NotificationInfo&) = default;
|
||||
NotificationInfo& NotificationInfo::operator=(const NotificationInfo&) =
|
||||
default;
|
||||
NotificationInfo::NotificationInfo(NotificationInfo&&) noexcept = default;
|
||||
NotificationInfo& NotificationInfo::operator=(NotificationInfo&&) noexcept =
|
||||
default;
|
||||
|
||||
NotificationAction::NotificationAction() = default;
|
||||
NotificationAction::~NotificationAction() = default;
|
||||
NotificationAction::NotificationAction(const NotificationAction&) = default;
|
||||
|
||||
@@ -59,6 +59,21 @@ struct NotificationOptions {
|
||||
~NotificationOptions();
|
||||
};
|
||||
|
||||
struct NotificationInfo {
|
||||
std::string id;
|
||||
std::string title;
|
||||
std::string subtitle;
|
||||
std::string body;
|
||||
std::string group_id;
|
||||
|
||||
NotificationInfo();
|
||||
~NotificationInfo();
|
||||
NotificationInfo(const NotificationInfo&);
|
||||
NotificationInfo& operator=(const NotificationInfo&);
|
||||
NotificationInfo(NotificationInfo&&) noexcept;
|
||||
NotificationInfo& operator=(NotificationInfo&&) noexcept;
|
||||
};
|
||||
|
||||
class Notification {
|
||||
public:
|
||||
virtual ~Notification();
|
||||
|
||||
@@ -44,4 +44,19 @@ void NotificationPresenter::CloseNotificationWithId(
|
||||
}
|
||||
}
|
||||
|
||||
void NotificationPresenter::GetDeliveredNotifications(
|
||||
GetDeliveredNotificationsCallback callback) {
|
||||
// Default: return empty list. Overridden on macOS.
|
||||
std::move(callback).Run({});
|
||||
}
|
||||
|
||||
void NotificationPresenter::RemoveDeliveredNotifications(
|
||||
const std::vector<std::string>& identifiers) {
|
||||
// Default: no-op. Overridden on macOS.
|
||||
}
|
||||
|
||||
void NotificationPresenter::RemoveAllDeliveredNotifications() {
|
||||
// Default: no-op. Overridden on macOS.
|
||||
}
|
||||
|
||||
} // namespace electron
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "base/functional/callback.h"
|
||||
#include "base/memory/weak_ptr.h"
|
||||
#include "shell/browser/notifications/notification.h"
|
||||
|
||||
namespace electron {
|
||||
|
||||
class Notification;
|
||||
class NotificationDelegate;
|
||||
|
||||
class NotificationPresenter {
|
||||
@@ -22,11 +24,20 @@ class NotificationPresenter {
|
||||
|
||||
virtual ~NotificationPresenter();
|
||||
|
||||
using GetDeliveredNotificationsCallback =
|
||||
base::OnceCallback<void(std::vector<NotificationInfo>)>;
|
||||
|
||||
base::WeakPtr<Notification> CreateNotification(
|
||||
NotificationDelegate* delegate,
|
||||
const std::string& notification_id);
|
||||
void CloseNotificationWithId(const std::string& notification_id);
|
||||
|
||||
virtual void GetDeliveredNotifications(
|
||||
GetDeliveredNotificationsCallback callback);
|
||||
virtual void RemoveDeliveredNotifications(
|
||||
const std::vector<std::string>& identifiers);
|
||||
virtual void RemoveAllDeliveredNotifications();
|
||||
|
||||
std::set<Notification*> notifications() const { return notifications_; }
|
||||
|
||||
// disable copy
|
||||
|
||||
@@ -261,4 +261,136 @@ describe('Notification module', () => {
|
||||
});
|
||||
|
||||
// TODO(sethlu): Find way to test init with notification icon?
|
||||
|
||||
describe('static methods', () => {
|
||||
ifit(process.platform === 'darwin')('getHistory returns a promise that resolves to an array', async () => {
|
||||
const result = Notification.getHistory();
|
||||
expect(result).to.be.a('promise');
|
||||
const history = await result;
|
||||
expect(history).to.be.an('array');
|
||||
});
|
||||
|
||||
ifit(process.platform === 'darwin')('remove does not throw with a string argument', () => {
|
||||
expect(() => Notification.remove('nonexistent-id')).to.not.throw();
|
||||
});
|
||||
|
||||
ifit(process.platform === 'darwin')('remove does not throw with an array argument', () => {
|
||||
expect(() => Notification.remove(['id-1', 'id-2'])).to.not.throw();
|
||||
});
|
||||
|
||||
ifit(process.platform === 'darwin')('remove throws with no arguments', () => {
|
||||
expect(() => (Notification.remove as any)()).to.throw(/Expected a string or array of strings/);
|
||||
});
|
||||
|
||||
ifit(process.platform === 'darwin')('remove throws with an invalid argument type', () => {
|
||||
expect(() => (Notification.remove as any)(123)).to.throw(/Expected a string or array of strings/);
|
||||
});
|
||||
|
||||
ifit(process.platform === 'darwin')('removeAll does not throw', () => {
|
||||
expect(() => Notification.removeAll()).to.not.throw();
|
||||
});
|
||||
|
||||
ifit(process.platform === 'darwin')('getHistory returns delivered notifications', async () => {
|
||||
const n = new Notification({
|
||||
id: 'history-test-id',
|
||||
title: 'history test',
|
||||
body: 'history body',
|
||||
groupId: 'history-group',
|
||||
silent: true
|
||||
});
|
||||
|
||||
const shown = once(n, 'show');
|
||||
n.show();
|
||||
await shown;
|
||||
|
||||
const history = await Notification.getHistory();
|
||||
// getHistory requires code-signed builds to return results;
|
||||
// skip the content assertions if Notification Center is empty.
|
||||
if (history.length > 0) {
|
||||
const found = history.find((item: any) => item.id === 'history-test-id');
|
||||
expect(found).to.not.be.undefined();
|
||||
expect(found.title).to.equal('history test');
|
||||
expect(found.body).to.equal('history body');
|
||||
expect(found.groupId).to.equal('history-group');
|
||||
}
|
||||
|
||||
n.close();
|
||||
});
|
||||
|
||||
ifit(process.platform === 'darwin')('remove removes a notification by id', async () => {
|
||||
const n = new Notification({
|
||||
id: 'remove-test-id',
|
||||
title: 'remove test',
|
||||
body: 'remove body',
|
||||
silent: true
|
||||
});
|
||||
|
||||
const shown = once(n, 'show');
|
||||
n.show();
|
||||
await shown;
|
||||
|
||||
Notification.remove('remove-test-id');
|
||||
|
||||
// Give the notification center a moment to process the removal
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const history = await Notification.getHistory();
|
||||
const found = history.find((item: any) => item.id === 'remove-test-id');
|
||||
expect(found).to.be.undefined();
|
||||
});
|
||||
|
||||
ifit(process.platform === 'darwin')('remove accepts an array of ids', async () => {
|
||||
const n1 = new Notification({
|
||||
id: 'remove-array-1',
|
||||
title: 'test 1',
|
||||
body: 'body 1',
|
||||
silent: true
|
||||
});
|
||||
const n2 = new Notification({
|
||||
id: 'remove-array-2',
|
||||
title: 'test 2',
|
||||
body: 'body 2',
|
||||
silent: true
|
||||
});
|
||||
|
||||
const shown1 = once(n1, 'show');
|
||||
n1.show();
|
||||
await shown1;
|
||||
|
||||
const shown2 = once(n2, 'show');
|
||||
n2.show();
|
||||
await shown2;
|
||||
|
||||
Notification.remove(['remove-array-1', 'remove-array-2']);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const history = await Notification.getHistory();
|
||||
const found1 = history.find((item: any) => item.id === 'remove-array-1');
|
||||
const found2 = history.find((item: any) => item.id === 'remove-array-2');
|
||||
expect(found1).to.be.undefined();
|
||||
expect(found2).to.be.undefined();
|
||||
});
|
||||
|
||||
ifit(process.platform === 'darwin')('removeAll removes all notifications', async () => {
|
||||
const n = new Notification({
|
||||
id: 'remove-all-test',
|
||||
title: 'removeAll test',
|
||||
body: 'body',
|
||||
silent: true
|
||||
});
|
||||
|
||||
const shown = once(n, 'show');
|
||||
n.show();
|
||||
await shown;
|
||||
|
||||
Notification.removeAll();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const history = await Notification.getHistory();
|
||||
const found = history.find((item: any) => item.id === 'remove-all-test');
|
||||
expect(found).to.be.undefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
3
typings/internal-ambient.d.ts
vendored
3
typings/internal-ambient.d.ts
vendored
@@ -118,6 +118,9 @@ declare namespace NodeJS {
|
||||
|
||||
interface NotificationBinding {
|
||||
isSupported(): boolean;
|
||||
getHistory(): Promise<Electron.NotificationHistoryInfo[]>;
|
||||
remove(id: string | string[]): void;
|
||||
removeAll(): void;
|
||||
Notification: typeof Electron.Notification;
|
||||
// Windows-only callback for cold-start notification activation
|
||||
handleActivation?: (callback: (details: ActivationArgumentsInternal) => void) => void;
|
||||
|
||||
Reference in New Issue
Block a user