fix: eliminate phantom notifications across the entire read-state pipeline
Root cause: own messages echoed by the server marked channels unread when the user had already navigated away. Seven related bugs compounded the problem — stale read states, missing cleanup on space/DM removal, REST broadcast ignoring VIEW_CHANNEL, and no validation on channel_ack writes. Frontend: - Skip markChannelUnread for the user's own messages (federation-aware) - Walk backward past temp_ IDs in ackChannel instead of bailing - Re-fire ack timer when temp message is replaced by server-confirmed ID - Add removeChannelStates to clean up unread/read/message caches - Clean up chatStore on removeSpace, removeDmChannel, removeInstanceSpaces Server: - Use sendToChannel instead of sendToSpace for REST message creation - Clean up read_states on space deletion, member kick/leave, and ban - Validate channel membership before accepting channel_ack writes - Clean up read_states on DM leave and DM channel deletion
This commit is contained in:
@@ -695,6 +695,12 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
))
|
||||
.run();
|
||||
|
||||
// Clean up read_states for the departing user
|
||||
db.delete(schema.readStates).where(and(
|
||||
eq(schema.readStates.userId, request.userId),
|
||||
eq(schema.readStates.channelId, id),
|
||||
)).run();
|
||||
|
||||
// Check remaining members
|
||||
const remainingMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
@@ -743,6 +749,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up all read_states for this DM channel (all members' rows)
|
||||
db.delete(schema.readStates).where(eq(schema.readStates.channelId, id)).run();
|
||||
|
||||
// Delete the DM channel (cascades to dm_messages)
|
||||
db.delete(schema.dmChannels).where(eq(schema.dmChannels.id, id)).run();
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
const messageWithUser = buildMessageWithUser(message, user, attachmentRows, [], replyTo);
|
||||
|
||||
// Broadcast via WebSocket
|
||||
connectionManager.sendToSpace(spaceId, {
|
||||
connectionManager.sendToChannel(spaceId, id, {
|
||||
type: 'message_created',
|
||||
message: messageWithUser,
|
||||
});
|
||||
|
||||
@@ -508,8 +508,12 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
const spaceIcon = server.icon;
|
||||
const spaceBanner = server.banner;
|
||||
|
||||
// Delete all channels (messages cascade), members, folder refs, then space atomically
|
||||
// Delete all channels (messages cascade), members, folder refs, read states, then space atomically
|
||||
db.transaction((tx) => {
|
||||
// Clean up read_states for all channels in this space (no FK cascade — channelId is plain text)
|
||||
if (channelIds.length > 0) {
|
||||
tx.delete(schema.readStates).where(inArray(schema.readStates.channelId, channelIds)).run();
|
||||
}
|
||||
tx.delete(schema.channels).where(eq(schema.channels.spaceId, id)).run();
|
||||
tx.delete(schema.spaceMembers).where(eq(schema.spaceMembers.spaceId, id)).run();
|
||||
tx.delete(schema.spaceFolderMembers).where(eq(schema.spaceFolderMembers.spaceId, id)).run();
|
||||
@@ -941,6 +945,16 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
)
|
||||
).run();
|
||||
|
||||
// Clean up read_states for the departing user in this space's channels
|
||||
const spaceChannelIds = db.select({ id: schema.channels.id })
|
||||
.from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id);
|
||||
if (spaceChannelIds.length > 0) {
|
||||
db.delete(schema.readStates).where(and(
|
||||
eq(schema.readStates.userId, uid),
|
||||
inArray(schema.readStates.channelId, spaceChannelIds),
|
||||
)).run();
|
||||
}
|
||||
|
||||
// Broadcast member_left event
|
||||
connectionManager.sendToSpace(id, {
|
||||
type: 'member_left',
|
||||
@@ -1269,6 +1283,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Fetch channel IDs before the transaction for read_states cleanup
|
||||
const banChannelIds = db.select({ id: schema.channels.id })
|
||||
.from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id);
|
||||
|
||||
db.transaction((tx) => {
|
||||
// Insert ban record
|
||||
tx.insert(schema.bans).values({
|
||||
@@ -1291,6 +1309,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
||||
eq(schema.memberRoles.userId, targetId),
|
||||
)).run();
|
||||
|
||||
// Clean up read_states for the banned user in this space's channels
|
||||
if (banChannelIds.length > 0) {
|
||||
tx.delete(schema.readStates).where(and(
|
||||
eq(schema.readStates.userId, targetId),
|
||||
inArray(schema.readStates.channelId, banChannelIds),
|
||||
)).run();
|
||||
}
|
||||
|
||||
// Clean up any voice restrictions for the banned member
|
||||
tx.delete(schema.voiceRestrictions).where(and(
|
||||
eq(schema.voiceRestrictions.spaceId, id),
|
||||
|
||||
@@ -1040,6 +1040,14 @@ function handleChannelAck(event: Record<string, unknown>, userId: string): void
|
||||
// Validate messageId is a valid snowflake (numeric string) — reject temp/garbage IDs
|
||||
if (!/^\d+$/.test(messageId)) return;
|
||||
|
||||
// Validate channel membership — reject acks for channels the user doesn't belong to
|
||||
const spaceId = getChannelSpaceId(channelId);
|
||||
if (spaceId) {
|
||||
if (!isMember(spaceId, userId)) return;
|
||||
} else {
|
||||
if (!isDmMember(channelId, userId)) return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.select()
|
||||
|
||||
Reference in New Issue
Block a user