наверное работает
This commit is contained in:
parent
2d48ae7a16
commit
185ba5b1d3
@ -16,6 +16,7 @@ import {
|
|||||||
state,
|
state,
|
||||||
terminateCurrentSession,
|
terminateCurrentSession,
|
||||||
addSignedMessageToChat,
|
addSignedMessageToChat,
|
||||||
|
markIncomingReadByBaseKey,
|
||||||
markOutgoingReadByBaseKey,
|
markOutgoingReadByBaseKey,
|
||||||
setContacts,
|
setContacts,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
@ -398,16 +399,20 @@ async function init() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
} else if (messageType === 3 || messageType === 4) {
|
} else if (messageType === 3 || messageType === 4) {
|
||||||
const refBaseKey = String(payload.receiptRefBaseKey || '').trim();
|
let refBaseKey = String(payload.receiptRefBaseKey || '').trim();
|
||||||
if (refBaseKey) {
|
if (!refBaseKey) {
|
||||||
markOutgoingReadByBaseKey(refBaseKey);
|
|
||||||
} else {
|
|
||||||
try {
|
try {
|
||||||
const ref = authService.parseReadReceiptPayload(parsed.payloadBytes);
|
const ref = authService.parseReadReceiptPayload(parsed.payloadBytes);
|
||||||
const fallbackRefBase = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`;
|
refBaseKey = `${ref.refFromLogin}|${ref.refToLogin}|${ref.refTimeMs}|${ref.refNonce}`;
|
||||||
markOutgoingReadByBaseKey(fallbackRefBase);
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
if (refBaseKey) {
|
||||||
|
if (messageType === 3) {
|
||||||
|
markOutgoingReadByBaseKey(refBaseKey);
|
||||||
|
} else {
|
||||||
|
markIncomingReadByBaseKey(refBaseKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'signed-dm',
|
source: 'signed-dm',
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export function renderHeader({ title, leftAction, rightActions = [] }) {
|
export function renderHeader({ title, leftAction, leftLabel = '', rightActions = [] }) {
|
||||||
const wrap = document.createElement('header');
|
const wrap = document.createElement('header');
|
||||||
wrap.className = 'page-header';
|
wrap.className = 'page-header';
|
||||||
|
|
||||||
@ -11,6 +11,12 @@ export function renderHeader({ title, leftAction, rightActions = [] }) {
|
|||||||
btn.addEventListener('click', leftAction.onClick);
|
btn.addEventListener('click', leftAction.onClick);
|
||||||
left.append(btn);
|
left.append(btn);
|
||||||
}
|
}
|
||||||
|
if (leftLabel) {
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'header-left-label';
|
||||||
|
label.textContent = leftLabel;
|
||||||
|
left.append(label);
|
||||||
|
}
|
||||||
|
|
||||||
const h1 = document.createElement('h1');
|
const h1 = document.createElement('h1');
|
||||||
h1.className = 'page-title';
|
h1.className = 'page-title';
|
||||||
|
|||||||
@ -508,17 +508,13 @@ function toListModel(groups) {
|
|||||||
|
|
||||||
function renderEmptyState(activeTab, navigate) {
|
function renderEmptyState(activeTab, navigate) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'channels-empty-state';
|
wrap.className = 'channels-empty-state channels-empty-state--compact';
|
||||||
|
|
||||||
const icon = document.createElement('div');
|
|
||||||
icon.className = 'channels-empty-icon';
|
|
||||||
icon.textContent = '◌';
|
|
||||||
|
|
||||||
const text = document.createElement('div');
|
const text = document.createElement('div');
|
||||||
text.className = 'meta-muted';
|
text.className = 'meta-muted';
|
||||||
text.textContent = 'В этом разделе пока нет каналов';
|
text.textContent = 'В этом разделе нет сообщений';
|
||||||
|
|
||||||
wrap.append(icon, text);
|
wrap.append(text);
|
||||||
|
|
||||||
if (activeTab === 'my') {
|
if (activeTab === 'my') {
|
||||||
const cta = document.createElement('button');
|
const cta = document.createElement('button');
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
getChatMessages,
|
getChatMessages,
|
||||||
markChatRead,
|
markChatRead,
|
||||||
markOutgoingSent,
|
markOutgoingSent,
|
||||||
|
markReadReceiptSentByBaseKey,
|
||||||
authService,
|
authService,
|
||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} from '../state.js';
|
||||||
@ -33,10 +34,10 @@ function renderLog(list, chatId) {
|
|||||||
messages.forEach((msg) => {
|
messages.forEach((msg) => {
|
||||||
if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) {
|
if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) {
|
||||||
const sep = document.createElement('div');
|
const sep = document.createElement('div');
|
||||||
sep.className = 'meta-muted';
|
sep.className = 'chat-unread-separator';
|
||||||
sep.style.textAlign = 'center';
|
const label = document.createElement('span');
|
||||||
sep.style.margin = '8px 0';
|
label.textContent = 'Новые сообщения';
|
||||||
sep.textContent = 'Новые сообщения';
|
sep.append(label);
|
||||||
list.append(sep);
|
list.append(sep);
|
||||||
unreadSeparatorInserted = true;
|
unreadSeparatorInserted = true;
|
||||||
}
|
}
|
||||||
@ -216,7 +217,11 @@ async function sendReadReceiptsForVisible(chatId) {
|
|||||||
refNonce: ref.nonce,
|
refNonce: ref.nonce,
|
||||||
refType: 1,
|
refType: 1,
|
||||||
});
|
});
|
||||||
row.readReceiptSent = true;
|
if (row.baseKey) {
|
||||||
|
markReadReceiptSentByBaseKey(row.baseKey);
|
||||||
|
} else {
|
||||||
|
row.readReceiptSent = true;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addAppLogEntry({
|
addAppLogEntry({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export function render({ navigate }) {
|
|||||||
screen.append(
|
screen.append(
|
||||||
renderHeader({
|
renderHeader({
|
||||||
title: 'Личные сообщения',
|
title: 'Личные сообщения',
|
||||||
|
leftLabel: String(state.session.login || '').trim(),
|
||||||
rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }],
|
rightActions: [{ label: '+', onClick: () => navigate('contact-search-view') }],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -348,6 +348,37 @@ export function markOutgoingReadByBaseKey(baseKey) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function markIncomingReadByBaseKey(baseKey) {
|
||||||
|
if (!baseKey) return;
|
||||||
|
const keys = Object.keys(state.chats || {});
|
||||||
|
keys.forEach((chatId) => {
|
||||||
|
const list = getChatMessages(chatId);
|
||||||
|
list.forEach((row) => {
|
||||||
|
if (row?.from !== 'in') return;
|
||||||
|
if (row.baseKey === baseKey) {
|
||||||
|
row.unread = false;
|
||||||
|
row.readReceiptSent = true;
|
||||||
|
persistMessageRecord(chatId, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markReadReceiptSentByBaseKey(baseKey) {
|
||||||
|
if (!baseKey) return;
|
||||||
|
const keys = Object.keys(state.chats || {});
|
||||||
|
keys.forEach((chatId) => {
|
||||||
|
const list = getChatMessages(chatId);
|
||||||
|
list.forEach((row) => {
|
||||||
|
if (row?.from !== 'in') return;
|
||||||
|
if (row.baseKey === baseKey) {
|
||||||
|
row.readReceiptSent = true;
|
||||||
|
persistMessageRecord(chatId, row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function addSignedMessageToChat({
|
export function addSignedMessageToChat({
|
||||||
chatId,
|
chatId,
|
||||||
messageKey,
|
messageKey,
|
||||||
|
|||||||
@ -24,6 +24,16 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-left-label {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a8bcdf;
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
@ -669,6 +679,27 @@
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-unread-separator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #9ab0de;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-unread-separator::before,
|
||||||
|
.chat-unread-separator::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, rgba(126, 150, 199, 0.12), rgba(126, 150, 199, 0.45), rgba(126, 150, 199, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-unread-separator > span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
max-width: 76%;
|
max-width: 76%;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@ -992,15 +1023,15 @@ textarea.input {
|
|||||||
.node.is-shine .node-dot::before {
|
.node.is-shine .node-dot::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -7px;
|
inset: -14px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: radial-gradient(circle, rgba(130, 235, 255, 0.45) 0%, rgba(130, 235, 255, 0.18) 45%, rgba(130, 235, 255, 0) 72%);
|
background: radial-gradient(circle, rgba(130, 235, 255, 0.62) 0%, rgba(130, 235, 255, 0.28) 42%, rgba(130, 235, 255, 0) 76%);
|
||||||
filter: blur(1px);
|
filter: blur(2px);
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node.is-shine .node-dot {
|
.node.is-shine .node-dot {
|
||||||
box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.38), 0 0 16px rgba(102, 220, 255, 0.38), 0 8px 16px rgba(4, 8, 15, 0.35);
|
box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.5), 0 0 28px rgba(102, 220, 255, 0.58), 0 8px 16px rgba(4, 8, 15, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.node.is-friend .node-dot {
|
.node.is-friend .node-dot {
|
||||||
@ -1375,6 +1406,7 @@ textarea.input {
|
|||||||
|
|
||||||
.channels-groups {
|
.channels-groups {
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
|
justify-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels-section {
|
.channels-section {
|
||||||
@ -1407,6 +1439,7 @@ textarea.input {
|
|||||||
grid-template-columns: 46px minmax(0, 1fr) 72px;
|
grid-template-columns: 46px minmax(0, 1fr) 72px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px 13px;
|
padding: 14px 13px;
|
||||||
|
width: min(100%, 340px);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(193, 157, 82, 0.28);
|
border: 1px solid rgba(193, 157, 82, 0.28);
|
||||||
background:
|
background:
|
||||||
@ -1865,6 +1898,14 @@ textarea.input {
|
|||||||
background: rgba(10, 18, 34, 0.52);
|
background: rgba(10, 18, 34, 0.52);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channels-empty-state--compact {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 4px 2px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.channels-empty-icon {
|
.channels-empty-icon {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #d9b56d;
|
color: #d9b56d;
|
||||||
@ -2076,7 +2117,7 @@ textarea.input {
|
|||||||
.channels-list-content {
|
.channels-list-content {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 42vh;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channels-list-body-fade {
|
.channels-list-body-fade {
|
||||||
|
|||||||
@ -528,6 +528,18 @@ public final class DatabaseInitializer {
|
|||||||
ON signed_messages_v2 (base_key, message_type);
|
ON signed_messages_v2 (base_key, message_type);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
||||||
|
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||||
|
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing
|
||||||
|
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||||
|
WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL;
|
||||||
|
""");
|
||||||
|
|
||||||
// 14) signed_message_session_delivery (доставка по сессиям)
|
// 14) signed_message_session_delivery (доставка по сессиям)
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
|
CREATE TABLE IF NOT EXISTS signed_message_session_delivery (
|
||||||
|
|||||||
@ -90,6 +90,7 @@ public final class SqliteDbController {
|
|||||||
ensureConnectionsIndexes(st);
|
ensureConnectionsIndexes(st);
|
||||||
ensureReactionsIndexes(st);
|
ensureReactionsIndexes(st);
|
||||||
ensureChannelNamesIndexes(st);
|
ensureChannelNamesIndexes(st);
|
||||||
|
ensureSignedMessageReceiptUniq(c, st);
|
||||||
|
|
||||||
DatabaseTriggersInstaller.createAllTriggers(st);
|
DatabaseTriggersInstaller.createAllTriggers(st);
|
||||||
|
|
||||||
@ -223,6 +224,53 @@ public final class SqliteDbController {
|
|||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ensureSignedMessageReceiptUniq(Connection c, Statement st) throws SQLException {
|
||||||
|
if (!tableExists(c, "signed_messages_v2")) return;
|
||||||
|
|
||||||
|
if (tableExists(c, "signed_message_session_delivery")) {
|
||||||
|
st.executeUpdate("""
|
||||||
|
DELETE FROM signed_message_session_delivery
|
||||||
|
WHERE message_key IN (
|
||||||
|
SELECT message_key
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE message_type IN (3, 4)
|
||||||
|
AND receipt_ref_base_key IS NOT NULL
|
||||||
|
AND rowid NOT IN (
|
||||||
|
SELECT MIN(rowid)
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE message_type IN (3, 4)
|
||||||
|
AND receipt_ref_base_key IS NOT NULL
|
||||||
|
GROUP BY target_login, message_type, receipt_ref_base_key
|
||||||
|
)
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
DELETE FROM signed_messages_v2
|
||||||
|
WHERE message_type IN (3, 4)
|
||||||
|
AND receipt_ref_base_key IS NOT NULL
|
||||||
|
AND rowid NOT IN (
|
||||||
|
SELECT MIN(rowid)
|
||||||
|
FROM signed_messages_v2
|
||||||
|
WHERE message_type IN (3, 4)
|
||||||
|
AND receipt_ref_base_key IS NOT NULL
|
||||||
|
GROUP BY target_login, message_type, receipt_ref_base_key
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_incoming
|
||||||
|
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||||
|
WHERE message_type = 3 AND receipt_ref_base_key IS NOT NULL;
|
||||||
|
""");
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_signed_messages_v2_receipt_outgoing
|
||||||
|
ON signed_messages_v2 (target_login, receipt_ref_base_key)
|
||||||
|
WHERE message_type = 4 AND receipt_ref_base_key IS NOT NULL;
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
private static void rebuildConnectionsStateTable(Statement st) throws SQLException {
|
private static void rebuildConnectionsStateTable(Statement st) throws SQLException {
|
||||||
st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2");
|
st.executeUpdate("DROP TABLE IF EXISTS connections_state_v2");
|
||||||
st.executeUpdate("""
|
st.executeUpdate("""
|
||||||
|
|||||||
@ -43,8 +43,11 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
||||||
}
|
}
|
||||||
|
|
||||||
SignedMessagesCore.saveIfAbsent(entry);
|
boolean inserted = SignedMessagesCore.saveIfAbsent(entry);
|
||||||
SignedMessagesRealtime.DeliveryCounters counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);
|
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
|
if (inserted) {
|
||||||
|
counters = SignedMessagesRealtime.deliverToTargetSessions(entry, null);
|
||||||
|
}
|
||||||
|
|
||||||
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
|
Net_ReceiveIncomingMessage_Response resp = new Net_ReceiveIncomingMessage_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
|
|||||||
@ -53,15 +53,19 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, ex.getMessage(), "Некорректный payload подтверждения");
|
||||||
}
|
}
|
||||||
|
|
||||||
SignedMessagesCore.saveIfAbsent(incomingEntry);
|
boolean incomingInserted = SignedMessagesCore.saveIfAbsent(incomingEntry);
|
||||||
SignedMessagesCore.saveIfAbsent(outgoingEntry);
|
boolean outgoingInserted = SignedMessagesCore.saveIfAbsent(outgoingEntry);
|
||||||
|
|
||||||
SignedMessagesRealtime.DeliveryCounters inCounters =
|
SignedMessagesRealtime.DeliveryCounters inCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null);
|
if (incomingInserted) {
|
||||||
|
inCounters = SignedMessagesRealtime.deliverToTargetSessions(incomingEntry, null);
|
||||||
|
}
|
||||||
|
|
||||||
String excludeSessionId = outgoingEntry.getTargetLogin().equalsIgnoreCase(ctx.getLogin()) ? ctx.getSessionId() : null;
|
String excludeSessionId = outgoingEntry.getTargetLogin().equalsIgnoreCase(ctx.getLogin()) ? ctx.getSessionId() : null;
|
||||||
SignedMessagesRealtime.DeliveryCounters outCounters =
|
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||||
SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId);
|
if (outgoingInserted) {
|
||||||
|
outCounters = SignedMessagesRealtime.deliverToTargetSessions(outgoingEntry, excludeSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
Net_SendMessagePair_Response resp = new Net_SendMessagePair_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
|
|||||||
@ -83,7 +83,7 @@ final class SignedMessagesCore {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
|
static boolean saveIfAbsent(SignedMessageV2Entry entry) throws Exception {
|
||||||
SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
return SignedMessagesV2DAO.getInstance().insertIfAbsent(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user