initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View 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 '~/BrandedTypes';
import type {ReadState} from '~/Models';
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>;
}

View File

@@ -0,0 +1,56 @@
/*
* 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 {Hono} from 'hono';
import {z} from 'zod';
import type {HonoEnv} from '~/App';
import {createChannelID, createMessageID} from '~/BrandedTypes';
import {DefaultUserOnly, LoginRequired} from '~/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware';
import {RateLimitConfigs} from '~/RateLimitConfig';
import {Int64Type} from '~/Schema';
import {Validator} from '~/Validator';
export function ReadStateController(app: Hono<HonoEnv>): void {
app.post(
'/read-states/ack-bulk',
RateLimitMiddleware(RateLimitConfigs.READ_STATE_ACK_BULK),
LoginRequired,
DefaultUserOnly,
Validator(
'json',
z.object({
read_states: z
.array(z.object({channel_id: Int64Type, message_id: Int64Type}))
.min(1)
.max(100),
}),
),
async (ctx) => {
await ctx.get('readStateService').bulkAckMessages({
userId: ctx.get('user').id,
readStates: ctx.req.valid('json').read_states.map((rs) => ({
channelId: createChannelID(rs.channel_id),
messageId: createMessageID(rs.message_id),
})),
});
return ctx.body(null, 204);
},
);
}

View File

@@ -0,0 +1,169 @@
/*
* 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 '~/BrandedTypes';
import {channelIdToMessageId} from '~/BrandedTypes';
import {BatchBuilder, Db, defineTable, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import {READ_STATE_COLUMNS, type ReadStateRow} from '~/database/CassandraTypes';
import type {ReadState} from '~/Models';
import {ReadState as ReadStateModel} from '~/Models';
import type {IReadStateRepository} from './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 ReadStateModel(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 ReadStateModel({
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 ReadStateModel(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 ReadStateModel({
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)}),
);
}
}

View File

@@ -0,0 +1,125 @@
/*
* 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 '~/BrandedTypes';
import type {IGatewayService} from '~/infrastructure/IGatewayService';
import type {ReadState} from '~/Models';
import type {IReadStateRepository} from './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> {
await Promise.all(readStates.map((readState) => this.ackMessage({...readState, userId, mentionCount: 0})));
await this.gatewayService.invalidatePushBadgeCount({userId});
}
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;
}
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})));
}
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(),
},
});
}
}