feat(web): MessageInput uses composerStore + transferStore (eager upload, FS handles)
This commit is contained in:
@@ -1,14 +1,19 @@
|
|||||||
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { isDmChannel, getChannelOrigin, getApiForOrigin, useSpaceStore } from '../../stores/spaceStore';
|
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { MentionPopover } from './MentionPopover';
|
import { MentionPopover } from './MentionPopover';
|
||||||
import { TypingIndicator } from './TypingIndicator';
|
import { TypingIndicator } from './TypingIndicator';
|
||||||
import { InputPopover, type InputPopoverTab } from './InputPopover';
|
import { InputPopover, type InputPopoverTab } from './InputPopover';
|
||||||
|
import { AttachmentProgress } from './AttachmentProgress';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
||||||
import { useSettingsStore } from '../../stores/settingsStore';
|
import { useSettingsStore } from '../../stores/settingsStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { useComposerStore } from '../../stores/composerStore';
|
||||||
|
import { useTransferStore, type Transfer } from '../../stores/transferStore';
|
||||||
|
import { usePendingMessageStore } from '../../stores/pendingMessageStore';
|
||||||
|
import { putHandle, supportsFsHandles, supportsDnDHandles } from '../../utils/idbHandles';
|
||||||
|
|
||||||
interface MessageInputProps {
|
interface MessageInputProps {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
@@ -21,22 +26,55 @@ interface MentionState {
|
|||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default tus expiration window if a transfer doesn't yet have one (24h).
|
||||||
|
const DEFAULT_TUS_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function makeFileHandleKey(): string {
|
||||||
|
return `up-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||||
const [content, setContent] = useState('');
|
// Composer state lives in composerStore (per-channel, persisted)
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const composerState = useComposerStore((s) => s.states.get(channelId)) ?? {
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
draftText: '',
|
||||||
const [uploadProgress, setUploadProgress] = useState<Map<number, number>>(new Map());
|
replyTo: null,
|
||||||
const addToast = useUIStore((s) => s.addToast);
|
stagedTransferIds: [] as string[],
|
||||||
|
};
|
||||||
|
const setDraft = useComposerStore((s) => s.setDraft);
|
||||||
|
const composerSetReplyTo = useComposerStore((s) => s.setReplyTo);
|
||||||
|
const attachToComposer = useComposerStore((s) => s.attach);
|
||||||
|
const removeStaged = useComposerStore((s) => s.removeStaged);
|
||||||
|
const clearComposer = useComposerStore((s) => s.clear);
|
||||||
|
|
||||||
|
// Transfer state — subscribe to the whole map so progress/state updates re-render
|
||||||
|
const transfers = useTransferStore((s) => s.transfers);
|
||||||
|
const startUpload = useTransferStore((s) => s.startUpload);
|
||||||
|
const pauseUpload = useTransferStore((s) => s.pauseUpload);
|
||||||
|
const resumeUpload = useTransferStore((s) => s.resumeUpload);
|
||||||
|
const abortUpload = useTransferStore((s) => s.abortUpload);
|
||||||
|
|
||||||
|
// UI-only state stays local
|
||||||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||||
const [activePopover, setActivePopover] = useState<InputPopoverTab | null>(null);
|
const [activePopover, setActivePopover] = useState<InputPopoverTab | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const inputContainerRef = useRef<HTMLDivElement>(null);
|
const inputContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Object URLs for current-session image previews. transferStore doesn't hold
|
||||||
|
// the raw File, so previews only exist for files picked in this session
|
||||||
|
// (after reload, persisted transfers fall back to the icon placeholder).
|
||||||
|
const previewUrlsRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||||
const replyTo = useChatStore((s) => s.replyTo);
|
const chatReplyTo = useChatStore((s) => s.replyTo);
|
||||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
const chatSetReplyTo = useChatStore((s) => s.setReplyTo);
|
||||||
const members = useSpaceStore((s) => s.members);
|
const members = useSpaceStore((s) => s.members);
|
||||||
|
|
||||||
|
const addToast = useUIStore((s) => s.addToast);
|
||||||
|
const appendBubble = usePendingMessageStore((s) => s.append);
|
||||||
|
|
||||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
@@ -48,24 +86,87 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
|
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
|
||||||
const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES);
|
const canAttachFiles = isDm || hasPermissionBit(channelPerms, PermissionBits.ATTACH_FILES);
|
||||||
|
|
||||||
|
// Derive staged transfers from composerStore staged ids + transferStore map
|
||||||
|
const stagedTransfers: Transfer[] = useMemo(() => {
|
||||||
|
const out: Transfer[] = [];
|
||||||
|
for (const tid of composerState.stagedTransferIds) {
|
||||||
|
const t = transfers.get(tid);
|
||||||
|
if (t) out.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [composerState.stagedTransferIds, transfers]);
|
||||||
|
|
||||||
|
const draftText = composerState.draftText;
|
||||||
|
const remaining = MAX_MESSAGE_LENGTH - draftText.length;
|
||||||
|
const isOverLimit = remaining < 0;
|
||||||
|
|
||||||
// Auto-focus textarea on channel navigation
|
// Auto-focus textarea on channel navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}, [channelId]);
|
}, [channelId]);
|
||||||
|
|
||||||
// Auto-focus textarea when replying
|
// Auto-focus textarea when replying (chatStore is the live source of truth)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (replyTo) {
|
if (chatReplyTo) {
|
||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [replyTo]);
|
}, [chatReplyTo]);
|
||||||
|
|
||||||
// Close popover on channel change
|
// Close popover on channel change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActivePopover(null);
|
setActivePopover(null);
|
||||||
|
setMentionState(null);
|
||||||
}, [channelId]);
|
}, [channelId]);
|
||||||
|
|
||||||
// Filter members for the mention popover
|
// Sync chatStore.replyTo into composerStore so reload restores it.
|
||||||
|
// chatStore holds the live MessageWithUser; composerStore stores a flat snapshot.
|
||||||
|
//
|
||||||
|
// First-mirror-per-channel guard: chatStore is not persisted, so on a fresh
|
||||||
|
// mount `chatReplyTo` is always null. Without this guard the mirror would
|
||||||
|
// clobber whatever replyTo was persisted in composerStore. Reverse hydration
|
||||||
|
// (composer→chat on reload) is not yet implemented; deferred to a future pass
|
||||||
|
// (it requires the original message to be present in chatStore.messages,
|
||||||
|
// which may not be loaded at mount).
|
||||||
|
const mirroredChannelsRef = useRef<Set<string>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
const isFirstMirrorForChannel = !mirroredChannelsRef.current.has(channelId);
|
||||||
|
mirroredChannelsRef.current.add(channelId);
|
||||||
|
|
||||||
|
if (isFirstMirrorForChannel) {
|
||||||
|
if (chatReplyTo) {
|
||||||
|
composerSetReplyTo(channelId, {
|
||||||
|
id: chatReplyTo.id,
|
||||||
|
userId: chatReplyTo.userId,
|
||||||
|
content: chatReplyTo.content ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Already mirrored once for this channel — propagate updates including null.
|
||||||
|
if (chatReplyTo) {
|
||||||
|
composerSetReplyTo(channelId, {
|
||||||
|
id: chatReplyTo.id,
|
||||||
|
userId: chatReplyTo.userId,
|
||||||
|
content: chatReplyTo.content ?? null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
composerSetReplyTo(channelId, null);
|
||||||
|
}
|
||||||
|
}, [channelId, chatReplyTo, composerSetReplyTo]);
|
||||||
|
|
||||||
|
// Surface permanent transfer failures as toasts (one toast per id, latched)
|
||||||
|
const toastedFailuresRef = useRef<Set<string>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
for (const t of stagedTransfers) {
|
||||||
|
if (t.state === 'failed' && !toastedFailuresRef.current.has(t.id)) {
|
||||||
|
toastedFailuresRef.current.add(t.id);
|
||||||
|
const msg = t.error?.message ?? 'Upload failed';
|
||||||
|
addToast(`Failed to upload ${t.file.name}: ${msg}`, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [stagedTransfers, addToast]);
|
||||||
|
|
||||||
|
// Filter members for the mention popover (used for keyboard nav clamping)
|
||||||
const filteredMembers = useMemo(() => {
|
const filteredMembers = useMemo(() => {
|
||||||
if (!mentionState) return [];
|
if (!mentionState) return [];
|
||||||
const q = mentionState.query.toLowerCase();
|
const q = mentionState.query.toLowerCase();
|
||||||
@@ -80,8 +181,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
|
|
||||||
const handleTyping = useCallback(() => {
|
const handleTyping = useCallback(() => {
|
||||||
if (typingTimeoutRef.current) return;
|
if (typingTimeoutRef.current) return;
|
||||||
const isDm = isDmChannel(channelId);
|
const dm = isDmChannel(channelId);
|
||||||
if (isDm) {
|
if (dm) {
|
||||||
wsSend({ type: 'dm_typing_start', dmChannelId: channelId }, getChannelOrigin(channelId));
|
wsSend({ type: 'dm_typing_start', dmChannelId: channelId }, getChannelOrigin(channelId));
|
||||||
} else {
|
} else {
|
||||||
wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId));
|
wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId));
|
||||||
@@ -91,104 +192,205 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
}, [channelId]);
|
}, [channelId]);
|
||||||
|
|
||||||
const remaining = MAX_MESSAGE_LENGTH - content.length;
|
/**
|
||||||
const isOverLimit = remaining < 0;
|
* Eagerly upload a file and stage the resulting transfer in composerStore.
|
||||||
|
* If a FileSystemFileHandle is provided (drag-drop via getAsFileSystemHandle,
|
||||||
const handleSubmit = async () => {
|
* or FS Access pick), it's persisted in IDB so the upload is resumable
|
||||||
const trimmed = content.trim();
|
* across reload.
|
||||||
if (!trimmed && files.length === 0) return;
|
*/
|
||||||
if (isOverLimit) return;
|
const enqueueFile = useCallback(
|
||||||
|
async (
|
||||||
setIsUploading(true);
|
input: { file: File; handle?: FileSystemFileHandle | undefined },
|
||||||
setMentionState(null);
|
): Promise<void> => {
|
||||||
setActivePopover(null);
|
const { file, handle } = input;
|
||||||
setUploadProgress(new Map());
|
let fileHandleId: string | undefined;
|
||||||
try {
|
if (handle && supportsFsHandles()) {
|
||||||
// Upload files first — route to the correct instance for this channel
|
|
||||||
const attachmentIds: string[] = [];
|
|
||||||
const uploadClient = getApiForOrigin(getChannelOrigin(channelId));
|
|
||||||
const failedFiles: string[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files[i]!;
|
|
||||||
try {
|
try {
|
||||||
const attachment = await uploadClient.uploads.uploadWithProgress(file, (loaded, total) => {
|
fileHandleId = makeFileHandleKey();
|
||||||
setUploadProgress(prev => new Map(prev).set(i, Math.round((loaded / total) * 100)));
|
await putHandle(fileHandleId, handle);
|
||||||
});
|
|
||||||
attachmentIds.push(attachment.id);
|
|
||||||
setUploadProgress(prev => new Map(prev).set(i, 100));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failedFiles.push(file.name);
|
// Persistence failed — proceed without resume capability.
|
||||||
const msg = err instanceof Error ? err.message : 'Upload failed';
|
fileHandleId = undefined;
|
||||||
addToast(`Failed to upload ${file.name}: ${msg}`, 'warning');
|
const msg = err instanceof Error ? err.message : 'unknown error';
|
||||||
|
console.warn('[MessageInput] putHandle failed:', msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failedFiles.length > 0 && attachmentIds.length === 0 && !trimmed) {
|
try {
|
||||||
// All uploads failed, no text — nothing to send
|
const id = await startUpload(file, {
|
||||||
return;
|
channelId,
|
||||||
|
tray: true,
|
||||||
|
origin: getChannelOrigin(channelId),
|
||||||
|
fileHandleId,
|
||||||
|
});
|
||||||
|
attachToComposer(channelId, id);
|
||||||
|
// Best-effort image preview for the current session. transferStore
|
||||||
|
// doesn't retain the File, so this URL only exists in-memory.
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
try {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
previewUrlsRef.current.set(id, url);
|
||||||
|
} catch {
|
||||||
|
// ignore — preview is optional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Upload failed';
|
||||||
|
addToast(`Failed to upload ${file.name}: ${msg}`, 'warning');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[channelId, startUpload, attachToComposer, addToast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeStagedTransfer = useCallback(
|
||||||
|
(transferId: string) => {
|
||||||
|
// Revoke any in-session image preview URL.
|
||||||
|
const previewUrl = previewUrlsRef.current.get(transferId);
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
previewUrlsRef.current.delete(transferId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined);
|
const t = useTransferStore.getState().transfers.get(transferId);
|
||||||
setContent('');
|
if (t?.state === 'completed') {
|
||||||
setFiles([]);
|
// User is discarding a fully-uploaded attachment. Drop it entirely so
|
||||||
|
// no orphan 'aborted'-with-attachmentId record persists. Server-side
|
||||||
// Reset textarea height and re-focus
|
// bytes get cleaned by the storage janitor (per docs/systems/uploads.md).
|
||||||
if (textareaRef.current) {
|
useTransferStore.getState().remove(transferId);
|
||||||
textareaRef.current.style.height = 'auto';
|
} else {
|
||||||
textareaRef.current.focus();
|
// abortUpload sets state='aborted' and tears down the live tus instance.
|
||||||
|
abortUpload(transferId);
|
||||||
}
|
}
|
||||||
|
removeStaged(channelId, transferId);
|
||||||
|
},
|
||||||
|
[abortUpload, removeStaged, channelId],
|
||||||
|
);
|
||||||
|
|
||||||
// Clear typing timeout
|
// Revoke all preview object URLs on unmount.
|
||||||
if (typingTimeoutRef.current) {
|
useEffect(() => {
|
||||||
clearTimeout(typingTimeoutRef.current);
|
const map = previewUrlsRef.current;
|
||||||
typingTimeoutRef.current = undefined;
|
return () => {
|
||||||
|
for (const url of map.values()) URL.revokeObjectURL(url);
|
||||||
|
map.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (): Promise<void> => {
|
||||||
|
const trimmed = draftText.trim();
|
||||||
|
if (!trimmed && stagedTransfers.length === 0) return;
|
||||||
|
if (isOverLimit) return;
|
||||||
|
|
||||||
|
// Block submission when ANY staged transfer is in a non-shippable state
|
||||||
|
// (failed/aborted) — those would prevent the bubble from ever resolving.
|
||||||
|
const hasUnshippable = stagedTransfers.some(
|
||||||
|
(t) => t.state === 'failed' || t.state === 'aborted',
|
||||||
|
);
|
||||||
|
if (hasUnshippable) return;
|
||||||
|
|
||||||
|
setMentionState(null);
|
||||||
|
setActivePopover(null);
|
||||||
|
|
||||||
|
// Clear typing timeout
|
||||||
|
if (typingTimeoutRef.current) {
|
||||||
|
clearTimeout(typingTimeoutRef.current);
|
||||||
|
typingTimeoutRef.current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stagedTransfers.length === 0) {
|
||||||
|
// Text-only path — preserve the legacy optimistic-message flow
|
||||||
|
try {
|
||||||
|
await sendMessage(channelId, trimmed);
|
||||||
|
// Clear draft + reply for this channel
|
||||||
|
clearComposer(channelId);
|
||||||
|
chatSetReplyTo(null);
|
||||||
|
// Reset textarea height + focus
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Failed to send message';
|
||||||
|
addToast(msg, 'warning');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
return;
|
||||||
const msg = err instanceof Error ? err.message : 'Failed to send message';
|
}
|
||||||
addToast(msg, 'warning');
|
|
||||||
} finally {
|
// Attachment path — stage a pending bubble. The orchestrator dispatches the
|
||||||
setIsUploading(false);
|
// actual API call when every transfer reaches state='completed'.
|
||||||
setUploadProgress(new Map());
|
const clientId = crypto.randomUUID();
|
||||||
|
const replyToId = chatReplyTo?.id ?? null;
|
||||||
|
|
||||||
|
// tusExpiresAt: min across staged transfers, default to 24h from now if absent.
|
||||||
|
const now = Date.now();
|
||||||
|
const fallbackExpires = now + DEFAULT_TUS_TTL_MS;
|
||||||
|
const expirations = stagedTransfers
|
||||||
|
.map((t) => t.tusExpiresAt)
|
||||||
|
.filter((x): x is number => typeof x === 'number' && x > 0);
|
||||||
|
const tusExpiresAt = expirations.length > 0 ? Math.min(...expirations) : fallbackExpires;
|
||||||
|
|
||||||
|
appendBubble({
|
||||||
|
clientId,
|
||||||
|
channelId,
|
||||||
|
content: trimmed,
|
||||||
|
replyToId,
|
||||||
|
transferIds: stagedTransfers.map((t) => t.id),
|
||||||
|
createdAtLocal: now,
|
||||||
|
state: 'sending',
|
||||||
|
tusExpiresAt,
|
||||||
|
retryCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detach the staged transfers from the composer (they're now owned by the bubble)
|
||||||
|
// and clear the draft + reply for this channel.
|
||||||
|
clearComposer(channelId);
|
||||||
|
chatSetReplyTo(null);
|
||||||
|
|
||||||
|
// Reset textarea height + focus
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
textareaRef.current.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectMention = useCallback((member: MemberWithUser) => {
|
const selectMention = useCallback(
|
||||||
if (!mentionState) return;
|
(member: MemberWithUser) => {
|
||||||
const textarea = textareaRef.current;
|
if (!mentionState) return;
|
||||||
const cursorPos = textarea?.selectionStart ?? content.length;
|
const textarea = textareaRef.current;
|
||||||
const before = content.slice(0, mentionState.startIndex);
|
const cursorPos = textarea?.selectionStart ?? draftText.length;
|
||||||
const after = content.slice(cursorPos);
|
const before = draftText.slice(0, mentionState.startIndex);
|
||||||
const insertion = `<@${member.userId}> `;
|
const after = draftText.slice(cursorPos);
|
||||||
const newContent = before + insertion + after;
|
const insertion = `<@${member.userId}> `;
|
||||||
setContent(newContent);
|
const newContent = before + insertion + after;
|
||||||
setMentionState(null);
|
setDraft(channelId, newContent);
|
||||||
|
setMentionState(null);
|
||||||
|
|
||||||
// Restore cursor position after React re-renders
|
// Restore cursor position after React re-renders
|
||||||
const newCursorPos = before.length + insertion.length;
|
const newCursorPos = before.length + insertion.length;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
textarea.selectionStart = newCursorPos;
|
textarea.selectionStart = newCursorPos;
|
||||||
textarea.selectionEnd = newCursorPos;
|
textarea.selectionEnd = newCursorPos;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [mentionState, content]);
|
},
|
||||||
|
[mentionState, draftText, setDraft, channelId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||||
// Mention popover keyboard navigation
|
// Mention popover keyboard navigation
|
||||||
if (mentionState && filteredMembers.length > 0) {
|
if (mentionState && filteredMembers.length > 0) {
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMentionState((prev) =>
|
setMentionState((prev) =>
|
||||||
prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null
|
prev ? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, filteredMembers.length - 1) } : null,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === 'ArrowUp') {
|
if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setMentionState((prev) =>
|
setMentionState((prev) =>
|
||||||
prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null
|
prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -208,45 +410,73 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
// Default: Enter to submit
|
// Default: Enter to submit
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
void handleSubmit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePaste = (e: React.ClipboardEvent) => {
|
const handlePaste = (e: React.ClipboardEvent): void => {
|
||||||
const items = e.clipboardData.items;
|
const items = e.clipboardData.items;
|
||||||
const pastedFiles: File[] = [];
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const item = items[i];
|
const item = items[i];
|
||||||
if (item && item.type.startsWith('image/')) {
|
if (item && item.type.startsWith('image/')) {
|
||||||
const file = item.getAsFile();
|
const file = item.getAsFile();
|
||||||
if (file) pastedFiles.push(file);
|
if (file) void enqueueFile({ file });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pastedFiles.length > 0) {
|
|
||||||
setFiles((prev) => [...prev, ...pastedFiles]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent): void => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const items = e.dataTransfer.items;
|
||||||
|
const useDndHandles = supportsDnDHandles();
|
||||||
|
|
||||||
|
if (useDndHandles && items && items.length > 0) {
|
||||||
|
// Chrome/Edge: upgrade to FileSystemFileHandle for resume-across-reload.
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
if (!item || item.kind !== 'file') continue;
|
||||||
|
// @ts-ignore — getAsFileSystemHandle is non-standard
|
||||||
|
const maybeHandle: Promise<FileSystemHandle | null> | undefined = item.getAsFileSystemHandle?.();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (maybeHandle) {
|
||||||
|
void maybeHandle.then(async (handle) => {
|
||||||
|
if (handle && handle.kind === 'file') {
|
||||||
|
const fh = handle as FileSystemFileHandle;
|
||||||
|
const handleAny = fh as unknown as { getFile?: () => Promise<File> };
|
||||||
|
if (typeof handleAny.getFile === 'function') {
|
||||||
|
try {
|
||||||
|
const f = await handleAny.getFile();
|
||||||
|
void enqueueFile({ file: f, handle: fh });
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// fall through to plain-file path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (file) void enqueueFile({ file });
|
||||||
|
});
|
||||||
|
} else if (file) {
|
||||||
|
void enqueueFile({ file });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: plain Files list (Firefox/Safari)
|
||||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
if (droppedFiles.length > 0) {
|
for (const file of droppedFiles) {
|
||||||
setFiles((prev) => [...prev, ...droppedFiles]);
|
void enqueueFile({ file });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent): void => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
|
||||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
const cursorPos = e.target.selectionStart;
|
const cursorPos = e.target.selectionStart;
|
||||||
setContent(value);
|
setDraft(channelId, value);
|
||||||
|
|
||||||
// Detect @mention trigger
|
// Detect @mention trigger
|
||||||
const textBeforeCursor = value.slice(0, cursorPos);
|
const textBeforeCursor = value.slice(0, cursorPos);
|
||||||
@@ -258,7 +488,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
const charBefore = atIndex > 0 ? value[atIndex - 1] : undefined;
|
const charBefore = atIndex > 0 ? value[atIndex - 1] : undefined;
|
||||||
if (charBefore === undefined || charBefore === ' ' || charBefore === '\n') {
|
if (charBefore === undefined || charBefore === ' ' || charBefore === '\n') {
|
||||||
setMentionState({
|
setMentionState({
|
||||||
query: mentionMatch[1]!,
|
query: mentionMatch[1] ?? '',
|
||||||
startIndex: atIndex,
|
startIndex: atIndex,
|
||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
});
|
});
|
||||||
@@ -277,51 +507,81 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
|
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiSelect = useCallback((emoji: { native: string }) => {
|
const handleEmojiSelect = useCallback(
|
||||||
const textarea = textareaRef.current;
|
(emoji: { native: string }) => {
|
||||||
if (!textarea) {
|
const textarea = textareaRef.current;
|
||||||
setContent((prev) => prev + emoji.native);
|
if (!textarea) {
|
||||||
return;
|
setDraft(channelId, draftText + emoji.native);
|
||||||
}
|
return;
|
||||||
const start = textarea.selectionStart;
|
}
|
||||||
const end = textarea.selectionEnd;
|
const start = textarea.selectionStart;
|
||||||
const before = content.slice(0, start);
|
const end = textarea.selectionEnd;
|
||||||
const after = content.slice(end);
|
const before = draftText.slice(0, start);
|
||||||
const newContent = before + emoji.native + after;
|
const after = draftText.slice(end);
|
||||||
setContent(newContent);
|
const newContent = before + emoji.native + after;
|
||||||
|
setDraft(channelId, newContent);
|
||||||
|
|
||||||
// Restore cursor position after the emoji
|
// Restore cursor position after the emoji
|
||||||
const newCursorPos = start + emoji.native.length;
|
const newCursorPos = start + emoji.native.length;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
textarea.selectionStart = newCursorPos;
|
textarea.selectionStart = newCursorPos;
|
||||||
textarea.selectionEnd = newCursorPos;
|
textarea.selectionEnd = newCursorPos;
|
||||||
});
|
});
|
||||||
}, [content]);
|
},
|
||||||
|
[draftText, setDraft, channelId],
|
||||||
|
);
|
||||||
|
|
||||||
const handleGifSelect = useCallback((url: string) => {
|
const handleGifSelect = useCallback(
|
||||||
setActivePopover(null);
|
(url: string) => {
|
||||||
sendMessage(channelId, url);
|
// GIF picks bypass the staged-transfer pipeline — they're remote URLs,
|
||||||
}, [channelId, sendMessage]);
|
// not local files, and ship as plain content.
|
||||||
|
setActivePopover(null);
|
||||||
|
void sendMessage(channelId, url);
|
||||||
|
},
|
||||||
|
[channelId, sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const togglePopover = useCallback((tab: InputPopoverTab) => {
|
const togglePopover = useCallback((tab: InputPopoverTab) => {
|
||||||
setActivePopover((prev) => prev === tab ? null : tab);
|
setActivePopover((prev) => (prev === tab ? null : tab));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const canSend = (content.trim() || files.length > 0) && !isOverLimit && !isUploading;
|
// anyActiveOrQueued: a visual indicator something is in flight; pending/paused
|
||||||
|
// bubbles are still allowed to ship (orchestrator handles them).
|
||||||
|
const anyActiveOrQueued = stagedTransfers.some(
|
||||||
|
(t) => t.state === 'active' || t.state === 'queued',
|
||||||
|
);
|
||||||
|
const anyUnshippable = stagedTransfers.some(
|
||||||
|
(t) => t.state === 'failed' || t.state === 'aborted',
|
||||||
|
);
|
||||||
|
const failedCount = stagedTransfers.filter((t) => t.state === 'failed').length;
|
||||||
|
|
||||||
|
const canSend =
|
||||||
|
(draftText.trim().length > 0 || stagedTransfers.length > 0) &&
|
||||||
|
!isOverLimit &&
|
||||||
|
!anyUnshippable;
|
||||||
|
|
||||||
if (!canSendMessages) {
|
if (!canSendMessages) {
|
||||||
return (
|
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
|
||||||
|
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">
|
<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>
|
<span className="text-txt-tertiary text-[14px]">
|
||||||
|
You do not have permission to send messages in this channel
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={popoverAnchorRef} 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
|
||||||
|
ref={popoverAnchorRef}
|
||||||
|
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} />
|
<TypingIndicator channelId={channelId} />
|
||||||
|
|
||||||
{/* Input popover (emoji / gif) */}
|
{/* Input popover (emoji / gif) */}
|
||||||
@@ -337,15 +597,18 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{replyTo && (
|
{chatReplyTo && (
|
||||||
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
|
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
|
||||||
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
|
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
|
||||||
<span className="opacity-60">Replying to</span>
|
<span className="opacity-60">Replying to</span>
|
||||||
<span className="font-bold">{replyTo.user.displayName ?? replyTo.user.username}</span>
|
<span className="font-bold">
|
||||||
|
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setReplyTo(null)}
|
onClick={() => chatSetReplyTo(null)}
|
||||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||||
|
aria-label="Cancel reply"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||||
@@ -355,7 +618,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={inputContainerRef}
|
ref={inputContainerRef}
|
||||||
className={`relative bg-surface-input md:bg-transparent ${replyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
|
className={`relative bg-surface-input md:bg-transparent ${chatReplyTo ? 'rounded-b-lg' : 'rounded-lg md:rounded-none'} overflow-visible`}
|
||||||
onDrop={canAttachFiles ? handleDrop : undefined}
|
onDrop={canAttachFiles ? handleDrop : undefined}
|
||||||
onDragOver={canAttachFiles ? handleDragOver : undefined}
|
onDragOver={canAttachFiles ? handleDragOver : undefined}
|
||||||
>
|
>
|
||||||
@@ -369,45 +632,67 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File previews */}
|
{/* Staged transfer tiles */}
|
||||||
{files.length > 0 && (
|
{stagedTransfers.length > 0 && (
|
||||||
<div className="p-4 flex flex-wrap gap-4 bg-surface-channel/30">
|
<div className="p-4 flex flex-wrap gap-4 bg-surface-channel/30">
|
||||||
{files.map((file, i) => {
|
{stagedTransfers.map((t) => {
|
||||||
const progress = uploadProgress.get(i);
|
const isImage = t.file.mimetype.startsWith('image/');
|
||||||
|
const isFinal = t.state === 'completed';
|
||||||
|
const showOverlay = t.state !== 'completed';
|
||||||
|
const previewUrl = previewUrlsRef.current.get(t.id);
|
||||||
return (
|
return (
|
||||||
<div key={i} className="relative group bg-surface-channel rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-border-hard overflow-hidden">
|
<div
|
||||||
{file.type.startsWith('image/') ? (
|
key={t.id}
|
||||||
<img
|
className="relative group bg-surface-channel rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-border-hard overflow-hidden"
|
||||||
src={URL.createObjectURL(file)}
|
>
|
||||||
alt={file.name}
|
{isImage ? (
|
||||||
className="max-h-[150px] rounded object-cover"
|
<div className="w-[150px] h-[150px] bg-surface-input/40 rounded flex items-center justify-center text-txt-tertiary overflow-hidden">
|
||||||
/>
|
{previewUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt={t.file.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<svg className="w-10 h-10 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-sm text-txt-secondary py-4 px-2">
|
<div className="flex items-center gap-2 text-sm text-txt-secondary py-4 px-2">
|
||||||
<svg className="w-8 h-8 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-8 h-8 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="truncate max-w-[120px] font-medium">{file.name}</span>
|
<span className="truncate max-w-[120px] font-medium">{t.file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Upload progress bar */}
|
|
||||||
{progress !== undefined && progress < 100 && (
|
{/* Overlay: progress / paused / failed indicator (driven by AttachmentProgress) */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/10">
|
{showOverlay && (
|
||||||
<div
|
<AttachmentProgress
|
||||||
className="h-full bg-accent-primary transition-all duration-200"
|
loaded={t.progress.loaded}
|
||||||
style={{ width: `${progress}%` }}
|
total={t.progress.total}
|
||||||
/>
|
state={t.state}
|
||||||
</div>
|
filename={t.file.name}
|
||||||
|
size="tile"
|
||||||
|
onPause={t.state === 'active' ? () => pauseUpload(t.id) : undefined}
|
||||||
|
onResume={t.state === 'paused' ? () => void resumeUpload(t.id) : undefined}
|
||||||
|
onAbort={() => removeStagedTransfer(t.id)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{progress !== undefined && progress < 100 && (
|
|
||||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center rounded-lg">
|
{/* Final-state remove button (top-right rose chip) — only when completed */}
|
||||||
<span className="text-xs font-medium text-white">{progress}%</span>
|
{isFinal && (
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isUploading && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => removeFile(i)}
|
onClick={() => removeStagedTransfer(t.id)}
|
||||||
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
||||||
|
aria-label="Remove attachment"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
||||||
@@ -427,6 +712,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
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"
|
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"
|
title="Attach file"
|
||||||
|
aria-label="Attach file"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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" />
|
||||||
@@ -440,8 +726,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const selected = Array.from(e.target.files ?? []);
|
const selected = Array.from(e.target.files ?? []);
|
||||||
if (selected.length > 0) {
|
for (const f of selected) {
|
||||||
setFiles((prev) => [...prev, ...selected]);
|
void enqueueFile({ file: f });
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}}
|
}}
|
||||||
@@ -450,19 +736,18 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
{/* Text input */}
|
{/* Text input */}
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={content}
|
value={draftText}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={canAttachFiles ? handlePaste : undefined}
|
onPaste={canAttachFiles ? handlePaste : undefined}
|
||||||
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
|
placeholder={`Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
|
||||||
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
||||||
rows={1}
|
rows={1}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Send indicator */}
|
{/* Active-upload indicator */}
|
||||||
{isUploading && (
|
{anyActiveOrQueued && (
|
||||||
<div className="p-3 text-txt-tertiary">
|
<div className="p-3 text-txt-tertiary" title="Uploading…" aria-label="Uploading">
|
||||||
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
@@ -470,9 +755,21 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Failed-upload hint (gates send button) */}
|
||||||
|
{failedCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
|
||||||
|
title="Remove or retry the failed attachment to send"
|
||||||
|
>
|
||||||
|
{failedCount} failed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Character counter (shows when near or over limit) */}
|
{/* Character counter (shows when near or over limit) */}
|
||||||
{content.length > MAX_MESSAGE_LENGTH - 200 && (
|
{draftText.length > MAX_MESSAGE_LENGTH - 200 && (
|
||||||
<span className={`text-[12px] font-medium tabular-nums flex-shrink-0 px-1 ${isOverLimit ? 'text-accent-rose' : 'text-txt-tertiary'}`}>
|
<span
|
||||||
|
className={`text-[12px] font-medium tabular-nums flex-shrink-0 px-1 ${isOverLimit ? 'text-accent-rose' : 'text-txt-tertiary'}`}
|
||||||
|
>
|
||||||
{remaining}
|
{remaining}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -485,6 +782,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="GIF"
|
title="GIF"
|
||||||
|
aria-label="GIF picker"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
||||||
@@ -499,17 +797,18 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
|||||||
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="Emoji"
|
title="Emoji"
|
||||||
|
aria-label="Emoji picker"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<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 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Send button — appears when there's content to send */}
|
{/* Send button — appears when there's content/attachments to send */}
|
||||||
{canSend && (
|
{canSend && (
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={() => void handleSubmit()}
|
||||||
disabled={isUploading}
|
disabled={anyUnshippable}
|
||||||
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
|
className="w-[34px] h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
title="Send"
|
title="Send"
|
||||||
|
|||||||
Reference in New Issue
Block a user