From 2d059e9ff530fd2901a82a4e210c7d9a7744fabfbc1b49a110eb50b4e3c57811 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 9 Sep 2026 17:01:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20?= =?UTF-8?q?=D0=BB=D0=B0=D0=B9=D0=BA=D0=BE=D0=B2=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../channels/Net_GetMessageLikes_Handler.java | 112 +++++++++++ .../entyties/Net_GetMessageLikes_Request.java | 29 +++ .../Net_GetMessageLikes_Response.java | 40 ++++ VERSION.properties | 4 +- docs/API/06_Channels_Read_API.md | 74 +++++++- docs/API/07_Channels_Feature_Runbook.md | 26 ++- docs/API/09_Operations_Index.md | 1 + shine-UI/js/pages/channel-view.js | 174 +++++++++++++++++- shine-UI/js/services/auth-service.js | 11 ++ shine-UI/styles/features/channel.css | 140 ++++++++++++++ 11 files changed, 595 insertions(+), 20 deletions(-) create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 920797fc..3462dfc9 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -75,6 +75,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler; @@ -85,6 +86,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsC import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; @@ -210,6 +212,7 @@ public final class JsonHandlerRegistry { Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), + Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()), Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()), Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()), Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()), @@ -302,6 +305,7 @@ public final class JsonHandlerRegistry { Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class), Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class), Map.entry("GetMessageThread", Net_GetMessageThread_Request.class), + Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class), Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class), Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class), Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java new file mode 100644 index 00000000..beec7175 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java @@ -0,0 +1,112 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +public class Net_GetMessageLikes_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class); + private static final int HARD_LIMIT = 1000; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest; + Net_GetMessageLikes_Request.MessageSelector message = req.getMessage(); + if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank() + || message.getBlockNumber() == null || message.getBlockNumber() < 0 + || message.getBlockHash() == null || message.getBlockHash().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message"); + } + + final byte[] blockHash; + try { + blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash()); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash"); + } + if (blockHash == null || blockHash.length == 0) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash"); + } + + int requested = req.getLimit() == null ? HARD_LIMIT : Math.max(1, Math.min(HARD_LIMIT, req.getLimit())); + try (Connection c = DbController.getInstance().getConnection()) { + String sql = """ + SELECT rs.from_login, + COALESCE(ups.first_name, '') AS first_name, + COALESCE(ups.last_name, '') AS last_name, + COALESCE(ups.ava_ar, '') AS avatar_ar, + COALESCE(ups.account_role, 'unknown') AS account_role, + COALESCE(ups.shine_status, 'unknown') AS shine_status + FROM reactions_state rs + LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login) + WHERE rs.reaction_type = 1 + AND rs.last_sub_type = 1 + AND rs.to_bch_name = ? + AND rs.to_block_number = ? + AND rs.to_block_hash = ? + ORDER BY LOWER(rs.from_login) + LIMIT ? + """; + + List shining = new ArrayList<>(); + List official = new ArrayList<>(); + List others = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, message.getBlockchainName().trim()); + ps.setInt(2, message.getBlockNumber()); + ps.setBytes(3, blockHash); + ps.setInt(4, requested + 1); + try (ResultSet rs = ps.executeQuery()) { + int accepted = 0; + boolean truncated = false; + while (rs.next()) { + if (accepted >= requested) { + truncated = true; + break; + } + accepted++; + Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem(); + user.setLogin(rs.getString("from_login")); + user.setFirstName(rs.getString("first_name")); + user.setLastName(rs.getString("last_name")); + user.setAvatarAr(rs.getString("avatar_ar")); + + boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role")); + boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status")); + if (isOfficial && isShining) shining.add(user); + else if (isOfficial) official.add(user); + else others.add(user); + } + + Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setShining(shining); + resp.setOfficial(official); + resp.setOthers(others); + resp.setTotal(shining.size() + official.size() + others.size()); + resp.setTruncated(truncated); + return resp; + } + } + } catch (Exception e) { + log.error("GetMessageLikes failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java new file mode 100644 index 00000000..7fdd1c61 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java @@ -0,0 +1,29 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetMessageLikes_Request extends Net_Request { + private MessageSelector message; + private Integer limit; + + public MessageSelector getMessage() { return message; } + public void setMessage(MessageSelector message) { this.message = message; } + + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } + + public static class MessageSelector { + private String blockchainName; + private Integer blockNumber; + private String blockHash; + + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + + public Integer getBlockNumber() { return blockNumber; } + public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; } + + public String getBlockHash() { return blockHash; } + public void setBlockHash(String blockHash) { this.blockHash = blockHash; } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java new file mode 100644 index 00000000..9f74bc9d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java @@ -0,0 +1,40 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import java.util.ArrayList; +import java.util.List; + +public class Net_GetMessageLikes_Response extends Net_Response { + private List shining = new ArrayList<>(); + private List official = new ArrayList<>(); + private List others = new ArrayList<>(); + private int total; + private boolean truncated; + + public List getShining() { return shining; } + public void setShining(List shining) { this.shining = shining; } + public List getOfficial() { return official; } + public void setOfficial(List official) { this.official = official; } + public List getOthers() { return others; } + public void setOthers(List others) { this.others = others; } + public int getTotal() { return total; } + public void setTotal(int total) { this.total = total; } + public boolean isTruncated() { return truncated; } + public void setTruncated(boolean truncated) { this.truncated = truncated; } + + public static class UserItem { + private String login; + private String firstName; + private String lastName; + private String avatarAr; + + 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 String getAvatarAr() { return avatarAr; } + public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; } + } +} diff --git a/VERSION.properties b/VERSION.properties index 306b82aa..d30e91ce 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.4 -server.version=1.10.1 +client.version=1.12.5 +server.version=1.10.2 diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 62b97634..944a3b06 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -14,13 +14,16 @@ 3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения: предки, фокус-сообщение, потомки. -4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя. +4. `GetMessageLikes` — отдает списки пользователей, поставивших лайк сообщению, + сгруппированные для UI по статусу профиля. -5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. +5. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя. -6. `ListGroupChats200` — отдает список групповых чатов типа `200`. +6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. -7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. +7. `ListGroupChats200` — отдает список групповых чатов типа `200`. + +8. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > @@ -266,7 +269,62 @@ --- -## 4) GetPersonalDiary +## 4) GetMessageLikes + +Возвращает пользователей, которые поставили лайк конкретному сообщению канала. + +- `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение. +- `limit` ограничивается сервером сверху значением `1000`. +- Пользователи группируются по состоянию профиля: + - `shining` — `account_role=primary` и `shine_status=shining`; + - `official` — `account_role=primary`, но без `shine_status=shining`; + - `others` — остальные пользователи. + +### Request +```json +{ + "op": "GetMessageLikes", + "requestId": "req-4", + "payload": { + "message": { + "blockchainName": "bob-001", + "blockNumber": 140, + "blockHash": "..." + }, + "limit": 1000 + } +} +``` + +### Response (success) +```json +{ + "op": "GetMessageLikes", + "requestId": "req-4", + "status": 200, + "ok": true, + "payload": { + "shining": [ + { "login": "Alice", "firstName": "Alice", "lastName": "", "avatarAr": "ArweaveTxId..." } + ], + "official": [], + "others": [ + { "login": "Carl", "firstName": "", "lastName": "", "avatarAr": "" } + ], + "total": 2, + "truncated": false + } +} +``` + +### Ошибки +- `bad_fields` — не передан `message` или обязательные поля ссылки на сообщение. +- `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока. +- `internal_error` — внутренняя ошибка чтения. + +--- + +## 5) GetPersonalDiary Возвращает виртуальный канал `Личный дневник` для самого пользователя. @@ -289,7 +347,7 @@ --- -## 4) GetChannelsCounters +## 6) GetChannelsCounters ### Request ```json @@ -321,7 +379,7 @@ --- -## 5) ListGroupChats200 +## 7) ListGroupChats200 ### Request ```json @@ -361,7 +419,7 @@ --- -## 6) GetGroupDialog +## 8) GetGroupDialog ### Request ```json diff --git a/docs/API/07_Channels_Feature_Runbook.md b/docs/API/07_Channels_Feature_Runbook.md index e60af339..9df49d25 100644 --- a/docs/API/07_Channels_Feature_Runbook.md +++ b/docs/API/07_Channels_Feature_Runbook.md @@ -8,6 +8,7 @@ - `ListSubscriptionsFeed` — экран списка каналов. - `GetChannelMessages` — сообщения конкретного канала. - `GetMessageThread` — дерево обсуждения для сообщения. + - `GetMessageLikes` — списки пользователей, поставивших лайк сообщению. 2. **UI вкладки Каналы**: - при открытии пытается загрузить реальный feed с сервера; @@ -33,12 +34,14 @@ 1. Вызвать `ListSubscriptionsFeed`. 2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`. 3. Для первого `messages[0]` вызвать `GetMessageThread`. +4. Для первого `messages[0]` вызвать `GetMessageLikes`. ### Ошибки 1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`. 2. `GetChannelMessages` с битым channel payload -> `bad_fields`. 3. `GetMessageThread` с несуществующим block -> `message_not_found`. -4. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. +4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`. +5. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. --- @@ -91,6 +94,22 @@ } ``` +## 3.4 GetMessageLikes +```json +{ + "op": "GetMessageLikes", + "requestId": "debug-likes-1", + "payload": { + "message": { + "blockchainName": "TestUser1-001", + "blockNumber": 123, + "blockHash": "" + }, + "limit": 1000 + } +} +``` + --- ## 4) Что смотреть в ответах @@ -115,6 +134,11 @@ - у узлов должны быть версии и счетчики. - у каждого узла дополнительно может приходить `rawBlockB64` (Base64 сырого `block_bytes`). +### GetMessageLikes +- `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей. +- у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`. +- `payload.truncated=true` означает, что сервер обрезал список по `limit`. + ### Важно по совместимости - `rawBlockB64` добавлен только в `GetMessageThread`. - `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты). diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index b961bcfc..abaa33da 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -46,6 +46,7 @@ | `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок | | `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала | | `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения | +| `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению | | `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION | | `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов | | `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` | diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 6fe5a6cd..45c479a2 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -1790,6 +1790,169 @@ function renderChannelMetaEventCard(event) { return card; } + +function likeCategoryCounts(post) { + const total = Math.max(0, Number(post?.likesCount || 0)); + const primary = Math.max(0, Math.min(total, Number(post?.primaryLikesCount || 0))); + const shining = Math.max(0, Math.min(primary, Number(post?.shiningLikesCount || 0))); + return { + shining, + official: Math.max(0, primary - shining), + others: Math.max(0, total - primary), + total, + }; +} + +function openMessageLikesListModal({ navigate, messageRef, initialTab = 'shining' }) { + const overlay = document.createElement('div'); + overlay.className = 'channel-likes-modal-overlay'; + overlay.innerHTML = ` + + `; + document.body.append(overlay); + + const modal = overlay.querySelector('.channel-likes-modal'); + const status = overlay.querySelector('.channel-likes-modal__status'); + const list = overlay.querySelector('.channel-likes-user-list'); + const close = () => overlay.remove(); + overlay.querySelector('.channel-likes-modal__close')?.addEventListener('click', close); + overlay.addEventListener('click', (event) => { if (event.target === overlay) close(); }); + modal?.addEventListener('click', (event) => event.stopPropagation()); + + let activeTab = ['shining', 'official', 'others'].includes(initialTab) ? initialTab : 'shining'; + let payload = null; + + const renderTab = () => { + overlay.querySelectorAll('[data-like-tab]').forEach((button) => { + const isActive = button.dataset.likeTab === activeTab; + button.classList.toggle('is-active', isActive); + button.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); + if (!payload) return; + const rows = Array.isArray(payload?.[activeTab]) ? payload[activeTab] : []; + list.innerHTML = ''; + rows.forEach((row) => { + const userButton = document.createElement('button'); + userButton.type = 'button'; + userButton.className = 'ui-button card row profile-list-row channel-like-user-row'; + const avatarRaw = String(row?.avatarAr || '').trim(); + userButton.append(renderUserAvatar({ + login: String(row?.login || '').trim() || 'unknown', + firstName: String(row?.firstName || ''), + lastName: String(row?.lastName || ''), + avatar: avatarRaw ? { ar: avatarRaw } : null, + size: 'md', + })); + const firstName = String(row?.firstName || '').trim(); + const lastName = String(row?.lastName || '').trim(); + const login = String(row?.login || '').trim(); + const name = [firstName, lastName].filter(Boolean).join(' ') || login; + const text = document.createElement('div'); + text.className = 'profile-list-row-text'; + text.innerHTML = `${escapeHtml(name)}${escapeHtml(login)}`; + userButton.append(text); + userButton.addEventListener('click', () => { + close(); + navigate(makeProfileRoute(login)); + }); + list.append(userButton); + }); + status.textContent = rows.length ? (payload?.truncated ? 'Показаны первые 1000 лайков.' : '') : 'В этом списке пока никого нет.'; + }; + + overlay.querySelectorAll('[data-like-tab]').forEach((button) => { + button.addEventListener('click', () => { + activeTab = button.dataset.likeTab; + renderTab(); + }); + }); + renderTab(); + + (async () => { + try { + payload = await authService.getMessageLikes(messageRef, 1000); + if (!overlay.isConnected) return; + ['shining', 'official', 'others'].forEach((key) => { + const countEl = overlay.querySelector(`[data-like-tab-count="${key}"]`); + if (countEl) countEl.textContent = String(Array.isArray(payload?.[key]) ? payload[key].length : 0); + }); + renderTab(); + } catch (error) { + if (!overlay.isConnected) return; + status.className = 'channel-likes-modal__status is-error'; + status.textContent = toUserMessage(error, 'Не удалось загрузить список лайков.'); + list.innerHTML = ''; + } + })(); +} + +function openMessageLikePopup({ anchor, post, navigate, onToggleLike }) { + document.querySelectorAll('.channel-like-popup-layer').forEach((el) => el.remove()); + const counts = likeCategoryCounts(post); + const layer = document.createElement('div'); + layer.className = 'channel-like-popup-layer'; + layer.innerHTML = ` + + `; + document.body.append(layer); + const popup = layer.querySelector('.channel-like-popup'); + const rect = anchor.getBoundingClientRect(); + const width = Math.min(330, Math.max(270, window.innerWidth - 24)); + const left = Math.max(12, Math.min(window.innerWidth - width - 12, rect.left + rect.width / 2 - width / 2)); + const estimatedHeight = 255; + const top = rect.bottom + estimatedHeight < window.innerHeight - 8 + ? rect.bottom + 8 + : Math.max(8, rect.top - estimatedHeight - 8); + popup.style.width = `${width}px`; + popup.style.left = `${left}px`; + popup.style.top = `${top}px`; + + const close = () => layer.remove(); + layer.addEventListener('click', (event) => { if (event.target === layer) close(); }); + popup.addEventListener('click', (event) => event.stopPropagation()); + layer.querySelector('.channel-like-popup__close')?.addEventListener('click', close); + layer.querySelectorAll('[data-like-list]').forEach((button) => { + button.addEventListener('click', () => { + const initialTab = button.dataset.likeList; + close(); + openMessageLikesListModal({ navigate, messageRef: post.messageRef, initialTab }); + }); + }); + layer.querySelector('.channel-like-popup__action')?.addEventListener('click', async (event) => { + const button = event.currentTarget; + button.disabled = true; + try { + await onToggleLike(post.messageRef, post.reactionState === 'liked' ? 'unlike' : 'like'); + close(); + } catch (error) { + button.disabled = false; + showToast(toUserMessage(error, 'Не удалось изменить лайк.')); + } + }); +} + function renderPostCard(post, { navigate, selector, @@ -1976,18 +2139,11 @@ function renderPostCard(post, { `; setActionTitle(likeButton, isPending ? 'Лайк...' : 'Лайк'); likeButton.disabled = isPending; - likeButton.addEventListener('click', async (event) => { + likeButton.addEventListener('click', (event) => { event.stopPropagation(); animatePress(event.currentTarget); if (isPending) return; - if (!isLiked) { - const ok = window.confirm('Поставить лайк?'); - if (!ok) return; - } - await longPressFeel(event.currentTarget, 130); - likeButton.disabled = true; - setActionTitle(likeButton, 'Лайк...'); - await onToggleLike(post.messageRef, isLiked ? 'unlike' : 'like', { likeButton }); + openMessageLikePopup({ anchor: event.currentTarget, post, navigate, onToggleLike }); }); const replyButton = document.createElement('button'); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 94cf1757..b8e4879b 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1595,6 +1595,17 @@ export class AuthService { return response.payload || {}; } + async getMessageLikes(message, limit = 1000) { + const normalizedMessage = { + blockchainName: String(message?.blockchainName || '').trim(), + blockNumber: Number(message?.blockNumber), + blockHash: String(message?.blockHash || '').trim(), + }; + const response = await this.ws.request('GetMessageLikes', { message: normalizedMessage, limit }); + if (response.status !== 200) throw opError('GetMessageLikes', response); + return response.payload || {}; + } + async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') { const normalizedMessage = { blockchainName: String(message?.blockchainName || '').trim(), diff --git a/shine-UI/styles/features/channel.css b/shine-UI/styles/features/channel.css index 3cb2163c..bb9a72cc 100644 --- a/shine-UI/styles/features/channel.css +++ b/shine-UI/styles/features/channel.css @@ -683,3 +683,143 @@ color: #ffb7c5; background: rgba(var(--shine-action-blue-rgb), 0.12); } + +/* Channel message likes: compact popup + full user list modal. */ +.channel-like-popup-layer, +.channel-likes-modal-overlay { + position: fixed; + inset: 0; + z-index: 1600; + background: rgba(5, 9, 18, 0.26); +} + +.channel-like-popup { + position: fixed; + z-index: 1601; + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 18px; + background: rgba(20, 27, 44, 0.96); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.34); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); +} + +.channel-like-popup__title { + margin-bottom: 10px; + font-weight: 700; + font-size: 15px; +} + +.channel-like-popup__counts { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.channel-like-count { + min-width: 0; + padding: 10px 6px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.05); + color: inherit; + display: flex; + flex-direction: column; + gap: 3px; + align-items: center; +} + +.channel-like-count b { font-size: 18px; } +.channel-like-count span { font-size: 10px; opacity: .72; overflow-wrap: anywhere; } + +.channel-like-popup__total { + padding: 10px 2px 8px; + font-size: 12px; + opacity: .75; +} + +.channel-like-popup__action, +.channel-like-popup__close { + width: 100%; + min-height: 40px; + margin-top: 7px; + border-radius: 12px; +} + +.channel-like-popup__close { opacity: .72; } + +.channel-likes-modal-overlay { + display: flex; + align-items: center; + justify-content: center; + padding: 14px; + background: rgba(5, 9, 18, 0.58); +} + +.channel-likes-modal { + width: min(560px, 100%); + max-height: min(720px, calc(100vh - 28px)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 20px; + background: rgba(18, 25, 42, 0.98); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.channel-likes-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px 8px; +} + +.channel-likes-modal__header h3 { margin: 0; } +.channel-likes-modal__close { font-size: 24px; min-width: 38px; min-height: 38px; } + +.channel-likes-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + padding: 8px 12px 10px; +} + +.channel-likes-tabs > button { + min-width: 0; + min-height: 40px; + padding-inline: 8px; + border-radius: 12px; + font-size: 11px; +} + +.channel-likes-tabs > button.is-active { + background: rgba(255, 255, 255, 0.12); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12); +} + +.channel-likes-tabs span { margin-left: 3px; opacity: .68; } + +.channel-likes-modal__status { + min-height: 20px; + padding: 0 16px 8px; + font-size: 12px; + opacity: .7; +} + +.channel-likes-modal__status.is-error { opacity: 1; } + +.channel-likes-user-list { + overflow-y: auto; + padding: 0 10px 14px; +} + +.channel-like-user-row { + width: 100%; + margin: 4px 0; + padding: 10px; +}