feat: chat — group settings (rename, photo, add/remove members), fix mention underline style
This commit is contained in:
2
backend/migrations/077_chat_room_avatar.sql
Normal file
2
backend/migrations/077_chat_room_avatar.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Group room avatar
|
||||
ALTER TABLE chat_rooms ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||
@@ -79,7 +79,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const userId = request.user.sub
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT r.id, r.type, r.name,
|
||||
`SELECT r.id, r.type, r.name, r.avatar_url,
|
||||
(SELECT COUNT(*) FROM chat_messages m
|
||||
WHERE m.room_id = r.id AND m.deleted_at IS NULL
|
||||
AND m.created_at > COALESCE(
|
||||
@@ -367,7 +367,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// ── PATCH group room (rename / add/remove members) ────────────────────────
|
||||
|
||||
fastify.patch<RoomParam & { Body: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] } }>(
|
||||
fastify.patch<RoomParam & { Body: { name?: string; avatarUrl?: string; avatar_url?: string; addMemberIds?: string[]; add_member_ids?: string[]; removeMemberIds?: string[]; remove_member_ids?: string[] } }>(
|
||||
'/api/hotels/:slug/chat/rooms/:roomId/group',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
async (request, reply) => {
|
||||
@@ -375,10 +375,20 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
|
||||
return reply.code(403).send({ error: 'Forbidden' })
|
||||
|
||||
const { name, addMemberIds = [], removeMemberIds = [] } = request.body
|
||||
const b = request.body as Record<string, unknown>
|
||||
const name = b.name as string | undefined
|
||||
const avatarUrl = (b.avatar_url ?? b.avatarUrl) as string | undefined
|
||||
const addMemberIds: string[] = Array.isArray(b.add_member_ids) ? b.add_member_ids as string[]
|
||||
: Array.isArray(b.addMemberIds) ? b.addMemberIds as string[] : []
|
||||
const removeMemberIds: string[] = Array.isArray(b.remove_member_ids) ? b.remove_member_ids as string[]
|
||||
: Array.isArray(b.removeMemberIds) ? b.removeMemberIds as string[] : []
|
||||
|
||||
if (name) {
|
||||
await db.query('UPDATE chat_rooms SET name = $1 WHERE id = $2', [name.trim(), roomId])
|
||||
}
|
||||
if (avatarUrl !== undefined) {
|
||||
await db.query('UPDATE chat_rooms SET avatar_url = $1 WHERE id = $2', [avatarUrl || null, roomId])
|
||||
}
|
||||
if (addMemberIds.length > 0) {
|
||||
const vals = addMemberIds.map((_, i) => `($1, $${i + 2})`).join(', ')
|
||||
await db.query(
|
||||
|
||||
@@ -105,17 +105,14 @@ interface ToastNotif {
|
||||
id: string; roomId: string; roomName: string; senderName: string; text: string
|
||||
}
|
||||
|
||||
function renderWithMentions(text: string, myName?: string) {
|
||||
function renderWithMentions(text: string, myName?: string, isOwn = false) {
|
||||
const parts = text.split(/(@\S+)/g)
|
||||
if (parts.length === 1) return <>{text}</>
|
||||
const myFirst = myName?.split(' ')[0]?.toLowerCase()
|
||||
return <>
|
||||
{parts.map((part, i) => {
|
||||
if (!part.startsWith('@')) return <span key={i}>{part}</span>
|
||||
const mentioned = part.slice(1).toLowerCase()
|
||||
const isMe = myFirst && mentioned.startsWith(myFirst)
|
||||
return (
|
||||
<span key={i} className={cn('font-semibold rounded px-0.5', isMe ? 'bg-amber-100 text-amber-700 dark:bg-yellow-300/20 dark:text-yellow-300' : 'text-brand-600 dark:text-brand-300')}>
|
||||
<span key={i} className={cn('underline underline-offset-2', isOwn ? 'decoration-white/70' : 'decoration-current')}>
|
||||
{part}
|
||||
</span>
|
||||
)
|
||||
@@ -125,7 +122,7 @@ function renderWithMentions(text: string, myName?: string) {
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type View = 'rooms' | 'messages' | 'search' | 'settings' | 'create-group'
|
||||
type View = 'rooms' | 'messages' | 'search' | 'settings' | 'create-group' | 'group-info'
|
||||
|
||||
export function ChatWidget() {
|
||||
const { user } = useAuth()
|
||||
@@ -186,6 +183,11 @@ export function ChatWidget() {
|
||||
const [groupMemberIds, setGroupMemberIds] = useState<string[]>([])
|
||||
const [creatingGroup, setCreatingGroup] = useState(false)
|
||||
|
||||
// Group info edit
|
||||
const [editGroupName, setEditGroupName] = useState('')
|
||||
const [savingGroup, setSavingGroup] = useState(false)
|
||||
const groupAvatarInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const widgetRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -316,10 +318,10 @@ export function ChatWidget() {
|
||||
if (allUsers.length === 0) api.users.list(slug).then(setAllUsers).catch(() => {/**/})
|
||||
}, [view, slug, allUsers.length])
|
||||
|
||||
// Load chat members (all roles) for @mentions and create-group
|
||||
// Load chat members (all roles) for @mentions, create-group, group-info
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
if (view === 'create-group' || (view === 'messages' && activeRoom?.type === 'general')) {
|
||||
if (view === 'create-group' || view === 'group-info' || (view === 'messages' && activeRoom?.type === 'general')) {
|
||||
if (chatMembers.length === 0) api.chat.listMembers(slug).then(setChatMembers).catch(() => {/**/})
|
||||
}
|
||||
}, [view, slug, activeRoom?.type, chatMembers.length])
|
||||
@@ -426,7 +428,7 @@ export function ChatWidget() {
|
||||
const { roomId } = await api.chat.openDirect(slug, targetUser.id)
|
||||
const data = await api.chat.listRooms(slug)
|
||||
setRooms(data)
|
||||
await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserRole: null, otherUserId: targetUser.id, otherUserLastRead: null, memberCount: 0, memberNames: null })
|
||||
await openRoom({ id: roomId, type: 'direct', name: null, unreadCount: 0, lastMessage: null, lastMessageAt: null, lastSender: null, otherUserName: targetUser.name, otherUserRole: null, otherUserId: targetUser.id, otherUserLastRead: null, memberCount: 0, memberNames: null, avatarUrl: null })
|
||||
setSearchQuery('')
|
||||
} catch { /**/ }
|
||||
}
|
||||
@@ -525,6 +527,35 @@ export function ChatWidget() {
|
||||
finally { setLoadingOlder(false) }
|
||||
}
|
||||
|
||||
const openGroupInfo = () => {
|
||||
if (!activeRoom) return
|
||||
setEditGroupName(activeRoom.name ?? '')
|
||||
setView('group-info')
|
||||
}
|
||||
|
||||
const handleGroupAvatarUpload = async (file: File) => {
|
||||
if (!activeRoom) return
|
||||
try {
|
||||
const { url } = await api.chat.uploadImage(file)
|
||||
await api.chat.updateGroup(slug, activeRoom.id, { avatarUrl: url })
|
||||
setActiveRoom(prev => prev ? { ...prev, avatarUrl: url } : prev)
|
||||
setRooms(prev => prev.map(r => r.id === activeRoom.id ? { ...r, avatarUrl: url } : r))
|
||||
} catch { /**/ }
|
||||
}
|
||||
|
||||
const handleSaveGroupInfo = async (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => {
|
||||
if (!activeRoom || savingGroup) return
|
||||
setSavingGroup(true)
|
||||
try {
|
||||
await api.chat.updateGroup(slug, activeRoom.id, patch)
|
||||
const data = await api.chat.listRooms(slug)
|
||||
setRooms(data)
|
||||
const updated = data.find(r => r.id === activeRoom.id)
|
||||
if (updated) setActiveRoom(updated)
|
||||
} catch { /**/ }
|
||||
finally { setSavingGroup(false) }
|
||||
}
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
if (!groupName.trim() || groupMemberIds.length === 0 || creatingGroup) return
|
||||
setCreatingGroup(true)
|
||||
@@ -550,6 +581,7 @@ export function ChatWidget() {
|
||||
else if (view === 'search') { setView('rooms'); setSearchQuery('') }
|
||||
else if (view === 'settings') setView('rooms')
|
||||
else if (view === 'create-group') { setView('rooms'); setGroupName(''); setGroupMemberIds([]) }
|
||||
else if (view === 'group-info') { setView('messages') }
|
||||
}
|
||||
|
||||
const roomDisplayName = (room: ChatRoom) =>
|
||||
@@ -605,6 +637,7 @@ export function ChatWidget() {
|
||||
{view === 'search' && 'Поиск'}
|
||||
{view === 'settings' && 'Настройки чата'}
|
||||
{view === 'create-group' && 'Новая группа'}
|
||||
{view === 'group-info' && 'Настройки группы'}
|
||||
</p>
|
||||
{/* Online dot in messages header */}
|
||||
{view === 'messages' && activeRoom && isOtherOnline(activeRoom) && (
|
||||
@@ -643,10 +676,17 @@ export function ChatWidget() {
|
||||
</div>
|
||||
)}
|
||||
{view === 'messages' && activeRoom?.type !== 'notifications' && (
|
||||
<button onClick={() => { setRoomSearchOpen(v => !v); setTimeout(() => roomSearchInputRef.current?.focus(), 50) }}
|
||||
className={cn('p-1.5 rounded-lg transition-colors shrink-0', roomSearchOpen ? 'bg-white/30' : 'hover:bg-white/20')}>
|
||||
<Search size={15} />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button onClick={() => { setRoomSearchOpen(v => !v); setTimeout(() => roomSearchInputRef.current?.focus(), 50) }}
|
||||
className={cn('p-1.5 rounded-lg transition-colors shrink-0', roomSearchOpen ? 'bg-white/30' : 'hover:bg-white/20')}>
|
||||
<Search size={15} />
|
||||
</button>
|
||||
{activeRoom?.type === 'group' && (
|
||||
<button onClick={openGroupInfo} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors shrink-0" title="Настройки группы">
|
||||
<Settings size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -814,6 +854,22 @@ export function ChatWidget() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Group Info ── */}
|
||||
{view === 'group-info' && activeRoom && (
|
||||
<GroupInfoView
|
||||
room={activeRoom}
|
||||
allMembers={chatMembers}
|
||||
currentUserId={user?.id ?? ''}
|
||||
saving={savingGroup}
|
||||
editName={editGroupName}
|
||||
onEditName={setEditGroupName}
|
||||
onAvatarClick={() => groupAvatarInputRef.current?.click()}
|
||||
onSave={handleSaveGroupInfo}
|
||||
/>
|
||||
)}
|
||||
<input ref={groupAvatarInputRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden"
|
||||
onChange={e => { const f = e.target.files?.[0]; if (f) void handleGroupAvatarUpload(f); e.target.value = '' }} />
|
||||
|
||||
{/* ── Messages ── */}
|
||||
{view === 'messages' && (
|
||||
<>
|
||||
@@ -1155,7 +1211,7 @@ function MessageBubble({ msg, isOwn, isEditing, editText, isRead, currentUserNam
|
||||
)}
|
||||
{msg.text && (
|
||||
<p className={cn('px-3 py-2', isSystem ? 'text-amber-900 dark:text-amber-200' : isOwn ? 'text-white' : 'text-slate-800 dark:text-slate-200')}>
|
||||
{renderWithMentions(msg.text, currentUserName)}
|
||||
{renderWithMentions(msg.text, currentUserName, isOwn)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1300,6 +1356,147 @@ function ToggleRow({ icon, label, checked, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ── GroupInfoView ──────────────────────────────────────────────────────────
|
||||
|
||||
function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEditName, onAvatarClick, onSave }: {
|
||||
room: ChatRoom
|
||||
allMembers: { id: string; name: string; role: string }[]
|
||||
currentUserId: string
|
||||
saving: boolean
|
||||
editName: string
|
||||
onEditName: (v: string) => void
|
||||
onAvatarClick: () => void
|
||||
onSave: (patch: { name?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) => void
|
||||
}) {
|
||||
const roleLabels: Record<string, string> = {
|
||||
hotel_admin: 'Администратор', manager: 'Менеджер', housekeeper: 'Горничная',
|
||||
receptionist: 'Ресепшн', accountant: 'Бухгалтер', security: 'Охрана', technician: 'Техник',
|
||||
}
|
||||
const [addingMembers, setAddingMembers] = useState(false)
|
||||
const [selectedAdd, setSelectedAdd] = useState<string[]>([])
|
||||
|
||||
// Current member ids from allMembers that are in this room (from memberNames we don't have IDs, so use a different approach)
|
||||
// We track removes locally until saved
|
||||
const [removedIds, setRemovedIds] = useState<string[]>([])
|
||||
|
||||
const currentMemberIds = allMembers
|
||||
.filter(u => room.memberNames?.some(n => n === u.name) || u.id === currentUserId)
|
||||
.map(u => u.id)
|
||||
.filter(id => !removedIds.includes(id))
|
||||
|
||||
const removableMemberIds = allMembers.filter(u =>
|
||||
(room.memberNames?.some(n => n === u.name) || u.id === currentUserId) && u.id !== currentUserId
|
||||
).map(u => u.id)
|
||||
|
||||
const notMembers = allMembers.filter(u =>
|
||||
u.id !== currentUserId &&
|
||||
!currentMemberIds.includes(u.id) &&
|
||||
!removedIds.includes(u.id)
|
||||
)
|
||||
|
||||
const handleRemove = (userId: string) => {
|
||||
setRemovedIds(p => [...p, userId])
|
||||
onSave({ removeMemberIds: [userId] })
|
||||
}
|
||||
|
||||
const handleAddMembers = () => {
|
||||
if (selectedAdd.length === 0) { setAddingMembers(false); return }
|
||||
onSave({ addMemberIds: selectedAdd })
|
||||
setSelectedAdd([])
|
||||
setAddingMembers(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Avatar + name */}
|
||||
<div className="flex flex-col items-center gap-3 px-4 pt-5 pb-4 border-b border-slate-100 dark:border-slate-700 shrink-0">
|
||||
<button onClick={onAvatarClick} className="relative group">
|
||||
{room.avatarUrl ? (
|
||||
<img src={room.avatarUrl} alt={room.name ?? ''} className="w-16 h-16 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center">
|
||||
<Users size={28} className="text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
)}
|
||||
<span className="absolute inset-0 rounded-full bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 w-full max-w-[200px]">
|
||||
<input value={editName} onChange={e => onEditName(e.target.value)}
|
||||
className="flex-1 text-center text-sm font-semibold bg-transparent border-b border-slate-300 dark:border-slate-600 focus:border-brand-500 outline-none py-0.5 text-slate-800 dark:text-slate-100"
|
||||
placeholder="Название группы" />
|
||||
<button onClick={() => onSave({ name: editName })} disabled={!editName.trim() || saving}
|
||||
className="text-brand-600 hover:text-brand-700 disabled:opacity-30 transition-colors text-xs font-medium shrink-0">
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-4 pt-3 pb-1 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
|
||||
Участники · {currentMemberIds.length}
|
||||
</p>
|
||||
<button onClick={() => { setAddingMembers(v => !v); setSelectedAdd([]) }}
|
||||
className="text-xs text-brand-600 hover:text-brand-700 font-medium flex items-center gap-1">
|
||||
<UserPlus size={12} /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add members panel */}
|
||||
{addingMembers && notMembers.length > 0 && (
|
||||
<div className="mx-3 mb-2 rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
|
||||
{notMembers.map(u => (
|
||||
<button key={u.id} onClick={() => setSelectedAdd(p => p.includes(u.id) ? p.filter(id => id !== u.id) : [...p, u.id])}
|
||||
className={cn('w-full flex items-center gap-2 px-3 py-2 text-left transition-colors border-b border-slate-100 dark:border-slate-700 last:border-0',
|
||||
selectedAdd.includes(u.id) ? 'bg-brand-50 dark:bg-brand-900/20' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50')}>
|
||||
<div className="relative shrink-0">
|
||||
<Avatar name={u.name} size={28} />
|
||||
{selectedAdd.includes(u.id) && (
|
||||
<span className="absolute inset-0 rounded-full bg-brand-600/80 flex items-center justify-center text-white text-[10px] font-bold">✓</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-slate-800 dark:text-slate-200 truncate">{u.name}</p>
|
||||
<p className="text-xs text-slate-400">{roleLabels[u.role] ?? u.role}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{selectedAdd.length > 0 && (
|
||||
<button onClick={handleAddMembers}
|
||||
className="w-full py-2 bg-brand-600 hover:bg-brand-700 text-white text-xs font-medium transition-colors flex items-center justify-center gap-1.5">
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Добавить {selectedAdd.length} чел.
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current members */}
|
||||
{allMembers.filter(u => currentMemberIds.includes(u.id)).map(u => (
|
||||
<div key={u.id} className="flex items-center gap-3 px-4 py-2.5 border-b border-slate-100 dark:border-slate-700/50">
|
||||
<Avatar name={u.name} size={32} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">
|
||||
{u.name} {u.id === currentUserId && <span className="text-xs text-slate-400 font-normal">(вы)</span>}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">{roleLabels[u.role] ?? u.role}</p>
|
||||
</div>
|
||||
{u.id !== currentUserId && (
|
||||
<button onClick={() => handleRemove(u.id)} title="Удалить из группы"
|
||||
className="shrink-0 p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomSkeleton({ icon, bg, label }: { icon: React.ReactNode; bg: string; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-slate-100 dark:border-slate-700/50 animate-pulse">
|
||||
|
||||
@@ -360,6 +360,8 @@ export const api = {
|
||||
req<{ id: string; name: string; role: string }[]>('GET', `/api/hotels/${slug}/chat/members`),
|
||||
createGroup: (slug: string, name: string, memberIds: string[]) =>
|
||||
req<{ roomId: string }>('POST', `/api/hotels/${slug}/chat/group`, { name, memberIds }),
|
||||
updateGroup: (slug: string, roomId: string, patch: { name?: string; avatarUrl?: string; addMemberIds?: string[]; removeMemberIds?: string[] }) =>
|
||||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/group`, patch),
|
||||
editMessage: (slug: string, roomId: string, msgId: string, text: string) =>
|
||||
req<ChatMessage>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/messages/${msgId}`, { text }),
|
||||
deleteMessage: (slug: string, roomId: string, msgId: string) =>
|
||||
@@ -1351,6 +1353,7 @@ export interface ChatRoom {
|
||||
otherUserLastRead: string | null
|
||||
memberCount: number
|
||||
memberNames: string[] | null
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
export interface ChatReaction {
|
||||
|
||||
Reference in New Issue
Block a user