refactor progress
This commit is contained in:
44
packages/api/src/read_state/IReadStateRepository.tsx
Normal file
44
packages/api/src/read_state/IReadStateRepository.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ReadState} from '@fluxer/api/src/models/ReadState';
|
||||
|
||||
export abstract class IReadStateRepository {
|
||||
abstract listReadStates(userId: UserID): Promise<Array<ReadState>>;
|
||||
abstract upsertReadState(
|
||||
userId: UserID,
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
mentionCount?: number,
|
||||
lastPinTimestamp?: Date,
|
||||
): Promise<ReadState>;
|
||||
abstract incrementReadStateMentions(
|
||||
userId: UserID,
|
||||
channelId: ChannelID,
|
||||
incrementBy?: number,
|
||||
): Promise<ReadState | null>;
|
||||
abstract bulkIncrementMentionCounts(updates: Array<{userId: UserID; channelId: ChannelID}>): Promise<void>;
|
||||
abstract deleteReadState(userId: UserID, channelId: ChannelID): Promise<void>;
|
||||
abstract bulkAckMessages(
|
||||
userId: UserID,
|
||||
readStates: Array<{channelId: ChannelID; messageId: MessageID}>,
|
||||
): Promise<Array<ReadState>>;
|
||||
abstract upsertPinAck(userId: UserID, channelId: ChannelID, lastPinTimestamp: Date): Promise<void>;
|
||||
}
|
||||
53
packages/api/src/read_state/ReadStateController.tsx
Normal file
53
packages/api/src/read_state/ReadStateController.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
|
||||
import type {HonoEnv} from '@fluxer/api/src/types/HonoEnv';
|
||||
import {Validator} from '@fluxer/api/src/Validator';
|
||||
import {ReadStateAckBulkRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import type {Hono} from 'hono';
|
||||
|
||||
export function ReadStateController(app: Hono<HonoEnv>): void {
|
||||
app.post(
|
||||
'/read-states/ack-bulk',
|
||||
RateLimitMiddleware(RateLimitConfigs.READ_STATE_ACK_BULK),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'ack_bulk_messages',
|
||||
summary: 'Mark channels as read',
|
||||
description: 'Marks multiple channels as read for the authenticated user in bulk.',
|
||||
responseSchema: null,
|
||||
statusCode: 204,
|
||||
security: ['botToken', 'bearerToken', 'sessionToken'],
|
||||
tags: ['Read States'],
|
||||
}),
|
||||
Validator('json', ReadStateAckBulkRequest),
|
||||
async (ctx) => {
|
||||
await ctx.get('readStateRequestService').bulkAckMessages({
|
||||
userId: ctx.get('user').id,
|
||||
data: ctx.req.valid('json'),
|
||||
});
|
||||
return ctx.body(null, 204);
|
||||
},
|
||||
);
|
||||
}
|
||||
177
packages/api/src/read_state/ReadStateRepository.tsx
Normal file
177
packages/api/src/read_state/ReadStateRepository.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {channelIdToMessageId} from '@fluxer/api/src/BrandedTypes';
|
||||
import {
|
||||
BatchBuilder,
|
||||
Db,
|
||||
defineTable,
|
||||
deleteOneOrMany,
|
||||
fetchMany,
|
||||
fetchOne,
|
||||
upsertOne,
|
||||
} from '@fluxer/api/src/database/Cassandra';
|
||||
import type {ReadStateRow} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import {READ_STATE_COLUMNS} from '@fluxer/api/src/database/types/ChannelTypes';
|
||||
import {ReadState} from '@fluxer/api/src/models/ReadState';
|
||||
import type {IReadStateRepository} from '@fluxer/api/src/read_state/IReadStateRepository';
|
||||
|
||||
const ReadStates = defineTable<ReadStateRow, 'user_id' | 'channel_id'>({
|
||||
name: 'read_states',
|
||||
columns: READ_STATE_COLUMNS,
|
||||
primaryKey: ['user_id', 'channel_id'],
|
||||
});
|
||||
|
||||
const FETCH_READ_STATES_CQL = ReadStates.selectCql({
|
||||
where: ReadStates.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL = ReadStates.selectCql({
|
||||
where: [ReadStates.where.eq('user_id'), ReadStates.where.eq('channel_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class ReadStateRepository implements IReadStateRepository {
|
||||
async listReadStates(userId: UserID): Promise<Array<ReadState>> {
|
||||
const rows = await fetchMany<ReadStateRow>(FETCH_READ_STATES_CQL, {user_id: userId});
|
||||
return rows.map((row) => new ReadState(row));
|
||||
}
|
||||
|
||||
async upsertReadState(
|
||||
userId: UserID,
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
mentionCount = 0,
|
||||
lastPinTimestamp?: Date,
|
||||
): Promise<ReadState> {
|
||||
const patch: Record<string, ReturnType<typeof Db.set>> = {
|
||||
message_id: Db.set(messageId),
|
||||
mention_count: Db.set(mentionCount),
|
||||
};
|
||||
if (lastPinTimestamp !== undefined) {
|
||||
patch['last_pin_timestamp'] = Db.set(lastPinTimestamp);
|
||||
}
|
||||
await upsertOne(ReadStates.patchByPk({user_id: userId, channel_id: channelId}, patch));
|
||||
return new ReadState({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
mention_count: mentionCount,
|
||||
last_pin_timestamp: lastPinTimestamp ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async incrementReadStateMentions(userId: UserID, channelId: ChannelID, incrementBy = 1): Promise<ReadState | null> {
|
||||
const currentReadState = await fetchOne<ReadStateRow>(FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL, {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
if (!currentReadState) {
|
||||
return this.upsertReadState(userId, channelId, channelIdToMessageId(channelId), incrementBy);
|
||||
}
|
||||
const newMentionCount = (currentReadState.mention_count || 0) + incrementBy;
|
||||
const updatedReadState: ReadStateRow = {...currentReadState, mention_count: newMentionCount};
|
||||
await upsertOne(ReadStates.upsertAll(updatedReadState));
|
||||
return new ReadState(updatedReadState);
|
||||
}
|
||||
|
||||
async bulkIncrementMentionCounts(updates: Array<{userId: UserID; channelId: ChannelID}>): Promise<void> {
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingStates = await Promise.all(
|
||||
updates.map(({userId, channelId}) =>
|
||||
fetchOne<ReadStateRow>(FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL, {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}).then((state) => ({userId, channelId, state})),
|
||||
),
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const {userId, channelId, state} of existingStates) {
|
||||
if (state) {
|
||||
batch.addPrepared(
|
||||
ReadStates.patchByPk(
|
||||
{user_id: userId, channel_id: channelId},
|
||||
{mention_count: Db.set((state.mention_count || 0) + 1)},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
batch.addPrepared(
|
||||
ReadStates.upsertAll({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
message_id: channelIdToMessageId(channelId),
|
||||
mention_count: 1,
|
||||
last_pin_timestamp: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteReadState(userId: UserID, channelId: ChannelID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
ReadStates.deleteByPk({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async bulkAckMessages(
|
||||
userId: UserID,
|
||||
readStates: Array<{channelId: ChannelID; messageId: MessageID}>,
|
||||
): Promise<Array<ReadState>> {
|
||||
const batch = new BatchBuilder();
|
||||
const results: Array<ReadState> = [];
|
||||
for (const readState of readStates) {
|
||||
batch.addPrepared(
|
||||
ReadStates.patchByPk(
|
||||
{user_id: userId, channel_id: readState.channelId},
|
||||
{
|
||||
message_id: Db.set(readState.messageId),
|
||||
mention_count: Db.set(0),
|
||||
},
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
new ReadState({
|
||||
user_id: userId,
|
||||
channel_id: readState.channelId,
|
||||
message_id: readState.messageId,
|
||||
mention_count: 0,
|
||||
last_pin_timestamp: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
return results;
|
||||
}
|
||||
|
||||
async upsertPinAck(userId: UserID, channelId: ChannelID, lastPinTimestamp: Date): Promise<void> {
|
||||
await upsertOne(
|
||||
ReadStates.patchByPk({user_id: userId, channel_id: channelId}, {last_pin_timestamp: Db.set(lastPinTimestamp)}),
|
||||
);
|
||||
}
|
||||
}
|
||||
42
packages/api/src/read_state/ReadStateRequestService.tsx
Normal file
42
packages/api/src/read_state/ReadStateRequestService.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {ReadStateService} from '@fluxer/api/src/read_state/ReadStateService';
|
||||
import type {ReadStateAckBulkRequest} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
|
||||
interface ReadStateAckBulkParams {
|
||||
userId: UserID;
|
||||
data: ReadStateAckBulkRequest;
|
||||
}
|
||||
|
||||
export class ReadStateRequestService {
|
||||
constructor(private readStateService: ReadStateService) {}
|
||||
|
||||
async bulkAckMessages({userId, data}: ReadStateAckBulkParams): Promise<void> {
|
||||
await this.readStateService.bulkAckMessages({
|
||||
userId,
|
||||
readStates: data.read_states.map((readState) => ({
|
||||
channelId: createChannelID(readState.channel_id),
|
||||
messageId: createMessageID(readState.message_id),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
156
packages/api/src/read_state/ReadStateService.tsx
Normal file
156
packages/api/src/read_state/ReadStateService.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
|
||||
import {Logger} from '@fluxer/api/src/Logger';
|
||||
import type {ReadState} from '@fluxer/api/src/models/ReadState';
|
||||
import type {IReadStateRepository} from '@fluxer/api/src/read_state/IReadStateRepository';
|
||||
|
||||
export class ReadStateService {
|
||||
constructor(
|
||||
private repository: IReadStateRepository,
|
||||
private gatewayService: IGatewayService,
|
||||
) {}
|
||||
|
||||
async getReadStates(userId: UserID): Promise<Array<ReadState>> {
|
||||
return await this.repository.listReadStates(userId);
|
||||
}
|
||||
|
||||
async ackMessage(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
mentionCount: number;
|
||||
manual?: boolean;
|
||||
silent?: boolean;
|
||||
}): Promise<void> {
|
||||
const {userId, channelId, messageId, mentionCount, manual, silent} = params;
|
||||
await this.repository.upsertReadState(userId, channelId, messageId, mentionCount);
|
||||
await this.gatewayService.invalidatePushBadgeCount({userId});
|
||||
|
||||
if (!silent) {
|
||||
await this.dispatchMessageAck({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
mentionCount,
|
||||
manual,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async bulkAckMessages({
|
||||
userId,
|
||||
readStates,
|
||||
}: {
|
||||
userId: UserID;
|
||||
readStates: Array<{channelId: ChannelID; messageId: MessageID}>;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await Promise.all(
|
||||
readStates.map((readState) =>
|
||||
this.ackMessage({...readState, userId, mentionCount: 0, silent: true}).catch((error) => {
|
||||
Logger.error(
|
||||
{userId: userId.toString(), channelId: readState.channelId.toString(), error},
|
||||
'Failed to ack message',
|
||||
);
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.gatewayService.invalidatePushBadgeCount({userId});
|
||||
} catch (error) {
|
||||
Logger.error({userId: userId.toString(), error}, 'Bulk ack messages failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteReadState({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
await this.repository.deleteReadState(userId, channelId);
|
||||
await this.gatewayService.invalidatePushBadgeCount({userId});
|
||||
}
|
||||
|
||||
async incrementMentionCount({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<void> {
|
||||
await this.repository.incrementReadStateMentions(userId, channelId, 1);
|
||||
await this.gatewayService.invalidatePushBadgeCount({userId});
|
||||
}
|
||||
|
||||
async bulkIncrementMentionCounts(updates: Array<{userId: UserID; channelId: ChannelID}>): Promise<void> {
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.repository.bulkIncrementMentionCounts(updates);
|
||||
const uniqueUserIds = Array.from(new Set(updates.map((update) => update.userId)));
|
||||
|
||||
await Promise.all(
|
||||
uniqueUserIds.map((userId) =>
|
||||
this.gatewayService.invalidatePushBadgeCount({userId}).catch((error) => {
|
||||
Logger.error({userId: userId.toString(), error}, 'Failed to invalidate push badge count');
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error({error}, 'Bulk increment mention counts failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async ackPins(params: {userId: UserID; channelId: ChannelID; timestamp: Date}): Promise<void> {
|
||||
const {userId, channelId, timestamp} = params;
|
||||
await this.repository.upsertPinAck(userId, channelId, timestamp);
|
||||
await this.dispatchPinsAck({userId, channelId, timestamp});
|
||||
}
|
||||
|
||||
private async dispatchMessageAck(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
mentionCount: number;
|
||||
manual?: boolean;
|
||||
}): Promise<void> {
|
||||
const {userId, channelId, messageId, mentionCount, manual} = params;
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'MESSAGE_ACK',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
message_id: messageId.toString(),
|
||||
mention_count: mentionCount,
|
||||
manual,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchPinsAck(params: {userId: UserID; channelId: ChannelID; timestamp: Date}): Promise<void> {
|
||||
const {userId, channelId, timestamp} = params;
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId,
|
||||
event: 'CHANNEL_PINS_ACK',
|
||||
data: {
|
||||
channel_id: channelId.toString(),
|
||||
timestamp: timestamp.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user