mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(push): stop pushes for read, silent and muted messages (#3033)
This commit is contained in:
@@ -243,7 +243,7 @@ export class CallService {
|
||||
channelId,
|
||||
messageId,
|
||||
mentionCount: 0,
|
||||
silent: true,
|
||||
implicit: {unreadThrough: channel.lastMessageId},
|
||||
emitGateway: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ export class MessagePersistenceService {
|
||||
channelId: params.channelId,
|
||||
messageId: params.messageId,
|
||||
mentionCount: 0,
|
||||
silent: true,
|
||||
implicit: {unreadThrough: params.user ? (params.channel?.lastMessageId ?? null) : null},
|
||||
emitGateway: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelID, GuildID, MessageID, RoleID, UserID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createRoleID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {createChannelID, createGuildID, createRoleID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {GatewayDispatchEvent} from '@app/api/constants/Gateway';
|
||||
import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient';
|
||||
@@ -710,11 +710,29 @@ export class GatewayService {
|
||||
guildId: GuildID;
|
||||
settings: unknown;
|
||||
}): Promise<void> {
|
||||
await this.call('push.sync_user_guild_settings', {
|
||||
user_id: userId.toString(),
|
||||
guild_id: guildId.toString(),
|
||||
user_guild_settings: settings,
|
||||
});
|
||||
try {
|
||||
await this.rpcClient.call('push.sync_user_guild_settings', {
|
||||
user_id: userId.toString(),
|
||||
guild_id: guildId.toString(),
|
||||
user_guild_settings: settings,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.isPrivateScopeSyncUnsupportedError(guildId, error)) {
|
||||
throw this.transformGatewayError(error);
|
||||
}
|
||||
Logger.warn(
|
||||
{userId: userId.toString()},
|
||||
'[gateway-rpc] push.sync_user_guild_settings rejected the private scope, gateway predates it',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isPrivateScopeSyncUnsupportedError(guildId: GuildID, error: unknown): boolean {
|
||||
return (
|
||||
guildId === createGuildID(0n) &&
|
||||
error instanceof GatewayRpcMethodError &&
|
||||
error.code === GatewayRpcMethodErrorCodes.INVALID_PARAMS
|
||||
);
|
||||
}
|
||||
|
||||
async getGuildCounts(guildId: GuildID): Promise<{
|
||||
|
||||
@@ -3,9 +3,16 @@
|
||||
import type {ChannelID, MessageID, UserID} from '@app/api/BrandedTypes';
|
||||
import type {ReadState} from '@app/api/models/ReadState';
|
||||
|
||||
export interface ReadStateUpsert {
|
||||
readState: ReadState;
|
||||
previous: ReadState | null;
|
||||
}
|
||||
|
||||
export abstract class IReadStateRepository {
|
||||
abstract listReadStates(userId: UserID): Promise<Array<ReadState>>;
|
||||
|
||||
abstract getReadState(userId: UserID, channelId: ChannelID): Promise<ReadState | null>;
|
||||
|
||||
abstract upsertReadState(
|
||||
userId: UserID,
|
||||
channelId: ChannelID,
|
||||
@@ -13,7 +20,7 @@ export abstract class IReadStateRepository {
|
||||
mentionCount?: number,
|
||||
lastPinTimestamp?: Date,
|
||||
manual?: boolean,
|
||||
): Promise<ReadState>;
|
||||
): Promise<ReadStateUpsert>;
|
||||
|
||||
abstract incrementReadStateMentions(
|
||||
userId: UserID,
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGuild,
|
||||
} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
interface AckResponse {
|
||||
read_states: Array<{
|
||||
id: string;
|
||||
last_message_id: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ReadSignals {
|
||||
clears: Array<{channelId: string; messageId: string}>;
|
||||
acks: Array<{channel_id: string; message_id: string}>;
|
||||
}
|
||||
|
||||
async function captureReadSignals(userId: string, action: () => Promise<void>): Promise<ReadSignals> {
|
||||
const clearSpy = vi.spyOn(NoopGatewayService.prototype, 'clearPushChannelNotifications');
|
||||
const presenceSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchPresence');
|
||||
try {
|
||||
await action();
|
||||
return {
|
||||
clears: clearSpy.mock.calls
|
||||
.filter(([params]) => params.userId.toString() === userId)
|
||||
.map(([params]) => ({channelId: params.channelId.toString(), messageId: params.messageId.toString()})),
|
||||
acks: presenceSpy.mock.calls
|
||||
.filter(([params]) => params.userId.toString() === userId && params.event === 'MESSAGE_ACK')
|
||||
.map(([params]) => {
|
||||
const data = params.data as {channel_id: string; message_id: string};
|
||||
return {channel_id: data.channel_id, message_id: data.message_id};
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
clearSpy.mockRestore();
|
||||
presenceSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
async function readLastMessageId(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
olderMessageId: string,
|
||||
): Promise<string | null | undefined> {
|
||||
const response = await createBuilder<AckResponse>(harness, token)
|
||||
.post('/read-states/ack')
|
||||
.body({read_states: [{channel_id: channelId, message_id: olderMessageId}]})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return response.read_states[0]?.last_message_id;
|
||||
}
|
||||
|
||||
async function ackMessage(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
body: {manual?: boolean; mention_count?: number} = {},
|
||||
): Promise<void> {
|
||||
await createBuilder(harness, token)
|
||||
.post(`/channels/${channelId}/messages/${messageId}/ack`)
|
||||
.body(body)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function ringCall(harness: ApiTestHarness, token: string, channelId: string): Promise<void> {
|
||||
await createBuilder(harness, token)
|
||||
.post(`/channels/${channelId}/call/ring`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function latestMessageId(harness: ApiTestHarness, token: string, channelId: string): Promise<string> {
|
||||
const messages = await createBuilder<Array<{id: string}>>(harness, token)
|
||||
.get(`/channels/${channelId}/messages?limit=1`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return messages[0]!.id;
|
||||
}
|
||||
|
||||
async function setupDm(harness: ApiTestHarness): Promise<{alice: TestAccount; bob: TestAccount; channelId: string}> {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, alice.token);
|
||||
await ensureSessionStarted(harness, bob.token);
|
||||
await createFriendship(harness, alice, bob);
|
||||
const dm = await createDmChannel(harness, alice.token, bob.userId);
|
||||
return {alice, bob, channelId: dm.id};
|
||||
}
|
||||
|
||||
describe('push clear on reply', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('replying in a DM clears the partner message push up to the reply without a MESSAGE_ACK', async () => {
|
||||
const {alice, bob, channelId} = await setupDm(harness);
|
||||
const fromBob = await sendMessage(harness, bob.token, channelId, 'ping from bob');
|
||||
let replyId = '';
|
||||
const signals = await captureReadSignals(alice.userId, async () => {
|
||||
const reply = await sendMessage(harness, alice.token, channelId, 'reply from alice');
|
||||
replyId = reply.id;
|
||||
});
|
||||
expect(BigInt(replyId) > BigInt(fromBob.id)).toBe(true);
|
||||
expect(signals.clears).toEqual([{channelId, messageId: replyId}]);
|
||||
expect(signals.acks).toEqual([]);
|
||||
expect(await readLastMessageId(harness, alice.token, channelId, fromBob.id)).toBe(replyId);
|
||||
});
|
||||
|
||||
it('a follow-up message with nothing new unread sends no push clear', async () => {
|
||||
const {alice, bob, channelId} = await setupDm(harness);
|
||||
await sendMessage(harness, bob.token, channelId, 'ping from bob');
|
||||
await sendMessage(harness, alice.token, channelId, 'reply from alice');
|
||||
const signals = await captureReadSignals(alice.userId, async () => {
|
||||
await sendMessage(harness, alice.token, channelId, 'second reply from alice');
|
||||
});
|
||||
expect(signals.clears).toEqual([]);
|
||||
expect(signals.acks).toEqual([]);
|
||||
});
|
||||
|
||||
it('the first message in a DM with nothing unread sends no push clear', async () => {
|
||||
const {alice, channelId} = await setupDm(harness);
|
||||
const signals = await captureReadSignals(alice.userId, async () => {
|
||||
await sendMessage(harness, alice.token, channelId, 'hello from alice');
|
||||
});
|
||||
expect(signals.clears).toEqual([]);
|
||||
});
|
||||
|
||||
it('the read state RPC reports the reply as the last read message', async () => {
|
||||
const {alice, bob, channelId} = await setupDm(harness);
|
||||
await sendMessage(harness, bob.token, channelId, 'ping from bob');
|
||||
const reply = await sendMessage(harness, alice.token, channelId, 'reply from alice');
|
||||
const readState = async (userId: string, channel: string) =>
|
||||
(
|
||||
await createBuilder<{type: 'get_read_state'; data: {last_message_id: string | null}}>(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({type: 'get_read_state', user_id: userId, channel_id: channel})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute()
|
||||
).data.last_message_id;
|
||||
expect(await readState(alice.userId, channelId)).toBe(reply.id);
|
||||
expect(await readState(alice.userId, '1')).toBeNull();
|
||||
});
|
||||
|
||||
it('an explicit ack in a DM sends exactly one push clear and one MESSAGE_ACK', async () => {
|
||||
const {alice, bob, channelId} = await setupDm(harness);
|
||||
const fromBob = await sendMessage(harness, bob.token, channelId, 'ping from bob');
|
||||
const signals = await captureReadSignals(alice.userId, async () => {
|
||||
await createBuilder(harness, alice.token)
|
||||
.post(`/channels/${channelId}/messages/${fromBob.id}/ack`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
expect(signals.clears).toEqual([{channelId, messageId: fromBob.id}]);
|
||||
expect(signals.acks).toEqual([{channel_id: channelId, message_id: fromBob.id}]);
|
||||
});
|
||||
|
||||
it('replying in a guild text channel clears the other member message push up to the reply without a MESSAGE_ACK', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, alice.token);
|
||||
await ensureSessionStarted(harness, bob.token);
|
||||
const guild = await createGuild(harness, alice.token, 'Push Clear Guild');
|
||||
const channel = await createChannel(harness, alice.token, guild.id, 'push-clear');
|
||||
const invite = await createChannelInvite(harness, alice.token, channel.id);
|
||||
await acceptInvite(harness, bob.token, invite.code);
|
||||
const fromBob = await sendMessage(harness, bob.token, channel.id, 'ping from bob');
|
||||
let replyId = '';
|
||||
const signals = await captureReadSignals(alice.userId, async () => {
|
||||
const reply = await sendMessage(harness, alice.token, channel.id, 'reply from alice');
|
||||
replyId = reply.id;
|
||||
});
|
||||
expect(BigInt(replyId) > BigInt(fromBob.id)).toBe(true);
|
||||
expect(signals.clears).toEqual([{channelId: channel.id, messageId: replyId}]);
|
||||
expect(signals.acks).toEqual([]);
|
||||
expect(await readLastMessageId(harness, alice.token, channel.id, fromBob.id)).toBe(replyId);
|
||||
});
|
||||
|
||||
it('starting a call clears the caller push for messages still unread up to the call message', async () => {
|
||||
const {alice, bob, channelId} = await setupDm(harness);
|
||||
const first = await sendMessage(harness, bob.token, channelId, 'first from bob');
|
||||
const second = await sendMessage(harness, bob.token, channelId, 'second from bob');
|
||||
await ackMessage(harness, alice.token, channelId, first.id);
|
||||
const signals = await captureReadSignals(alice.userId, async () => {
|
||||
await ringCall(harness, alice.token, channelId);
|
||||
});
|
||||
const callMessageId = await latestMessageId(harness, alice.token, channelId);
|
||||
expect(BigInt(callMessageId) > BigInt(second.id)).toBe(true);
|
||||
expect(signals.clears).toEqual([{channelId, messageId: callMessageId}]);
|
||||
expect(signals.acks).toEqual([]);
|
||||
});
|
||||
|
||||
it('starting a call with nothing unread sends the caller no push clear', async () => {
|
||||
const {alice, bob, channelId} = await setupDm(harness);
|
||||
const fromBob = await sendMessage(harness, bob.token, channelId, 'ping from bob');
|
||||
await ackMessage(harness, alice.token, channelId, fromBob.id);
|
||||
const signals = await captureReadSignals(alice.userId, async () => {
|
||||
await ringCall(harness, alice.token, channelId);
|
||||
});
|
||||
expect(signals.clears).toEqual([]);
|
||||
});
|
||||
|
||||
it('ending a call clears a participant push up to the call message only when it was unread', async () => {
|
||||
const {alice, bob, channelId} = await setupDm(harness);
|
||||
const fromBob = await sendMessage(harness, bob.token, channelId, 'ping from bob');
|
||||
await ringCall(harness, alice.token, channelId);
|
||||
const callMessageId = await latestMessageId(harness, alice.token, channelId);
|
||||
await ackMessage(harness, bob.token, channelId, fromBob.id, {manual: true, mention_count: 0});
|
||||
const endCall = async () => {
|
||||
await createBuilder(harness, '')
|
||||
.post('/test/rpc-session-init')
|
||||
.body({
|
||||
type: 'call_ended',
|
||||
channel_id: channelId,
|
||||
message_id: callMessageId,
|
||||
participants: [alice.userId, bob.userId],
|
||||
ended_timestamp: Date.now(),
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
};
|
||||
const clearSpy = vi.spyOn(NoopGatewayService.prototype, 'clearPushChannelNotifications');
|
||||
try {
|
||||
await endCall();
|
||||
const clears = clearSpy.mock.calls.map(([params]) => ({
|
||||
userId: params.userId.toString(),
|
||||
channelId: params.channelId.toString(),
|
||||
messageId: params.messageId.toString(),
|
||||
}));
|
||||
expect(clears).toEqual([{userId: bob.userId, channelId, messageId: callMessageId}]);
|
||||
} finally {
|
||||
clearSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('joining a guild with an active system channel sends the joiner no push clear', async () => {
|
||||
const alice = await createTestAccount(harness);
|
||||
const bob = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, alice.token);
|
||||
await ensureSessionStarted(harness, bob.token);
|
||||
const guild = await createGuild(harness, alice.token, 'Push Clear Join Guild');
|
||||
const systemChannelId = guild.system_channel_id!;
|
||||
await sendMessage(harness, alice.token, systemChannelId, 'welcome');
|
||||
const invite = await createChannelInvite(harness, alice.token, systemChannelId);
|
||||
const signals = await captureReadSignals(bob.userId, async () => {
|
||||
await acceptInvite(harness, bob.token, invite.code);
|
||||
});
|
||||
expect(signals.clears).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ describe('ReadStateRepository row storage', () => {
|
||||
const channelId = createChannelID(10n);
|
||||
await seedReadState(userId, channelId, 100n, 3);
|
||||
const repository = new ReadStateRepository();
|
||||
const readState = await repository.upsertReadState(userId, channelId, createMessageID(90n), 0);
|
||||
const {readState} = await repository.upsertReadState(userId, channelId, createMessageID(90n), 0);
|
||||
expect(readState.lastMessageId).toBe(createMessageID(100n));
|
||||
expect(await loadReadState(userId, channelId)).toMatchObject({
|
||||
message_id: createMessageID(100n),
|
||||
@@ -77,7 +77,7 @@ describe('ReadStateRepository row storage', () => {
|
||||
const channelId = createChannelID(10n);
|
||||
await seedReadState(userId, channelId, 100n, 0);
|
||||
const repository = new ReadStateRepository();
|
||||
const readState = await repository.upsertReadState(userId, channelId, createMessageID(90n), 2, undefined, true);
|
||||
const {readState} = await repository.upsertReadState(userId, channelId, createMessageID(90n), 2, undefined, true);
|
||||
expect(readState.lastMessageId).toBe(createMessageID(90n));
|
||||
expect(readState.mentionCount).toBe(2);
|
||||
expect(await loadReadState(userId, channelId)).toMatchObject({
|
||||
|
||||
@@ -14,7 +14,7 @@ import {Db, type DbOp} from '@app/api/database/CassandraTypes';
|
||||
import type {ReadStateRow} from '@app/api/database/types/ChannelTypes';
|
||||
import {READ_STATE_COLUMNS} from '@app/api/database/types/ChannelTypes';
|
||||
import {ReadState} from '@app/api/models/ReadState';
|
||||
import type {IReadStateRepository} from '@app/api/read_state/IReadStateRepository';
|
||||
import type {IReadStateRepository, ReadStateUpsert} from '@app/api/read_state/IReadStateRepository';
|
||||
|
||||
const ReadStates = defineTable<ReadStateRow, 'user_id' | 'channel_id'>({
|
||||
name: 'read_states',
|
||||
@@ -39,6 +39,14 @@ export class ReadStateRepository implements IReadStateRepository {
|
||||
return rows.map((row) => new ReadState(row));
|
||||
}
|
||||
|
||||
async getReadState(userId: UserID, channelId: ChannelID): Promise<ReadState | null> {
|
||||
const row = await fetchOne<ReadStateRow>(FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL, {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
return row ? new ReadState(row) : null;
|
||||
}
|
||||
|
||||
async upsertReadState(
|
||||
userId: UserID,
|
||||
channelId: ChannelID,
|
||||
@@ -46,7 +54,7 @@ export class ReadStateRepository implements IReadStateRepository {
|
||||
mentionCount = 0,
|
||||
lastPinTimestamp?: Date,
|
||||
manual = false,
|
||||
): Promise<ReadState> {
|
||||
): Promise<ReadStateUpsert> {
|
||||
return this.upsertReadStateRow(userId, channelId, messageId, mentionCount, lastPinTimestamp, manual);
|
||||
}
|
||||
|
||||
@@ -57,13 +65,14 @@ export class ReadStateRepository implements IReadStateRepository {
|
||||
mentionCount = 0,
|
||||
lastPinTimestamp?: Date,
|
||||
manual = false,
|
||||
): Promise<ReadState> {
|
||||
): Promise<ReadStateUpsert> {
|
||||
const currentReadState = await fetchOne<ReadStateRow>(FETCH_READ_STATE_BY_USER_AND_CHANNEL_CQL, {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
if (!manual && currentReadState?.message_id != null && currentReadState.message_id > messageId) {
|
||||
return new ReadState(currentReadState);
|
||||
const previous = currentReadState ? new ReadState(currentReadState) : null;
|
||||
if (!manual && previous?.lastMessageId != null && previous.lastMessageId > messageId) {
|
||||
return {readState: previous, previous};
|
||||
}
|
||||
const patch: Record<string, DbOp<unknown>> = {
|
||||
message_id: Db.set(messageId),
|
||||
@@ -73,13 +82,14 @@ export class ReadStateRepository implements IReadStateRepository {
|
||||
patch['last_pin_timestamp'] = Db.set(lastPinTimestamp);
|
||||
}
|
||||
await upsertOne(ReadStates.patchByPk({user_id: userId, channel_id: channelId}, patch));
|
||||
return new ReadState({
|
||||
const readState = new ReadState({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
mention_count: mentionCount,
|
||||
last_pin_timestamp: lastPinTimestamp ?? currentReadState?.last_pin_timestamp ?? null,
|
||||
});
|
||||
return {readState, previous};
|
||||
}
|
||||
|
||||
async incrementReadStateMentions(
|
||||
@@ -106,7 +116,7 @@ export class ReadStateRepository implements IReadStateRepository {
|
||||
if (baselineMessageId >= messageId) {
|
||||
return null;
|
||||
}
|
||||
return this.upsertReadStateRow(userId, channelId, baselineMessageId, incrementBy);
|
||||
return (await this.upsertReadStateRow(userId, channelId, baselineMessageId, incrementBy)).readState;
|
||||
}
|
||||
if (currentReadState.message_id != null && currentReadState.message_id >= messageId) {
|
||||
return null;
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
const repository = {
|
||||
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => {
|
||||
stored.push({channelId, messageId});
|
||||
return makeReadState(channelId, messageId);
|
||||
return {readState: makeReadState(channelId, messageId), previous: null};
|
||||
}),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
@@ -60,9 +60,10 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
|
||||
it('acknowledges the message when the MESSAGE_ACK dispatch fails', async () => {
|
||||
const repository = {
|
||||
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) =>
|
||||
makeReadState(channelId, messageId),
|
||||
),
|
||||
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => ({
|
||||
readState: makeReadState(channelId, messageId),
|
||||
previous: null,
|
||||
})),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
clearPushChannelNotifications: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -85,7 +86,7 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
const repository = {
|
||||
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => {
|
||||
stored.push(channelId.toString());
|
||||
return makeReadState(channelId, messageId, 1);
|
||||
return {readState: makeReadState(channelId, messageId, 1), previous: null};
|
||||
}),
|
||||
} as unknown as IReadStateRepository;
|
||||
const gatewayService = {
|
||||
@@ -125,3 +126,103 @@ describe('ReadStateService gateway side effects after the write', () => {
|
||||
expect(readStates).toBe(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReadStateService implicit acknowledgements', () => {
|
||||
async function implicitAck(params: {
|
||||
previous: ReadState | null;
|
||||
messageId: MessageID;
|
||||
unreadThrough: MessageID | null;
|
||||
}): Promise<Array<MessageID>> {
|
||||
const repository = {
|
||||
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => ({
|
||||
readState: makeReadState(channelId, messageId),
|
||||
previous: params.previous,
|
||||
})),
|
||||
} as unknown as IReadStateRepository;
|
||||
const clearPushChannelNotifications = vi.fn().mockResolvedValue(undefined);
|
||||
const gatewayService = {
|
||||
clearPushChannelNotifications,
|
||||
dispatchPresence: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as IGatewayService;
|
||||
await new ReadStateService(repository, gatewayService).ackMessage({
|
||||
userId: USER_ID,
|
||||
channelId: CHANNEL_ID,
|
||||
messageId: params.messageId,
|
||||
mentionCount: 0,
|
||||
implicit: {unreadThrough: params.unreadThrough},
|
||||
emitGateway: false,
|
||||
});
|
||||
return clearPushChannelNotifications.mock.calls.map(([call]) => call.messageId);
|
||||
}
|
||||
|
||||
it('clears when the previous read state lagged the latest earlier message', async () => {
|
||||
const cleared = await implicitAck({
|
||||
previous: makeReadState(CHANNEL_ID, createMessageID(30n)),
|
||||
messageId: createMessageID(40n),
|
||||
unreadThrough: createMessageID(35n),
|
||||
});
|
||||
expect(cleared).toEqual([createMessageID(40n)]);
|
||||
});
|
||||
|
||||
it('does not clear when the previous read state already covered the latest earlier message', async () => {
|
||||
const cleared = await implicitAck({
|
||||
previous: makeReadState(CHANNEL_ID, createMessageID(35n)),
|
||||
messageId: createMessageID(40n),
|
||||
unreadThrough: createMessageID(35n),
|
||||
});
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears when unread mentions remained', async () => {
|
||||
const cleared = await implicitAck({
|
||||
previous: makeReadState(CHANNEL_ID, createMessageID(35n), 2),
|
||||
messageId: createMessageID(40n),
|
||||
unreadThrough: createMessageID(35n),
|
||||
});
|
||||
expect(cleared).toEqual([createMessageID(40n)]);
|
||||
});
|
||||
|
||||
it('does not clear when the read state was already past the acknowledged message', async () => {
|
||||
const cleared = await implicitAck({
|
||||
previous: makeReadState(CHANNEL_ID, createMessageID(50n), 2),
|
||||
messageId: createMessageID(40n),
|
||||
unreadThrough: createMessageID(40n),
|
||||
});
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
|
||||
it('clears when there was no read state and the channel had earlier messages', async () => {
|
||||
const cleared = await implicitAck({
|
||||
previous: null,
|
||||
messageId: createMessageID(40n),
|
||||
unreadThrough: createMessageID(35n),
|
||||
});
|
||||
expect(cleared).toEqual([createMessageID(40n)]);
|
||||
});
|
||||
|
||||
it('does not clear when there was no read state and no earlier message', async () => {
|
||||
const cleared = await implicitAck({previous: null, messageId: createMessageID(40n), unreadThrough: null});
|
||||
expect(cleared).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not wait for the push clear to finish', async () => {
|
||||
const repository = {
|
||||
upsertReadState: vi.fn(async (_userId: UserID, channelId: ChannelID, messageId: MessageID) => ({
|
||||
readState: makeReadState(channelId, messageId),
|
||||
previous: makeReadState(channelId, createMessageID(30n), 1),
|
||||
})),
|
||||
} as unknown as IReadStateRepository;
|
||||
const clearPushChannelNotifications = vi.fn(() => new Promise<void>(() => {}));
|
||||
const gatewayService = {clearPushChannelNotifications} as unknown as IGatewayService;
|
||||
const readState = await new ReadStateService(repository, gatewayService).ackMessage({
|
||||
userId: USER_ID,
|
||||
channelId: CHANNEL_ID,
|
||||
messageId: createMessageID(40n),
|
||||
mentionCount: 0,
|
||||
implicit: {unreadThrough: createMessageID(35n)},
|
||||
emitGateway: false,
|
||||
});
|
||||
expect(readState.lastMessageId).toBe(createMessageID(40n));
|
||||
expect(clearPushChannelNotifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,17 @@ import {Logger} from '@app/api/Logger';
|
||||
import type {ReadState} from '@app/api/models/ReadState';
|
||||
import type {IReadStateRepository} from '@app/api/read_state/IReadStateRepository';
|
||||
|
||||
function hadUnreadThrough(previous: ReadState | null, messageId: MessageID, unreadThrough: MessageID | null): boolean {
|
||||
const previousMessageId = previous?.lastMessageId ?? null;
|
||||
if (previousMessageId !== null && previousMessageId >= messageId) {
|
||||
return false;
|
||||
}
|
||||
if (previous !== null && previous.mentionCount > 0) {
|
||||
return true;
|
||||
}
|
||||
return unreadThrough !== null && (previousMessageId === null || previousMessageId < unreadThrough);
|
||||
}
|
||||
|
||||
export class ReadStateService {
|
||||
constructor(
|
||||
private repository: IReadStateRepository,
|
||||
@@ -16,17 +27,21 @@ export class ReadStateService {
|
||||
return await this.repository.listReadStates(userId);
|
||||
}
|
||||
|
||||
async getReadState(userId: UserID, channelId: ChannelID): Promise<ReadState | null> {
|
||||
return await this.repository.getReadState(userId, channelId);
|
||||
}
|
||||
|
||||
async ackMessage(params: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
messageId: MessageID;
|
||||
mentionCount: number;
|
||||
manual?: boolean;
|
||||
silent?: boolean;
|
||||
implicit?: {unreadThrough: MessageID | null};
|
||||
emitGateway?: boolean;
|
||||
}): Promise<ReadState> {
|
||||
const {userId, channelId, messageId, mentionCount, manual, silent, emitGateway = true} = params;
|
||||
const readState = await this.repository.upsertReadState(
|
||||
const {userId, channelId, messageId, mentionCount, manual, implicit, emitGateway = true} = params;
|
||||
const {readState, previous} = await this.repository.upsertReadState(
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
@@ -34,8 +49,10 @@ export class ReadStateService {
|
||||
undefined,
|
||||
manual ?? false,
|
||||
);
|
||||
if (!silent) {
|
||||
if (!implicit) {
|
||||
await this.clearPushChannelNotifications({userId, channelId, messageId});
|
||||
} else if (hadUnreadThrough(previous, messageId, implicit.unreadThrough)) {
|
||||
void this.clearPushChannelNotifications({userId, channelId, messageId});
|
||||
}
|
||||
if (emitGateway) {
|
||||
await this.dispatchMessageAck({
|
||||
|
||||
@@ -629,6 +629,16 @@ export class RpcService {
|
||||
data: {channel},
|
||||
};
|
||||
}
|
||||
case 'get_read_state': {
|
||||
const readState = await this.readStateService.getReadState(
|
||||
createUserID(request.user_id),
|
||||
createChannelID(request.channel_id),
|
||||
);
|
||||
return {
|
||||
type: 'get_read_state',
|
||||
data: {last_message_id: readState?.lastMessageId?.toString() ?? null},
|
||||
};
|
||||
}
|
||||
case 'get_gateway_rollout_config': {
|
||||
const rolloutConfig = await this.instanceConfigRepository.getGatewayRolloutConfig();
|
||||
return {
|
||||
@@ -1978,7 +1988,7 @@ export class RpcService {
|
||||
channelId,
|
||||
messageId: createMessageID(messageId),
|
||||
mentionCount: 0,
|
||||
silent: true,
|
||||
implicit: {unreadThrough: createMessageID(messageId)},
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error(
|
||||
|
||||
@@ -54,13 +54,11 @@ export class UserAccountUpdatePropagator extends BaseUserUpdatePropagator {
|
||||
event: 'USER_GUILD_SETTINGS_UPDATE',
|
||||
data: payload,
|
||||
});
|
||||
if (payload.guild_id !== null) {
|
||||
await this.deps.gatewayService.syncPushUserGuildSettings({
|
||||
userId,
|
||||
guildId: settings.guildId,
|
||||
settings: payload,
|
||||
});
|
||||
}
|
||||
await this.deps.gatewayService.syncPushUserGuildSettings({
|
||||
userId,
|
||||
guildId: settings.guildId,
|
||||
settings: payload,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchUserNoteUpdate(params: {userId: UserID; targetId: UserID; note: string}): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {GatewayRpcClient} from '@app/api/infrastructure/GatewayRpcClient';
|
||||
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError';
|
||||
import {GatewayService} from '@app/api/infrastructure/GatewayService';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {updateGuildSettings} from '@app/api/user/tests/UserTestUtils';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const DM_CHANNEL_ID = '1500000000000000005';
|
||||
const MUTE_DM = {
|
||||
channel_overrides: {
|
||||
[DM_CHANNEL_ID]: {collapsed: false, message_notifications: 3, muted: true, mute_config: null},
|
||||
},
|
||||
};
|
||||
|
||||
function gatewayRejectingSyncWith(code: string): GatewayService {
|
||||
GatewayRpcClient.createForTests({
|
||||
async call(): Promise<unknown> {
|
||||
throw new GatewayRpcMethodError(code);
|
||||
},
|
||||
async destroy(): Promise<void> {},
|
||||
});
|
||||
return new GatewayService();
|
||||
}
|
||||
|
||||
function routeSyncThrough(gateway: GatewayService): void {
|
||||
vi.spyOn(NoopGatewayService.prototype, 'syncPushUserGuildSettings').mockImplementation((params) =>
|
||||
gateway.syncPushUserGuildSettings(params),
|
||||
);
|
||||
}
|
||||
|
||||
describe('Private channel settings push sync', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await GatewayRpcClient.resetForTests();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('syncs a muted DM to the push cache under the private scope', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const syncPush = vi.spyOn(NoopGatewayService.prototype, 'syncPushUserGuildSettings');
|
||||
|
||||
await updateGuildSettings(harness, account.token, MUTE_DM);
|
||||
|
||||
expect(syncPush).toHaveBeenCalledTimes(1);
|
||||
const [params] = syncPush.mock.calls[0]!;
|
||||
expect(params.userId.toString()).toBe(account.userId);
|
||||
expect(params.guildId.toString()).toBe('0');
|
||||
expect(params.settings).toMatchObject({
|
||||
guild_id: null,
|
||||
channel_overrides: {[DM_CHANNEL_ID]: {muted: true}},
|
||||
});
|
||||
});
|
||||
|
||||
it('still mutes a DM when the gateway predates private scope sync', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
routeSyncThrough(gatewayRejectingSyncWith(GatewayRpcMethodErrorCodes.INVALID_PARAMS));
|
||||
|
||||
const {json} = await updateGuildSettings(harness, account.token, MUTE_DM);
|
||||
|
||||
expect(json).toMatchObject({channel_overrides: {[DM_CHANNEL_ID]: {muted: true}}});
|
||||
});
|
||||
|
||||
it('fails the DM settings update on any other gateway sync error', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
routeSyncThrough(gatewayRejectingSyncWith(GatewayRpcMethodErrorCodes.INTERNAL_ERROR));
|
||||
|
||||
await createBuilder(harness, account.token)
|
||||
.patch('/users/@me/guilds/@me/settings')
|
||||
.body(MUTE_DM)
|
||||
.expect(502)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('keeps rejecting invalid_params for a real guild', async () => {
|
||||
const gateway = gatewayRejectingSyncWith(GatewayRpcMethodErrorCodes.INVALID_PARAMS);
|
||||
|
||||
await expect(
|
||||
gateway.syncPushUserGuildSettings({userId: createUserID(2n), guildId: createGuildID(1n), settings: {}}),
|
||||
).rejects.toBeInstanceOf(BadRequestError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user