detached cc5d0b4

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 UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
2 files · +13 -5
M src/convex/aim.ts +10 -4
@@ -18,6 +18,8 @@ const MAX_PROFILE_LENGTH = 1000;
1818 const MAX_ROOM_NAME_LENGTH = 40;
1919 const MAX_TOPIC_LENGTH = 120;
2020 const DEFAULT_GROUP = 'Buddies';
21+// Unread badges stop counting here (shown as "99+")
22+export const MAX_UNREAD_COUNT = 100;
2123
2224 export type EffectiveStatus = 'online' | 'away' | 'busy' | 'idle' | 'offline';
2325
@@ -119,15 +121,19 @@ export const getMyDirectRooms = authQuery({
119121 const otherId = (room.memberIds ?? []).find((id) => id !== me);
120122 const other = otherId ? await ctx.db.get(otherId) : null;
121123 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+").
122126 const recent = await ctx.db
123127 .query('messages')
124128 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', room._id))
125129 .order('desc')
126- .take(50);
130+ .take(MAX_UNREAD_COUNT);
127131 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;
131137 return {
132138 id: room._id,
133139 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.db126 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 !== me133+ 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({
7373 throw new Error('User not found');
7474 }
7575
76+ // An away message keeps the user away: presence heartbeats only refresh lastSeen.
77+ const keepAway = user.awayMessage && args.status !== 'offline';
7678 await ctx.db.patch(args.id, {
77- status: args.status,
79+ status: keepAway ? 'away' : args.status,
7880 lastSeen: Date.now()
7981 });
8082
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close