From 1001addeb0d38270f74ee28c09cbbd6544ba9f99 Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Sat, 21 Mar 2026 18:43:35 -0700 Subject: [PATCH 1/5] feat: add user blocking system, username editing, and mention display fix - 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 indicator filtering, DM input disabled when blocked, blocked users hidden from new DM dialog - Username editing in account settings with availability check - Fix mention suggestion list to show displayUsername instead of username --- ROADMAP.md | 5 + apps/api/src/app.ts | 2 + apps/api/src/routes/v1/allies/handlers.ts | 26 ++ apps/api/src/routes/v1/blocks/handlers.ts | 138 +++++++++ apps/api/src/routes/v1/blocks/index.ts | 10 + apps/api/src/routes/v1/blocks/routes.ts | 87 ++++++ apps/api/src/routes/v1/blocks/schema.ts | 42 +++ apps/api/src/routes/v1/dms/handlers.ts | 26 ++ apps/api/src/routes/v1/users/handlers.ts | 39 +++ apps/api/src/routes/v1/users/schema.ts | 6 + apps/realtime/src/index.ts | 31 +- apps/realtime/src/services/blocks.ts | 49 +++ .../web/src/components/allies/allies-page.tsx | 109 ++++++- .../chat/composer/mention-suggestion-list.tsx | 2 +- apps/web/src/components/chat/message-item.tsx | 17 ++ apps/web/src/components/chat/message-list.tsx | 3 + .../settings/my-account-settings.tsx | 122 +++++++- .../sidebar/dm-panel/new-dm-dialog.tsx | 8 +- .../src/components/ui/user-profile-card.tsx | 289 +++++++++++++++--- apps/web/src/hooks/use-blocked-users.ts | 22 ++ apps/web/src/hooks/use-typing-indicator.ts | 3 + apps/web/src/lib/api-types.ts | 10 + .../_authenticated/$guildSlug/$channelId.tsx | 4 + .../src/routes/_authenticated/dms/$dmId.tsx | 47 ++- packages/db/src/schemas/index.ts | 1 + packages/db/src/schemas/user-blocks.ts | 51 ++++ packages/db/src/schemas/users.ts | 7 + 27 files changed, 1085 insertions(+), 71 deletions(-) create mode 100644 apps/api/src/routes/v1/blocks/handlers.ts create mode 100644 apps/api/src/routes/v1/blocks/index.ts create mode 100644 apps/api/src/routes/v1/blocks/routes.ts create mode 100644 apps/api/src/routes/v1/blocks/schema.ts create mode 100644 apps/realtime/src/services/blocks.ts create mode 100644 apps/web/src/hooks/use-blocked-users.ts create mode 100644 packages/db/src/schemas/user-blocks.ts diff --git a/ROADMAP.md b/ROADMAP.md index 65af220..b3a2a52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -39,6 +39,7 @@ - [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 +- [x] Direct messages — create 1:1 and group DMs with allies, new DM dialog - [ ] User blocking - [ ] Privacy settings @@ -55,7 +56,11 @@ - [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 - [ ] Other settings pages diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ca910fc..6d6e22a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -5,6 +5,7 @@ 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" @@ -38,6 +39,7 @@ app.route("/", index) const routes = app .route("/", waitlistRouter) .route("/v1", alliesRouter) + .route("/v1", blocksRouter) .route("/v1", channelsRouter) .route("/v1", guildsRouter) .route("/v1", invitesRouter) diff --git a/apps/api/src/routes/v1/allies/handlers.ts b/apps/api/src/routes/v1/allies/handlers.ts index dc7b641..3e5eab5 100644 --- a/apps/api/src/routes/v1/allies/handlers.ts +++ b/apps/api/src/routes/v1/allies/handlers.ts @@ -94,6 +94,32 @@ 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 for existing relationship (in either direction) const existing = await db .select({ 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..ab62d11 --- /dev/null +++ b/apps/api/src/routes/v1/blocks/handlers.ts @@ -0,0 +1,138 @@ +import { and, db, 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 + ) + } + + // Check if already blocked + const existingBlock = await db + .select({ id: schema.userBlock.id }) + .from(schema.userBlock) + .where( + and( + eq(schema.userBlock.blockerId, currentUser.id), + eq(schema.userBlock.blockedId, targetUserId) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (existingBlock) { + return c.json( + { success: false, message: "User is already blocked" }, + HttpStatusCodes.BAD_REQUEST + ) + } + + // Atomically: insert block + remove any ally relationship + await db.transaction(async (tx) => { + await tx.insert(schema.userBlock).values({ + blockerId: currentUser.id, + blockedId: targetUserId, + }) + + // 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 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)) + + 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..689827d 100644 --- a/apps/api/src/routes/v1/dms/handlers.ts +++ b/apps/api/src/routes/v1/dms/handlers.ts @@ -5,6 +5,7 @@ import { channelMember, message, user, + userBlock, } 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,6 +72,31 @@ export const createDM: AppRouteHandler = async (c) => { ) } + // Check if any target user has a block relationship with the current user + const blockRows = await db + .select({ id: userBlock.id }) + .from(userBlock) + .where( + or( + and( + eq(userBlock.blockerId, currentUser.id), + inArray(userBlock.blockedId, targetUserIds) + ), + and( + inArray(userBlock.blockerId, targetUserIds), + eq(userBlock.blockedId, currentUser.id) + ) + ) + ) + .limit(1) + + if (blockRows.length > 0) { + return c.json( + { success: false, message: "Unable to create conversation" }, + HttpStatusCodes.FORBIDDEN + ) + } + // Verify all target users are allies of the current user const allyRows = await db .select({ 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..ed3c74d 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -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, @@ -371,6 +372,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 +529,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, 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..3a17504 100644 --- a/apps/web/src/components/chat/message-item.tsx +++ b/apps/web/src/components/chat/message-item.tsx @@ -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,7 @@ export function MessageItem({ const [isActionBarPinned, setIsActionBarPinned] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isEditing, setIsEditing] = useState(false) + const [showBlockedContent, setShowBlockedContent] = useState(false) const isOwnMessage = !!currentUserId && currentUserId === message.authorId const isReply = message.type === "reply" @@ -191,6 +194,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..3a654ae 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]) @@ -60,6 +69,49 @@ export function MyAccountSettings() { } }, []) + 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") + usernameCheckTimer.current = setTimeout(async () => { + try { + const { data } = await authClient.isUsernameAvailable({ + username: trimmed, + }) + setUsernameAvailability(data?.available ? "available" : "taken") + } catch { + setUsernameAvailability("idle") + } + }, 500) + }, + [session?.user?.displayUsername, session?.user?.username] + ) + const setAvatarFromFile = useCallback((file: File) => { const error = validateAvatarFile(file) if (error) { @@ -159,6 +211,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 +229,32 @@ 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" || + usernameAvailability === "idle" + + const isValid = name.trim().length > 0 && isUsernameValid if (!user) return null @@ -235,7 +306,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/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..c2f3025 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,13 @@ 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 { Ban, Check, Clock, ShieldOff, UserMinus, UserPlus } from "lucide-react" import { useState } from "react" import { toast } from "sonner" import { apiClient } from "@/lib/api-client" @@ -97,6 +112,50 @@ 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 [confirmBlock, setConfirmBlock] = useState(false) + const [confirmRemoveAlly, setConfirmRemoveAlly] = useState(false) + if (isPending) { return (
@@ -122,8 +181,17 @@ 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 return (
@@ -158,12 +226,12 @@ function ProfileCardContent({ userId }: { userId: string }) {
{/* Status */} - {user.status && ( + {user.status && !isBlockedByThem && (
{user.status}
)} {/* Bio */} - {user.bio && ( + {user.bio && !isBlockedByThem && (
{user.bio}
@@ -178,22 +246,130 @@ function ProfileCardContent({ userId }: { userId: string }) { })}
- {/* Ally actions */} + {/* Actions row */} {!isCurrentUser && ( - sendRequest.mutate()} - onAcceptRequest={(id) => acceptRequest.mutate(id)} - onRemoveAlly={() => removeAlly.mutate()} - /> + <> +
+ {/* 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 +387,68 @@ 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-typing-indicator.ts b/apps/web/src/hooks/use-typing-indicator.ts index 659ae50..2f2cf11 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 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..e5a58f4 100644 --- a/packages/db/src/schemas/index.ts +++ b/packages/db/src/schemas/index.ts @@ -14,6 +14,7 @@ export * from "./messages" export * from "./notification-events" export * from "./sessions" export * from "./two-factors" +export * from "./user-blocks" 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..e019a83 --- /dev/null +++ b/packages/db/src/schemas/user-blocks.ts @@ -0,0 +1,51 @@ +import { relations } from "drizzle-orm" +import { + 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), + ] +) + +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/users.ts b/packages/db/src/schemas/users.ts index 3a47ab1..0d4ea2f 100644 --- a/packages/db/src/schemas/users.ts +++ b/packages/db/src/schemas/users.ts @@ -14,6 +14,7 @@ import { guild } from "./guilds" import { invitation } from "./invitations" import { session } from "./sessions" import { twoFactor } from "./two-factors" +import { userBlock } from "./user-blocks" export const user = pgTable("user", { id: uuid("id").defaultRandom().primaryKey(), @@ -56,4 +57,10 @@ export const userRelations = relations(user, ({ many }) => ({ }), invitations: many(invitation), twoFactors: many(twoFactor), + blockedUsers: many(userBlock, { + relationName: "userBlockBlocker", + }), + blockedByUsers: many(userBlock, { + relationName: "userBlockBlocked", + }), })) From 8cf4d7f17d08f1c1c794dc591659e78d108f74fb Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Sat, 21 Mar 2026 19:05:33 -0700 Subject: [PATCH 2/5] fix: fixed block users various bugs --- ROADMAP.md | 3 +- apps/api/src/routes/v1/blocks/handlers.ts | 50 +++++++++---------- apps/web/src/components/chat/message-item.tsx | 7 ++- .../settings/my-account-settings.tsx | 5 +- apps/web/src/hooks/use-typing-indicator.ts | 2 +- packages/db/src/schemas/user-blocks.ts | 7 ++- 6 files changed, 41 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b3a2a52..a6828d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,7 +40,7 @@ - [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 - [x] Direct messages — create 1:1 and group DMs with allies, new DM dialog -- [ ] User blocking +- [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 @@ -62,6 +62,7 @@ - [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/routes/v1/blocks/handlers.ts b/apps/api/src/routes/v1/blocks/handlers.ts index ab62d11..886977d 100644 --- a/apps/api/src/routes/v1/blocks/handlers.ts +++ b/apps/api/src/routes/v1/blocks/handlers.ts @@ -1,4 +1,4 @@ -import { and, db, eq, or, schema } from "@repo/db" +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 { @@ -33,32 +33,20 @@ export const blockUser: AppRouteHandler = async (c) => { ) } - // Check if already blocked - const existingBlock = await db - .select({ id: schema.userBlock.id }) - .from(schema.userBlock) - .where( - and( - eq(schema.userBlock.blockerId, currentUser.id), - eq(schema.userBlock.blockedId, targetUserId) - ) - ) - .limit(1) - .then((rows) => rows[0]) - - if (existingBlock) { - return c.json( - { success: false, message: "User is already blocked" }, - HttpStatusCodes.BAD_REQUEST - ) - } - // Atomically: insert block + remove any ally relationship - await db.transaction(async (tx) => { - await tx.insert(schema.userBlock).values({ - blockerId: currentUser.id, - blockedId: targetUserId, - }) + 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 @@ -75,8 +63,17 @@ export const blockUser: AppRouteHandler = async (c) => { ) ) ) + + 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) } @@ -121,6 +118,7 @@ export const listBlockedUsers: AppRouteHandler = async ( .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( { diff --git a/apps/web/src/components/chat/message-item.tsx b/apps/web/src/components/chat/message-item.tsx index 3a17504..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" @@ -142,6 +142,11 @@ export function MessageItem({ 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" diff --git a/apps/web/src/components/settings/my-account-settings.tsx b/apps/web/src/components/settings/my-account-settings.tsx index 3a654ae..a713335 100644 --- a/apps/web/src/components/settings/my-account-settings.tsx +++ b/apps/web/src/components/settings/my-account-settings.tsx @@ -66,6 +66,7 @@ export function MyAccountSettings() { return () => { if (avatarPreviewRef.current) URL.revokeObjectURL(avatarPreviewRef.current) + if (usernameCheckTimer.current) clearTimeout(usernameCheckTimer.current) } }, []) @@ -250,9 +251,7 @@ export function MyAccountSettings() { avatarFile !== null) const isUsernameValid = - !usernameChanged || - usernameAvailability === "available" || - usernameAvailability === "idle" + !usernameChanged || usernameAvailability === "available" const isValid = name.trim().length > 0 && isUsernameValid diff --git a/apps/web/src/hooks/use-typing-indicator.ts b/apps/web/src/hooks/use-typing-indicator.ts index 2f2cf11..f887c3b 100644 --- a/apps/web/src/hooks/use-typing-indicator.ts +++ b/apps/web/src/hooks/use-typing-indicator.ts @@ -64,7 +64,7 @@ export function useTypingIndicator({ return () => { socket.off("typing:update", onTypingUpdate) } - }, [socket, channelId, currentUserId]) + }, [socket, channelId, currentUserId, blockedUserIds]) // Cleanup expired entries useEffect(() => { diff --git a/packages/db/src/schemas/user-blocks.ts b/packages/db/src/schemas/user-blocks.ts index e019a83..ab323d2 100644 --- a/packages/db/src/schemas/user-blocks.ts +++ b/packages/db/src/schemas/user-blocks.ts @@ -1,5 +1,6 @@ -import { relations } from "drizzle-orm" +import { relations, sql } from "drizzle-orm" import { + check, index, pgTable, timestamp, @@ -28,6 +29,10 @@ export const userBlock = pgTable( ), 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}` + ), ] ) From 887b18fb0b591ff2263ec269f499b463921a1ceb Mon Sep 17 00:00:00 2001 From: Jacob Owens Date: Sat, 21 Mar 2026 22:00:23 -0700 Subject: [PATCH 3/5] feat: add privacy settings system and profile popover DM button --- apps/api/src/app.ts | 2 + apps/api/src/routes/v1/allies/handlers.ts | 20 +++ apps/api/src/routes/v1/allies/routes.ts | 1 + apps/api/src/routes/v1/dms/handlers.ts | 88 +++++++---- .../routes/v1/privacy-settings/handlers.ts | 62 ++++++++ .../src/routes/v1/privacy-settings/index.ts | 9 ++ .../src/routes/v1/privacy-settings/routes.ts | 57 ++++++++ .../src/routes/v1/privacy-settings/schema.ts | 8 + apps/realtime/src/index.ts | 127 ++++++++++++++-- .../settings/privacy-safety-settings.tsx | 138 ++++++++++++++++++ .../components/settings/settings-dialog.tsx | 3 + .../src/components/ui/user-profile-card.tsx | 60 +++++++- apps/web/src/hooks/use-privacy-settings.ts | 38 +++++ packages/db/src/schemas/index.ts | 1 + .../db/src/schemas/user-privacy-settings.ts | 76 ++++++++++ packages/db/src/schemas/users.ts | 4 +- 16 files changed, 652 insertions(+), 42 deletions(-) create mode 100644 apps/api/src/routes/v1/privacy-settings/handlers.ts create mode 100644 apps/api/src/routes/v1/privacy-settings/index.ts create mode 100644 apps/api/src/routes/v1/privacy-settings/routes.ts create mode 100644 apps/api/src/routes/v1/privacy-settings/schema.ts create mode 100644 apps/web/src/components/settings/privacy-safety-settings.tsx create mode 100644 apps/web/src/hooks/use-privacy-settings.ts create mode 100644 packages/db/src/schemas/user-privacy-settings.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6d6e22a..c160c0d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ 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" @@ -43,6 +44,7 @@ const routes = app .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 3e5eab5..591c068 100644 --- a/apps/api/src/routes/v1/allies/handlers.ts +++ b/apps/api/src/routes/v1/allies/handlers.ts @@ -120,6 +120,26 @@ export const sendAllyRequest: AppRouteHandler = async ( ) } + // 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/dms/handlers.ts b/apps/api/src/routes/v1/dms/handlers.ts index 689827d..bba88fc 100644 --- a/apps/api/src/routes/v1/dms/handlers.ts +++ b/apps/api/src/routes/v1/dms/handlers.ts @@ -6,6 +6,7 @@ import { 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" @@ -97,43 +98,76 @@ export const createDM: AppRouteHandler = async (c) => { ) } - // Verify all target users are allies of the current user - const allyRows = await db + // Fetch target users' privacy settings + const targetPrivacyRows = await db .select({ - senderId: allyRequest.senderId, - receiverId: allyRequest.receiverId, + userId: userPrivacySettings.userId, + dmPrivacy: userPrivacySettings.dmPrivacy, }) - .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) - ) - ) - ) - ) + .from(userPrivacySettings) + .where(inArray(userPrivacySettings.userId, targetUserIds)) - const allyUserIds = new Set( - allyRows.map((r) => - r.senderId === currentUser.id ? r.receiverId : r.senderId - ) + 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/realtime/src/index.ts b/apps/realtime/src/index.ts index ed3c74d..e8b94b7 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, schema } from "@repo/db" import { env } from "@repo/env/server" import type { ClientToServerEvents, @@ -233,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", + }) + } } } @@ -320,11 +334,86 @@ 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"), + inArray(schema.allyRequest.senderId, [ + requestingUserId, + ...alliesOnlyIds, + ]), + inArray(schema.allyRequest.receiverId, [ + requestingUserId, + ...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) { @@ -612,12 +701,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/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/ui/user-profile-card.tsx b/apps/web/src/components/ui/user-profile-card.tsx index c2f3025..5649fcc 100644 --- a/apps/web/src/components/ui/user-profile-card.tsx +++ b/apps/web/src/components/ui/user-profile-card.tsx @@ -23,7 +23,16 @@ import { TooltipTrigger, } from "@repo/ui/components/tooltip" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Ban, Check, Clock, ShieldOff, 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" @@ -153,6 +162,30 @@ function ProfileCardContent({ userId }: { userId: string }) { }, }) + 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) @@ -191,7 +224,8 @@ function ProfileCardContent({ userId }: { userId: string }) { acceptRequest.isPending || removeAlly.isPending || blockUser.isPending || - unblockUser.isPending + unblockUser.isPending || + createDM.isPending return (
@@ -250,6 +284,26 @@ function ProfileCardContent({ userId }: { userId: string }) { {!isCurrentUser && ( <>
+ {/* Send DM */} + {!isBlockedByMe && !isBlockedByThem && ( +
+ + + + + Send DM + +
+ )} + {/* Ally action */} {!isBlockedByMe && !isBlockedByThem && (
@@ -391,6 +445,7 @@ function AllyActionIconButton({