feat(chat): add stale user cleanup and improve disabled state styling
- Add cleanupStaleUsers mutation for automatic offline status - Add crons module for scheduled tasks - Improve input placeholder for non-authenticated users - Enhance disabled state styling for buttons and inputs
- commit
- e96174a7b27602c4fa64f446d4f34fffeab9cdad
- author
- Kit · · 2026-01-20 05:47 UTC
- committer
- Kit · · 2026-01-20 05:53 UTC
- parent
- baa04b0
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
6 files · +66 -12
M src/convex/_generated/api.d.ts +2 -0
| @@ -10,6 +10,7 @@ | |||
| 10 | 10 | ||
| 11 | 11 | import type * as auth from "../auth.js"; | |
| 12 | 12 | import type * as chatRoomsInternal from "../chatRoomsInternal.js"; | |
| 13 | + | import type * as crons from "../crons.js"; | |
| 13 | 14 | import type * as http from "../http.js"; | |
| 14 | 15 | import type * as messagesInternal from "../messagesInternal.js"; | |
| 15 | 16 | import type * as mutations from "../mutations.js"; | |
| @@ -27,6 +28,7 @@ import type { | |||
| 27 | 28 | declare const fullApi: ApiFromModules<{ | |
| 28 | 29 | auth: typeof auth; | |
| 29 | 30 | chatRoomsInternal: typeof chatRoomsInternal; | |
| 31 | + | crons: typeof crons; | |
| 30 | 32 | http: typeof http; | |
| 31 | 33 | messagesInternal: typeof messagesInternal; | |
| 32 | 34 | 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({ | |||
| 9 | 9 | avatarUrl: v.optional(v.string()), | |
| 10 | 10 | createdAt: v.number(), | |
| 11 | 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 | 16 | sessions: defineTable({ | |
| 15 | 17 | 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 token | 17 | 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({ | |||
| 93 | 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 | + | }); | |
| @@ -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 @@ | |||
| 1143 | 1143 | </div> | |
| 1144 | 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 | 1147 | {#if currentTextStyle.gradient && currentTextStyle.gradient.length > 1} | |
| 1148 | 1148 | <div | |
| 1149 | 1149 | class="gradient-input-wrapper" | |
| @@ -1184,7 +1184,7 @@ | |||
| 1184 | 1184 | bind:value={currentMessage} | |
| 1185 | 1185 | maxlength={MAX_MESSAGE_LENGTH} | |
| 1186 | 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.bold | |
| 1187 | + | style="width: 100%; background: transparent; color: transparent; caret-color: black; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold | |
| 1188 | 1188 | ? currentTextStyle.fontFamily === 'tahoma' | |
| 1189 | 1189 | ? 'font-weight: 200;' | |
| 1190 | 1190 | : 'font-weight: 700;' | |
| @@ -1200,9 +1200,11 @@ | |||
| 1200 | 1200 | onfocus={handleInputFocus} | |
| 1201 | 1201 | onclick={handleInputClick} | |
| 1202 | 1202 | 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...'} | |
| 1206 | 1208 | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 1207 | 1209 | role="combobox" | |
| 1208 | 1210 | aria-autocomplete="list" | |
| @@ -1247,7 +1249,7 @@ | |||
| 1247 | 1249 | bind:value={currentMessage} | |
| 1248 | 1250 | maxlength={MAX_MESSAGE_LENGTH} | |
| 1249 | 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.bold | |
| 1252 | + | style="width: 100%; background: transparent; color: transparent; font-size: {currentTextStyle.fontSize}px; padding-left: 3px; {currentTextStyle.bold | |
| 1251 | 1253 | ? currentTextStyle.fontFamily === 'tahoma' | |
| 1252 | 1254 | ? 'font-weight: 200;' | |
| 1253 | 1255 | : 'font-weight: 700;' | |
| @@ -1263,9 +1265,11 @@ | |||
| 1263 | 1265 | onfocus={handleInputFocus} | |
| 1264 | 1266 | onclick={handleInputClick} | |
| 1265 | 1267 | 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...'} | |
| 1269 | 1273 | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 1270 | 1274 | role="combobox" | |
| 1271 | 1275 | aria-autocomplete="list" | |
| @@ -1547,10 +1551,17 @@ | |||
| 1547 | 1551 | ||
| 1548 | 1552 | .input-container input:disabled { | |
| 1549 | 1553 | opacity: 0.7; | |
| 1550 | - | background-color: rgba(128, 128, 128, 0.1); | |
| 1554 | + | background-color: rgba(128, 128, 128, 0.3) !important; | |
| 1551 | 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 | 1565 | .hidden { | |
| 1555 | 1566 | display: none !important; | |
| 1556 | 1567 | } | |
| @@ -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 | <div | 1148 | <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.bold | 1187 | + | 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={cooldownEndTime | 1203 | + | 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.bold | 1252 | + | 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={cooldownEndTime | 1268 | + | 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 @@ | |||
| 78 | 78 | } | |
| 79 | 79 | button:disabled { | |
| 80 | 80 | cursor: not-allowed; | |
| 81 | - | opacity: 0.7; | |
| 81 | + | opacity: 0.5; | |
| 82 | + | filter: grayscale(50%); | |
| 82 | 83 | } | |
| 83 | 84 | </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> | ||