diff --git a/SHiNE-bundle-20260829-180249.zip b/SHiNE-bundle-20260829-180249.zip deleted file mode 100644 index 193937b8..00000000 Binary files a/SHiNE-bundle-20260829-180249.zip and /dev/null differ diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java index d75a6ea4..f214e2c7 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java @@ -364,6 +364,8 @@ public final class DmDialogStateDAO { String nextRelation = current.relationFlag(); if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) { nextRelation = "close_friend"; + } else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) { + nextRelation = "friend"; } else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) { nextRelation = "contact"; } else { @@ -440,7 +442,7 @@ public final class DmDialogStateDAO { private String normalizeRelationFlag(String value) { String clean = normalize(value).toLowerCase(Locale.ROOT); - if ("close_friend".equals(clean) || "contact".equals(clean)) return clean; + if ("close_friend".equals(clean) || "friend".equals(clean) || "contact".equals(clean)) return clean; return "none"; } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserProfileStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserProfileStateDAO.java index 6721fcdc..250a0bd3 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserProfileStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserProfileStateDAO.java @@ -31,6 +31,28 @@ public final class UserProfileStateDAO { } } + + /** Effective outgoing social relation with priority close_friend > friend > contact > none. */ + public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException { + if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none"; + try (PreparedStatement ps = c.prepareStatement(""" + SELECT CASE + WHEN EXISTS(SELECT 1 FROM connections_state WHERE LOWER(login)=LOWER(?) AND LOWER(to_login)=LOWER(?) AND rel_type=10) THEN 'close_friend' + WHEN EXISTS(SELECT 1 FROM connections_state WHERE LOWER(login)=LOWER(?) AND LOWER(to_login)=LOWER(?) AND rel_type=14) THEN 'friend' + WHEN EXISTS(SELECT 1 FROM connections_state WHERE LOWER(login)=LOWER(?) AND LOWER(to_login)=LOWER(?) AND rel_type=20) THEN 'contact' + ELSE 'none' END AS relation_type + """)) { + int i = 1; + for (int n = 0; n < 3; n++) { + ps.setString(i++, ownerLogin); + ps.setString(i++, targetLogin); + } + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("relation_type") : "none"; + } + } + } + public List listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException { int safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500)); int safeOffset = Math.max(0, offset); @@ -98,10 +120,10 @@ public final class UserProfileStateDAO { """; } else if ("following".equals(mode)) { sql=""" - SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, LOWER(cn.display_name) AS display_name_sort_key, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash + SELECT DISTINCT cn.owner_login, cn.slug, cn.display_name, cn.ava_ar, cn.owner_bch_name, cn.channel_root_block_number, encode(cn.channel_root_block_hash,'hex') root_hash FROM connections_state cs JOIN channel_names_state cn ON cn.owner_bch_name=cs.to_bch_name AND cn.channel_root_block_number=cs.to_block_number AND cn.channel_root_block_hash=cs.to_block_hash WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1 - ORDER BY display_name_sort_key, cn.slug LIMIT ? OFFSET ? + ORDER BY LOWER(cn.display_name), cn.slug LIMIT ? OFFSET ? """; } else throw new IllegalArgumentException("Unsupported channel mode: "+mode); List out=new ArrayList<>(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java index 3953be7e..827d7ab2 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_ListContacts_Handler.java @@ -46,6 +46,7 @@ public class Net_ListContacts_Handler implements JsonMessageHandler { item.setFirstName(card.firstName()); item.setLastName(card.lastName()); item.setAvatarAr(card.avatarAr()); + item.setAvatar(parseAvatar(card.avatarAr())); item.setAccountRole(card.accountRole()); item.setShineStatus(card.shineStatus()); item.setLastMessageBlobB64(dialog.lastMessageBlobB64()); @@ -56,4 +57,14 @@ public class Net_ListContacts_Handler implements JsonMessageHandler { } return items; } + private static Net_ListContacts_Response.Avatar parseAvatar(String value) { + Net_ListContacts_Response.Avatar out = new Net_ListContacts_Response.Avatar(); + String raw = value == null ? "" : value.trim(); + java.util.regex.Matcher ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw); + if (ar.find()) out.setAr(ar.group(1)); + java.util.regex.Matcher sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw); + if (sha.find()) out.setSha256Hex(sha.group(1).toLowerCase()); + return out; + } + } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java index 0f77f2f8..50f1de3d 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_ListContacts_Response.java @@ -20,6 +20,7 @@ public class Net_ListContacts_Response extends Net_Response { private String firstName; private String lastName; private String avatarAr; + private Avatar avatar; private String accountRole; private String shineStatus; private String lastMessageBlobB64; @@ -37,6 +38,8 @@ public class Net_ListContacts_Response extends Net_Response { public void setLastName(String lastName) { this.lastName = lastName; } public String getAvatarAr() { return avatarAr; } public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; } + public Avatar getAvatar() { return avatar; } + public void setAvatar(Avatar avatar) { this.avatar = avatar; } public String getAccountRole() { return accountRole; } public void setAccountRole(String accountRole) { this.accountRole = accountRole; } public String getShineStatus() { return shineStatus; } @@ -50,4 +53,13 @@ public class Net_ListContacts_Response extends Net_Response { public boolean isHasDialog() { return hasDialog; } public void setHasDialog(boolean hasDialog) { this.hasDialog = hasDialog; } } + + public static class Avatar { + private String ar; + private String sha256Hex; + public String getAr() { return ar; } + public void setAr(String ar) { this.ar = ar; } + public String getSha256Hex() { return sha256Hex; } + public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; } + } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java index 74d4fee1..8113a996 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_GetDirectMessages_Handler.java @@ -11,6 +11,10 @@ import server.logic.ws_protocol.JSON.messages.entyties.Net_GetDirectMessages_Res import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.dao.SignedMessagesDAO; +import shine.db.dao.UserProfileStateDAO; +import shine.db.DbController; + +import java.sql.Connection; import shine.db.dao.DmDeliveryStateDAO; import shine.db.entities.DmDeliveryStateEntry; import shine.db.entities.SignedMessageEntry; @@ -74,6 +78,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler { resp.setLimit(limit); resp.setHasMore(hasMore); + // Return a normalized peer card together with the dialog. This makes the + // chat header self-contained even when /chat/ is opened directly. + try (Connection c = DbController.getInstance().getConnection()) { + UserProfileStateDAO.ProfileCard card = UserProfileStateDAO.getInstance().get(c, peerLogin); + Net_GetDirectMessages_Response.PeerCard peer = new Net_GetDirectMessages_Response.PeerCard(); + peer.setLogin(peerLogin); + peer.setFirstName(card.firstName()); + peer.setLastName(card.lastName()); + peer.setRelationType(UserProfileStateDAO.getInstance().getEffectiveRelationType(c, login, peerLogin)); + peer.setAccountRole(card.accountRole()); + peer.setShineStatus(card.shineStatus()); + peer.setAvatar(parseAvatar(card.avatarAr())); + resp.setPeer(peer); + } + List items = new ArrayList<>(); for (SignedMessageEntry entry : page) { Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem(); @@ -108,4 +127,16 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера"); } } + private static Net_GetDirectMessages_Response.Avatar parseAvatar(String value) { + Net_GetDirectMessages_Response.Avatar out = new Net_GetDirectMessages_Response.Avatar(); + String raw = value == null ? "" : value.trim(); + java.util.regex.Matcher ar = java.util.regex.Pattern.compile("(?:^|,)\\s*AR:([A-Za-z0-9_-]{43})(?:,|$)").matcher(raw); + if (!ar.find()) ar = java.util.regex.Pattern.compile("AR:([A-Za-z0-9_-]{43})").matcher(raw); + if (ar.find()) out.setAr(ar.group(1)); + java.util.regex.Matcher sha = java.util.regex.Pattern.compile("(?:^|,)\\s*SHA256:([A-Fa-f0-9]{64})(?:,|$)").matcher(raw); + if (!sha.find()) sha = java.util.regex.Pattern.compile("SHA256:([A-Fa-f0-9]{64})").matcher(raw); + if (sha.find()) out.setSha256Hex(sha.group(1).toLowerCase()); + return out; + } + } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java index a8f4844a..b9cd357c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/entyties/Net_GetDirectMessages_Response.java @@ -12,6 +12,7 @@ public class Net_GetDirectMessages_Response extends Net_Response { private boolean hasMore; private Long nextBeforeTimeMs; private String nextBeforeMessageKey; + private PeerCard peer; private List messages = new ArrayList<>(); public String getLogin() { return login; } @@ -26,9 +27,46 @@ public class Net_GetDirectMessages_Response extends Net_Response { public void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; } public String getNextBeforeMessageKey() { return nextBeforeMessageKey; } public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = nextBeforeMessageKey; } + public PeerCard getPeer() { return peer; } + public void setPeer(PeerCard peer) { this.peer = peer; } public List getMessages() { return messages; } public void setMessages(List messages) { this.messages = messages; } + + public static class PeerCard { + private String login; + private String firstName; + private String lastName; + private Avatar avatar; + private String relationType; + private String accountRole; + private String shineStatus; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public String getFirstName() { return firstName; } + public void setFirstName(String firstName) { this.firstName = firstName; } + public String getLastName() { return lastName; } + public void setLastName(String lastName) { this.lastName = lastName; } + public Avatar getAvatar() { return avatar; } + public void setAvatar(Avatar avatar) { this.avatar = avatar; } + public String getRelationType() { return relationType; } + public void setRelationType(String relationType) { this.relationType = relationType; } + public String getAccountRole() { return accountRole; } + public void setAccountRole(String accountRole) { this.accountRole = accountRole; } + public String getShineStatus() { return shineStatus; } + public void setShineStatus(String shineStatus) { this.shineStatus = shineStatus; } + } + + public static class Avatar { + private String ar; + private String sha256Hex; + public String getAr() { return ar; } + public void setAr(String ar) { this.ar = ar; } + public String getSha256Hex() { return sha256Hex; } + public void setSha256Hex(String sha256Hex) { this.sha256Hex = sha256Hex; } + } + public static class MessageItem { private String messageKey; private String baseKey; diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 2fa3ca88..4f4f6666 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -920,7 +920,7 @@ function renderNodeCard(node, heading, handlers, localNumber) { likeButton.innerHTML = ` ${isPending ? 'Лайк...' : 'Лайк'} - ${likes} · ${primaryLikes} · ${shiningLikes} + ${likes}/${primaryLikes}/${shiningLikes} `; setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк'); likeButton.disabled = isPending; @@ -964,24 +964,8 @@ function renderNodeCard(node, heading, handlers, localNumber) { onSubmit: async (textValue) => handlers.onReply(target, textValue), }); }); - const ratingButton = document.createElement('button'); - ratingButton.type = 'button'; - ratingButton.className = 'channel-action-item thread-rating-btn'; - ratingButton.innerHTML = ` - - Оценка - ${ratings} - `; - setActionTitle(ratingButton, 'Оценка'); - ratingButton.addEventListener('click', (event) => { - event.stopPropagation(); - animatePress(event.currentTarget); - openReplyModal({ - navigate: handlers.navigate, - mode: 'rating', - onSubmit: async (textValue) => handlers.onRating(target, textValue), - }); - }); + // Rating/opinion action is intentionally hidden from UI for now. + // Backend/subtype/counters remain supported so the feature can be restored later. const shareButton = document.createElement('button'); shareButton.type = 'button'; @@ -999,7 +983,7 @@ function renderNodeCard(node, heading, handlers, localNumber) { // Репосты временно отключены до будущей реализации. // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md - actions.append(likeButton, replyButton, ratingButton, shareButton); + actions.append(likeButton, replyButton, shareButton); if (repostTarget) { const originalButton = document.createElement('button'); originalButton.type = 'button'; diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 829f25ec..79dfc5f8 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1963,7 +1963,7 @@ function renderPostCard(post, { likeButton.innerHTML = ` ${isPending ? 'Лайк...' : 'Лайк'} - ${post.likesCount || 0} · ${post.primaryLikesCount || 0} · ${post.shiningLikesCount || 0} + ${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0} `; setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк'); likeButton.disabled = isPending; @@ -1998,27 +1998,10 @@ function renderPostCard(post, { onSubmit: async (text) => onReply(post.messageRef, text), }); }); - const ratingButton = document.createElement('button'); - ratingButton.type = 'button'; - ratingButton.className = 'channel-action-item channel-action-rating'; - ratingButton.innerHTML = ` - - Оценка - ${post.ratingsCount || 0} - `; - setActionTitle(ratingButton, 'Оценка'); - ratingButton.addEventListener('click', (event) => { - event.stopPropagation(); - animatePress(event.currentTarget); - openReplyModal({ - navigate, - mode: 'rating', - onSubmit: async (text) => onRating(post.messageRef, text), - }); - }); - // Репосты временно отключены до будущей реализации. - // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md - actions.append(likeButton, replyButton, ratingButton); + // Rating/opinion action is intentionally hidden from UI for now. + // Backend/subtype/counters remain supported so the feature can be restored later. + + actions.append(likeButton, replyButton); const shareButton = document.createElement('button'); shareButton.type = 'button'; diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index ef940ba6..ab9bfb5a 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -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 }) { @@ -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 = `${cleanLogin}`; - - 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({ @@ -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); diff --git a/shine-UI/js/pages/messages-list.js b/shine-UI/js/pages/messages-list.js index d9fcce62..11a1c9bf 100644 --- a/shine-UI/js/pages/messages-list.js +++ b/shine-UI/js/pages/messages-list.js @@ -11,9 +11,9 @@ import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.js'; import { createDropdownMenu } from '../components/dropdown-menu.js'; import { createShineConnectionsLogo } from '../components/shine-logo.js'; +import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { parseDmTechBlocks } from '../services/dm-tech-blocks.js'; import { formatRelativeTime } from '../services/channels-ux.js'; -import { userDisplayName } from '../services/user-display.js'; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; const PREVIEW_MAX_LEN = 200; @@ -22,14 +22,72 @@ const SVG_CHEVRON = ` `; +const DM_BLOB_PREVIEW_CACHE = new Map(); +const DM_BLOB_PREVIEW_PENDING = new Map(); +const dmAvatarSnapshotCache = new Map(); +const dmAvatarPendingByLogin = new Map(); + const RELATION_ORDER = new Map([ ['close_friend', 0], ['friend', 1], ['contact', 2], - ['none', 99], + ['none', 3], ]); -const DM_BLOB_PREVIEW_CACHE = new Map(); -const DM_BLOB_PREVIEW_PENDING = new Map(); + +async function loadDmAvatarSnapshot(login) { + const cleanLogin = String(login || '').trim(); + if (!cleanLogin) return null; + const key = cleanLogin.toLowerCase(); + if (dmAvatarSnapshotCache.has(key)) return dmAvatarSnapshotCache.get(key); + if (dmAvatarPendingByLogin.has(key)) return dmAvatarPendingByLogin.get(key); + const pending = loadProfileSnapshot(cleanLogin) + .then((snapshot) => { + dmAvatarSnapshotCache.set(key, snapshot || null); + dmAvatarPendingByLogin.delete(key); + return snapshot || null; + }) + .catch(() => { + dmAvatarSnapshotCache.set(key, null); + dmAvatarPendingByLogin.delete(key); + return null; + }); + dmAvatarPendingByLogin.set(key, pending); + return pending; +} + +function createDmAvatar(login, { className = '', avatar = null, firstName = '', lastName = '' } = {}) { + const cleanLogin = String(login || '').trim(); + const title = cleanLogin ? `Профиль ${cleanLogin}` : ''; + const avatarEl = renderUserAvatar({ + login: cleanLogin || 'unknown', + firstName: String(firstName || '').trim(), + lastName: String(lastName || '').trim(), + avatar: avatar?.ar ? { ar: String(avatar.ar || '').trim(), sha256Hex: String(avatar.sha256Hex || '').trim().toLowerCase() } : null, + size: 'lg', + title, + className, + }); + if (!cleanLogin || avatar?.ar) return avatarEl; + void loadDmAvatarSnapshot(cleanLogin).then((snapshot) => { + if (!avatarEl.isConnected) return; + const upgraded = renderUserAvatar({ + login: cleanLogin, + avatar: snapshot?.avatar?.txId + ? { + ar: String(snapshot.avatar.txId || '').trim(), + sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase(), + } + : null, + size: 'lg', + title, + className, + }); + upgraded.classList.add('avatar'); + avatarEl.replaceWith(upgraded); + }); + return avatarEl; +} + function normalizeRelationFlag(value) { const clean = String(value || '').trim().toLowerCase(); if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean; @@ -288,13 +346,10 @@ function renderRow(item) { const relationBadge = relationFlag === 'none' ? 'не в контактах' : relationLabel(relationFlag); - const avatarEl = renderUserAvatar({ - login: item.peerLogin, + const avatarEl = createDmAvatar(item.peerLogin, { + avatar: item.avatar, firstName: item.firstName, lastName: item.lastName, - avatar: item.avatarAr ? { ar: String(item.avatarAr).trim() } : null, - size: 'lg', - title: `Профиль ${item.peerLogin}`, }); avatarEl.classList.add('avatar'); const avatarWrap = document.createElement('div'); @@ -319,7 +374,10 @@ function renderRow(item) { const titleEl = row.querySelector('.dm-row-title'); const previewEl = row.querySelector('.dm-row-last-message'); const timeEl = row.querySelector('.dm-row-time'); - if (titleEl) titleEl.textContent = userDisplayName({ login: item.peerLogin, firstName: item.firstName, lastName: item.lastName }); + if (titleEl) { + const fullName = [String(item.firstName || '').trim(), String(item.lastName || '').trim()].filter(Boolean).join(' '); + titleEl.textContent = fullName || String(item.peerLogin || ''); + } if (previewEl) previewEl.textContent = 'Загрузка…'; if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs); row.prepend(avatarWrap); @@ -343,7 +401,10 @@ function renderRow(item) { try { const payload = await authService.listContacts(); const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : []; - const contacts = Array.isArray(payload?.contacts) ? payload.contacts : []; + const contacts = dialogs + .filter((dialog) => normalizeRelationFlag(dialog?.relationFlag) !== 'none') + .map((dialog) => String(dialog?.peerLogin || '').trim()) + .filter(Boolean); setContacts(contacts); list.innerHTML = ''; @@ -356,12 +417,12 @@ function renderRow(item) { const next = { id: peerLogin, peerLogin, + relationFlag, firstName: String(dialog?.firstName || '').trim(), lastName: String(dialog?.lastName || '').trim(), - avatarAr: String(dialog?.avatarAr || '').trim(), + avatar: dialog?.avatar && typeof dialog.avatar === 'object' ? dialog.avatar : null, accountRole: String(dialog?.accountRole || '').trim(), shineStatus: String(dialog?.shineStatus || '').trim(), - relationFlag, lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''), lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0), unreadCount: Number(dialog?.unreadCount || 0), diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 81f456f2..c5e21174 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1847,7 +1847,7 @@ } .bubble-meta { - margin-top: 0; + margin-top: 4px; display: flex; justify-content: flex-end; align-items: center; @@ -2241,7 +2241,7 @@ } .call-peer-avatar-slot .call-peer-avatar .avatar-fallback { - font-size: clamp(28px, 7.2vw, 46px); + font-size: clamp(34px, 9vw, 58px); font-weight: 800; } @@ -6468,14 +6468,6 @@ html, body { overflow-x: hidden; } gap: 10px; } -.dm-add-contact-card { - margin: 0 14px 2px; -} - -.dm-add-contact-card .secondary-btn { - width: 100%; -} - .dm-history-loader { position: sticky; top: 0; @@ -10692,711 +10684,78 @@ body.chat-topbar-overlay .composer-slot { ); } -/* DM header: human name is primary, login remains secondary. */ -.chat-header-login-btn{display:flex;flex-direction:column;align-items:flex-start;line-height:1.1} -.chat-header-display-name{font-size:14px;font-weight:650} -.chat-header-user-login{font-size:11px;font-weight:400;opacity:.68;margin-top:2px} -/* ===== Профиль другого пользователя: плавающая композиция без рамок (2026-09-01) ===== */ -.user-profile-screen { - --user-profile-avatar-size: min(33.6vw, 134px); - --user-profile-metric-size: clamp(42px, 12vw, 48px); - position: relative; - min-height: 100%; - gap: 0; - isolation: isolate; - overflow-x: hidden; - margin-top: -8px; +/* DM peer identity: one large invisible button fills the area between Back and Call. */ +body.chat-topbar-overlay .page-header.app-topbar-shell { + grid-template-columns: 44px minmax(0, 1fr) auto; + gap: 6px; } -.user-profile-screen::before { - content: ''; - position: absolute; - z-index: -1; - inset: 4% 0 14%; - pointer-events: none; - background: - radial-gradient(circle at 50% 27%, rgba(73, 113, 189, 0.16), transparent 29%), - radial-gradient(circle at 25% 58%, rgba(49, 198, 255, 0.08), transparent 24%), - radial-gradient(circle at 76% 56%, rgba(224, 190, 86, 0.07), transparent 25%); - filter: blur(18px); -} - - -.user-profile-status { - min-height: 0; - margin: 0; - text-align: center; - font-size: 12px; -} - -.user-profile-status:empty { - display: none; -} - -.user-profile-body { - display: flex; - flex-direction: column; - align-items: stretch; - min-height: 0; - padding: 0 0 10px; -} - -.user-profile-hero { - display: grid; - grid-template-columns: minmax(0, 1fr) var(--user-profile-avatar-size) minmax(0, 1fr); - grid-template-rows: 1fr 1fr; - align-items: stretch; +body.chat-topbar-overlay .page-header.app-topbar-shell .header-center { width: 100%; - height: var(--user-profile-avatar-size); - margin-top: 0; + justify-content: stretch; + overflow: hidden; } -.user-profile-avatar-slot { - grid-column: 2; - grid-row: 1 / 3; - width: var(--user-profile-avatar-size); - height: var(--user-profile-avatar-size); - display: grid; - place-items: center; - align-self: center; - justify-self: center; - z-index: 2; -} - -.user-profile-screen .user-profile-hero-avatar.avatar, -.user-profile-screen .user-profile-hero-avatar.avatar-image { +.chat-header-peer-btn { width: 100%; - height: 100%; - min-width: 100%; - min-height: 100%; - overflow: visible; - border: 0 !important; - background: transparent; - box-shadow: none; -} - -/* На профиле используем ту же стеклянную обводку аватара, что и в остальных местах приложения. */ -.user-profile-screen .user-profile-hero-avatar.avatar-framed::after { - display: block; -} - -.user-profile-screen .user-profile-hero-avatar.avatar-framed > .avatar-fallback, -.user-profile-screen .user-profile-hero-avatar.avatar-framed > .avatar-photo { - width: 92.5%; - height: 92.5%; -} - -.user-profile-screen .user-profile-hero-avatar.avatar-glow::before { - inset: -12%; - opacity: 0.5; -} - -.user-profile-screen .user-profile-hero-avatar .avatar-fallback { - font-size: clamp(34px, 9vw, 58px); - font-weight: 800; -} - -.user-profile-metric { - width: min(100%, 92px); min-width: 0; - padding: 0; - border: 0; - outline: 0; - background: transparent; - box-shadow: none; - display: flex; - flex-direction: column; - align-items: center; - gap: 5px; - color: #edf5ff; - cursor: pointer; -} - -.user-profile-metric:focus-visible .user-profile-metric-circle, -.user-profile-metric:active .user-profile-metric-circle { - transform: scale(0.94); -} - -.user-profile-metric-circle { - width: var(--user-profile-metric-size); - height: var(--user-profile-metric-size); - min-width: var(--user-profile-metric-size); - min-height: var(--user-profile-metric-size); - border-radius: 50%; - display: grid; - place-items: center; - border: 0; - background: rgba(14, 25, 43, 0.34); - box-shadow: none; - color: rgba(238, 246, 255, 0.94); - font-size: clamp(15px, 4.2vw, 19px); - font-weight: 800; - line-height: 1; - transition: transform 120ms ease, filter 160ms ease, box-shadow 160ms ease; -} - -.user-profile-metric.is-glowing .user-profile-metric-circle { - color: var(--app-topbar-gold); - background: rgba(14, 25, 43, 0.42); - box-shadow: - 0 0 8px var(--app-topbar-blue-glow), - 0 0 22px var(--app-topbar-blue-glow-soft), - 0 0 38px rgba(72, 145, 255, 0.18); - text-shadow: - 0 0 5px var(--app-topbar-blue-glow), - 0 0 14px var(--app-topbar-blue-glow-soft); -} - -.user-profile-metric-label { - width: 100%; - min-height: 24px; - color: rgba(205, 220, 243, 0.78); - font-size: clamp(9px, 2.8vw, 11px); - font-weight: 600; - line-height: 1.08; - text-align: center; -} - -.user-profile-metric.is-top-left, -.user-profile-metric.is-bottom-left { - grid-column: 1; - justify-self: end; - padding-right: clamp(4px, 2vw, 10px); -} - -.user-profile-metric.is-top-right, -.user-profile-metric.is-bottom-right { - grid-column: 3; - justify-self: start; - padding-left: clamp(4px, 2vw, 10px); -} - -/* Парные подписи читаются от центра: у левых к аватару прижат конец текста, у правых — начало. */ -.user-profile-hero .user-profile-metric.is-top-left .user-profile-metric-label, -.user-profile-hero .user-profile-metric.is-bottom-left .user-profile-metric-label { - text-align: right; - align-self: flex-end; -} - -.user-profile-hero .user-profile-metric.is-top-right .user-profile-metric-label, -.user-profile-hero .user-profile-metric.is-bottom-right .user-profile-metric-label { - text-align: left; - align-self: flex-start; -} - -.user-profile-metric.is-top-left, -.user-profile-metric.is-top-right { - grid-row: 1; - align-self: start; -} - -.user-profile-metric.is-bottom-left, -.user-profile-metric.is-bottom-right { - grid-row: 2; - align-self: end; - flex-direction: column-reverse; -} - -.user-profile-channel-metrics { - width: 66.666%; - margin: clamp(22px, 4vh, 30px) auto 0; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: start; - justify-items: center; -} - -.user-profile-channel-metrics .user-profile-metric { - width: 100%; -} - -.user-profile-channel-metrics .user-profile-metric-label { - min-height: 16px; - font-size: 11px; -} - -.user-profile-actions-wrap { - position: relative; - width: min(86%, 310px); - margin: clamp(18px, 3.4vh, 26px) auto 0; -} - -.user-profile-actions { - display: grid; - grid-template-columns: repeat(3, 1fr); - align-items: center; - justify-items: center; -} - -.user-profile-action-btn { - width: 54px; - height: 54px; - min-width: 54px; - min-height: 54px; - padding: 7px; - display: grid; - place-items: center; - border: 0 !important; - border-radius: 50%; - outline: 0; - background: transparent !important; - box-shadow: none !important; - color: rgba(226, 238, 255, 0.86); - cursor: pointer; - opacity: 0.9; - transition: transform 120ms ease, filter 160ms ease, opacity 160ms ease; -} - -.user-profile-action-btn:hover, -.user-profile-action-btn:focus-visible { - opacity: 1; - filter: drop-shadow(0 0 10px rgba(95, 196, 255, 0.42)); -} - -.user-profile-action-btn:active { - transform: scale(0.9); -} - -.user-profile-action-btn.is-active { - color: #d9f8ff; - filter: drop-shadow(0 0 9px rgba(83, 211, 255, 0.52)); -} - -.user-profile-action-btn img { - display: block; - width: 32px; - height: 32px; - object-fit: contain; -} - -.user-profile-action-btn.is-links img { - width: 39px; - height: 39px; - filter: brightness(1.08); -} - -.user-profile-action-svg { - width: 38px; - height: 38px; - overflow: visible; - fill: none; - stroke: currentColor; - stroke-width: 2.8; - stroke-linecap: round; - stroke-linejoin: round; -} - -.user-profile-add-menu { - position: absolute; - z-index: 12; - left: 0; - bottom: calc(100% + 8px); - width: min(190px, 68vw); - padding: 6px 4px; - border: 0; - border-radius: 18px; - background: linear-gradient(180deg, rgba(13, 20, 35, 0.93), rgba(5, 10, 20, 0.88)); - box-shadow: 0 18px 42px rgba(0, 0, 0, 0.38); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - transform-origin: 28px 100%; - animation: user-profile-menu-in 130ms ease-out both; -} - -.user-profile-add-menu[hidden] { - display: none !important; -} - -.user-profile-add-menu.is-busy { - pointer-events: none; - opacity: 0.58; -} - -.user-profile-add-option { - width: 100%; - min-height: 42px; - padding: 8px 11px; + min-height: 46px; + padding: 2px 0; border: 0; border-radius: 12px; background: transparent; box-shadow: none; - display: grid; - grid-template-columns: 1fr 24px; + display: flex; align-items: center; - gap: 8px; + justify-content: flex-start; + gap: 7px; text-align: left; - color: rgba(231, 239, 255, 0.88); - font-size: 13px; cursor: pointer; } -.user-profile-add-option:hover, -.user-profile-add-option:focus-visible, -.user-profile-add-option.is-current { - background: radial-gradient(circle at 18% 50%, rgba(70, 171, 224, 0.14), transparent 68%); - color: #ffffff; -} - -.user-profile-add-option-check { - color: #8deeff; - font-weight: 800; - text-align: center; - text-shadow: 0 0 9px rgba(89, 224, 255, 0.7); -} - -.user-profile-detail-links { - width: min(86%, 330px); - margin: clamp(16px, 3vh, 24px) auto 0; - display: flex; - align-items: center; - justify-content: space-between; - gap: 30px; -} - -.user-profile-detail-links button { - position: relative; - isolation: isolate; - min-height: 38px; - padding: 5px 8px; - border: 0 !important; - background: transparent !important; - box-shadow: none !important; - color: rgba(229, 239, 253, 0.86); - font-size: 15px; - font-weight: 650; - cursor: pointer; - text-shadow: - 0 0 4px rgba(92, 190, 255, 0.46), - 0 0 11px rgba(72, 145, 255, 0.2); -} - -.user-profile-detail-links button::before { - content: ''; - position: absolute; - z-index: -1; - left: 50%; - top: 50%; - width: 112px; - height: 34px; - transform: translate(-50%, -50%); - border-radius: 50%; - pointer-events: none; - background: radial-gradient(ellipse at center, rgba(92, 190, 255, 0.14) 0%, rgba(72, 145, 255, 0.07) 46%, transparent 74%); - filter: blur(4px); -} - -.user-profile-detail-links button:hover, -.user-profile-detail-links button:focus-visible { - color: #f2f7ff; - text-shadow: 0 0 12px rgba(111, 189, 255, 0.34); -} - -.user-profile-sheet-backdrop { - position: fixed; - z-index: 1400; - inset: 0; - display: flex; - align-items: flex-end; - justify-content: center; - padding: 0 max(10px, env(safe-area-inset-right)) max(10px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left)); - background: rgba(0, 0, 0, 0.34); - backdrop-filter: blur(3px); - -webkit-backdrop-filter: blur(3px); -} - -.user-profile-sheet { - width: min(100%, 410px); - max-height: min(58vh, 480px); - overflow: auto; - padding: 10px 20px calc(22px + env(safe-area-inset-bottom)); +.chat-header-peer-btn:hover, +.chat-header-peer-btn:active, +.chat-header-peer-btn:focus, +.chat-header-peer-btn:focus-visible { border: 0; - border-radius: 28px 28px 18px 18px; - background: - radial-gradient(circle at 50% -12%, rgba(73, 145, 211, 0.16), transparent 42%), - linear-gradient(180deg, rgba(13, 20, 34, 0.96), rgba(5, 9, 17, 0.97)); - box-shadow: 0 -20px 55px rgba(0, 0, 0, 0.46); - animation: user-profile-sheet-in 180ms ease-out both; -} - -.user-profile-sheet-handle { - width: 42px; - height: 4px; - margin: 1px auto 14px; - border-radius: 999px; - background: rgba(211, 226, 246, 0.2); -} - -.user-profile-sheet-title { - margin-bottom: 16px; - color: #f2f7ff; - font-size: 19px; - font-weight: 800; - text-align: center; -} - -.user-profile-sheet-content { - display: grid; - gap: 13px; -} - -.user-profile-sheet-copy { - white-space: pre-wrap; - overflow-wrap: anywhere; - color: rgba(225, 234, 248, 0.88); - font-size: 14px; - line-height: 1.58; -} - -.user-profile-sheet-copy.is-muted { - color: rgba(194, 207, 229, 0.56); - text-align: center; -} - -.user-profile-contact-row { - display: grid; - grid-template-columns: minmax(70px, auto) minmax(0, 1fr); - gap: 14px; - align-items: baseline; -} - -.user-profile-contact-row span { - color: rgba(181, 198, 225, 0.56); - font-size: 12px; -} - -.user-profile-contact-row b { - overflow-wrap: anywhere; - color: rgba(233, 240, 252, 0.9); - font-size: 14px; - font-weight: 600; - text-align: right; -} - -@keyframes user-profile-menu-in { - from { opacity: 0; transform: translateY(8px) scale(0.96); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -@keyframes user-profile-sheet-in { - from { opacity: 0; transform: translateY(24px); } - to { opacity: 1; transform: translateY(0); } -} - -@media (max-height: 700px) { - .user-profile-channel-metrics { - margin-top: 18px; - } - .user-profile-actions-wrap { - margin-top: 16px; - } - .user-profile-detail-links { - margin-top: 14px; - } -} - -@media (max-width: 350px) { - .user-profile-screen { - --user-profile-metric-size: 40px; - } - .user-profile-metric-label { - font-size: 9px; - } - .user-profile-actions-wrap { - width: 92%; - } -} - -/* Боковые метрики: круг строго остаётся внутри высоты аватара, подпись может быть ниже. */ -.user-profile-hero > .user-profile-metric { - position: relative; - height: var(--user-profile-metric-size); - gap: 0; -} - -.user-profile-hero > .user-profile-metric .user-profile-metric-label { - position: absolute; - top: calc(100% + 5px); - left: 0; -} - -.user-profile-metric.is-bottom-left, -.user-profile-metric.is-bottom-right { - flex-direction: column; -} - -/* ===== Профиль другого пользователя: геометрия v4 (2026-09-01) ===== - * - однородный фон без локальных пятен; - * - аватар +10% относительно v3; - * - все малые круги +10% и строго симметричны по четвертям высоты аватара; - * - неактивные круги полностью прозрачные; - * - активное состояние даёт только внешний ореол невидимой окружности (эффект затмения); - * - подписи якорятся на собственных кругах и остаются в боковых колонках. - */ -.user-profile-screen { - --user-profile-avatar-size: min(36.96vw, 147px); - --user-profile-metric-size: clamp(46px, 13.2vw, 53px); - margin-top: 0; - background: transparent; -} - -.user-profile-screen::before { - display: none; - content: none; -} - -.user-profile-body { - padding-top: clamp(10px, 1.8vh, 16px); -} - -.user-profile-hero { - grid-template-columns: minmax(0, 1fr) var(--user-profile-avatar-size) minmax(0, 1fr); - grid-template-rows: repeat(2, minmax(0, 1fr)); - height: var(--user-profile-avatar-size); - margin-top: 0; - overflow: visible; -} - -/* Боковые метрики имеют ровно размер своего невидимого круга. - Это исключает разные смещения слева/справа из-за ширины текста. */ -.user-profile-hero > .user-profile-metric { - position: relative; - width: var(--user-profile-metric-size); - min-width: var(--user-profile-metric-size); - height: var(--user-profile-metric-size); - padding: 0; - gap: 0; - justify-self: center; - align-self: center; - overflow: visible; -} - -.user-profile-metric.is-top-left, -.user-profile-metric.is-bottom-left { - grid-column: 1; - padding-right: 0; -} - -.user-profile-metric.is-top-right, -.user-profile-metric.is-bottom-right { - grid-column: 3; - padding-left: 0; -} - -.user-profile-metric.is-top-left, -.user-profile-metric.is-top-right { - grid-row: 1; - align-self: center; -} - -.user-profile-metric.is-bottom-left, -.user-profile-metric.is-bottom-right { - grid-row: 2; - align-self: center; - flex-direction: column; -} - -.user-profile-metric-circle { - position: relative; - z-index: 1; - width: var(--user-profile-metric-size); - height: var(--user-profile-metric-size); - min-width: var(--user-profile-metric-size); - min-height: var(--user-profile-metric-size); - border: 0 !important; - background: transparent !important; + background: rgba(255,255,255,0.035); box-shadow: none; - color: rgba(238, 246, 255, 0.94); - font-size: clamp(16px, 4.5vw, 20px); -} - -/* Свет идёт от геометрии невидимой окружности, а не от заливки/рамки. */ -.user-profile-metric.is-glowing .user-profile-metric-circle { - border: 0 !important; - background: transparent !important; - color: var(--app-topbar-gold); - box-shadow: - 0 0 7px 1px var(--app-topbar-blue-glow), - 0 0 20px 4px var(--app-topbar-blue-glow-soft), - 0 0 34px 8px rgba(72, 145, 255, 0.12); - text-shadow: none; -} - -/* Подпись всегда привязана к центру собственного круга и не влияет на его позицию. */ -.user-profile-hero > .user-profile-metric .user-profile-metric-label { - position: absolute; - top: calc(100% + 5px); - left: 50%; - width: clamp(76px, 22vw, 104px); - min-height: 0; - transform: translateX(-50%); - padding: 0; - text-align: center !important; - align-self: auto !important; - line-height: 1.05; - pointer-events: none; -} - -/* Чуть сильнее уводим подписи от аватара к внешним краям, - оставаясь связанными с центром соответствующего круга. */ -.user-profile-hero > .user-profile-metric.is-top-left .user-profile-metric-label, -.user-profile-hero > .user-profile-metric.is-bottom-left .user-profile-metric-label { - left: calc(50% - clamp(3px, 1.1vw, 5px)); -} - -.user-profile-hero > .user-profile-metric.is-top-right .user-profile-metric-label, -.user-profile-hero > .user-profile-metric.is-bottom-right .user-profile-metric-label { - left: calc(50% + clamp(3px, 1.1vw, 5px)); -} - -/* Нижние счётчики используют ту же прозрачную окружность и размер. */ -.user-profile-channel-metrics { - margin-top: clamp(28px, 4.6vh, 36px); -} - -.user-profile-channel-metrics .user-profile-metric { - gap: 5px; -} - -.user-profile-channel-metrics .user-profile-metric-label { - position: static; - width: 100%; transform: none; - text-align: center; } -@media (max-height: 700px) { - .user-profile-body { - padding-top: 8px; - } - - .user-profile-channel-metrics { - margin-top: 25px; - } +.chat-header-peer-btn .chat-header-avatar-slot { + flex: 0 0 40px; } -@media (max-width: 350px) { - .user-profile-screen { - --user-profile-avatar-size: min(36.96vw, 147px); - --user-profile-metric-size: 46px; - } - - .user-profile-hero > .user-profile-metric .user-profile-metric-label { - width: 72px; - font-size: 9px; - } +.chat-header-peer-text { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; } -/* ===== Channel list preview/time flex sizing fix (2026-09-02) ===== - * The global .channel-row-time rule still has width:100%, which makes the - * timestamp consume the entire flex row and collapses the message preview. - * Keep the timestamp content-sized and reserve a little more room at the - * right edge so the final digit is never clipped by the widened list tile. */ -.channels-screen--list .channel-row { - padding-right: 14px !important; +.chat-header-peer-name, +.chat-header-peer-meta { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.channels-screen--list .channel-row-preview-line .channel-row-time { - width: auto !important; - max-width: none !important; +.chat-header-peer-name { + font-size: 14px; + line-height: 1.15; + font-weight: 650; + color: rgba(255,255,255,0.94); +} + +.chat-header-peer-meta { + font-size: 11px; + line-height: 1.15; + font-weight: 500; + color: rgba(189,207,232,0.72); }