Channels UI + read/unread + unique views + style polish

This commit is contained in:
DrygMira
2026-04-23 13:36:01 +03:00
parent 78d6124f2a
commit c0dfa6c7ab
25 changed files with 2369 additions and 145 deletions
@@ -49,9 +49,11 @@ import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstra
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_ListSubscriptionsFeed_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_MarkChannelMessagesSeen_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
@@ -129,6 +131,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("MarkChannelMessagesSeen", new Net_MarkChannelMessagesSeen_Handler()),
Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
@@ -183,6 +186,7 @@ public final class JsonHandlerRegistry {
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("MarkChannelMessagesSeen", Net_MarkChannelMessagesSeen_Request.class),
Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
@@ -212,6 +212,111 @@ final class ChannelsReadSupport {
}
}
static int countViews(Connection c, String bch, int blockNumber, byte[] blockHash) throws SQLException {
String sql = """
SELECT COUNT(*) AS cnt
FROM message_views_state
WHERE to_bch_name=? AND to_block_number=? AND to_block_hash=?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bch);
ps.setInt(2, blockNumber);
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0;
}
}
}
static boolean isSeenByLogin(Connection c, String login, String toBch, int toBlockNumber, byte[] toBlockHash) throws SQLException {
if (login == null || login.isBlank() || toBch == null || toBch.isBlank() || toBlockHash == null || toBlockHash.length != 32) {
return false;
}
String sql = """
SELECT 1
FROM message_views_state
WHERE viewer_login = ? COLLATE NOCASE
AND to_bch_name = ?
AND to_block_number = ?
AND to_block_hash = ?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setString(2, toBch);
ps.setInt(3, toBlockNumber);
ps.setBytes(4, toBlockHash);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
static int countUnreadPosts(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return 0;
String sql = """
SELECT COUNT(*) AS cnt
FROM blocks b
LEFT JOIN message_views_state v
ON v.viewer_login = ?
AND v.to_bch_name = b.bch_name
AND v.to_block_number = b.block_number
AND v.to_block_hash = b.block_hash
WHERE b.bch_name = ?
AND b.msg_type = ?
AND b.msg_sub_type = ?
AND b.line_code = ?
AND v.viewer_login IS NULL
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBch);
ps.setInt(3, MSG_TYPE_TEXT);
ps.setInt(4, MsgSubType.TEXT_POST);
ps.setInt(5, lineCode);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("cnt") : 0;
}
}
}
static PostBlock firstUnreadPost(Connection c, String viewerLogin, String ownerBch, int lineCode) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() || lineCode < 0) return null;
String sql = """
SELECT b.login,b.bch_name,b.block_number,b.block_hash,b.block_bytes
FROM blocks b
LEFT JOIN message_views_state v
ON v.viewer_login = ?
AND v.to_bch_name = b.bch_name
AND v.to_block_number = b.block_number
AND v.to_block_hash = b.block_hash
WHERE b.bch_name = ?
AND b.msg_type = ?
AND b.msg_sub_type = ?
AND b.line_code = ?
AND v.viewer_login IS NULL
ORDER BY b.block_number ASC
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBch);
ps.setInt(3, MSG_TYPE_TEXT);
ps.setInt(4, MsgSubType.TEXT_POST);
ps.setInt(5, lineCode);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
PostBlock pb = new PostBlock();
pb.login = rs.getString("login");
pb.bchName = rs.getString("bch_name");
pb.blockNumber = rs.getInt("block_number");
pb.blockHash = rs.getBytes("block_hash");
pb.blockBytes = rs.getBytes("block_bytes");
return pb;
}
}
}
static String detectChannelDescription(Connection c, String ownerBch, int rootNumber) throws SQLException {
if (rootNumber == 0) return "";
@@ -108,11 +108,22 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
item.setLikesCount(stats[0]);
item.setRepliesCount(stats[1]);
item.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
item.setViewCount(ChannelsReadSupport.countViews(c, post.bchName, post.blockNumber, post.blockHash));
item.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, post.bchName, post.blockNumber, post.blockHash));
items.add(item);
}
resp.setMessages(items);
int unreadCount = ChannelsReadSupport.countUnreadPosts(c, viewerLogin, ownerBch, lineCode);
resp.setUnreadCount(unreadCount);
ChannelsReadSupport.PostBlock firstUnread = ChannelsReadSupport.firstUnreadPost(c, viewerLogin, ownerBch, lineCode);
if (firstUnread != null) {
Net_GetChannelMessages_Response.BlockRef firstUnreadRef = new Net_GetChannelMessages_Response.BlockRef();
firstUnreadRef.setBlockNumber(firstUnread.blockNumber);
firstUnreadRef.setBlockHash(ChannelsReadSupport.toHex(firstUnread.blockHash));
resp.setFirstUnreadMessageRef(firstUnreadRef);
}
return resp;
} catch (Exception e) {
log.error("GetChannelMessages failed", e);
@@ -178,6 +178,8 @@ public class Net_GetMessageThread_Handler implements JsonMessageHandler {
node.setLikesCount(stats[0]);
node.setRepliesCount(stats[1]);
node.setLikedByMe(ChannelsReadSupport.isLikedByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
node.setViewCount(ChannelsReadSupport.countViews(c, row.bchName, row.blockNumber, row.blockHash));
node.setSeenByMe(ChannelsReadSupport.isSeenByLogin(c, viewerLogin, row.bchName, row.blockNumber, row.blockHash));
if (row.lineCode != null && row.lineCode >= 0) {
Net_GetMessageThread_Response.ChannelInfo ci = new Net_GetMessageThread_Response.ChannelInfo();
@@ -45,9 +45,9 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
List<ChannelKey> followedUsersChannels = loadFollowedChannels(c, canonicalLogin, true);
List<ChannelKey> followedChannels = loadFollowedChannels(c, canonicalLogin, false);
resp.setOwnedChannels(buildSummaries(c, own));
resp.setFollowedUsersChannels(buildSummaries(c, followedUsersChannels));
resp.setFollowedChannels(buildSummaries(c, followedChannels));
resp.setOwnedChannels(buildSummaries(c, canonicalLogin, own));
resp.setFollowedUsersChannels(buildSummaries(c, canonicalLogin, followedUsersChannels));
resp.setFollowedChannels(buildSummaries(c, canonicalLogin, followedChannels));
return resp;
} catch (Exception e) {
@@ -56,7 +56,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
}
}
private List<Net_ListSubscriptionsFeed_Response.ChannelSummary> buildSummaries(Connection c, List<ChannelKey> keys) throws Exception {
private List<Net_ListSubscriptionsFeed_Response.ChannelSummary> buildSummaries(Connection c, String viewerLogin, List<ChannelKey> keys) throws Exception {
List<Net_ListSubscriptionsFeed_Response.ChannelSummary> out = new ArrayList<>();
for (ChannelKey key : keys) {
Net_ListSubscriptionsFeed_Response.ChannelSummary row = new Net_ListSubscriptionsFeed_Response.ChannelSummary();
@@ -74,6 +74,7 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
row.setChannel(channelRef);
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
row.setUnreadCount(ChannelsReadSupport.countUnreadPosts(c, viewerLogin, key.ownerBch, key.rootNumber));
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
if (lastPost != null) {
@@ -0,0 +1,135 @@
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_MarkChannelMessagesSeen_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_MarkChannelMessagesSeen_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.SqliteDbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class Net_MarkChannelMessagesSeen_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_MarkChannelMessagesSeen_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_MarkChannelMessagesSeen_Request req = (Net_MarkChannelMessagesSeen_Request) baseRequest;
List<Net_MarkChannelMessagesSeen_Request.MessageRef> refs = req.getMessages();
if (refs == null || refs.isEmpty()) {
Net_MarkChannelMessagesSeen_Response ok = new Net_MarkChannelMessagesSeen_Response();
ok.setOp(req.getOp());
ok.setRequestId(req.getRequestId());
ok.setStatus(WireCodes.Status.OK);
ok.setSeenAccepted(0);
ok.setInserted(0);
return ok;
}
try (Connection c = SqliteDbController.getInstance().getConnection()) {
String viewerLogin = ctx != null ? ctx.getLogin() : null;
if (viewerLogin == null || viewerLogin.isBlank()) {
viewerLogin = ChannelsReadSupport.canonicalLogin(c, req.getLogin());
}
if (viewerLogin == null || viewerLogin.isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Invalid login");
}
Integer expectedRoot = req.getChannel() != null ? req.getChannel().getChannelRootBlockNumber() : null;
String expectedOwnerBch = req.getChannel() != null ? req.getChannel().getOwnerBlockchainName() : null;
boolean strictChannelMatch = expectedRoot != null && expectedRoot >= 0
&& expectedOwnerBch != null && !expectedOwnerBch.isBlank();
String existsSql = """
SELECT 1
FROM blocks
WHERE bch_name = ?
AND block_number = ?
AND block_hash = ?
AND msg_type = ?
AND msg_sub_type = ?
%s
LIMIT 1
""".formatted(strictChannelMatch ? "AND line_code = ?" : "");
String insertSql = """
INSERT OR IGNORE INTO message_views_state (
viewer_login, to_bch_name, to_block_number, to_block_hash, first_seen_at_ms
) VALUES (?, ?, ?, ?, ?)
""";
int seenAccepted = 0;
int inserted = 0;
long nowMs = System.currentTimeMillis();
Set<String> dedup = new HashSet<>();
try (PreparedStatement existsPs = c.prepareStatement(existsSql);
PreparedStatement insertPs = c.prepareStatement(insertSql)) {
for (Net_MarkChannelMessagesSeen_Request.MessageRef ref : refs) {
if (ref == null) continue;
String bch = String.valueOf(ref.getBlockchainName() == null ? "" : ref.getBlockchainName()).trim();
Integer no = ref.getBlockNumber();
String hashHex = String.valueOf(ref.getBlockHash() == null ? "" : ref.getBlockHash()).trim().toLowerCase();
if (bch.isBlank() || no == null || no < 0) continue;
if (!hashHex.matches("^[0-9a-f]{64}$")) continue;
if (strictChannelMatch && !bch.equals(expectedOwnerBch)) continue;
String key = bch + "|" + no + "|" + hashHex;
if (!dedup.add(key)) continue;
existsPs.clearParameters();
existsPs.setString(1, bch);
existsPs.setInt(2, no);
existsPs.setBytes(3, ChannelsReadSupport.hexToBytes(hashHex));
existsPs.setInt(4, ChannelsReadSupport.MSG_TYPE_TEXT);
existsPs.setInt(5, MsgSubType.TEXT_POST);
if (strictChannelMatch) {
existsPs.setInt(6, expectedRoot);
}
boolean exists;
try (ResultSet rs = existsPs.executeQuery()) {
exists = rs.next();
}
if (!exists) continue;
seenAccepted += 1;
insertPs.clearParameters();
insertPs.setString(1, viewerLogin);
insertPs.setString(2, bch);
insertPs.setInt(3, no);
insertPs.setBytes(4, ChannelsReadSupport.hexToBytes(hashHex));
insertPs.setLong(5, nowMs);
inserted += insertPs.executeUpdate();
}
}
Net_MarkChannelMessagesSeen_Response ok = new Net_MarkChannelMessagesSeen_Response();
ok.setOp(req.getOp());
ok.setRequestId(req.getRequestId());
ok.setStatus(WireCodes.Status.OK);
ok.setSeenAccepted(seenAccepted);
ok.setInserted(inserted);
return ok;
} catch (Exception e) {
log.error("MarkChannelMessagesSeen failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Internal server error");
}
}
}
@@ -8,6 +8,8 @@ import java.util.List;
public class Net_GetChannelMessages_Response extends Net_Response {
private Channel channel;
private List<MessageItem> messages = new ArrayList<>();
private int unreadCount;
private BlockRef firstUnreadMessageRef;
public Channel getChannel() { return channel; }
public void setChannel(Channel channel) { this.channel = channel; }
@@ -15,6 +17,12 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public List<MessageItem> getMessages() { return messages; }
public void setMessages(List<MessageItem> messages) { this.messages = messages; }
public int getUnreadCount() { return unreadCount; }
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
public BlockRef getFirstUnreadMessageRef() { return firstUnreadMessageRef; }
public void setFirstUnreadMessageRef(BlockRef firstUnreadMessageRef) { this.firstUnreadMessageRef = firstUnreadMessageRef; }
public static class Channel {
private String ownerLogin;
private String ownerBlockchainName;
@@ -47,6 +55,8 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private int likesCount;
private boolean likedByMe;
private int repliesCount;
private int viewCount;
private boolean seenByMe;
private int versionsTotal;
private List<VersionItem> versions = new ArrayList<>();
@@ -74,6 +84,12 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public int getRepliesCount() { return repliesCount; }
public void setRepliesCount(int repliesCount) { this.repliesCount = repliesCount; }
public int getViewCount() { return viewCount; }
public void setViewCount(int viewCount) { this.viewCount = viewCount; }
public boolean isSeenByMe() { return seenByMe; }
public void setSeenByMe(boolean seenByMe) { this.seenByMe = seenByMe; }
public int getVersionsTotal() { return versionsTotal; }
public void setVersionsTotal(int versionsTotal) { this.versionsTotal = versionsTotal; }
@@ -26,6 +26,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public static class ChannelSummary {
private ChannelRef channel;
private int messagesCount;
private int unreadCount;
private LastMessage lastMessage;
public ChannelRef getChannel() { return channel; }
@@ -34,6 +35,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public int getMessagesCount() { return messagesCount; }
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
public int getUnreadCount() { return unreadCount; }
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
public LastMessage getLastMessage() { return lastMessage; }
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
}
@@ -0,0 +1,51 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import java.util.ArrayList;
import java.util.List;
public class Net_MarkChannelMessagesSeen_Request extends Net_Request {
private String login;
private ChannelSelector channel;
private List<MessageRef> messages = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public ChannelSelector getChannel() { return channel; }
public void setChannel(ChannelSelector channel) { this.channel = channel; }
public List<MessageRef> getMessages() { return messages; }
public void setMessages(List<MessageRef> messages) { this.messages = messages; }
public static class ChannelSelector {
private String ownerBlockchainName;
private Integer channelRootBlockNumber;
private String channelRootBlockHash;
public String getOwnerBlockchainName() { return ownerBlockchainName; }
public void setOwnerBlockchainName(String ownerBlockchainName) { this.ownerBlockchainName = ownerBlockchainName; }
public Integer getChannelRootBlockNumber() { return channelRootBlockNumber; }
public void setChannelRootBlockNumber(Integer channelRootBlockNumber) { this.channelRootBlockNumber = channelRootBlockNumber; }
public String getChannelRootBlockHash() { return channelRootBlockHash; }
public void setChannelRootBlockHash(String channelRootBlockHash) { this.channelRootBlockHash = channelRootBlockHash; }
}
public static class MessageRef {
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; }
}
}
@@ -0,0 +1,14 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_MarkChannelMessagesSeen_Response extends Net_Response {
private int seenAccepted;
private int inserted;
public int getSeenAccepted() { return seenAccepted; }
public void setSeenAccepted(int seenAccepted) { this.seenAccepted = seenAccepted; }
public int getInserted() { return inserted; }
public void setInserted(int inserted) { this.inserted = inserted; }
}