feat: markdown chat, nested quotes and read receipts

Move the web/server app under server/, add Markdown composer and rendering, cursor-insert image/file uploads, nested quote viewer, group read receipt dialog, and link Markdown attachments to sent messages.
This commit is contained in:
DHEnry committed 2026-10-25 19:14:34 +08:00
1 parent 5fdf324a38
commit 9dfc96cd67
135 files changed
+3169 -591

No files matched your search

+1 -13
View File
@@ -1,13 +1 @@
node_modules/
dist/
.env
.env.local
*.log
logs/
storage/
.mysql/
.DS_Store
Thumbs.db
apps/*/dist/
apps/*/node_modules/
*.tsbuildinfo
desktop/
-470
View File
@@ -1,470 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { AtSign, ImagePlus, Loader2, Quote, Send, Smile, Users, VolumeX, X } from 'lucide-react'
import { api } from '../lib/api'
import { getSocket } from '../lib/socket'
import type { Attachment } from '../types'
import { cn, displayUserName, formatBytes, matchesSearch, matchesUserSearch } from '../lib/utils'
import { useAuth } from '../stores/auth'
import { useChat } from '../stores/chat'
import { toastError, useToast } from '../stores/toast'
import { Avatar } from './Avatar'
const EMOJIS = [
'😀', '😄', '😁', '😆', '😂', '🤣', '😊', '😍',
'😘', '😎', '🤔', '😅', '😢', '😭', '😡', '🥳',
'👍', '👎', '👏', '🙏', '💪', '🤝', '❤️', '🔥',
'🎉', '✨', '💯', '☕', '🌈', '🐶', '🍜', '🚀',
]
interface MentionCandidate {
key: string
label: string
handle: string
all: boolean
avatarUrl?: string | null
}
function formatMuteUntil(iso: string): string {
const ms = new Date(iso).getTime() - Date.now()
if (ms <= 0) return ''
const mins = Math.ceil(ms / 60_000)
if (mins < 60) return `${mins} 分钟后解除`
const hours = Math.ceil(mins / 60)
if (hours < 24) return `${hours} 小时后解除`
return `${Math.ceil(hours / 24)} 天后解除`
}
export function Composer({ conversationId }: { conversationId: string }) {
const me = useAuth((s) => s.user)
const detail = useChat((s) => s.detail)
const conversation = useChat((s) => s.conversations.find((c) => c.id === conversationId))
const replyTo = useChat((s) => s.replyTo)
const setReplyTo = useChat((s) => s.setReplyTo)
const [text, setText] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [uploading, setUploading] = useState(0)
const [emojiOpen, setEmojiOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [mention, setMention] = useState<{ start: number; query: string } | null>(null)
const [mentionIndex, setMentionIndex] = useState(0)
const inputRef = useRef<HTMLTextAreaElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const typingAt = useRef(0)
const stopTimer = useRef<number | undefined>(undefined)
const toast = useToast((s) => s.push)
const groupDetail =
detail && detail.id === conversationId && detail.type === 'GROUP' ? detail : null
const isGroup = conversation?.type === 'GROUP' || !!groupDetail
const mutedByAll = !!groupDetail && groupDetail.muteAll && groupDetail.role === 'MEMBER'
const mutedByUser =
!!groupDetail?.myMutedUntil && new Date(groupDetail.myMutedUntil).getTime() > Date.now()
const muted = mutedByAll || mutedByUser
const candidates = useMemo<MentionCandidate[]>(() => {
if (!mention || !groupDetail) return []
const q = mention.query.trim()
const members = groupDetail.members
.filter((m) => m.user.id !== me?.id)
.filter((m) => !q || matchesUserSearch(m.user, q))
.slice(0, 8)
.map((m) => ({
key: m.user.id,
label: displayUserName(m.user),
handle: m.user.handle,
all: false,
avatarUrl: m.user.avatarUrl,
}))
const allMatches =
!q || matchesSearch('所有人', q) || matchesSearch('all', q)
return allMatches
? [{ key: 'all', label: '所有人', handle: '所有人', all: true }, ...members]
: members
}, [mention, groupDetail, me?.id])
const activeIndex = candidates.length ? Math.min(mentionIndex, candidates.length - 1) : 0
const emitTyping = () => {
const socket = getSocket()
if (!socket?.connected) return
const now = Date.now()
if (now - typingAt.current > 2000) {
socket.emit('typing:start', { conversationId })
typingAt.current = now
}
window.clearTimeout(stopTimer.current)
stopTimer.current = window.setTimeout(() => {
socket.emit('typing:stop', { conversationId })
}, 2500)
}
useEffect(() => {
if (!replyTo) return
const frame = requestAnimationFrame(() => inputRef.current?.focus())
return () => cancelAnimationFrame(frame)
}, [replyTo])
const syncMention = (value: string, cursor: number) => {
if (!isGroup) {
if (mention) setMention(null)
return
}
const before = value.slice(0, cursor)
const match = /(^|\s)@([^\n@]*)$/.exec(before)
if (!match) {
if (mention) setMention(null)
return
}
const start = cursor - match[2].length - 1
const query = match[2]
if (mention && mention.start === start && mention.query === query) return
setMention({ start, query })
setMentionIndex(0)
setEmojiOpen(false)
}
const insertMention = (candidate: MentionCandidate) => {
if (!mention) return
const start = mention.start
const cursor = Math.max(start, inputRef.current?.selectionStart ?? text.length)
const token = `@${candidate.all ? '所有人' : candidate.label.trim()} `
const next = text.slice(0, start) + token + text.slice(cursor)
setText(next)
setMention(null)
requestAnimationFrame(() => {
const el = inputRef.current
if (!el) return
el.focus()
const pos = start + token.length
el.setSelectionRange(pos, pos)
})
}
const insertAt = () => {
if (muted) return
const cursor = inputRef.current?.selectionStart ?? text.length
const next = text.slice(0, cursor) + '@' + text.slice(cursor)
setText(next)
setMention({ start: cursor, query: '' })
setMentionIndex(0)
setEmojiOpen(false)
requestAnimationFrame(() => {
const el = inputRef.current
if (!el) return
el.focus()
el.setSelectionRange(cursor + 1, cursor + 1)
})
}
const uploadFiles = async (files: File[]) => {
if (!files.length || muted) return
if (attachments.length + files.length > 6) {
toast('每条消息最多 6 个附件', 'error')
return
}
setUploading((n) => n + files.length)
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
toast(`${file.name} 超过 20MB`, 'error')
setUploading((n) => n - 1)
continue
}
try {
const { attachment } = await api.uploadAttachment(file)
setAttachments((prev) => [...prev, attachment])
} catch (err) {
toastError(err, '上传失败')
} finally {
setUploading((n) => n - 1)
}
}
}
const send = async () => {
if (muted) return
const content = text
if (!content.trim() && !attachments.length) return
if (uploading > 0) {
toast('还有文件正在上传,请稍候', 'error')
return
}
const files = attachments
setText('')
setAttachments([])
setEmojiOpen(false)
setMention(null)
const socket = getSocket()
socket?.emit('typing:stop', { conversationId })
await useChat.getState().send(conversationId, content, files)
inputRef.current?.focus()
}
return (
<div
className={cn(
'relative border-t border-line bg-panel px-3 pt-2.5 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-5',
dragOver && 'bg-accent-soft/40'
)}
onDragOver={(e) => {
e.preventDefault()
if (!muted) setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
if (!muted) void uploadFiles([...e.dataTransfer.files])
}}
>
{mention && candidates.length > 0 && (
<div className="animate-in absolute bottom-full left-3 z-20 mb-2 w-64 overflow-hidden rounded-xl border border-line bg-panel shadow-xl sm:left-5">
<p className="border-b border-line px-3 py-1.5 text-[10px] text-muted">
选择要提及的成员
</p>
<div className="max-h-64 overflow-y-auto py-1">
{candidates.map((candidate, index) => (
<button
key={candidate.key}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertMention(candidate)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
index === activeIndex ? 'bg-accent-soft text-accent' : 'hover:bg-panel2'
)}
>
{candidate.all ? (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
<Users size={12} />
</span>
) : (
<Avatar
id={candidate.key}
name={candidate.label}
avatarUrl={candidate.avatarUrl}
size={20}
/>
)}
<span className="min-w-0 flex-1 truncate">{candidate.label}</span>
<span className="shrink-0 text-[10px] text-muted">@{candidate.handle}</span>
</button>
))}
</div>
</div>
)}
{emojiOpen && !muted && (
<div className="animate-in absolute bottom-full left-3 z-20 mb-2 grid w-64 grid-cols-8 gap-1 rounded-xl border border-line bg-panel p-2 shadow-xl sm:left-5">
{EMOJIS.map((emoji) => (
<button
key={emoji}
onClick={() => {
setText((v) => v + emoji)
inputRef.current?.focus()
}}
className="rounded-md p-1 text-lg transition-colors hover:bg-panel2"
>
{emoji}
</button>
))}
</div>
)}
{muted && (
<div className="mb-2 flex items-start gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-600 dark:text-amber-400">
<VolumeX size={13} className="mt-0.5 shrink-0" />
<span>
{mutedByAll
? '全员禁言中,仅群主和管理员可发言。'
: `你已被禁言,${groupDetail?.myMutedUntil ? formatMuteUntil(groupDetail.myMutedUntil) : ''}。`}
</span>
</div>
)}
{replyTo && !muted && (
<div className="mb-2 flex items-start gap-2 rounded-lg border border-line bg-panel2 px-2.5 py-2 text-xs">
<Quote size={13} className="mt-0.5 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-accent">
回复 {displayUserName(replyTo.sender)}
</p>
<p className="truncate text-muted">
{replyTo.deletedAt
? '消息已撤回'
: replyTo.type === 'IMAGE'
? '[图片]'
: replyTo.type === 'FILE'
? '[文件]'
: replyTo.content || '[消息]'}
</p>
</div>
<button
onClick={() => setReplyTo(null)}
className="shrink-0 rounded p-0.5 text-muted hover:text-fg"
title="取消引用"
>
<X size={13} />
</button>
</div>
)}
{(attachments.length > 0 || uploading > 0) && (
<div className="mb-2 flex flex-wrap gap-2">
{attachments.map((a) => (
<div
key={a.id}
className="group relative flex items-center gap-2 rounded-lg border border-line bg-panel2 py-1.5 pr-6 pl-2 text-xs"
>
{a.mimeType.startsWith('image/') ? (
<img src={a.thumbUrl ?? a.url} alt="" className="h-8 w-8 rounded object-cover" />
) : (
<span className="flex h-8 w-8 items-center justify-center rounded bg-panel text-[10px] text-muted">
FILE
</span>
)}
<span className="max-w-32 truncate">{a.fileName}</span>
<span className="text-[10px] text-muted">{formatBytes(a.size)}</span>
<button
onClick={() => setAttachments((prev) => prev.filter((x) => x.id !== a.id))}
className="absolute top-1 right-1 rounded p-0.5 text-muted hover:text-danger"
>
<X size={12} />
</button>
</div>
))}
{uploading > 0 && (
<div className="flex items-center gap-1.5 rounded-lg border border-line bg-panel2 px-2 py-1.5 text-xs text-muted">
<Loader2 size={13} className="animate-spin" /> 上传中 {uploading}
</div>
)}
</div>
)}
<div className="flex items-end gap-1.5">
<button
onClick={() => fileRef.current?.click()}
disabled={muted}
className="mb-0.5 rounded-lg p-2 text-muted transition-colors hover:bg-panel2 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
title="发送图片或文件"
>
<ImagePlus size={19} />
</button>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
void uploadFiles([...(e.target.files ?? [])])
e.target.value = ''
}}
/>
<button
onClick={() => {
if (muted) return
setEmojiOpen((v) => !v)
setMention(null)
}}
disabled={muted}
className={cn(
'mb-0.5 rounded-lg p-2 transition-colors hover:bg-panel2 disabled:cursor-not-allowed disabled:opacity-40',
emojiOpen ? 'text-accent' : 'text-muted hover:text-fg'
)}
title="表情"
>
<Smile size={19} />
</button>
{isGroup && (
<button
onClick={insertAt}
disabled={muted}
className="mb-0.5 rounded-lg p-2 text-muted transition-colors hover:bg-panel2 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
title="提及成员"
>
<AtSign size={19} />
</button>
)}
<div
className={cn(
'min-w-0 flex-1 rounded-xl border border-line bg-panel2 transition-colors focus-within:border-accent/60',
muted && 'opacity-60'
)}
>
<textarea
ref={inputRef}
value={text}
rows={1}
maxLength={4000}
disabled={muted}
onChange={(e) => {
const value = e.target.value
setText(value)
syncMention(value, e.target.selectionStart ?? value.length)
if (value.trim() && !muted) emitTyping()
}}
onClick={(e) => syncMention(text, e.currentTarget.selectionStart ?? text.length)}
onKeyUp={(e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') return
syncMention(text, e.currentTarget.selectionStart ?? text.length)
}}
onPaste={(e) => {
const files = [...e.clipboardData.files]
if (files.length && !muted) {
e.preventDefault()
void uploadFiles(files)
}
}}
onKeyDown={(e) => {
if (mention && candidates.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setMentionIndex((activeIndex + 1) % candidates.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setMentionIndex((activeIndex - 1 + candidates.length) % candidates.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
insertMention(candidates[activeIndex])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setMention(null)
return
}
}
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
void send()
}
}}
placeholder={
muted
? '你已被禁言,暂时无法发送消息'
: isGroup
? '输入消息,输入 @ 提及成员,Enter 发送'
: '输入消息,Enter 发送,Shift+Enter 换行'
}
className="max-h-40 w-full resize-none bg-transparent px-3 py-2.5 text-sm leading-relaxed outline-none placeholder:text-muted/70 disabled:cursor-not-allowed"
/>
</div>
<button
onClick={() => void send()}
disabled={muted || (!text.trim() && !attachments.length) || uploading > 0}
className="mb-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-35"
title="发送"
>
<Send size={17} />
</button>
</div>
</div>
)
}
File renamed without changes.
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
.env
.env.local
*.log
logs/
storage/
.mysql/
.DS_Store
Thumbs.db
apps/*/dist/
apps/*/node_modules/
*.tsbuildinfo
View File
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,61 @@
/* eslint-disable no-console */
const fs = require('node:fs')
const path = require('node:path')
function loadEnv() {
const envPath = path.resolve(__dirname, '../../../.env')
if (!fs.existsSync(envPath)) return
const text = fs.readFileSync(envPath, 'utf8')
for (const line of text.split(/\r?\n/)) {
const match = /^([A-Z0-9_]+)\s*=\s*"?([^"]*)"?\s*$/.exec(line.trim())
if (match) process.env[match[1]] = match[2]
}
}
loadEnv()
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
async function main() {
const messages = await prisma.message.findMany({
where: { content: { contains: '/api/files/attachment/' } },
select: { id: true, senderId: true, content: true },
})
let linked = 0
let scanned = 0
for (const message of messages) {
scanned += 1
if (!message.content) continue
const ids = [
...new Set(
[...message.content.matchAll(/\/api\/files\/attachment\/([0-9A-HJKMNP-TV-Z]{26})/g)].map(
(match) => match[1]
)
),
]
if (!ids.length) continue
const result = await prisma.attachment.updateMany({
where: {
id: { in: ids },
uploaderId: message.senderId,
messageId: null,
},
data: { messageId: message.id },
})
linked += result.count
}
console.log(`scanned messages: ${scanned}`)
console.log(`linked attachments: ${linked}`)
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -93,11 +93,42 @@ export type MessageWithRelations = Message & {
attachments: Attachment[]
}
export const MAX_REPLY_DEPTH = 5
function replyDto(
m: MessageWithRelations,
remarks: Map<string, string> | undefined,
replyById: Map<string, MessageWithRelations> | undefined,
depth: number
): Record<string, unknown> {
const nested =
depth < MAX_REPLY_DEPTH && m.replyToId && replyById
? (replyById.get(m.replyToId) ?? null)
: null
return {
id: m.id,
conversationId: m.conversationId,
senderId: m.senderId,
type: m.type,
content: m.deletedAt ? null : m.content,
createdAt: m.createdAt.toISOString(),
deletedAt: m.deletedAt?.toISOString() ?? null,
sender: {
...userDto(m.sender),
remark: remarks?.get(m.senderId) ?? null,
},
attachments: m.deletedAt ? [] : m.attachments.map(attachmentDto),
replyToId: m.replyToId,
replyTo: nested ? replyDto(nested, remarks, replyById, depth + 1) : null,
}
}
export function messageDto(
m: MessageWithRelations,
remarks?: Map<string, string>,
replyTo?: MessageWithRelations | null,
favorited = false
favorited = false,
replyById?: Map<string, MessageWithRelations>
) {
return {
id: m.id,
@@ -116,21 +147,6 @@ export function messageDto(
attachments: m.deletedAt ? [] : m.attachments.map(attachmentDto),
favorited,
mentions: [] as string[],
replyTo: replyTo
? {
id: replyTo.id,
conversationId: replyTo.conversationId,
senderId: replyTo.senderId,
type: replyTo.type,
content: replyTo.deletedAt ? null : replyTo.content,
createdAt: replyTo.createdAt.toISOString(),
deletedAt: replyTo.deletedAt?.toISOString() ?? null,
sender: {
...userDto(replyTo.sender),
remark: remarks?.get(replyTo.senderId) ?? null,
},
attachments: replyTo.deletedAt ? [] : replyTo.attachments.map(attachmentDto),
}
: null,
replyTo: replyTo ? replyDto(replyTo, remarks, replyById, 1) : null,
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -119,14 +119,20 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
const attachment = await prisma.attachment.findUnique({
where: { id: paramId(req) },
select: {
uploaderId: true,
storageKey: true,
mimeType: true,
fileName: true,
message: { select: { conversationId: true, deletedAt: true } },
},
})
if (!attachment || !attachment.message || attachment.message.deletedAt) throw notFound('文件不存在')
await requireMember(attachment.message.conversationId, req.user.id)
if (!attachment || attachment.message?.deletedAt) throw notFound('文件不存在')
if (attachment.message) {
await requireMember(attachment.message.conversationId, req.user.id)
} else if (attachment.uploaderId !== req.user.id) {
// 上传后尚未发送的附件允许上传者预览;发送后会绑定到消息,再由会话成员访问。
throw notFound('文件不存在')
}
return sendStoredFile(req, reply, attachment.storageKey, attachment.mimeType, attachment.fileName)
})
@@ -134,13 +140,18 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
const attachment = await prisma.attachment.findUnique({
where: { id: paramId(req) },
select: {
uploaderId: true,
storageKey: true,
thumbKey: true,
message: { select: { conversationId: true, deletedAt: true } },
},
})
if (!attachment || !attachment.message || attachment.message.deletedAt) throw notFound('文件不存在')
await requireMember(attachment.message.conversationId, req.user.id)
if (!attachment || attachment.message?.deletedAt) throw notFound('文件不存在')
if (attachment.message) {
await requireMember(attachment.message.conversationId, req.user.id)
} else if (attachment.uploaderId !== req.user.id) {
throw notFound('文件不存在')
}
const key = attachment.thumbKey ?? attachment.storageKey
return sendStoredFile(req, reply, key, attachment.thumbKey ? 'image/webp' : 'application/octet-stream')
})
File renamed without changes.
@@ -5,6 +5,7 @@ import { badRequest, forbidden, notFound, tooMany } from '../lib/errors.js'
import { takeToken } from '../lib/ratelimit.js'
import {
attachmentDto,
MAX_REPLY_DEPTH,
messageDto,
userDto,
userSelect,
@@ -26,19 +27,38 @@ export const messageInclude = {
attachments: true,
} satisfies Prisma.MessageInclude
async function loadReplyTree(seedIds: string[]) {
const byId = new Map<string, MessageWithRelations>()
let pending = new Set(seedIds.filter((id) => isId(id)))
for (let depth = 0; depth < MAX_REPLY_DEPTH && pending.size; depth += 1) {
const rows = (await prisma.message.findMany({
where: { id: { in: [...pending] } },
include: messageInclude,
})) as MessageWithRelations[]
const next = new Set<string>()
for (const row of rows) {
if (!byId.has(row.id)) byId.set(row.id, row)
if (row.replyToId && !byId.has(row.replyToId)) next.add(row.replyToId)
}
pending = next
}
return { rows: [...byId.values()], byId }
}
async function withReplyPreview(
m: MessageWithRelations,
viewerId: string,
favorited = false
) {
const replyTo = m.replyToId
? await prisma.message.findUnique({ where: { id: m.replyToId }, include: messageInclude })
: null
const { rows: replyRows, byId: replyById } = m.replyToId
? await loadReplyTree([m.replyToId])
: { rows: [] as MessageWithRelations[], byId: new Map<string, MessageWithRelations>() }
const replyTo = m.replyToId ? (replyById.get(m.replyToId) ?? null) : null
const remarks = await remarkMap(viewerId, [
m.senderId,
...(replyTo ? [replyTo.senderId] : []),
...replyRows.map((row) => row.senderId),
])
return messageDto(m, remarks, replyTo as MessageWithRelations | null, favorited)
return messageDto(m, remarks, replyTo, favorited, replyById)
}
async function detectMentions(conversationId: string, senderId: string, content: string) {
@@ -120,7 +140,28 @@ export async function sendMessage(input: SendMessageInput) {
const content = (input.content ?? '').trim()
if (content.length > MAX_CONTENT) throw badRequest(`消息最多 ${MAX_CONTENT} 字`)
const attachmentIds = [...new Set(input.attachmentIds ?? [])].slice(0, MAX_ATTACHMENTS)
// 上传后插入正文的 Markdown 附件(图片 ![]() / 文件 []())也需要绑定到消息,
// 否则附件没有 messageId,接收方访问文件时会 404。
const referencedIds = [
...new Set(
[...content.matchAll(/\/api\/files\/attachment\/([0-9A-HJKMNP-TV-Z]{26})/g)].map(
(match) => match[1]
)
),
]
const referenced = referencedIds.length
? await prisma.attachment.findMany({
where: {
id: { in: referencedIds },
uploaderId: input.userId,
messageId: null,
},
select: { id: true },
})
: []
const attachmentIds = [
...new Set([...(input.attachmentIds ?? []), ...referenced.map((item) => item.id)]),
].slice(0, MAX_ATTACHMENTS)
if (attachmentIds.some((id) => !isId(id))) throw badRequest('附件参数不合法')
if (!content && !attachmentIds.length && input.type !== 'SYSTEM') {
throw badRequest('消息不能为空')
@@ -261,13 +302,9 @@ export async function listMessages(
const replyIds = [
...new Set(page.map((m) => m.replyToId).filter((id): id is string => !!id)),
]
const replyRows = replyIds.length
? await prisma.message.findMany({
where: { id: { in: replyIds } },
include: messageInclude,
})
: []
const replyById = new Map(replyRows.map((m) => [m.id, m]))
const { rows: replyRows, byId: replyById } = replyIds.length
? await loadReplyTree(replyIds)
: { rows: [] as MessageWithRelations[], byId: new Map<string, MessageWithRelations>() }
const remarks = await remarkMap(userId, [
...page.map((m) => m.senderId),
...replyRows.map((m) => m.senderId),
@@ -283,7 +320,8 @@ export async function listMessages(
m,
remarks,
m.replyToId ? (replyById.get(m.replyToId) ?? null) : null,
favoriteIds.has(m.id)
favoriteIds.has(m.id),
replyById
)
),
hasMore,
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -17,7 +17,10 @@
"pinyin-pro": "^3.29.5",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.4",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.4",
"zustand": "^5.0.16"
},
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,8 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { getToken, refreshSession } from '../lib/api'
import { connectSocket, disconnectSocket, getSocket } from '../lib/socket'
import type { Message, NotificationEntry } from '../types'
import type { Conversation, Message, NotificationEntry } from '../types'
import { displayUserName } from '../lib/utils'
import { useAuth } from '../stores/auth'
import { useChat } from '../stores/chat'
@@ -12,9 +13,101 @@ import { useUI } from '../stores/ui'
import { FloatingUserCard } from './FloatingUserCard'
import { SettingsDialog } from './SettingsDialog'
interface DesktopNotificationPayload {
title: string
body: string
conversationId?: string
avatarUrl?: string
}
interface DesktopBridge {
notify: (payload: DesktopNotificationPayload) => void
}
function getDesktopBridge(): DesktopBridge | undefined {
if (typeof window === 'undefined') return undefined
return (window as Window & { desktopBridge?: DesktopBridge }).desktopBridge
}
const avatarCache = new Map<string, Promise<string | undefined>>()
function avatarDataUrl(url: string): Promise<string | undefined> {
const absolute = new URL(url, window.location.origin).href
const cached = avatarCache.get(absolute)
if (cached) return cached
const pending = fetch(absolute, { credentials: 'include' })
.then(async (response) => {
if (!response.ok) return undefined
const blob = await response.blob()
if (blob.size > 2 * 1024 * 1024) return undefined
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(blob)
})
})
.catch(() => undefined)
avatarCache.set(absolute, pending)
return pending
}
function notifyDesktop(payload: DesktopNotificationPayload) {
const bridge = getDesktopBridge()
if (!bridge) return
try {
if (!payload.avatarUrl) {
bridge.notify(payload)
return
}
void avatarDataUrl(payload.avatarUrl)
.then((avatarUrl) => bridge.notify({ ...payload, avatarUrl }))
.catch(() => bridge.notify(payload))
} catch {
/* 桌面端桥接失败不影响网页 */
}
}
function conversationTitle(conversation: Conversation | undefined, fallback: string): string {
if (!conversation) return fallback
if (conversation.type === 'DM') {
return conversation.peer ? displayUserName(conversation.peer) : '私信'
}
return conversation.name ?? '群聊'
}
function plainPreview(content: string): string {
return content
.replace(/!\[[^\]]*\]\([^)]*\)/g, '[图片]')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/```[\s\S]*?```/g, '[代码]')
.replace(/`([^`]*)`/g, '$1')
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/~~(.*?)~~/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/^>\s?/gm, '')
.replace(/\s+/g, ' ')
.trim()
}
function messagePreview(message: Message): string {
if (message.deletedAt) return '消息已撤回'
if (message.type === 'FORWARD') return '[聊天记录]'
if (message.type === 'SYSTEM') return message.content ?? ''
const content = plainPreview(message.content ?? '')
if (content) return content
if (message.attachments.length) {
if (message.attachments.every((item) => item.mimeType.startsWith('image/'))) return '[图片]'
return `[文件] ${message.attachments[0]?.fileName ?? ''}`.trim()
}
return '新消息'
}
/** 判断用户当前是否真正停留在某个聊天窗口。 */
function isChatViewing(conversationId: string): boolean {
if (typeof window === 'undefined') return false
// 最小化或窗口失焦时视为未查看,确保桌面端能收到提醒。
if (typeof document !== 'undefined' && (document.hidden || !document.hasFocus())) return false
if (window.location.pathname !== '/') return false
const tab = new URLSearchParams(window.location.search).get('tab')
if (tab === 'contacts') return false
@@ -32,6 +125,24 @@ export function AppRuntime() {
const userId = user?.id
const settingsOpen = useUI((s) => s.settingsOpen)
const closeSettings = useUI((s) => s.closeSettings)
const navigate = useNavigate()
useEffect(() => {
const openConversation = (conversationId: string) => {
if (!conversationId) return
navigate('/')
void useChat.getState().openConversation(conversationId)
}
const desktopWindow = window as Window & {
desktopOpenConversation?: (conversationId: string) => void
}
desktopWindow.desktopOpenConversation = openConversation
return () => {
if (desktopWindow.desktopOpenConversation === openConversation) {
delete desktopWindow.desktopOpenConversation
}
}
}, [navigate])
useEffect(() => {
if (status !== 'authed' || !userId) return
@@ -52,6 +163,19 @@ export function AppRuntime() {
useChat.getState().receiveMessage(message, viewing)
const me = useAuth.getState().user
if (!me || message.senderId === me.id) return
if (!viewing && message.type !== 'SYSTEM') {
const conversation = useChat
.getState()
.conversations.find((item) => item.id === message.conversationId)
const senderName = displayUserName(message.sender)
const preview = messagePreview(message)
notifyDesktop({
title: conversationTitle(conversation, senderName),
body: conversation?.type === 'GROUP' ? `${senderName}:${preview}` : preview,
conversationId: message.conversationId,
avatarUrl: message.sender.avatarUrl ?? undefined,
})
}
const content = message.content ?? ''
const mentioned =
message.mentions?.includes(me.id) ||
@@ -117,10 +241,9 @@ export function AppRuntime() {
inviterName?: string
}) => {
void useFriends.getState().load()
useToast.getState().push(
`${p.inviterName ?? '有人'} 邀请你加入群聊「${p.conversationName ?? '未命名群聊'}」`,
'info'
)
const text = `${p.inviterName ?? '有人'} 邀请你加入群聊「${p.conversationName ?? '未命名群聊'}」`
useToast.getState().push(text, 'info')
notifyDesktop({ title: '入群邀请', body: text })
}
const onGroupInviteUpdated = (p: {
inviteId: string
@@ -159,6 +282,7 @@ export function AppRuntime() {
const onNotification = (notification: NotificationEntry) => {
useNotifications.getState().receive(notification)
useToast.getState().push(notification.title, 'info')
notifyDesktop({ title: notification.title, body: notification.content })
}
const onNotificationRead = (p: { id: string; readAt: string }) =>
useNotifications.getState().receiveRead(p.id, p.readAt)
File renamed without changes.
+680
View File
@@ -0,0 +1,680 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
AtSign,
Bold,
Code,
Eye,
ImagePlus,
Italic,
Link2,
List,
Loader2,
Paperclip,
Pencil,
Quote,
Send,
Smile,
Strikethrough,
Users,
VolumeX,
X,
} from 'lucide-react'
import { api } from '../lib/api'
import { getSocket } from '../lib/socket'
import { cn, displayUserName, matchesSearch, matchesUserSearch } from '../lib/utils'
import type { Attachment } from '../types'
import { useAuth } from '../stores/auth'
import { useChat } from '../stores/chat'
import { toastError, useToast } from '../stores/toast'
import { Avatar } from './Avatar'
import { MarkdownMessage } from './MarkdownMessage'
const EMOJIS = [
'😀', '😄', '😁', '😆', '😂', '🤣', '😊', '😍',
'😘', '😎', '🤔', '😅', '😢', '😭', '😡', '🥳',
'👍', '👎', '👏', '🙏', '💪', '🤝', '❤️', '🔥',
'🎉', '✨', '💯', '☕', '🌈', '🐶', '🍜', '🚀',
]
interface MentionCandidate {
key: string
label: string
handle: string
all: boolean
avatarUrl?: string | null
}
function formatMuteUntil(iso: string): string {
const ms = new Date(iso).getTime() - Date.now()
if (ms <= 0) return ''
const mins = Math.ceil(ms / 60_000)
if (mins < 60) return `${mins} 分钟后解除`
const hours = Math.ceil(mins / 60)
if (hours < 24) return `${hours} 小时后解除`
return `${Math.ceil(hours / 24)} 天后解除`
}
function cleanLabel(label: string): string {
return label.replace(/[[\]()|\\]/g, (ch) => `\\${ch}`)
}
export function Composer({ conversationId }: { conversationId: string }) {
const me = useAuth((s) => s.user)
const detail = useChat((s) => s.detail)
const conversation = useChat((s) => s.conversations.find((c) => c.id === conversationId))
const replyTo = useChat((s) => s.replyTo)
const setReplyTo = useChat((s) => s.setReplyTo)
const [text, setText] = useState('')
const [uploading, setUploading] = useState(0)
const [emojiOpen, setEmojiOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [preview, setPreview] = useState(false)
const [mention, setMention] = useState<{ start: number; query: string } | null>(null)
const [mentionIndex, setMentionIndex] = useState(0)
const inputRef = useRef<HTMLTextAreaElement>(null)
const imageRef = useRef<HTMLInputElement>(null)
const fileRef = useRef<HTMLInputElement>(null)
const uploadedAttachments = useRef(new Map<string, Attachment>())
const pendingSelection = useRef<number | null>(null)
const typingAt = useRef(0)
const stopTimer = useRef<number | undefined>(undefined)
const toast = useToast((s) => s.push)
const groupDetail =
detail && detail.id === conversationId && detail.type === 'GROUP' ? detail : null
const isGroup = conversation?.type === 'GROUP' || !!groupDetail
const mutedByAll = !!groupDetail && groupDetail.muteAll && groupDetail.role === 'MEMBER'
const mutedByUser =
!!groupDetail?.myMutedUntil && new Date(groupDetail.myMutedUntil).getTime() > Date.now()
const muted = mutedByAll || mutedByUser
const candidates = useMemo<MentionCandidate[]>(() => {
if (!mention || !groupDetail) return []
const q = mention.query.trim()
const members = groupDetail.members
.filter((m) => m.user.id !== me?.id)
.filter((m) => !q || matchesUserSearch(m.user, q))
.slice(0, 8)
.map((m) => ({
key: m.user.id,
label: displayUserName(m.user),
handle: m.user.handle,
all: false,
avatarUrl: m.user.avatarUrl,
}))
const allMatches =
!q || matchesSearch('所有人', q) || matchesSearch('all', q)
return allMatches
? [{ key: 'all', label: '所有人', handle: '所有人', all: true }, ...members]
: members
}, [mention, groupDetail, me?.id])
const activeIndex = candidates.length ? Math.min(mentionIndex, candidates.length - 1) : 0
const mentionNames = useMemo(
() =>
[
...(groupDetail?.members ?? []).flatMap((m) => [
m.user.handle,
m.user.username,
m.user.remark,
]),
me?.handle,
me?.username,
me?.remark,
].filter((name): name is string => !!name),
[groupDetail, me]
)
const emitTyping = () => {
const socket = getSocket()
if (!socket?.connected) return
const now = Date.now()
if (now - typingAt.current > 2000) {
socket.emit('typing:start', { conversationId })
typingAt.current = now
}
window.clearTimeout(stopTimer.current)
stopTimer.current = window.setTimeout(() => {
socket.emit('typing:stop', { conversationId })
}, 2500)
}
useEffect(() => {
if (!replyTo) return
const frame = requestAnimationFrame(() => inputRef.current?.focus())
return () => cancelAnimationFrame(frame)
}, [replyTo])
useEffect(() => {
if (pendingSelection.current === null) return
const frame = requestAnimationFrame(() => {
const pos = pendingSelection.current
pendingSelection.current = null
const el = inputRef.current
if (!el || pos === null) return
el.focus()
el.setSelectionRange(pos, pos)
})
return () => cancelAnimationFrame(frame)
}, [text])
const applyEdit = (next: string, selectionStart: number, selectionEnd = selectionStart) => {
pendingSelection.current = selectionEnd
setText(next)
if (inputRef.current) {
inputRef.current.setSelectionRange(selectionStart, selectionEnd)
}
syncMention(next, selectionEnd)
}
const insertAtCursor = (snippet: string, selectFrom?: string, selectTo?: string) => {
const el = inputRef.current
const start = el?.selectionStart ?? text.length
const end = el?.selectionEnd ?? text.length
const next = text.slice(0, start) + snippet + text.slice(end)
let selStart = start + snippet.length
let selEnd = selStart
if (selectFrom !== undefined) {
selStart = start + snippet.indexOf(selectFrom)
selEnd = selectTo !== undefined ? start + snippet.indexOf(selectTo) : selStart + selectFrom.length
if (selStart < start) selStart = start
selEnd = Math.max(selStart, selEnd)
}
applyEdit(next, selStart, selEnd)
}
const wrapSelection = (before: string, after = before, placeholder = '') => {
const el = inputRef.current
const start = el?.selectionStart ?? text.length
const end = el?.selectionEnd ?? text.length
const selected = text.slice(start, end) || placeholder
const snippet = `${before}${selected}${after}`
const next = text.slice(0, start) + snippet + text.slice(end)
const selStart = start + before.length
applyEdit(next, selStart, selStart + selected.length)
}
const prefixLine = (prefix: string) => {
const el = inputRef.current
const start = el?.selectionStart ?? text.length
const lineStart = text.lastIndexOf('\n', Math.max(0, start - 1)) + 1
if (text.startsWith(prefix, lineStart)) {
applyEdit(text.slice(0, lineStart) + text.slice(lineStart + prefix.length), start - prefix.length)
return
}
const next = text.slice(0, lineStart) + prefix + text.slice(lineStart)
applyEdit(next, start + prefix.length)
}
const syncMention = (value: string, cursor: number) => {
if (!isGroup) {
if (mention) setMention(null)
return
}
const before = value.slice(0, cursor)
const match = /(^|\s)@([^\n@]*)$/.exec(before)
if (!match) {
if (mention) setMention(null)
return
}
const start = cursor - match[2].length - 1
const query = match[2]
if (mention && mention.start === start && mention.query === query) return
setMention({ start, query })
setMentionIndex(0)
setEmojiOpen(false)
}
const insertMention = (candidate: MentionCandidate) => {
if (!mention) return
const start = mention.start
const cursor = Math.max(start, inputRef.current?.selectionStart ?? text.length)
const token = `@${candidate.all ? '所有人' : candidate.label.trim()} `
const next = text.slice(0, start) + token + text.slice(cursor)
applyEdit(next, start + token.length)
setMention(null)
}
const insertAt = () => {
if (muted) return
const cursor = inputRef.current?.selectionStart ?? text.length
const next = text.slice(0, cursor) + '@' + text.slice(cursor)
applyEdit(next, cursor + 1)
setMention({ start: cursor, query: '' })
setMentionIndex(0)
setEmojiOpen(false)
}
const uploadFiles = async (files: File[]) => {
if (!files.length || muted) return
const existing = (text.match(/\/api\/files\/attachment\/[0-9A-Z]{26}/g) ?? []).length
if (existing + files.length > 6) {
toast('每条消息最多 6 个附件', 'error')
return
}
const images = files.filter((file) => file.type.startsWith('image/'))
const others = files.filter((file) => !file.type.startsWith('image/'))
setUploading((n) => n + files.length)
for (const file of images) {
if (file.size > 20 * 1024 * 1024) {
toast(`${file.name} 超过 20MB`, 'error')
setUploading((n) => n - 1)
continue
}
try {
const { attachment } = await api.uploadAttachment(file)
uploadedAttachments.current.set(attachment.url, attachment)
insertAtCursor(`![${cleanLabel(file.name)}](${attachment.url})`)
} catch (err) {
toastError(err, '上传失败')
} finally {
setUploading((n) => n - 1)
}
}
for (const file of others) {
if (file.size > 20 * 1024 * 1024) {
toast(`${file.name} 超过 20MB`, 'error')
setUploading((n) => n - 1)
continue
}
try {
const { attachment } = await api.uploadAttachment(file)
uploadedAttachments.current.set(attachment.url, attachment)
insertAtCursor(`[${cleanLabel(file.name)}](${attachment.url})`)
} catch (err) {
toastError(err, '上传失败')
} finally {
setUploading((n) => n - 1)
}
}
}
const send = async () => {
if (muted) return
const content = text
if (!content.trim()) return
if (uploading > 0) {
toast('还有文件正在上传,请稍候', 'error')
return
}
const files = [...uploadedAttachments.current.entries()]
.filter(([url]) => content.includes(url))
.map(([, attachment]) => attachment)
setText('')
setPreview(false)
setEmojiOpen(false)
setMention(null)
uploadedAttachments.current.clear()
const socket = getSocket()
socket?.emit('typing:stop', { conversationId })
await useChat.getState().send(conversationId, content, files)
inputRef.current?.focus()
}
const canSend = !!text.trim() && uploading === 0
const toolbar = [
{
key: 'bold',
title: '加粗',
icon: <Bold size={15} />,
action: () => wrapSelection('**', '**', '加粗文字'),
},
{
key: 'italic',
title: '斜体',
icon: <Italic size={15} />,
action: () => wrapSelection('*', '*', '斜体文字'),
},
{
key: 'strike',
title: '删除线',
icon: <Strikethrough size={15} />,
action: () => wrapSelection('~~', '~~', '删除线'),
},
{
key: 'code',
title: '代码',
icon: <Code size={15} />,
action: () => wrapSelection('`', '`', 'code'),
},
{
key: 'quote',
title: '引用',
icon: <Quote size={15} />,
action: () => prefixLine('> '),
},
{
key: 'list',
title: '列表',
icon: <List size={15} />,
action: () => prefixLine('- '),
},
{
key: 'link',
title: '链接',
icon: <Link2 size={15} />,
action: () => insertAtCursor('[链接文字](https://)', '链接文字'),
},
]
return (
<div
className={cn(
'relative border-t border-line bg-panel px-3 pt-2.5 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-5',
dragOver && 'bg-accent-soft/40'
)}
onDragOver={(e) => {
e.preventDefault()
if (!muted) setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
if (!muted) void uploadFiles([...e.dataTransfer.files])
}}
>
{mention && candidates.length > 0 && (
<div className="animate-in absolute bottom-full left-3 z-20 mb-2 w-64 overflow-hidden rounded-xl border border-line bg-panel shadow-xl sm:left-5">
<p className="border-b border-line px-3 py-1.5 text-[10px] text-muted">
选择要提及的成员
</p>
<div className="max-h-64 overflow-y-auto py-1">
{candidates.map((candidate, index) => (
<button
key={candidate.key}
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertMention(candidate)}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
index === activeIndex ? 'bg-accent-soft text-accent' : 'hover:bg-panel2'
)}
>
{candidate.all ? (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
<Users size={12} />
</span>
) : (
<Avatar
id={candidate.key}
name={candidate.label}
avatarUrl={candidate.avatarUrl}
size={20}
/>
)}
<span className="min-w-0 flex-1 truncate">{candidate.label}</span>
<span className="shrink-0 text-[10px] text-muted">@{candidate.handle}</span>
</button>
))}
</div>
</div>
)}
{emojiOpen && !muted && (
<div className="animate-in absolute bottom-full left-3 z-20 mb-2 grid w-64 grid-cols-8 gap-1 rounded-xl border border-line bg-panel p-2 shadow-xl sm:left-5">
{EMOJIS.map((emoji) => (
<button
key={emoji}
onClick={() => insertAtCursor(emoji)}
className="rounded-md p-1 text-lg transition-colors hover:bg-panel2"
>
{emoji}
</button>
))}
</div>
)}
{muted && (
<div className="mb-2 flex items-start gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-[11px] leading-relaxed text-amber-600 dark:text-amber-400">
<VolumeX size={13} className="mt-0.5 shrink-0" />
<span>
{mutedByAll
? '全员禁言中,仅群主和管理员可发言。'
: `你已被禁言,${groupDetail?.myMutedUntil ? formatMuteUntil(groupDetail.myMutedUntil) : ''}。`}
</span>
</div>
)}
{replyTo && !muted && (
<div className="mb-2 flex items-start gap-2 rounded-lg border border-line bg-panel2 px-2.5 py-2 text-xs">
<Quote size={13} className="mt-0.5 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-accent">
回复 {displayUserName(replyTo.sender)}
</p>
<p className="truncate text-muted">
{replyTo.deletedAt
? '消息已撤回'
: replyTo.type === 'IMAGE'
? '[图片]'
: replyTo.type === 'FILE'
? '[文件]'
: replyTo.content || '[消息]'}
</p>
</div>
<button
onClick={() => setReplyTo(null)}
className="shrink-0 rounded p-0.5 text-muted hover:text-fg"
title="取消引用"
>
<X size={13} />
</button>
</div>
)}
<div className="mb-1 flex items-center gap-0.5 overflow-x-auto text-muted">
{toolbar.map((item) => (
<button
key={item.key}
onClick={item.action}
disabled={muted || preview}
title={item.title}
className="shrink-0 rounded-md p-1.5 transition-colors hover:bg-panel2 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
{item.icon}
</button>
))}
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
<button
onClick={() => imageRef.current?.click()}
disabled={muted}
className="shrink-0 rounded-md p-1.5 text-muted transition-colors hover:bg-panel2 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
title="插入图片"
>
<ImagePlus size={16} />
</button>
<button
onClick={() => fileRef.current?.click()}
disabled={muted}
className="shrink-0 rounded-md p-1.5 text-muted transition-colors hover:bg-panel2 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
title="插入文件"
>
<Paperclip size={16} />
</button>
<span className="mx-0.5 h-4 w-px shrink-0 bg-line" />
<button
onClick={() => {
if (muted) return
setEmojiOpen((v) => !v)
setMention(null)
}}
disabled={muted || preview}
className={cn(
'shrink-0 rounded-md p-1.5 transition-colors hover:bg-panel2 disabled:cursor-not-allowed disabled:opacity-40',
emojiOpen ? 'text-accent' : 'text-muted hover:text-fg'
)}
title="表情"
>
<Smile size={16} />
</button>
{isGroup && (
<button
onClick={insertAt}
disabled={muted || preview}
className="shrink-0 rounded-md p-1.5 text-muted transition-colors hover:bg-panel2 hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
title="提及成员"
>
<AtSign size={16} />
</button>
)}
<button
onClick={() => setPreview((v) => !v)}
disabled={muted}
className={cn(
'ml-auto shrink-0 rounded-md px-2 py-1 text-[11px] transition-colors hover:bg-panel2 disabled:cursor-not-allowed disabled:opacity-40',
preview ? 'text-accent' : 'text-muted hover:text-fg'
)}
title={preview ? '返回编辑' : '预览 Markdown'}
>
<span className="flex items-center gap-1">
{preview ? <Pencil size={13} /> : <Eye size={13} />}
{preview ? '编辑' : '预览'}
</span>
</button>
</div>
<input
ref={imageRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
void uploadFiles([...(e.target.files ?? [])])
e.target.value = ''
}}
/>
<input
ref={fileRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
void uploadFiles([...(e.target.files ?? [])])
e.target.value = ''
}}
/>
<div className="mb-1 flex items-center justify-between gap-2 px-0.5 text-[10px] text-muted">
<span>支持 Markdown。图片插入 ![]( ),文件插入 []( )</span>
{uploading > 0 && (
<span className="flex shrink-0 items-center gap-1 text-accent">
<Loader2 size={12} className="animate-spin" /> 插入中 {uploading}
</span>
)}
</div>
<div className="flex items-end gap-1.5">
<div
className={cn(
'min-w-0 flex-1 rounded-xl border border-line bg-panel2 transition-colors focus-within:border-accent/60',
muted && 'opacity-60'
)}
>
{preview ? (
<div className="max-h-40 min-h-[42px] overflow-y-auto px-3 py-2.5 text-sm leading-relaxed">
{text.trim() ? (
<MarkdownMessage
content={text}
isOwn={false}
mentionNames={mentionNames}
/>
) : (
<span className="text-muted/70">没有可预览的内容</span>
)}
</div>
) : (
<textarea
ref={inputRef}
value={text}
rows={1}
maxLength={4000}
disabled={muted}
onChange={(e) => {
const value = e.target.value
setText(value)
syncMention(value, e.target.selectionStart ?? value.length)
if (value.trim() && !muted) emitTyping()
}}
onClick={(e) => syncMention(text, e.currentTarget.selectionStart ?? text.length)}
onKeyUp={(e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') return
syncMention(text, e.currentTarget.selectionStart ?? text.length)
}}
onPaste={(e) => {
const files = [...e.clipboardData.files]
if (files.length && !muted) {
e.preventDefault()
void uploadFiles(files)
}
}}
onKeyDown={(e) => {
if (mention && candidates.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setMentionIndex((activeIndex + 1) % candidates.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setMentionIndex((activeIndex - 1 + candidates.length) % candidates.length)
return
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault()
insertMention(candidates[activeIndex])
return
}
if (e.key === 'Escape') {
e.preventDefault()
setMention(null)
return
}
}
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault()
insertAtCursor(' ')
return
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
void send()
return
}
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
insertAtCursor('\n')
return
}
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
void send()
}
}}
placeholder={
muted
? '你已被禁言,暂时无法发送消息'
: isGroup
? '输入消息,支持 Markdown,Enter 发送,Shift+Enter 换行'
: '输入消息,支持 Markdown,Enter 发送,Shift+Enter 换行'
}
className="max-h-40 w-full resize-none bg-transparent px-3 py-2.5 text-sm leading-relaxed outline-none placeholder:text-muted/70 disabled:cursor-not-allowed"
/>
)}
</div>
<button
onClick={() => void send()}
disabled={muted || !canSend}
className="mb-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-accent text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-35"
title="发送"
>
<Send size={17} />
</button>
</div>
</div>
)
}
@@ -0,0 +1,220 @@
import { useMemo, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { cn } from '../lib/utils'
interface Props {
content: string
isOwn: boolean
mentionNames: string[]
onLightbox?: (url: string) => void
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function safeUrl(url: string | undefined): string | undefined {
if (!url) return url
const lower = url.trim().toLowerCase()
if (
lower.startsWith('javascript:') ||
lower.startsWith('vbscript:') ||
lower.startsWith('file:')
) {
return undefined
}
return url
}
function remarkMentions(tokens: string[], isOwn: boolean) {
const pattern = tokens.length
? new RegExp(`(${tokens.map(escapeRegExp).join('|')})`, 'g')
: null
return (tree: unknown) => {
if (!pattern) return
const tokenSet = new Set(tokens)
const className = isOwn
? 'rounded px-0.5 font-medium bg-white/20 text-white'
: 'rounded px-0.5 font-medium bg-accent/15 text-accent'
const walk = (node: unknown) => {
if (!node || typeof node !== 'object') return
const current = node as { type?: string; children?: unknown[] }
if (!Array.isArray(current.children)) return
if (current.type === 'code' || current.type === 'inlineCode') return
const next: unknown[] = []
for (const child of current.children) {
const item = child as { type?: string; value?: unknown }
if (item?.type === 'text' && typeof item.value === 'string') {
const parts = item.value.split(pattern)
for (const part of parts) {
if (!part) continue
if (tokenSet.has(part)) {
next.push({
type: 'mention',
data: {
hName: 'span',
hProperties: { className },
hChildren: [{ type: 'text', value: part }],
},
})
} else {
next.push({ type: 'text', value: part })
}
}
} else {
walk(child)
next.push(child)
}
}
current.children = next
}
walk(tree)
}
}
export function MarkdownMessage({ content, isOwn, mentionNames, onLightbox }: Props) {
const [brokenImages, setBrokenImages] = useState<Record<string, true>>({})
const tokens = useMemo(() => {
const names = [...new Set(['所有人', ...mentionNames].filter(Boolean))].sort(
(a, b) => b.length - a.length
)
return names.map((name) => `@${name}`)
}, [mentionNames])
const mentionPlugin = useMemo(() => remarkMentions(tokens, isOwn), [tokens, isOwn])
return (
<div
className={cn(
'markdown-body max-w-full min-w-0 break-words [word-break:break-word]',
isOwn ? 'markdown-own' : ''
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks, mentionPlugin]}
components={{
p: ({ children }) => <p className="mb-1.5 last:mb-0">{children}</p>,
a: ({ href, children }) => {
const safe = safeUrl(href)
if (!safe) return <span>{children}</span>
return (
<a
href={safe}
target="_blank"
rel="noreferrer noopener"
className={cn(
'underline decoration-1 underline-offset-2',
isOwn ? 'text-white' : 'text-accent'
)}
>
{children}
</a>
)
},
img: ({ src, alt }) => {
const url = typeof src === 'string' ? safeUrl(src) : undefined
if (!url) return <span className="opacity-70">{alt || '[图片]'}</span>
const broken = !!brokenImages[url]
if (broken) return <span className="opacity-70">{alt || '[图片]'}</span>
return (
<img
src={url}
alt={alt || ''}
loading="lazy"
decoding="async"
className="my-1 max-h-64 w-auto max-w-full cursor-zoom-in rounded-xl object-contain"
onError={() => setBrokenImages((prev) => ({ ...prev, [url]: true }))}
onClick={() => onLightbox?.(url)}
/>
)
},
code: ({ children, className }) => {
if (className) {
return (
<code
className={cn(
'block overflow-x-auto rounded-lg px-2.5 py-2 font-mono text-[12px]',
isOwn ? 'bg-black/20 text-white' : 'bg-panel2 text-fg'
)}
>
{children}
</code>
)
}
return (
<code
className={cn(
'rounded px-1 py-0.5 font-mono text-[12px]',
isOwn ? 'bg-black/20 text-white' : 'bg-panel2 text-fg'
)}
>
{children}
</code>
)
},
pre: ({ children }) => <pre className="my-1.5 last:mb-0">{children}</pre>,
ul: ({ children }) => (
<ul className="my-1 list-disc pl-5 marker:text-muted last:mb-0">{children}</ul>
),
ol: ({ children }) => (
<ol className="my-1 list-decimal pl-5 marker:text-muted last:mb-0">{children}</ol>
),
li: ({ children }) => <li className="my-0.5">{children}</li>,
blockquote: ({ children }) => (
<blockquote
className={cn(
'my-1 border-l-2 pl-2.5 opacity-80',
isOwn ? 'border-white/50' : 'border-accent/50'
)}
>
{children}
</blockquote>
),
h1: ({ children }) => <h1 className="mt-1 mb-1 text-base font-semibold">{children}</h1>,
h2: ({ children }) => <h2 className="mt-1 mb-1 text-[15px] font-semibold">{children}</h2>,
h3: ({ children }) => <h3 className="mt-1 mb-1 text-sm font-semibold">{children}</h3>,
h4: ({ children }) => <h4 className="mt-1 mb-1 text-sm font-semibold">{children}</h4>,
h5: ({ children }) => <h5 className="mt-1 mb-1 text-sm font-semibold">{children}</h5>,
h6: ({ children }) => <h6 className="mt-1 mb-1 text-sm font-semibold">{children}</h6>,
hr: () => <hr className={cn('my-2 border-t', isOwn ? 'border-white/25' : 'border-line')} />,
table: ({ children }) => (
<div className="my-1.5 max-w-full overflow-x-auto">
<table className="min-w-full border-collapse text-[12px]">{children}</table>
</div>
),
th: ({ children }) => (
<th
className={cn(
'border px-2 py-1 text-left font-semibold',
isOwn ? 'border-white/25' : 'border-line'
)}
>
{children}
</th>
),
td: ({ children }) => (
<td className={cn('border px-2 py-1', isOwn ? 'border-white/25' : 'border-line')}>
{children}
</td>
),
input: ({ checked, ...rest }) => (
<input
type="checkbox"
checked={checked}
readOnly
className="mr-1 align-middle accent-current"
{...rest}
/>
),
}}
>
{content}
</ReactMarkdown>
</div>
)
}
@@ -15,7 +15,7 @@ import {
Square,
Star,
} from 'lucide-react'
import type { Message } from '../types'
import type { Message, ReplyMessage } from '../types'
import { api } from '../lib/api'
import { cn, displayUserName, formatBytes, formatDateTime, isImage } from '../lib/utils'
import { useAuth } from '../stores/auth'
@@ -23,7 +23,10 @@ import { useChat } from '../stores/chat'
import { toastError, useToast } from '../stores/toast'
import { Avatar } from './Avatar'
import { useFloatingCard } from './FloatingUserCard'
import { MarkdownMessage } from './MarkdownMessage'
import { Modal } from './Modal'
import { QuoteViewer } from './QuoteViewer'
import { ReadReceiptDialog, type ReadMember } from './ReadReceiptDialog'
interface Props {
message: Message
@@ -33,37 +36,10 @@ interface Props {
readCount: number
readTotal: number
readers: string[]
readMembers: ReadMember[]
onLightbox: (url: string) => void
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function renderContent(content: string, isOwn: boolean, names: string[]) {
const mentionNames = [...new Set(['所有人', ...names].filter(Boolean))].sort(
(a, b) => b.length - a.length
)
if (!mentionNames.length) return content
const pattern = new RegExp(`(@(?:${mentionNames.map(escapeRegExp).join('|')}))`, 'g')
const tokens = new Set(mentionNames.map((name) => `@${name}`))
return content.split(pattern).map((part, index) =>
tokens.has(part) ? (
<span
key={index}
className={cn(
'rounded px-0.5 font-medium',
isOwn ? 'bg-white/20 text-white' : 'bg-accent/15 text-accent'
)}
>
{part}
</span>
) : (
<span key={index}>{part}</span>
)
)
}
export function MessageItem({
message,
showHeader,
@@ -71,6 +47,7 @@ export function MessageItem({
readCount,
readTotal,
readers,
readMembers,
onLightbox,
}: Props) {
const me = useAuth((s) => s.user)!
@@ -84,6 +61,8 @@ export function MessageItem({
const [busy, setBusy] = useState(false)
const [pinBusy, setPinBusy] = useState(false)
const [forwardOpen, setForwardOpen] = useState(false)
const [quoteStack, setQuoteStack] = useState<ReplyMessage[]>([])
const [readOpen, setReadOpen] = useState(false)
const toast = useToast((s) => s.push)
const isOwn = message.senderId === me.id
@@ -116,6 +95,10 @@ export function MessageItem({
sender.username,
sender.remark,
].filter((name): name is string => !!name)
// 上传后插入到 Markdown 正文里的附件不再重复显示附件卡片
const visibleAttachments = message.attachments.filter(
(a) => !message.content?.includes(a.url)
)
const openSenderCard = (x: number, y: number) => {
openCard({ userId: sender.id, role: senderRole, x, y })
}
@@ -262,13 +245,16 @@ export function MessageItem({
) : (
<>
{message.replyTo && (
<div
<button
type="button"
onClick={() => setQuoteStack([message.replyTo!])}
className={cn(
'mb-1.5 rounded-lg border-l-2 px-2 py-1 text-[11px] leading-relaxed',
'mb-1.5 block w-full rounded-lg border-l-2 px-2 py-1 text-left text-[11px] leading-relaxed transition-colors',
isOwn
? 'border-white/60 bg-white/10 text-white/80'
: 'border-accent/70 bg-panel2 text-muted'
? 'border-white/60 bg-white/10 text-white/80 hover:bg-white/15'
: 'border-accent/70 bg-panel2 text-muted hover:bg-line/50'
)}
title="点击查看引用的消息"
>
<p className="truncate font-medium">
回复 {displayUserName(message.replyTo.sender)}
@@ -282,11 +268,11 @@ export function MessageItem({
? '[文件]'
: message.replyTo.content || '[消息]'}
</p>
</div>
</button>
)}
{message.attachments.length > 0 && (
{visibleAttachments.length > 0 && (
<div className={cn('flex flex-col gap-1.5', message.content && 'mb-1.5')}>
{message.attachments.map((a) =>
{visibleAttachments.map((a) =>
isImage(a.mimeType) ? (
<button
key={a.id}
@@ -322,9 +308,12 @@ export function MessageItem({
</div>
)}
{message.content && (
<p className="whitespace-pre-wrap">
{renderContent(message.content, isOwn, mentionNames)}
</p>
<MarkdownMessage
content={message.content}
isOwn={isOwn}
mentionNames={mentionNames}
onLightbox={onLightbox}
/>
)}
</>
)}
@@ -403,21 +392,41 @@ export function MessageItem({
</div>
{isOwn && !message.pending && !message.failed && (
<span
className="mt-0.5 flex items-center gap-0.5 text-[10px] text-muted"
title={readers.length ? `已读:${readers.join('、')}` : undefined}
>
{readCount > 0 ? <CheckCheck size={12} /> : <Check size={12} />}
{readTotal <= 0
? '已发送'
: read
? readTotal > 1
? '全部已读'
: '已读'
: readCount > 0
? `已读 ${readCount}/${readTotal}`
: '未读'}
</span>
isGroup && readTotal > 0 ? (
<button
type="button"
onClick={() => setReadOpen(true)}
className="mt-0.5 flex items-center gap-0.5 text-[10px] text-muted transition-colors hover:text-accent"
title="查看谁已读、谁未读"
>
{readCount > 0 ? <CheckCheck size={12} /> : <Check size={12} />}
{readTotal <= 0
? '已发送'
: read
? readTotal > 1
? '全部已读'
: '已读'
: readCount > 0
? `已读 ${readCount}/${readTotal}`
: '未读'}
</button>
) : (
<span
className="mt-0.5 flex items-center gap-0.5 text-[10px] text-muted"
title={readers.length ? `已读:${readers.join('、')}` : undefined}
>
{readCount > 0 ? <CheckCheck size={12} /> : <Check size={12} />}
{readTotal <= 0
? '已发送'
: read
? readTotal > 1
? '全部已读'
: '已读'
: readCount > 0
? `已读 ${readCount}/${readTotal}`
: '未读'}
</span>
)
)}
</div>
@@ -523,6 +532,23 @@ export function MessageItem({
))}
</div>
</Modal>
<QuoteViewer
stack={quoteStack}
onClose={() => setQuoteStack([])}
onBack={() => setQuoteStack((prev) => prev.slice(0, -1))}
onPush={(reply) => setQuoteStack((prev) => [...prev, reply])}
onJump={(conversationId, messageId) =>
useChat.getState().jumpToMessage(conversationId, messageId)
}
onLightbox={onLightbox}
/>
<ReadReceiptDialog
open={readOpen}
onClose={() => setReadOpen(false)}
members={readMembers}
/>
</div>
)
}
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { ArrowDown, Loader2, Pin, PinOff, Send, Star, X } from 'lucide-react'
import { useAuth } from '../stores/auth'
import { useChat } from '../stores/chat'
import { displayUserName, formatDate, formatDateTime, isSameSender, sameDay } from '../lib/utils'
import { cn, displayUserName, formatDate, formatDateTime, isSameSender, sameDay } from '../lib/utils'
import { MessageItem } from './MessageItem'
import { ForwardDialog } from './ForwardDialog'
import { Modal } from './Modal'
@@ -23,6 +23,7 @@ export function MessageList({ conversationId }: { conversationId: string }) {
const pinned = useChat((s) => s.pinned[conversationId]) ?? []
const selecting = useChat((s) => s.selecting)
const selectedCount = useChat((s) => Object.keys(s.selected).length)
const highlightMessageId = useChat((s) => s.highlightMessageId)
const me = useAuth((s) => s.user)!
const containerRef = useRef<HTMLDivElement>(null)
@@ -52,6 +53,20 @@ export function MessageList({ conversationId }: { conversationId: string }) {
el.scrollTop = el.scrollHeight
}, [lastId, typingNames.join(',')])
useEffect(() => {
if (!highlightMessageId) return
const root = containerRef.current
if (!root) return
const target = root.querySelector<HTMLElement>(
`[data-message-id="${highlightMessageId}"]`
)
if (!target) return
target.scrollIntoView({ block: 'center', behavior: 'smooth' })
stickRef.current = false
const timer = window.setTimeout(() => useChat.getState().clearHighlight(), 2400)
return () => window.clearTimeout(timer)
}, [highlightMessageId, messages.length])
const onScroll = () => {
const el = containerRef.current
if (!el) return
@@ -80,19 +95,30 @@ export function MessageList({ conversationId }: { conversationId: string }) {
const readInfo = (messageId: string, isOwn: boolean) => {
if (!isOwn || !others.length) {
return { read: false, readCount: 0, readTotal: others.length, readers: [] as string[] }
return {
read: false,
readCount: 0,
readTotal: others.length,
readers: [] as string[],
members: [],
}
}
const readers = others
.filter((m) => {
const last = readState?.[m.user.id] ?? m.lastReadMessageId
return !!last && last >= messageId
})
.map((m) => displayUserName(m.user))
const members = others.map((m) => {
const last = readState?.[m.user.id] ?? m.lastReadMessageId
return {
id: m.user.id,
name: displayUserName(m.user),
avatarUrl: m.user.avatarUrl,
read: !!last && last >= messageId,
}
})
const readers = members.filter((m) => m.read).map((m) => m.name)
return {
read: readers.length === others.length,
read: readers.length === members.length,
readCount: readers.length,
readTotal: others.length,
readTotal: members.length,
readers,
members,
}
}
@@ -231,7 +257,15 @@ export function MessageList({ conversationId }: { conversationId: string }) {
const readInfoForMessage = readInfo(message.id, isOwn)
return (
<div key={message.id} id={`message-${message.id}`}>
<div
key={message.id}
id={`message-${message.id}`}
data-message-id={message.id}
className={cn(
'transition-colors duration-500',
highlightMessageId === message.id && 'bg-amber-400/10'
)}
>
{newDay && (
<div className="flex justify-center py-3">
<span className="rounded-full bg-panel2 px-2.5 py-0.5 text-[10px] text-muted">
@@ -247,6 +281,7 @@ export function MessageList({ conversationId }: { conversationId: string }) {
readCount={readInfoForMessage.readCount}
readTotal={readInfoForMessage.readTotal}
readers={readInfoForMessage.readers}
readMembers={readInfoForMessage.members}
onLightbox={setLightbox}
/>
</div>
File renamed without changes.
@@ -0,0 +1,171 @@
import { useState } from 'react'
import { ArrowLeft, CornerDownRight, LocateFixed } from 'lucide-react'
import type { ReplyMessage } from '../types'
import { cn, displayUserName, formatBytes, formatDateTime, isImage } from '../lib/utils'
import { toastError, useToast } from '../stores/toast'
import { Avatar } from './Avatar'
import { MarkdownMessage } from './MarkdownMessage'
import { Modal } from './Modal'
interface Props {
stack: ReplyMessage[]
onClose: () => void
onBack: () => void
onPush: (message: ReplyMessage) => void
onJump: (conversationId: string, messageId: string) => Promise<boolean>
onLightbox: (url: string) => void
}
export function QuoteViewer({ stack, onClose, onBack, onPush, onJump, onLightbox }: Props) {
const current = stack[stack.length - 1]
const [jumping, setJumping] = useState(false)
const toast = useToast((s) => s.push)
if (!current) return null
const jump = async () => {
if (jumping) return
setJumping(true)
try {
const ok = await onJump(current.conversationId, current.id)
if (ok) {
onClose()
} else {
toast('原消息不在当前已加载的聊天记录中', 'error')
}
} catch (err) {
toastError(err, '定位失败')
} finally {
setJumping(false)
}
}
return (
<Modal
open
onClose={onClose}
title={stack.length > 1 ? `引用消息 · 第 ${stack.length} 层` : '引用消息'}
width="max-w-xl"
footer={
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-muted">
{current.replyTo ? '点击下方引用可继续查看上一层引用' : ''}
</span>
<div className="flex gap-2">
<button
onClick={onClose}
className="rounded-lg px-3 py-2 text-sm text-muted hover:bg-panel2"
>
关闭
</button>
<button
onClick={() => void jump()}
disabled={jumping}
className="flex items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-sm font-medium text-white disabled:opacity-50"
>
<LocateFixed size={14} /> 定位到聊天
</button>
</div>
</div>
}
>
<div className="space-y-3">
{stack.length > 1 && (
<button
onClick={onBack}
className="flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted transition-colors hover:bg-panel2 hover:text-fg"
>
<ArrowLeft size={13} /> 返回上一层
</button>
)}
<div className="flex items-center gap-2.5">
<Avatar
id={current.senderId}
name={current.sender.username}
avatarUrl={current.sender.avatarUrl}
size={32}
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{displayUserName(current.sender)}</p>
<p className="text-[10px] text-muted">{formatDateTime(current.createdAt)}</p>
</div>
</div>
{current.deletedAt ? (
<p className="rounded-xl border border-line bg-panel2 px-3 py-2.5 text-sm text-muted italic">
消息已撤回
</p>
) : (
<div className="rounded-xl border border-line bg-panel2 px-3 py-2.5 text-sm leading-relaxed">
{current.type === 'FORWARD' ? (
<p className="text-xs text-muted">[聊天记录]</p>
) : current.content ? (
<MarkdownMessage
content={current.content}
isOwn={false}
mentionNames={[]}
onLightbox={onLightbox}
/>
) : (
<p className="text-xs text-muted">(无文字内容)</p>
)}
{current.attachments.length > 0 && (
<div className="mt-2 flex flex-col gap-1.5">
{current.attachments.map((a) =>
isImage(a.mimeType) ? (
<button
key={a.id}
onClick={() => onLightbox(a.url)}
className="overflow-hidden rounded-lg text-left"
>
<img
src={a.thumbUrl ?? a.url}
alt={a.fileName}
className="max-h-60 w-auto max-w-full object-contain"
/>
</button>
) : (
<a
key={a.id}
href={a.url}
className={cn(
'flex items-center justify-between gap-2 rounded-lg bg-panel px-2.5 py-2 text-xs transition-colors hover:bg-line/50'
)}
>
<span className="min-w-0 truncate">{a.fileName}</span>
<span className="shrink-0 text-[10px] text-muted">{formatBytes(a.size)}</span>
</a>
)
)}
</div>
)}
</div>
)}
{current.replyTo && (
<button
onClick={() => onPush(current.replyTo!)}
className="block w-full rounded-xl border border-accent/25 bg-accent-soft/40 px-3 py-2.5 text-left transition-colors hover:border-accent/50 hover:bg-accent-soft/60"
>
<p className="mb-1 flex items-center gap-1 text-[11px] font-medium text-accent">
<CornerDownRight size={12} />
引用 {displayUserName(current.replyTo.sender)}
</p>
<p className="line-clamp-3 text-xs leading-relaxed text-muted">
{current.replyTo.deletedAt
? '消息已撤回'
: current.replyTo.type === 'IMAGE'
? '[图片]'
: current.replyTo.type === 'FILE'
? '[文件]'
: current.replyTo.content || '[消息]'}
</p>
<p className="mt-1 text-[10px] text-accent/80">点击继续查看这条引用</p>
</button>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,96 @@
import { CheckCheck, EyeOff } from 'lucide-react'
import { Avatar } from './Avatar'
import { Modal } from './Modal'
export interface ReadMember {
id: string
name: string
avatarUrl?: string | null
read: boolean
}
interface Props {
open: boolean
onClose: () => void
members: ReadMember[]
}
export function ReadReceiptDialog({ open, onClose, members }: Props) {
const readers = members.filter((m) => m.read)
const unread = members.filter((m) => !m.read)
return (
<Modal
open={open}
onClose={onClose}
title="消息已读状态"
width="max-w-md"
footer={
<div className="flex justify-end">
<button
onClick={onClose}
className="rounded-lg px-3 py-2 text-sm text-muted hover:bg-panel2"
>
关闭
</button>
</div>
}
>
<div className="space-y-4">
<section>
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-accent">
<CheckCheck size={14} />
已读 {readers.length}/{members.length}
</div>
{readers.length > 0 ? (
<div className="divide-y divide-line overflow-hidden rounded-xl border border-line">
{readers.map((member) => (
<div key={member.id} className="flex items-center gap-2.5 px-3 py-2">
<Avatar
id={member.id}
name={member.name}
avatarUrl={member.avatarUrl}
size={28}
/>
<span className="min-w-0 flex-1 truncate text-xs">{member.name}</span>
<span className="text-[10px] text-accent">已读</span>
</div>
))}
</div>
) : (
<p className="rounded-xl border border-line bg-panel2 px-3 py-2.5 text-xs text-muted">
还没有人读这条消息
</p>
)}
</section>
<section>
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-muted">
<EyeOff size={14} />
未读 {unread.length}/{members.length}
</div>
{unread.length > 0 ? (
<div className="divide-y divide-line overflow-hidden rounded-xl border border-line">
{unread.map((member) => (
<div key={member.id} className="flex items-center gap-2.5 px-3 py-2">
<Avatar
id={member.id}
name={member.name}
avatarUrl={member.avatarUrl}
size={28}
/>
<span className="min-w-0 flex-1 truncate text-xs">{member.name}</span>
<span className="text-[10px] text-muted">未读</span>
</div>
))}
</div>
) : (
<p className="rounded-xl border border-line bg-panel2 px-3 py-2.5 text-xs text-muted">
所有成员都已读
</p>
)}
</section>
</div>
</Modal>
)
}
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more