diff --git a/ROADMAP.md b/ROADMAP.md index 7077b19..627d3d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,7 @@ ## Phase 3 — Social Features -- [ ] Shareable invite links (not just email invites) +- [~] Shareable invite links (not just email invites) — schema, API, and UI implemented - [ ] Ally (friend) system with requests - [ ] User blocking - [ ] Privacy settings diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 06ed05d..5faa9b7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,6 +6,7 @@ import index from "@/routes/index.route" import channelsRouter from "@/routes/v1/channels/index" import dmsRouter from "@/routes/v1/dms/index" import guildsRouter from "@/routes/v1/guilds/index" +import invitesRouter from "@/routes/v1/invites/index" import uploadsRouter from "@/routes/v1/uploads/index" import waitlistRouter from "@/routes/waitlist/index" @@ -33,6 +34,7 @@ const routes = app .route("/", waitlistRouter) .route("/v1", channelsRouter) .route("/v1", guildsRouter) + .route("/v1", invitesRouter) .route("/v1", dmsRouter) .route("/v1", uploadsRouter) diff --git a/apps/api/src/routes/v1/invites/handlers.ts b/apps/api/src/routes/v1/invites/handlers.ts new file mode 100644 index 0000000..7599b09 --- /dev/null +++ b/apps/api/src/routes/v1/invites/handlers.ts @@ -0,0 +1,460 @@ +import { randomBytes } from "node:crypto" +import { and, db, eq, schema, sql } from "@repo/db" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import { assertGuildPermission } from "@/lib/permissions" +import type { AppRouteHandler } from "@/lib/types/app-types" +import type { + AcceptInviteRoute, + CreateInviteRoute, + DeleteInviteRoute, + ListInvitesRoute, + PreviewInviteRoute, +} from "./routes" + +// ── Helpers ────────────────────────────────────────────── + +const CODE_LENGTH = 8 +const MAX_CODE_RETRIES = 3 + +function generateInviteCode(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + const bytes = randomBytes(CODE_LENGTH) + let code = "" + for (let i = 0; i < CODE_LENGTH; i++) { + code += chars[(bytes[i] as number) % chars.length] + } + return code +} + +function isInviteExpired(expiresAt: Date | null): boolean { + if (!expiresAt) return false + return expiresAt.getTime() <= Date.now() +} + +function isInviteMaxedOut(uses: number, maxUses: number | null): boolean { + if (maxUses === null) return false + return uses >= maxUses +} + +function toInviteResponse( + invite: { + id: string + code: string + guildId: string + inviterId: string + channelId: string | null + maxUses: number | null + uses: number + expiresAt: Date | null + createdAt: Date + }, + inviter: { + name: string + username: string | null + image: string | null + } +) { + return { + id: invite.id, + code: invite.code, + guildId: invite.guildId, + inviterId: invite.inviterId, + channelId: invite.channelId, + maxUses: invite.maxUses, + uses: invite.uses, + expiresAt: invite.expiresAt?.toISOString() ?? null, + createdAt: invite.createdAt.toISOString(), + inviter: { + name: inviter.name, + username: inviter.username, + image: inviter.image, + }, + } +} + +// ── Guild-scoped Handlers ──────────────────────────────── + +export const createInvite: AppRouteHandler = async (c) => { + const guild = c.var.guild + const user = c.var.user + const { channelId, maxUses, expiresInMinutes } = c.req.valid("json") + + const expiresAt = expiresInMinutes + ? new Date(Date.now() + expiresInMinutes * 60 * 1000) + : null + + let invite = null + for (let attempt = 0; attempt < MAX_CODE_RETRIES; attempt++) { + const code = generateInviteCode() + try { + const rows = await db + .insert(schema.guildInvite) + .values({ + guildId: guild.id, + code, + inviterId: user.id, + channelId: channelId ?? null, + maxUses: maxUses ?? null, + expiresAt, + }) + .returning() + + invite = rows[0] + break + } catch (error) { + // Unique constraint violation on code — retry with new code + const isUniqueViolation = + error instanceof Error && "code" in error && error.code === "23505" + if (!isUniqueViolation || attempt === MAX_CODE_RETRIES - 1) { + throw error + } + } + } + + if (!invite) { + return c.json( + { success: false, message: "Failed to generate invite code" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ) + } + + return c.json( + { + success: true as const, + invite: toInviteResponse(invite, { + name: user.name, + username: user.username ?? null, + image: user.image ?? null, + }), + }, + HttpStatusCodes.OK + ) +} + +export const listInvites: AppRouteHandler = async (c) => { + const guild = c.var.guild + const actor = c.var.member + + assertGuildPermission(actor, guild, { + guildMember: ["kick"], // admins+ can view invites (same permission tier as kick) + }) + + const rows = await db + .select({ + id: schema.guildInvite.id, + code: schema.guildInvite.code, + guildId: schema.guildInvite.guildId, + inviterId: schema.guildInvite.inviterId, + channelId: schema.guildInvite.channelId, + maxUses: schema.guildInvite.maxUses, + uses: schema.guildInvite.uses, + expiresAt: schema.guildInvite.expiresAt, + createdAt: schema.guildInvite.createdAt, + inviterName: schema.user.name, + inviterUsername: schema.user.username, + inviterImage: schema.user.image, + }) + .from(schema.guildInvite) + .innerJoin(schema.user, eq(schema.guildInvite.inviterId, schema.user.id)) + .where( + and( + eq(schema.guildInvite.guildId, guild.id), + sql`(${schema.guildInvite.expiresAt} IS NULL OR ${schema.guildInvite.expiresAt} > NOW())`, + sql`(${schema.guildInvite.maxUses} IS NULL OR ${schema.guildInvite.uses} < ${schema.guildInvite.maxUses})` + ) + ) + + const invites = rows.map((row) => + toInviteResponse(row, { + name: row.inviterName, + username: row.inviterUsername, + image: row.inviterImage, + }) + ) + + return c.json({ success: true as const, invites }, HttpStatusCodes.OK) +} + +export const deleteInvite: AppRouteHandler = async (c) => { + const guild = c.var.guild + const actor = c.var.member + const { code } = c.req.valid("param") + + const invite = await db + .select() + .from(schema.guildInvite) + .where( + and( + eq(schema.guildInvite.guildId, guild.id), + eq(schema.guildInvite.code, code) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (!invite) { + return c.json( + { success: false, message: "Invite not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + // Non-admin members can only delete their own invites + if (invite.inviterId !== actor.userId) { + assertGuildPermission(actor, guild, { + guildMember: ["kick"], + }) + } + + await db + .delete(schema.guildInvite) + .where(eq(schema.guildInvite.id, invite.id)) + + return c.json({ success: true as const }, HttpStatusCodes.OK) +} + +// ── Public Handlers (session-only) ─────────────────────── + +export const previewInvite: AppRouteHandler = async (c) => { + const user = c.var.user + const { code } = c.req.valid("param") + + const invite = await db + .select({ + code: schema.guildInvite.code, + guildId: schema.guildInvite.guildId, + channelId: schema.guildInvite.channelId, + maxUses: schema.guildInvite.maxUses, + uses: schema.guildInvite.uses, + expiresAt: schema.guildInvite.expiresAt, + guildName: schema.guild.name, + guildSlug: schema.guild.slug, + guildLogo: schema.guild.logo, + inviterName: schema.user.name, + inviterUsername: schema.user.username, + inviterImage: schema.user.image, + }) + .from(schema.guildInvite) + .innerJoin(schema.guild, eq(schema.guildInvite.guildId, schema.guild.id)) + .innerJoin(schema.user, eq(schema.guildInvite.inviterId, schema.user.id)) + .where(eq(schema.guildInvite.code, code)) + .limit(1) + .then((rows) => rows[0]) + + if (!invite) { + return c.json( + { success: false, message: "Invite not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + // Get member count + const memberCountResult = await db + .select({ count: sql`count(*)::int` }) + .from(schema.guildMember) + .where(eq(schema.guildMember.guildId, invite.guildId)) + .then((rows) => rows[0]) + + // Check if user is already a member + const existingMember = await db + .select({ id: schema.guildMember.id }) + .from(schema.guildMember) + .where( + and( + eq(schema.guildMember.guildId, invite.guildId), + eq(schema.guildMember.userId, user.id) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + // Get channel info if present + let channelInfo = null + if (invite.channelId) { + channelInfo = await db + .select({ id: schema.channel.id, name: schema.channel.name }) + .from(schema.channel) + .where(eq(schema.channel.id, invite.channelId)) + .limit(1) + .then((rows) => rows[0] ?? null) + } + + const isExpired = + isInviteExpired(invite.expiresAt) || + isInviteMaxedOut(invite.uses, invite.maxUses) + + return c.json( + { + success: true as const, + invite: { + code: invite.code, + guild: { + name: invite.guildName, + slug: invite.guildSlug, + logo: invite.guildLogo, + memberCount: memberCountResult?.count ?? 0, + }, + channel: channelInfo, + inviter: { + name: invite.inviterName, + username: invite.inviterUsername, + image: invite.inviterImage, + }, + isExpired, + isMember: !!existingMember, + }, + }, + HttpStatusCodes.OK + ) +} + +export const acceptInvite: AppRouteHandler = async (c) => { + const user = c.var.user + const { code } = c.req.valid("param") + + const invite = await db + .select() + .from(schema.guildInvite) + .where(eq(schema.guildInvite.code, code)) + .limit(1) + .then((rows) => rows[0]) + + if (!invite) { + return c.json( + { success: false, message: "Invite not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + // Check expired / maxed out + if (isInviteExpired(invite.expiresAt)) { + return c.json( + { success: false, message: "This invite has expired" }, + HttpStatusCodes.FORBIDDEN + ) + } + + if (isInviteMaxedOut(invite.uses, invite.maxUses)) { + return c.json( + { success: false, message: "This invite has reached its maximum uses" }, + HttpStatusCodes.FORBIDDEN + ) + } + + // Check if banned (outside transaction — read-only check) + const activeBan = await db + .select({ id: schema.guildBan.id }) + .from(schema.guildBan) + .where( + and( + eq(schema.guildBan.guildId, invite.guildId), + eq(schema.guildBan.userId, user.id), + sql`${schema.guildBan.revokedAt} IS NULL`, + sql`(${schema.guildBan.expiresAt} IS NULL OR ${schema.guildBan.expiresAt} > NOW())` + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (activeBan) { + return c.json( + { success: false, message: "You are banished from this guild" }, + HttpStatusCodes.FORBIDDEN + ) + } + + // Join the guild in a transaction with race-condition protection + const result = await db.transaction(async (tx) => { + // Check if already a member (inside transaction) + const existingMember = await tx + .select({ id: schema.guildMember.id }) + .from(schema.guildMember) + .where( + and( + eq(schema.guildMember.guildId, invite.guildId), + eq(schema.guildMember.userId, user.id) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (existingMember) { + const guild = await tx + .select({ + id: schema.guild.id, + name: schema.guild.name, + slug: schema.guild.slug, + }) + .from(schema.guild) + .where(eq(schema.guild.id, invite.guildId)) + .limit(1) + .then((rows) => rows[0]) + return { alreadyMember: true as const, guild } + } + + // Atomically increment uses only if under the limit + const updated = await tx + .update(schema.guildInvite) + .set({ uses: sql`${schema.guildInvite.uses} + 1` }) + .where( + and( + eq(schema.guildInvite.id, invite.id), + sql`(${schema.guildInvite.maxUses} IS NULL OR ${schema.guildInvite.uses} < ${schema.guildInvite.maxUses})` + ) + ) + .returning({ id: schema.guildInvite.id }) + + if (updated.length === 0) { + return { maxedOut: true as const } + } + + // Insert membership + await tx.insert(schema.guildMember).values({ + guildId: invite.guildId, + userId: user.id, + role: "member", + createdAt: new Date(), + }) + + const guild = await tx + .select({ + id: schema.guild.id, + name: schema.guild.name, + slug: schema.guild.slug, + }) + .from(schema.guild) + .where(eq(schema.guild.id, invite.guildId)) + .limit(1) + .then((rows) => rows[0]) + + return { joined: true as const, guild } + }) + + if ("maxedOut" in result) { + return c.json( + { success: false, message: "This invite has reached its maximum uses" }, + HttpStatusCodes.FORBIDDEN + ) + } + + const guildRecord = result.guild + + if (!guildRecord) { + return c.json( + { success: false, message: "Guild not found" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ) + } + + return c.json( + { + success: true as const, + guild: { + id: guildRecord.id, + name: guildRecord.name, + slug: guildRecord.slug, + }, + }, + HttpStatusCodes.OK + ) +} diff --git a/apps/api/src/routes/v1/invites/index.ts b/apps/api/src/routes/v1/invites/index.ts new file mode 100644 index 0000000..e2deb58 --- /dev/null +++ b/apps/api/src/routes/v1/invites/index.ts @@ -0,0 +1,14 @@ +import { createRouter } from "@/lib/helpers/app/create-app" +import * as handlers from "@/routes/v1/invites/handlers" +import * as routes from "@/routes/v1/invites/routes" + +const invitesRouter = createRouter() + // Guild-scoped routes + .openapi(routes.createInvite, handlers.createInvite) + .openapi(routes.listInvites, handlers.listInvites) + .openapi(routes.deleteInvite, handlers.deleteInvite) + // Public routes (session-only) + .openapi(routes.previewInvite, handlers.previewInvite) + .openapi(routes.acceptInvite, handlers.acceptInvite) + +export default invitesRouter diff --git a/apps/api/src/routes/v1/invites/routes.ts b/apps/api/src/routes/v1/invites/routes.ts new file mode 100644 index 0000000..77ee508 --- /dev/null +++ b/apps/api/src/routes/v1/invites/routes.ts @@ -0,0 +1,152 @@ +import { createRoute } from "@hono/zod-openapi" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import jsonContent from "@/lib/helpers/openapi/json-content" +import { + forbiddenSchema, + internalServerErrorSchema, + notFoundSchema, + unauthorizedSchema, +} from "@/lib/helpers/openapi/schemas" +import { guildAuthMiddleware } from "@/middleware/guild-auth" +import { sessionAuthMiddleware } from "@/middleware/session-auth" +import { + acceptInviteResponseSchema, + createInviteRequestSchema, + createInviteResponseSchema, + deleteInviteResponseSchema, + guildInviteCodeParamsSchema, + guildSlugParamsSchema, + inviteCodeParamsSchema, + invitePreviewResponseSchema, + listInvitesResponseSchema, +} from "./schema" + +// ── Guild-scoped routes (require guild membership) ────── + +export const createInvite = createRoute({ + path: "/guilds/{guildSlug}/invites", + method: "post", + summary: "Create a guild invite link", + description: + "Generates a shareable invite code for the guild. Any guild member can create invite links.", + tags: ["Invites"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildSlugParamsSchema, + body: jsonContent({ + schema: createInviteRequestSchema, + description: "Invite options", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: createInviteResponseSchema, + description: "Created invite link", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type CreateInviteRoute = typeof createInvite + +export const listInvites = createRoute({ + path: "/guilds/{guildSlug}/invites", + method: "get", + summary: "List guild invite links", + description: + "Returns all active invite links for the guild. Requires admin or higher role.", + tags: ["Invites"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildSlugParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: listInvitesResponseSchema, + description: "Active guild invites", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type ListInvitesRoute = typeof listInvites + +export const deleteInvite = createRoute({ + path: "/guilds/{guildSlug}/invites/{code}", + method: "delete", + summary: "Revoke a guild invite link", + description: + "Deletes an invite link. Admins+ can delete any invite; members can only delete their own.", + tags: ["Invites"], + middleware: [guildAuthMiddleware] as const, + request: { + params: guildInviteCodeParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: deleteInviteResponseSchema, + description: "Invite revoked", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type DeleteInviteRoute = typeof deleteInvite + +// ── Public routes (only require auth session) ────────── + +export const previewInvite = createRoute({ + path: "/invites/{code}", + method: "get", + summary: "Preview an invite link", + description: + "Returns guild info for an invite code. Used to show a preview before joining.", + tags: ["Invites"], + middleware: [sessionAuthMiddleware] as const, + request: { + params: inviteCodeParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: invitePreviewResponseSchema, + description: "Invite preview", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type PreviewInviteRoute = typeof previewInvite + +export const acceptInvite = createRoute({ + path: "/invites/{code}/accept", + method: "post", + summary: "Accept an invite link", + description: + "Joins the guild associated with the invite code. Checks for bans, expiry, and max uses.", + tags: ["Invites"], + middleware: [sessionAuthMiddleware] as const, + request: { + params: inviteCodeParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: acceptInviteResponseSchema, + description: "Successfully joined guild", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type AcceptInviteRoute = typeof acceptInvite diff --git a/apps/api/src/routes/v1/invites/schema.ts b/apps/api/src/routes/v1/invites/schema.ts new file mode 100644 index 0000000..2c7add9 --- /dev/null +++ b/apps/api/src/routes/v1/invites/schema.ts @@ -0,0 +1,120 @@ +import { z } from "@hono/zod-openapi" +import { guildSlugParamsSchema } from "@/routes/v1/channels/schema" + +export { guildSlugParamsSchema } + +// ── Path Params ────────────────────────────────────────── + +export const inviteCodeParamsSchema = z.object({ + code: z + .string() + .min(1) + .max(12) + .openapi({ + param: { + name: "code", + in: "path", + required: true, + }, + example: "aBc4xZ7q", + }), +}) + +export const guildInviteCodeParamsSchema = guildSlugParamsSchema.extend({ + code: z + .string() + .min(1) + .max(12) + .openapi({ + param: { + name: "code", + in: "path", + required: true, + }, + example: "aBc4xZ7q", + }), +}) + +// ── Request Schemas ────────────────────────────────────── + +export const createInviteRequestSchema = z.object({ + channelId: z.string().uuid().nullable().optional(), + maxUses: z.number().int().min(1).max(1000).nullable().optional(), + expiresInMinutes: z + .number() + .int() + .min(30) + .max(60 * 24 * 7) // max 7 days + .nullable() + .optional(), +}) + +// ── Response Schemas ────────────────────────────────────── + +export const guildInviteSchema = z.object({ + id: z.string().uuid(), + code: z.string(), + guildId: z.string().uuid(), + inviterId: z.string().uuid(), + channelId: z.string().uuid().nullable(), + maxUses: z.number().nullable(), + uses: z.number(), + expiresAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), + inviter: z.object({ + name: z.string(), + username: z.string().nullable(), + image: z.string().nullable(), + }), +}) + +export const createInviteResponseSchema = z.object({ + success: z.literal(true), + invite: guildInviteSchema, +}) + +export const listInvitesResponseSchema = z.object({ + success: z.literal(true), + invites: z.array(guildInviteSchema), +}) + +export const deleteInviteResponseSchema = z.object({ + success: z.literal(true), +}) + +export const invitePreviewSchema = z.object({ + code: z.string(), + guild: z.object({ + name: z.string(), + slug: z.string(), + logo: z.string().nullable(), + memberCount: z.number(), + }), + channel: z + .object({ + id: z.string().uuid(), + name: z.string().nullable(), + }) + .nullable(), + inviter: z.object({ + name: z.string(), + username: z.string().nullable(), + image: z.string().nullable(), + }), + isExpired: z.boolean(), + isMember: z.boolean(), +}) + +export const invitePreviewResponseSchema = z.object({ + success: z.literal(true), + invite: invitePreviewSchema, +}) + +export const acceptInviteResponseSchema = z.object({ + success: z.literal(true), + guild: z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + }), +}) diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 0398b54..a2e4a61 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -1,6 +1,6 @@ import { createServer } from "node:http" import { auth, type Session } from "@repo/auth" -import { db, eq, schema } from "@repo/db" +import { and, db, eq, schema } from "@repo/db" import { env } from "@repo/env/server" import type { ClientToServerEvents, @@ -12,11 +12,13 @@ import { channelRoomPayloadSchema, deleteMessagePayloadSchema, editMessagePayloadSchema, + guildMemberJoinedPayloadSchema, guildRoom, markChannelReadPayloadSchema, presenceSubscribePayloadSchema, sendMessagePayloadSchema, toggleMessageReactionPayloadSchema, + typingStartPayloadSchema, userRoom, } from "@repo/realtime-types" import type { LinkUnfurlJobData } from "@repo/realtime-types/queues" @@ -452,6 +454,66 @@ io.on("connection", (socket) => { } }) + socket.on("typing:start", async (payload) => { + try { + const parsed = typingStartPayloadSchema.parse(payload) + await assertUserCanAccessChannel(socket.data.user.id, parsed.channelId) + socket.to(channelRoom(parsed.channelId)).emit("typing:update", { + channelId: parsed.channelId, + userId: socket.data.user.id, + name: socket.data.user.name, + }) + } catch { + // silently ignore — unauthorized or invalid payload + } + }) + + socket.on("guild:member:joined", async (payload, ack) => { + try { + const parsed = guildMemberJoinedPayloadSchema.parse(payload) + + // Verify the user is actually a member of this guild + const membership = await db + .select({ guildId: schema.guildMember.guildId }) + .from(schema.guildMember) + .where( + and( + eq(schema.guildMember.guildId, parsed.guildId), + eq(schema.guildMember.userId, socket.data.user.id) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (!membership) { + ack?.({ ok: false, error: "Forbidden" }) + return + } + + // Join the guild room so the new member receives future events + await socket.join(guildRoom(parsed.guildId)) + + // Deduplicate guildIds + const currentGuildIds = socket.data.guildIds ?? [] + if (!currentGuildIds.includes(parsed.guildId)) { + socket.data.guildIds = [...currentGuildIds, parsed.guildId] + } + + // Broadcast to other guild members + socket.to(guildRoom(parsed.guildId)).emit("guild:member:joined", { + guildId: parsed.guildId, + userId: socket.data.user.id, + name: socket.data.user.name, + username: socket.data.user.username ?? null, + image: socket.data.user.image ?? null, + }) + + ack?.({ ok: true }) + } catch (error) { + ack?.({ ok: false, error: toErrorMessage(error) }) + } + }) + socket.on("disconnect", () => { socket.data.isAlive = false diff --git a/apps/web/src/components/chat/composer/message-input.tsx b/apps/web/src/components/chat/composer/message-input.tsx index 5e90a02..d087ed2 100644 --- a/apps/web/src/components/chat/composer/message-input.tsx +++ b/apps/web/src/components/chat/composer/message-input.tsx @@ -108,6 +108,7 @@ interface MessageInputProps { clearAttachments: () => void getUploadedAttachments: () => NonNullable isUploading: boolean + onTyping?: () => void } interface SuggestionPopupListRef { @@ -413,11 +414,14 @@ export function MessageInput({ clearAttachments, getUploadedAttachments, isUploading, + onTyping, }: MessageInputProps) { const [plainText, setPlainText] = useState("") const [isAttachmentMenuOpen, setIsAttachmentMenuOpen] = useState(false) const mentionCandidatesRef = useRef([]) const fileInputRef = useRef(null) + const onTypingRef = useRef(onTyping) + onTypingRef.current = onTyping const placeholder = context.type === "channel" @@ -505,6 +509,7 @@ export function MessageInput({ }, onUpdate: ({ editor: tiptapEditor }) => { setPlainText(tiptapEditor.getText({ blockSeparator: "\n" })) + onTypingRef.current?.() }, }, [] diff --git a/apps/web/src/components/chat/typing-indicator.tsx b/apps/web/src/components/chat/typing-indicator.tsx new file mode 100644 index 0000000..78047aa --- /dev/null +++ b/apps/web/src/components/chat/typing-indicator.tsx @@ -0,0 +1,56 @@ +import { AnimatePresence, motion } from "motion/react" + +type TypingUser = { + userId: string + name: string +} + +function formatTypingText(users: TypingUser[]): string { + const first = users[0] + const second = users[1] + if (!first) return "" + if (users.length === 1) return `${first.name} is typing` + if (users.length === 2 && second) + return `${first.name} and ${second.name} are typing` + return `${first.name} and ${users.length - 1} others are typing` +} + +export function TypingIndicator({ users }: { users: TypingUser[] }) { + return ( + + {users.length > 0 && ( + +
+ + + . + + + . + + + . + + + {formatTypingText(users)} +
+
+ )} +
+ ) +} diff --git a/apps/web/src/components/invite/create-invite-dialog.tsx b/apps/web/src/components/invite/create-invite-dialog.tsx new file mode 100644 index 0000000..db6d528 --- /dev/null +++ b/apps/web/src/components/invite/create-invite-dialog.tsx @@ -0,0 +1,232 @@ +import { Button } from "@repo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import { Input } from "@repo/ui/components/input" +import { Label } from "@repo/ui/components/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/components/select" +import { useMutation } from "@tanstack/react-query" +import { useParams } from "@tanstack/react-router" +import { Check, Copy, Link } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { apiClient } from "@/lib/api-client" + +const EXPIRY_OPTIONS = [ + { label: "30 minutes", value: "30" }, + { label: "1 hour", value: "60" }, + { label: "6 hours", value: "360" }, + { label: "12 hours", value: "720" }, + { label: "1 day", value: "1440" }, + { label: "7 days", value: "10080" }, + { label: "Never", value: "never" }, +] + +const MAX_USES_OPTIONS = [ + { label: "No limit", value: "none" }, + { label: "1 use", value: "1" }, + { label: "5 uses", value: "5" }, + { label: "10 uses", value: "10" }, + { label: "25 uses", value: "25" }, + { label: "50 uses", value: "50" }, + { label: "100 uses", value: "100" }, +] + +export function CreateInviteDialog({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const { guildSlug } = useParams({ strict: false }) + const [expiresIn, setExpiresIn] = useState("1440") + const [maxUses, setMaxUses] = useState("none") + const [inviteCode, setInviteCode] = useState(null) + const [copied, setCopied] = useState(false) + + const createMutation = useMutation({ + mutationFn: async () => { + if (!guildSlug) throw new Error("Missing guild slug") + + const res = await apiClient.v1.guilds[":guildSlug"].invites.$post({ + param: { guildSlug }, + json: { + expiresInMinutes: expiresIn === "never" ? null : Number(expiresIn), + maxUses: maxUses === "none" ? null : Number(maxUses), + }, + }) + + if (!res.ok) { + const body = await res.text() + let message = "Failed to create invite" + try { + const parsed = JSON.parse(body) as { message?: string } + if (typeof parsed.message === "string") message = parsed.message + } catch { + // use default message + } + throw new Error(message) + } + + return res.json() + }, + onSuccess: (data) => { + setInviteCode(data.invite.code) + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to create invite" + ) + }, + }) + + function getInviteUrl(code: string) { + return `${window.location.origin}/invite/${code}` + } + + async function handleCopy() { + if (!inviteCode) return + try { + await navigator.clipboard.writeText(getInviteUrl(inviteCode)) + setCopied(true) + toast.success("Invite link copied!") + setTimeout(() => setCopied(false), 2000) + } catch { + toast.error("Failed to copy to clipboard") + } + } + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) { + // Reset state when closing + setInviteCode(null) + setCopied(false) + setExpiresIn("1440") + setMaxUses("none") + } + onOpenChange(nextOpen) + } + + return ( + + + + Create Invite Link + + Generate a shareable link to invite people to this guild. + + + + {inviteCode ? ( +
+
+ +
+ + +
+
+ + + + +
+ ) : ( +
+
+ + +
+
+ + +
+ + + + +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/invite/manage-invites-dialog.tsx b/apps/web/src/components/invite/manage-invites-dialog.tsx new file mode 100644 index 0000000..a890ede --- /dev/null +++ b/apps/web/src/components/invite/manage-invites-dialog.tsx @@ -0,0 +1,201 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@repo/ui/components/alert-dialog" +import { Button } from "@repo/ui/components/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@repo/ui/components/dialog" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useParams } from "@tanstack/react-router" +import { Copy, Trash2 } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { apiClient } from "@/lib/api-client" + +export function ManageInvitesDialog({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const { guildSlug } = useParams({ strict: false }) + const queryClient = useQueryClient() + const [revokeCode, setRevokeCode] = useState(null) + + const { data, isPending, isError } = useQuery({ + queryKey: ["guild-invites", guildSlug], + queryFn: async () => { + if (!guildSlug) throw new Error("Missing guild slug") + const res = await apiClient.v1.guilds[":guildSlug"].invites.$get({ + param: { guildSlug }, + }) + if (!res.ok) throw new Error("Failed to fetch invites") + return res.json() + }, + enabled: open && !!guildSlug, + }) + + const revokeMutation = useMutation({ + mutationFn: async (code: string) => { + if (!guildSlug) throw new Error("Missing guild slug") + const res = await apiClient.v1.guilds[":guildSlug"].invites[ + ":code" + ].$delete({ + param: { guildSlug, code }, + }) + if (!res.ok) throw new Error("Failed to revoke invite") + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["guild-invites", guildSlug], + }) + toast.success("Invite revoked") + setRevokeCode(null) + }, + onError: () => { + toast.error("Failed to revoke invite") + }, + }) + + async function handleCopy(code: string) { + try { + await navigator.clipboard.writeText( + `${window.location.origin}/invite/${code}` + ) + toast.success("Invite link copied!") + } catch { + toast.error("Failed to copy") + } + } + + function formatExpiry(expiresAt: string | null) { + if (!expiresAt) return "Never" + const date = new Date(expiresAt) + const now = Date.now() + const diff = date.getTime() - now + if (diff <= 0) return "Expired" + const hours = Math.floor(diff / (1000 * 60 * 60)) + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) + if (hours > 24) return `${Math.floor(hours / 24)}d` + if (hours > 0) return `${hours}h ${minutes}m` + return `${minutes}m` + } + + const invites = data?.invites ?? [] + + return ( + <> + + + + Guild Invites + + View and manage active invite links. + + + + {isPending ? ( +
+ Loading invites... +
+ ) : isError ? ( +
+ Failed to load invites. Try closing and reopening. +
+ ) : invites.length === 0 ? ( +
+ No active invites +
+ ) : ( +
+ {invites.map((invite) => ( +
+
+
+ + {invite.code} + + + {invite.uses} + {invite.maxUses !== null ? `/${invite.maxUses}` : ""}{" "} + uses + +
+
+ by {invite.inviter.name} — expires{" "} + {formatExpiry(invite.expiresAt)} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+ + { + if (!open) setRevokeCode(null) + }} + > + + + Revoke Invite + + This will permanently deactivate this invite link. Anyone with the + link will no longer be able to join. + + + + Cancel + { + e.preventDefault() + if (revokeCode) revokeMutation.mutate(revokeCode) + }} + disabled={revokeMutation.isPending} + > + {revokeMutation.isPending ? "Revoking..." : "Revoke"} + + + + + + ) +} diff --git a/apps/web/src/components/sidebar/channel-panel/guild-header.tsx b/apps/web/src/components/sidebar/channel-panel/guild-header.tsx index 6b76386..c92f275 100644 --- a/apps/web/src/components/sidebar/channel-panel/guild-header.tsx +++ b/apps/web/src/components/sidebar/channel-panel/guild-header.tsx @@ -1,11 +1,24 @@ import { authClient } from "@repo/auth/client" +import { isGuildRole } from "@repo/auth/permissions" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@repo/ui/components/dropdown-menu" import { useQuery } from "@tanstack/react-query" import { useParams } from "@tanstack/react-router" -import { ChevronDown } from "lucide-react" -import { useMemo } from "react" +import { ChevronDown, Link, UserPlus } from "lucide-react" +import { useMemo, useState } from "react" +import { CreateInviteDialog } from "@/components/invite/create-invite-dialog" +import { ManageInvitesDialog } from "@/components/invite/manage-invites-dialog" +import { canKickGuildMembers } from "@/lib/permissions" export function GuildHeader() { const { guildSlug } = useParams({ strict: false }) + const [inviteDialogOpen, setInviteDialogOpen] = useState(false) + const [manageInvitesOpen, setManageInvitesOpen] = useState(false) const { data: guilds, isPending } = useQuery({ queryKey: ["guilds"], @@ -16,6 +29,24 @@ export function GuildHeader() { }, }) + const { data: activeMember } = useQuery({ + queryKey: ["active-guild-member", guildSlug], + queryFn: async () => { + const res = await authClient.organization.getActiveMember() + if (res.error) { + if (res.error.status === 403) return null + throw res.error + } + return res.data + }, + enabled: !!guildSlug, + }) + + const canManageInvites = + typeof activeMember?.role === "string" && + isGuildRole(activeMember.role) && + canKickGuildMembers(activeMember.role) + const guildName = useMemo( () => guilds?.find((g) => g.slug === guildSlug)?.name, [guilds, guildSlug] @@ -23,13 +54,50 @@ export function GuildHeader() { const title = isPending ? "Loading..." : (guildName ?? "Guild not found") + if (!canManageInvites) { + return ( +
+

