fix(aim): keep away status across presence heartbeats and count unread IMs up to 99+
- commit
- cc5d0b414ef7fc1521616705c06b1a248e7f37a6
- author
- Kit · · 2026-09-29 05:59 UTC
- parent
- 1c1c842
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
2 files · +13 -5
M src/convex/aim.ts +10 -4
| @@ -18,6 +18,8 @@ const MAX_PROFILE_LENGTH = 1000; | |||
| 18 | 18 | const MAX_ROOM_NAME_LENGTH = 40; | |
| 19 | 19 | const MAX_TOPIC_LENGTH = 120; | |
| 20 | 20 | const DEFAULT_GROUP = 'Buddies'; | |
| 21 | + | // Unread badges stop counting here (shown as "99+") | |
| 22 | + | export const MAX_UNREAD_COUNT = 100; | |
| 21 | 23 | ||
| 22 | 24 | export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline'; | |
| 23 | 25 | ||
| @@ -119,15 +121,19 @@ export const getMyDirectRooms = authQuery({ | |||
| 119 | 121 | const otherId = (room.memberIds ?? []).find((id) => id !== me); | |
| 120 | 122 | const other = otherId ? await ctx.db.get(otherId) : null; | |
| 121 | 123 | const lastRead = readMap.get(room._id) ?? 0; | |
| 124 | + | // Walk newest-first until the read cursor, scanning at most MAX_UNREAD_COUNT | |
| 125 | + | // messages; a full scan still unread saturates the badge ("99+"). | |
| 122 | 126 | const recent = await ctx.db | |
| 123 | 127 | .query('messages') | |
| 124 | 128 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id)) | |
| 125 | 129 | .order('desc') | |
| 126 | - | .take(50); | |
| 130 | + | .take(MAX_UNREAD_COUNT); | |
| 127 | 131 | const lastMessage = recent[0] ?? null; | |
| 128 | - | const unreadCount = recent.filter( | |
| 129 | - | (m) => m.timestamp > lastRead && m.senderId !== me | |
| 130 | - | ).length; | |
| 132 | + | const unread = recent.filter((m) => m.timestamp > lastRead); | |
| 133 | + | const unreadCount = | |
| 134 | + | unread.length === MAX_UNREAD_COUNT | |
| 135 | + | ? MAX_UNREAD_COUNT | |
| 136 | + | : unread.filter((m) => m.senderId !== me).length; | |
| 131 | 137 | return { | |
| 132 | 138 | id: room._id, | |
| 133 | 139 | other: other ? toPublicUser(other) : null, | |
| @@ -18,6 +18,8 @@ const MAX_PROFILE_LENGTH = 1000; | |||||
| 18 | const MAX_ROOM_NAME_LENGTH = 40; | 18 | const MAX_ROOM_NAME_LENGTH = 40; | ||
| 19 | const MAX_TOPIC_LENGTH = 120; | 19 | const MAX_TOPIC_LENGTH = 120; | ||
| 20 | const DEFAULT_GROUP = 'Buddies'; | 20 | const DEFAULT_GROUP = 'Buddies'; | ||
| 21 | + | // Unread badges stop counting here (shown as "99+") | |||
| 22 | + | export const MAX_UNREAD_COUNT = 100; | |||
| 21 | 23 | ||||
| 22 | export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline'; | 24 | export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline'; | ||
| 23 | 25 | ||||
| @@ -119,15 +121,19 @@ export const getMyDirectRooms = authQuery({ | |||||
| 119 | const otherId = (room.memberIds ?? []).find((id) => id !== me); | 121 | const otherId = (room.memberIds ?? []).find((id) => id !== me); | ||
| 120 | const other = otherId ? await ctx.db.get(otherId) : null; | 122 | const other = otherId ? await ctx.db.get(otherId) : null; | ||
| 121 | const lastRead = readMap.get(room._id) ?? 0; | 123 | const lastRead = readMap.get(room._id) ?? 0; | ||
| 124 | + | // Walk newest-first until the read cursor, scanning at most MAX_UNREAD_COUNT | |||
| 125 | + | // messages; a full scan still unread saturates the badge ("99+"). | |||
| 122 | const recent = await ctx.db | 126 | const recent = await ctx.db | ||
| 123 | .query('messages') | 127 | .query('messages') | ||
| 124 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id)) | 128 | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id)) | ||
| 125 | .order('desc') | 129 | .order('desc') | ||
| 126 | - | .take(50); | 130 | + | .take(MAX_UNREAD_COUNT); |
| 127 | const lastMessage = recent[0] ?? null; | 131 | const lastMessage = recent[0] ?? null; | ||
| 128 | - | const unreadCount = recent.filter( | 132 | + | const unread = recent.filter((m) => m.timestamp > lastRead); |
| 129 | - | (m) => m.timestamp > lastRead && m.senderId !== me | 133 | + | const unreadCount = |
| 130 | - | ).length; | 134 | + | unread.length === MAX_UNREAD_COUNT |
| 135 | + | ? MAX_UNREAD_COUNT | |||
| 136 | + | : unread.filter((m) => m.senderId !== me).length; | |||
| 131 | return { | 137 | return { | ||
| 132 | id: room._id, | 138 | id: room._id, | ||
| 133 | other: other ? toPublicUser(other) : null, | 139 | other: other ? toPublicUser(other) : null, | ||
M src/convex/usersInternal.ts +3 -1
| @@ -73,8 +73,10 @@ export const updateStatus = internalMutation({ | |||
| 73 | 73 | throw new Error('User not found'); | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | // An away message keeps the user away: presence heartbeats only refresh lastSeen. | |
| 77 | + | const keepAway = user.awayMessage && args.status !== 'offline'; | |
| 76 | 78 | await ctx.db.patch(args.id, { | |
| 77 | - | status: args.status, | |
| 79 | + | status: keepAway ? 'away' : args.status, | |
| 78 | 80 | lastSeen: Date.now() | |
| 79 | 81 | }); | |
| 80 | 82 | ||
| @@ -73,8 +73,10 @@ export const updateStatus = internalMutation({ | |||||
| 73 | throw new Error('User not found'); | 73 | throw new Error('User not found'); | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | // An away message keeps the user away: presence heartbeats only refresh lastSeen. | |||
| 77 | + | const keepAway = user.awayMessage && args.status !== 'offline'; | |||
| 76 | await ctx.db.patch(args.id, { | 78 | await ctx.db.patch(args.id, { | ||
| 77 | - | status: args.status, | 79 | + | status: keepAway ? 'away' : args.status, |
| 78 | lastSeen: Date.now() | 80 | lastSeen: Date.now() | ||
| 79 | }); | 81 | }); | ||
| 80 | 82 | ||||