mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-23 20:18:42 +08:00
feat(server): cleanup gateway code (#6118)
This commit is contained in:
@@ -38,27 +38,21 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
|
|||||||
return desc;
|
return desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
desc.value = function (...args: any[]) {
|
desc.value = async function (...args: any[]) {
|
||||||
let result: any;
|
|
||||||
try {
|
try {
|
||||||
result = originalMethod.apply(this, args);
|
return await originalMethod.apply(this, args);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
metrics.socketio.counter('unhandled_errors').add(1);
|
if (e instanceof EventError) {
|
||||||
return {
|
|
||||||
error: new InternalError(e as Error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
return result.catch(e => {
|
|
||||||
metrics.socketio.counter('unhandled_errors').add(1);
|
|
||||||
new Logger('EventsGateway').error(e, e.stack);
|
|
||||||
return {
|
return {
|
||||||
error: new InternalError(e),
|
error: e,
|
||||||
};
|
};
|
||||||
});
|
} else {
|
||||||
} else {
|
metrics.socketio.counter('unhandled_errors').add(1);
|
||||||
return result;
|
new Logger('EventsGateway').error(e, (e as Error).stack);
|
||||||
|
return {
|
||||||
|
error: new InternalError(e as Error),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +79,14 @@ type EventResponse<Data = any> =
|
|||||||
data: Data;
|
data: Data;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function Sync(workspaceId: string): `${string}:sync` {
|
||||||
|
return `${workspaceId}:sync`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Awareness(workspaceId: string): `${string}:awareness` {
|
||||||
|
return `${workspaceId}:awareness`;
|
||||||
|
}
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: !AFFiNE.node.prod,
|
cors: !AFFiNE.node.prod,
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
@@ -113,7 +115,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
|
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkVersion(client: Socket, version?: string) {
|
assertVersion(client: Socket, version?: string) {
|
||||||
if (
|
if (
|
||||||
// @todo(@darkskygit): remove this flag after 0.12 goes stable
|
// @todo(@darkskygit): remove this flag after 0.12 goes stable
|
||||||
AFFiNE.featureFlags.syncClientVersionCheck &&
|
AFFiNE.featureFlags.syncClientVersionCheck &&
|
||||||
@@ -126,14 +128,48 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
version ? ` ${version}` : ''
|
version ? ` ${version}` : ''
|
||||||
} is outdated, please update to ${AFFiNE.version}`,
|
} is outdated, please update to ${AFFiNE.version}`,
|
||||||
});
|
});
|
||||||
return {
|
|
||||||
error: new EventError(
|
throw new EventError(
|
||||||
EventErrorCode.VERSION_REJECTED,
|
EventErrorCode.VERSION_REJECTED,
|
||||||
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
|
`Client version ${version} is outdated, please update to ${AFFiNE.version}`
|
||||||
),
|
);
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinWorkspace(
|
||||||
|
client: Socket,
|
||||||
|
room: `${string}:${'sync' | 'awareness'}`
|
||||||
|
) {
|
||||||
|
await client.join(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveWorkspace(
|
||||||
|
client: Socket,
|
||||||
|
room: `${string}:${'sync' | 'awareness'}`
|
||||||
|
) {
|
||||||
|
await client.leave(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertInWorkspace(client: Socket, room: `${string}:${'sync' | 'awareness'}`) {
|
||||||
|
if (!client.rooms.has(room)) {
|
||||||
|
throw new NotInWorkspaceError(room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertWorkspaceAccessible(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
permission: Permission = Permission.Read
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!(await this.permissions.isWorkspaceMember(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
permission
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new AccessDeniedError(workspaceId);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Auth()
|
@Auth()
|
||||||
@@ -144,29 +180,19 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody('version') version: string | undefined,
|
@MessageBody('version') version: string | undefined,
|
||||||
@ConnectedSocket() client: Socket
|
@ConnectedSocket() client: Socket
|
||||||
): Promise<EventResponse<{ clientId: string }>> {
|
): Promise<EventResponse<{ clientId: string }>> {
|
||||||
const versionError = this.checkVersion(client, version);
|
this.assertVersion(client, version);
|
||||||
if (versionError) {
|
await this.assertWorkspaceAccessible(
|
||||||
return versionError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id,
|
user.id,
|
||||||
Permission.Write
|
Permission.Write
|
||||||
);
|
);
|
||||||
|
|
||||||
if (canWrite) {
|
await this.joinWorkspace(client, Sync(workspaceId));
|
||||||
await client.join(`${workspaceId}:sync`);
|
return {
|
||||||
return {
|
data: {
|
||||||
data: {
|
clientId: client.id,
|
||||||
clientId: client.id,
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
error: new AccessDeniedError(workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Auth()
|
@Auth()
|
||||||
@@ -177,47 +203,18 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody('version') version: string | undefined,
|
@MessageBody('version') version: string | undefined,
|
||||||
@ConnectedSocket() client: Socket
|
@ConnectedSocket() client: Socket
|
||||||
): Promise<EventResponse<{ clientId: string }>> {
|
): Promise<EventResponse<{ clientId: string }>> {
|
||||||
const versionError = this.checkVersion(client, version);
|
this.assertVersion(client, version);
|
||||||
if (versionError) {
|
await this.assertWorkspaceAccessible(
|
||||||
return versionError;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canWrite = await this.permissions.tryCheckWorkspace(
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id,
|
user.id,
|
||||||
Permission.Write
|
Permission.Write
|
||||||
);
|
);
|
||||||
|
|
||||||
if (canWrite) {
|
await this.joinWorkspace(client, Awareness(workspaceId));
|
||||||
await client.join(`${workspaceId}:awareness`);
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
clientId: client.id,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
error: new AccessDeniedError(workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated use `client-handshake-sync` and `client-handshake-awareness` instead
|
|
||||||
*/
|
|
||||||
@Auth()
|
|
||||||
@SubscribeMessage('client-handshake')
|
|
||||||
async handleClientHandShake(
|
|
||||||
@MessageBody() workspaceId: string,
|
|
||||||
@ConnectedSocket() client: Socket
|
|
||||||
): Promise<EventResponse<{ clientId: string }>> {
|
|
||||||
const versionError = this.checkVersion(client);
|
|
||||||
if (versionError) {
|
|
||||||
return versionError;
|
|
||||||
}
|
|
||||||
// should unreachable
|
|
||||||
return {
|
return {
|
||||||
error: new AccessDeniedError(workspaceId),
|
data: {
|
||||||
|
clientId: client.id,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,14 +223,9 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody() workspaceId: string,
|
@MessageBody() workspaceId: string,
|
||||||
@ConnectedSocket() client: Socket
|
@ConnectedSocket() client: Socket
|
||||||
): Promise<EventResponse> {
|
): Promise<EventResponse> {
|
||||||
if (client.rooms.has(`${workspaceId}:sync`)) {
|
this.assertInWorkspace(client, Sync(workspaceId));
|
||||||
await client.leave(`${workspaceId}:sync`);
|
await this.leaveWorkspace(client, Sync(workspaceId));
|
||||||
return {};
|
return {};
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
error: new NotInWorkspaceError(workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('client-leave-awareness')
|
@SubscribeMessage('client-leave-awareness')
|
||||||
@@ -241,14 +233,9 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody() workspaceId: string,
|
@MessageBody() workspaceId: string,
|
||||||
@ConnectedSocket() client: Socket
|
@ConnectedSocket() client: Socket
|
||||||
): Promise<EventResponse> {
|
): Promise<EventResponse> {
|
||||||
if (client.rooms.has(`${workspaceId}:awareness`)) {
|
this.assertInWorkspace(client, Awareness(workspaceId));
|
||||||
await client.leave(`${workspaceId}:awareness`);
|
await this.leaveWorkspace(client, Awareness(workspaceId));
|
||||||
return {};
|
return {};
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
error: new NotInWorkspaceError(workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('client-pre-sync')
|
@SubscribeMessage('client-pre-sync')
|
||||||
@@ -257,11 +244,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody()
|
@MessageBody()
|
||||||
{ workspaceId, timestamp }: { workspaceId: string; timestamp?: number }
|
{ workspaceId, timestamp }: { workspaceId: string; timestamp?: number }
|
||||||
): Promise<EventResponse<Record<string, number>>> {
|
): Promise<EventResponse<Record<string, number>>> {
|
||||||
if (!client.rooms.has(`${workspaceId}:sync`)) {
|
this.assertInWorkspace(client, Sync(workspaceId));
|
||||||
return {
|
|
||||||
error: new NotInWorkspaceError(workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await this.docManager.getStats(workspaceId, timestamp);
|
const stats = await this.docManager.getStats(workspaceId, timestamp);
|
||||||
|
|
||||||
@@ -284,11 +267,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
},
|
},
|
||||||
@ConnectedSocket() client: Socket
|
@ConnectedSocket() client: Socket
|
||||||
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
|
): Promise<EventResponse<{ accepted: true; timestamp?: number }>> {
|
||||||
if (!client.rooms.has(`${workspaceId}:sync`)) {
|
this.assertInWorkspace(client, Sync(workspaceId));
|
||||||
return {
|
|
||||||
error: new NotInWorkspaceError(workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const docId = new DocID(guid, workspaceId);
|
const docId = new DocID(guid, workspaceId);
|
||||||
const buffers = updates.map(update => Buffer.from(update, 'base64'));
|
const buffers = updates.map(update => Buffer.from(update, 'base64'));
|
||||||
@@ -299,7 +278,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
);
|
);
|
||||||
|
|
||||||
client
|
client
|
||||||
.to(`${docId.workspace}:sync`)
|
.to(Sync(workspaceId))
|
||||||
.emit('server-updates', { workspaceId, guid, updates, timestamp });
|
.emit('server-updates', { workspaceId, guid, updates, timestamp });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -310,11 +289,9 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Auth()
|
|
||||||
@SubscribeMessage('doc-load-v2')
|
@SubscribeMessage('doc-load-v2')
|
||||||
async loadDocV2(
|
async loadDocV2(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@CurrentUser() user: CurrentUser,
|
|
||||||
@MessageBody()
|
@MessageBody()
|
||||||
{
|
{
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -326,17 +303,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
stateVector?: string;
|
stateVector?: string;
|
||||||
}
|
}
|
||||||
): Promise<EventResponse<{ missing: string; state?: string }>> {
|
): Promise<EventResponse<{ missing: string; state?: string }>> {
|
||||||
if (!client.rooms.has(`${workspaceId}:sync`)) {
|
this.assertInWorkspace(client, Sync(workspaceId));
|
||||||
const canRead = await this.permissions.tryCheckWorkspace(
|
|
||||||
workspaceId,
|
|
||||||
user.id
|
|
||||||
);
|
|
||||||
if (!canRead) {
|
|
||||||
return {
|
|
||||||
error: new AccessDeniedError(workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const docId = new DocID(guid, workspaceId);
|
const docId = new DocID(guid, workspaceId);
|
||||||
const doc = await this.docManager.get(docId.workspace, docId.guid);
|
const doc = await this.docManager.get(docId.workspace, docId.guid);
|
||||||
@@ -363,40 +330,33 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Auth()
|
|
||||||
@SubscribeMessage('awareness-init')
|
@SubscribeMessage('awareness-init')
|
||||||
async handleInitAwareness(
|
async handleInitAwareness(
|
||||||
@MessageBody() workspaceId: string,
|
@MessageBody() workspaceId: string,
|
||||||
@ConnectedSocket() client: Socket
|
@ConnectedSocket() client: Socket
|
||||||
): Promise<EventResponse<{ clientId: string }>> {
|
): Promise<EventResponse<{ clientId: string }>> {
|
||||||
if (client.rooms.has(`${workspaceId}:awareness`)) {
|
this.assertInWorkspace(client, Awareness(workspaceId));
|
||||||
client.to(`${workspaceId}:awareness`).emit('new-client-awareness-init');
|
client.to(Awareness(workspaceId)).emit('new-client-awareness-init');
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
clientId: client.id,
|
clientId: client.id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
error: new NotInWorkspaceError(workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('awareness-update')
|
@SubscribeMessage('awareness-update')
|
||||||
async handleHelpGatheringAwareness(
|
async handleHelpGatheringAwareness(
|
||||||
@MessageBody() message: { workspaceId: string; awarenessUpdate: string },
|
@MessageBody()
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
awarenessUpdate,
|
||||||
|
}: { workspaceId: string; awarenessUpdate: string },
|
||||||
@ConnectedSocket() client: Socket
|
@ConnectedSocket() client: Socket
|
||||||
): Promise<EventResponse> {
|
): Promise<EventResponse> {
|
||||||
if (client.rooms.has(`${message.workspaceId}:awareness`)) {
|
this.assertInWorkspace(client, Awareness(workspaceId));
|
||||||
client
|
client
|
||||||
.to(`${message.workspaceId}:awareness`)
|
.to(Awareness(workspaceId))
|
||||||
.emit('server-awareness-broadcast', message);
|
.emit('server-awareness-broadcast', { workspaceId, awarenessUpdate });
|
||||||
return {};
|
return {};
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
error: new NotInWorkspaceError(message.workspaceId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,28 @@ export class PermissionService {
|
|||||||
return this.tryCheckPage(ws, id, user);
|
return this.tryCheckPage(ws, id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether a given user is a member of a workspace and has the given or higher permission.
|
||||||
|
*/
|
||||||
|
async isWorkspaceMember(
|
||||||
|
ws: string,
|
||||||
|
user: string,
|
||||||
|
permission: Permission
|
||||||
|
): Promise<boolean> {
|
||||||
|
const count = await this.prisma.workspaceUserPermission.count({
|
||||||
|
where: {
|
||||||
|
workspaceId: ws,
|
||||||
|
userId: user,
|
||||||
|
accepted: true,
|
||||||
|
type: {
|
||||||
|
gte: permission,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return count !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
async checkWorkspace(
|
async checkWorkspace(
|
||||||
ws: string,
|
ws: string,
|
||||||
user?: string,
|
user?: string,
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ export const CallTimer = (
|
|||||||
return desc;
|
return desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
desc.value = function (...args: any[]) {
|
desc.value = async function (...args: any[]) {
|
||||||
const timer = metrics[scope].histogram(name, {
|
const timer = metrics[scope].histogram(name, {
|
||||||
description: `function call time costs of ${name}`,
|
description: `function call time costs of ${name}`,
|
||||||
unit: 'ms',
|
unit: 'ms',
|
||||||
});
|
});
|
||||||
|
metrics[scope]
|
||||||
|
.counter(`${name}_calls`, {
|
||||||
|
description: `function call counts of ${name}`,
|
||||||
|
})
|
||||||
|
.add(1, attrs);
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
@@ -30,19 +35,10 @@ export const CallTimer = (
|
|||||||
timer.record(Date.now() - start, attrs);
|
timer.record(Date.now() - start, attrs);
|
||||||
};
|
};
|
||||||
|
|
||||||
let result: any;
|
|
||||||
try {
|
try {
|
||||||
result = originalMethod.apply(this, args);
|
return await originalMethod.apply(this, args);
|
||||||
} catch (e) {
|
} finally {
|
||||||
end();
|
end();
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
return result.finally(end);
|
|
||||||
} else {
|
|
||||||
end();
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export class AffineCloudAwarenessProvider implements AwarenessProvider {
|
|||||||
uint8ArrayToBase64(awarenessUpdate)
|
uint8ArrayToBase64(awarenessUpdate)
|
||||||
.then(encodedAwarenessUpdate => {
|
.then(encodedAwarenessUpdate => {
|
||||||
this.socket.emit('awareness-update', {
|
this.socket.emit('awareness-update', {
|
||||||
guid: this.workspaceId,
|
workspaceId: this.workspaceId,
|
||||||
awarenessUpdate: encodedAwarenessUpdate,
|
awarenessUpdate: encodedAwarenessUpdate,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -120,11 +120,19 @@ export class AffineCloudAwarenessProvider implements AwarenessProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleConnect = () => {
|
handleConnect = () => {
|
||||||
this.socket.emit('client-handshake-awareness', {
|
this.socket.emit(
|
||||||
workspaceId: this.workspaceId,
|
'client-handshake-awareness',
|
||||||
version: runtimeConfig.appVersion,
|
{
|
||||||
});
|
workspaceId: this.workspaceId,
|
||||||
this.socket.emit('awareness-init', this.workspaceId);
|
version: runtimeConfig.appVersion,
|
||||||
|
},
|
||||||
|
(res: any) => {
|
||||||
|
logger.debug('awareness handshake finished', res);
|
||||||
|
this.socket.emit('awareness-init', this.workspaceId, (res: any) => {
|
||||||
|
logger.debug('awareness-init finished', res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleReject = (_msg: RejectByVersion) => {
|
handleReject = (_msg: RejectByVersion) => {
|
||||||
|
|||||||
@@ -42,10 +42,16 @@ export class AffineSyncStorage implements SyncStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleConnect = () => {
|
handleConnect = () => {
|
||||||
this.socket.emit('client-handshake-sync', {
|
this.socket.emit(
|
||||||
workspaceId: this.workspaceId,
|
'client-handshake-sync',
|
||||||
version: runtimeConfig.appVersion,
|
{
|
||||||
});
|
workspaceId: this.workspaceId,
|
||||||
|
version: runtimeConfig.appVersion,
|
||||||
|
},
|
||||||
|
(res: any) => {
|
||||||
|
logger.debug('client handshake finished', res);
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleReject = (message: RejectByVersion) => {
|
handleReject = (message: RejectByVersion) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user