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 { 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 { MentionPopover } from './MentionPopover';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import { InputPopover, type InputPopoverTab } from './InputPopover';
|
||||
import { AttachmentProgress } from './AttachmentProgress';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
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 {
|
||||
channelId: string;
|
||||
@@ -21,22 +26,55 @@ interface MentionState {
|
||||
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) {
|
||||
const [content, setContent] = useState('');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<Map<number, number>>(new Map());
|
||||
const addToast = useUIStore((s) => s.addToast);
|
||||
// Composer state lives in composerStore (per-channel, persisted)
|
||||
const composerState = useComposerStore((s) => s.states.get(channelId)) ?? {
|
||||
draftText: '',
|
||||
replyTo: null,
|
||||
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 [activePopover, setActivePopover] = useState<InputPopoverTab | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputContainerRef = 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 replyTo = useChatStore((s) => s.replyTo);
|
||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||
const chatReplyTo = useChatStore((s) => s.replyTo);
|
||||
const chatSetReplyTo = useChatStore((s) => s.setReplyTo);
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
|
||||
const addToast = useUIStore((s) => s.addToast);
|
||||
const appendBubble = usePendingMessageStore((s) => s.append);
|
||||
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Feature flags
|
||||
@@ -48,24 +86,87 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
const canSendMessages = isDm || hasPermissionBit(channelPerms, PermissionBits.SEND_MESSAGES);
|
||||
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
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, [channelId]);
|
||||
|
||||
// Auto-focus textarea when replying
|
||||
// Auto-focus textarea when replying (chatStore is the live source of truth)
|
||||
useEffect(() => {
|
||||
if (replyTo) {
|
||||
if (chatReplyTo) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [replyTo]);
|
||||
}, [chatReplyTo]);
|
||||
|
||||
// Close popover on channel change
|
||||
useEffect(() => {
|
||||
setActivePopover(null);
|
||||
setMentionState(null);
|
||||
}, [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(() => {
|
||||
if (!mentionState) return [];
|
||||
const q = mentionState.query.toLowerCase();
|
||||
@@ -80,8 +181,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
|
||||
const handleTyping = useCallback(() => {
|
||||
if (typingTimeoutRef.current) return;
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (isDm) {
|
||||
const dm = isDmChannel(channelId);
|
||||
if (dm) {
|
||||
wsSend({ type: 'dm_typing_start', dmChannelId: channelId }, getChannelOrigin(channelId));
|
||||
} else {
|
||||
wsSend({ type: 'typing_start', channelId }, getChannelOrigin(channelId));
|
||||
@@ -91,104 +192,205 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
}, 3000);
|
||||
}, [channelId]);
|
||||
|
||||
const remaining = MAX_MESSAGE_LENGTH - content.length;
|
||||
const isOverLimit = remaining < 0;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed && files.length === 0) return;
|
||||
if (isOverLimit) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setMentionState(null);
|
||||
setActivePopover(null);
|
||||
setUploadProgress(new Map());
|
||||
try {
|
||||
// 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]!;
|
||||
/**
|
||||
* Eagerly upload a file and stage the resulting transfer in composerStore.
|
||||
* If a FileSystemFileHandle is provided (drag-drop via getAsFileSystemHandle,
|
||||
* or FS Access pick), it's persisted in IDB so the upload is resumable
|
||||
* across reload.
|
||||
*/
|
||||
const enqueueFile = useCallback(
|
||||
async (
|
||||
input: { file: File; handle?: FileSystemFileHandle | undefined },
|
||||
): Promise<void> => {
|
||||
const { file, handle } = input;
|
||||
let fileHandleId: string | undefined;
|
||||
if (handle && supportsFsHandles()) {
|
||||
try {
|
||||
const attachment = await uploadClient.uploads.uploadWithProgress(file, (loaded, total) => {
|
||||
setUploadProgress(prev => new Map(prev).set(i, Math.round((loaded / total) * 100)));
|
||||
});
|
||||
attachmentIds.push(attachment.id);
|
||||
setUploadProgress(prev => new Map(prev).set(i, 100));
|
||||
fileHandleId = makeFileHandleKey();
|
||||
await putHandle(fileHandleId, handle);
|
||||
} catch (err) {
|
||||
failedFiles.push(file.name);
|
||||
const msg = err instanceof Error ? err.message : 'Upload failed';
|
||||
addToast(`Failed to upload ${file.name}: ${msg}`, 'warning');
|
||||
// Persistence failed — proceed without resume capability.
|
||||
fileHandleId = undefined;
|
||||
const msg = err instanceof Error ? err.message : 'unknown error';
|
||||
console.warn('[MessageInput] putHandle failed:', msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0 && attachmentIds.length === 0 && !trimmed) {
|
||||
// All uploads failed, no text — nothing to send
|
||||
return;
|
||||
try {
|
||||
const id = await startUpload(file, {
|
||||
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);
|
||||
setContent('');
|
||||
setFiles([]);
|
||||
|
||||
// Reset textarea height and re-focus
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.focus();
|
||||
const t = useTransferStore.getState().transfers.get(transferId);
|
||||
if (t?.state === 'completed') {
|
||||
// User is discarding a fully-uploaded attachment. Drop it entirely so
|
||||
// no orphan 'aborted'-with-attachmentId record persists. Server-side
|
||||
// bytes get cleaned by the storage janitor (per docs/systems/uploads.md).
|
||||
useTransferStore.getState().remove(transferId);
|
||||
} else {
|
||||
// abortUpload sets state='aborted' and tears down the live tus instance.
|
||||
abortUpload(transferId);
|
||||
}
|
||||
removeStaged(channelId, transferId);
|
||||
},
|
||||
[abortUpload, removeStaged, channelId],
|
||||
);
|
||||
|
||||
// Clear typing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = undefined;
|
||||
// Revoke all preview object URLs on unmount.
|
||||
useEffect(() => {
|
||||
const map = previewUrlsRef.current;
|
||||
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) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to send message';
|
||||
addToast(msg, 'warning');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
// Attachment path — stage a pending bubble. The orchestrator dispatches the
|
||||
// actual API call when every transfer reaches state='completed'.
|
||||
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) => {
|
||||
if (!mentionState) return;
|
||||
const textarea = textareaRef.current;
|
||||
const cursorPos = textarea?.selectionStart ?? content.length;
|
||||
const before = content.slice(0, mentionState.startIndex);
|
||||
const after = content.slice(cursorPos);
|
||||
const insertion = `<@${member.userId}> `;
|
||||
const newContent = before + insertion + after;
|
||||
setContent(newContent);
|
||||
setMentionState(null);
|
||||
const selectMention = useCallback(
|
||||
(member: MemberWithUser) => {
|
||||
if (!mentionState) return;
|
||||
const textarea = textareaRef.current;
|
||||
const cursorPos = textarea?.selectionStart ?? draftText.length;
|
||||
const before = draftText.slice(0, mentionState.startIndex);
|
||||
const after = draftText.slice(cursorPos);
|
||||
const insertion = `<@${member.userId}> `;
|
||||
const newContent = before + insertion + after;
|
||||
setDraft(channelId, newContent);
|
||||
setMentionState(null);
|
||||
|
||||
// Restore cursor position after React re-renders
|
||||
const newCursorPos = before.length + insertion.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
}
|
||||
});
|
||||
}, [mentionState, content]);
|
||||
// Restore cursor position after React re-renders
|
||||
const newCursorPos = before.length + insertion.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
}
|
||||
});
|
||||
},
|
||||
[mentionState, draftText, setDraft, channelId],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
||||
// Mention popover keyboard navigation
|
||||
if (mentionState && filteredMembers.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
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;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setMentionState((prev) =>
|
||||
prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null
|
||||
prev ? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) } : null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -208,45 +410,73 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
// Default: Enter to submit
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
void handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
const handlePaste = (e: React.ClipboardEvent): void => {
|
||||
const items = e.clipboardData.items;
|
||||
const pastedFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item && item.type.startsWith('image/')) {
|
||||
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();
|
||||
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);
|
||||
if (droppedFiles.length > 0) {
|
||||
setFiles((prev) => [...prev, ...droppedFiles]);
|
||||
for (const file of droppedFiles) {
|
||||
void enqueueFile({ file });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
const handleDragOver = (e: React.DragEvent): void => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
const value = e.target.value;
|
||||
const cursorPos = e.target.selectionStart;
|
||||
setContent(value);
|
||||
setDraft(channelId, value);
|
||||
|
||||
// Detect @mention trigger
|
||||
const textBeforeCursor = value.slice(0, cursorPos);
|
||||
@@ -258,7 +488,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
const charBefore = atIndex > 0 ? value[atIndex - 1] : undefined;
|
||||
if (charBefore === undefined || charBefore === ' ' || charBefore === '\n') {
|
||||
setMentionState({
|
||||
query: mentionMatch[1]!,
|
||||
query: mentionMatch[1] ?? '',
|
||||
startIndex: atIndex,
|
||||
selectedIndex: 0,
|
||||
});
|
||||
@@ -277,51 +507,81 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
|
||||
};
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji: { native: string }) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
setContent((prev) => prev + emoji.native);
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const before = content.slice(0, start);
|
||||
const after = content.slice(end);
|
||||
const newContent = before + emoji.native + after;
|
||||
setContent(newContent);
|
||||
const handleEmojiSelect = useCallback(
|
||||
(emoji: { native: string }) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
setDraft(channelId, draftText + emoji.native);
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const before = draftText.slice(0, start);
|
||||
const after = draftText.slice(end);
|
||||
const newContent = before + emoji.native + after;
|
||||
setDraft(channelId, newContent);
|
||||
|
||||
// Restore cursor position after the emoji
|
||||
const newCursorPos = start + emoji.native.length;
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
});
|
||||
}, [content]);
|
||||
// Restore cursor position after the emoji
|
||||
const newCursorPos = start + emoji.native.length;
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
textarea.selectionStart = newCursorPos;
|
||||
textarea.selectionEnd = newCursorPos;
|
||||
});
|
||||
},
|
||||
[draftText, setDraft, channelId],
|
||||
);
|
||||
|
||||
const handleGifSelect = useCallback((url: string) => {
|
||||
setActivePopover(null);
|
||||
sendMessage(channelId, url);
|
||||
}, [channelId, sendMessage]);
|
||||
const handleGifSelect = useCallback(
|
||||
(url: string) => {
|
||||
// GIF picks bypass the staged-transfer pipeline — they're remote URLs,
|
||||
// not local files, and ship as plain content.
|
||||
setActivePopover(null);
|
||||
void sendMessage(channelId, url);
|
||||
},
|
||||
[channelId, sendMessage],
|
||||
);
|
||||
|
||||
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) {
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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} />
|
||||
|
||||
{/* 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="flex items-center gap-1 text-[14px] text-txt-message truncate">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => setReplyTo(null)}
|
||||
onClick={() => chatSetReplyTo(null)}
|
||||
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">
|
||||
<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
|
||||
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}
|
||||
onDragOver={canAttachFiles ? handleDragOver : undefined}
|
||||
>
|
||||
@@ -369,45 +632,67 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* File previews */}
|
||||
{files.length > 0 && (
|
||||
{/* Staged transfer tiles */}
|
||||
{stagedTransfers.length > 0 && (
|
||||
<div className="p-4 flex flex-wrap gap-4 bg-surface-channel/30">
|
||||
{files.map((file, i) => {
|
||||
const progress = uploadProgress.get(i);
|
||||
{stagedTransfers.map((t) => {
|
||||
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 (
|
||||
<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">
|
||||
{file.type.startsWith('image/') ? (
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
className="max-h-[150px] rounded object-cover"
|
||||
/>
|
||||
<div
|
||||
key={t.id}
|
||||
className="relative group bg-surface-channel rounded-lg p-2 max-w-[200px] shadow-elevation-low border border-border-hard overflow-hidden"
|
||||
>
|
||||
{isImage ? (
|
||||
<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">
|
||||
<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>
|
||||
<span className="truncate max-w-[120px] font-medium">{file.name}</span>
|
||||
<span className="truncate max-w-[120px] font-medium">{t.file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Upload progress bar */}
|
||||
{progress !== undefined && progress < 100 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-white/10">
|
||||
<div
|
||||
className="h-full bg-accent-primary transition-all duration-200"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Overlay: progress / paused / failed indicator (driven by AttachmentProgress) */}
|
||||
{showOverlay && (
|
||||
<AttachmentProgress
|
||||
loaded={t.progress.loaded}
|
||||
total={t.progress.total}
|
||||
state={t.state}
|
||||
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">
|
||||
<span className="text-xs font-medium text-white">{progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
{!isUploading && (
|
||||
|
||||
{/* Final-state remove button (top-right rose chip) — only when completed */}
|
||||
{isFinal && (
|
||||
<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"
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<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" />
|
||||
@@ -427,6 +712,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
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"
|
||||
aria-label="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" />
|
||||
@@ -440,8 +726,8 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const selected = Array.from(e.target.files ?? []);
|
||||
if (selected.length > 0) {
|
||||
setFiles((prev) => [...prev, ...selected]);
|
||||
for (const f of selected) {
|
||||
void enqueueFile({ file: f });
|
||||
}
|
||||
e.target.value = '';
|
||||
}}
|
||||
@@ -450,19 +736,18 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
{/* Text input */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
value={draftText}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={canAttachFiles ? handlePaste : undefined}
|
||||
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"
|
||||
rows={1}
|
||||
|
||||
/>
|
||||
|
||||
{/* Send indicator */}
|
||||
{isUploading && (
|
||||
<div className="p-3 text-txt-tertiary">
|
||||
{/* Active-upload indicator */}
|
||||
{anyActiveOrQueued && (
|
||||
<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">
|
||||
<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" />
|
||||
@@ -470,9 +755,21 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
</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) */}
|
||||
{content.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'}`}>
|
||||
{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'}`}
|
||||
>
|
||||
{remaining}
|
||||
</span>
|
||||
)}
|
||||
@@ -485,6 +782,7 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||
}`}
|
||||
title="GIF"
|
||||
aria-label="GIF picker"
|
||||
>
|
||||
<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" />
|
||||
@@ -499,17 +797,18 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||
}`}
|
||||
title="Emoji"
|
||||
aria-label="Emoji picker"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Send button — appears when there's content to send */}
|
||||
{/* Send button — appears when there's content/attachments to send */}
|
||||
{canSend && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isUploading}
|
||||
onClick={() => void handleSubmit()}
|
||||
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"
|
||||
aria-label="Send message"
|
||||
title="Send"
|
||||
|
||||
Reference in New Issue
Block a user