+ {title} +

+
+ ) + } + return ( - + <> + + + + + + setInviteDialogOpen(true)}> + + Invite People + + setManageInvitesOpen(true)}> + + Manage Invites + + + + + + + ) } diff --git a/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx index 345e35d..14b954a 100644 --- a/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx +++ b/apps/web/src/components/sidebar/right-panel/guild-members-panel.tsx @@ -5,7 +5,10 @@ import { type GuildRole, isGuildRole, } from "@repo/auth/permissions" -import type { PresenceUserUpdate } from "@repo/realtime-types" +import type { + GuildMemberJoinedEvent, + PresenceUserUpdate, +} from "@repo/realtime-types" import { AlertDialog, AlertDialogAction, @@ -574,9 +577,16 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { ) } + const onMemberJoined = (payload: GuildMemberJoinedEvent) => { + if (!guildId || payload.guildId !== guildId) return + // Refetch the full member list to get the new member with all fields + queryClient.invalidateQueries({ queryKey }) + } + socket.on("presence:ready", onPresenceReady) socket.on("connect", onConnect) socket.on("presence:user:update", onPresenceUpdate) + socket.on("guild:member:joined", onMemberJoined) if (socket.connected) { requestSnapshot() @@ -586,6 +596,7 @@ export function GuildMembersPanel({ view }: { view: GuildMembersSidebarView }) { socket.off("presence:ready", onPresenceReady) socket.off("connect", onConnect) socket.off("presence:user:update", onPresenceUpdate) + socket.off("guild:member:joined", onMemberJoined) } }, [socket, guildId, queryClient, queryKey]) diff --git a/apps/web/src/hooks/use-typing-indicator.ts b/apps/web/src/hooks/use-typing-indicator.ts new file mode 100644 index 0000000..659ae50 --- /dev/null +++ b/apps/web/src/hooks/use-typing-indicator.ts @@ -0,0 +1,92 @@ +import type { TypingIndicatorEvent } from "@repo/realtime-types" +import { useCallback, useEffect, useRef, useState } from "react" +import type { AppSocket } from "@/lib/socket" + +type TypingUser = { + userId: string + name: string + expiresAt: number +} + +const TYPING_THROTTLE_MS = 3000 +const TYPING_EXPIRE_MS = 5000 + +export function useTypingIndicator({ + socket, + channelId, + currentUserId, +}: { + socket: AppSocket | null + channelId: string + currentUserId: string | undefined +}) { + const [typingUsers, setTypingUsers] = useState([]) + const lastEmitRef = useRef(0) + const cleanupTimerRef = useRef | null>(null) + + // Emit typing event (throttled) + const emitTyping = useCallback(() => { + if (!socket?.connected) return + const now = Date.now() + if (now - lastEmitRef.current < TYPING_THROTTLE_MS) return + lastEmitRef.current = now + socket.emit("typing:start", { channelId }) + }, [socket, channelId]) + + // Listen for typing events from others + useEffect(() => { + if (!socket) return + + const onTypingUpdate = (payload: TypingIndicatorEvent) => { + if (payload.channelId !== channelId) return + if (payload.userId === currentUserId) return + + setTypingUsers((prev) => { + const expiresAt = Date.now() + TYPING_EXPIRE_MS + const existing = prev.find((u) => u.userId === payload.userId) + if (existing) { + return prev.map((u) => + u.userId === payload.userId ? { ...u, expiresAt } : u + ) + } + return [ + ...prev, + { userId: payload.userId, name: payload.name, expiresAt }, + ] + }) + } + + socket.on("typing:update", onTypingUpdate) + + return () => { + socket.off("typing:update", onTypingUpdate) + } + }, [socket, channelId, currentUserId]) + + // Cleanup expired entries + useEffect(() => { + cleanupTimerRef.current = setInterval(() => { + const now = Date.now() + setTypingUsers((prev) => { + const filtered = prev.filter((u) => u.expiresAt > now) + if (filtered.length === prev.length) return prev + return filtered + }) + }, 1000) + + return () => { + if (cleanupTimerRef.current) clearInterval(cleanupTimerRef.current) + } + }, []) + + // Reset when channel changes + useEffect(() => { + setTypingUsers([]) + lastEmitRef.current = 0 + }, [channelId]) + + return { + typingUsers, + emitTyping, + } +} diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 18b9bc6..a58634c 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -47,6 +47,23 @@ export type ListDMMessagesResponse = InferResponseType< 200 > +// ── Guild Invites ────────────────────────────────────────── + +type GuildInvitesClient = Client["v1"]["guilds"][":guildSlug"]["invites"] + +export type ListGuildInvitesResponse = InferResponseType< + GuildInvitesClient["$get"], + 200 +> +export type GuildInvite = ListGuildInvitesResponse["invites"][number] + +type InvitePreviewClient = Client["v1"]["invites"][":code"] + +export type InvitePreviewResponse = InferResponseType< + InvitePreviewClient["$get"], + 200 +> + // ── Guild Members ────────────────────────────────────────── type GuildMembersClient = Client["v1"]["guilds"][":guildSlug"]["members"] diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index 1c85cd9..eca8331 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -8,6 +8,7 @@ import { MessageInput } from "@/components/chat/composer/message-input" import { DropZoneOverlay } from "@/components/chat/drop-zone-overlay" import { ChatHeader } from "@/components/chat/header" import { MessageList } from "@/components/chat/message-list" +import { TypingIndicator } from "@/components/chat/typing-indicator" import { useRightSidebar } from "@/components/sidebar/right-panel/right-sidebar-context" import { useSocket } from "@/context/socket-context" import { useFileUpload } from "@/hooks/use-file-upload" @@ -16,6 +17,7 @@ import { useMessageEditing } from "@/hooks/use-message-editing" import { useMessageReactions } from "@/hooks/use-message-reactions" import { useMessageSending } from "@/hooks/use-message-sending" import { useReplyState } from "@/hooks/use-reply-state" +import { useTypingIndicator } from "@/hooks/use-typing-indicator" import { apiClient } from "@/lib/api-client" import type { ListMessagesResponse } from "@/lib/api-types" @@ -120,6 +122,12 @@ function ChannelView() { const { replyingTo, setReplyingTo, clearReply } = useReplyState() + const { typingUsers, emitTyping } = useTypingIndicator({ + socket, + channelId, + currentUserId, + }) + // Clear reply state when switching channels useEffect(() => { clearReply() @@ -208,6 +216,7 @@ function ChannelView() { mentionCandidates={mentionCandidates} isLoading={messagesLoading} /> + ) diff --git a/apps/web/src/routes/_authenticated/dms/$dmId.tsx b/apps/web/src/routes/_authenticated/dms/$dmId.tsx index 98e9670..4cf965e 100644 --- a/apps/web/src/routes/_authenticated/dms/$dmId.tsx +++ b/apps/web/src/routes/_authenticated/dms/$dmId.tsx @@ -8,6 +8,7 @@ import { MessageInput } from "@/components/chat/composer/message-input" import { DropZoneOverlay } from "@/components/chat/drop-zone-overlay" import { ChatHeader } from "@/components/chat/header" import { MessageList } from "@/components/chat/message-list" +import { TypingIndicator } from "@/components/chat/typing-indicator" import { useSocket } from "@/context/socket-context" import { useFileUpload } from "@/hooks/use-file-upload" import { useMessageDeletion } from "@/hooks/use-message-deletion" @@ -15,6 +16,7 @@ import { useMessageEditing } from "@/hooks/use-message-editing" import { useMessageReactions } from "@/hooks/use-message-reactions" import { useMessageSending } from "@/hooks/use-message-sending" import { useReplyState } from "@/hooks/use-reply-state" +import { useTypingIndicator } from "@/hooks/use-typing-indicator" import { apiClient } from "@/lib/api-client" import type { ListDMMessagesResponse } from "@/lib/api-types" @@ -90,6 +92,12 @@ function DMConversation() { const { replyingTo, setReplyingTo, clearReply } = useReplyState() + const { typingUsers, emitTyping } = useTypingIndicator({ + socket, + channelId: dmId, + currentUserId, + }) + // Clear reply state when switching DMs useEffect(() => { clearReply() @@ -175,6 +183,7 @@ function DMConversation() { mentionCandidates={mentionCandidates} isLoading={messagesLoading} /> + ) diff --git a/apps/web/src/routes/_authenticated/invite/$code.tsx b/apps/web/src/routes/_authenticated/invite/$code.tsx new file mode 100644 index 0000000..eb88d45 --- /dev/null +++ b/apps/web/src/routes/_authenticated/invite/$code.tsx @@ -0,0 +1,194 @@ +import { Button } from "@repo/ui/components/button" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { Shield, Users } from "lucide-react" +import { toast } from "sonner" +import { useSocket } from "@/context/socket-context" +import { apiClient } from "@/lib/api-client" + +export const Route = createFileRoute("/_authenticated/invite/$code")({ + component: InvitePage, +}) + +function InvitePage() { + const { code } = Route.useParams() + const navigate = useNavigate() + const socket = useSocket() + const queryClient = useQueryClient() + + const { + data: preview, + isPending, + isError, + } = useQuery({ + queryKey: ["invite-preview", code], + queryFn: async () => { + const res = await apiClient.v1.invites[":code"].$get({ + param: { code }, + }) + if (!res.ok) { + throw new Error("Invite not found") + } + return res.json() + }, + }) + + const acceptMutation = useMutation({ + mutationFn: async () => { + const res = await apiClient.v1.invites[":code"].accept.$post({ + param: { code }, + }) + if (!res.ok) { + const body = await res.text() + let message = "Failed to join guild" + try { + const parsed = JSON.parse(body) as { message?: string } + if (typeof parsed.message === "string") message = parsed.message + } catch { + // use default + } + throw new Error(message) + } + return res.json() + }, + onSuccess: (data) => { + if (socket?.connected) { + socket.emit("guild:member:joined", { guildId: data.guild.id }) + } + queryClient.invalidateQueries({ queryKey: ["guilds"] }) + toast.success(`Joined ${data.guild.name}!`) + navigate({ to: "/$guildSlug", params: { guildSlug: data.guild.slug } }) + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to join guild" + ) + }, + }) + + function goHome() { + navigate({ to: "/" }) + } + + // Error / expired states + let errorContent: { title: string; description: string } | null = null + + if (!isPending && (isError || !preview)) { + errorContent = { + title: "Invalid Invite", + description: "This invite link is invalid or has expired.", + } + } else if (!isPending && preview?.invite.isExpired) { + errorContent = { + title: "Invite Expired", + description: "This invite link has expired or reached its maximum uses.", + } + } + + if (errorContent) { + return ( + // biome-ignore lint/a11y/useSemanticElements: backdrop overlay for dismiss on click +
e.key === "Escape" && goHome()} + > + {/* biome-ignore lint/a11y/noStaticElementInteractions: stop click propagation to backdrop */} +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + +

