Skip to content
Merged

Dev #16

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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
## Phase 1 — Core UX Gaps

- [x] File/image attachment uploads (R2)
- [ ] Message deletion
- [x] Message deletion
- [ ] Message editing UI
- [ ] User profiles (bio, custom status, avatar upload)
- [ ] Channel edit/delete
Expand Down
49 changes: 48 additions & 1 deletion apps/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
import {
channelRoom,
channelRoomPayloadSchema,
deleteMessagePayloadSchema,
editMessagePayloadSchema,
guildRoom,
markChannelReadPayloadSchema,
presenceSubscribePayloadSchema,
Expand All @@ -25,7 +27,12 @@ import { createClient } from "redis"
import { Server, type Socket } from "socket.io"
import { toErrorMessage } from "@/lib/errors"
import { assertUserCanAccessChannel } from "@/services/channel-access"
import { createMessage, toggleMessageReaction } from "@/services/messages"
import {
createMessage,
deleteMessage,
editMessage,
toggleMessageReaction,
} from "@/services/messages"
import { buildMessageFanout } from "@/services/notifications"
import {
listOnlineUserIds,
Expand Down Expand Up @@ -353,6 +360,46 @@ io.on("connection", (socket) => {
}
})

socket.on("message:delete", async (payload, ack) => {
try {
const parsed = deleteMessagePayloadSchema.parse(payload)
const result = await deleteMessage({
userId: socket.data.user.id,
payload: parsed,
})

socket.to(channelRoom(parsed.channelId)).emit("message:deleted", {
channelId: result.channelId,
messageId: result.messageId,
})

ack?.({ ok: true })
} catch (error) {
ack?.({ ok: false, error: toErrorMessage(error) })
}
})

socket.on("message:edit", async (payload, ack) => {
try {
const parsed = editMessagePayloadSchema.parse(payload)
const result = await editMessage({
userId: socket.data.user.id,
payload: parsed,
})

socket.to(channelRoom(parsed.channelId)).emit("message:updated", {
channelId: result.channelId,
messageId: result.messageId,
content: result.content,
editedAt: result.editedAt,
})

ack?.({ ok: true })
} catch (error) {
ack?.({ ok: false, error: toErrorMessage(error) })
}
})

socket.on("message:reaction:toggle", async (payload, ack) => {
try {
const parsed = toggleMessageReactionPayloadSchema.parse(payload)
Expand Down
115 changes: 115 additions & 0 deletions apps/realtime/src/services/messages.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { and, count, db, eq, schema } from "@repo/db"
import type {
DeleteMessagePayload,
EditMessagePayload,
RealtimeMessage,
RealtimeMessageReactionUpdated,
SendMessagePayload,
Expand All @@ -15,6 +17,16 @@ type CreateMessageInput = {
payload: SendMessagePayload
}

type DeleteMessageInput = {
userId: string
payload: DeleteMessagePayload
}

type EditMessageInput = {
userId: string
payload: EditMessagePayload
}

type ToggleMessageReactionInput = {
userId: string
payload: ToggleMessageReactionPayload
Expand All @@ -25,6 +37,12 @@ export type CreateMessageResult = {
channel: AccessibleChannel
}

export type DeleteMessageResult = {
channelId: string
messageId: string
channel: AccessibleChannel
}

export type ToggleMessageReactionResult = {
update: RealtimeMessageReactionUpdated
channel: AccessibleChannel
Expand Down Expand Up @@ -173,6 +191,103 @@ export async function createMessage(input: CreateMessageInput) {
} satisfies CreateMessageResult
}

export async function deleteMessage(
input: DeleteMessageInput
): Promise<DeleteMessageResult> {
const channelRecord = await assertUserCanAccessChannel(
input.userId,
input.payload.channelId
)

const messageRecord = await db
.select({
id: schema.message.id,
authorId: schema.message.authorId,
})
.from(schema.message)
.where(
and(
eq(schema.message.id, input.payload.messageId),
eq(schema.message.channelId, input.payload.channelId)
)
)
.limit(1)
.then((rows) => rows[0])

if (!messageRecord) {
throw new Error("Message not found")
}

if (messageRecord.authorId !== input.userId) {
throw new Error("You can only delete your own messages")
}

await db
.delete(schema.message)
.where(eq(schema.message.id, input.payload.messageId))

return {
channelId: input.payload.channelId,
messageId: input.payload.messageId,
channel: channelRecord,
}
}

export type EditMessageResult = {
channelId: string
messageId: string
content: string
editedAt: string
channel: AccessibleChannel
}

export async function editMessage(
input: EditMessageInput
): Promise<EditMessageResult> {
const channelRecord = await assertUserCanAccessChannel(
input.userId,
input.payload.channelId
)

const messageRecord = await db
.select({
id: schema.message.id,
authorId: schema.message.authorId,
})
.from(schema.message)
.where(
and(
eq(schema.message.id, input.payload.messageId),
eq(schema.message.channelId, input.payload.channelId)
)
)
.limit(1)
.then((rows) => rows[0])

if (!messageRecord) {
throw new Error("Message not found")
}

if (messageRecord.authorId !== input.userId) {
throw new Error("You can only edit your own messages")
}

const editedAt = new Date()

await db
.update(schema.message)
.set({ content: input.payload.content, editedAt })
.where(eq(schema.message.id, input.payload.messageId))

return {
channelId: input.payload.channelId,
messageId: input.payload.messageId,
content: input.payload.content,
editedAt: editedAt.toISOString(),
channel: channelRecord,
}
}

export async function toggleMessageReaction(input: ToggleMessageReactionInput) {
const channelRecord = await assertUserCanAccessChannel(
input.userId,
Expand Down
40 changes: 3 additions & 37 deletions apps/web/src/components/chat/composer/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from "react"
import type { PendingAttachment } from "@/hooks/use-file-upload"
import type { Message } from "@/lib/api-types"
import { extractMentionIds, toStoredMarkdown } from "@/lib/editor-utils"
import type { ChatContext } from "../header"
import { AttachmentPreview } from "./attachment-preview"
import {
Expand All @@ -65,13 +66,9 @@ const MAX_MESSAGE_LENGTH = 2000
const POPUP_HORIZONTAL_PADDING = 8
const POPUP_VERTICAL_PADDING = 8
const POPUP_GAP = 6
const SUGGESTION_MENU_SELECTOR =
export const SUGGESTION_MENU_SELECTOR =
"[data-suggestion-open='true'], [data-mention-suggestion-open='true'], [data-slash-suggestion-open='true'], [data-slash-command-open='true']"
const EVERYONE_MENTION_ID = "everyone"
const SLASH_COMMAND_PLUGIN_KEY = new PluginKey("slash-command")
const TIPTAP_MARKDOWN_MENTION_REGEX = /\[@[^\]]*?\bid="([^"]+)"[^\]]*]/g
const STORED_MENTION_REGEX =
/<@([0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})>/gi
const DEFAULT_CODE_BLOCK_LANGUAGE = "plaintext"
const CODE_BLOCK_LANGUAGE_OPTIONS = [
{ value: "plaintext", label: "Plain Text" },
Expand Down Expand Up @@ -113,37 +110,6 @@ interface MessageInputProps {
isUploading: boolean
}

function toStoredMarkdown(markdown: string) {
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}>`
})
)
}

function extractMentionIds(content: string) {
const mentionIds = new Set<string>()

for (const match of content.matchAll(STORED_MENTION_REGEX)) {
const mentionId = match[1]
if (mentionId) {
mentionIds.add(mentionId)
}
}

return Array.from(mentionIds)
}

interface SuggestionPopupListRef {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
}
Expand Down Expand Up @@ -253,7 +219,7 @@ function createSuggestionPopupManager<
}
}

function createMentionSuggestion(
export function createMentionSuggestion(
getMentionCandidates: () => MentionCandidate[]
): MentionOptions<MentionCandidate>["suggestion"] {
return {
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/components/chat/message-action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,24 @@ export function MessageActionBar({
</Tooltip>

<DropdownMenuContent side="top" align="end">
{canManageMessage && (
<DropdownMenuItem onSelect={onEdit} disabled={!onEdit}>
Edit message
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={onCopyText}>Copy text</DropdownMenuItem>
{canManageMessage && (
<>
<DropdownMenuItem onSelect={onEdit} disabled={!onEdit}>
Edit message
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDelete} disabled={!onDelete}>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={onDelete}
disabled={!onDelete}
className="text-destructive focus:text-destructive"
>
Delete message
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={onCopyText}>Copy text</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
Expand Down
Loading