SHA256
Update DM and profile handling
This commit is contained in:
Binary file not shown.
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RelationCard> 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<ChannelCard> out=new ArrayList<>();
|
||||
|
||||
+11
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+12
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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/<login> 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<Net_GetDirectMessages_Response.MessageItem> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+38
@@ -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<MessageItem> 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<MessageItem> getMessages() { return messages; }
|
||||
public void setMessages(List<MessageItem> 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;
|
||||
|
||||
@@ -920,7 +920,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter" title="Все / основные / сияющие">${likes} · ${primaryLikes} · ${shiningLikes}</span>
|
||||
<span class="channel-action-counter" title="Все / основные / сияющие">${likes}/${primaryLikes}/${shiningLikes}</span>
|
||||
`;
|
||||
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 = `
|
||||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
||||
<span class="channel-action-label">Оценка</span>
|
||||
<span class="channel-action-counter">${ratings}</span>
|
||||
`;
|
||||
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';
|
||||
|
||||
@@ -1963,7 +1963,7 @@ function renderPostCard(post, {
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0} · ${post.primaryLikesCount || 0} · ${post.shiningLikesCount || 0}</span>
|
||||
<span class="channel-action-counter" title="Все / основные / сияющие">${post.likesCount || 0}/${post.primaryLikesCount || 0}/${post.shiningLikesCount || 0}</span>
|
||||
`;
|
||||
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 = `
|
||||
<span class="channel-action-icon" aria-hidden="true">★</span>
|
||||
<span class="channel-action-label">Оценка</span>
|
||||
<span class="channel-action-counter">${post.ratingsCount || 0}</span>
|
||||
`;
|
||||
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';
|
||||
|
||||
+118
-115
@@ -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);
|
||||
|
||||
@@ -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 = `
|
||||
<path d="M9 6l6 6-6 6"></path>
|
||||
</svg>
|
||||
`;
|
||||
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),
|
||||
|
||||
+48
-689
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user