diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 5faa9b7..be010fc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,6 +2,7 @@ import { auth } from "@repo/auth" import { cors } from "hono/cors" import createApp from "@/lib/helpers/app/create-app" import configureOpenAPI from "@/lib/helpers/openapi/configure-openapi" +import { globalRateLimit } from "@/middleware/rate-limit" import index from "@/routes/index.route" import channelsRouter from "@/routes/v1/channels/index" import dmsRouter from "@/routes/v1/dms/index" @@ -20,6 +21,8 @@ app.use( }) ) +app.use("*", globalRateLimit) + app.on(["POST", "GET"], "/api/auth/*", (c) => { return auth.handler(c.req.raw) }) diff --git a/apps/api/src/middleware/rate-limit.ts b/apps/api/src/middleware/rate-limit.ts new file mode 100644 index 0000000..7f966f9 --- /dev/null +++ b/apps/api/src/middleware/rate-limit.ts @@ -0,0 +1,90 @@ +import type { Context, Next } from "hono" +import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" +import { getRedisClient } from "@/lib/redis" +import type { AppBindings } from "@/lib/types/app-types" + +const WINDOW_SECONDS = 60 +const KEY_TTL_SECONDS = 90 + +interface RateLimitConfig { + /** Requests per window */ + max: number + /** Window size in seconds (default 60) */ + window?: number + /** Key prefix for Redis */ + prefix: string + /** Extract the identifier from the request (default: IP address) */ + keyExtractor?: (c: Context) => string +} + +function getIp(c: Context): string { + return ( + c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || + c.req.header("x-real-ip") || + "unknown" + ) +} + +function getWindowNumber(windowSeconds: number): number { + return Math.floor(Date.now() / (windowSeconds * 1000)) +} + +function getRetryAfterSeconds(windowSeconds: number): number { + const elapsed = Math.floor(Date.now() / 1000) % windowSeconds + return Math.max(1, windowSeconds - elapsed) +} + +export function rateLimiter(config: RateLimitConfig) { + const windowSeconds = config.window ?? WINDOW_SECONDS + + return async (c: Context, next: Next) => { + try { + const redis = await getRedisClient() + const identifier = config.keyExtractor ? config.keyExtractor(c) : getIp(c) + + const windowNum = getWindowNumber(windowSeconds) + const key = `ratelimit:api:${config.prefix}:${identifier}:${windowNum}` + + const count = await redis.incr(key) + if (count === 1) { + await redis.expire( + key, + config.window ? config.window + 30 : KEY_TTL_SECONDS + ) + } + + c.header("X-RateLimit-Limit", String(config.max)) + c.header("X-RateLimit-Remaining", String(Math.max(0, config.max - count))) + + if (count > config.max) { + const retryAfter = getRetryAfterSeconds(windowSeconds) + c.header("Retry-After", String(retryAfter)) + c.header("X-RateLimit-Remaining", "0") + return c.json( + { + success: false, + message: `Rate limit exceeded. Try again in ${retryAfter} seconds`, + }, + HttpStatusCodes.TOO_MANY_REQUESTS + ) + } + } catch (err) { + console.error("[rate-limit] Redis unavailable, failing open:", err) + } + + await next() + } +} + +/** Global rate limit: 100 requests/min per IP */ +export const globalRateLimit = rateLimiter({ + prefix: "global", + max: 100, +}) + +/** Stricter rate limit for write operations: 30 requests/min per user */ +export const writeRateLimit = rateLimiter({ + prefix: "write", + max: 30, + keyExtractor: (c) => c.get("user")?.id ?? getIp(c), +}) diff --git a/apps/api/src/routes/v1/channels/handlers.ts b/apps/api/src/routes/v1/channels/handlers.ts index 99a7386..044206d 100644 --- a/apps/api/src/routes/v1/channels/handlers.ts +++ b/apps/api/src/routes/v1/channels/handlers.ts @@ -1,6 +1,12 @@ import { db } from "@repo/db" -import { channel } from "@repo/db/schema" -import { and, asc, eq, inArray } from "drizzle-orm" +import { + channel, + message, + messageMention, + messageReaction, + user, +} from "@repo/db/schema" +import { and, asc, desc, eq, inArray } from "drizzle-orm" import * as HttpStatusCodes from "@/lib/helpers/http/status-codes" import { assertGuildPermission } from "@/lib/permissions" import { fetchMessagePage } from "@/lib/queries/messages" @@ -11,7 +17,9 @@ import type { GetChannelRoute, ListChannelMessagesRoute, ListChannelsRoute, + ListPinnedMessagesRoute, ReorderChannelsRoute, + ToggleMessagePinRoute, UpdateChannelRoute, } from "./routes" @@ -227,3 +235,220 @@ export const listChannelMessages: AppRouteHandler< HttpStatusCodes.OK ) } + +export const toggleMessagePin: AppRouteHandler = async ( + c +) => { + const guild = c.var.guild + const member = c.var.member + const { channelId, messageId } = c.req.valid("param") + + assertGuildPermission(member, guild, { + message: ["pin"], + }) + + // Verify message exists in this channel and guild + const msg = await db + .select({ + id: message.id, + pinned: message.pinned, + channelId: message.channelId, + }) + .from(message) + .innerJoin(channel, eq(message.channelId, channel.id)) + .where( + and( + eq(message.id, messageId), + eq(message.channelId, channelId), + eq(channel.guildId, guild.id) + ) + ) + .limit(1) + .then((rows) => rows[0]) + + if (!msg) { + return c.json( + { success: false, message: "Message not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + const newPinned = !msg.pinned + + await db + .update(message) + .set({ pinned: newPinned }) + .where(eq(message.id, messageId)) + + return c.json( + { success: true as const, pinned: newPinned }, + HttpStatusCodes.OK + ) +} + +export const listPinnedMessages: AppRouteHandler< + ListPinnedMessagesRoute +> = async (c) => { + const guild = c.var.guild + const currentUser = c.var.user + const { channelId } = c.req.valid("param") + + // Verify channel belongs to guild + const ch = await db + .select({ id: channel.id }) + .from(channel) + .where(and(eq(channel.id, channelId), eq(channel.guildId, guild.id))) + .limit(1) + .then((rows) => rows[0]) + + if (!ch) { + return c.json( + { success: false, message: "Channel not found" }, + HttpStatusCodes.NOT_FOUND + ) + } + + const messages = await db + .select({ + id: message.id, + channelId: message.channelId, + content: message.content, + type: message.type, + pinned: message.pinned, + attachments: message.attachments, + embeds: message.embeds, + referencedMessageId: message.referencedMessageId, + editedAt: message.editedAt, + createdAt: message.createdAt, + authorId: message.authorId, + author: { + id: user.id, + name: user.name, + username: user.username, + displayUsername: user.displayUsername, + image: user.image, + }, + }) + .from(message) + .innerJoin(user, eq(message.authorId, user.id)) + .where(and(eq(message.channelId, channelId), eq(message.pinned, true))) + .orderBy(desc(message.createdAt)) + + const messageIds = messages.map((msg) => msg.id) + + const mentionRows = + messageIds.length > 0 + ? await db + .select({ + messageId: messageMention.messageId, + id: user.id, + name: user.name, + username: user.username, + displayUsername: user.displayUsername, + image: user.image, + }) + .from(messageMention) + .innerJoin(user, eq(messageMention.mentionedUserId, user.id)) + .where( + and( + inArray(messageMention.messageId, messageIds), + eq(messageMention.mentionType, "direct") + ) + ) + : [] + + const reactionRows = + messageIds.length > 0 + ? await db + .select({ + messageId: messageReaction.messageId, + emoji: messageReaction.emoji, + userId: messageReaction.userId, + }) + .from(messageReaction) + .where(inArray(messageReaction.messageId, messageIds)) + : [] + + const referencedMessageIds = messages + .map((msg) => msg.referencedMessageId) + .filter((id): id is string => id !== null) + + const referencedMessageRows = + referencedMessageIds.length > 0 + ? await db + .select({ + id: message.id, + content: message.content, + authorId: user.id, + authorName: user.name, + authorUsername: user.username, + authorDisplayUsername: user.displayUsername, + authorImage: user.image, + }) + .from(message) + .innerJoin(user, eq(message.authorId, user.id)) + .where(inArray(message.id, referencedMessageIds)) + : [] + + const referencedMessagesById = new Map( + referencedMessageRows.map((row) => [ + row.id, + { + id: row.id, + content: row.content, + author: { + id: row.authorId, + name: row.authorName, + username: row.authorUsername, + displayUsername: row.authorDisplayUsername, + image: row.authorImage, + }, + }, + ]) + ) + + const mentionsByMessageId = new Map() + for (const row of mentionRows) { + const existing = mentionsByMessageId.get(row.messageId) ?? [] + existing.push(row) + mentionsByMessageId.set(row.messageId, existing) + } + + const reactionsByMessageId = new Map< + string, + Map + >() + for (const row of reactionRows) { + const reactionsByEmoji = + reactionsByMessageId.get(row.messageId) ?? new Map() + const existing = reactionsByEmoji.get(row.emoji) ?? { + emoji: row.emoji, + count: 0, + reactedByCurrentUser: false, + } + existing.count += 1 + if (row.userId === currentUser.id) { + existing.reactedByCurrentUser = true + } + reactionsByEmoji.set(row.emoji, existing) + reactionsByMessageId.set(row.messageId, reactionsByEmoji) + } + + const data = messages.map((msg) => ({ + ...msg, + embeds: msg.embeds ?? [], + mentions: (mentionsByMessageId.get(msg.id) ?? []).map((m) => ({ + id: m.id, + name: m.name, + username: m.username, + displayUsername: m.displayUsername, + image: m.image, + })), + reactions: Array.from(reactionsByMessageId.get(msg.id)?.values() ?? []), + referencedMessage: msg.referencedMessageId + ? (referencedMessagesById.get(msg.referencedMessageId) ?? null) + : null, + })) + + return c.json({ data }, HttpStatusCodes.OK) +} diff --git a/apps/api/src/routes/v1/channels/index.ts b/apps/api/src/routes/v1/channels/index.ts index 5949d28..9d2468f 100644 --- a/apps/api/src/routes/v1/channels/index.ts +++ b/apps/api/src/routes/v1/channels/index.ts @@ -10,5 +10,7 @@ const channelsRouter = createRouter() .openapi(routes.updateChannel, handlers.updateChannel) .openapi(routes.deleteChannel, handlers.deleteChannel) .openapi(routes.listChannelMessages, handlers.listChannelMessages) + .openapi(routes.toggleMessagePin, handlers.toggleMessagePin) + .openapi(routes.listPinnedMessages, handlers.listPinnedMessages) export default channelsRouter diff --git a/apps/api/src/routes/v1/channels/routes.ts b/apps/api/src/routes/v1/channels/routes.ts index 6608662..4ecf91f 100644 --- a/apps/api/src/routes/v1/channels/routes.ts +++ b/apps/api/src/routes/v1/channels/routes.ts @@ -18,8 +18,11 @@ import { listChannelsResponseSchema, listMessagesQuerySchema, listMessagesResponseSchema, + listPinnedMessagesResponseSchema, + messageIdParamsSchema, reorderChannelsRequestSchema, reorderChannelsResponseSchema, + togglePinResponseSchema, updateChannelRequestSchema, updateChannelResponseSchema, } from "./schema" @@ -191,6 +194,51 @@ export const deleteChannel = createRoute({ }, }) +export const toggleMessagePin = createRoute({ + path: "/guilds/{guildSlug}/channels/{channelId}/messages/{messageId}/pin", + method: "patch", + summary: "Toggle message pin", + description: + "Pins or unpins a message in the channel. Requires message:pin permission.", + tags: ["Channels"], + middleware: [guildAuthMiddleware] as const, + request: { + params: messageIdParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: togglePinResponseSchema, + description: "Pin toggled", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + +export const listPinnedMessages = createRoute({ + path: "/guilds/{guildSlug}/channels/{channelId}/pins", + method: "get", + summary: "List pinned messages", + description: "Returns all pinned messages in a channel.", + tags: ["Channels"], + middleware: [guildAuthMiddleware] as const, + request: { + params: channelParamsSchema, + }, + responses: { + [HttpStatusCodes.OK]: jsonContent({ + schema: listPinnedMessagesResponseSchema, + description: "Pinned messages", + }), + [HttpStatusCodes.UNAUTHORIZED]: unauthorizedSchema, + [HttpStatusCodes.FORBIDDEN]: forbiddenSchema, + [HttpStatusCodes.NOT_FOUND]: notFoundSchema, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: internalServerErrorSchema, + }, +}) + export type ListChannelsRoute = typeof listChannels export type CreateChannelRoute = typeof createChannel export type ReorderChannelsRoute = typeof reorderChannels @@ -198,3 +246,5 @@ export type GetChannelRoute = typeof getChannel export type UpdateChannelRoute = typeof updateChannel export type DeleteChannelRoute = typeof deleteChannel export type ListChannelMessagesRoute = typeof listChannelMessages +export type ToggleMessagePinRoute = typeof toggleMessagePin +export type ListPinnedMessagesRoute = typeof listPinnedMessages diff --git a/apps/api/src/routes/v1/channels/schema.ts b/apps/api/src/routes/v1/channels/schema.ts index b09a6c5..1ce437a 100644 --- a/apps/api/src/routes/v1/channels/schema.ts +++ b/apps/api/src/routes/v1/channels/schema.ts @@ -79,6 +79,27 @@ export { listMessagesResponseSchema, } +// ── Pins ────────────────────────────────────────── + +export const messageIdParamsSchema = channelParamsSchema.extend({ + messageId: z + .string() + .uuid() + .openapi({ + param: { name: "messageId", in: "path", required: true }, + example: "00000000-0000-0000-0000-000000000000", + }), +}) + +export const togglePinResponseSchema = z.object({ + success: z.literal(true), + pinned: z.boolean(), +}) + +export const listPinnedMessagesResponseSchema = z.object({ + data: z.array(messageWithAuthorSchema), +}) + // ── Reorder ────────────────────────────────────────── export const reorderChannelItemSchema = z.object({ diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index a2e4a61..2d0663c 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -41,7 +41,10 @@ import { markUserConnected, markUserDisconnected, } from "@/services/presence" -import { enforceGuildMessageRateLimit } from "@/services/rate-limit" +import { + enforceDmMessageRateLimit, + enforceGuildMessageRateLimit, +} from "@/services/rate-limit" import { markChannelRead } from "@/services/read-states" type SocketData = { @@ -134,6 +137,36 @@ const io = new Server< }, }) +const CONNECTION_RATE_WINDOW = 60 +const CONNECTION_RATE_MAX = 10 +const CONNECTION_RATE_TTL = 90 + +io.use(async (socket, next) => { + const ip = + socket.handshake.headers["x-forwarded-for"] + ?.toString() + .split(",")[0] + ?.trim() || + socket.handshake.address || + "unknown" + const windowNum = Math.floor(Date.now() / (CONNECTION_RATE_WINDOW * 1000)) + const key = `ratelimit:ws:connect:${ip}:${windowNum}` + + try { + const count = await redisPresenceClient.incr(key) + if (count === 1) { + await redisPresenceClient.expire(key, CONNECTION_RATE_TTL) + } + if (count > CONNECTION_RATE_MAX) { + next(new Error("Too many connections. Try again later.")) + return + } + } catch { + // allow connection if Redis is unavailable + } + next() +}) + io.use(async (socket, next) => { try { const session = await auth.api.getSession({ @@ -316,6 +349,11 @@ io.on("connection", (socket) => { userId: socket.data.user.id, role: accessibleChannel.memberRole, }) + } else { + await enforceDmMessageRateLimit( + redisPresenceClient, + socket.data.user.id + ) } const createdMessage = await createMessage({ diff --git a/apps/realtime/src/services/messages.ts b/apps/realtime/src/services/messages.ts index dbd7a44..30ce71f 100644 --- a/apps/realtime/src/services/messages.ts +++ b/apps/realtime/src/services/messages.ts @@ -179,6 +179,7 @@ export async function createMessage(input: CreateMessageInput) { channelId: messageWithAuthor.channelId, content: messageWithAuthor.content, type: messageWithAuthor.type, + pinned: false, createdAt: messageWithAuthor.createdAt.toISOString(), author: { id: messageWithAuthor.authorId, diff --git a/apps/realtime/src/services/rate-limit.ts b/apps/realtime/src/services/rate-limit.ts index 666b787..7b2a89c 100644 --- a/apps/realtime/src/services/rate-limit.ts +++ b/apps/realtime/src/services/rate-limit.ts @@ -47,3 +47,29 @@ export async function enforceGuildMessageRateLimit( `Rate limit exceeded. Try again in ${getRetryAfterSeconds(now)} seconds` ) } + +const DM_RATE_LIMIT_PER_MINUTE = 45 + +function getDmRateLimitKey(userId: string, timestamp: number) { + const currentWindow = Math.floor(timestamp / (WINDOW_SECONDS * 1000)) + return `ratelimit:dm:user:${userId}:message:${currentWindow}` +} + +export async function enforceDmMessageRateLimit( + redis: RedisClient, + userId: string +) { + const now = Date.now() + const key = getDmRateLimitKey(userId, now) + const nextCount = await redis.incr(key) + + if (nextCount === 1) { + await redis.expire(key, KEY_TTL_SECONDS) + } + + if (nextCount <= DM_RATE_LIMIT_PER_MINUTE) return + + throw new Error( + `Rate limit exceeded. Try again in ${getRetryAfterSeconds(now)} seconds` + ) +} diff --git a/apps/web/src/components/chat/header.tsx b/apps/web/src/components/chat/header.tsx index 767485b..ca117be 100644 --- a/apps/web/src/components/chat/header.tsx +++ b/apps/web/src/components/chat/header.tsx @@ -1,5 +1,10 @@ import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" -import { Hash } from "lucide-react" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@repo/ui/components/tooltip" +import { Hash, Pin } from "lucide-react" export type ChatContext = | { type: "channel"; name: string; topic?: string } @@ -11,7 +16,13 @@ function nameInitial(name: string) { return trimmed.length > 0 ? trimmed.charAt(0).toUpperCase() : "?" } -export function ChatHeader({ context }: { context: ChatContext }) { +export function ChatHeader({ + context, + onTogglePinnedMessages, +}: { + context: ChatContext + onTogglePinnedMessages?: () => void +}) { return (
{context.type === "channel" && ( @@ -41,6 +52,22 @@ export function ChatHeader({ context }: { context: ChatContext }) { {context.memberCount} members )} + {context.type === "channel" && onTogglePinnedMessages && ( +
+ + + + + Pinned Messages + +
+ )}
) } diff --git a/apps/web/src/components/chat/message-action-bar.tsx b/apps/web/src/components/chat/message-action-bar.tsx index 9d9f1a3..4a35641 100644 --- a/apps/web/src/components/chat/message-action-bar.tsx +++ b/apps/web/src/components/chat/message-action-bar.tsx @@ -11,7 +11,7 @@ import { TooltipContent, TooltipTrigger, } from "@repo/ui/components/tooltip" -import { MoreHorizontal, Reply } from "lucide-react" +import { MoreHorizontal, Pin, PinOff, Reply } from "lucide-react" import { useCallback, useEffect, useRef, useState } from "react" import { EmojiReactionPicker } from "./emoji-reaction-picker" @@ -21,7 +21,10 @@ interface MessageActionBarProps { onCopyText?: () => void onEdit?: () => void onDelete?: () => void + onTogglePin?: () => void + isPinned?: boolean canManageMessage?: boolean + canPin?: boolean onOverlayOpenChange?: (open: boolean) => void } @@ -31,7 +34,10 @@ export function MessageActionBar({ onCopyText, onEdit, onDelete, + onTogglePin, + isPinned = false, canManageMessage = false, + canPin = false, onOverlayOpenChange, }: MessageActionBarProps) { const [isEmojiOpen, setIsEmojiOpen] = useState(false) @@ -82,6 +88,30 @@ export function MessageActionBar({ Reply + {canPin && ( + + + + + + {isPinned ? "Unpin message" : "Pin message"} + + + )} + diff --git a/apps/web/src/components/chat/message-item.tsx b/apps/web/src/components/chat/message-item.tsx index c5fab99..1e34f72 100644 --- a/apps/web/src/components/chat/message-item.tsx +++ b/apps/web/src/components/chat/message-item.tsx @@ -11,6 +11,7 @@ import { import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" import { cn } from "@repo/ui/lib/utils" import { formatTime } from "@repo/utils/date" +import { Pin } from "lucide-react" import { useCallback, useState } from "react" import type { Message } from "@/lib/api-types" import type { MentionCandidate } from "./composer/mention-types" @@ -28,6 +29,8 @@ interface MessageItemProps { onReply?: (message: Message) => void onDelete?: (messageId: string) => void onEdit?: (messageId: string, content: string) => void + onTogglePin?: (messageId: string, currentlyPinned: boolean) => void + canPin?: boolean mentionCandidates?: MentionCandidate[] } @@ -122,6 +125,8 @@ export function MessageItem({ onReply, onDelete, onEdit, + onTogglePin, + canPin = false, mentionCandidates, }: MessageItemProps) { const author = message.author @@ -160,6 +165,10 @@ export function MessageItem({ setIsDeleteDialogOpen(false) }, [message.id, onDelete]) + const handleTogglePin = useCallback(() => { + onTogglePin?.(message.id, message.pinned) + }, [message.id, message.pinned, onTogglePin]) + const handleEditRequest = useCallback(() => { setIsEditing(true) }, []) @@ -194,10 +203,19 @@ export function MessageItem({ onCopyText={handleCopyText} onEdit={isOwnMessage && onEdit ? handleEditRequest : undefined} onDelete={isOwnMessage && onDelete ? handleDeleteRequest : undefined} + onTogglePin={canPin ? handleTogglePin : undefined} + isPinned={message.pinned} canManageMessage={isOwnMessage} + canPin={canPin} onOverlayOpenChange={setIsActionBarPinned} /> + {message.pinned && ( +
+ + Pinned +
+ )} {isReply && ( )} diff --git a/apps/web/src/components/chat/message-list.tsx b/apps/web/src/components/chat/message-list.tsx index 9052561..6892b0d 100644 --- a/apps/web/src/components/chat/message-list.tsx +++ b/apps/web/src/components/chat/message-list.tsx @@ -17,6 +17,8 @@ interface MessageListProps { onReply?: (message: Message) => void onDelete?: (messageId: string) => void onEdit?: (messageId: string, content: string) => void + onTogglePin?: (messageId: string, currentlyPinned: boolean) => void + canPin?: boolean mentionCandidates?: MentionCandidate[] isLoading?: boolean hasMore?: boolean @@ -70,6 +72,8 @@ export function MessageList({ onReply, onDelete, onEdit, + onTogglePin, + canPin, mentionCandidates, isLoading, hasMore, @@ -187,6 +191,8 @@ export function MessageList({ onReply={onReply} onDelete={onDelete} onEdit={onEdit} + onTogglePin={onTogglePin} + canPin={canPin} mentionCandidates={mentionCandidates} /> diff --git a/apps/web/src/components/sidebar/right-panel/pinned-messages-panel.tsx b/apps/web/src/components/sidebar/right-panel/pinned-messages-panel.tsx new file mode 100644 index 0000000..315d2b0 --- /dev/null +++ b/apps/web/src/components/sidebar/right-panel/pinned-messages-panel.tsx @@ -0,0 +1,99 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/avatar" +import { formatTime } from "@repo/utils/date" +import { useQuery } from "@tanstack/react-query" +import { ArrowLeft, Pin } from "lucide-react" +import { MessageMarkdown } from "@/components/chat/message-markdown" +import { apiClient } from "@/lib/api-client" +import { useRightSidebar } from "./right-sidebar-context" +import type { PinnedMessagesSidebarView } from "./right-sidebar-types" + +function nameInitial(name: string) { + const trimmed = name.trim() + return trimmed.length > 0 ? trimmed.charAt(0).toUpperCase() : "?" +} + +export function PinnedMessagesPanel({ + view, +}: { + view: PinnedMessagesSidebarView +}) { + const { setView } = useRightSidebar() + + const goBack = () => { + setView({ + type: "guild-members", + guildSlug: view.guildSlug, + channelId: view.channelId, + }) + } + + const { data, isPending } = useQuery({ + queryKey: ["pinned-messages", view.channelId], + queryFn: async () => { + const res = await apiClient.v1.guilds[":guildSlug"].channels[ + ":channelId" + ].pins.$get({ + param: { guildSlug: view.guildSlug, channelId: view.channelId }, + }) + if (!res.ok) throw new Error("Failed to fetch pinned messages") + return res.json() + }, + }) + + return ( +
+
+ + + Pinned Messages +
+
+ {isPending && ( +
+ Loading... +
+ )} + {data && data.data.length === 0 && ( +
+ +

+ No pinned messages yet +

+
+ )} + {data?.data.map((msg) => ( +
+
+ + {msg.author.image && ( + + )} + + {nameInitial(msg.author.displayUsername ?? msg.author.name)} + + + + {msg.author.displayUsername ?? msg.author.name} + + + {formatTime(msg.createdAt)} + +
+
+ +
+
+ ))} +
+
+ ) +} diff --git a/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx b/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx index 8af9679..520bb2d 100644 --- a/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx +++ b/apps/web/src/components/sidebar/right-panel/right-sidebar-panel.tsx @@ -1,6 +1,7 @@ import { Image, MessageSquareQuote } from "lucide-react" import type { ReactNode } from "react" import { GuildMembersPanel } from "./guild-members-panel" +import { PinnedMessagesPanel } from "./pinned-messages-panel" import type { RightSidebarView } from "./right-sidebar-types" function PlaceholderSidebar({ @@ -27,6 +28,7 @@ export function RightSidebarPanel({ view }: { view: RightSidebarView }) { return (