diff --git a/.gitignore b/.gitignore index 73f482a..aee616c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1 @@ -node_modules/ -dist/ -.env -.env.local -*.log -logs/ -storage/ -.mysql/ -.DS_Store -Thumbs.db -apps/*/dist/ -apps/*/node_modules/ -*.tsbuildinfo +desktop/ diff --git a/apps/web/src/components/Composer.tsx b/apps/web/src/components/Composer.tsx deleted file mode 100644 index 427228a..0000000 --- a/apps/web/src/components/Composer.tsx +++ /dev/null @@ -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([]) - 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(null) - const fileRef = useRef(null) - const typingAt = useRef(0) - const stopTimer = useRef(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(() => { - 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 ( -
{ - 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 && ( -
-

- 选择要提及的成员 -

-
- {candidates.map((candidate, index) => ( - - ))} -
-
- )} - - {emojiOpen && !muted && ( -
- {EMOJIS.map((emoji) => ( - - ))} -
- )} - - {muted && ( -
- - - {mutedByAll - ? '全员禁言中,仅群主和管理员可发言。' - : `你已被禁言,${groupDetail?.myMutedUntil ? formatMuteUntil(groupDetail.myMutedUntil) : ''}。`} - -
- )} - - {replyTo && !muted && ( -
- -
-

- 回复 {displayUserName(replyTo.sender)} -

-

- {replyTo.deletedAt - ? '消息已撤回' - : replyTo.type === 'IMAGE' - ? '[图片]' - : replyTo.type === 'FILE' - ? '[文件]' - : replyTo.content || '[消息]'} -

-
- -
- )} - - {(attachments.length > 0 || uploading > 0) && ( -
- {attachments.map((a) => ( -
- {a.mimeType.startsWith('image/') ? ( - - ) : ( - - FILE - - )} - {a.fileName} - {formatBytes(a.size)} - -
- ))} - {uploading > 0 && ( -
- 上传中 {uploading} -
- )} -
- )} - -
- - { - void uploadFiles([...(e.target.files ?? [])]) - e.target.value = '' - }} - /> - - - - {isGroup && ( - - )} - -
-