Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions apps/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
listOnlineUserIds,
markUserConnected,
markUserDisconnected,
reconcilePresence,
refreshPresenceHeartbeat,
} from "@/services/presence"
import {
enforceDmMessageRateLimit,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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(", ")}`)
Expand Down
44 changes: 43 additions & 1 deletion apps/realtime/src/services/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import type { createClient } from "redis"

type RedisClient = ReturnType<typeof createClient>

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])
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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
}
37 changes: 31 additions & 6 deletions apps/web/src/components/onboarding/onboarding-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Step>("welcome")
const [name, setName] = useState("")
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -274,16 +299,16 @@ export function OnboardingDialog({ open }: { open: boolean }) {
<DialogHeader className="mb-6 text-left">
<DialogTitle className="text-2xl">Join a Guild</DialogTitle>
<DialogDescription className="text-sm">
Paste an invite link to join an existing guild.
Paste an invite link or code to join an existing guild.
</DialogDescription>
</DialogHeader>

<form onSubmit={handleJoin} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="invite-link">Invite Link</Label>
<Label htmlFor="invite-link">Invite Link or Code</Label>
<Input
id="invite-link"
placeholder="https://townhall.gg/invite/abc123"
placeholder="https://townhall.chat/invite/abc123 or abc123"
value={inviteLink}
onChange={(e) => setInviteLink(e.target.value)}
disabled={loading}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/routes/_authenticated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down