Archived
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:
1 parent
5fdf324a38
commit
9dfc96cd67
135 files changed
+3169
-591
No files matched your search
+1
-13
@@ -1,13 +1 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
logs/
|
||||
storage/
|
||||
.mysql/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
apps/*/dist/
|
||||
apps/*/node_modules/
|
||||
*.tsbuildinfo
|
||||
desktop/
|
||||
@@ -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.
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
logs/
|
||||
storage/
|
||||
.mysql/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
apps/*/dist/
|
||||
apps/*/node_modules/
|
||||
*.tsbuildinfo
|
||||
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.
File renamed without changes.
File renamed without changes.
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.
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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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.
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.
File renamed without changes.
File renamed without changes.
+129
-5
@@ -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.
File renamed without changes.
File renamed without changes.
@@ -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(``)
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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>
|
||||
)
|
||||
}
|
||||
File renamed without changes.
+80
-54
@@ -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>
|
||||
)
|
||||
}
|
||||
+46
-11
@@ -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.
File renamed without changes.
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>
|
||||
)
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user