Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/api/src/lib/helpers/openapi/message-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,22 @@ export const messageReactionSchema = z.object({
reactedByCurrentUser: z.boolean(),
})

const httpsUrlSchema = z.string().regex(/^https?:\/\//i)

export const messageEmbedSchema = z.object({
type: z.enum(["link", "image", "video", "rich"]),
url: httpsUrlSchema,
title: z.string().optional(),
description: z.string().optional(),
thumbnail: httpsUrlSchema.optional(),
siteName: z.string().optional(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

export const messageWithAuthorSchema = selectMessageSchema.extend({
author: messageAuthorSchema,
mentions: z.array(messageAuthorSchema),
reactions: z.array(messageReactionSchema),
embeds: z.array(messageEmbedSchema),
})

export const listMessagesQuerySchema = paginationQuerySchema
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/lib/queries/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export async function fetchMessagePage(

const messagesWithMentions = messages.map((msg) => ({
...msg,
embeds: msg.embeds ?? [],
mentions: mentionsByMessageId.get(msg.id) ?? [],
reactions: Array.from(reactionsByMessageId.get(msg.id)?.values() ?? []),
}))
Expand Down
3 changes: 2 additions & 1 deletion apps/realtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
"check-types": "tsc --noEmit"
},
"dependencies": {
"@socket.io/redis-adapter": "^8.3.0",
"@repo/auth": "workspace:*",
"@repo/db": "workspace:*",
"@repo/env": "workspace:*",
"@repo/realtime-types": "workspace:*",
"@socket.io/redis-adapter": "^8.3.0",
"bullmq": "^5.52.2",
"redis": "^4.7.0",
"socket.io": "^4.8.1",
"zod": "^4.3.6"
Expand Down
44 changes: 44 additions & 0 deletions apps/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
toggleMessageReactionPayloadSchema,
userRoom,
} from "@repo/realtime-types"
import type { LinkUnfurlJobData } from "@repo/realtime-types/queues"
import { LINK_UNFURL_QUEUE } from "@repo/realtime-types/queues"
import { createAdapter } from "@socket.io/redis-adapter"
import { Queue } from "bullmq"
import { createClient } from "redis"
import { Server, type Socket } from "socket.io"
import { toErrorMessage } from "@/lib/errors"
Expand Down Expand Up @@ -327,6 +330,23 @@ io.on("connection", (socket) => {
}

ack?.({ ok: true, message: messageWithMentions })

// Enqueue link unfurl job if the message contains a URL
const rawUrlMatches = parsed.content.match(/https?:\/\/[^\s<>"]+/g)
const urlMatches = rawUrlMatches?.map((u) =>
u.replace(/[.,!?:;'")\]]+$/, "")
)
if (urlMatches && urlMatches.length > 0) {
void linkUnfurlQueue
.add("unfurl", {
messageId: createdMessage.message.id,
channelId: parsed.channelId,
urls: urlMatches,
})
.catch((err) => {
console.error("[realtime] failed to enqueue link-unfurl:", err)
})
}
} catch (error) {
ack?.({ ok: false, error: toErrorMessage(error) })
}
Expand Down Expand Up @@ -400,6 +420,30 @@ io.on("connection", (socket) => {
})
})

function parseRedisUrl(url: string) {
const parsed = new URL(url)
const dbIndex = Number.parseInt(parsed.pathname.slice(1), 10)
return {
host: parsed.hostname,
port: Number(parsed.port) || 6379,
password: parsed.password || undefined,
username: parsed.username || undefined,
tls: parsed.protocol === "rediss:" ? {} : undefined,
db: Number.isFinite(dbIndex) ? dbIndex : 0,
}
}

const linkUnfurlQueue = new Queue<LinkUnfurlJobData>(LINK_UNFURL_QUEUE, {
connection: {
...parseRedisUrl(env.REDIS_URL),
maxRetriesPerRequest: null,
},
defaultJobOptions: {
removeOnComplete: { age: 3600, count: 1000 },
removeOnFail: { age: 86400, count: 5000 },
},
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function bootstrap() {
await Promise.all([
redisPubClient.connect(),
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/src/services/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export async function createMessage(input: CreateMessageInput) {
},
mentions: [],
reactions: [],
embeds: [],
}

if (input.payload.nonce) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.120.3",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-mention": "^3.20.0",
"@tiptap/markdown": "^3.20.0",
"@tiptap/pm": "^3.20.0",
Expand Down
37 changes: 28 additions & 9 deletions apps/web/src/components/chat/composer/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
PopoverTrigger,
} from "@repo/ui/components/popover"
import { cn } from "@repo/ui/lib/utils"
import Link from "@tiptap/extension-link"
import Mention, { type MentionOptions } from "@tiptap/extension-mention"
import { Markdown } from "@tiptap/markdown"
import { PluginKey } from "@tiptap/pm/state"
Expand Down Expand Up @@ -102,15 +103,21 @@ interface MessageInputProps {
}

function toStoredMarkdown(markdown: string) {
return markdown
.replace(/\u00A0/g, " ")
.replace(TIPTAP_MARKDOWN_MENTION_REGEX, (_match, mentionId: string) => {
if (mentionId.toLowerCase() === EVERYONE_MENTION_ID) {
return "@everyone"
}
return (
markdown
.replace(/\u00A0/g, " ")
// Strip ++…++ wrappers the Markdown extension generates for unrecognised marks (e.g. Link)
// TipTap outputs either ++[url](url)++ or ++bareUrl++
.replace(/\+\+\[([^\]]+)\]\([^)]+\)\+\+/g, "$1")
.replace(/\+\+([\s\S]+?)\+\+/g, "$1")
.replace(TIPTAP_MARKDOWN_MENTION_REGEX, (_match, mentionId: string) => {
if (mentionId.toLowerCase() === EVERYONE_MENTION_ID) {
return "@everyone"
}

return `<@${mentionId}>`
})
return `<@${mentionId}>`
})
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function extractMentionIds(content: string) {
Expand Down Expand Up @@ -480,6 +487,17 @@ export function MessageInput({
horizontalRule: false,
}),
Markdown,
Link.configure({
openOnClick: false,
autolink: true,
linkOnPaste: true,
HTMLAttributes: {
class:
"text-primary underline-offset-2 hover:underline cursor-pointer",
rel: "noreferrer noopener",
target: "_blank",
},
}),
Mention.configure({
HTMLAttributes: {
class: "rounded bg-primary/15 px-1 py-0.5 font-medium text-primary",
Expand Down Expand Up @@ -509,7 +527,8 @@ export function MessageInput({
const handleSend = useCallback(() => {
if (!editor) return

const markdown = toStoredMarkdown(editor.getMarkdown())
const rawMarkdown = editor.getMarkdown()
const markdown = toStoredMarkdown(rawMarkdown)
const trimmed = markdown.trim()
if (!trimmed || trimmed.length > MAX_MESSAGE_LENGTH || isSending) return

Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/components/chat/embed-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { cn } from "@repo/ui/lib/utils"
import { ExternalLink } from "lucide-react"
import type { Message } from "@/lib/api-types"

type Embed = Message["embeds"][number]

interface EmbedCardProps {
embed: Embed
className?: string
}

export function EmbedCard({ embed, className }: EmbedCardProps) {
const hasMeta = Boolean(embed.title || embed.description)

return (
<div
className={cn(
"mt-1 max-w-lg overflow-hidden rounded border border-border/70 bg-[hsl(var(--card))]/60",
"border-l-4 border-l-primary/60",
className
)}
>
<div className="flex min-w-0 flex-col gap-1.5 p-3">
{embed.title && (
<a
href={embed.url}
target="_blank"
rel="noreferrer noopener"
className="line-clamp-2 text-sm font-semibold text-primary hover:underline"
>
{embed.title}
</a>
)}
{embed.description && (
<p className="whitespace-pre-line text-[13px] leading-snug text-muted-foreground">
{embed.description}
</p>
)}
{!hasMeta && (
<a
href={embed.url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="size-3.5" />
{embed.url}
</a>
)}
</div>
{embed.thumbnail &&
(embed.title || embed.description || embed.siteName) && (
<div className="px-3 pb-3">
<img
src={embed.thumbnail}
alt={embed.title ?? "Link preview"}
className="w-full rounded object-cover"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = "none"
}}
/>
</div>
)}
{embed.siteName && (
<div className="flex items-center gap-1.5 border-t border-border/50 px-3 py-2">
<span className="text-xs text-muted-foreground">
{embed.siteName}
</span>
</div>
)}
</div>
)
}
8 changes: 8 additions & 0 deletions apps/web/src/components/chat/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cn } from "@repo/ui/lib/utils"
import { formatTime } from "@repo/utils/date"
import { useCallback, useState } from "react"
import type { Message } from "@/lib/api-types"
import { EmbedCard } from "./embed-card"
import { MessageActionBar } from "./message-action-bar"
import { MessageMarkdown } from "./message-markdown"

Expand Down Expand Up @@ -89,6 +90,13 @@ export function MessageItem({
content={message.content}
mentions={message.mentions}
/>
{message.embeds.length > 0 && (
<div className="flex flex-col gap-1">
{message.embeds.map((embed, index) => (
<EmbedCard key={`${embed.url}-${index}`} embed={embed} />
))}
</div>
)}
{message.reactions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{message.reactions.map((reaction) => (
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/hooks/use-message-sending.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { RealtimeMessageEmbedsUpdated } from "@repo/realtime-types"
import type { QueryClient } from "@tanstack/react-query"
import { useCallback, useEffect, useRef } from "react"
import type { Message } from "@/lib/api-types"
Expand Down Expand Up @@ -83,6 +84,25 @@ export function useMessageSending<TData extends MessagesQueryData>({
}
}, [socket, channelId, updateMessagesInCache])

useEffect(() => {
if (!socket) return

const handleEmbedsUpdated = (update: RealtimeMessageEmbedsUpdated) => {
if (update.channelId !== channelId) return

updateMessagesInCache((messages) =>
messages.map((m) =>
m.id === update.messageId ? { ...m, embeds: update.embeds } : m
)
)
}

socket.on("message:embeds:updated", handleEmbedsUpdated)
return () => {
socket.off("message:embeds:updated", handleEmbedsUpdated)
}
}, [socket, channelId, updateMessagesInCache])

const handleSend = useCallback(
(content: string, options?: { mentions: Message["mentions"] }) => {
if (!socket?.connected || !currentUser) return
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/realtime-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function realtimeMessageToMessage(rm: RealtimeMessage): Message {
author: rm.author,
referencedMessageId: null,
attachments: [],
embeds: [],
embeds: rm.embeds ?? [],
pinned: false,
editedAt: null,
mentions: rm.mentions,
Expand Down
26 changes: 26 additions & 0 deletions apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@repo/worker",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup",
"start": "node dist/index.js",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@repo/db": "workspace:*",
"@repo/env": "workspace:*",
"@repo/realtime-types": "workspace:*",
"@socket.io/redis-emitter": "^5.1.0",
"bullmq": "^5.70.2",
"open-graph-scraper": "^6.11.0",
"redis": "^4.7.1"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"tsup": "^8.5.1",
"tsx": "^4.21.0"
}
}
Loading