Compare commits

...
16 Commits
Author SHA256 Message Date
tram 0c5089fa79 Исправить профиль и список каналов 2026-09-02 16:32:22 +03:00
tram e5556f3706 Вернуть профиль пользователя 2026-09-02 15:31:04 +03:00
tram 09c7f8541e Вернуть профильные элементы 2026-09-02 15:02:48 +03:00
tram 8eb2ef438c Восстановить профиль пользователя 2026-09-02 14:53:11 +03:00
tram 34abe14151 Доработать профиль пользователя 2026-09-02 14:42:58 +03:00
AidarKC 90e37c198f Adjust DM contact card spacing 2026-09-02 14:58:17 +04:00
AidarKC 1373283947 Refine direct message profile data 2026-09-02 14:56:33 +04:00
AidarKC 065616a18b Update DM and profile handling 2026-09-02 14:24:44 +04:00
AidarKC 53cd55a2a0 Fix DM name fallbacks and chat header 2026-09-02 14:23:42 +04:00
AidarKC 079be37fff Починить ListUserProfileChannels для PostgreSQL 2026-09-02 13:09:05 +04:00
AidarKC 806a8c57d4 Merge remote-tracking branch 'origin/main' 2026-09-02 12:52:54 +04:00
tram 797e769cc3 Merge branch 'ui-artem'
# Conflicts:
#	shine-UI/styles/components.css
2026-09-02 11:45:49 +03:00
tram 822a72e246 Доработать профиль пользователя 2026-09-02 11:43:47 +03:00
AidarKC 58b564b385 Промежуточная версия: UI еще доделать и мелкие ошибки 2026-09-01 19:07:59 +04:00
AidarKC 7aa9e2f6a4 Merge branch 'Доделать-связи-и-профиль' 2026-09-01 19:07:41 +04:00
tram 8801b93973 Обновить UI сети и серверные экраны 2026-09-01 14:04:12 +03:00
16 changed files with 1674 additions and 210 deletions
Binary file not shown.
@@ -364,6 +364,8 @@ public final class DmDialogStateDAO {
String nextRelation = current.relationFlag(); String nextRelation = current.relationFlag();
if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) { if ("close_friend".equalsIgnoreCase(relationFlag) || "close_friend".equalsIgnoreCase(nextRelation)) {
nextRelation = "close_friend"; nextRelation = "close_friend";
} else if ("friend".equalsIgnoreCase(relationFlag) || "friend".equalsIgnoreCase(nextRelation)) {
nextRelation = "friend";
} else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) { } else if ("contact".equalsIgnoreCase(relationFlag) || "contact".equalsIgnoreCase(nextRelation)) {
nextRelation = "contact"; nextRelation = "contact";
} else { } else {
@@ -440,7 +442,7 @@ public final class DmDialogStateDAO {
private String normalizeRelationFlag(String value) { private String normalizeRelationFlag(String value) {
String clean = normalize(value).toLowerCase(Locale.ROOT); 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"; return "none";
} }
@@ -7,6 +7,8 @@ import java.sql.SQLException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import shine.db.MsgSubType;
/** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */ /** Fast denormalized projection for user cards shown in dialogs/relations/profile lists. */
public final class UserProfileStateDAO { public final class UserProfileStateDAO {
private static volatile UserProfileStateDAO instance; private static volatile UserProfileStateDAO instance;
@@ -31,6 +33,29 @@ public final class UserProfileStateDAO {
} }
} }
/**
* Effective outgoing social relation with priority close_friend > friend > contact > none.
*
* Use the canonical relation lookup instead of comparing only connections_state.to_login:
* older/current blocks may address a user by blockchain name, so a direct to_login-only
* query can incorrectly report "none" even when ListContacts/Connections sees the relation.
*/
public String getEffectiveRelationType(Connection c, String ownerLogin, String targetLogin) throws SQLException {
if (ownerLogin == null || ownerLogin.isBlank() || targetLogin == null || targetLogin.isBlank()) return "none";
ConnectionsStateDAO relations = ConnectionsStateDAO.getInstance();
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CLOSE_FRIEND)) {
return "close_friend";
}
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_FRIEND)) {
return "friend";
}
if (relations.hasOutgoingByRelTypeCanonical(c, ownerLogin, targetLogin, MsgSubType.CONNECTION_CONTACT)) {
return "contact";
}
return "none";
}
public List<RelationCard> listRelations(Connection c, String ownerLogin, String listType, int limit, int offset) throws SQLException { 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 safeLimit = Math.max(1, Math.min(limit <= 0 ? 100 : limit, 500));
int safeOffset = Math.max(0, offset); int safeOffset = Math.max(0, offset);
@@ -94,14 +119,14 @@ public final class UserProfileStateDAO {
sql=""" sql="""
SELECT 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 SELECT 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 channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1 FROM channel_names_state cn WHERE LOWER(cn.owner_login)=LOWER(?) AND cn.channel_type_code=1
ORDER BY LOWER(cn.display_name), cn.slug LIMIT ? OFFSET ? ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
"""; """;
} else if ("following".equals(mode)) { } else if ("following".equals(mode)) {
sql=""" sql="""
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 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 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 WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=30 AND cn.channel_type_code=1
ORDER BY LOWER(cn.display_name), cn.slug LIMIT ? OFFSET ? ORDER BY cn.display_name, cn.slug LIMIT ? OFFSET ?
"""; """;
} else throw new IllegalArgumentException("Unsupported channel mode: "+mode); } else throw new IllegalArgumentException("Unsupported channel mode: "+mode);
List<ChannelCard> out=new ArrayList<>(); List<ChannelCard> out=new ArrayList<>();
@@ -46,6 +46,7 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
item.setFirstName(card.firstName()); item.setFirstName(card.firstName());
item.setLastName(card.lastName()); item.setLastName(card.lastName());
item.setAvatarAr(card.avatarAr()); item.setAvatarAr(card.avatarAr());
item.setAvatar(parseAvatar(card.avatarAr()));
item.setAccountRole(card.accountRole()); item.setAccountRole(card.accountRole());
item.setShineStatus(card.shineStatus()); item.setShineStatus(card.shineStatus());
item.setLastMessageBlobB64(dialog.lastMessageBlobB64()); item.setLastMessageBlobB64(dialog.lastMessageBlobB64());
@@ -56,4 +57,14 @@ public class Net_ListContacts_Handler implements JsonMessageHandler {
} }
return items; 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;
}
} }
@@ -20,6 +20,7 @@ public class Net_ListContacts_Response extends Net_Response {
private String firstName; private String firstName;
private String lastName; private String lastName;
private String avatarAr; private String avatarAr;
private Avatar avatar;
private String accountRole; private String accountRole;
private String shineStatus; private String shineStatus;
private String lastMessageBlobB64; private String lastMessageBlobB64;
@@ -37,6 +38,8 @@ public class Net_ListContacts_Response extends Net_Response {
public void setLastName(String lastName) { this.lastName = lastName; } public void setLastName(String lastName) { this.lastName = lastName; }
public String getAvatarAr() { return avatarAr; } public String getAvatarAr() { return avatarAr; }
public void setAvatarAr(String avatarAr) { this.avatarAr = 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 String getAccountRole() { return accountRole; }
public void setAccountRole(String accountRole) { this.accountRole = accountRole; } public void setAccountRole(String accountRole) { this.accountRole = accountRole; }
public String getShineStatus() { return shineStatus; } public String getShineStatus() { return shineStatus; }
@@ -50,4 +53,13 @@ public class Net_ListContacts_Response extends Net_Response {
public boolean isHasDialog() { return hasDialog; } public boolean isHasDialog() { return hasDialog; }
public void setHasDialog(boolean hasDialog) { this.hasDialog = 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; }
}
} }
@@ -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.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes; import server.logic.ws_protocol.WireCodes;
import shine.db.dao.SignedMessagesDAO; 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.dao.DmDeliveryStateDAO;
import shine.db.entities.DmDeliveryStateEntry; import shine.db.entities.DmDeliveryStateEntry;
import shine.db.entities.SignedMessageEntry; import shine.db.entities.SignedMessageEntry;
@@ -74,6 +78,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
resp.setLimit(limit); resp.setLimit(limit);
resp.setHasMore(hasMore); 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<>(); List<Net_GetDirectMessages_Response.MessageItem> items = new ArrayList<>();
for (SignedMessageEntry entry : page) { for (SignedMessageEntry entry : page) {
Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem(); Net_GetDirectMessages_Response.MessageItem item = new Net_GetDirectMessages_Response.MessageItem();
@@ -108,4 +127,21 @@ public class Net_GetDirectMessages_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "INTERNAL_ERROR", "Внутренняя ошибка сервера"); 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();
// Do not call Matcher.find() twice on the same matcher: the first successful
// call advances it and the second one can make a perfectly valid avatar vanish.
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,6 +12,7 @@ public class Net_GetDirectMessages_Response extends Net_Response {
private boolean hasMore; private boolean hasMore;
private Long nextBeforeTimeMs; private Long nextBeforeTimeMs;
private String nextBeforeMessageKey; private String nextBeforeMessageKey;
private PeerCard peer;
private List<MessageItem> messages = new ArrayList<>(); private List<MessageItem> messages = new ArrayList<>();
public String getLogin() { return login; } 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 void setNextBeforeTimeMs(Long nextBeforeTimeMs) { this.nextBeforeTimeMs = nextBeforeTimeMs; }
public String getNextBeforeMessageKey() { return nextBeforeMessageKey; } public String getNextBeforeMessageKey() { return nextBeforeMessageKey; }
public void setNextBeforeMessageKey(String nextBeforeMessageKey) { this.nextBeforeMessageKey = 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 List<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> messages) { this.messages = 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 { public static class MessageItem {
private String messageKey; private String messageKey;
private String baseKey; private String baseKey;
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.11.2 client.version=1.12.0
server.version=1.9.0 server.version=1.10.0
+4 -20
View File
@@ -920,7 +920,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
likeButton.innerHTML = ` likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span> <span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</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 ? 'Лайк...' : 'Лайк'); setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending; likeButton.disabled = isPending;
@@ -964,24 +964,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
onSubmit: async (textValue) => handlers.onReply(target, textValue), onSubmit: async (textValue) => handlers.onReply(target, textValue),
}); });
}); });
const ratingButton = document.createElement('button'); // Rating/opinion action is intentionally hidden from UI for now.
ratingButton.type = 'button'; // Backend/subtype/counters remain supported so the feature can be restored later.
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),
});
});
const shareButton = document.createElement('button'); const shareButton = document.createElement('button');
shareButton.type = 'button'; shareButton.type = 'button';
@@ -999,7 +983,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
// Репосты временно отключены до будущей реализации. // Репосты временно отключены до будущей реализации.
// Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md // Точка возврата: docs/Future_Features/2026-05-24_1140_репосты_в_каналах_и_тредах.md
actions.append(likeButton, replyButton, ratingButton, shareButton); actions.append(likeButton, replyButton, shareButton);
if (repostTarget) { if (repostTarget) {
const originalButton = document.createElement('button'); const originalButton = document.createElement('button');
originalButton.type = 'button'; originalButton.type = 'button';
+5 -22
View File
@@ -1963,7 +1963,7 @@ function renderPostCard(post, {
likeButton.innerHTML = ` likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span> <span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</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 ? 'Лайк...' : 'Лайк'); setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк');
likeButton.disabled = isPending; likeButton.disabled = isPending;
@@ -1998,27 +1998,10 @@ function renderPostCard(post, {
onSubmit: async (text) => onReply(post.messageRef, text), onSubmit: async (text) => onReply(post.messageRef, text),
}); });
}); });
const ratingButton = document.createElement('button'); // Rating/opinion action is intentionally hidden from UI for now.
ratingButton.type = 'button'; // Backend/subtype/counters remain supported so the feature can be restored later.
ratingButton.className = 'channel-action-item channel-action-rating';
ratingButton.innerHTML = ` actions.append(likeButton, replyButton);
<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);
const shareButton = document.createElement('button'); const shareButton = document.createElement('button');
shareButton.type = 'button'; shareButton.type = 'button';
+15 -2
View File
@@ -52,6 +52,18 @@ function cleanChannelMessagePreview(text) {
|| (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний'); || (parsed.attachments.length ? 'Вложение' : 'Ждем ваших начинаний');
} }
// Keep the channel-list preview tolerant to small API naming changes. The current
// server uses lastMessage.text/createdAtMs; legacy/alternate payloads are accepted
// so the third line (last message + time) does not silently disappear.
function resolveChannelLastMessage(summary) {
const row = summary?.lastMessage || summary?.latestMessage || summary?.last_message || null;
if (!row || typeof row !== 'object') return { text: '', createdAtMs: 0 };
return {
text: String(row.text ?? row.messageText ?? row.preview ?? row.body ?? '').trim(),
createdAtMs: Number(row.createdAtMs ?? row.timeMs ?? row.created_at_ms ?? 0),
};
}
function isChannelsDemoMode() { function isChannelsDemoMode() {
try { try {
const qs = new URLSearchParams(window.location.search); const qs = new URLSearchParams(window.location.search);
@@ -719,6 +731,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
const isOwn = bucketKey === 'own'; const isOwn = bucketKey === 'own';
const title = displayTitle || channelName; const title = displayTitle || channelName;
const technicalLabel = `${ownerLogin} / ${channelName}`; const technicalLabel = `${ownerLogin} / ${channelName}`;
const lastMessage = resolveChannelLastMessage(summary);
return { return {
id: rowId, id: rowId,
@@ -737,10 +750,10 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
channelDescription, channelDescription,
channelTypeCode, channelTypeCode,
channelTypeVersion, channelTypeVersion,
messagePreview: cleanChannelMessagePreview(summary?.lastMessage?.text), messagePreview: cleanChannelMessagePreview(lastMessage.text),
messagesCount: Number(summary?.messagesCount || 0), messagesCount: Number(summary?.messagesCount || 0),
unreadCount: Number(summary?.unreadCount || 0), unreadCount: Number(summary?.unreadCount || 0),
lastMessageAt: Number(summary?.lastMessage?.createdAtMs || 0), lastMessageAt: Number(lastMessage.createdAtMs || 0),
isOwnChannel: isOwn, isOwnChannel: isOwn,
isSubscribed: !isOwn, isSubscribed: !isOwn,
notificationsEnabled: notificationsState[rowId] === true, notificationsEnabled: notificationsState[rowId] === true,
+117 -97
View File
@@ -30,7 +30,6 @@ import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speec
import { showToast } from '../services/channels-ux.js'; import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { userDisplayName } from '../services/user-display.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' }; 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"> <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"> <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" /> <img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
<span>Связи</span> <span>Показать связи</span>
</button> </button>
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile"> <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" /> <img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
<span>Профиль</span> <span>Показать профиль</span>
</button> </button>
</div> </div>
</div> </div>
@@ -102,70 +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) { function createChatHeaderParts(login, navigate) {
const cleanLogin = String(login || '').trim() || 'unknown'; 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'); const avatarSlot = document.createElement('span');
avatarSlot.className = 'chat-header-avatar-slot chat-header-avatar-slot--left'; avatarSlot.className = 'chat-header-avatar-slot';
const initialAvatar = renderUserAvatar({ const textWrap = document.createElement('span');
login: cleanLogin, textWrap.className = 'chat-header-peer-text';
size: 'md', const nameEl = document.createElement('span');
className: 'chat-header-avatar', nameEl.className = 'chat-header-peer-name';
title: cleanLogin, const metaEl = document.createElement('span');
}); metaEl.className = 'chat-header-peer-meta';
avatarSlot.append(initialAvatar); textWrap.append(nameEl, metaEl);
identityButton.append(avatarSlot, textWrap);
const avatarButton = document.createElement('button'); const renderPeer = () => {
avatarButton.type = 'button'; const firstName = String(currentPeer?.firstName || '').trim();
avatarButton.className = 'chat-header-avatar-btn'; const lastName = String(currentPeer?.lastName || '').trim();
avatarButton.title = `Меню ${cleanLogin}`; const fullName = [firstName, lastName].filter(Boolean).join(' ');
avatarButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`); nameEl.textContent = fullName || cleanLogin;
avatarButton.append(avatarSlot); 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(),
}
: null,
size: 'md',
className: 'chat-header-avatar',
title: cleanLogin,
});
avatarSlot.replaceChildren(avatar);
};
const loginEl = document.createElement('button'); const updatePeer = (peer) => {
loginEl.type = 'button'; if (!peer || typeof peer !== 'object') return;
loginEl.className = 'chat-header-login chat-header-login-btn'; currentPeer = {
loginEl.setAttribute('role', 'heading'); ...currentPeer,
loginEl.setAttribute('aria-level', '1'); ...peer,
loginEl.setAttribute('aria-label', `Чат с ${cleanLogin}`); login: String(peer.login || cleanLogin).trim() || cleanLogin,
loginEl.innerHTML = `<span class="chat-header-display-name">${cleanLogin}</span><span class="chat-header-user-login">${cleanLogin}</span>`; relationType: normalizeChatRelationType(peer.relationType),
};
renderPeer();
};
void loadProfileSnapshot(cleanLogin) renderPeer();
.then((snapshot) => { identityButton.addEventListener('click', (event) => {
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;
}
})
.catch(() => {});
const openMenu = (event) => {
if (!cleanLogin || cleanLogin === 'unknown') return; if (!cleanLogin || cleanLogin === 'unknown') return;
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate }); 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) { function truncatePreviewText(value, maxLen = 72) {
@@ -381,8 +403,10 @@ function openMessageActionsMenu({
function openChatActionsMenu({ function openChatActionsMenu({
anchorX = 0, anchorX = 0,
anchorY = 0, anchorY = 0,
showAddContact = false,
onCall, onCall,
onVideoCall, onVideoCall,
onAddContact,
onClearHistory, onClearHistory,
onDeleteChat, onDeleteChat,
}) { }) {
@@ -395,6 +419,7 @@ function openChatActionsMenu({
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}"> <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-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> <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" 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> <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> </div>
@@ -446,6 +471,10 @@ function openChatActionsMenu({
close(); close();
if (typeof onVideoCall === 'function') await onVideoCall(); 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 () => { root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close(); close();
if (typeof onClearHistory === 'function') await onClearHistory(); if (typeof onClearHistory === 'function') await onClearHistory();
@@ -968,6 +997,7 @@ export function render({ navigate, route, chrome }) {
screen.className = 'stack dm-screen dm-chat-screen'; screen.className = 'stack dm-screen dm-chat-screen';
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings); const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); 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 hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000; const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
let historyHasMore = true; let historyHasMore = true;
@@ -1062,6 +1092,24 @@ export function render({ navigate, route, chrome }) {
notifyUnreadStateUpdated(); 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'); const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap'; wrap.className = 'chat-wrap dm-chat-wrap';
const historyLoader = document.createElement('div'); const historyLoader = document.createElement('div');
@@ -1098,8 +1146,16 @@ export function render({ navigate, route, chrome }) {
openChatActionsMenu({ openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0), anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0), anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
showAddContact: normalizeChatRelationType(peerRelationType) === 'none',
onCall: () => handleStartCall('audio'), onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'), onVideoCall: () => handleStartCall('video'),
onAddContact: async () => {
try {
await addPeerToContacts();
} catch (error) {
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
onClearHistory: async () => { onClearHistory: async () => {
openChatConfirmModal({ openChatConfirmModal({
title: 'Очистить историю?', title: 'Очистить историю?',
@@ -1147,48 +1203,8 @@ export function render({ navigate, route, chrome }) {
}, },
], ],
}); });
const chatHeaderLeft = chatHeader.querySelector('.header-left');
chatHeaderLeft?.append(chatHeaderParts.avatarButton);
chrome?.setTopbar(chatHeader); chrome?.setTopbar(chatHeader);
if (!isKnownContact) {
const card = document.createElement('div');
card.className = 'card';
const btn = document.createElement('button');
btn.className = 'secondary-btn';
btn.type = 'button';
btn.textContent = 'Добавить собеседника в контакты';
btn.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} добавлен в контакты`,
});
card.remove();
} catch (e) {
addAppLogEntry({
level: 'warn',
source: 'contacts',
message: 'Не удалось добавить пользователя в контакты',
details: { login: chatId, error: e?.message || 'unknown' },
});
}
});
card.append(btn);
screen.append(card);
}
const form = document.createElement('form'); const form = document.createElement('form');
form.className = 'chat-input dm-chat-input'; form.className = 'chat-input dm-chat-input';
@@ -1580,6 +1596,10 @@ export function render({ navigate, route, chrome }) {
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0, beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '', beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
}); });
if (payload?.peer) {
peerRelationType = normalizeChatRelationType(payload.peer.relationType);
chatHeaderParts.updatePeer(payload.peer);
}
await mergeDirectMessagesPage(chatId, payload?.messages || []); await mergeDirectMessagesPage(chatId, payload?.messages || []);
historyHasMore = Boolean(payload?.hasMore); historyHasMore = Boolean(payload?.hasMore);
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0); historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
+74 -13
View File
@@ -11,9 +11,9 @@ import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js'; import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js'; import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createShineConnectionsLogo } from '../components/shine-logo.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 { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
import { formatRelativeTime } from '../services/channels-ux.js'; import { formatRelativeTime } from '../services/channels-ux.js';
import { userDisplayName } from '../services/user-display.js';
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' }; export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
const PREVIEW_MAX_LEN = 200; const PREVIEW_MAX_LEN = 200;
@@ -22,14 +22,72 @@ const SVG_CHEVRON = `
<path d="M9 6l6 6-6 6"></path> <path d="M9 6l6 6-6 6"></path>
</svg> </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([ const RELATION_ORDER = new Map([
['close_friend', 0], ['close_friend', 0],
['friend', 1], ['friend', 1],
['contact', 2], ['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) { function normalizeRelationFlag(value) {
const clean = String(value || '').trim().toLowerCase(); const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean; if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
@@ -288,13 +346,10 @@ function renderRow(item) {
const relationBadge = relationFlag === 'none' const relationBadge = relationFlag === 'none'
? 'не в контактах' ? 'не в контактах'
: relationLabel(relationFlag); : relationLabel(relationFlag);
const avatarEl = renderUserAvatar({ const avatarEl = createDmAvatar(item.peerLogin, {
login: item.peerLogin, avatar: item.avatar,
firstName: item.firstName, firstName: item.firstName,
lastName: item.lastName, lastName: item.lastName,
avatar: item.avatarAr ? { ar: String(item.avatarAr).trim() } : null,
size: 'lg',
title: `Профиль ${item.peerLogin}`,
}); });
avatarEl.classList.add('avatar'); avatarEl.classList.add('avatar');
const avatarWrap = document.createElement('div'); const avatarWrap = document.createElement('div');
@@ -319,7 +374,10 @@ function renderRow(item) {
const titleEl = row.querySelector('.dm-row-title'); const titleEl = row.querySelector('.dm-row-title');
const previewEl = row.querySelector('.dm-row-last-message'); const previewEl = row.querySelector('.dm-row-last-message');
const timeEl = row.querySelector('.dm-row-time'); const timeEl = row.querySelector('.dm-row-time');
if (titleEl) titleEl.textContent = userDisplayName(item); 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 (previewEl) previewEl.textContent = 'Загрузка…';
if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs); if (timeEl) timeEl.textContent = formatChatRowTime(item.lastMessageTimeMs);
row.prepend(avatarWrap); row.prepend(avatarWrap);
@@ -343,7 +401,10 @@ function renderRow(item) {
try { try {
const payload = await authService.listContacts(); const payload = await authService.listContacts();
const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : []; 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); setContacts(contacts);
list.innerHTML = ''; list.innerHTML = '';
@@ -356,12 +417,12 @@ function renderRow(item) {
const next = { const next = {
id: peerLogin, id: peerLogin,
peerLogin, peerLogin,
relationFlag,
firstName: String(dialog?.firstName || '').trim(), firstName: String(dialog?.firstName || '').trim(),
lastName: String(dialog?.lastName || '').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(), accountRole: String(dialog?.accountRole || '').trim(),
shineStatus: String(dialog?.shineStatus || '').trim(), shineStatus: String(dialog?.shineStatus || '').trim(),
relationFlag,
lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''), lastMessageBlobB64: String(dialog?.lastMessageBlobB64 || ''),
lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0), lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0),
unreadCount: Number(dialog?.unreadCount || 0), unreadCount: Number(dialog?.unreadCount || 0),
+2 -2
View File
@@ -13,8 +13,8 @@ function parseAvatar(raw) {
} }
const TITLES = { const TITLES = {
friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили основной аккаунт', friends: 'Друзья', close_friends: 'Близкие друзья', primary_received: 'Подтвердили аккаунт',
primary_given: 'Подтверждённые аккаунты', shine_received: 'Считают сияющим', shine_given: 'Подтверждённые сияющие', primary_given: 'Подтверждённые аккаунты', shine_received: 'Подтвердили сияние', shine_given: 'Подтверждённые сияющие',
channels_owned: 'Каналы', channels_following: 'Подписки на каналы', channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
}; };
+334 -43
View File
@@ -1,56 +1,347 @@
import { renderHeader } from '../components/header.js'; import { renderHeader } from '../components/header.js';
import { state } from '../state.js'; import { SHINE_CONNECTIONS_LOGO_SRC } from '../components/shine-logo.js';
import { loadUserProfileCard } from '../services/user-connections.js';
import { renderUserAvatar } from '../components/avatar-image.js'; import { renderUserAvatar } from '../components/avatar-image.js';
import { authService, state } from '../state.js';
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
import { makeProfileLinksRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute } from '../services/shine-routes.js';
import { navigateBack } from '../router.js'; import { navigateBack } from '../router.js';
export const pageMeta = { id: 'user', title: 'Профиль пользователя' }; export const pageMeta = { id: 'user', title: 'Профиль' };
function escapeHtml(text){return String(text||'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#39;');} function escapeHtml(text) {
function fullName(card){return [card.firstName,card.lastName].filter(Boolean).join(' ')||card.login;} return String(text || '')
.replaceAll('&', '&amp;')
function openTextModal(title,text){ .replaceAll('<', '&lt;')
const root=document.getElementById('modal-root'); if(!root)return; .replaceAll('>', '&gt;')
root.innerHTML=`<div class="modal" id="profile-text-modal"><div class="modal-card stack"><h3>${escapeHtml(title)}</h3><div style="white-space:pre-wrap;line-height:1.5">${escapeHtml(text||'Не заполнено')}</div><button class="secondary-btn" id="profile-text-close">Закрыть</button></div></div>`; .replaceAll('"', '&quot;')
const close=()=>{root.innerHTML='';}; root.querySelector('#profile-text-close')?.addEventListener('click',close); root.querySelector('#profile-text-modal')?.addEventListener('click',e=>{if(e.target?.id==='profile-text-modal')close();}); .replaceAll("'", '&#39;');
} }
function statusBadges(card){ function effectiveSocial(flags = {}) {
const role=card.accountRole==='primary'?'Основной аккаунт':card.accountRole==='non_voting'?'Голос не учитывать':''; if (flags.outCloseFriend) return 'close_friend';
const shine=card.shineStatus==='shining'?'Сияющий':''; if (flags.outFriend) return 'friend';
return `<div class="row wrap-row">${role?`<span class="badge">${escapeHtml(role)}</span>`:''}${shine?'<span class="badge is-yes-shine">Сияющий</span>':''}${card.shineStatus==='not_interested'?'<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно"></span>':''}</div>`; if (flags.outContact) return 'contact';
return 'none';
} }
function statsHtml(card){ function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
const s=card.stats||{}; const numericValue = Number(value || 0);
const rows=[ return `
['friends','Друзья',s.friendsCount],['close_friends','Близкие друзья',s.closeFriendsCount], <button
['primary_received','Подтвердили основной аккаунт',s.primaryReceivedCount],['primary_given','Подтверждённые аккаунты',s.primaryGivenCount], type="button"
['shine_received','Считают сияющим',s.shineReceivedCount],['shine_given','Подтверждённые сияющие',s.shineGivenCount], class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
['channels_following','Подписки на каналы',s.followingChannelsCount],['channels_owned','Каналы',s.ownedPublicChannelsCount], data-profile-list="${escapeHtml(kind)}"
]; aria-label="${escapeHtml(label)}: ${numericValue}"
return `<div class="profile-stats-grid">${rows.map(([kind,label,n])=>`<button type="button" class="card profile-stat-card" data-profile-list="${kind}"><b>${Number(n||0)}</b><span>${escapeHtml(label)}</span></button>`).join('')}</div>`; >
<span class="user-profile-metric-circle">${numericValue}</span>
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
</button>`;
} }
export function render({navigate,route}){ function spiritualPathDetailHtml(card) {
const requestedLogin=String(route?.params?.login||'').trim(); const selfLogin=String(state.session.login||'').trim(); const value = String(card?.spiritualPath || '').trim();
const screen=document.createElement('section'); screen.className='stack'; const body=document.createElement('div');body.className='stack';const status=document.createElement('div');status.className='status-line';status.textContent='Загрузка профиля...'; return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
screen.append(renderHeader({title:'Профиль пользователя',leftAction:{label:'←',onClick:()=>navigateBack()}}),status,body); }
let card=null;
async function refresh(){ function contactsDetailHtml(card) {
card=await loadUserProfileCard(requestedLogin); const isSelf=card.login.toLowerCase()===selfLogin.toLowerCase(); const rows = [
body.innerHTML=`${statusBadges(card)}<div class="card profile-about" style="white-space:pre-wrap">${escapeHtml(card.about||'')}</div>${statsHtml(card)} ['Ссылки', card?.web],
<div class="row wrap-row"><button class="secondary-btn" data-profile-detail="contacts">Контакты</button><button class="secondary-btn" data-profile-detail="spiritual">Духовный путь</button></div> ['Телефон', card?.phone],
${!isSelf?`<div class="row profile-bottom-actions"><button class="icon-btn" data-bottom="chat" title="Диалог">💬</button><button class="icon-btn" data-bottom="links" title="Связи">✦</button><button class="primary-btn" data-bottom="add">Добавить</button></div>`:''}`; ['Адрес', card?.address],
const identity=document.createElement('div');identity.className='card row';identity.style.gap='12px';identity.style.alignItems='center'; ].filter(([, value]) => String(value || '').trim());
identity.append(renderUserAvatar({login:card.login,firstName:card.firstName,lastName:card.lastName,avatar:card.avatar,size:'xl',className:'profile-avatar'}));
const txt=document.createElement('div');txt.innerHTML=`<div class="profile-identity-line">${escapeHtml(fullName(card))}</div><div class="profile-identity-login">${escapeHtml(card.login)}</div>`;identity.append(txt);body.prepend(identity);status.textContent=''; if (!rows.length) {
} return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
body.addEventListener('click',e=>{const el=e.target.closest('[data-profile-list],[data-profile-detail],[data-bottom]');if(!el||!card)return; }
const list=el.dataset.profileList;if(list){navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(list)}`);return;} return rows.map(([label, value]) => `
const detail=el.dataset.profileDetail;if(detail==='contacts'){openTextModal('Контакты',[card.web?`Links: ${card.web}`:'',card.phone?`Телефон: ${card.phone}`:'',card.address?`Адрес: ${card.address}`:''].filter(Boolean).join('\n')||'Не заполнено');return;} if(detail==='spiritual'){openTextModal('Духовный путь',card.spiritualPath);return;} <div class="user-profile-contact-row">
const bottom=el.dataset.bottom;if(bottom==='chat')navigate(`chat/${encodeURIComponent(card.login)}`);if(bottom==='links')navigate(makeProfileLinksRoute(card.login));if(bottom==='add')navigate(`SHiNE/${encodeURIComponent(card.login)}/manage`); <span>${escapeHtml(label)}</span>
}); <b>${escapeHtml(value)}</b>
refresh().catch(e=>{status.className='status-line is-unavailable';status.textContent=`Ошибка: ${e.message||'unknown'}`;}); return screen; </div>`).join('');
}
function addIconHtml() {
return `
<svg class="user-profile-action-svg" viewBox="0 0 40 40" aria-hidden="true">
<path d="M10.5 20.5 17 27l13-14" />
</svg>`;
}
function relationMenuHtml(flags = {}) {
const current = effectiveSocial(flags);
const rows = [
['friend', 'Друг'],
['close_friend', 'Близкий друг'],
['contact', 'Контакт'],
];
return rows.map(([kind, label]) => `
<button type="button" class="user-profile-add-option${current === kind ? ' is-current' : ''}" data-relation-kind="${kind}">
<span>${escapeHtml(label)}</span>
<span class="user-profile-add-option-check" aria-hidden="true">${current === kind ? '✓' : ''}</span>
</button>`).join('');
}
export function render({ navigate, route, chrome }) {
const requestedLogin = String(route?.params?.login || '').trim();
const selfLogin = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack user-profile-screen';
const header = renderHeader({
title: requestedLogin || 'Профиль',
leftAction: { label: '←', onClick: () => navigateBack() },
});
header.classList.add('user-profile-header');
chrome?.setTopbar(header);
const status = document.createElement('div');
status.className = 'status-line user-profile-status';
status.textContent = 'Загрузка профиля...';
const body = document.createElement('div');
body.className = 'user-profile-body';
screen.append(status, body);
let card = null;
let relationFlags = null;
let relationLoadPromise = null;
let addMenu = null;
let addActionButton = null;
function updateRelationUi() {
if (!addMenu) return;
addMenu.innerHTML = relationMenuHtml(relationFlags || {});
const social = effectiveSocial(relationFlags || {});
addActionButton?.classList.toggle('is-active', social !== 'none');
addActionButton?.setAttribute('aria-label', social === 'none' ? 'Добавить' : 'Изменить связь');
}
async function ensureRelationFlags() {
if (relationFlags) return relationFlags;
if (relationLoadPromise) return relationLoadPromise;
if (!selfLogin || !card?.login) return {};
relationLoadPromise = loadRelationsForPair({ currentLogin: selfLogin, targetLogin: card.login })
.then((flags) => {
relationFlags = flags || {};
updateRelationUi();
return relationFlags;
})
.finally(() => {
relationLoadPromise = null;
});
return relationLoadPromise;
}
async function setRelationKind(kind, enabled) {
await authService.setUserRelation({
login: selfLogin,
toLogin: card.login,
kind,
enabled,
storagePwd: state.session.storagePwdInMemory,
});
}
async function changeSocial(next) {
const flags = await ensureRelationFlags();
const current = effectiveSocial(flags);
if (current === next) return;
if (next === 'contact') {
if (flags.outCloseFriend) await setRelationKind('close_friend', false);
if (flags.outFriend) await setRelationKind('friend', false);
if (!flags.outContact) await setRelationKind('contact', true);
}
if (next === 'friend') {
if (flags.outCloseFriend) await setRelationKind('close_friend', false);
if (!flags.outFriend) await setRelationKind('friend', true);
}
if (next === 'close_friend' && !flags.outCloseFriend) {
await setRelationKind('close_friend', true);
}
relationFlags = await loadRelationsForPair({ currentLogin: selfLogin, targetLogin: card.login });
updateRelationUi();
}
function renderProfile() {
if (!card) return;
const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase();
const stats = card.stats || {};
const official = card.accountRole === 'primary';
const shining = card.shineStatus === 'shining';
const fullName = [card.firstName, card.lastName]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const about = String(card.about || '').trim();
const title = header.querySelector('.page-title');
if (title) title.textContent = card.login;
body.innerHTML = `
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
</div>
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
<div class="user-profile-channel-metrics">
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-channel-metric' })}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-channel-metric' })}
</div>
${!isSelf ? `
<div class="user-profile-actions-wrap">
<div class="user-profile-add-menu" hidden></div>
<div class="user-profile-actions" aria-label="Действия с пользователем">
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">
${addIconHtml()}
</button>
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи">
<img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение">
<img src="/assets/icon_lichnye.png" alt="" aria-hidden="true">
</button>
</div>
</div>` : ''}
<div class="user-profile-detail-links" aria-label="Дополнительная информация о пользователе">
<button type="button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Духовный путь</span>
</button>
<button type="button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Контакты</span>
</button>
</div>
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({
login: card.login,
firstName: card.firstName,
lastName: card.lastName,
avatar: card.avatar,
size: 'xl',
className: 'user-profile-hero-avatar',
glow: shining,
}));
addMenu = body.querySelector('.user-profile-add-menu');
addActionButton = body.querySelector('[data-profile-action="add"]');
updateRelationUi();
status.textContent = '';
if (!isSelf && selfLogin) {
void ensureRelationFlags().catch(() => {});
}
}
body.addEventListener('click', async (event) => {
if (!card) return;
const listButton = event.target.closest('[data-profile-list]');
if (listButton) {
const kind = listButton.dataset.profileList;
if (kind) navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(kind)}`);
return;
}
const detailButton = event.target.closest('[data-profile-detail]');
if (detailButton) {
const detailKind = detailButton.dataset.profileDetail;
const detailPanel = body.querySelector('#user-profile-detail-panel');
if (!detailPanel) return;
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
const active = button === detailButton;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (detailKind === 'spiritual-path') {
detailPanel.innerHTML = spiritualPathDetailHtml(card);
} else if (detailKind === 'contacts') {
detailPanel.innerHTML = contactsDetailHtml(card);
} else {
return;
}
detailPanel.hidden = false;
detailPanel.dataset.activeDetail = detailKind;
return;
}
const relationButton = event.target.closest('[data-relation-kind]');
if (relationButton) {
if (!selfLogin) {
status.className = 'status-line user-profile-status is-unavailable';
status.textContent = 'Для добавления пользователя необходимо войти.';
return;
}
const next = relationButton.dataset.relationKind;
try {
addMenu?.classList.add('is-busy');
await changeSocial(next);
if (addMenu) addMenu.hidden = true;
addActionButton?.setAttribute('aria-expanded', 'false');
status.className = 'status-line user-profile-status';
status.textContent = '';
} catch (error) {
status.className = 'status-line user-profile-status is-unavailable';
status.textContent = `Ошибка: ${error?.message || 'Не удалось изменить связь'}`;
} finally {
addMenu?.classList.remove('is-busy');
}
return;
}
const actionButton = event.target.closest('[data-profile-action]');
const action = actionButton?.dataset.profileAction;
if (action === 'add') {
if (!addMenu) return;
navigate(`SHiNE/${encodeURIComponent(card.login)}/manage`);
return;
}
if (action === 'links') {
navigate(makeProfileLinksRoute(card.login));
return;
}
if (action === 'chat') {
navigate(`chat/${encodeURIComponent(card.login)}`);
}
});
const handleOutsidePointer = (event) => {
if (!addMenu || addMenu.hidden) return;
if (event.target instanceof Node && body.contains(event.target)) {
const insideActions = event.target.closest?.('.user-profile-actions-wrap');
if (insideActions) return;
}
addMenu.hidden = true;
addActionButton?.setAttribute('aria-expanded', 'false');
};
document.addEventListener('pointerdown', handleOutsidePointer);
async function refresh() {
card = await loadUserProfileCard(requestedLogin);
renderProfile();
}
refresh().catch((error) => {
status.className = 'status-line user-profile-status is-unavailable';
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
});
screen.cleanup = () => {
document.removeEventListener('pointerdown', handleOutsidePointer);
};
return screen;
} }
File diff suppressed because it is too large Load Diff