perf: server navigation
This commit is contained in:
@@ -16,11 +16,12 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
|
||||
|
||||
const rows = await repo.find({
|
||||
where: { roomId, ownerUserId: currentUserId },
|
||||
order: { timestamp: 'ASC' },
|
||||
order: { timestamp: 'DESC' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
|
||||
const chronologicalRows = [...rows].reverse();
|
||||
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, chronologicalRows.map((row) => row.id));
|
||||
|
||||
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||
return chronologicalRows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export class AttachmentManagerService {
|
||||
|
||||
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
|
||||
private isDatabaseInitialised = false;
|
||||
private autoDownloadRequestsByRoom = new Map<string, Promise<void>>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -79,27 +80,23 @@ export class AttachmentManagerService {
|
||||
}
|
||||
|
||||
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
|
||||
if (!roomId || !this.isRoomWatched(roomId))
|
||||
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0)
|
||||
return;
|
||||
|
||||
if (this.database.isReady()) {
|
||||
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||
const activeRequest = this.autoDownloadRequestsByRoom.get(roomId);
|
||||
|
||||
for (const message of messages) {
|
||||
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
||||
await this.requestAutoDownloadsForMessage(message.id);
|
||||
}
|
||||
|
||||
return;
|
||||
if (activeRequest) {
|
||||
return activeRequest;
|
||||
}
|
||||
|
||||
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
||||
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||
|
||||
if (attachmentRoomId === roomId) {
|
||||
await this.requestAutoDownloadsForMessage(messageId);
|
||||
const request = this.runAutoDownloadsForRoom(roomId).finally(() => {
|
||||
if (this.autoDownloadRequestsByRoom.get(roomId) === request) {
|
||||
this.autoDownloadRequestsByRoom.delete(roomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.autoDownloadRequestsByRoom.set(roomId, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
async deleteForMessage(messageId: string): Promise<void> {
|
||||
@@ -180,6 +177,31 @@ export class AttachmentManagerService {
|
||||
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
|
||||
}
|
||||
|
||||
private async runAutoDownloadsForRoom(roomId: string): Promise<void> {
|
||||
if (!this.isRoomWatched(roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.database.isReady()) {
|
||||
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||
|
||||
for (const message of messages) {
|
||||
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
||||
await this.requestAutoDownloadsForMessage(message.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
||||
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||
|
||||
if (attachmentRoomId === roomId) {
|
||||
await this.requestAutoDownloadsForMessage(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
|
||||
if (!messageId)
|
||||
return;
|
||||
|
||||
@@ -80,6 +80,7 @@ export class ServersRailComponent {
|
||||
menuX = signal(72);
|
||||
menuY = signal(100);
|
||||
contextRoom = signal<Room | null>(null);
|
||||
optimisticSelectedRoomId = signal<string | null>(null);
|
||||
showLeaveConfirm = signal(false);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
@@ -192,6 +193,18 @@ export class ServersRailComponent {
|
||||
void this.refreshBannedLookup(rooms, currentUser ?? null);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const optimisticRoomId = this.optimisticSelectedRoomId();
|
||||
|
||||
if (!optimisticRoomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentRoom()?.id === optimisticRoomId && !this.isOnDirectMessage() && !this.isOnCall()) {
|
||||
this.optimisticSelectedRoomId.set(null);
|
||||
}
|
||||
});
|
||||
|
||||
this.savedRoomJoinRequests
|
||||
.pipe(
|
||||
switchMap(({ room, password }) => this.requestJoinInBackground(room, password)),
|
||||
@@ -214,6 +227,8 @@ export class ServersRailComponent {
|
||||
createServer(): void {
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
this.optimisticSelectedRoomId.set(null);
|
||||
|
||||
if (voiceServerId) {
|
||||
this.voiceSession.setViewingVoiceServer(false);
|
||||
}
|
||||
@@ -235,11 +250,13 @@ export class ServersRailComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.optimisticSelectedRoomId.set(room.id);
|
||||
this.activateSavedRoom(room);
|
||||
this.savedRoomJoinRequests.next({ room });
|
||||
}
|
||||
|
||||
openCall(callId: string): void {
|
||||
this.optimisticSelectedRoomId.set(null);
|
||||
void this.router.navigate(['/call', callId]);
|
||||
}
|
||||
|
||||
@@ -335,6 +352,7 @@ export class ServersRailComponent {
|
||||
);
|
||||
|
||||
if (isCurrentRoom) {
|
||||
this.optimisticSelectedRoomId.set(null);
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
@@ -374,6 +392,12 @@ export class ServersRailComponent {
|
||||
}
|
||||
|
||||
isSelectedRoom(room: Room): boolean {
|
||||
const optimisticRoomId = this.optimisticSelectedRoomId();
|
||||
|
||||
if (optimisticRoomId) {
|
||||
return optimisticRoomId === room.id;
|
||||
}
|
||||
|
||||
if (this.isOnDirectMessage() || this.isOnCall()) {
|
||||
return false;
|
||||
}
|
||||
@@ -492,6 +516,7 @@ export class ServersRailComponent {
|
||||
|
||||
if (errorCode === 'BANNED') {
|
||||
this.closePasswordDialog();
|
||||
this.optimisticSelectedRoomId.set(null);
|
||||
this.bannedRoomLookup.update((lookup) => ({
|
||||
...lookup,
|
||||
[room.id]: true
|
||||
|
||||
@@ -66,31 +66,32 @@ export class BrowserDatabaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve messages for a room, sorted oldest-first.
|
||||
* Retrieve the latest messages for a room, sorted oldest-first for display.
|
||||
* @param roomId - Target room.
|
||||
* @param limit - Maximum number of messages to return.
|
||||
* @param offset - Number of messages to skip (for pagination).
|
||||
* @param offset - Number of newer messages to skip (for pagination).
|
||||
*/
|
||||
async getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
||||
const allRoomMessages = await this.getAllFromIndex<Message>(
|
||||
STORE_MESSAGES, 'roomId', roomId
|
||||
);
|
||||
const sortedMessages = allRoomMessages.sort((first, second) => first.timestamp - second.timestamp);
|
||||
const endIndex = Math.max(sortedMessages.length - offset, 0);
|
||||
const startIndex = Math.max(endIndex - limit, 0);
|
||||
const messages = sortedMessages.slice(startIndex, endIndex);
|
||||
|
||||
return allRoomMessages
|
||||
.sort((first, second) => first.timestamp - second.timestamp)
|
||||
.slice(offset, offset + limit)
|
||||
.map((message) => this.normaliseMessage(message));
|
||||
return this.hydrateMessages(messages);
|
||||
}
|
||||
|
||||
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
|
||||
const allRoomMessages = await this.getAllFromIndex<Message>(
|
||||
STORE_MESSAGES, 'roomId', roomId
|
||||
);
|
||||
|
||||
return allRoomMessages
|
||||
const messages = allRoomMessages
|
||||
.filter((message) => message.timestamp > sinceTimestamp)
|
||||
.sort((first, second) => first.timestamp - second.timestamp)
|
||||
.map((message) => this.normaliseMessage(message));
|
||||
.sort((first, second) => first.timestamp - second.timestamp);
|
||||
|
||||
return this.hydrateMessages(messages);
|
||||
}
|
||||
|
||||
/** Delete a message by its ID. */
|
||||
@@ -112,7 +113,11 @@ export class BrowserDatabaseService {
|
||||
async getMessageById(messageId: string): Promise<Message | null> {
|
||||
const message = await this.get<Message>(STORE_MESSAGES, messageId);
|
||||
|
||||
return message ? this.normaliseMessage(message) : null;
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await this.hydrateMessages([message]))[0] ?? null;
|
||||
}
|
||||
|
||||
/** Remove every message belonging to a room. */
|
||||
@@ -520,6 +525,47 @@ export class BrowserDatabaseService {
|
||||
await this.awaitTransaction(transaction);
|
||||
}
|
||||
|
||||
private async hydrateMessages(messages: Message[]): Promise<Message[]> {
|
||||
if (messages.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const reactionsByMessageId = await this.loadReactionsForMessages(messages.map((message) => message.id));
|
||||
|
||||
return messages.map((message) => this.normaliseMessage({
|
||||
...message,
|
||||
reactions: reactionsByMessageId.get(message.id) ?? message.reactions ?? []
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadReactionsForMessages(messageIds: readonly string[]): Promise<Map<string, Reaction[]>> {
|
||||
const messageIdSet = new Set(messageIds.filter((messageId) => messageId.trim().length > 0));
|
||||
const reactionsByMessageId = new Map<string, Reaction[]>();
|
||||
|
||||
if (messageIdSet.size === 0) {
|
||||
return reactionsByMessageId;
|
||||
}
|
||||
|
||||
const allReactions = await this.getAll<Reaction>(STORE_REACTIONS);
|
||||
|
||||
for (const reaction of allReactions) {
|
||||
if (!messageIdSet.has(reaction.messageId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const reactions = reactionsByMessageId.get(reaction.messageId) ?? [];
|
||||
|
||||
reactions.push(reaction);
|
||||
reactionsByMessageId.set(reaction.messageId, reactions);
|
||||
}
|
||||
|
||||
for (const reactions of reactionsByMessageId.values()) {
|
||||
reactions.sort((first, second) => first.timestamp - second.timestamp);
|
||||
}
|
||||
|
||||
return reactionsByMessageId;
|
||||
}
|
||||
|
||||
private normaliseMessage(message: Message): Message {
|
||||
if (message.content === DELETED_MESSAGE_CONTENT) {
|
||||
return { ...message,
|
||||
|
||||
@@ -49,7 +49,7 @@ export class DatabaseService {
|
||||
/** Persist a single chat message. */
|
||||
saveMessage(message: Message) { return this.backend.saveMessage(message); }
|
||||
|
||||
/** Retrieve messages for a room with optional pagination. */
|
||||
/** Retrieve the latest messages for a room with optional pagination. */
|
||||
getMessages(roomId: string, limit = 100, offset = 0) { return this.backend.getMessages(roomId, limit, offset); }
|
||||
|
||||
/** Retrieve messages newer than a given timestamp for a room. */
|
||||
|
||||
@@ -37,11 +37,11 @@ export class ElectronDatabaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve messages for a room, sorted oldest-first.
|
||||
* Retrieve the latest messages for a room, sorted oldest-first for display.
|
||||
*
|
||||
* @param roomId - Target room.
|
||||
* @param limit - Maximum number of messages to return.
|
||||
* @param offset - Number of messages to skip (for pagination).
|
||||
* @param offset - Number of newer messages to skip (for pagination).
|
||||
*/
|
||||
getMessages(roomId: string, limit = 100, offset = 0): Promise<Message[]> {
|
||||
return this.api.query<Message[]>({ type: 'get-messages', payload: { roomId, limit, offset } });
|
||||
|
||||
@@ -111,40 +111,47 @@ export class MessagesSyncEffects {
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
mergeMap(([{ room }, currentRoom]) => {
|
||||
const activeRoom = currentRoom || room;
|
||||
switchMap(([{ room }, currentRoom]) => {
|
||||
const requestedRoomId = room.id;
|
||||
|
||||
if (!activeRoom)
|
||||
return EMPTY;
|
||||
return timer(75).pipe(
|
||||
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||
switchMap(([, latestCurrentRoom]) => {
|
||||
const activeRoom = latestCurrentRoom ?? currentRoom ?? room;
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
return from(
|
||||
this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)
|
||||
).pipe(
|
||||
tap((messages) => {
|
||||
const count = messages.length;
|
||||
const lastUpdated = getLatestTimestamp(messages);
|
||||
|
||||
for (const pid of this.webrtc.getConnectedPeers()) {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, {
|
||||
type: 'chat-sync-summary',
|
||||
roomId: activeRoom.id,
|
||||
count,
|
||||
lastUpdated
|
||||
});
|
||||
|
||||
this.webrtc.sendToPeer(pid, {
|
||||
type: 'chat-inventory-request',
|
||||
roomId: activeRoom.id
|
||||
});
|
||||
} catch (error) {
|
||||
this.debugging.warn('messages', 'Failed to kick off room sync for peer', {
|
||||
error,
|
||||
peerId: pid,
|
||||
roomId: activeRoom.id
|
||||
});
|
||||
}
|
||||
if (!activeRoom || activeRoom.id !== requestedRoomId || peers.length === 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return from(this.db.getMessages(activeRoom.id, FULL_SYNC_LIMIT, 0)).pipe(
|
||||
tap((messages) => {
|
||||
const count = messages.length;
|
||||
const lastUpdated = getLatestTimestamp(messages);
|
||||
|
||||
for (const pid of peers) {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, {
|
||||
type: 'chat-sync-summary',
|
||||
roomId: activeRoom.id,
|
||||
count,
|
||||
lastUpdated
|
||||
});
|
||||
|
||||
this.webrtc.sendToPeer(pid, {
|
||||
type: 'chat-inventory-request',
|
||||
roomId: activeRoom.id
|
||||
});
|
||||
} catch (error) {
|
||||
this.debugging.warn('messages', 'Failed to kick off room sync for peer', {
|
||||
error,
|
||||
peerId: pid,
|
||||
roomId: activeRoom.id
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
|
||||
@@ -50,6 +50,8 @@ import { canEditMessage } from '../../domains/chat/domain/rules/message.rules';
|
||||
import { resolveRoomPermission } from '../../domains/access-control';
|
||||
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
||||
|
||||
const INITIAL_ROOM_MESSAGE_LIMIT = 30;
|
||||
|
||||
@Injectable()
|
||||
export class MessagesEffects {
|
||||
private readonly actions$ = inject(Actions);
|
||||
@@ -66,7 +68,7 @@ export class MessagesEffects {
|
||||
this.actions$.pipe(
|
||||
ofType(MessagesActions.loadMessages),
|
||||
switchMap(({ roomId }) =>
|
||||
from(this.db.getMessages(roomId)).pipe(
|
||||
from(this.db.getMessages(roomId, INITIAL_ROOM_MESSAGE_LIMIT, 0)).pipe(
|
||||
mergeMap(async (messages) => {
|
||||
const hydrated = await hydrateMessages(messages, this.db);
|
||||
|
||||
|
||||
@@ -29,23 +29,20 @@ export type { InventoryItem } from '../../domains/chat/domain/rules/message-sync
|
||||
/** Hydrates a single message with its reactions from the database. */
|
||||
export async function hydrateMessage(
|
||||
msg: Message,
|
||||
db: DatabaseService
|
||||
_db: DatabaseService
|
||||
): Promise<Message> {
|
||||
if (msg.isDeleted)
|
||||
return normaliseDeletedMessage(msg);
|
||||
|
||||
const reactions = await db.getReactionsForMessage(msg.id);
|
||||
|
||||
return reactions.length > 0 ? { ...msg,
|
||||
reactions } : msg;
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** Hydrates an array of messages with their reactions. */
|
||||
export async function hydrateMessages(
|
||||
messages: Message[],
|
||||
db: DatabaseService
|
||||
_db: DatabaseService
|
||||
): Promise<Message[]> {
|
||||
return Promise.all(messages.map((msg) => hydrateMessage(msg, db)));
|
||||
return messages.map((msg) => msg.isDeleted ? normaliseDeletedMessage(msg) : msg);
|
||||
}
|
||||
|
||||
/** Builds a sync inventory item from a message and its reaction count. */
|
||||
|
||||
@@ -340,11 +340,12 @@ export class RoomMembersSyncEffects {
|
||||
const role = room.hostId === currentUser.id
|
||||
? 'host'
|
||||
: (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member');
|
||||
const seenAt = existingMember?.lastSeenAt ?? currentUser.joinedAt ?? Date.now();
|
||||
|
||||
return {
|
||||
...roomMemberFromUser(currentUser, Date.now(), role),
|
||||
...roomMemberFromUser(currentUser, seenAt, role),
|
||||
id: existingMember?.id ?? currentUser.id,
|
||||
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? Date.now(),
|
||||
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? seenAt,
|
||||
avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl,
|
||||
role
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
of,
|
||||
from,
|
||||
EMPTY,
|
||||
merge
|
||||
merge,
|
||||
timer
|
||||
} from 'rxjs';
|
||||
import {
|
||||
map,
|
||||
@@ -60,6 +61,8 @@ type BlockedRoomAccessAction =
|
||||
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||
| ReturnType<typeof RoomsActions.joinRoomFailure>;
|
||||
|
||||
const VIEW_SERVER_LOAD_DELAY_MS = 75;
|
||||
|
||||
@Injectable()
|
||||
export class RoomsEffects {
|
||||
private actions$ = inject(Actions);
|
||||
@@ -608,7 +611,12 @@ export class RoomsEffects {
|
||||
navigationRequestVersion
|
||||
});
|
||||
|
||||
this.router.navigate(['/room', room.id]);
|
||||
window.setTimeout(() => {
|
||||
if (this.signalingConnection.isCurrentRoomNavigation(room.id, navigationRequestVersion)) {
|
||||
void this.router.navigate(['/room', room.id]);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return of(RoomsActions.viewServerSuccess({ room }));
|
||||
};
|
||||
|
||||
@@ -634,7 +642,9 @@ export class RoomsEffects {
|
||||
onViewServerSuccess$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(RoomsActions.viewServerSuccess),
|
||||
mergeMap(({ room }) => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
|
||||
switchMap(({ room }) => timer(VIEW_SERVER_LOAD_DELAY_MS).pipe(
|
||||
mergeMap(() => [MessagesActions.loadMessages({ roomId: room.id }), UsersActions.loadBans()])
|
||||
))
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -42,6 +42,20 @@ function getDefaultTextChannelId(room: Room): string {
|
||||
return resolveActiveTextChannelId(enrichRoom(room).channels, 'general');
|
||||
}
|
||||
|
||||
function activateRoomView(state: RoomsState, room: Room, isConnecting: boolean): RoomsState {
|
||||
const enriched = enrichRoom(room);
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: enriched,
|
||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||
isConnecting,
|
||||
signalServerCompatibilityError: null,
|
||||
isConnected: true,
|
||||
activeChannelId: getDefaultTextChannelId(enriched)
|
||||
};
|
||||
}
|
||||
|
||||
/** Upsert a room into a saved-rooms list (add or replace by id) */
|
||||
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
|
||||
const normalizedRoom = enrichRoom(room);
|
||||
@@ -220,27 +234,24 @@ export const roomsReducer = createReducer(
|
||||
})),
|
||||
|
||||
// View server - just switch the viewed room, stay connected
|
||||
on(RoomsActions.viewServer, (state) => ({
|
||||
...state,
|
||||
isConnecting: true,
|
||||
signalServerCompatibilityError: null,
|
||||
error: null
|
||||
})),
|
||||
|
||||
on(RoomsActions.viewServerSuccess, (state, { room }) => {
|
||||
const enriched = enrichRoom(room);
|
||||
on(RoomsActions.viewServer, (state, { room, skipBanCheck }) => {
|
||||
if (skipBanCheck) {
|
||||
return {
|
||||
...activateRoomView(state, room, true),
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentRoom: enriched,
|
||||
savedRooms: upsertRoom(state.savedRooms, enriched),
|
||||
isConnecting: false,
|
||||
isConnecting: true,
|
||||
signalServerCompatibilityError: null,
|
||||
isConnected: true,
|
||||
activeChannelId: getDefaultTextChannelId(enriched)
|
||||
error: null
|
||||
};
|
||||
}),
|
||||
|
||||
on(RoomsActions.viewServerSuccess, (state, { room }) => activateRoomView(state, room, false)),
|
||||
|
||||
// Update room settings
|
||||
on(RoomsActions.updateRoomSettings, (state) => ({
|
||||
...state,
|
||||
|
||||
Reference in New Issue
Block a user