fix(push): stop pushes for read, silent and muted messages (#3033)

This commit is contained in:
Hampus
2026-09-29 15:58:46 +02:00
committed by GitHub
parent 9908518f5b
commit b30ea361d3
45 changed files with 3393 additions and 268 deletions
@@ -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({
+11 -1
View File
@@ -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);
});
});