feat: bans system, voice moderation, and federated space settings fixes
Add ban/unban functionality with BansPanel in space settings, voice moderation context menu (mute/deafen/disconnect), and fix federated space settings panels to use origin-aware API client. Show domain indicators for federated members in MembersPanel.
This commit is contained in:
@@ -54,6 +54,8 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
const myChPerms = channelPermissions.get(message.channelId);
|
||||
const canManageMessages = hasPermissionBit(myChPerms, PermissionBits.MANAGE_MESSAGES);
|
||||
const canDelete = isAuthor || canManageMessages;
|
||||
const isDmMessage = !!(message as any).dmChannelId || !message.channelId;
|
||||
const canAddReactions = isDmMessage || hasPermissionBit(myChPerms, PermissionBits.ADD_REACTIONS);
|
||||
|
||||
const addReaction = useChatStore((s) => s.addReaction);
|
||||
const removeReaction = useChatStore((s) => s.removeReaction);
|
||||
@@ -66,7 +68,7 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
const hasReacted = message.reactions?.some(r => isOwnReaction(r) && r.emoji === emoji);
|
||||
if (hasReacted) {
|
||||
removeReaction(message.id, emoji);
|
||||
} else {
|
||||
} else if (canAddReactions) {
|
||||
addReaction(message.id, emoji);
|
||||
}
|
||||
};
|
||||
@@ -324,17 +326,19 @@ export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
|
||||
{/* Action buttons on hover */}
|
||||
{isHovered && !isEditing && (
|
||||
<div className="absolute -top-[18px] right-4 flex items-center glass rounded-[10px] overflow-hidden z-10 h-8">
|
||||
<div className="flex items-center px-1 border-r border-white/[0.06] h-full">
|
||||
{['👍', '❤️', '😂', '😮'].map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => toggleReaction(emoji)}
|
||||
className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{canAddReactions && (
|
||||
<div className="flex items-center px-1 border-r border-white/[0.06] h-full">
|
||||
{['👍', '❤️', '😂', '😮'].map(emoji => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => toggleReaction(emoji)}
|
||||
className="p-1 hover:bg-interactive-hover rounded transition-colors text-[16px] leading-none"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setReplyTo(message)}
|
||||
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '.
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
import { MentionPopover } from './MentionPopover';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import type { MemberWithUser } from '@backspace/shared';
|
||||
|
||||
interface MessageInputProps {
|
||||
@@ -31,6 +32,12 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
||||
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
|
||||
const isDm = isDmChannel(channelId);
|
||||
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
|
||||
const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES);
|
||||
|
||||
// Auto-focus textarea on channel navigation
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
@@ -230,6 +237,16 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
|
||||
};
|
||||
|
||||
if (!canSendMessages) {
|
||||
return (
|
||||
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
|
||||
<div className="flex items-center justify-center py-[14px] px-4">
|
||||
<span className="text-txt-tertiary text-[14px]">You do not have permission to send messages in this channel</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
|
||||
<TypingIndicator channelId={channelId} />
|
||||
@@ -252,8 +269,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
<div
|
||||
ref={inputContainerRef}
|
||||
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={canAttachFiles ? handleDrop : undefined}
|
||||
onDragOver={canAttachFiles ? handleDragOver : undefined}
|
||||
>
|
||||
{/* Mention autocomplete popover */}
|
||||
{mentionState && filteredMembers.length > 0 && (
|
||||
@@ -299,15 +316,17 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
|
||||
<div className="flex items-center pl-[10px] pr-1">
|
||||
{/* File attach button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
|
||||
title="Attach file"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{canAttachFiles && (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
|
||||
title="Attach file"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -328,7 +347,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
value={content}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onPaste={canAttachFiles ? handlePaste : undefined}
|
||||
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
|
||||
className="flex-1 py-[10px] px-1 bg-transparent text-txt-primary placeholder-txt-tertiary/60 outline-none resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
||||
rows={1}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../stores/authStore';
|
||||
import { useSocialStore } from '../../stores/socialStore';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import type { MessageWithUser } from '@backspace/shared';
|
||||
|
||||
const EMPTY_MESSAGES: MessageWithUser[] = [];
|
||||
@@ -53,9 +54,16 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
const prevMessagesLength = useRef(0);
|
||||
const ackTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Permission check: DM channels always allow history; space channels check READ_MESSAGE_HISTORY
|
||||
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
|
||||
const isDm = isDmChannel(channelId);
|
||||
const canReadHistory = isDm || hasPermissionBit(channelPerms, PermissionBits.READ_MESSAGE_HISTORY);
|
||||
|
||||
useEffect(() => {
|
||||
loadMessages(channelId);
|
||||
}, [channelId, loadMessages]);
|
||||
if (canReadHistory) {
|
||||
loadMessages(channelId);
|
||||
}
|
||||
}, [channelId, loadMessages, canReadHistory]);
|
||||
|
||||
// Ack channel when messages load or when new messages arrive while near bottom
|
||||
useEffect(() => {
|
||||
@@ -135,6 +143,14 @@ export function MessageList({ channelId }: MessageListProps) {
|
||||
}
|
||||
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]);
|
||||
|
||||
if (!canReadHistory) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-txt-tertiary text-[14px]">You do not have permission to view message history in this channel</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && messages.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
|
||||
Reference in New Issue
Block a user