diff --git a/ROADMAP.md b/ROADMAP.md index 65af220..a6828d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,7 +39,8 @@ - [x] Shareable invite links (not just email invites) — schema, API, and UI implemented - [x] Ally (friend) system with requests — schema, API, allies page, user profile popover with ally actions -- [ ] User blocking +- [x] Direct messages — create 1:1 and group DMs with allies, new DM dialog +- [x] User blocking — schema, API (block/unblock/list), realtime DM enforcement, blocked tab on allies page, block/unblock in profile popover, message collapse with click-to-reveal, typing/DM filtering - [ ] Privacy settings ## Phase 4 — Tests & CI/CD @@ -55,8 +56,13 @@ - [x] Typing indicators - [x] Pinned messages panel - [ ] Thread support +- [ ] Desktop notifications (browser Notification API for mentions, DMs, etc.) - [ ] Notification preferences +- [x] Reaction tooltips (who reacted with each emoji) +- [x] User profile popover (bio, status, online indicator, ally actions) +- [x] Remember last visited channel per guild (localStorage) - [ ] Error handling & loading state improvements +- [x] Username editing in account settings (with availability check) - [ ] Other settings pages ## Phase 6 — Infrastructure diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ca910fc..c160c0d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,10 +5,12 @@ 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 blocksRouter from "@/routes/v1/blocks/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 privacySettingsRouter from "@/routes/v1/privacy-settings/index" import uploadsRouter from "@/routes/v1/uploads/index" import usersRouter from "@/routes/v1/users/index" import waitlistRouter from "@/routes/waitlist/index" @@ -38,9 +40,11 @@ app.route("/", index) const routes = app .route("/", waitlistRouter) .route("/v1", alliesRouter) + .route("/v1", blocksRouter) .route("/v1", channelsRouter) .route("/v1", guildsRouter) .route("/v1", invitesRouter) + .route("/v1", privacySettingsRouter) .route("/v1", dmsRouter) .route("/v1", uploadsRouter) .route("/v1", usersRouter) diff --git a/apps/api/src/routes/v1/allies/handlers.ts b/apps/api/src/routes/v1/allies/handlers.ts index dc7b641..591c068 100644 --- a/apps/api/src/routes/v1/allies/handlers.ts +++ b/apps/api/src/routes/v1/allies/handlers.ts @@ -94,6 +94,52 @@ export const sendAllyRequest: AppRouteHandler = async ( ) } + // Check if either user has blocked the other + const blockExists = await db + .select({ id: schema.userBlock.id }) + .from(schema.userBlock) + .where( + or( + and( + eq(schema.userBlock.blockerId, currentUser.id), + eq(schema.userBlock.blockedId, targetUserId) + ), + and( + eq(schema.userBlock.blockerId, targetUserId), + eq(schema.userBlock.blockedId, currentUser.id) + ) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (blockExists) { + return c.json( + { success: false, message: "Unable to send ally request" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + // Check target user's privacy settings for ally requests + const targetPrivacy = await db + .select({ + allyRequestPrivacy: schema.userPrivacySettings.allyRequestPrivacy, + }) + .from(schema.userPrivacySettings) + .where(eq(schema.userPrivacySettings.userId, targetUserId)) + .limit(1) + .then((rows) => rows[0]) + + if (targetPrivacy?.allyRequestPrivacy === "no_one") { + return c.json( + { + success: false, + message: "This user is not accepting ally requests", + }, + HttpStatusCodes.FORBIDDEN + ) + } + // Check for existing relationship (in either direction) const existing = await db .select({ diff --git a/apps/api/src/routes/v1/allies/routes.ts b/apps/api/src/routes/v1/allies/routes.ts index ba9dd9c..4648462 100644 --- a/apps/api/src/routes/v1/allies/routes.ts +++ b/apps/api/src/routes/v1/allies/routes.ts @@ -41,6 +41,7 @@ export const sendAllyRequest = createRoute({ }), [HttpStatusCodes.BAD_REQUEST]: badRequestSchema, [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, [HttpStatusCodes.NOT_FOUND]: notFoundSchema, [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, }, diff --git a/apps/api/src/routes/v1/blocks/handlers.ts b/apps/api/src/routes/v1/blocks/handlers.ts new file mode 100644 index 0000000..886977d --- /dev/null +++ b/apps/api/src/routes/v1/blocks/handlers.ts @@ -0,0 +1,136 @@ +import { and, db, desc, eq, or, schema } from "@repo/db" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import type { AppRouteHandler } from "@/lib/types/app-types" +import type { + BlockUserRoute, + ListBlockedUsersRoute, + UnblockUserRoute, +} from "./routes" + +export const blockUser: 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 block yourself" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + // Check target user exists + const targetUser = await db + .select({ id: schema.user.id }) + .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 + ) + } + + // Atomically: insert block + remove any ally relationship + const result = await db.transaction(async (tx) => { + const inserted = await tx + .insert(schema.userBlock) + .values({ + blockerId: currentUser.id, + blockedId: targetUserId, + }) + .onConflictDoNothing() + .returning() + + if (inserted.length === 0) { + return { alreadyBlocked: true } + } + + // Delete any ally request between the two users (in either direction) + await tx + .delete(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) + ) + ) + ) + + return { alreadyBlocked: false } + }) + + if (result.alreadyBlocked) { + return c.json( + { success: false, message: "User is already blocked" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + return c.json({ success: true }, HttpStatusCodes.OK) +} + +export const unblockUser: AppRouteHandler = async (c) => { + const currentUser = c.var.user + const { userId: targetUserId } = c.req.valid("param") + + const deleted = await db + .delete(schema.userBlock) + .where( + and( + eq(schema.userBlock.blockerId, currentUser.id), + eq(schema.userBlock.blockedId, targetUserId) + ) + ) + .returning() + + if (deleted.length === 0) { + return c.json( + { success: false, message: "Block not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + return c.json({ success: true }, HttpStatusCodes.OK) +} + +export const listBlockedUsers: AppRouteHandler = async ( + c +) => { + const currentUser = c.var.user + + const blocks = await db + .select({ + id: schema.user.id, + name: schema.user.name, + username: schema.user.username, + displayUsername: schema.user.displayUsername, + image: schema.user.image, + blockedAt: schema.userBlock.createdAt, + }) + .from(schema.userBlock) + .innerJoin(schema.user, eq(schema.userBlock.blockedId, schema.user.id)) + .where(eq(schema.userBlock.blockerId, currentUser.id)) + .orderBy(desc(schema.userBlock.createdAt)) + + return c.json( + { + blockedUsers: blocks.map((b) => ({ + id: b.id, + name: b.name, + username: b.username, + displayUsername: b.displayUsername, + image: b.image, + blockedAt: b.blockedAt.toISOString(), + })), + }, + HttpStatusCodes.OK + ) +} diff --git a/apps/api/src/routes/v1/blocks/index.ts b/apps/api/src/routes/v1/blocks/index.ts new file mode 100644 index 0000000..6737601 --- /dev/null +++ b/apps/api/src/routes/v1/blocks/index.ts @@ -0,0 +1,10 @@ +import { createRouter } from "@/lib/helpers/app/create-app" +import * as handlers from "@/routes/v1/blocks/handlers" +import * as routes from "@/routes/v1/blocks/routes" + +const blocksRouter = createRouter() + .openapi(routes.blockUser, handlers.blockUser) + .openapi(routes.unblockUser, handlers.unblockUser) + .openapi(routes.listBlockedUsers, handlers.listBlockedUsers) + +export default blocksRouter diff --git a/apps/api/src/routes/v1/blocks/routes.ts b/apps/api/src/routes/v1/blocks/routes.ts new file mode 100644 index 0000000..cace9a3 --- /dev/null +++ b/apps/api/src/routes/v1/blocks/routes.ts @@ -0,0 +1,87 @@ +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, + internalServerErrorSchema, + notFoundSchema, + unauthorizedSchema, +} from "@/lib/helpers/openapi/schemas" +import { sessionAuthMiddleware } from "@/middleware/session-auth" +import { + blockUserBodySchema, + blockUserIdParamsSchema, + blockUserResponseSchema, + listBlockedUsersResponseSchema, + unblockUserResponseSchema, +} from "./schema" + +export const blockUser = createRoute({ + path: "/blocks", + method: "post", + summary: "Block a user", + description: + "Blocks a user. Removes any existing ally relationship between the users.", + tags: ["Blocks"], + middleware: [sessionAuthMiddleware] as const, + request: { + body: jsonContent({ + schema: blockUserBodySchema, + description: "User to block", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: blockUserResponseSchema, + description: "User blocked", + }), + [HttpStatusCodes.BAD_REQUEST]: badRequestSchema, + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type BlockUserRoute = typeof blockUser + +export const unblockUser = createRoute({ + path: "/blocks/{userId}", + method: "delete", + summary: "Unblock a user", + description: "Removes a block on the specified user.", + tags: ["Blocks"], + middleware: [sessionAuthMiddleware] as const, + request: { + params: blockUserIdParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: unblockUserResponseSchema, + description: "User unblocked", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type UnblockUserRoute = typeof unblockUser + +export const listBlockedUsers = createRoute({ + path: "/blocks", + method: "get", + summary: "List blocked users", + description: "Returns all users blocked by the current user.", + tags: ["Blocks"], + middleware: [sessionAuthMiddleware] as const, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: listBlockedUsersResponseSchema, + description: "List of blocked users", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type ListBlockedUsersRoute = typeof listBlockedUsers diff --git a/apps/api/src/routes/v1/blocks/schema.ts b/apps/api/src/routes/v1/blocks/schema.ts new file mode 100644 index 0000000..05090d8 --- /dev/null +++ b/apps/api/src/routes/v1/blocks/schema.ts @@ -0,0 +1,42 @@ +import { z } from "@hono/zod-openapi" + +// ── Path Params ────────────────────────────────────────── + +export const blockUserIdParamsSchema = z.object({ + userId: z + .string() + .uuid() + .openapi({ + param: { name: "userId", in: "path", required: true }, + example: "00000000-0000-0000-0000-000000000000", + }), +}) + +// ── Request Schemas ────────────────────────────────────── + +export const blockUserBodySchema = z.object({ + userId: z.string().uuid(), +}) + +// ── Response Schemas ────────────────────────────────────── + +const blockedUserSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + username: z.string().nullable(), + displayUsername: z.string().nullable(), + image: z.string().nullable(), + blockedAt: z.string().datetime(), +}) + +export const blockUserResponseSchema = z.object({ + success: z.literal(true), +}) + +export const unblockUserResponseSchema = z.object({ + success: z.literal(true), +}) + +export const listBlockedUsersResponseSchema = z.object({ + blockedUsers: z.array(blockedUserSchema), +}) diff --git a/apps/api/src/routes/v1/dms/handlers.ts b/apps/api/src/routes/v1/dms/handlers.ts index c02fd28..bba88fc 100644 --- a/apps/api/src/routes/v1/dms/handlers.ts +++ b/apps/api/src/routes/v1/dms/handlers.ts @@ -5,6 +5,8 @@ import { channelMember, message, user, + userBlock, + userPrivacySettings, } 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" @@ -71,43 +73,101 @@ export const createDM: AppRouteHandler = async (c) => { ) } - // Verify all target users are allies of the current user - const allyRows = await db - .select({ - senderId: allyRequest.senderId, - receiverId: allyRequest.receiverId, - }) - .from(allyRequest) + // Check if any target user has a block relationship with the current user + const blockRows = await db + .select({ id: userBlock.id }) + .from(userBlock) .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) - ) + or( + and( + eq(userBlock.blockerId, currentUser.id), + inArray(userBlock.blockedId, targetUserIds) + ), + and( + inArray(userBlock.blockerId, targetUserIds), + eq(userBlock.blockedId, currentUser.id) ) ) ) + .limit(1) - const allyUserIds = new Set( - allyRows.map((r) => - r.senderId === currentUser.id ? r.receiverId : r.senderId + if (blockRows.length > 0) { + return c.json( + { success: false, message: "Unable to create conversation" }, + HttpStatusCodes.FORBIDDEN ) + } + + // Fetch target users' privacy settings + const targetPrivacyRows = await db + .select({ + userId: userPrivacySettings.userId, + dmPrivacy: userPrivacySettings.dmPrivacy, + }) + .from(userPrivacySettings) + .where(inArray(userPrivacySettings.userId, targetUserIds)) + + const privacyByUserId = new Map( + targetPrivacyRows.map((r) => [r.userId, r.dmPrivacy]) ) - const nonAllyIds = targetUserIds.filter((id) => !allyUserIds.has(id)) - if (nonAllyIds.length > 0) { + // Check if any target user has DMs set to "no_one" + const noOneIds = targetUserIds.filter( + (id) => privacyByUserId.get(id) === "no_one" + ) + if (noOneIds.length > 0) { return c.json( - { success: false, message: "You can only create DMs with your allies" }, + { success: false, message: "This user is not accepting direct messages" }, HttpStatusCodes.FORBIDDEN ) } + // For users with "allies_only" privacy, verify ally relationship + const alliesOnlyIds = targetUserIds.filter( + (id) => privacyByUserId.get(id) === "allies_only" + ) + + if (alliesOnlyIds.length > 0) { + 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, alliesOnlyIds) + ), + and( + inArray(allyRequest.senderId, alliesOnlyIds), + eq(allyRequest.receiverId, currentUser.id) + ) + ) + ) + ) + + const allyUserIds = new Set( + allyRows.map((r) => + r.senderId === currentUser.id ? r.receiverId : r.senderId + ) + ) + + const nonAllyIds = alliesOnlyIds.filter((id) => !allyUserIds.has(id)) + if (nonAllyIds.length > 0) { + return c.json( + { + success: false, + message: "This user only accepts DMs from allies", + }, + HttpStatusCodes.FORBIDDEN + ) + } + } + const allMemberIds = [currentUser.id, ...targetUserIds].sort() const isDirect = targetUserIds.length === 1 diff --git a/apps/api/src/routes/v1/privacy-settings/handlers.ts b/apps/api/src/routes/v1/privacy-settings/handlers.ts new file mode 100644 index 0000000..3420a05 --- /dev/null +++ b/apps/api/src/routes/v1/privacy-settings/handlers.ts @@ -0,0 +1,62 @@ +import { db, eq, schema } from "@repo/db" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import type { AppRouteHandler } from "@/lib/types/app-types" +import type { + GetPrivacySettingsRoute, + UpdatePrivacySettingsRoute, +} from "./routes" + +const DEFAULT_SETTINGS = { + dmPrivacy: "everyone" as const, + allyRequestPrivacy: "everyone" as const, + onlineStatusPrivacy: "everyone" as const, +} + +export const getPrivacySettings: AppRouteHandler< + GetPrivacySettingsRoute +> = async (c) => { + const currentUser = c.var.user + + const settings = await db + .select({ + dmPrivacy: schema.userPrivacySettings.dmPrivacy, + allyRequestPrivacy: schema.userPrivacySettings.allyRequestPrivacy, + onlineStatusPrivacy: schema.userPrivacySettings.onlineStatusPrivacy, + }) + .from(schema.userPrivacySettings) + .where(eq(schema.userPrivacySettings.userId, currentUser.id)) + .limit(1) + .then((rows) => rows[0]) + + return c.json(settings ?? DEFAULT_SETTINGS, HttpStatusCodes.OK) +} + +export const updatePrivacySettings: AppRouteHandler< + UpdatePrivacySettingsRoute +> = async (c) => { + const currentUser = c.var.user + const body = c.req.valid("json") + + const updated = await db + .insert(schema.userPrivacySettings) + .values({ + userId: currentUser.id, + ...body, + }) + .onConflictDoUpdate({ + target: schema.userPrivacySettings.userId, + set: body, + }) + .returning({ + dmPrivacy: schema.userPrivacySettings.dmPrivacy, + allyRequestPrivacy: schema.userPrivacySettings.allyRequestPrivacy, + onlineStatusPrivacy: schema.userPrivacySettings.onlineStatusPrivacy, + }) + .then((rows) => rows[0]) + + if (!updated) { + return c.json(DEFAULT_SETTINGS, HttpStatusCodes.OK) + } + + return c.json(updated, HttpStatusCodes.OK) +} diff --git a/apps/api/src/routes/v1/privacy-settings/index.ts b/apps/api/src/routes/v1/privacy-settings/index.ts new file mode 100644 index 0000000..8f64fce --- /dev/null +++ b/apps/api/src/routes/v1/privacy-settings/index.ts @@ -0,0 +1,9 @@ +import { createRouter } from "@/lib/helpers/app/create-app" +import * as handlers from "@/routes/v1/privacy-settings/handlers" +import * as routes from "@/routes/v1/privacy-settings/routes" + +const privacySettingsRouter = createRouter() + .openapi(routes.getPrivacySettings, handlers.getPrivacySettings) + .openapi(routes.updatePrivacySettings, handlers.updatePrivacySettings) + +export default privacySettingsRouter diff --git a/apps/api/src/routes/v1/privacy-settings/routes.ts b/apps/api/src/routes/v1/privacy-settings/routes.ts new file mode 100644 index 0000000..972097e --- /dev/null +++ b/apps/api/src/routes/v1/privacy-settings/routes.ts @@ -0,0 +1,57 @@ +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, + unauthorizedSchema, +} from "@/lib/helpers/openapi/schemas" +import { sessionAuthMiddleware } from "@/middleware/session-auth" +import { + getPrivacySettingsResponseSchema, + updatePrivacySettingsBodySchema, + updatePrivacySettingsResponseSchema, +} from "./schema" + +export const getPrivacySettings = createRoute({ + path: "/privacy-settings", + method: "get", + summary: "Get privacy settings", + description: "Returns the current user's privacy settings.", + tags: ["Privacy Settings"], + middleware: [sessionAuthMiddleware] as const, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: getPrivacySettingsResponseSchema, + description: "Privacy settings", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type GetPrivacySettingsRoute = typeof getPrivacySettings + +export const updatePrivacySettings = createRoute({ + path: "/privacy-settings", + method: "patch", + summary: "Update privacy settings", + description: "Updates the current user's privacy settings.", + tags: ["Privacy Settings"], + middleware: [sessionAuthMiddleware] as const, + request: { + body: jsonContent({ + schema: updatePrivacySettingsBodySchema, + description: "Privacy settings to update", + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: updatePrivacySettingsResponseSchema, + description: "Updated privacy settings", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export type UpdatePrivacySettingsRoute = typeof updatePrivacySettings diff --git a/apps/api/src/routes/v1/privacy-settings/schema.ts b/apps/api/src/routes/v1/privacy-settings/schema.ts new file mode 100644 index 0000000..8b3376e --- /dev/null +++ b/apps/api/src/routes/v1/privacy-settings/schema.ts @@ -0,0 +1,8 @@ +import { + privacySettingsResponseSchema, + updatePrivacySettingsSchema, +} from "@repo/db/schema" + +export const getPrivacySettingsResponseSchema = privacySettingsResponseSchema +export const updatePrivacySettingsBodySchema = updatePrivacySettingsSchema +export const updatePrivacySettingsResponseSchema = privacySettingsResponseSchema diff --git a/apps/api/src/routes/v1/users/handlers.ts b/apps/api/src/routes/v1/users/handlers.ts index 0dba068..dd6ad66 100644 --- a/apps/api/src/routes/v1/users/handlers.ts +++ b/apps/api/src/routes/v1/users/handlers.ts @@ -46,6 +46,44 @@ export const getUserProfile: AppRouteHandler = async ( // fail open — default to offline } + // Check block relationship + let blockStatus: + | "none" + | "blocked_by_me" + | "blocked_by_them" + | "mutual_block" = "none" + + if (currentUser.id !== userId) { + const blocks = await db + .select({ + blockerId: schema.userBlock.blockerId, + }) + .from(schema.userBlock) + .where( + or( + and( + eq(schema.userBlock.blockerId, currentUser.id), + eq(schema.userBlock.blockedId, userId) + ), + and( + eq(schema.userBlock.blockerId, userId), + eq(schema.userBlock.blockedId, currentUser.id) + ) + ) + ) + + const blockedByMe = blocks.some((b) => b.blockerId === currentUser.id) + const blockedByThem = blocks.some((b) => b.blockerId === userId) + + if (blockedByMe && blockedByThem) { + blockStatus = "mutual_block" + } else if (blockedByMe) { + blockStatus = "blocked_by_me" + } else if (blockedByThem) { + blockStatus = "blocked_by_them" + } + } + // Check ally relationship const allyRequest = await db .select({ @@ -101,6 +139,7 @@ export const getUserProfile: AppRouteHandler = async ( presenceStatus, allyStatus, allyRequestId, + blockStatus, }, }, HttpStatusCodes.OK diff --git a/apps/api/src/routes/v1/users/schema.ts b/apps/api/src/routes/v1/users/schema.ts index e6ff0be..3aaa073 100644 --- a/apps/api/src/routes/v1/users/schema.ts +++ b/apps/api/src/routes/v1/users/schema.ts @@ -27,6 +27,12 @@ export const userProfileResponseSchema = z.object({ "allies", ]), allyRequestId: z.string().uuid().nullable(), + blockStatus: z.enum([ + "none", + "blocked_by_me", + "blocked_by_them", + "mutual_block", + ]), }) export const getUserProfileResponseSchema = z.object({ diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 9d9dc17..220e9d8 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 { and, db, eq, schema } from "@repo/db" +import { and, db, eq, inArray, or, schema } from "@repo/db" import { env } from "@repo/env/server" import type { ClientToServerEvents, @@ -28,6 +28,7 @@ import { Queue } from "bullmq" import { createClient } from "redis" import { Server, type Socket } from "socket.io" import { toErrorMessage } from "@/lib/errors" +import { isDMBlockedForUser } from "@/services/blocks" import { assertUserCanAccessChannel } from "@/services/channel-access" import { createMessage, @@ -232,12 +233,26 @@ async function initializeConnection(socket: RealtimeSocket) { } if (becameOnline && isCurrentSocketAlive) { - for (const guildId of guildIds) { - io.to(guildRoom(guildId)).emit("presence:user:update", { - guildId, - userId: socket.data.user.id, - status: "online", + // Check user's online status privacy before broadcasting + const privacyRow = await db + .select({ + onlineStatusPrivacy: schema.userPrivacySettings.onlineStatusPrivacy, }) + .from(schema.userPrivacySettings) + .where(eq(schema.userPrivacySettings.userId, socket.data.user.id)) + .limit(1) + .then((rows) => rows[0]) + + const onlinePrivacy = privacyRow?.onlineStatusPrivacy ?? "everyone" + + if (onlinePrivacy !== "no_one") { + for (const guildId of guildIds) { + io.to(guildRoom(guildId)).emit("presence:user:update", { + guildId, + userId: socket.data.user.id, + status: "online", + }) + } } } @@ -319,11 +334,88 @@ io.on("connection", (socket) => { userIds ) + // Filter online users by their privacy settings + const requestingUserId = socket.data.user.id + let visibleOnlineUserIds = onlineUserIds + + if (onlineUserIds.length > 0) { + // Fetch privacy settings for online users (excluding the requester) + const otherOnlineIds = onlineUserIds.filter( + (id) => id !== requestingUserId + ) + + if (otherOnlineIds.length > 0) { + const privacyRows = await db + .select({ + userId: schema.userPrivacySettings.userId, + onlineStatusPrivacy: + schema.userPrivacySettings.onlineStatusPrivacy, + }) + .from(schema.userPrivacySettings) + .where(inArray(schema.userPrivacySettings.userId, otherOnlineIds)) + + const privacyByUserId = new Map( + privacyRows.map((r) => [r.userId, r.onlineStatusPrivacy]) + ) + + // Find users with "allies_only" privacy + const alliesOnlyIds = otherOnlineIds.filter( + (id) => privacyByUserId.get(id) === "allies_only" + ) + + // Find users with "no_one" privacy + const noOneIds = new Set( + otherOnlineIds.filter((id) => privacyByUserId.get(id) === "no_one") + ) + + // Check ally relationships for "allies_only" users + let allyIds = new Set() + if (alliesOnlyIds.length > 0) { + const allyRows = await db + .select({ + senderId: schema.allyRequest.senderId, + receiverId: schema.allyRequest.receiverId, + }) + .from(schema.allyRequest) + .where( + and( + eq(schema.allyRequest.status, "accepted"), + or( + and( + eq(schema.allyRequest.senderId, requestingUserId), + inArray(schema.allyRequest.receiverId, alliesOnlyIds) + ), + and( + eq(schema.allyRequest.receiverId, requestingUserId), + inArray(schema.allyRequest.senderId, alliesOnlyIds) + ) + ) + ) + ) + + allyIds = new Set( + allyRows.map((r) => + r.senderId === requestingUserId ? r.receiverId : r.senderId + ) + ) + } + + visibleOnlineUserIds = onlineUserIds.filter((id) => { + if (id === requestingUserId) return true + if (noOneIds.has(id)) return false + if (privacyByUserId.get(id) === "allies_only") { + return allyIds.has(id) + } + return true // "everyone" or no settings (default) + }) + } + } + ack?.({ ok: true, snapshot: { guildId: parsed.guildId, - onlineUserIds, + onlineUserIds: visibleOnlineUserIds, }, }) } catch (error) { @@ -371,6 +463,21 @@ io.on("connection", (socket) => { redisPresenceClient, socket.data.user.id ) + + // Block enforcement for 1:1 DMs only (group DMs use client-side filtering) + if (accessibleChannel.type === "dm") { + const blocked = await isDMBlockedForUser( + parsed.channelId, + socket.data.user.id + ) + if (blocked) { + ack?.({ + ok: false, + error: "Cannot send messages in this conversation", + }) + return + } + } } const createdMessage = await createMessage({ @@ -513,7 +620,20 @@ io.on("connection", (socket) => { socket.on("typing:start", async (payload) => { try { const parsed = typingStartPayloadSchema.parse(payload) - await assertUserCanAccessChannel(socket.data.user.id, parsed.channelId) + const accessibleChannel = await assertUserCanAccessChannel( + socket.data.user.id, + parsed.channelId + ) + + // Suppress typing in 1:1 DMs if blocked + if (accessibleChannel.type === "dm") { + const blocked = await isDMBlockedForUser( + parsed.channelId, + socket.data.user.id + ) + if (blocked) return + } + socket.to(channelRoom(parsed.channelId)).emit("typing:update", { channelId: parsed.channelId, userId: socket.data.user.id, @@ -583,12 +703,26 @@ io.on("connection", (socket) => { if (!becameOffline) return - for (const guildId of socket.data.guildIds ?? []) { - io.to(guildRoom(guildId)).emit("presence:user:update", { - guildId, - userId: socket.data.user.id, - status: "offline", + // Check user's online status privacy before broadcasting + const privacyRow = await db + .select({ + onlineStatusPrivacy: schema.userPrivacySettings.onlineStatusPrivacy, }) + .from(schema.userPrivacySettings) + .where(eq(schema.userPrivacySettings.userId, socket.data.user.id)) + .limit(1) + .then((rows) => rows[0]) + + const onlinePrivacy = privacyRow?.onlineStatusPrivacy ?? "everyone" + + if (onlinePrivacy !== "no_one") { + for (const guildId of socket.data.guildIds ?? []) { + io.to(guildRoom(guildId)).emit("presence:user:update", { + guildId, + userId: socket.data.user.id, + status: "offline", + }) + } } } catch (error) { console.error("[realtime] disconnect presence cleanup failed:", { diff --git a/apps/realtime/src/services/blocks.ts b/apps/realtime/src/services/blocks.ts new file mode 100644 index 0000000..080cbb4 --- /dev/null +++ b/apps/realtime/src/services/blocks.ts @@ -0,0 +1,49 @@ +import { and, db, eq, ne, or, schema } from "@repo/db" + +/** + * Check if a block exists between users in a 1:1 DM channel. + * Only enforced for "dm" type, NOT "group_dm" — in group DMs, + * blocked users can still send but messages are hidden client-side. + */ +export async function isDMBlockedForUser( + channelId: string, + userId: string +): Promise { + // Get the single other member of the 1:1 DM + const otherMember = await db + .select({ userId: schema.channelMember.userId }) + .from(schema.channelMember) + .where( + and( + eq(schema.channelMember.channelId, channelId), + ne(schema.channelMember.userId, userId) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (!otherMember) return false + + const otherUserId = otherMember.userId + + // Check if a block exists in either direction + const block = await db + .select({ id: schema.userBlock.id }) + .from(schema.userBlock) + .where( + or( + and( + eq(schema.userBlock.blockerId, userId), + eq(schema.userBlock.blockedId, otherUserId) + ), + and( + eq(schema.userBlock.blockerId, otherUserId), + eq(schema.userBlock.blockedId, userId) + ) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + return !!block +} diff --git a/apps/web/src/components/allies/allies-page.tsx b/apps/web/src/components/allies/allies-page.tsx index 651b44f..89ed59e 100644 --- a/apps/web/src/components/allies/allies-page.tsx +++ b/apps/web/src/components/allies/allies-page.tsx @@ -19,6 +19,7 @@ import { Check, MessageCircle, Search, + ShieldOff, UserMinus, UserPlus, Users, @@ -27,11 +28,12 @@ import { import { useState } from "react" import { toast } from "sonner" import { UserAvatar } from "@/components/ui/user-avatar" +import { useBlockedUsers } from "@/hooks/use-blocked-users" import { useCreateDM } from "@/hooks/use-create-dm" import { apiClient } from "@/lib/api-client" -import type { Ally, AllyRequest } from "@/lib/api-types" +import type { Ally, AllyRequest, BlockedUser } from "@/lib/api-types" -type Tab = "all" | "pending" +type Tab = "all" | "pending" | "blocked" function AlliesSkeleton() { return ( @@ -166,6 +168,40 @@ function OutgoingRequestRow({ request }: { request: AllyRequest }) { ) } +function BlockedUserRow({ + user, + onUnblock, + isUnblocking, +}: { + user: BlockedUser + onUnblock: (userId: string) => void + isUnblocking: boolean +}) { + return ( +
+ +
+
{user.name}
+ {user.username && ( +
+ @{user.displayUsername ?? user.username} +
+ )} +
+ +
+ ) +} + export function AlliesPage() { const queryClient = useQueryClient() const createDM = useCreateDM() @@ -199,8 +235,15 @@ export function AlliesPage() { }, }) + const { + data: blockedData, + isPending: blockedLoading, + isError: blockedError, + } = useBlockedUsers() + const [removingAllyId, setRemovingAllyId] = useState(null) const [confirmRemoveAlly, setConfirmRemoveAlly] = useState(null) + const [unblockingUserId, setUnblockingUserId] = useState(null) const invalidate = (affectedUserId?: string) => { void queryClient.invalidateQueries({ queryKey: ["allies"] }) @@ -305,6 +348,29 @@ export function AlliesPage() { }, }) + const unblockUser = useMutation({ + mutationFn: async (userId: string) => { + setUnblockingUserId(userId) + const res = await apiClient.v1.blocks[":userId"].$delete({ + param: { userId }, + }) + if (!res.ok) throw new Error("Failed to unblock user") + return userId + }, + onSuccess: (userId) => { + setUnblockingUserId(null) + void queryClient.invalidateQueries({ queryKey: ["blocked-users"] }) + void queryClient.invalidateQueries({ + queryKey: ["user-profile", userId], + }) + toast.success("User unblocked") + }, + onError: () => { + setUnblockingUserId(null) + toast.error("Failed to unblock user") + }, + }) + const handleSendRequest = (e: React.FormEvent) => { e.preventDefault() const trimmed = addUsername.trim() @@ -361,6 +427,18 @@ export function AlliesPage() { )} + @@ -504,6 +582,33 @@ export function AlliesPage() { )} )} + + {tab === "blocked" && + (blockedLoading ? ( + + ) : blockedError ? ( +
+ Failed to load blocked users. +
+ ) : (blockedData?.blockedUsers ?? []).length === 0 ? ( +
+ You haven't blocked anyone. +
+ ) : ( +
+
+ Blocked — {blockedData?.blockedUsers.length} +
+ {blockedData?.blockedUsers.map((user) => ( + unblockUser.mutate(userId)} + isUnblocking={unblockingUserId === user.id} + /> + ))} +
+ ))} - @{item.label} + @{item.displayUsername ?? item.username ?? item.label} ))} diff --git a/apps/web/src/components/chat/message-item.tsx b/apps/web/src/components/chat/message-item.tsx index e0b8446..58de255 100644 --- a/apps/web/src/components/chat/message-item.tsx +++ b/apps/web/src/components/chat/message-item.tsx @@ -17,7 +17,7 @@ import { import { cn } from "@repo/ui/lib/utils" import { formatTime } from "@repo/utils/date" import { Pin } from "lucide-react" -import { useCallback, useState } from "react" +import { useCallback, useEffect, 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" @@ -31,6 +31,7 @@ interface MessageItemProps { message: Message showHeader: boolean currentUserId?: string + isBlocked?: boolean onReact?: (messageId: string, emoji: string) => void onReply?: (message: Message) => void onDelete?: (messageId: string) => void @@ -127,6 +128,7 @@ export function MessageItem({ message, showHeader, currentUserId, + isBlocked = false, onReact, onReply, onDelete, @@ -139,6 +141,12 @@ export function MessageItem({ const [isActionBarPinned, setIsActionBarPinned] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) + const [showBlockedContent, setShowBlockedContent] = useState(false) + + useEffect(() => { + setShowBlockedContent(false) + }, [message.id]) + const isOwnMessage = !!currentUserId && currentUserId === message.authorId const isReply = message.type === "reply" @@ -191,6 +199,20 @@ export function MessageItem({ setIsEditing(false) }, []) + if (isBlocked && !showBlockedContent) { + return ( +
+ +
+ ) + } + return (
onReact?: (messageId: string, emoji: string) => void onReply?: (message: Message) => void onDelete?: (messageId: string) => void @@ -68,6 +69,7 @@ export function MessageList({ context, messages, currentUserId, + blockedUserIds, onReact, onReply, onDelete, @@ -187,6 +189,7 @@ export function MessageList({ message={msg} showHeader={showHeader} currentUserId={currentUserId} + isBlocked={blockedUserIds?.has(msg.authorId) ?? false} onReact={onReact} onReply={onReply} onDelete={onDelete} diff --git a/apps/web/src/components/settings/my-account-settings.tsx b/apps/web/src/components/settings/my-account-settings.tsx index 5b7c3e5..4e37899 100644 --- a/apps/web/src/components/settings/my-account-settings.tsx +++ b/apps/web/src/components/settings/my-account-settings.tsx @@ -6,13 +6,16 @@ import { Label } from "@repo/ui/components/label" import { Separator } from "@repo/ui/components/separator" import { Textarea } from "@repo/ui/components/textarea" import { cn } from "@repo/ui/lib/utils" -import { Camera, Loader2, Upload } from "lucide-react" +import { Camera, Check, Loader2, Upload, X } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" import { toast } from "sonner" import { apiClient } from "@/lib/api-client" const MAX_BIO_LENGTH = 255 const MAX_STATUS_LENGTH = 128 +const MAX_USERNAME_LENGTH = 30 +const MIN_USERNAME_LENGTH = 3 +const USERNAME_REGEX = /^[a-zA-Z0-9_.]+$/ const MAX_AVATAR_BYTES = 2 * 1024 * 1024 const ACCEPTED_IMAGE_TYPES = [ "image/jpeg", @@ -36,19 +39,25 @@ export function MyAccountSettings() { const user = session?.user const [name, setName] = useState("") + const [displayUsername, setDisplayUsername] = useState("") const [bio, setBio] = useState("") const [status, setStatus] = useState("") const [avatarPreview, setAvatarPreview] = useState(null) const [avatarFile, setAvatarFile] = useState(null) const [isSaving, setIsSaving] = useState(false) const [isDragging, setIsDragging] = useState(false) + const [usernameAvailability, setUsernameAvailability] = useState< + "idle" | "checking" | "available" | "taken" | "invalid" + >("idle") const fileInputRef = useRef(null) const dragCountRef = useRef(0) const avatarPreviewRef = useRef(null) + const usernameCheckTimer = useRef | null>(null) useEffect(() => { if (!user) return setName(user.name ?? "") + setDisplayUsername(user.displayUsername ?? user.username ?? "") setBio((user.bio as string) ?? "") setStatus((user.status as string) ?? "") }, [user]) @@ -57,9 +66,63 @@ export function MyAccountSettings() { return () => { if (avatarPreviewRef.current) URL.revokeObjectURL(avatarPreviewRef.current) + if (usernameCheckTimer.current) clearTimeout(usernameCheckTimer.current) } }, []) + const originalUsername = user?.displayUsername ?? user?.username ?? "" + const usernameChanged = displayUsername.trim() !== originalUsername + + const handleUsernameChange = useCallback( + (value: string) => { + setDisplayUsername(value) + + if (usernameCheckTimer.current) clearTimeout(usernameCheckTimer.current) + + const trimmed = value.trim() + const currentOriginal = + session?.user?.displayUsername ?? session?.user?.username ?? "" + + if (!trimmed || trimmed === currentOriginal) { + setUsernameAvailability("idle") + return + } + + if (trimmed.length < MIN_USERNAME_LENGTH) { + setUsernameAvailability("invalid") + return + } + + if (!USERNAME_REGEX.test(trimmed)) { + setUsernameAvailability("invalid") + return + } + + setUsernameAvailability("checking") + const checked = trimmed + usernameCheckTimer.current = setTimeout(async () => { + try { + const { data } = await authClient.isUsernameAvailable({ + username: checked, + }) + // Only update if input hasn't changed since we started checking + setUsernameAvailability((prev) => + prev === "checking" + ? data?.available + ? "available" + : "taken" + : prev + ) + } catch { + setUsernameAvailability((prev) => + prev === "checking" ? "idle" : prev + ) + } + }, 500) + }, + [session?.user?.displayUsername, session?.user?.username] + ) + const setAvatarFromFile = useCallback((file: File) => { const error = validateAvatarFile(file) if (error) { @@ -159,6 +222,9 @@ export function MyAccountSettings() { image: imageUrl ?? undefined, bio: bio.trim() || undefined, status: status.trim() || undefined, + ...(usernameChanged && displayUsername.trim() + ? { username: displayUsername.trim() } + : {}), }) setAvatarFile(null) @@ -174,16 +240,30 @@ export function MyAccountSettings() { } finally { setIsSaving(false) } - }, [user, name, bio, status, avatarFile, avatarPreview, uploadAvatar]) + }, [ + user, + name, + displayUsername, + usernameChanged, + bio, + status, + avatarFile, + avatarPreview, + uploadAvatar, + ]) const hasChanges = user && (name.trim() !== (user.name ?? "") || + usernameChanged || bio.trim() !== ((user.bio as string) ?? "") || status.trim() !== ((user.status as string) ?? "") || avatarFile !== null) - const isValid = name.trim().length > 0 + const isUsernameValid = + !usernameChanged || usernameAvailability === "available" + + const isValid = name.trim().length > 0 && isUsernameValid if (!user) return null @@ -235,7 +315,9 @@ export function MyAccountSettings() {

{user.name}

- {user.username ? `@${user.username}` : user.email} + {(user.displayUsername ?? user.username) + ? `@${user.displayUsername ?? user.username}` + : user.email}

+
+ +
+ handleUsernameChange(e.target.value)} + maxLength={MAX_USERNAME_LENGTH} + placeholder="your_username" + /> + {usernameChanged && ( +
+ {usernameAvailability === "checking" && ( + + )} + {usernameAvailability === "available" && ( + + )} + {usernameAvailability === "taken" && ( + + )} + {usernameAvailability === "invalid" && ( + + )} +
+ )} +
+ {usernameChanged && usernameAvailability === "taken" && ( +

+ Username is already taken +

+ )} + {usernameChanged && usernameAvailability === "invalid" && ( +

+ {displayUsername.trim().length < MIN_USERNAME_LENGTH + ? `Username must be at least ${MIN_USERNAME_LENGTH} characters` + : "Only letters, numbers, underscores, and dots allowed"} +

+ )} +
+
diff --git a/apps/web/src/components/settings/privacy-safety-settings.tsx b/apps/web/src/components/settings/privacy-safety-settings.tsx new file mode 100644 index 0000000..b8e3fab --- /dev/null +++ b/apps/web/src/components/settings/privacy-safety-settings.tsx @@ -0,0 +1,138 @@ +import { Label } from "@repo/ui/components/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@repo/ui/components/select" +import { Separator } from "@repo/ui/components/separator" +import { Loader2 } from "lucide-react" +import { toast } from "sonner" +import { + type PrivacySettings, + usePrivacySettings, + useUpdatePrivacySettings, +} from "@/hooks/use-privacy-settings" + +const DM_PRIVACY_OPTIONS = [ + { value: "everyone", label: "Everyone" }, + { value: "allies_only", label: "Allies Only" }, + { value: "no_one", label: "No One" }, +] as const + +const ALLY_REQUEST_OPTIONS = [ + { value: "everyone", label: "Everyone" }, + { value: "no_one", label: "No One" }, +] as const + +const ONLINE_STATUS_OPTIONS = [ + { value: "everyone", label: "Everyone" }, + { value: "allies_only", label: "Allies Only" }, + { value: "no_one", label: "No One" }, +] as const + +export function PrivacySafetySettings() { + const { data: settings, isPending } = usePrivacySettings() + const { mutate: updateSettings } = useUpdatePrivacySettings() + + const handleChange = (key: keyof PrivacySettings, value: string) => { + updateSettings( + { [key]: value }, + { + onError: () => { + toast.error("Failed to update privacy setting") + }, + } + ) + } + + if (isPending) { + return ( +
+ +
+ ) + } + + return ( +
+
+

Privacy & Safety

+

+ Control who can contact you and see your activity. +

+
+ + + +
+
+ +

+ Controls who can start a new DM conversation with you. +

+ +
+ +
+ +

+ Controls who can send you ally requests. +

+ +
+ +
+ +

+ Controls who can see when you are online in guilds. +

+ +
+
+
+ ) +} diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index 1130342..f465eb0 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -38,6 +38,7 @@ import { import { useMemo, useState } from "react" import { useSettings } from "@/context/settings-context" import { MyAccountSettings } from "./my-account-settings" +import { PrivacySafetySettings } from "./privacy-safety-settings" interface SettingsNav { name: string @@ -125,6 +126,8 @@ export function SettingsDialog() {
{activeItem === "My Account" ? ( + ) : activeItem === "Privacy & Safety" ? ( + ) : (
{activeItem} settings coming soon. 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 index 5387914..8c72f54 100644 --- a/apps/web/src/components/sidebar/dm-panel/new-dm-dialog.tsx +++ b/apps/web/src/components/sidebar/dm-panel/new-dm-dialog.tsx @@ -14,6 +14,7 @@ import { useQuery } from "@tanstack/react-query" import { Check, Search } from "lucide-react" import { useState } from "react" import { UserAvatar } from "@/components/ui/user-avatar" +import { useBlockedUserIds } from "@/hooks/use-blocked-users" import { useCreateDM } from "@/hooks/use-create-dm" import { apiClient } from "@/lib/api-client" import type { Ally } from "@/lib/api-types" @@ -28,6 +29,7 @@ export function NewDMDialog({ const [search, setSearch] = useState("") const [selectedIds, setSelectedIds] = useState>(new Set()) const createDM = useCreateDM() + const blockedUserIds = useBlockedUserIds() const { data: allies, @@ -43,8 +45,10 @@ export function NewDMDialog({ enabled: open, }) - const filteredAllies = (allies?.allies ?? []).filter((ally) => - ally.name.toLowerCase().includes(search.toLowerCase()) + const filteredAllies = (allies?.allies ?? []).filter( + (ally) => + ally.name.toLowerCase().includes(search.toLowerCase()) && + !blockedUserIds.has(ally.id) ) const toggleAlly = (id: string) => { diff --git a/apps/web/src/components/ui/user-profile-card.tsx b/apps/web/src/components/ui/user-profile-card.tsx index cf86426..5649fcc 100644 --- a/apps/web/src/components/ui/user-profile-card.tsx +++ b/apps/web/src/components/ui/user-profile-card.tsx @@ -1,4 +1,14 @@ import { authClient } from "@repo/auth/client" +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 { @@ -7,8 +17,22 @@ import { PopoverTrigger, } from "@repo/ui/components/popover" import { Skeleton } from "@repo/ui/components/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@repo/ui/components/tooltip" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Check, Clock, UserMinus, UserPlus } from "lucide-react" +import { useNavigate } from "@tanstack/react-router" +import { + Ban, + Check, + Clock, + MessageCircle, + ShieldOff, + UserMinus, + UserPlus, +} from "lucide-react" import { useState } from "react" import { toast } from "sonner" import { apiClient } from "@/lib/api-client" @@ -97,6 +121,74 @@ function ProfileCardContent({ userId }: { userId: string }) { }, }) + const blockUser = useMutation({ + mutationFn: async () => { + const res = await apiClient.v1.blocks.$post({ + json: { userId }, + }) + if (!res.ok) throw new Error("Failed to block user") + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["user-profile", userId], + }) + void queryClient.invalidateQueries({ queryKey: ["allies"] }) + void queryClient.invalidateQueries({ queryKey: ["ally-requests"] }) + void queryClient.invalidateQueries({ queryKey: ["blocked-users"] }) + void queryClient.invalidateQueries({ queryKey: ["dms"] }) + toast.success("User blocked") + }, + onError: () => { + toast.error("Failed to block user") + }, + }) + + const unblockUser = useMutation({ + mutationFn: async () => { + const res = await apiClient.v1.blocks[":userId"].$delete({ + param: { userId }, + }) + if (!res.ok) throw new Error("Failed to unblock user") + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ["user-profile", userId], + }) + void queryClient.invalidateQueries({ queryKey: ["blocked-users"] }) + toast.success("User unblocked") + }, + onError: () => { + toast.error("Failed to unblock user") + }, + }) + + const navigate = useNavigate() + + const createDM = useMutation({ + mutationFn: async () => { + const res = await apiClient.v1.dms.$post({ + json: { userIds: [userId] }, + }) + 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) + }, + }) + + const [confirmBlock, setConfirmBlock] = useState(false) + const [confirmRemoveAlly, setConfirmRemoveAlly] = useState(false) + if (isPending) { return (
@@ -122,8 +214,18 @@ function ProfileCardContent({ userId }: { userId: string }) { const user = data.user const isCurrentUser = session?.user?.id === userId + const isBlockedByMe = + user.blockStatus === "blocked_by_me" || user.blockStatus === "mutual_block" + const isBlockedByThem = + user.blockStatus === "blocked_by_them" || + user.blockStatus === "mutual_block" const isMutating = - sendRequest.isPending || acceptRequest.isPending || removeAlly.isPending + sendRequest.isPending || + acceptRequest.isPending || + removeAlly.isPending || + blockUser.isPending || + unblockUser.isPending || + createDM.isPending return (
@@ -158,12 +260,12 @@ function ProfileCardContent({ userId }: { userId: string }) {
{/* Status */} - {user.status && ( + {user.status && !isBlockedByThem && (
{user.status}
)} {/* Bio */} - {user.bio && ( + {user.bio && !isBlockedByThem && (
{user.bio}
@@ -178,22 +280,150 @@ function ProfileCardContent({ userId }: { userId: string }) { })}
- {/* Ally actions */} + {/* Actions row */} {!isCurrentUser && ( - sendRequest.mutate()} - onAcceptRequest={(id) => acceptRequest.mutate(id)} - onRemoveAlly={() => removeAlly.mutate()} - /> + <> +
+ {/* Send DM */} + {!isBlockedByMe && !isBlockedByThem && ( +
+ + + + + Send DM + +
+ )} + + {/* Ally action */} + {!isBlockedByMe && !isBlockedByThem && ( +
+ sendRequest.mutate()} + onAcceptRequest={(id) => acceptRequest.mutate(id)} + onRemoveAlly={() => setConfirmRemoveAlly(true)} + /> +
+ )} + + {/* Block / Unblock */} +
+ {isBlockedByMe ? ( + + + + + Unblock + + ) : ( + + + + + Block + + )} +
+
+ + + + + Block {user.name}? + + They won't be able to send you ally requests or direct + messages. Any existing ally relationship will be removed. + + + + + Cancel + + { + e.preventDefault() + blockUser.mutate(undefined, { + onSuccess: () => setConfirmBlock(false), + }) + }} + > + Block + + + + + + + + + Remove ally + + Are you sure you want to remove{" "} + + {user.name} + {" "} + as an ally? + + + + + Cancel + + { + e.preventDefault() + removeAlly.mutate(undefined, { + onSuccess: () => setConfirmRemoveAlly(false), + }) + }} + > + Remove + + + + + )}
) } -function AllyActionButton({ +function AllyActionIconButton({ allyStatus, allyRequestId, isMutating, @@ -211,47 +441,70 @@ function AllyActionButton({ switch (allyStatus) { case "none": return ( - + + + + + Send Ally Request + ) case "pending_outgoing": return ( - + + + + + Ally Request Sent + ) case "pending_incoming": return ( - + + + + + Accept Ally Request + ) case "allies": return ( - + + + + + Remove Ally + ) } } diff --git a/apps/web/src/hooks/use-blocked-users.ts b/apps/web/src/hooks/use-blocked-users.ts new file mode 100644 index 0000000..6c9c549 --- /dev/null +++ b/apps/web/src/hooks/use-blocked-users.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query" +import { useMemo } from "react" +import { apiClient } from "@/lib/api-client" + +export function useBlockedUsers() { + return useQuery({ + queryKey: ["blocked-users"], + queryFn: async () => { + const res = await apiClient.v1.blocks.$get() + if (!res.ok) throw new Error("Failed to fetch blocked users") + return res.json() + }, + }) +} + +export function useBlockedUserIds() { + const { data } = useBlockedUsers() + return useMemo( + () => new Set(data?.blockedUsers.map((u) => u.id) ?? []), + [data] + ) +} diff --git a/apps/web/src/hooks/use-privacy-settings.ts b/apps/web/src/hooks/use-privacy-settings.ts new file mode 100644 index 0000000..32a9c3f --- /dev/null +++ b/apps/web/src/hooks/use-privacy-settings.ts @@ -0,0 +1,38 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { apiClient } from "@/lib/api-client" + +export type PrivacySettings = { + dmPrivacy: "everyone" | "allies_only" | "no_one" + allyRequestPrivacy: "everyone" | "no_one" + onlineStatusPrivacy: "everyone" | "allies_only" | "no_one" +} + +const PRIVACY_SETTINGS_KEY = ["privacy-settings"] + +export function usePrivacySettings() { + return useQuery({ + queryKey: PRIVACY_SETTINGS_KEY, + queryFn: async () => { + const res = await apiClient.v1["privacy-settings"].$get() + if (!res.ok) throw new Error("Failed to fetch privacy settings") + return res.json() as Promise + }, + }) +} + +export function useUpdatePrivacySettings() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (settings: Partial) => { + const res = await apiClient.v1["privacy-settings"].$patch({ + json: settings, + }) + if (!res.ok) throw new Error("Failed to update privacy settings") + return res.json() as Promise + }, + onSuccess: (data) => { + queryClient.setQueryData(PRIVACY_SETTINGS_KEY, data) + }, + }) +} diff --git a/apps/web/src/hooks/use-typing-indicator.ts b/apps/web/src/hooks/use-typing-indicator.ts index 659ae50..4573047 100644 --- a/apps/web/src/hooks/use-typing-indicator.ts +++ b/apps/web/src/hooks/use-typing-indicator.ts @@ -15,10 +15,12 @@ export function useTypingIndicator({ socket, channelId, currentUserId, + blockedUserIds, }: { socket: AppSocket | null channelId: string currentUserId: string | undefined + blockedUserIds?: Set }) { const [typingUsers, setTypingUsers] = useState([]) const lastEmitRef = useRef(0) @@ -40,6 +42,7 @@ export function useTypingIndicator({ const onTypingUpdate = (payload: TypingIndicatorEvent) => { if (payload.channelId !== channelId) return if (payload.userId === currentUserId) return + if (blockedUserIds?.has(payload.userId)) return setTypingUsers((prev) => { const expiresAt = Date.now() + TYPING_EXPIRE_MS @@ -56,12 +59,20 @@ export function useTypingIndicator({ }) } + // Prune any currently visible typers who are now blocked + if (blockedUserIds && blockedUserIds.size > 0) { + setTypingUsers((prev) => { + const filtered = prev.filter((u) => !blockedUserIds.has(u.userId)) + return filtered.length === prev.length ? prev : filtered + }) + } + socket.on("typing:update", onTypingUpdate) return () => { socket.off("typing:update", onTypingUpdate) } - }, [socket, channelId, currentUserId]) + }, [socket, channelId, currentUserId, blockedUserIds]) // Cleanup expired entries useEffect(() => { diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index a54601a..bf93158 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -79,6 +79,16 @@ export type ListAllyRequestsResponse = InferResponseType< > export type AllyRequest = ListAllyRequestsResponse["incoming"][number] +// ── Blocks ────────────────────────────────────────── + +type BlocksClient = Client["v1"]["blocks"] + +export type ListBlockedUsersResponse = InferResponseType< + BlocksClient["$get"], + 200 +> +export type BlockedUser = ListBlockedUsersResponse["blockedUsers"][number] + // ── Users ────────────────────────────────────────── type UserProfileClient = Client["v1"]["users"][":userId"] diff --git a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx index db9634f..bf288df 100644 --- a/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx +++ b/apps/web/src/routes/_authenticated/$guildSlug/$channelId.tsx @@ -16,6 +16,7 @@ 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 { useBlockedUserIds } from "@/hooks/use-blocked-users" import { useFileUpload } from "@/hooks/use-file-upload" import { useMessageDeletion } from "@/hooks/use-message-deletion" import { useMessageEditing } from "@/hooks/use-message-editing" @@ -38,6 +39,7 @@ function ChannelView() { const { view, setView, clearView } = useRightSidebar() const { data: session } = authClient.useSession() const currentUserId = session?.user.id + const blockedUserIds = useBlockedUserIds() useEffect(() => { if (!guildSlug || !channelId) return @@ -173,6 +175,7 @@ function ChannelView() { socket, channelId, currentUserId, + blockedUserIds, }) // Clear reply state when switching channels @@ -259,6 +262,7 @@ function ChannelView() { context={context} messages={messagesData?.data ?? []} currentUserId={currentUserId} + blockedUserIds={blockedUserIds} onReact={handleReact} onReply={setReplyingTo} onDelete={handleDelete} diff --git a/apps/web/src/routes/_authenticated/dms/$dmId.tsx b/apps/web/src/routes/_authenticated/dms/$dmId.tsx index b1f2e1e..56ad9fb 100644 --- a/apps/web/src/routes/_authenticated/dms/$dmId.tsx +++ b/apps/web/src/routes/_authenticated/dms/$dmId.tsx @@ -10,6 +10,7 @@ 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 { useBlockedUserIds } from "@/hooks/use-blocked-users" import { useFileUpload } from "@/hooks/use-file-upload" import { useMessageDeletion } from "@/hooks/use-message-deletion" import { useMessageEditing } from "@/hooks/use-message-editing" @@ -30,6 +31,7 @@ function DMConversation() { const queryClient = useQueryClient() const { data: session } = authClient.useSession() const currentUserId = session?.user.id + const blockedUserIds = useBlockedUserIds() const { data: dm, isPending } = useQuery({ queryKey: ["dms", dmId], @@ -97,6 +99,7 @@ function DMConversation() { socket, channelId: dmId, currentUserId, + blockedUserIds, }) // Clear reply state when switching DMs @@ -154,6 +157,13 @@ function DMConversation() { avatarUrl: dm.members[0]?.image ?? undefined, } + // For 1:1 DMs, check if the other user is blocked + const isDirect = dm.type === "dm" + const otherMemberId = isDirect ? dm.members[0]?.id : undefined + const isOtherBlocked = otherMemberId + ? blockedUserIds.has(otherMemberId) + : false + const mentionCandidates = dm.members.map((member) => ({ id: member.id, label: member.displayUsername ?? member.username ?? member.name, @@ -177,6 +187,7 @@ function DMConversation() { context={context} messages={messagesData?.data ?? []} currentUserId={currentUserId} + blockedUserIds={blockedUserIds} onReact={handleReact} onReply={setReplyingTo} onDelete={handleDelete} @@ -185,21 +196,27 @@ function DMConversation() { isLoading={messagesLoading} /> - + {isOtherBlocked ? ( +
+ You have blocked this user. Unblock them to send messages. +
+ ) : ( + + )}
) } diff --git a/packages/db/src/schemas/index.ts b/packages/db/src/schemas/index.ts index 6967565..0a7cdaf 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -14,6 +14,8 @@ export * from "./messages" export * from "./notification-events" export * from "./sessions" export * from "./two-factors" +export * from "./user-blocks" +export * from "./user-privacy-settings" export * from "./users" export * from "./verifications" export * from "./waitlist" diff --git a/packages/db/src/schemas/user-blocks.ts b/packages/db/src/schemas/user-blocks.ts new file mode 100644 index 0000000..ab323d2 --- /dev/null +++ b/packages/db/src/schemas/user-blocks.ts @@ -0,0 +1,56 @@ +import { relations, sql } from "drizzle-orm" +import { + check, + index, + pgTable, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +import { user } from "./users" + +export const userBlock = pgTable( + "user_block", + { + id: uuid("id").defaultRandom().primaryKey(), + createdAt: timestamp("created_at").defaultNow().notNull(), + blockerId: uuid("blocker_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + blockedId: uuid("blocked_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [ + uniqueIndex("userBlock_blocker_blocked_uidx").on( + table.blockerId, + table.blockedId + ), + index("userBlock_blockerId_idx").on(table.blockerId), + index("userBlock_blockedId_idx").on(table.blockedId), + check( + "user_block_no_self_block", + sql`${table.blockerId} <> ${table.blockedId}` + ), + ] +) + +export const selectUserBlockSchema = createSelectSchema(userBlock) +export const insertUserBlockSchema = createInsertSchema(userBlock).omit({ + id: true, + createdAt: true, +}) + +export const userBlockRelations = relations(userBlock, ({ one }) => ({ + blocker: one(user, { + relationName: "userBlockBlocker", + fields: [userBlock.blockerId], + references: [user.id], + }), + blocked: one(user, { + relationName: "userBlockBlocked", + fields: [userBlock.blockedId], + references: [user.id], + }), +})) diff --git a/packages/db/src/schemas/user-privacy-settings.ts b/packages/db/src/schemas/user-privacy-settings.ts new file mode 100644 index 0000000..71b1f2f --- /dev/null +++ b/packages/db/src/schemas/user-privacy-settings.ts @@ -0,0 +1,76 @@ +import { relations } from "drizzle-orm" +import { pgEnum, pgTable, timestamp, uuid } from "drizzle-orm/pg-core" +import { createInsertSchema, createSelectSchema } from "drizzle-zod" +import { user } from "./users" + +export const dmPrivacyEnum = pgEnum("dm_privacy", [ + "everyone", + "allies_only", + "no_one", +]) + +export const allyRequestPrivacyEnum = pgEnum("ally_request_privacy", [ + "everyone", + "no_one", +]) + +export const onlineStatusPrivacyEnum = pgEnum("online_status_privacy", [ + "everyone", + "allies_only", + "no_one", +]) + +export const userPrivacySettings = pgTable("user_privacy_settings", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id") + .notNull() + .unique() + .references(() => user.id, { onDelete: "cascade" }), + dmPrivacy: dmPrivacyEnum("dm_privacy").default("everyone").notNull(), + allyRequestPrivacy: allyRequestPrivacyEnum("ally_request_privacy") + .default("everyone") + .notNull(), + onlineStatusPrivacy: onlineStatusPrivacyEnum("online_status_privacy") + .default("everyone") + .notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}) + +export const selectUserPrivacySettingsSchema = + createSelectSchema(userPrivacySettings) +export const insertUserPrivacySettingsSchema = createInsertSchema( + userPrivacySettings +).omit({ + id: true, + createdAt: true, + updatedAt: true, +}) + +export const privacySettingsResponseSchema = + selectUserPrivacySettingsSchema.pick({ + dmPrivacy: true, + allyRequestPrivacy: true, + onlineStatusPrivacy: true, + }) + +export const updatePrivacySettingsSchema = insertUserPrivacySettingsSchema + .pick({ + dmPrivacy: true, + allyRequestPrivacy: true, + onlineStatusPrivacy: true, + }) + .partial() + +export const userPrivacySettingsRelations = relations( + userPrivacySettings, + ({ one }) => ({ + user: one(user, { + fields: [userPrivacySettings.userId], + references: [user.id], + }), + }) +) diff --git a/packages/db/src/schemas/users.ts b/packages/db/src/schemas/users.ts index 3a47ab1..7aed7fc 100644 --- a/packages/db/src/schemas/users.ts +++ b/packages/db/src/schemas/users.ts @@ -14,6 +14,8 @@ import { guild } from "./guilds" import { invitation } from "./invitations" import { session } from "./sessions" import { twoFactor } from "./two-factors" +import { userBlock } from "./user-blocks" +import { userPrivacySettings } from "./user-privacy-settings" export const user = pgTable("user", { id: uuid("id").defaultRandom().primaryKey(), @@ -38,7 +40,7 @@ export const user = pgTable("user", { status: varchar("status", { length: 128 }), }) -export const userRelations = relations(user, ({ many }) => ({ +export const userRelations = relations(user, ({ many, one }) => ({ sessions: many(session), accounts: many(account), guilds: many(guild), // can be owners of many guilds @@ -56,4 +58,11 @@ export const userRelations = relations(user, ({ many }) => ({ }), invitations: many(invitation), twoFactors: many(twoFactor), + blockedUsers: many(userBlock, { + relationName: "userBlockBlocker", + }), + blockedByUsers: many(userBlock, { + relationName: "userBlockBlocked", + }), + privacySettings: one(userPrivacySettings), }))