Update DM and profile handling

This commit is contained in:
AidarKC
2026-09-02 14:24:44 +04:00
parent 53cd55a2a0
commit 065616a18b
12 changed files with 368 additions and 862 deletions
+118 -115
View File
@@ -30,7 +30,6 @@ import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speec
import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { userDisplayName } from '../services/user-display.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
@@ -59,11 +58,11 @@ function openUserIdentityMenu({ anchorEl, login, navigate }) {
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
<span>Связи</span>
<span>Показать связи</span>
</button>
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
<span>Профиль</span>
<span>Показать профиль</span>
</button>
</div>
</div>
@@ -102,73 +101,93 @@ function openUserIdentityMenu({ anchorEl, login, navigate }) {
});
}
function normalizeChatRelationType(value) {
const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
return 'none';
}
function chatRelationLabel(value) {
switch (normalizeChatRelationType(value)) {
case 'close_friend': return 'Близкий друг';
case 'friend': return 'Друг';
case 'contact': return 'Контакт';
default: return 'Не в контактах';
}
}
function createChatHeaderParts(login, navigate) {
const cleanLogin = String(login || '').trim() || 'unknown';
let currentPeer = {
login: cleanLogin,
firstName: '',
lastName: '',
avatar: null,
relationType: 'none',
};
const identityButton = document.createElement('button');
identityButton.type = 'button';
identityButton.className = 'chat-header-peer-btn';
identityButton.title = `Меню ${cleanLogin}`;
identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
const avatarSlot = document.createElement('span');
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left';
const initialAvatar = renderUserAvatar({
login: cleanLogin,
size: 'md',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.append(initialAvatar);
avatarSlot.className = 'chat-header-avatar-slot';
const textWrap = document.createElement('span');
textWrap.className = 'chat-header-peer-text';
const nameEl = document.createElement('span');
nameEl.className = 'chat-header-peer-name';
const metaEl = document.createElement('span');
metaEl.className = 'chat-header-peer-meta';
textWrap.append(nameEl, metaEl);
identityButton.append(avatarSlot, textWrap);
const avatarButton = document.createElement('button');
avatarButton.type = 'button';
avatarButton.className = 'chat-header-avatar-btn';
avatarButton.title = `Меню ${cleanLogin}`;
avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`);
avatarButton.append(avatarSlot);
const loginEl = document.createElement('button');
loginEl.type = 'button';
loginEl.className = 'chat-header-login chat-header-login-btn';
loginEl.setAttribute('role', 'heading');
loginEl.setAttribute('aria-level', '1');
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`);
loginEl.innerHTML = `<span class="chat-header-display-name">${cleanLogin}</span><span class="chat-header-user-login" hidden>${cleanLogin}</span>`;
void loadProfileSnapshot(cleanLogin)
.then((snapshot) => {
if (!avatarSlot.isConnected) return;
const upgradedAvatar = renderUserAvatar({
login: cleanLogin,
firstName: String(snapshot?.firstName || '').trim(),
lastName: String(snapshot?.lastName || '').trim(),
avatar: snapshot?.avatar?.txId
? {
ar: String(snapshot.avatar.txId || '').trim(),
sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(),
}
: null,
size: 'md',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.replaceChildren(upgradedAvatar);
if (loginEl.isConnected) {
const display = userDisplayName({ login: cleanLogin, firstName: snapshot?.firstName, lastName: snapshot?.lastName });
const nameNode = loginEl.querySelector('.chat-header-display-name');
const loginNode = loginEl.querySelector('.chat-header-user-login');
if (nameNode) nameNode.textContent = display;
if (loginNode) {
loginNode.textContent = cleanLogin;
loginNode.hidden = display === cleanLogin;
const renderPeer = () => {
const firstName = String(currentPeer?.firstName || '').trim();
const lastName = String(currentPeer?.lastName || '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
nameEl.textContent = fullName || cleanLogin;
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
const avatar = renderUserAvatar({
login: cleanLogin,
firstName,
lastName,
avatar: currentPeer?.avatar?.ar
? {
ar: String(currentPeer.avatar.ar || '').trim(),
sha256Hex: String(currentPeer.avatar.sha256Hex || '').trim().toLowerCase(),
}
}
})
.catch(() => {});
: null,
size: 'md',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.replaceChildren(avatar);
};
const openMenu = (event) => {
const updatePeer = (peer) => {
if (!peer || typeof peer !== 'object') return;
currentPeer = {
...currentPeer,
...peer,
login: String(peer.login || cleanLogin).trim() || cleanLogin,
relationType: normalizeChatRelationType(peer.relationType),
};
renderPeer();
};
renderPeer();
identityButton.addEventListener('click', (event) => {
if (!cleanLogin || cleanLogin === 'unknown') return;
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
};
avatarButton.addEventListener('click', openMenu);
loginEl.addEventListener('click', openMenu);
});
return { centerNode: loginEl, avatarButton };
return {
centerNode: identityButton,
updatePeer,
getPeer: () => ({ ...currentPeer }),
};
}
function truncatePreviewText(value, maxLen = 72) {
@@ -384,8 +403,10 @@ function openMessageActionsMenu({
function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
showAddContact = false,
onCall,
onVideoCall,
onAddContact,
onClearHistory,
onDeleteChat,
}) {
@@ -398,6 +419,7 @@ function openChatActionsMenu({
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
${showAddContact ? `<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-add-contact"><span class="dm-menu-icon" aria-hidden="true"></span><span>Добавить в контакты</span></button>` : ''}
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
</div>
@@ -449,6 +471,10 @@ function openChatActionsMenu({
close();
if (typeof onVideoCall === 'function') await onVideoCall();
});
root.querySelector('#chat-menu-add-contact')?.addEventListener('click', async () => {
close();
if (typeof onAddContact === 'function') await onAddContact();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
@@ -970,6 +996,8 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-chat-screen';
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
let peerRelationType = isKnownContact ? 'contact' : 'none';
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
let historyHasMore = true;
@@ -1064,6 +1092,24 @@ export function render({ navigate, route, chrome }) {
notifyUnreadStateUpdated();
};
const addPeerToContacts = async () => {
const approved = await openConfirmContactModal(chatId);
if (!approved) return;
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
peerRelationType = 'contact';
chatHeaderParts?.updatePeer?.({ relationType: 'contact' });
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin) || []);
notifyUnreadStateUpdated();
showToast('Добавлено в контакты', { timeoutMs: 1200 });
};
const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap';
const historyLoader = document.createElement('div');
@@ -1100,8 +1146,16 @@ export function render({ navigate, route, chrome }) {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
showAddContact: normalizeChatRelationType(peerRelationType) === 'none',
onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onAddContact: async () => {
try {
await addPeerToContacts();
} catch (error) {
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
@@ -1149,63 +1203,8 @@ export function render({ navigate, route, chrome }) {
},
],
});
const chatHeaderLeft = chatHeader.querySelector('.header-left');
chatHeaderLeft?.append(chatHeaderParts.avatarButton);
chrome?.setTopbar(chatHeader);
const addContactCard = document.createElement('div');
addContactCard.className = 'card dm-add-contact-card';
addContactCard.hidden = true;
const addContactBtn = document.createElement('button');
addContactBtn.className = 'secondary-btn';
addContactBtn.type = 'button';
addContactBtn.textContent = 'Добавить собеседника в контакты';
addContactBtn.addEventListener('click', async () => {
try {
const approved = await openConfirmContactModal(chatId);
if (!approved) return;
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || []);
addAppLogEntry({
level: 'info',
source: 'contacts',
message: `Пользователь ${chatId} добавлен в контакты`,
});
addContactCard.remove();
} catch (e) {
addAppLogEntry({
level: 'warn',
source: 'contacts',
message: 'Не удалось добавить пользователя в контакты',
details: { login: chatId, error: e?.message || 'unknown' },
});
}
});
addContactCard.append(addContactBtn);
log.prepend(addContactCard);
// The button is valid only when there is no social relation at all.
// listContacts already returns relationFlag for every dialog/peer, so no extra server API is needed.
void authService.listContacts()
.then((contactsPayload) => {
setContacts(contactsPayload?.contacts || []);
const peerKey = String(chatId || '').trim().toLowerCase();
const peerDialog = (Array.isArray(contactsPayload?.dialogs) ? contactsPayload.dialogs : [])
.find((dialog) => String(dialog?.peerLogin || '').trim().toLowerCase() === peerKey);
const relationFlag = String(peerDialog?.relationFlag || 'none').trim().toLowerCase();
addContactCard.hidden = relationFlag !== 'none';
})
.catch(() => {
// On an API failure do not offer a potentially invalid relation action.
addContactCard.hidden = true;
});
const form = document.createElement('form');
form.className = 'chat-input dm-chat-input';
@@ -1597,6 +1596,10 @@ export function render({ navigate, route, chrome }) {
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
});
if (payload?.peer) {
peerRelationType = normalizeChatRelationType(payload.peer.relationType);
chatHeaderParts.updatePeer(payload.peer);
}
await mergeDirectMessagesPage(chatId, payload?.messages || []);
historyHasMore = Boolean(payload?.hasMore);
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);