SHA256
Исправить синхронизацию прочитанных каналов
This commit is contained in:
@@ -136,6 +136,23 @@ public final class ChannelReadStateDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT channel_root_block_number
|
||||||
|
FROM channel_names_state
|
||||||
|
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||||
|
LIMIT 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerBchName);
|
||||||
|
ps.setString(2, channelName);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
return rs.next() ? rs.getInt("channel_root_block_number") : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException {
|
public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT owner_login
|
SELECT owner_login
|
||||||
|
|||||||
+3
-2
@@ -236,12 +236,13 @@ final class ChannelsReadSupport {
|
|||||||
|
|
||||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||||
String order = asc ? "ASC" : "DESC";
|
String order = asc ? "ASC" : "DESC";
|
||||||
|
boolean bounded = limit > 0;
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||||
ORDER BY block_number
|
ORDER BY block_number
|
||||||
""" + order + " LIMIT ?";
|
""" + order + (bounded ? " LIMIT ?" : "");
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setString(1, ownerBch);
|
ps.setString(1, ownerBch);
|
||||||
ps.setInt(2, MSG_TYPE_TEXT);
|
ps.setInt(2, MSG_TYPE_TEXT);
|
||||||
@@ -252,7 +253,7 @@ final class ChannelsReadSupport {
|
|||||||
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||||
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||||
ps.setInt(9, lineCode);
|
ps.setInt(9, lineCode);
|
||||||
ps.setInt(10, limit);
|
if (bounded) ps.setInt(10, limit);
|
||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
List<PostBlock> out = new ArrayList<>();
|
List<PostBlock> out = new ArrayList<>();
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
|
|||||||
+6
-4
@@ -34,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
int limit = req.getLimit() == null ? 30 : req.getLimit();
|
// limit is optional. null/0 means "return the whole channel".
|
||||||
if (limit <= 0 || limit > 1000) {
|
// Positive values are kept for backwards-compatible callers that still want a bounded response.
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
int limit = req.getLimit() == null ? 0 : req.getLimit();
|
||||||
|
if (limit < 0) {
|
||||||
|
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||||
@@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
|||||||
if (!asc) {
|
if (!asc) {
|
||||||
java.util.Collections.reverse(posts);
|
java.util.Collections.reverse(posts);
|
||||||
}
|
}
|
||||||
if (posts.size() > limit) {
|
if (limit > 0 && posts.size() > limit) {
|
||||||
posts = new ArrayList<>(posts.subList(0, limit));
|
posts = new ArrayList<>(posts.subList(0, limit));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -82,12 +82,14 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
|||||||
channelRef.setChannelRoot(rootRef);
|
channelRef.setChannelRoot(rootRef);
|
||||||
|
|
||||||
row.setChannel(channelRef);
|
row.setChannel(channelRef);
|
||||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
|
||||||
|
row.setMessagesCount(messagesCount);
|
||||||
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||||
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
|
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
|
||||||
? null
|
? null
|
||||||
: ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName);
|
: ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName);
|
||||||
row.setReadStateInitialized(readState != null);
|
row.setReadStateInitialized(readState != null);
|
||||||
|
row.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount())));
|
||||||
row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount()));
|
row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount()));
|
||||||
|
|
||||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||||
|
|||||||
+10
-2
@@ -100,13 +100,21 @@ public final class Net_SetChannelReadState_Handler implements JsonMessageHandler
|
|||||||
"CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал");
|
"CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал");
|
||||||
}
|
}
|
||||||
String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName);
|
String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName);
|
||||||
if (ownerLogin == null || ownerLogin.isBlank()) {
|
Integer channelRoot = dao.resolvePublicChannelRootByName(c, ownerBch, channelName);
|
||||||
|
if (ownerLogin == null || ownerLogin.isBlank() || channelRoot == null) {
|
||||||
return NetExceptionResponseFactory.error(req, 404,
|
return NetExceptionResponseFactory.error(req, 404,
|
||||||
"CHANNEL_NOT_FOUND", "Канал не найден");
|
"CHANNEL_NOT_FOUND", "Канал не найден");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never persist a watermark beyond the number of messages that really exists.
|
||||||
|
// Without this guard, one buggy/malicious oversized read_count would be monotonic
|
||||||
|
// and could make all future unread counters impossible to clear correctly.
|
||||||
|
long actualMessagesCount = ChannelsReadSupport.countPosts(c, ownerBch, channelRoot);
|
||||||
|
long effectiveReadCount = Math.min(readCount, actualMessagesCount);
|
||||||
|
|
||||||
result = dao.upsertSignedReadIfNewer(c,
|
result = dao.upsertSignedReadIfNewer(c,
|
||||||
authenticatedLogin, ownerLogin, ownerBch, channelName,
|
authenticatedLogin, ownerLogin, ownerBch, channelName,
|
||||||
readCount, timeMs, clientKeyB64, signatureB64, nowMs);
|
effectiveReadCount, timeMs, clientKeyB64, signatureB64, nowMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response();
|
Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response();
|
||||||
|
|||||||
+4
@@ -26,6 +26,7 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public static class ChannelSummary {
|
public static class ChannelSummary {
|
||||||
private ChannelRef channel;
|
private ChannelRef channel;
|
||||||
private int messagesCount;
|
private int messagesCount;
|
||||||
|
private long readCount;
|
||||||
private int unreadCount;
|
private int unreadCount;
|
||||||
private boolean readStateInitialized;
|
private boolean readStateInitialized;
|
||||||
private LastMessage lastMessage;
|
private LastMessage lastMessage;
|
||||||
@@ -36,6 +37,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
|||||||
public int getMessagesCount() { return messagesCount; }
|
public int getMessagesCount() { return messagesCount; }
|
||||||
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
||||||
|
|
||||||
|
public long getReadCount() { return readCount; }
|
||||||
|
public void setReadCount(long readCount) { this.readCount = readCount; }
|
||||||
|
|
||||||
public int getUnreadCount() { return unreadCount; }
|
public int getUnreadCount() { return unreadCount; }
|
||||||
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.12.9
|
client.version=1.12.10
|
||||||
server.version=1.10.3
|
server.version=1.10.4
|
||||||
|
|||||||
@@ -29,9 +29,9 @@
|
|||||||
|
|
||||||
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки.
|
||||||
>
|
>
|
||||||
> `unreadCount` для канала считается по `user_settings`.
|
> `unreadCount` для канала считается по подписанному состоянию чтения канала.
|
||||||
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
|
> Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого.
|
||||||
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным; UI при загрузке списка каналов создаёт baseline на текущем `messagesCount`. После этого новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным. После появления записи новые публикации увеличивают `unreadCount` до продвижения курсора чтения.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
"channelRoot": { "blockNumber": 456, "blockHash": "..." }
|
||||||
},
|
},
|
||||||
"messagesCount": 90,
|
"messagesCount": 90,
|
||||||
|
"readCount": 0,
|
||||||
"unreadCount": 0,
|
"unreadCount": 0,
|
||||||
"readStateInitialized": false,
|
"readStateInitialized": false,
|
||||||
"lastMessage": {
|
"lastMessage": {
|
||||||
@@ -148,6 +149,8 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`limit` необязателен. Если поле отсутствует или равно `0`, сервер возвращает всю ленту канала. Положительное значение ограничивает количество сообщений для совместимых клиентов.
|
||||||
|
|
||||||
### Response (success)
|
### Response (success)
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -539,7 +542,7 @@ SHiNe/ChannelReadState:<login>|<owner_bch_name>|<channel_name>|<time_ms>|<read_c
|
|||||||
- `user_not_found`
|
- `user_not_found`
|
||||||
- `channel_not_found`
|
- `channel_not_found`
|
||||||
- `message_not_found`
|
- `message_not_found`
|
||||||
- `limit_too_large`
|
- `bad_limit`
|
||||||
- `channel_name_already_exists`
|
- `channel_name_already_exists`
|
||||||
- `CHANNEL_NOT_FOLLOWED`
|
- `CHANNEL_NOT_FOLLOWED`
|
||||||
- `CHANNEL_NOT_FOUND`
|
- `CHANNEL_NOT_FOUND`
|
||||||
|
|||||||
@@ -292,10 +292,14 @@ function createChannelReadTracker({
|
|||||||
const storagePwd = state.session.storagePwdInMemory;
|
const storagePwd = state.session.storagePwdInMemory;
|
||||||
const cleanOwnerBlockchainName = String(ownerBlockchainName || '').trim();
|
const cleanOwnerBlockchainName = String(ownerBlockchainName || '').trim();
|
||||||
const cleanChannelName = String(channelName || '').trim();
|
const cleanChannelName = String(channelName || '').trim();
|
||||||
const canWrite = !!(cleanOwnerBlockchainName && cleanChannelName && login && storagePwd);
|
const missingWriteParts = [];
|
||||||
|
if (!cleanOwnerBlockchainName) missingWriteParts.push('ownerBlockchainName');
|
||||||
|
if (!cleanChannelName) missingWriteParts.push('channelName');
|
||||||
|
if (!login) missingWriteParts.push('login');
|
||||||
|
if (!storagePwd) missingWriteParts.push('storagePwd');
|
||||||
|
const canWrite = missingWriteParts.length === 0;
|
||||||
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
|
const safeMessagesCount = Math.max(0, Number(messagesCount || 0));
|
||||||
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
|
const safeInitialSeenCount = Math.max(0, Math.min(Number(initialSeenCount || 0), safeMessagesCount));
|
||||||
const unreadLine = unreadCount > 0 ? screen.querySelector('.channel-unread-line') : null;
|
|
||||||
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
|
const unreadAnchorFraction = getUnreadAnchorViewportFraction(unreadCount);
|
||||||
|
|
||||||
let desiredSeenCount = safeInitialSeenCount;
|
let desiredSeenCount = safeInitialSeenCount;
|
||||||
@@ -333,7 +337,7 @@ function createChannelReadTracker({
|
|||||||
|
|
||||||
inFlight = true;
|
inFlight = true;
|
||||||
try {
|
try {
|
||||||
await authService.setChannelReadState({
|
const persisted = await authService.setChannelReadState({
|
||||||
login,
|
login,
|
||||||
ownerBlockchainName: cleanOwnerBlockchainName,
|
ownerBlockchainName: cleanOwnerBlockchainName,
|
||||||
channelName: cleanChannelName,
|
channelName: cleanChannelName,
|
||||||
@@ -341,11 +345,20 @@ function createChannelReadTracker({
|
|||||||
timeMs: Date.now(),
|
timeMs: Date.now(),
|
||||||
storagePwd,
|
storagePwd,
|
||||||
});
|
});
|
||||||
persistedSeenCount = next;
|
const serverReadCount = Math.max(0, Number(persisted?.read_count ?? next));
|
||||||
|
const serverUnreadCount = Math.max(0, Number(persisted?.unread_count ?? Math.max(0, safeMessagesCount - serverReadCount)));
|
||||||
|
persistedSeenCount = Math.max(persistedSeenCount, Math.min(serverReadCount, safeMessagesCount));
|
||||||
|
desiredSeenCount = Math.max(desiredSeenCount, persistedSeenCount);
|
||||||
initialPersistPending = false;
|
initialPersistPending = false;
|
||||||
if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount);
|
if (typeof onPersistSuccess === 'function') {
|
||||||
|
onPersistSuccess({
|
||||||
|
readCount: persistedSeenCount,
|
||||||
|
unreadCount: serverUnreadCount,
|
||||||
|
applied: persisted?.applied !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof onPersistError === 'function') onPersistError(error);
|
if (typeof onPersistError === 'function') onPersistError(error, { retrying: true });
|
||||||
queueFlush(800);
|
queueFlush(800);
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = false;
|
inFlight = false;
|
||||||
@@ -355,7 +368,9 @@ function createChannelReadTracker({
|
|||||||
const collectSeenCount = () => {
|
const collectSeenCount = () => {
|
||||||
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
|
const cards = Array.from(screen.querySelectorAll('.channel-message-card[data-local-number]'));
|
||||||
if (!cards.length) return safeInitialSeenCount;
|
if (!cards.length) return safeInitialSeenCount;
|
||||||
if (!unreadLine) return safeMessagesCount;
|
// Absence of the divider must never imply that unseen messages are read.
|
||||||
|
// Use only the highest message card that actually crossed the viewport.
|
||||||
|
|
||||||
|
|
||||||
const root = getChannelScrollRoot();
|
const root = getChannelScrollRoot();
|
||||||
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
|
const viewportHeight = root?.clientHeight || window.innerHeight || document.documentElement.clientHeight || 0;
|
||||||
@@ -389,6 +404,12 @@ function createChannelReadTracker({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!canWrite && (initializeIfMissing || unreadCount > 0)) {
|
||||||
|
const error = new Error(`Нельзя сохранить прочитанность канала: отсутствует ${missingWriteParts.join(', ')}`);
|
||||||
|
console.error('[SHiNE][ChannelReadState]', error);
|
||||||
|
if (typeof onPersistError === 'function') onPersistError(error, { retrying: false });
|
||||||
|
}
|
||||||
|
|
||||||
const scrollRoot = getChannelScrollRoot();
|
const scrollRoot = getChannelScrollRoot();
|
||||||
const onScroll = () => measure();
|
const onScroll = () => measure();
|
||||||
const onResize = () => measure();
|
const onResize = () => measure();
|
||||||
@@ -1462,6 +1483,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
const isAuthorized = !!currentSessionLogin;
|
const isAuthorized = !!currentSessionLogin;
|
||||||
let unreadCount = 0;
|
let unreadCount = 0;
|
||||||
let messagesCount = 0;
|
let messagesCount = 0;
|
||||||
|
let readCount = 0;
|
||||||
let readStateInitialized = false;
|
let readStateInitialized = false;
|
||||||
let cachedFeed = null;
|
let cachedFeed = null;
|
||||||
const ensureFeed = async () => {
|
const ensureFeed = async () => {
|
||||||
@@ -1523,9 +1545,6 @@ async function loadFromApi(route, channelId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selector?.ownerBlockchainName && selector?.channelName) {
|
if (selector?.ownerBlockchainName && selector?.channelName) {
|
||||||
let unreadCount = 0;
|
|
||||||
let messagesCount = 0;
|
|
||||||
|
|
||||||
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
|
||||||
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
|
||||||
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
const routeOwnerLoginFromBch = extractLoginFromBlockchainName(routeOwnerRaw);
|
||||||
@@ -1577,6 +1596,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
}
|
}
|
||||||
unreadCount = Number(channel?.unreadCount || 0);
|
unreadCount = Number(channel?.unreadCount || 0);
|
||||||
messagesCount = Number(channel?.messagesCount || 0);
|
messagesCount = Number(channel?.messagesCount || 0);
|
||||||
|
readCount = Number(channel?.readCount || 0);
|
||||||
readStateInitialized = !!channel?.readStateInitialized;
|
readStateInitialized = !!channel?.readStateInitialized;
|
||||||
selector = {
|
selector = {
|
||||||
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
|
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
|
||||||
@@ -1590,7 +1610,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
throw new Error('Не удалось определить канал из адреса страницы.');
|
throw new Error('Не удалось определить канал из адреса страницы.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await authService.getChannelMessages(selector, 200, 'asc', currentSessionLogin);
|
const payload = await authService.getChannelMessages(selector, null, 'asc', currentSessionLogin);
|
||||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||||
let reverseChannelMissingWarning = '';
|
let reverseChannelMissingWarning = '';
|
||||||
let mergedMessages = [...messages];
|
let mergedMessages = [...messages];
|
||||||
@@ -1625,7 +1645,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
|
channelRootBlockNumber: Number(reverseSummary.channel.channelRoot.blockNumber),
|
||||||
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
|
channelRootBlockHash: normalizeRouteHash(reverseSummary.channel.channelRoot.blockHash),
|
||||||
};
|
};
|
||||||
const reversePayload = await authService.getChannelMessages(reverseSelector, 200, 'asc', currentSessionLogin);
|
const reversePayload = await authService.getChannelMessages(reverseSelector, null, 'asc', currentSessionLogin);
|
||||||
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
const reverseMessages = Array.isArray(reversePayload?.messages) ? reversePayload.messages : [];
|
||||||
mergedMessages = mergedMessages.concat(reverseMessages);
|
mergedMessages = mergedMessages.concat(reverseMessages);
|
||||||
} else {
|
} else {
|
||||||
@@ -1669,6 +1689,7 @@ async function loadFromApi(route, channelId) {
|
|||||||
reverseChannelMissingWarning,
|
reverseChannelMissingWarning,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
messagesCount,
|
messagesCount,
|
||||||
|
readCount,
|
||||||
isOwnChannel,
|
isOwnChannel,
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
readStateInitialized,
|
readStateInitialized,
|
||||||
@@ -2276,7 +2297,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
const showStatus = typeof handlers?.showStatus === 'function' ? handlers.showStatus : () => {};
|
const showStatus = typeof handlers?.showStatus === 'function' ? handlers.showStatus : () => {};
|
||||||
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
|
const unreadCount = Math.max(0, Number(channelData.unreadCount || 0));
|
||||||
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
|
const messagesCount = Math.max(0, Number(channelData.messagesCount || (Array.isArray(channelData.posts) ? channelData.posts.length : 0) || 0));
|
||||||
const readCount = Math.max(0, messagesCount - unreadCount);
|
const readCount = Math.max(0, Math.min(Number(channelData.readCount || 0), messagesCount));
|
||||||
|
|
||||||
if (channelData.reverseChannelMissingWarning) {
|
if (channelData.reverseChannelMissingWarning) {
|
||||||
const reverseWarning = document.createElement('p');
|
const reverseWarning = document.createElement('p');
|
||||||
@@ -2328,7 +2349,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
||||||
const unreadLine = document.createElement('div');
|
const unreadLine = document.createElement('div');
|
||||||
unreadLine.className = 'card channel-unread-line';
|
unreadLine.className = 'card channel-unread-line';
|
||||||
unreadLine.textContent = 'Не прочитанные сообщения';
|
unreadLine.textContent = 'Новые сообщения';
|
||||||
feed.append(unreadLine);
|
feed.append(unreadLine);
|
||||||
unreadLineInserted = true;
|
unreadLineInserted = true;
|
||||||
}
|
}
|
||||||
@@ -2399,10 +2420,34 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
|||||||
unreadCount,
|
unreadCount,
|
||||||
messagesCount,
|
messagesCount,
|
||||||
initialSeenCount: readCount,
|
initialSeenCount: readCount,
|
||||||
onPersistError: () => {
|
onPersistError: (error, options = {}) => {
|
||||||
showStatus('Не удалось сохранить, сколько сообщений прочитано. Сервер повторит попытку автоматически.');
|
const detail = String(error?.message || '').trim();
|
||||||
|
const retryText = options?.retrying === false ? '' : ' Сервер повторит попытку автоматически.';
|
||||||
|
showStatus(`Не удалось сохранить, сколько сообщений прочитано.${detail ? ` ${detail}` : ''}${retryText}`);
|
||||||
},
|
},
|
||||||
onPersistSuccess: () => {
|
onPersistSuccess: ({ readCount: persistedReadCount, unreadCount: persistedUnreadCount }) => {
|
||||||
|
channelData.readCount = persistedReadCount;
|
||||||
|
channelData.unreadCount = persistedUnreadCount;
|
||||||
|
channelData.readStateInitialized = true;
|
||||||
|
|
||||||
|
// The "Новые сообщения" divider is a snapshot of the unread boundary at the
|
||||||
|
// moment this channel view was opened. Persisting read state must not move or
|
||||||
|
// remove it during the current view session; reopening the channel recalculates it.
|
||||||
|
|
||||||
|
const feedGroups = ['ownedChannels', 'followedUsersChannels', 'followedChannels'];
|
||||||
|
for (const group of feedGroups) {
|
||||||
|
const rows = Array.isArray(state.channelsFeed?.[group]) ? state.channelsFeed[group] : [];
|
||||||
|
const row = rows.find((item) => (
|
||||||
|
String(item?.channel?.ownerBlockchainName || '') === String(channelData.selector?.ownerBlockchainName || '')
|
||||||
|
&& Number(item?.channel?.channelRoot?.blockNumber) === Number(channelData.selector?.channelRootBlockNumber)
|
||||||
|
&& normalizeRouteHash(item?.channel?.channelRoot?.blockHash) === normalizeRouteHash(channelData.selector?.channelRootBlockHash)
|
||||||
|
));
|
||||||
|
if (row) {
|
||||||
|
row.readCount = persistedReadCount;
|
||||||
|
row.unreadCount = persistedUnreadCount;
|
||||||
|
row.readStateInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
showStatus('');
|
showStatus('');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -902,7 +902,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
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';
|
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;
|
|
||||||
let historyHasMore = true;
|
let historyHasMore = true;
|
||||||
let historyLoading = false;
|
let historyLoading = false;
|
||||||
let historyNextBeforeTimeMs = 0;
|
let historyNextBeforeTimeMs = 0;
|
||||||
@@ -910,16 +909,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
let historyBootstrapped = false;
|
let historyBootstrapped = false;
|
||||||
let boundScrollContainer = null;
|
let boundScrollContainer = null;
|
||||||
let unreadSeparatorVisible = hasUnreadIncoming;
|
let unreadSeparatorVisible = hasUnreadIncoming;
|
||||||
let unreadSeparatorHideTimer = null;
|
|
||||||
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
|
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
|
||||||
|
|
||||||
const clearUnreadSeparatorHideTimer = () => {
|
|
||||||
if (unreadSeparatorHideTimer) {
|
|
||||||
window.clearTimeout(unreadSeparatorHideTimer);
|
|
||||||
unreadSeparatorHideTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
|
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
|
||||||
renderLog(log, chatId, {
|
renderLog(log, chatId, {
|
||||||
onOpenActions: handleOpenActions,
|
onOpenActions: handleOpenActions,
|
||||||
@@ -928,13 +919,9 @@ export function render({ navigate, route, chrome }) {
|
|||||||
showUnreadSeparator: unreadSeparatorVisible,
|
showUnreadSeparator: unreadSeparatorVisible,
|
||||||
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||||
});
|
});
|
||||||
if (unreadSeparatorVisible) {
|
|
||||||
scheduleUnreadSeparatorAutoHide();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hideUnreadSeparator = ({ rerender = true } = {}) => {
|
const hideUnreadSeparator = ({ rerender = true } = {}) => {
|
||||||
clearUnreadSeparatorHideTimer();
|
|
||||||
if (!unreadSeparatorVisible) return;
|
if (!unreadSeparatorVisible) return;
|
||||||
unreadSeparatorVisible = false;
|
unreadSeparatorVisible = false;
|
||||||
if (rerender) {
|
if (rerender) {
|
||||||
@@ -942,14 +929,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleUnreadSeparatorAutoHide = () => {
|
|
||||||
clearUnreadSeparatorHideTimer();
|
|
||||||
if (!unreadSeparatorVisible) return;
|
|
||||||
unreadSeparatorHideTimer = window.setTimeout(() => {
|
|
||||||
hideUnreadSeparator({ rerender: true });
|
|
||||||
}, UNREAD_SEPARATOR_AUTO_HIDE_MS);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReadAloud = async (msg) => {
|
const handleReadAloud = async (msg) => {
|
||||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||||
showTtsMissingConfigDialog();
|
showTtsMissingConfigDialog();
|
||||||
@@ -1371,7 +1350,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
|
||||||
const text = safeText.trim();
|
const text = safeText.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
hideUnreadSeparator({ rerender: false });
|
|
||||||
const editing = activeEdit;
|
const editing = activeEdit;
|
||||||
const replying = !editing ? activeReply : null;
|
const replying = !editing ? activeReply : null;
|
||||||
const finalText = editing
|
const finalText = editing
|
||||||
@@ -1414,6 +1392,13 @@ export function render({ navigate, route, chrome }) {
|
|||||||
deliveryState: result?.deliveryState || 'accepted',
|
deliveryState: result?.deliveryState || 'accepted',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// In a DM the "Новые сообщения" divider stays fixed for the current open
|
||||||
|
// chat session and disappears only after a successfully sent NEW message.
|
||||||
|
// Editing an existing message must not clear the divider.
|
||||||
|
if (!editing) {
|
||||||
|
hideUnreadSeparator({ rerender: false });
|
||||||
|
}
|
||||||
|
|
||||||
if (editing) {
|
if (editing) {
|
||||||
cancelEditMode({ restoreDraft: true });
|
cancelEditMode({ restoreDraft: true });
|
||||||
} else if (replying) {
|
} else if (replying) {
|
||||||
@@ -1634,7 +1619,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
|
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
|
||||||
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
|
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
|
||||||
}
|
}
|
||||||
scheduleUnreadSeparatorAutoHide();
|
|
||||||
}
|
}
|
||||||
preserveComposerSelection(input, () => {
|
preserveComposerSelection(input, () => {
|
||||||
renderChatLog({ scrollMode: 'latest' });
|
renderChatLog({ scrollMode: 'latest' });
|
||||||
@@ -1660,10 +1644,8 @@ export function render({ navigate, route, chrome }) {
|
|||||||
markAsRead: false,
|
markAsRead: false,
|
||||||
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
|
||||||
showUnreadSeparator: unreadSeparatorVisible,
|
showUnreadSeparator: unreadSeparatorVisible,
|
||||||
|
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
|
||||||
});
|
});
|
||||||
if (unreadSeparatorVisible) {
|
|
||||||
scheduleUnreadSeparatorAutoHide();
|
|
||||||
}
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (markChatRead(chatId) > 0) {
|
if (markChatRead(chatId) > 0) {
|
||||||
notifyUnreadStateUpdated();
|
notifyUnreadStateUpdated();
|
||||||
@@ -1681,7 +1663,6 @@ export function render({ navigate, route, chrome }) {
|
|||||||
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
|
||||||
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
|
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
|
||||||
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
|
||||||
clearUnreadSeparatorHideTimer();
|
|
||||||
};
|
};
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1574,13 +1574,15 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChannelMessages(channel, limit = 200, sort = 'asc', login = '') {
|
async getChannelMessages(channel, limit = null, sort = 'asc', login = '') {
|
||||||
const normalizedChannel = {
|
const normalizedChannel = {
|
||||||
ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
|
ownerBlockchainName: String(channel?.ownerBlockchainName || '').trim(),
|
||||||
channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
|
channelRootBlockNumber: Number(channel?.channelRootBlockNumber),
|
||||||
channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
|
channelRootBlockHash: String(channel?.channelRootBlockHash || '').trim(),
|
||||||
};
|
};
|
||||||
const payload = { channel: normalizedChannel, limit, sort };
|
const payload = { channel: normalizedChannel, sort };
|
||||||
|
const cleanLimit = Number(limit);
|
||||||
|
if (Number.isFinite(cleanLimit) && cleanLimit > 0) payload.limit = Math.trunc(cleanLimit);
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
if (cleanLogin) payload.login = cleanLogin;
|
if (cleanLogin) payload.login = cleanLogin;
|
||||||
const response = await this.ws.request('GetChannelMessages', payload);
|
const response = await this.ws.request('GetChannelMessages', payload);
|
||||||
|
|||||||
Reference in New Issue
Block a user