{errorContent.title}

+

+ {errorContent.description} +

+ +
+
+ ) + } + + const invite = preview?.invite + + return ( + // biome-ignore lint/a11y/useSemanticElements: backdrop overlay for dismiss on click +
e.key === "Escape" && goHome()} + > + {/* biome-ignore lint/a11y/noStaticElementInteractions: stop click propagation to backdrop */} +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {isPending ? ( +
+ Loading invite... +
+ ) : invite ? ( +
+ {invite.guild.logo ? ( + {invite.guild.name} + ) : ( +
+ {invite.guild.name.charAt(0).toUpperCase()} +
+ )} + +
+

{invite.guild.name}

+

+ + {invite.guild.memberCount}{" "} + {invite.guild.memberCount === 1 ? "member" : "members"} +

+
+ +

+ + {invite.inviter.name} + {" "} + invited you to join +

+ + {invite.isMember ? ( + + ) : ( + + )} +
+ ) : null} +
+
+ ) +} diff --git a/packages/db/src/schemas/guild-invites.ts b/packages/db/src/schemas/guild-invites.ts new file mode 100644 index 0000000..9d1f2e0 --- /dev/null +++ b/packages/db/src/schemas/guild-invites.ts @@ -0,0 +1,53 @@ +import { relations } from "drizzle-orm" +import { + index, + integer, + pgTable, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core" +import { channel } from "./channels" +import { guild } from "./guilds" +import { user } from "./users" + +export const guildInvite = pgTable( + "guild_invite", + { + id: uuid("id").defaultRandom().primaryKey(), + guildId: uuid("guild_id") + .notNull() + .references(() => guild.id, { onDelete: "cascade" }), + code: varchar("code", { length: 12 }).notNull(), + inviterId: uuid("inviter_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + channelId: uuid("channel_id").references(() => channel.id, { + onDelete: "set null", + }), + maxUses: integer("max_uses"), + uses: integer("uses").default(0).notNull(), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => [ + uniqueIndex("guildInvite_code_uidx").on(table.code), + index("guildInvite_guildId_idx").on(table.guildId), + ] +) + +export const guildInviteRelations = relations(guildInvite, ({ one }) => ({ + guild: one(guild, { + fields: [guildInvite.guildId], + references: [guild.id], + }), + inviter: one(user, { + fields: [guildInvite.inviterId], + references: [user.id], + }), + channel: one(channel, { + fields: [guildInvite.channelId], + references: [channel.id], + }), +})) diff --git a/packages/db/src/schemas/guilds.ts b/packages/db/src/schemas/guilds.ts index 0ae2025..543b019 100644 --- a/packages/db/src/schemas/guilds.ts +++ b/packages/db/src/schemas/guilds.ts @@ -12,6 +12,7 @@ import { createUpdateSchema, } from "drizzle-zod" import { guildBan } from "./guild-bans" +import { guildInvite } from "./guild-invites" import { guildMember } from "./guild-members" import { guildRole } from "./guild-roles" import { invitation } from "./invitations" @@ -42,6 +43,7 @@ export const guildRelations = relations(guild, ({ one, many }) => ({ guildRoles: many(guildRole), guildMembers: many(guildMember), invitations: many(invitation), + guildInvites: many(guildInvite), })) // Zod schemas diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts index 682d43e..4e6144e 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -2,6 +2,7 @@ export * from "./accounts" export * from "./channel-read-states" export * from "./channels" export * from "./guild-bans" +export * from "./guild-invites" export * from "./guild-members" export * from "./guild-roles" export * from "./guilds" diff --git a/packages/realtime-types/src/events.ts b/packages/realtime-types/src/events.ts index b0cba4f..e66471c 100644 --- a/packages/realtime-types/src/events.ts +++ b/packages/realtime-types/src/events.ts @@ -211,6 +211,36 @@ export type MentionNotification = { createdAt: string } +export const guildMemberJoinedPayloadSchema = z.object({ + guildId: z.string().uuid(), +}) + +export type GuildMemberJoinedPayload = z.infer< + typeof guildMemberJoinedPayloadSchema +> + +export type GuildMemberJoinedEvent = { + guildId: string + userId: string + name: string + username: string | null + image: string | null +} + +export type GuildMemberJoinedAck = (result: OkResult | ErrorResult) => void + +export const typingStartPayloadSchema = z.object({ + channelId: z.string().uuid(), +}) + +export type TypingStartPayload = z.infer + +export type TypingIndicatorEvent = { + channelId: string + userId: string + name: string +} + export interface ClientToServerEvents { "presence:subscribe": ( payload: PresenceSubscribePayload, @@ -232,6 +262,11 @@ export interface ClientToServerEvents { payload: MarkChannelReadPayload, ack?: MarkChannelReadAck ) => void + "guild:member:joined": ( + payload: GuildMemberJoinedPayload, + ack?: GuildMemberJoinedAck + ) => void + "typing:start": (payload: TypingStartPayload) => void } export interface ServerToClientEvents { @@ -256,6 +291,8 @@ export interface ServerToClientEvents { "notification:unread": (payload: UnreadNotification) => void "notification:mention": (payload: MentionNotification) => void "channel:read-state": (payload: ChannelReadState) => void + "guild:member:joined": (payload: GuildMemberJoinedEvent) => void + "typing:update": (payload: TypingIndicatorEvent) => void } export type InterServerEvents = Record