diff --git a/ROADMAP.md b/ROADMAP.md index 3c9ee37..65af220 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,14 +31,14 @@ ## Phase 2 — Permissions & Moderation - [ ] Granular permission system (beyond owner/admin/member) -- [~] Member management UI (kick, banish, silence, role assignment) (in progress in this PR) +- [x] Member management UI (kick, banish, silence, role assignment) - [ ] Rate limiting enforcement (API-level + per-channel) - [ ] Audit logs ## Phase 3 — Social Features - [x] Shareable invite links (not just email invites) — schema, API, and UI implemented -- [ ] Ally (friend) system with requests +- [x] Ally (friend) system with requests — schema, API, allies page, user profile popover with ally actions - [ ] User blocking - [ ] Privacy settings @@ -79,6 +79,7 @@ ## Backlog (Short-Term Hardening) +- [x] Fix stale presence after server restart (heartbeat TTL + reconciliation sweep). - [ ] Add explicit error logging in `initializeConnection` before disconnecting a socket (include `socket.id` + `userId` context). - [ ] Update onboarding `normalizeSlugInput` to collapse repeated hyphens while typing. - [ ] Use `DM_CHANNEL_TYPES` constant everywhere in DM route filters to avoid drift. diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index be010fc..ca910fc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,11 +4,13 @@ import createApp from "@/lib/helpers/app/create-app" import configureOpenAPI from "@/lib/helpers/openapi/configure-openapi" import { globalRateLimit } from "@/middleware/rate-limit" import index from "@/routes/index.route" +import alliesRouter from "@/routes/v1/allies/index" 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 usersRouter from "@/routes/v1/users/index" import waitlistRouter from "@/routes/waitlist/index" const app = createApp() @@ -35,11 +37,13 @@ app.route("/", index) // Route mounting — chained for Hono RPC type inference const routes = app .route("/", waitlistRouter) + .route("/v1", alliesRouter) .route("/v1", channelsRouter) .route("/v1", guildsRouter) .route("/v1", invitesRouter) .route("/v1", dmsRouter) .route("/v1", uploadsRouter) + .route("/v1", usersRouter) export type AppType = typeof routes diff --git a/apps/api/src/lib/helpers/openapi/message-schemas.ts b/apps/api/src/lib/helpers/openapi/message-schemas.ts index e034651..22d85c3 100644 --- a/apps/api/src/lib/helpers/openapi/message-schemas.ts +++ b/apps/api/src/lib/helpers/openapi/message-schemas.ts @@ -14,6 +14,12 @@ export const messageReactionSchema = z.object({ emoji: z.string(), count: z.number().int().nonnegative(), reactedByCurrentUser: z.boolean(), + reactors: z.array( + z.object({ + id: z.string().uuid(), + name: z.string(), + }) + ), }) const httpsUrlSchema = z.string().regex(/^https?:\/\//i) diff --git a/apps/api/src/lib/helpers/openapi/schemas.ts b/apps/api/src/lib/helpers/openapi/schemas.ts index 9ab7b26..9830393 100644 --- a/apps/api/src/lib/helpers/openapi/schemas.ts +++ b/apps/api/src/lib/helpers/openapi/schemas.ts @@ -44,6 +44,13 @@ export const payloadTooLargeSchema = jsonContent({ description: "Payload too large", }) +export const badRequestSchema = jsonContent({ + schema: errorSchema.openapi({ + example: { success: false, message: "Bad request" }, + }), + description: "Bad request", +}) + export const notFoundSchema = jsonContent({ schema: errorSchema.openapi({ example: { success: false, message: "Not found" }, diff --git a/apps/api/src/lib/queries/messages.ts b/apps/api/src/lib/queries/messages.ts index d136640..2dff896 100644 --- a/apps/api/src/lib/queries/messages.ts +++ b/apps/api/src/lib/queries/messages.ts @@ -76,8 +76,10 @@ export async function fetchMessagePage( messageId: messageReaction.messageId, emoji: messageReaction.emoji, userId: messageReaction.userId, + userName: user.name, }) .from(messageReaction) + .innerJoin(user, eq(messageReaction.userId, user.id)) .where(inArray(messageReaction.messageId, messageIds)) : [] @@ -138,6 +140,7 @@ export async function fetchMessagePage( emoji: string count: number reactedByCurrentUser: boolean + reactors: Array<{ id: string; name: string }> } > >() @@ -161,9 +164,14 @@ export async function fetchMessagePage( emoji: reactionRow.emoji, count: 0, reactedByCurrentUser: false, + reactors: [], } existingReaction.count += 1 + existingReaction.reactors.push({ + id: reactionRow.userId, + name: reactionRow.userName, + }) if (reactionRow.userId === currentUserId) { existingReaction.reactedByCurrentUser = true } diff --git a/apps/api/src/routes/v1/allies/handlers.ts b/apps/api/src/routes/v1/allies/handlers.ts new file mode 100644 index 0000000..dc7b641 --- /dev/null +++ b/apps/api/src/routes/v1/allies/handlers.ts @@ -0,0 +1,546 @@ +import { and, db, eq, inArray, or, schema } from "@repo/db" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import type { AppRouteHandler } from "@/lib/types/app-types" +import type { + AcceptAllyRequestRoute, + DeclineAllyRequestRoute, + ListAlliesRoute, + ListAllyRequestsRoute, + RemoveAllyRoute, + SendAllyRequestRoute, +} from "./routes" + +// ── Helpers ────────────────────────────────────────────── + +function toUserResponse(user: { + id: string + name: string + username: string | null + displayUsername: string | null + image: string | null +}) { + return { + id: user.id, + name: user.name, + username: user.username, + displayUsername: user.displayUsername, + image: user.image, + } +} + +function toAllyRequestResponse( + request: { + id: string + status: "pending" | "accepted" | "declined" + createdAt: Date + }, + sender: { + id: string + name: string + username: string | null + displayUsername: string | null + image: string | null + }, + receiver: { + id: string + name: string + username: string | null + displayUsername: string | null + image: string | null + } +) { + return { + id: request.id, + sender: toUserResponse(sender), + receiver: toUserResponse(receiver), + status: request.status, + createdAt: request.createdAt.toISOString(), + } +} + +// ── Handlers ────────────────────────────────────────────── + +export const sendAllyRequest: AppRouteHandler = async ( + c +) => { + const currentUser = c.var.user + const { userId: targetUserId } = c.req.valid("json") + + if (currentUser.id === targetUserId) { + return c.json( + { success: false, message: "Cannot send an ally request to yourself" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + // Check target user exists + const targetUser = await db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }) + .from(schema.user) + .where(eq(schema.user.id, targetUserId)) + .limit(1) + .then((rows) => rows[0]) + + if (!targetUser) { + return c.json( + { success: false, message: "User not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + // Check for existing relationship (in either direction) + const existing = await db + .select({ + id: schema.allyRequest.id, + status: schema.allyRequest.status, + senderId: schema.allyRequest.senderId, + }) + .from(schema.allyRequest) + .where( + or( + and( + eq(schema.allyRequest.senderId, currentUser.id), + eq(schema.allyRequest.receiverId, targetUserId) + ), + and( + eq(schema.allyRequest.senderId, targetUserId), + eq(schema.allyRequest.receiverId, currentUser.id) + ) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (existing) { + if (existing.status === "accepted") { + return c.json( + { success: false, message: "You are already allies" }, + HttpStatusCodes.BAD_REQUEST + ) + } + if (existing.status === "pending") { + return c.json( + { success: false, message: "An ally request already exists" }, + HttpStatusCodes.BAD_REQUEST + ) + } + // Status is "declined" — replace atomically to avoid race conditions + const [request] = await db.transaction(async (tx) => { + await tx + .delete(schema.allyRequest) + .where(eq(schema.allyRequest.id, existing.id)) + return tx + .insert(schema.allyRequest) + .values({ + senderId: currentUser.id, + receiverId: targetUserId, + }) + .returning() + }) + + if (!request) { + return c.json( + { success: false, message: "Failed to create ally request" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ) + } + + const sender = await db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }) + .from(schema.user) + .where(eq(schema.user.id, currentUser.id)) + .limit(1) + .then((rows) => rows[0]) + + if (!sender) { + return c.json( + { success: false, message: "Failed to fetch user data" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ) + } + + return c.json( + { + success: true, + request: toAllyRequestResponse(request, sender, targetUser), + }, + HttpStatusCodes.OK + ) + } + + const [request] = await db + .insert(schema.allyRequest) + .values({ + senderId: currentUser.id, + receiverId: targetUserId, + }) + .returning() + + if (!request) { + return c.json( + { success: false, message: "Failed to create ally request" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ) + } + + const sender = await db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }) + .from(schema.user) + .where(eq(schema.user.id, currentUser.id)) + .limit(1) + .then((rows) => rows[0]) + + if (!sender) { + return c.json( + { success: false, message: "Failed to fetch user data" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ) + } + + return c.json( + { + success: true, + request: toAllyRequestResponse(request, sender, targetUser), + }, + HttpStatusCodes.OK + ) +} + +export const listAllyRequests: AppRouteHandler = async ( + c +) => { + const currentUser = c.var.user + + const pendingRequests = await db + .select({ + id: schema.allyRequest.id, + senderId: schema.allyRequest.senderId, + receiverId: schema.allyRequest.receiverId, + status: schema.allyRequest.status, + createdAt: schema.allyRequest.createdAt, + senderName: schema.user.name, + senderUsername: schema.user.username, + senderDisplayUsername: schema.user.displayUsername, + senderImage: schema.user.image, + }) + .from(schema.allyRequest) + .innerJoin(schema.user, eq(schema.allyRequest.senderId, schema.user.id)) + .where( + and( + eq(schema.allyRequest.status, "pending"), + or( + eq(schema.allyRequest.senderId, currentUser.id), + eq(schema.allyRequest.receiverId, currentUser.id) + ) + ) + ) + + // We need receiver info too — fetch separately for the user IDs we need + const receiverIds = [ + ...new Set(pendingRequests.map((r) => r.receiverId)), + ].filter((id) => id !== currentUser.id) + + const receivers = + receiverIds.length > 0 + ? await db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }) + .from(schema.user) + .where(inArray(schema.user.id, receiverIds)) + : [] + + const receiverMap = new Map(receivers.map((r) => [r.id, r])) + const currentUserInfo = { + id: currentUser.id, + name: currentUser.name, + username: currentUser.username ?? null, + displayUsername: currentUser.displayUsername ?? null, + image: currentUser.image ?? null, + } + + const incoming: ReturnType[] = [] + const outgoing: ReturnType[] = [] + + for (const row of pendingRequests) { + const sender = { + id: row.senderId, + name: row.senderName, + username: row.senderUsername, + displayUsername: row.senderDisplayUsername, + image: row.senderImage, + } + + const receiver = + row.receiverId === currentUser.id + ? currentUserInfo + : receiverMap.get(row.receiverId) + + if (!receiver) continue + + const response = toAllyRequestResponse( + { id: row.id, status: row.status, createdAt: row.createdAt }, + sender, + receiver + ) + + if (row.receiverId === currentUser.id) { + incoming.push(response) + } else { + outgoing.push(response) + } + } + + return c.json({ incoming, outgoing }, HttpStatusCodes.OK) +} + +export const acceptAllyRequest: AppRouteHandler< + AcceptAllyRequestRoute +> = async (c) => { + const currentUser = c.var.user + const { requestId } = c.req.valid("param") + + const request = await db + .select() + .from(schema.allyRequest) + .where(eq(schema.allyRequest.id, requestId)) + .limit(1) + .then((rows) => rows[0]) + + if (!request) { + return c.json( + { success: false, message: "Ally request not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + if (request.receiverId !== currentUser.id) { + return c.json( + { success: false, message: "Forbidden" }, + HttpStatusCodes.FORBIDDEN + ) + } + + if (request.status !== "pending") { + return c.json( + { success: false, message: "Request is no longer pending" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + const [updated] = await db + .update(schema.allyRequest) + .set({ status: "accepted", updatedAt: new Date() }) + .where( + and( + eq(schema.allyRequest.id, requestId), + eq(schema.allyRequest.receiverId, currentUser.id), + eq(schema.allyRequest.status, "pending") + ) + ) + .returning() + + if (!updated) { + return c.json( + { success: false, message: "Request is no longer pending" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + // Fetch both users for the response + const [sender, receiver] = await Promise.all([ + db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }) + .from(schema.user) + .where(eq(schema.user.id, updated.senderId)) + .limit(1) + .then((rows) => rows[0]), + db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }) + .from(schema.user) + .where(eq(schema.user.id, updated.receiverId)) + .limit(1) + .then((rows) => rows[0]), + ]) + + if (!sender || !receiver) { + return c.json( + { success: false, message: "Failed to fetch user data" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ) + } + + return c.json( + { + success: true, + request: toAllyRequestResponse(updated, sender, receiver), + }, + HttpStatusCodes.OK + ) +} + +export const declineAllyRequest: AppRouteHandler< + DeclineAllyRequestRoute +> = async (c) => { + const currentUser = c.var.user + const { requestId } = c.req.valid("param") + + const request = await db + .select() + .from(schema.allyRequest) + .where(eq(schema.allyRequest.id, requestId)) + .limit(1) + .then((rows) => rows[0]) + + if (!request) { + return c.json( + { success: false, message: "Ally request not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + if (request.receiverId !== currentUser.id) { + return c.json( + { success: false, message: "Forbidden" }, + HttpStatusCodes.FORBIDDEN + ) + } + + if (request.status !== "pending") { + return c.json( + { success: false, message: "Request is no longer pending" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + const updated = await db + .update(schema.allyRequest) + .set({ status: "declined", updatedAt: new Date() }) + .where( + and( + eq(schema.allyRequest.id, requestId), + eq(schema.allyRequest.receiverId, currentUser.id), + eq(schema.allyRequest.status, "pending") + ) + ) + .returning() + + if (updated.length === 0) { + return c.json( + { success: false, message: "Request is no longer pending" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + return c.json({ success: true }, HttpStatusCodes.OK) +} + +export const listAllies: AppRouteHandler = async (c) => { + const currentUser = c.var.user + + // Get all accepted ally requests where current user is sender or receiver + const acceptedRequests = await db + .select({ + senderId: schema.allyRequest.senderId, + receiverId: schema.allyRequest.receiverId, + }) + .from(schema.allyRequest) + .where( + and( + eq(schema.allyRequest.status, "accepted"), + or( + eq(schema.allyRequest.senderId, currentUser.id), + eq(schema.allyRequest.receiverId, currentUser.id) + ) + ) + ) + + // Extract the ally user IDs (the other person in each pair) + const allyIds = acceptedRequests.map((r) => + r.senderId === currentUser.id ? r.receiverId : r.senderId + ) + + if (allyIds.length === 0) { + return c.json({ allies: [] }, HttpStatusCodes.OK) + } + + const allies = await db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + }) + .from(schema.user) + .where(inArray(schema.user.id, allyIds)) + + return c.json({ allies: allies.map(toUserResponse) }, HttpStatusCodes.OK) +} + +export const removeAlly: AppRouteHandler = async (c) => { + const currentUser = c.var.user + const { userId: allyUserId } = c.req.valid("param") + + const deleted = await db + .delete(schema.allyRequest) + .where( + and( + eq(schema.allyRequest.status, "accepted"), + or( + and( + eq(schema.allyRequest.senderId, currentUser.id), + eq(schema.allyRequest.receiverId, allyUserId) + ), + and( + eq(schema.allyRequest.senderId, allyUserId), + eq(schema.allyRequest.receiverId, currentUser.id) + ) + ) + ) + ) + .returning() + + if (deleted.length === 0) { + return c.json( + { success: false, message: "Ally relationship not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + return c.json({ success: true }, HttpStatusCodes.OK) +} diff --git a/apps/api/src/routes/v1/allies/index.ts b/apps/api/src/routes/v1/allies/index.ts new file mode 100644 index 0000000..fe5100c --- /dev/null +++ b/apps/api/src/routes/v1/allies/index.ts @@ -0,0 +1,13 @@ +import { createRouter } from "@/lib/helpers/app/create-app" +import * as handlers from "@/routes/v1/allies/handlers" +import * as routes from "@/routes/v1/allies/routes" + +const alliesRouter = createRouter() + .openapi(routes.sendAllyRequest, handlers.sendAllyRequest) + .openapi(routes.listAllyRequests, handlers.listAllyRequests) + .openapi(routes.acceptAllyRequest, handlers.acceptAllyRequest) + .openapi(routes.declineAllyRequest, handlers.declineAllyRequest) + .openapi(routes.listAllies, handlers.listAllies) + .openapi(routes.removeAlly, handlers.removeAlly) + +export default alliesRouter diff --git a/apps/api/src/routes/v1/allies/routes.ts b/apps/api/src/routes/v1/allies/routes.ts new file mode 100644 index 0000000..ba9dd9c --- /dev/null +++ b/apps/api/src/routes/v1/allies/routes.ts @@ -0,0 +1,162 @@ +import { createRoute } from "@hono/zod-openapi" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import jsonContent from "@/lib/helpers/openapi/json-content" +import { + badRequestSchema, + forbiddenSchema, + internalServerErrorSchema, + notFoundSchema, + unauthorizedSchema, +} from "@/lib/helpers/openapi/schemas" +import { sessionAuthMiddleware } from "@/middleware/session-auth" +import { + acceptAllyRequestResponseSchema, + allyUserIdParamsSchema, + declineAllyRequestResponseSchema, + listAlliesResponseSchema, + listAllyRequestsResponseSchema, + removeAllyResponseSchema, + requestIdParamsSchema, + sendAllyRequestBodySchema, + sendAllyRequestResponseSchema, +} from "./schema" + +export const sendAllyRequest = createRoute({ + path: "/allies/requests", + method: "post", + summary: "Send an ally request", + description: "Sends an ally request to another user.", + tags: ["Allies"], + middleware: [sessionAuthMiddleware] as const, + request: { + body: jsonContent({ + schema: sendAllyRequestBodySchema, + description: "Target user to send ally request to", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: sendAllyRequestResponseSchema, + description: "Ally request sent", + }), + [HttpStatusCodes.BAD_REQUEST]: badRequestSchema, + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type SendAllyRequestRoute = typeof sendAllyRequest + +export const listAllyRequests = createRoute({ + path: "/allies/requests", + method: "get", + summary: "List pending ally requests", + description: + "Returns incoming and outgoing pending ally requests for the current user.", + tags: ["Allies"], + middleware: [sessionAuthMiddleware] as const, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: listAllyRequestsResponseSchema, + description: "Pending ally requests", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type ListAllyRequestsRoute = typeof listAllyRequests + +export const acceptAllyRequest = createRoute({ + path: "/allies/requests/{requestId}/accept", + method: "post", + summary: "Accept an ally request", + description: "Accepts a pending incoming ally request.", + tags: ["Allies"], + middleware: [sessionAuthMiddleware] as const, + request: { + params: requestIdParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: acceptAllyRequestResponseSchema, + description: "Ally request accepted", + }), + [HttpStatusCodes.BAD_REQUEST]: badRequestSchema, + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type AcceptAllyRequestRoute = typeof acceptAllyRequest + +export const declineAllyRequest = createRoute({ + path: "/allies/requests/{requestId}/decline", + method: "post", + summary: "Decline an ally request", + description: + "Declines a pending incoming ally request. The sender can re-request later.", + tags: ["Allies"], + middleware: [sessionAuthMiddleware] as const, + request: { + params: requestIdParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: declineAllyRequestResponseSchema, + description: "Ally request declined", + }), + [HttpStatusCodes.BAD_REQUEST]: badRequestSchema, + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type DeclineAllyRequestRoute = typeof declineAllyRequest + +export const listAllies = createRoute({ + path: "/allies", + method: "get", + summary: "List allies", + description: "Returns all allies for the current user.", + tags: ["Allies"], + middleware: [sessionAuthMiddleware] as const, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: listAlliesResponseSchema, + description: "List of allies", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type ListAlliesRoute = typeof listAllies + +export const removeAlly = createRoute({ + path: "/allies/{userId}", + method: "delete", + summary: "Remove an ally", + description: "Removes an ally relationship. Either user can remove.", + tags: ["Allies"], + middleware: [sessionAuthMiddleware] as const, + request: { + params: allyUserIdParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: removeAllyResponseSchema, + description: "Ally removed", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type RemoveAllyRoute = typeof removeAlly diff --git a/apps/api/src/routes/v1/allies/schema.ts b/apps/api/src/routes/v1/allies/schema.ts new file mode 100644 index 0000000..00698de --- /dev/null +++ b/apps/api/src/routes/v1/allies/schema.ts @@ -0,0 +1,75 @@ +import { z } from "@hono/zod-openapi" +import { selectAllyRequestSchema } from "@repo/db/schema" + +// ── Path Params ────────────────────────────────────────── + +export const requestIdParamsSchema = z.object({ + requestId: z + .string() + .uuid() + .openapi({ + param: { name: "requestId", in: "path", required: true }, + example: "00000000-0000-0000-0000-000000000000", + }), +}) + +export const allyUserIdParamsSchema = z.object({ + userId: z + .string() + .uuid() + .openapi({ + param: { name: "userId", in: "path", required: true }, + example: "00000000-0000-0000-0000-000000000000", + }), +}) + +// ── Request Schemas ────────────────────────────────────── + +export const sendAllyRequestBodySchema = z.object({ + userId: z.string().uuid(), +}) + +// ── Response Schemas ────────────────────────────────────── + +const allyUserSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + username: z.string().nullable(), + displayUsername: z.string().nullable(), + image: z.string().nullable(), +}) + +export const allyRequestResponseSchema = z.object({ + id: selectAllyRequestSchema.shape.id, + sender: allyUserSchema, + receiver: allyUserSchema, + status: selectAllyRequestSchema.shape.status, + createdAt: z.string().datetime(), +}) + +export const sendAllyRequestResponseSchema = z.object({ + success: z.literal(true), + request: allyRequestResponseSchema, +}) + +export const listAllyRequestsResponseSchema = z.object({ + incoming: z.array(allyRequestResponseSchema), + outgoing: z.array(allyRequestResponseSchema), +}) + +export const acceptAllyRequestResponseSchema = z.object({ + success: z.literal(true), + request: allyRequestResponseSchema, +}) + +export const declineAllyRequestResponseSchema = z.object({ + success: z.literal(true), +}) + +export const listAlliesResponseSchema = z.object({ + allies: z.array(allyUserSchema), +}) + +export const removeAllyResponseSchema = z.object({ + success: z.literal(true), +}) diff --git a/apps/api/src/routes/v1/channels/handlers.ts b/apps/api/src/routes/v1/channels/handlers.ts index 044206d..5490d8d 100644 --- a/apps/api/src/routes/v1/channels/handlers.ts +++ b/apps/api/src/routes/v1/channels/handlers.ts @@ -364,8 +364,10 @@ export const listPinnedMessages: AppRouteHandler< messageId: messageReaction.messageId, emoji: messageReaction.emoji, userId: messageReaction.userId, + userName: user.name, }) .from(messageReaction) + .innerJoin(user, eq(messageReaction.userId, user.id)) .where(inArray(messageReaction.messageId, messageIds)) : [] @@ -416,7 +418,15 @@ export const listPinnedMessages: AppRouteHandler< const reactionsByMessageId = new Map< string, - Map + Map< + string, + { + emoji: string + count: number + reactedByCurrentUser: boolean + reactors: Array<{ id: string; name: string }> + } + > >() for (const row of reactionRows) { const reactionsByEmoji = @@ -425,8 +435,10 @@ export const listPinnedMessages: AppRouteHandler< emoji: row.emoji, count: 0, reactedByCurrentUser: false, + reactors: [], } existing.count += 1 + existing.reactors.push({ id: row.userId, name: row.userName }) if (row.userId === currentUser.id) { existing.reactedByCurrentUser = true } diff --git a/apps/api/src/routes/v1/dms/handlers.ts b/apps/api/src/routes/v1/dms/handlers.ts index be01d52..c02fd28 100644 --- a/apps/api/src/routes/v1/dms/handlers.ts +++ b/apps/api/src/routes/v1/dms/handlers.ts @@ -1,10 +1,21 @@ import { db } from "@repo/db" -import { channel, channelMember, message, user } from "@repo/db/schema" -import { and, count, desc, eq, inArray, ne } from "drizzle-orm" +import { + allyRequest, + channel, + channelMember, + message, + user, +} from "@repo/db/schema" +import { and, count, desc, eq, inArray, ne, or, sql } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import { fetchMessagePage } from "@/lib/queries/messages" import type { AppRouteHandler } from "@/lib/types/app-types" -import type { GetDMRoute, ListDMMessagesRoute, ListDMsRoute } from "./routes" +import type { + CreateDMRoute, + GetDMRoute, + ListDMMessagesRoute, + ListDMsRoute, +} from "./routes" const emptyPage = (page: number) => ({ itemsTotal: 0, @@ -44,6 +55,198 @@ async function fetchDMMembershipChannel(dmId: string, userId: string) { .then((rows) => rows[0] ?? null) } +export const createDM: AppRouteHandler = async (c) => { + const currentUser = c.var.user + const { userIds } = c.req.valid("json") + + // Deduplicate and remove self + const targetUserIds = [...new Set(userIds)].filter( + (id) => id !== currentUser.id + ) + + if (targetUserIds.length === 0) { + return c.json( + { success: false, message: "Cannot create a DM with yourself" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + // Verify all target users are allies of the current user + const allyRows = await db + .select({ + senderId: allyRequest.senderId, + receiverId: allyRequest.receiverId, + }) + .from(allyRequest) + .where( + and( + eq(allyRequest.status, "accepted"), + or( + and( + eq(allyRequest.senderId, currentUser.id), + inArray(allyRequest.receiverId, targetUserIds) + ), + and( + inArray(allyRequest.senderId, targetUserIds), + eq(allyRequest.receiverId, currentUser.id) + ) + ) + ) + ) + + const allyUserIds = new Set( + allyRows.map((r) => + r.senderId === currentUser.id ? r.receiverId : r.senderId + ) + ) + + const nonAllyIds = targetUserIds.filter((id) => !allyUserIds.has(id)) + if (nonAllyIds.length > 0) { + return c.json( + { success: false, message: "You can only create DMs with your allies" }, + HttpStatusCodes.FORBIDDEN + ) + } + + const allMemberIds = [currentUser.id, ...targetUserIds].sort() + const isDirect = targetUserIds.length === 1 + + // For 1-on-1 DMs, check if one already exists + if (isDirect) { + const candidates = await db + .select({ + channelId: channelMember.channelId, + }) + .from(channelMember) + .innerJoin(channel, eq(channel.id, channelMember.channelId)) + .where( + and(eq(channel.type, "dm"), inArray(channelMember.userId, allMemberIds)) + ) + .groupBy(channelMember.channelId) + .having(sql`count(*) = ${allMemberIds.length}`) + + // Verify the channel has exactly 2 members (not more) + for (const candidate of candidates) { + const totalMembers = await db + .select({ total: count() }) + .from(channelMember) + .where(eq(channelMember.channelId, candidate.channelId)) + .then((rows) => rows[0]?.total ?? 0) + + if (totalMembers === allMemberIds.length) { + return returnDMResponse(c, candidate.channelId, currentUser.id, false) + } + } + } + + // Create new DM/group DM in a transaction to prevent races + const newChannelId = await db.transaction(async (tx) => { + // Re-check for 1:1 DMs inside the transaction + if (isDirect) { + const candidates = await tx + .select({ channelId: channelMember.channelId }) + .from(channelMember) + .innerJoin(channel, eq(channel.id, channelMember.channelId)) + .where( + and( + eq(channel.type, "dm"), + inArray(channelMember.userId, allMemberIds) + ) + ) + .groupBy(channelMember.channelId) + .having(sql`count(*) = ${allMemberIds.length}`) + + for (const candidate of candidates) { + const totalMembers = await tx + .select({ total: count() }) + .from(channelMember) + .where(eq(channelMember.channelId, candidate.channelId)) + .then((rows) => rows[0]?.total ?? 0) + + if (totalMembers === allMemberIds.length) { + return candidate.channelId + } + } + } + + const now = new Date() + const [newChannel] = await tx + .insert(channel) + .values({ + type: isDirect ? "dm" : "group_dm", + guildId: null, + ownerId: isDirect ? null : currentUser.id, + position: 0, + createdAt: now, + updatedAt: now, + }) + .returning() + + if (!newChannel) { + throw new Error("Failed to create DM channel") + } + + await tx.insert(channelMember).values( + allMemberIds.map((userId) => ({ + channelId: newChannel.id, + userId, + })) + ) + + return newChannel.id + }) + + return returnDMResponse(c, newChannelId, currentUser.id, true) +} + +async function returnDMResponse( + c: Parameters>[0], + channelId: string, + currentUserId: string, + created: boolean +) { + const [ch, members] = await Promise.all([ + db + .select() + .from(channel) + .where(eq(channel.id, channelId)) + .limit(1) + .then((rows) => rows[0]), + db + .select({ + id: user.id, + name: user.name, + username: user.username, + displayUsername: user.displayUsername, + image: user.image, + }) + .from(channelMember) + .innerJoin(user, eq(channelMember.userId, user.id)) + .where( + and( + eq(channelMember.channelId, channelId), + ne(channelMember.userId, currentUserId) + ) + ), + ]) + + if (!ch) { + return c.json( + { success: false, message: "Failed to fetch DM" }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ) + } + + return c.json( + { + success: true, + dm: { ...ch, members, lastMessage: null }, + created, + }, + HttpStatusCodes.OK + ) +} + export const listDMs: AppRouteHandler = async (c) => { const currentUser = c.var.user const { page, perPage } = c.req.valid("query") diff --git a/apps/api/src/routes/v1/dms/index.ts b/apps/api/src/routes/v1/dms/index.ts index efcdcce..b59e235 100644 --- a/apps/api/src/routes/v1/dms/index.ts +++ b/apps/api/src/routes/v1/dms/index.ts @@ -3,6 +3,7 @@ import * as handlers from "./handlers" import * as routes from "./routes" const dmsRouter = createRouter() + .openapi(routes.createDM, handlers.createDM) .openapi(routes.listDMs, handlers.listDMs) .openapi(routes.getDM, handlers.getDM) .openapi(routes.listDMMessages, handlers.listDMMessages) diff --git a/apps/api/src/routes/v1/dms/routes.ts b/apps/api/src/routes/v1/dms/routes.ts index 7f06e57..601905c 100644 --- a/apps/api/src/routes/v1/dms/routes.ts +++ b/apps/api/src/routes/v1/dms/routes.ts @@ -2,6 +2,8 @@ import { createRoute } from "@hono/zod-openapi" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import jsonContent from "@/lib/helpers/openapi/json-content" import { + badRequestSchema, + forbiddenSchema, internalServerErrorSchema, notFoundSchema, paginationQuerySchema, @@ -9,6 +11,8 @@ import { } from "@/lib/helpers/openapi/schemas" import { sessionAuthMiddleware } from "@/middleware/session-auth" import { + createDMRequestSchema, + createDMResponseSchema, dmParamsSchema, getDMResponseSchema, listDMMessagesQuerySchema, @@ -16,6 +20,34 @@ import { listDMsResponseSchema, } from "./schema" +export const createDM = createRoute({ + path: "/dms", + method: "post", + summary: "Create or find a DM", + description: + "Creates a new DM or group DM with the specified users, or returns an existing one. For 1-on-1 DMs, requires the target user to be an ally. For group DMs, requires all target users to be allies of the creator.", + tags: ["DMs"], + middleware: [sessionAuthMiddleware] as const, + request: { + body: jsonContent({ + schema: createDMRequestSchema, + description: "User IDs to create DM with", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: createDMResponseSchema, + description: "DM channel created or found", + }), + [HttpStatusCodes.BAD_REQUEST]: badRequestSchema, + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type CreateDMRoute = typeof createDM + export const listDMs = createRoute({ path: "/dms", method: "get", diff --git a/apps/api/src/routes/v1/dms/schema.ts b/apps/api/src/routes/v1/dms/schema.ts index 771b031..519208f 100644 --- a/apps/api/src/routes/v1/dms/schema.ts +++ b/apps/api/src/routes/v1/dms/schema.ts @@ -44,6 +44,19 @@ export const dmChannelSchema = selectChannelSchema.extend({ lastMessage: lastMessageSchema.nullable(), }) +export const createDMRequestSchema = z.object({ + userIds: z + .array(z.string().uuid()) + .min(1, "At least one user is required") + .max(9, "Group DMs can have at most 10 members"), +}) + +export const createDMResponseSchema = z.object({ + success: z.literal(true), + dm: dmChannelSchema, + created: z.boolean(), +}) + export const listDMsResponseSchema = paginatedResponseSchema(dmChannelSchema) export const getDMResponseSchema = dmChannelSchema diff --git a/apps/api/src/routes/v1/users/handlers.ts b/apps/api/src/routes/v1/users/handlers.ts new file mode 100644 index 0000000..0dba068 --- /dev/null +++ b/apps/api/src/routes/v1/users/handlers.ts @@ -0,0 +1,108 @@ +import { and, db, eq, or, schema } from "@repo/db" +import { PRESENCE_ONLINE_USERS_SET_KEY } from "@repo/realtime-types" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import { getRedisClient } from "@/lib/redis" +import type { AppRouteHandler } from "@/lib/types/app-types" +import type { GetUserProfileRoute } from "./routes" + +export const getUserProfile: AppRouteHandler = async ( + c +) => { + const currentUser = c.var.user + const { userId } = c.req.valid("param") + + const targetUser = await db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + bio: schema.user.bio, + status: schema.user.status, + createdAt: schema.user.createdAt, + }) + .from(schema.user) + .where(eq(schema.user.id, userId)) + .limit(1) + .then((rows) => rows[0]) + + if (!targetUser) { + return c.json( + { success: false, message: "User not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + // Check online status + let presenceStatus: "online" | "offline" = "offline" + try { + const redis = await getRedisClient() + const [isOnline] = await redis.smIsMember(PRESENCE_ONLINE_USERS_SET_KEY, [ + userId, + ]) + if (isOnline) presenceStatus = "online" + } catch { + // fail open — default to offline + } + + // Check ally relationship + const allyRequest = await db + .select({ + id: schema.allyRequest.id, + senderId: schema.allyRequest.senderId, + receiverId: schema.allyRequest.receiverId, + status: schema.allyRequest.status, + }) + .from(schema.allyRequest) + .where( + or( + and( + eq(schema.allyRequest.senderId, currentUser.id), + eq(schema.allyRequest.receiverId, userId) + ), + and( + eq(schema.allyRequest.senderId, userId), + eq(schema.allyRequest.receiverId, currentUser.id) + ) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + let allyStatus: "none" | "pending_incoming" | "pending_outgoing" | "allies" = + "none" + let allyRequestId: string | null = null + + if (allyRequest) { + allyRequestId = allyRequest.id + if (allyRequest.status === "accepted") { + allyStatus = "allies" + } else if (allyRequest.status === "pending") { + allyStatus = + allyRequest.senderId === currentUser.id + ? "pending_outgoing" + : "pending_incoming" + } + } + + return c.json( + { + success: true, + user: { + id: targetUser.id, + name: targetUser.name, + username: targetUser.username, + displayUsername: targetUser.displayUsername, + image: targetUser.image, + bio: targetUser.bio ?? null, + status: targetUser.status ?? null, + createdAt: targetUser.createdAt.toISOString(), + presenceStatus, + allyStatus, + allyRequestId, + }, + }, + HttpStatusCodes.OK + ) +} diff --git a/apps/api/src/routes/v1/users/index.ts b/apps/api/src/routes/v1/users/index.ts new file mode 100644 index 0000000..7f66df1 --- /dev/null +++ b/apps/api/src/routes/v1/users/index.ts @@ -0,0 +1,10 @@ +import { createRouter } from "@/lib/helpers/app/create-app" +import * as handlers from "@/routes/v1/users/handlers" +import * as routes from "@/routes/v1/users/routes" + +const usersRouter = createRouter().openapi( + routes.getUserProfile, + handlers.getUserProfile +) + +export default usersRouter diff --git a/apps/api/src/routes/v1/users/routes.ts b/apps/api/src/routes/v1/users/routes.ts new file mode 100644 index 0000000..a736649 --- /dev/null +++ b/apps/api/src/routes/v1/users/routes.ts @@ -0,0 +1,34 @@ +import { createRoute } from "@hono/zod-openapi" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import jsonContent from "@/lib/helpers/openapi/json-content" +import { + internalServerErrorSchema, + notFoundSchema, + unauthorizedSchema, +} from "@/lib/helpers/openapi/schemas" +import { sessionAuthMiddleware } from "@/middleware/session-auth" +import { getUserProfileResponseSchema, userIdParamsSchema } from "./schema" + +export const getUserProfile = createRoute({ + path: "/users/{userId}", + method: "get", + summary: "Get user profile", + description: + "Returns a user's public profile including ally status with the current user.", + tags: ["Users"], + middleware: [sessionAuthMiddleware] as const, + request: { + params: userIdParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: getUserProfileResponseSchema, + description: "User profile", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type GetUserProfileRoute = typeof getUserProfile diff --git a/apps/api/src/routes/v1/users/schema.ts b/apps/api/src/routes/v1/users/schema.ts new file mode 100644 index 0000000..e6ff0be --- /dev/null +++ b/apps/api/src/routes/v1/users/schema.ts @@ -0,0 +1,35 @@ +import { z } from "@hono/zod-openapi" + +export const userIdParamsSchema = z.object({ + userId: z + .string() + .uuid() + .openapi({ + param: { name: "userId", in: "path", required: true }, + example: "00000000-0000-0000-0000-000000000000", + }), +}) + +export const userProfileResponseSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + username: z.string().nullable(), + displayUsername: z.string().nullable(), + image: z.string().nullable(), + bio: z.string().nullable(), + status: z.string().nullable(), + createdAt: z.string().datetime(), + presenceStatus: z.enum(["online", "offline"]), + allyStatus: z.enum([ + "none", + "pending_incoming", + "pending_outgoing", + "allies", + ]), + allyRequestId: z.string().uuid().nullable(), +}) + +export const getUserProfileResponseSchema = z.object({ + success: z.literal(true), + user: userProfileResponseSchema, +}) diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 5469706..9d9dc17 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -477,6 +477,7 @@ io.on("connection", (socket) => { const parsed = toggleMessageReactionPayloadSchema.parse(payload) const reactionUpdate = await toggleMessageReaction({ userId: socket.data.user.id, + userName: socket.data.user.name, payload: parsed, }) diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index 30ce71f..f572782 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -40,6 +40,7 @@ type EditMessageInput = { type ToggleMessageReactionInput = { userId: string + userName: string payload: ToggleMessageReactionPayload } @@ -377,6 +378,7 @@ export async function toggleMessageReaction(input: ToggleMessageReactionInput) { emoji: input.payload.emoji, count: reactionCount, actorUserId: input.userId, + actorName: input.userName, reactedByActor: nextReactionState, }, channel: channelRecord, diff --git a/apps/web/src/components/allies/allies-page.tsx b/apps/web/src/components/allies/allies-page.tsx new file mode 100644 index 0000000..651b44f --- /dev/null +++ b/apps/web/src/components/allies/allies-page.tsx @@ -0,0 +1,548 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@repo/ui/components/alert-dialog" +import { Badge } from "@repo/ui/components/badge" +import { Button } from "@repo/ui/components/button" +import { Input } from "@repo/ui/components/input" +import { ScrollArea } from "@repo/ui/components/scroll-area" +import { Skeleton } from "@repo/ui/components/skeleton" +import { cn } from "@repo/ui/lib/utils" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { + Check, + MessageCircle, + Search, + UserMinus, + UserPlus, + Users, + X, +} from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { UserAvatar } from "@/components/ui/user-avatar" +import { useCreateDM } from "@/hooks/use-create-dm" +import { apiClient } from "@/lib/api-client" +import type { Ally, AllyRequest } from "@/lib/api-types" + +type Tab = "all" | "pending" + +function AlliesSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton +
+ +
+ + +
+
+ ))} +
+ ) +} + +function AllyRow({ + ally, + onMessage, + onRemove, + isRemoving, +}: { + ally: Ally + onMessage: (userId: string) => void + onRemove: (userId: string) => void + isRemoving: boolean +}) { + return ( +
+ +
+
{ally.name}
+ {ally.username && ( +
+ @{ally.displayUsername ?? ally.username} +
+ )} +
+
+ + +
+
+ ) +} + +function IncomingRequestRow({ + request, + onAccept, + onDecline, + isPending, +}: { + request: AllyRequest + onAccept: (requestId: string) => void + onDecline: (requestId: string) => void + isPending: boolean +}) { + return ( +
+ +
+
+ {request.sender.name} +
+ {request.sender.username && ( +
+ @{request.sender.displayUsername ?? request.sender.username} +
+ )} +
+
+ + +
+
+ ) +} + +function OutgoingRequestRow({ request }: { request: AllyRequest }) { + return ( +
+ +
+
+ {request.receiver.name} +
+ {request.receiver.username && ( +
+ @{request.receiver.displayUsername ?? request.receiver.username} +
+ )} +
+ Pending +
+ ) +} + +export function AlliesPage() { + const queryClient = useQueryClient() + const createDM = useCreateDM() + const [tab, setTab] = useState("all") + const [search, setSearch] = useState("") + const [addUsername, setAddUsername] = useState("") + + const { + data: allies, + isPending: alliesLoading, + isError: alliesError, + } = useQuery({ + queryKey: ["allies"], + queryFn: async () => { + const res = await apiClient.v1.allies.$get() + if (!res.ok) throw new Error("Failed to fetch allies") + return res.json() + }, + }) + + const { + data: requests, + isPending: requestsLoading, + isError: requestsError, + } = useQuery({ + queryKey: ["ally-requests"], + queryFn: async () => { + const res = await apiClient.v1.allies.requests.$get() + if (!res.ok) throw new Error("Failed to fetch ally requests") + return res.json() + }, + }) + + const [removingAllyId, setRemovingAllyId] = useState(null) + const [confirmRemoveAlly, setConfirmRemoveAlly] = useState(null) + + const invalidate = (affectedUserId?: string) => { + void queryClient.invalidateQueries({ queryKey: ["allies"] }) + void queryClient.invalidateQueries({ queryKey: ["ally-requests"] }) + if (affectedUserId) { + void queryClient.invalidateQueries({ + queryKey: ["user-profile", affectedUserId], + }) + } + } + + const sendRequest = useMutation({ + mutationFn: async (userId: string) => { + const res = await apiClient.v1.allies.requests.$post({ + json: { userId }, + }) + if (!res.ok) { + const body = await res.json() + throw new Error( + "message" in body ? body.message : "Failed to send ally request" + ) + } + return { data: await res.json(), targetUserId: userId } + }, + onSuccess: ({ targetUserId }) => { + invalidate(targetUserId) + setAddUsername("") + toast.success("Ally request sent") + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const acceptRequest = useMutation({ + mutationFn: async ({ + requestId, + senderId, + }: { + requestId: string + senderId: string + }) => { + const res = await apiClient.v1.allies.requests[":requestId"].accept.$post( + { + param: { requestId }, + } + ) + if (!res.ok) throw new Error("Failed to accept request") + return { data: await res.json(), senderId } + }, + onSuccess: ({ senderId }) => { + invalidate(senderId) + toast.success("Ally request accepted") + }, + onError: () => { + toast.error("Failed to accept request") + }, + }) + + const declineRequest = useMutation({ + mutationFn: async ({ + requestId, + senderId, + }: { + requestId: string + senderId: string + }) => { + const res = await apiClient.v1.allies.requests[ + ":requestId" + ].decline.$post({ + param: { requestId }, + }) + if (!res.ok) throw new Error("Failed to decline request") + return { senderId } + }, + onSuccess: ({ senderId }) => { + invalidate(senderId) + toast.success("Ally request declined") + }, + onError: () => { + toast.error("Failed to decline request") + }, + }) + + const removeAlly = useMutation({ + mutationFn: async (userId: string) => { + setRemovingAllyId(userId) + const res = await apiClient.v1.allies[":userId"].$delete({ + param: { userId }, + }) + if (!res.ok) throw new Error("Failed to remove ally") + return userId + }, + onSuccess: (userId) => { + setRemovingAllyId(null) + invalidate(userId) + toast.success("Ally removed") + }, + onError: () => { + setRemovingAllyId(null) + toast.error("Failed to remove ally") + }, + }) + + const handleSendRequest = (e: React.FormEvent) => { + e.preventDefault() + const trimmed = addUsername.trim() + if (!trimmed) return + // The input is a userId for now — we can enhance to support username lookup later + sendRequest.mutate(trimmed) + } + + const filteredAllies = (allies?.allies ?? []).filter((ally) => + ally.name.toLowerCase().includes(search.toLowerCase()) + ) + + const incomingRequests = requests?.incoming ?? [] + const outgoingRequests = requests?.outgoing ?? [] + const pendingCount = incomingRequests.length + outgoingRequests.length + + return ( +
+ {/* Header */} +
+ +

Allies

+
+
+ + +
+
+ + {/* Content */} + +
+ {tab === "all" && ( + <> + {/* Search */} +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + {alliesLoading ? ( + + ) : alliesError ? ( +
+ Failed to load allies. +
+ ) : filteredAllies.length === 0 ? ( +
+ {search + ? "No allies match your search." + : "You don't have any allies yet. Send an ally request to get started."} +
+ ) : ( +
+
+ All Allies - {filteredAllies.length} +
+ {filteredAllies.map((ally) => ( + createDM.mutate([userId])} + onRemove={() => setConfirmRemoveAlly(ally)} + isRemoving={removingAllyId === ally.id} + /> + ))} +
+ )} + + )} + + {tab === "pending" && ( + <> + {/* Add Ally form */} +
+ +

+ Enter a user ID to send an ally request. +

+
+ setAddUsername(e.target.value)} + className="flex-1" + /> + +
+
+ + {requestsLoading ? ( + + ) : requestsError ? ( +
+ Failed to load requests. +
+ ) : ( + <> + {incomingRequests.length > 0 && ( +
+
+ Incoming - {incomingRequests.length} +
+ {incomingRequests.map((request) => ( + + acceptRequest.mutate({ + requestId: id, + senderId: request.sender.id, + }) + } + onDecline={(id) => + declineRequest.mutate({ + requestId: id, + senderId: request.sender.id, + }) + } + isPending={ + acceptRequest.isPending || declineRequest.isPending + } + /> + ))} +
+ )} + + {outgoingRequests.length > 0 && ( +
+
+ Outgoing - {outgoingRequests.length} +
+ {outgoingRequests.map((request) => ( + + ))} +
+ )} + + {incomingRequests.length === 0 && + outgoingRequests.length === 0 && ( +
+ No pending ally requests. +
+ )} + + )} + + )} +
+
+ { + if (!open) setConfirmRemoveAlly(null) + }} + > + + + Remove ally + + Are you sure you want to remove{" "} + + {confirmRemoveAlly?.name} + {" "} + as an ally? + + + + + Cancel + + { + e.preventDefault() + if (!confirmRemoveAlly) return + removeAlly.mutate(confirmRemoveAlly.id, { + onSuccess: () => setConfirmRemoveAlly(null), + }) + }} + > + Remove + + + + +
+ ) +} diff --git a/apps/web/src/components/chat/message-item.tsx b/apps/web/src/components/chat/message-item.tsx index 1e34f72..e0b8446 100644 --- a/apps/web/src/components/chat/message-item.tsx +++ b/apps/web/src/components/chat/message-item.tsx @@ -9,10 +9,16 @@ import { AlertDialogTitle, } from "@repo/ui/components/alert-dialog" import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@repo/ui/components/tooltip" import { cn } from "@repo/ui/lib/utils" import { formatTime } from "@repo/utils/date" import { Pin } from "lucide-react" import { useCallback, useState } from "react" +import { UserProfilePopover } from "@/components/ui/user-profile-card" import type { Message } from "@/lib/api-types" import type { MentionCandidate } from "./composer/mention-types" import { EmbedCard } from "./embed-card" @@ -221,14 +227,18 @@ export function MessageItem({ )}
{showHeader ? ( - - {author.image && ( - - )} - - {nameInitial(author.displayUsername ?? author.name)} - - + + + ) : (
@@ -239,9 +249,14 @@ export function MessageItem({
{showHeader && (
- - {author.displayUsername ?? author.name} - + + + {formatTime(message.createdAt)} @@ -273,23 +288,40 @@ export function MessageItem({ )} {message.reactions.length > 0 && (
- {message.reactions.map((reaction) => ( - - ))} + {message.reactions.map((reaction) => { + const reactors = reaction.reactors ?? [] + const names = reactors.map((r) => + r.id === currentUserId ? "You" : r.name + ) + const tooltipText = + names.length > 0 + ? `${names.join(", ")} reacted with ${reaction.emoji}` + : `${reaction.count} reaction${reaction.count !== 1 ? "s" : ""}` + + return ( + + + + + +

{tooltipText}

+
+
+ ) + })}
)}
diff --git a/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx b/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx index e8d08f0..b7dae06 100644 --- a/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx +++ b/apps/web/src/components/sidebar/dm-panel/dm-panel.tsx @@ -2,14 +2,17 @@ import { ScrollArea } from "@repo/ui/components/scroll-area" import { Separator } from "@repo/ui/components/separator" import { cn } from "@repo/ui/lib/utils" import { useNavigate, useParams } from "@tanstack/react-router" -import { Inbox, Plus, Users } from "lucide-react" +import { Plus, Users } from "lucide-react" +import { useState } from "react" import { SearchBar } from "../channel-panel/search-bar" import { UserBar } from "../channel-panel/user-bar" import { DMList } from "./dm-list" +import { NewDMDialog } from "./new-dm-dialog" export function DMPanel() { const navigate = useNavigate() const { dmId } = useParams({ strict: false }) + const [newDMOpen, setNewDMOpen] = useState(false) return (
@@ -28,16 +31,6 @@ export function DMPanel() { Allies - {/* TODO: implement navigateToMessageRequests */} -
@@ -46,7 +39,9 @@ export function DMPanel() { @@ -55,6 +50,7 @@ export function DMPanel() { +
) } diff --git a/apps/web/src/components/sidebar/dm-panel/new-dm-dialog.tsx b/apps/web/src/components/sidebar/dm-panel/new-dm-dialog.tsx new file mode 100644 index 0000000..5387914 --- /dev/null +++ b/apps/web/src/components/sidebar/dm-panel/new-dm-dialog.tsx @@ -0,0 +1,187 @@ +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 { ScrollArea } from "@repo/ui/components/scroll-area" +import { cn } from "@repo/ui/lib/utils" +import { useQuery } from "@tanstack/react-query" +import { Check, Search } from "lucide-react" +import { useState } from "react" +import { UserAvatar } from "@/components/ui/user-avatar" +import { useCreateDM } from "@/hooks/use-create-dm" +import { apiClient } from "@/lib/api-client" +import type { Ally } from "@/lib/api-types" + +export function NewDMDialog({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const [search, setSearch] = useState("") + const [selectedIds, setSelectedIds] = useState>(new Set()) + const createDM = useCreateDM() + + const { + data: allies, + isPending, + isError, + } = useQuery({ + queryKey: ["allies"], + queryFn: async () => { + const res = await apiClient.v1.allies.$get() + if (!res.ok) throw new Error("Failed to fetch allies") + return res.json() + }, + enabled: open, + }) + + const filteredAllies = (allies?.allies ?? []).filter((ally) => + ally.name.toLowerCase().includes(search.toLowerCase()) + ) + + const toggleAlly = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const handleCreate = () => { + if (selectedIds.size === 0) return + createDM.mutate([...selectedIds], { + onSuccess: () => { + onOpenChange(false) + setSelectedIds(new Set()) + setSearch("") + }, + }) + } + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setSelectedIds(new Set()) + setSearch("") + } + onOpenChange(nextOpen) + } + + return ( + + + + New Direct Message + + Select allies to start a conversation with. + + + +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + {selectedIds.size > 0 && ( +
+ {selectedIds.size} selected + {selectedIds.size > 1 ? " — this will create a group DM" : ""} +
+ )} + + + {isPending ? ( +
+ Loading allies... +
+ ) : isError ? ( +
+ Failed to load allies. +
+ ) : filteredAllies.length === 0 ? ( +
+ {search + ? "No allies match your search." + : "You don't have any allies yet."} +
+ ) : ( +
+ {filteredAllies.map((ally) => ( + toggleAlly(ally.id)} + /> + ))} +
+ )} +
+ + + + +
+
+ ) +} + +function AllySelectRow({ + ally, + selected, + onToggle, +}: { + ally: Ally + selected: boolean + onToggle: () => void +}) { + return ( + + ) +} 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 14b954a..4736851 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 @@ -40,6 +40,7 @@ import { MoreHorizontal, Users } from "lucide-react" import { useEffect, useMemo, useState } from "react" import { toast } from "sonner" import { UserAvatar } from "@/components/ui/user-avatar" +import { UserProfilePopover } from "@/components/ui/user-profile-card" import { useSocket } from "@/context/socket-context" import { apiClient } from "@/lib/api-client" import type { @@ -169,17 +170,26 @@ function MemberRow({ return (
-
- - -
+ + +
-
{member.name}
+ + +
{formatRole(member.role)}
diff --git a/apps/web/src/components/ui/user-profile-card.tsx b/apps/web/src/components/ui/user-profile-card.tsx new file mode 100644 index 0000000..cf86426 --- /dev/null +++ b/apps/web/src/components/ui/user-profile-card.tsx @@ -0,0 +1,285 @@ +import { authClient } from "@repo/auth/client" +import { Badge } from "@repo/ui/components/badge" +import { Button } from "@repo/ui/components/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@repo/ui/components/popover" +import { Skeleton } from "@repo/ui/components/skeleton" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Check, Clock, UserMinus, UserPlus } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { apiClient } from "@/lib/api-client" +import type { UserProfile } from "@/lib/api-types" +import { UserAvatar } from "./user-avatar" + +function ProfileCardContent({ userId }: { userId: string }) { + const queryClient = useQueryClient() + const { data: session } = authClient.useSession() + + const { data, isPending, isError } = useQuery({ + queryKey: ["user-profile", userId], + queryFn: async () => { + const res = await apiClient.v1.users[":userId"].$get({ + param: { userId }, + }) + if (!res.ok) throw new Error("Failed to fetch user profile") + return res.json() + }, + }) + + const sendRequest = useMutation({ + mutationFn: async () => { + const res = await apiClient.v1.allies.requests.$post({ + json: { userId }, + }) + if (!res.ok) { + const body = await res.json() + throw new Error( + "message" in body ? body.message : "Failed to send ally request" + ) + } + return res.json() + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["user-profile", userId], + }) + void queryClient.invalidateQueries({ queryKey: ["ally-requests"] }) + toast.success("Ally request sent") + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const acceptRequest = useMutation({ + mutationFn: async (requestId: string) => { + const res = await apiClient.v1.allies.requests[":requestId"].accept.$post( + { + param: { requestId }, + } + ) + if (!res.ok) throw new Error("Failed to accept request") + return res.json() + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["user-profile", userId], + }) + void queryClient.invalidateQueries({ queryKey: ["allies"] }) + void queryClient.invalidateQueries({ queryKey: ["ally-requests"] }) + toast.success("Ally request accepted") + }, + onError: () => { + toast.error("Failed to accept request") + }, + }) + + const removeAlly = useMutation({ + mutationFn: async () => { + const res = await apiClient.v1.allies[":userId"].$delete({ + param: { userId }, + }) + if (!res.ok) throw new Error("Failed to remove ally") + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["user-profile", userId], + }) + void queryClient.invalidateQueries({ queryKey: ["allies"] }) + toast.success("Ally removed") + }, + onError: () => { + toast.error("Failed to remove ally") + }, + }) + + if (isPending) { + return ( +
+
+ +
+ + +
+
+ +
+ ) + } + + if (isError || !data) { + return ( +
+ Failed to load profile. +
+ ) + } + + const user = data.user + const isCurrentUser = session?.user?.id === userId + const isMutating = + sendRequest.isPending || acceptRequest.isPending || removeAlly.isPending + + return ( +
+ {/* Avatar + name + presence */} +
+ {isCurrentUser && ( + + Me + + )} +
+ + +
+
+
{user.name}
+ {user.username && ( +
+ @{user.displayUsername ?? user.username} +
+ )} +
+
+ + {/* Status */} + {user.status && ( +
{user.status}
+ )} + + {/* Bio */} + {user.bio && ( +
+ {user.bio} +
+ )} + + {/* Member since */} +
+ Member since{" "} + {new Date(user.createdAt).toLocaleDateString(undefined, { + month: "short", + year: "numeric", + })} +
+ + {/* Ally actions */} + {!isCurrentUser && ( + sendRequest.mutate()} + onAcceptRequest={(id) => acceptRequest.mutate(id)} + onRemoveAlly={() => removeAlly.mutate()} + /> + )} +
+ ) +} + +function AllyActionButton({ + allyStatus, + allyRequestId, + isMutating, + onSendRequest, + onAcceptRequest, + onRemoveAlly, +}: { + allyStatus: UserProfile["allyStatus"] + allyRequestId: string | null + isMutating: boolean + onSendRequest: () => void + onAcceptRequest: (requestId: string) => void + onRemoveAlly: () => void +}) { + switch (allyStatus) { + case "none": + return ( + + ) + case "pending_outgoing": + return ( + + ) + case "pending_incoming": + return ( + + ) + case "allies": + return ( + + ) + } +} + +export function UserProfilePopover({ + userId, + children, + side = "right", + align = "start", +}: { + userId: string + children: React.ReactNode + side?: "top" | "bottom" | "left" | "right" + align?: "start" | "center" | "end" +}) { + const [open, setOpen] = useState(false) + + return ( + + {children} + e.preventDefault()} + > + {open && } + + + ) +} diff --git a/apps/web/src/hooks/use-create-dm.ts b/apps/web/src/hooks/use-create-dm.ts new file mode 100644 index 0000000..6f4b810 --- /dev/null +++ b/apps/web/src/hooks/use-create-dm.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { toast } from "sonner" +import { apiClient } from "@/lib/api-client" + +export function useCreateDM() { + const navigate = useNavigate() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (userIds: string[]) => { + const res = await apiClient.v1.dms.$post({ + json: { userIds }, + }) + if (!res.ok) { + const body = await res.json() + throw new Error( + "message" in body ? body.message : "Failed to create DM" + ) + } + return res.json() + }, + onSuccess: (data) => { + void queryClient.invalidateQueries({ queryKey: ["dms"] }) + void navigate({ to: "/dms/$dmId", params: { dmId: data.dm.id } }) + }, + onError: (error) => { + toast.error(error.message) + }, + }) +} diff --git a/apps/web/src/hooks/use-message-reactions.ts b/apps/web/src/hooks/use-message-reactions.ts index 3e7d113..afb8870 100644 --- a/apps/web/src/hooks/use-message-reactions.ts +++ b/apps/web/src/hooks/use-message-reactions.ts @@ -1,6 +1,6 @@ import type { RealtimeMessageReactionUpdated } from "@repo/realtime-types" import type { QueryClient } from "@tanstack/react-query" -import { useCallback, useEffect } from "react" +import { useCallback, useEffect, useMemo } from "react" import { applyReactionUpdateToMessage, toggleReactionOptimistically, @@ -18,6 +18,7 @@ interface UseMessageReactionsOptions { queryClient: QueryClient channelId: string currentUserId?: string + currentUserName?: string } export function useMessageReactions({ @@ -25,6 +26,7 @@ export function useMessageReactions({ queryClient, channelId, currentUserId, + currentUserName, }: UseMessageReactionsOptions) { const updateMessageInCache = useCallback( ( @@ -44,13 +46,21 @@ export function useMessageReactions({ [queryClient, channelId] ) + const currentUser = useMemo( + () => + currentUserId != null && currentUserName != null + ? { id: currentUserId, name: currentUserName } + : undefined, + [currentUserId, currentUserName] + ) + const toggleReactionLocal = useCallback( (messageId: string, emoji: string) => { updateMessageInCache(messageId, (message) => - toggleReactionOptimistically(message, emoji) + toggleReactionOptimistically(message, emoji, currentUser) ) }, - [updateMessageInCache] + [updateMessageInCache, currentUser] ) const applyReactionServerUpdate = useCallback( diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index a58634c..a54601a 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -64,6 +64,31 @@ export type InvitePreviewResponse = InferResponseType< 200 > +// ── Allies ────────────────────────────────────────── + +type AlliesClient = Client["v1"]["allies"] + +export type ListAlliesResponse = InferResponseType +export type Ally = ListAlliesResponse["allies"][number] + +type AllyRequestsClient = Client["v1"]["allies"]["requests"] + +export type ListAllyRequestsResponse = InferResponseType< + AllyRequestsClient["$get"], + 200 +> +export type AllyRequest = ListAllyRequestsResponse["incoming"][number] + +// ── Users ────────────────────────────────────────── + +type UserProfileClient = Client["v1"]["users"][":userId"] + +export type GetUserProfileResponse = InferResponseType< + UserProfileClient["$get"], + 200 +> +export type UserProfile = GetUserProfileResponse["user"] + // ── Guild Members ────────────────────────────────────────── type GuildMembersClient = Client["v1"]["guilds"][":guildSlug"]["members"] diff --git a/apps/web/src/lib/realtime-adapter.ts b/apps/web/src/lib/realtime-adapter.ts index 3cca7fb..f08f721 100644 --- a/apps/web/src/lib/realtime-adapter.ts +++ b/apps/web/src/lib/realtime-adapter.ts @@ -57,10 +57,26 @@ export function applyReactionUpdateToMessage( : ((reactionIndex >= 0 ? nextReactions[reactionIndex] : undefined) ?.reactedByCurrentUser ?? false) + // Update reactors list based on the actor's action + const existingReactors = [ + ...((reactionIndex >= 0 ? nextReactions[reactionIndex] : undefined) + ?.reactors ?? []), + ] + const actorInList = existingReactors.findIndex( + (r) => r.id === update.actorUserId + ) + + if (update.reactedByActor && actorInList === -1) { + existingReactors.push({ id: update.actorUserId, name: update.actorName }) + } else if (!update.reactedByActor && actorInList !== -1) { + existingReactors.splice(actorInList, 1) + } + const nextReaction = { emoji: update.emoji, count: update.count, reactedByCurrentUser, + reactors: existingReactors, } if (reactionIndex === -1) { @@ -81,7 +97,8 @@ export function applyReactionUpdateToMessage( */ export function toggleReactionOptimistically( message: Message, - emoji: string + emoji: string, + currentUser?: { id: string; name: string } ): Message { const existingReactions = message.reactions ?? [] const reactionIndex = existingReactions.findIndex( @@ -93,7 +110,12 @@ export function toggleReactionOptimistically( ...message, reactions: [ ...existingReactions, - { emoji, count: 1, reactedByCurrentUser: true }, + { + emoji, + count: 1, + reactedByCurrentUser: true, + reactors: currentUser ? [currentUser] : [], + }, ], } } @@ -113,6 +135,11 @@ export function toggleReactionOptimistically( ...currentReaction, count: nextCount, reactedByCurrentUser: false, + reactors: currentUser + ? (currentReaction.reactors ?? []).filter( + (r) => r.id !== currentUser.id + ) + : (currentReaction.reactors ?? []), } } } else { @@ -120,6 +147,9 @@ export function toggleReactionOptimistically( ...currentReaction, count: currentReaction.count + 1, reactedByCurrentUser: true, + reactors: currentUser + ? [...(currentReaction.reactors ?? []), currentUser] + : (currentReaction.reactors ?? []), } } diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index 6f9a14d..db9634f 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -39,6 +39,17 @@ function ChannelView() { const { data: session } = authClient.useSession() const currentUserId = session?.user.id + useEffect(() => { + if (!guildSlug || !channelId) return + try { + if (typeof window !== "undefined") { + localStorage.setItem(`last-channel:${guildSlug}`, channelId) + } + } catch { + // localStorage may be unavailable in restricted environments + } + }, [guildSlug, channelId]) + useEffect(() => { setView({ type: "guild-members", @@ -105,6 +116,7 @@ function ChannelView() { queryClient, channelId, currentUserId, + currentUserName: session?.user.name, }) const { handleDelete } = useMessageDeletion({ diff --git a/apps/web/src/routes/_authenticated/$guildSlug/index.tsx b/apps/web/src/routes/_authenticated/$guildSlug/index.tsx index 604d7ee..c3d1c1e 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/index.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/index.tsx @@ -1,10 +1,32 @@ -import { createFileRoute } from "@tanstack/react-router" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useEffect } from "react" export const Route = createFileRoute("/_authenticated/$guildSlug/")({ component: GuildHome, }) function GuildHome() { + const { guildSlug } = Route.useParams() + const navigate = useNavigate() + + useEffect(() => { + let lastChannelId: string | null = null + try { + if (typeof window !== "undefined") { + lastChannelId = localStorage.getItem(`last-channel:${guildSlug}`) + } + } catch { + // localStorage may be unavailable in restricted environments + } + if (lastChannelId) { + void navigate({ + to: "/$guildSlug/$channelId", + params: { guildSlug, channelId: lastChannelId }, + replace: true, + }) + } + }, [guildSlug, navigate]) + return (
diff --git a/apps/web/src/routes/_authenticated/dms/$dmId.tsx b/apps/web/src/routes/_authenticated/dms/$dmId.tsx index 4cf965e..b1f2e1e 100644 --- a/apps/web/src/routes/_authenticated/dms/$dmId.tsx +++ b/apps/web/src/routes/_authenticated/dms/$dmId.tsx @@ -69,6 +69,7 @@ function DMConversation() { queryClient, channelId: dmId, currentUserId, + currentUserName: session?.user.name, }) const { handleDelete } = useMessageDeletion({ diff --git a/apps/web/src/routes/_authenticated/dms/index.tsx b/apps/web/src/routes/_authenticated/dms/index.tsx index 6248c48..e03aa3f 100644 --- a/apps/web/src/routes/_authenticated/dms/index.tsx +++ b/apps/web/src/routes/_authenticated/dms/index.tsx @@ -1,15 +1,6 @@ import { createFileRoute } from "@tanstack/react-router" +import { AlliesPage } from "@/components/allies/allies-page" export const Route = createFileRoute("/_authenticated/dms/")({ - component: DMsHome, + component: AlliesPage, }) - -function DMsHome() { - return ( -
- - Select a conversation to start chatting - -
- ) -} diff --git a/packages/db/src/schemas/ally-requests.ts b/packages/db/src/schemas/ally-requests.ts new file mode 100644 index 0000000..d6893a4 --- /dev/null +++ b/packages/db/src/schemas/ally-requests.ts @@ -0,0 +1,62 @@ +import { relations } from "drizzle-orm" +import { + index, + pgEnum, + pgTable, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +import { user } from "./users" + +export const allyRequestStatusEnum = pgEnum("ally_request_status", [ + "pending", + "accepted", + "declined", +]) + +export const allyRequest = pgTable( + "ally_request", + { + id: uuid("id").defaultRandom().primaryKey(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + senderId: uuid("sender_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + receiverId: uuid("receiver_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + status: allyRequestStatusEnum("status").notNull().default("pending"), + }, + (table) => [ + uniqueIndex("allyRequest_sender_receiver_uidx").on( + table.senderId, + table.receiverId + ), + index("allyRequest_receiverId_idx").on(table.receiverId), + index("allyRequest_senderId_idx").on(table.senderId), + index("allyRequest_status_idx").on(table.status), + ] +) + +export const selectAllyRequestSchema = createSelectSchema(allyRequest) +export const insertAllyRequestSchema = createInsertSchema(allyRequest).omit({ + id: true, + createdAt: true, + updatedAt: true, +}) + +export const allyRequestRelations = relations(allyRequest, ({ one }) => ({ + sender: one(user, { + relationName: "allyRequestSender", + fields: [allyRequest.senderId], + references: [user.id], + }), + receiver: one(user, { + relationName: "allyRequestReceiver", + fields: [allyRequest.receiverId], + references: [user.id], + }), +})) diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts index 4e6144e..6967565 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -1,4 +1,5 @@ export * from "./accounts" +export * from "./ally-requests" export * from "./channel-read-states" export * from "./channels" export * from "./guild-bans" diff --git a/packages/realtime-types/src/events.ts b/packages/realtime-types/src/events.ts index 4b4ca92..4e662ba 100644 --- a/packages/realtime-types/src/events.ts +++ b/packages/realtime-types/src/events.ts @@ -98,6 +98,7 @@ export type RealtimeMessageReaction = { emoji: string count: number reactedByCurrentUser: boolean + reactors: Array<{ id: string; name: string }> } export type RealtimeAttachment = { @@ -161,6 +162,7 @@ export type RealtimeMessageReactionUpdated = { emoji: string count: number actorUserId: string + actorName: string reactedByActor: boolean }