diff --git a/ROADMAP.md b/ROADMAP.md index 627d3d9..3c9ee37 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,7 @@ ## Phase 3 — Social Features -- [~] Shareable invite links (not just email invites) — schema, API, and UI implemented +- [x] Shareable invite links (not just email invites) — schema, API, and UI implemented - [ ] Ally (friend) system with requests - [ ] User blocking - [ ] Privacy settings @@ -52,8 +52,8 @@ ## Phase 5 — Polish - [ ] Message search -- [ ] Typing indicators -- [ ] Pinned messages panel +- [x] Typing indicators +- [x] Pinned messages panel - [ ] Thread support - [ ] Notification preferences - [ ] Error handling & loading state improvements diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 2d0663c..5469706 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -40,6 +40,8 @@ import { listOnlineUserIds, markUserConnected, markUserDisconnected, + reconcilePresence, + refreshPresenceHeartbeat, } from "@/services/presence" import { enforceDmMessageRateLimit, @@ -239,6 +241,21 @@ async function initializeConnection(socket: RealtimeSocket) { } } + // Refresh TTL on the socket set so it expires if this server crashes + await refreshPresenceHeartbeat(redisPresenceClient, socket.data.user.id) + + // Keep refreshing while connected + const heartbeatInterval = setInterval(() => { + void refreshPresenceHeartbeat( + redisPresenceClient, + socket.data.user.id + ).catch(() => {}) + }, 30 * 1000) + + socket.once("disconnect", () => { + clearInterval(heartbeatInterval) + }) + socket.emit("presence:ready", { userId: socket.data.user.id, rooms: { @@ -616,6 +633,13 @@ async function bootstrap() { io.adapter(createAdapter(redisPubClient, redisSubClient)) + // Periodically clean up stale presence entries (crashed servers) + setInterval(() => { + void reconcilePresence(redisPresenceClient).catch((error) => { + console.error("[realtime] presence reconciliation failed:", error) + }) + }, 30 * 1000) + httpServer.listen(realtimePort, () => { console.log(`Realtime server running on port ${realtimePort}`) console.log(`Allowed origins: ${corsOrigins.join(", ")}`) diff --git a/apps/realtime/src/services/presence.ts b/apps/realtime/src/services/presence.ts index a5e9f7f..3a6cf55 100644 --- a/apps/realtime/src/services/presence.ts +++ b/apps/realtime/src/services/presence.ts @@ -3,8 +3,11 @@ import type { createClient } from "redis" type RedisClient = ReturnType +const PRESENCE_HEARTBEAT_TTL = 60 + const MARK_USER_CONNECTED_SCRIPT = ` redis.call("SADD", KEYS[1], ARGV[1]) +redis.call("EXPIRE", KEYS[1], ARGV[3]) local socketCount = redis.call("SCARD", KEYS[1]) if socketCount == 1 then redis.call("SADD", KEYS[2], ARGV[2]) @@ -39,7 +42,7 @@ export async function markUserConnected( ) { const result = await redis.eval(MARK_USER_CONNECTED_SCRIPT, { keys: [userSocketsKey(userId), PRESENCE_ONLINE_USERS_SET_KEY], - arguments: [socketId, userId], + arguments: [socketId, userId, String(PRESENCE_HEARTBEAT_TTL)], }) return { becameOnline: toRedisBoolean(result) } @@ -68,3 +71,42 @@ export async function listOnlineUserIds(redis: RedisClient, userIds: string[]) { return userIds.filter((_, index) => toRedisBoolean(membership[index])) } + +/** + * Refresh the TTL on a user's socket set so it expires if the server dies + * without running disconnect handlers. Call periodically per socket. + */ +export async function refreshPresenceHeartbeat( + redis: RedisClient, + userId: string +) { + const key = userSocketsKey(userId) + await redis.expire(key, PRESENCE_HEARTBEAT_TTL) +} + +/** + * Reconcile the online-users set by removing users whose socket sets have + * expired (server crash / no heartbeat). Call periodically on a timer. + */ +export async function reconcilePresence(redis: RedisClient) { + const onlineUserIds = await redis.sMembers(PRESENCE_ONLINE_USERS_SET_KEY) + if (onlineUserIds.length === 0) return [] + + const staleUserIds: string[] = [] + + for (const userId of onlineUserIds) { + const exists = await redis.exists(userSocketsKey(userId)) + if (!exists) { + staleUserIds.push(userId) + } + } + + if (staleUserIds.length > 0) { + await redis.sRem(PRESENCE_ONLINE_USERS_SET_KEY, staleUserIds) + console.log( + `[realtime] reconciled ${staleUserIds.length} stale presence entries` + ) + } + + return staleUserIds +} diff --git a/apps/web/src/components/onboarding/onboarding-dialog.tsx b/apps/web/src/components/onboarding/onboarding-dialog.tsx index cfaf27f..b699901 100644 --- a/apps/web/src/components/onboarding/onboarding-dialog.tsx +++ b/apps/web/src/components/onboarding/onboarding-dialog.tsx @@ -27,6 +27,21 @@ function normalizeSlugInput(value: string) { .replace(/[^a-z0-9-]/g, "") } +function parseInviteCode(value: string) { + const trimmed = value.trim() + if (!trimmed) return null + + if (/^[A-Za-z0-9]+$/.test(trimmed)) { + return trimmed + } + + const inviteMatch = trimmed.match( + /(?:^|\/)invite\/([A-Za-z0-9]+)(?:\/)?(?:[?#].*)?$/i + ) + + return inviteMatch?.[1] ?? null +} + export function OnboardingDialog({ open }: { open: boolean }) { const [step, setStep] = useState("welcome") const [name, setName] = useState("") @@ -111,9 +126,19 @@ export function OnboardingDialog({ open }: { open: boolean }) { if (!inviteLink.trim()) return setError(null) setLoading(true) - // TODO: implement join via invite link API - setError("Joining via invite link is not yet supported.") - setLoading(false) + + const inviteCode = parseInviteCode(inviteLink) + + if (!inviteCode) { + setError("Enter a valid invite link or invite code.") + setLoading(false) + return + } + + await navigate({ + to: "/invite/$code", + params: { code: inviteCode }, + }) } return ( @@ -274,16 +299,16 @@ export function OnboardingDialog({ open }: { open: boolean }) { Join a Guild - Paste an invite link to join an existing guild. + Paste an invite link or code to join an existing guild.
- + setInviteLink(e.target.value)} disabled={loading} diff --git a/apps/web/src/routes/_authenticated.tsx b/apps/web/src/routes/_authenticated.tsx index d88a196..c2d92b6 100644 --- a/apps/web/src/routes/_authenticated.tsx +++ b/apps/web/src/routes/_authenticated.tsx @@ -59,7 +59,9 @@ function AuthenticatedLayout() { // Only show onboarding if explicitly not completed AND no existing guilds // (guards against existing users whose flag defaulted to false) + const isInviteRoute = location.pathname.startsWith("/invite/") const showOnboarding = + !isInviteRoute && session.user.onboardingCompleted === false && guilds !== undefined && guilds?.length === 0