detached 200b9b6

Merge pull request #80 from kitpdr/feat/stale-user-cleanup

feat(chat): add stale user cleanup and improve disabled state styling
commit
200b9b6390774d92cb4578e26394c8254569ca32
author
KiT · · 2026-01-20 05:55 UTC
committer
GitHub · · 2026-01-20 05:55 UTC
merge of
baa04b0 + e96174a · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
6 files · +66 -12
M src/convex/_generated/api.d.ts +2 -0
@@ -10,6 +10,7 @@
1010
1111 import type * as auth from "../auth.js";
1212 import type * as chatRoomsInternal from "../chatRoomsInternal.js";
13+import type * as crons from "../crons.js";
1314 import type * as http from "../http.js";
1415 import type * as messagesInternal from "../messagesInternal.js";
1516 import type * as mutations from "../mutations.js";
@@ -27,6 +28,7 @@ import type {
2728 declare const fullApi: ApiFromModules<{
2829 auth: typeof auth;
2930 chatRoomsInternal: typeof chatRoomsInternal;
31+ crons: typeof crons;
3032 http: typeof http;
3133 messagesInternal: typeof messagesInternal;
3234 mutations: typeof mutations;
@@ -10,6 +10,7 @@
10 10
11 import type * as auth from "../auth.js";11 import type * as auth from "../auth.js";
12 import type * as chatRoomsInternal from "../chatRoomsInternal.js";12 import type * as chatRoomsInternal from "../chatRoomsInternal.js";
13+import type * as crons from "../crons.js";
13 import type * as http from "../http.js";14 import type * as http from "../http.js";
14 import type * as messagesInternal from "../messagesInternal.js";15 import type * as messagesInternal from "../messagesInternal.js";
15 import type * as mutations from "../mutations.js";16 import type * as mutations from "../mutations.js";
@@ -27,6 +28,7 @@ import type {
27 declare const fullApi: ApiFromModules<{28 declare const fullApi: ApiFromModules<{
28 auth: typeof auth;29 auth: typeof auth;
29 chatRoomsInternal: typeof chatRoomsInternal;30 chatRoomsInternal: typeof chatRoomsInternal;
31+ crons: typeof crons;
30 http: typeof http;32 http: typeof http;
31 messagesInternal: typeof messagesInternal;33 messagesInternal: typeof messagesInternal;
32 mutations: typeof mutations;34 mutations: typeof mutations;
A src/convex/crons.ts +9 -0
@@ -0,0 +1,9 @@
1+import { cronJobs } from 'convex/server';
2+import { internal } from './_generated/api';
3+
4+const crons = cronJobs();
5+
6+// Run every minute to check for stale online users
7+crons.interval('cleanup stale users', { minutes: 1 }, internal.usersInternal.cleanupStaleUsers);
8+
9+export default crons;
@@ -0,0 +1,9 @@
1+import { cronJobs } from 'convex/server';
2+import { internal } from './_generated/api';
3+
4+const crons = cronJobs();
5+
6+// Run every minute to check for stale online users
7+crons.interval('cleanup stale users', { minutes: 1 }, internal.usersInternal.cleanupStaleUsers);
8+
9+export default crons;
M src/convex/schema.ts +3 -1
@@ -9,7 +9,9 @@ export default defineSchema({
99 avatarUrl: v.optional(v.string()),
1010 createdAt: v.number(),
1111 lastSeen: v.optional(v.number())
12- }).index('by_nickname', ['nickname']),
12+ })
13+ .index('by_nickname', ['nickname'])
14+ .index('by_status', ['status']),
1315
1416 sessions: defineTable({
1517 tokenHash: v.string(), // SHA-256 hash of the session token
@@ -9,7 +9,9 @@ export default defineSchema({
9 avatarUrl: v.optional(v.string()),9 avatarUrl: v.optional(v.string()),
10 createdAt: v.number(),10 createdAt: v.number(),
11 lastSeen: v.optional(v.number())11 lastSeen: v.optional(v.number())
12- }).index('by_nickname', ['nickname']),12+ })
13+ .index('by_nickname', ['nickname'])
14+ .index('by_status', ['status']),
13 15
14 sessions: defineTable({16 sessions: defineTable({
15 tokenHash: v.string(), // SHA-256 hash of the session token17 tokenHash: v.string(), // SHA-256 hash of the session token
M src/convex/usersInternal.ts +29 -0
@@ -93,3 +93,32 @@ export const setAllOffline = internalMutation({
9393 return { updated: users.length };
9494 }
9595 });
96+
97+// Cleanup stale users who haven't sent a heartbeat in 2 minutes
98+export const cleanupStaleUsers = internalMutation({
99+ args: {},
100+ handler: async (ctx) => {
101+ const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
102+ const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
103+
104+ const nonOfflineStatuses = ['online', 'away', 'busy', 'idle'];
105+ let updated = 0;
106+
107+ for (const status of nonOfflineStatuses) {
108+ const users = await ctx.db
109+ .query('users')
110+ .withIndex('by_status', (q) => q.eq('status', status))
111+ .collect();
112+
113+ for (const user of users) {
114+ const lastSeen = user.lastSeen ?? 0;
115+ if (lastSeen < timeoutThreshold) {
116+ await ctx.db.patch(user._id, { status: 'offline' });
117+ updated++;
118+ }
119+ }
120+ }
121+
122+ return { updated };
123+ }
124+});
@@ -93,3 +93,32 @@ export const setAllOffline = internalMutation({
93 return { updated: users.length };93 return { updated: users.length };
94 }94 }
95 });95 });
96+
97+// Cleanup stale users who haven't sent a heartbeat in 2 minutes
98+export const cleanupStaleUsers = internalMutation({
99+ args: {},
100+ handler: async (ctx) => {
101+ const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
102+ const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
103+
104+ const nonOfflineStatuses = ['online', 'away', 'busy', 'idle'];
105+ let updated = 0;
106+
107+ for (const status of nonOfflineStatuses) {
108+ const users = await ctx.db
109+ .query('users')
110+ .withIndex('by_status', (q) => q.eq('status', status))
111+ .collect();
112+
113+ for (const user of users) {
114+ const lastSeen = user.lastSeen ?? 0;
115+ if (lastSeen < timeoutThreshold) {
116+ await ctx.db.patch(user._id, { status: 'offline' });
117+ updated++;
118+ }
119+ }
120+ }
121+
122+ return { updated };
123+ }
124+});
M src/lib/components/chat-room.svelte +21 -10
@@ -1143,7 +1143,7 @@
11431143 </div>
11441144 {/if}
11451145
1146- <div class="field-row input-container" style="margin: 0;">
1146+ <div class="field-row input-container" style="margin: 0; position: relative;">
11471147 {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
11481148 <div
11491149 class="gradient-input-wrapper"
@@ -1184,7 +1184,7 @@
11841184 bind:value={currentMessage}
11851185 maxlength={MAX_MESSAGE_LENGTH}
11861186 class="styled-input retro-font-{currentTextStyle.fontFamily}"
1187- style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold
1187+ style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
11881188 ? currentTextStyle.fontFamily === 'tahoma'
11891189 ? 'font-weight: 200;'
11901190 : 'font-weight: 700;'
@@ -1200,9 +1200,11 @@
12001200 onfocus={handleInputFocus}
12011201 onclick={handleInputClick}
12021202 bind:this={messageInput}
1203- placeholder={cooldownEndTime
1204- ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1205- : 'Écrivez un message...'}
1203+ placeholder={!currentUser
1204+ ? 'Inscris-toi pour participer.'
1205+ : cooldownEndTime
1206+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1207+ : 'Écrivez un message...'}
12061208 disabled={!currentUser || Boolean(cooldownEndTime)}
12071209 role="combobox"
12081210 aria-autocomplete="list"
@@ -1247,7 +1249,7 @@
12471249 bind:value={currentMessage}
12481250 maxlength={MAX_MESSAGE_LENGTH}
12491251 class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"
1250- style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold
1252+ style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
12511253 ? currentTextStyle.fontFamily === 'tahoma'
12521254 ? 'font-weight: 200;'
12531255 : 'font-weight: 700;'
@@ -1263,9 +1265,11 @@
12631265 onfocus={handleInputFocus}
12641266 onclick={handleInputClick}
12651267 bind:this={messageInput}
1266- placeholder={cooldownEndTime
1267- ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1268- : 'Écrivez un message...'}
1268+ placeholder={!currentUser
1269+ ? 'Inscris-toi pour participer.'
1270+ : cooldownEndTime
1271+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1272+ : 'Écrivez un message...'}
12691273 disabled={!currentUser || Boolean(cooldownEndTime)}
12701274 role="combobox"
12711275 aria-autocomplete="list"
@@ -1547,10 +1551,17 @@
15471551
15481552 .input-container input:disabled {
15491553 opacity: 0.7;
1550- background-color: rgba(128, 128, 128, 0.1);
1554+ background-color: rgba(128, 128, 128, 0.3) !important;
15511555 cursor: not-allowed;
15521556 }
15531557
1558+ /* Fade input wrapper when disabled */
1559+ .input-container:has(input:disabled) .gradient-input-wrapper,
1560+ .input-container:has(input:disabled) .mention-input-wrapper {
1561+ opacity: 0.6;
1562+ background-color: #f0f0f0 !important;
1563+ }
1564+
15541565 .hidden {
15551566 display: none !important;
15561567 }
@@ -1143,7 +1143,7 @@
1143 </div>1143 </div>
1144 {/if}1144 {/if}
1145 1145
1146- <div class="field-row input-container" style="margin: 0;">1146+ <div class="field-row input-container" style="margin: 0; position: relative;">
1147 {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}1147 {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1}
1148 <div1148 <div
1149 class="gradient-input-wrapper"1149 class="gradient-input-wrapper"
@@ -1184,7 +1184,7 @@
1184 bind:value={currentMessage}1184 bind:value={currentMessage}
1185 maxlength={MAX_MESSAGE_LENGTH}1185 maxlength={MAX_MESSAGE_LENGTH}
1186 class="styled-input retro-font-{currentTextStyle.fontFamily}"1186 class="styled-input retro-font-{currentTextStyle.fontFamily}"
1187- style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold1187+ style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1188 ? currentTextStyle.fontFamily === 'tahoma'1188 ? currentTextStyle.fontFamily === 'tahoma'
1189 ? 'font-weight: 200;'1189 ? 'font-weight: 200;'
1190 : 'font-weight: 700;'1190 : 'font-weight: 700;'
@@ -1200,9 +1200,11 @@
1200 onfocus={handleInputFocus}1200 onfocus={handleInputFocus}
1201 onclick={handleInputClick}1201 onclick={handleInputClick}
1202 bind:this={messageInput}1202 bind:this={messageInput}
1203- placeholder={cooldownEndTime1203+ placeholder={!currentUser
1204- ? `Patientez ${cooldownProgress.toFixed(1)}s...`1204+ ? 'Inscris-toi pour participer.'
1205- : 'Écrivez un message...'}1205+ : cooldownEndTime
1206+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1207+ : 'Écrivez un message...'}
1206 disabled={!currentUser || Boolean(cooldownEndTime)}1208 disabled={!currentUser || Boolean(cooldownEndTime)}
1207 role="combobox"1209 role="combobox"
1208 aria-autocomplete="list"1210 aria-autocomplete="list"
@@ -1247,7 +1249,7 @@
1247 bind:value={currentMessage}1249 bind:value={currentMessage}
1248 maxlength={MAX_MESSAGE_LENGTH}1250 maxlength={MAX_MESSAGE_LENGTH}
1249 class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"1251 class="styled-input mention-overlay-input retro-font-{currentTextStyle.fontFamily}"
1250- style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; {currentTextStyle.bold1252+ style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold
1251 ? currentTextStyle.fontFamily === 'tahoma'1253 ? currentTextStyle.fontFamily === 'tahoma'
1252 ? 'font-weight: 200;'1254 ? 'font-weight: 200;'
1253 : 'font-weight: 700;'1255 : 'font-weight: 700;'
@@ -1263,9 +1265,11 @@
1263 onfocus={handleInputFocus}1265 onfocus={handleInputFocus}
1264 onclick={handleInputClick}1266 onclick={handleInputClick}
1265 bind:this={messageInput}1267 bind:this={messageInput}
1266- placeholder={cooldownEndTime1268+ placeholder={!currentUser
1267- ? `Patientez ${cooldownProgress.toFixed(1)}s...`1269+ ? 'Inscris-toi pour participer.'
1268- : 'Écrivez un message...'}1270+ : cooldownEndTime
1271+ ? `Patientez ${cooldownProgress.toFixed(1)}s...`
1272+ : 'Écrivez un message...'}
1269 disabled={!currentUser || Boolean(cooldownEndTime)}1273 disabled={!currentUser || Boolean(cooldownEndTime)}
1270 role="combobox"1274 role="combobox"
1271 aria-autocomplete="list"1275 aria-autocomplete="list"
@@ -1547,10 +1551,17 @@
1547 1551
1548 .input-container input:disabled {1552 .input-container input:disabled {
1549 opacity: 0.7;1553 opacity: 0.7;
1550- background-color: rgba(128, 128, 128, 0.1);1554+ background-color: rgba(128, 128, 128, 0.3) !important;
1551 cursor: not-allowed;1555 cursor: not-allowed;
1552 }1556 }
1553 1557
1558+ /* Fade input wrapper when disabled */
1559+ .input-container:has(input:disabled) .gradient-input-wrapper,
1560+ .input-container:has(input:disabled) .mention-input-wrapper {
1561+ opacity: 0.6;
1562+ background-color: #f0f0f0 !important;
1563+ }
1564+
1554 .hidden {1565 .hidden {
1555 display: none !important;1566 display: none !important;
1556 }1567 }
M src/lib/components/ui/button-loading.svelte +2 -1
@@ -78,6 +78,7 @@
7878 }
7979 button:disabled {
8080 cursor: not-allowed;
81- opacity: 0.7;
81+ opacity: 0.5;
82+ filter: grayscale(50%);
8283 }
8384 </style>
@@ -78,6 +78,7 @@
78 }78 }
79 button:disabled {79 button:disabled {
80 cursor: not-allowed;80 cursor: not-allowed;
81- opacity: 0.7;81+ opacity: 0.5;
82+ filter: grayscale(50%);
82 }83 }
83 </style>84 </style>

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