/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {Endpoints} from '~/Endpoints'; import http from '~/lib/HttpClient'; import {Logger} from '~/lib/Logger'; import ChannelStore from '~/stores/ChannelStore'; import MessageStore from '~/stores/MessageStore'; import ReadStateStore from '~/stores/ReadStateStore'; import SnowflakeUtil from '~/utils/SnowflakeUtil'; const logger = new Logger('ReadStateActionCreators'); type ChannelId = string; type MessageId = string; export const ack = (channelId: ChannelId, immediate = false, force = false): void => { logger.debug(`Acking channel ${channelId}, immediate=${immediate}, force=${force}`); ReadStateStore.handleChannelAck({channelId, immediate, force}); }; export const ackWithStickyUnread = (channelId: ChannelId): void => { logger.debug(`Acking channel ${channelId} with sticky unread preservation`); ReadStateStore.handleChannelAckWithStickyUnread({channelId}); }; export const manualAck = async (channelId: ChannelId, messageId: MessageId): Promise => { try { logger.debug(`Manual ack: ${messageId} in ${channelId}`); const mentionCount = ReadStateStore.getManualAckMentionCount(channelId, messageId); await http.post({ url: Endpoints.CHANNEL_MESSAGE_ACK(channelId, messageId), body: { manual: true, mention_count: mentionCount, }, }); ReadStateStore.handleMessageAck({channelId, messageId, manual: true}); logger.debug(`Successfully manual acked ${messageId}`); } catch (error) { logger.error(`Failed to manual ack ${messageId}:`, error); throw error; } }; export const markAsUnread = async (channelId: ChannelId, messageId: MessageId): Promise => { const messages = MessageStore.getMessages(channelId); const messagesArray = messages.toArray(); const messageIndex = messagesArray.findIndex((m) => m.id === messageId); logger.debug(`Marking message ${messageId} as unread, index: ${messageIndex}, total: ${messagesArray.length}`); if (messageIndex < 0) { logger.debug('Message not found in cache; skipping mark-as-unread request'); return; } const ackMessageId = messageIndex > 0 ? messagesArray[messageIndex - 1].id : SnowflakeUtil.atPreviousMillisecond(messageId); if (!ackMessageId || ackMessageId === '0') { logger.debug('Unable to determine a previous message to ack; skipping mark-as-unread request'); return; } logger.debug(`Acking ${ackMessageId} to mark ${messageId} as unread`); await manualAck(channelId, ackMessageId); }; export const clearManualAck = (channelId: ChannelId): void => { ReadStateStore.handleClearManualAck({channelId}); }; export const clearStickyUnread = (channelId: ChannelId): void => { logger.debug(`Clearing sticky unread for ${channelId}`); ReadStateStore.clearStickyUnread(channelId); }; interface BulkAckEntry { channelId: ChannelId; messageId: MessageId; } const BULK_ACK_BATCH_SIZE = 100; function chunkEntries(entries: Array, size: number): Array> { const chunks: Array> = []; for (let i = 0; i < entries.length; i += size) { chunks.push(entries.slice(i, i + size)); } return chunks; } function createBulkEntry(channelId: ChannelId): BulkAckEntry | null { const messageId = ReadStateStore.lastMessageId(channelId) ?? ChannelStore.getChannel(channelId)?.lastMessageId ?? null; if (messageId == null) { return null; } return {channelId, messageId}; } async function sendBulkAck(entries: Array): Promise { if (entries.length === 0) return; try { await http.post({ url: Endpoints.READ_STATES_ACK_BULK, body: { read_states: entries.map((entry) => ({ channel_id: entry.channelId, message_id: entry.messageId, })), }, }); } catch (error) { logger.error('Failed to bulk ack read states:', error); } } function updateReadStatesLocally(entries: Array): void { for (const entry of entries) { ReadStateStore.handleMessageAck({channelId: entry.channelId, messageId: entry.messageId, manual: false}); } } export async function bulkAckChannels(channelIds: Array): Promise { const entries = channelIds .map((channelId) => createBulkEntry(channelId)) .filter((entry): entry is BulkAckEntry => entry != null); if (entries.length === 0) return; const chunks = chunkEntries(entries, BULK_ACK_BATCH_SIZE); for (const chunk of chunks) { updateReadStatesLocally(chunk); await sendBulkAck(chunk); } }