chore: Initial commit of Opencord base state

This commit is contained in:
Jannis Braun
2026-02-18 02:49:21 +01:00
commit 4fd17084a5
124 changed files with 17955 additions and 0 deletions
@@ -0,0 +1,10 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useUIStore } from '../../stores/uiStore';
export function ImagePreview() {
const imageUrl = useUIStore((s) => s.imagePreviewUrl);
const closeImagePreview = useUIStore((s) => s.closeImagePreview);
const activeModal = useUIStore((s) => s.activeModal);
if (activeModal !== 'imagePreview' || !imageUrl)
return null;
return (_jsxs("div", { className: "fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in cursor-pointer", onClick: closeImagePreview, children: [_jsx("button", { className: "absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10", onClick: closeImagePreview, children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) }), _jsx("img", { src: imageUrl, alt: "Preview", className: "max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl", onClick: (e) => e.stopPropagation() })] }));
}
@@ -0,0 +1,32 @@
import React from 'react';
import { useUIStore } from '../../stores/uiStore';
export function ImagePreview() {
const imageUrl = useUIStore((s) => s.imagePreviewUrl);
const closeImagePreview = useUIStore((s) => s.closeImagePreview);
const activeModal = useUIStore((s) => s.activeModal);
if (activeModal !== 'imagePreview' || !imageUrl) return null;
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in cursor-pointer"
onClick={closeImagePreview}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10"
onClick={closeImagePreview}
>
<svg width="24" height="24" 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" />
</svg>
</button>
<img
src={imageUrl}
alt="Preview"
className="max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Avatar } from '../ui/Avatar';
import { ContextMenu } from '../ui/ContextMenu';
import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
function formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday)
return `Today at ${time}`;
if (isYesterday)
return `Yesterday at ${time}`;
return `${date.toLocaleDateString()} ${time}`;
}
function formatHoverTime(timestamp) {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function Message({ message, isCompact, isFirstInGroup }) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content ?? '');
const [isHovered, setIsHovered] = useState(false);
const currentUser = useAuthStore((s) => s.user);
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useServerStore((s) => s.members);
const openImagePreview = useUIStore((s) => s.openImagePreview);
const isAuthor = currentUser?.id === message.userId;
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
const canDelete = isAuthor || isAdminUser;
const contextMenuItems = [];
if (isAuthor) {
contextMenuItems.push({
label: 'Edit Message',
onClick: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
},
});
}
if (canDelete) {
contextMenuItems.push({
label: 'Delete Message',
onClick: () => deleteMessage(message.id),
danger: true,
});
}
const handleEditSubmit = async (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (editContent.trim()) {
await editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}
if (e.key === 'Escape') {
setIsEditing(false);
setEditContent(message.content ?? '');
}
};
const displayName = message.user.displayName ?? message.user.username;
const roleColor = (() => {
const member = members.find(m => m.userId === message.userId);
if (member?.role === 'owner')
return 'text-discord-red';
if (member?.role === 'admin')
return 'text-discord-blurple';
return 'text-white';
})();
const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [_jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-center", children: isFirstInGroup ? (_jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, className: "mt-0.5 cursor-pointer" })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0", children: [isFirstInGroup && (_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: `font-medium cursor-pointer hover:underline ${roleColor}`, children: displayName }), _jsx("span", { className: "text-xs text-discord-text-muted", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-xs text-discord-text-muted mt-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-[#00aff4] hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => {
if (editContent.trim()) {
editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}, className: "text-[#00aff4] hover:underline", children: "save" })] })] })) : (_jsxs(_Fragment, { children: [message.content && (_jsxs("div", { className: "text-discord-text-primary text-sm leading-[1.375rem] break-words", children: [_jsx(ReactMarkdown, { components: {
p: ({ children }) => _jsx("span", { children: children }),
a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[#00aff4] hover:underline", children: children })),
code: ({ children }) => (_jsx("code", { className: "px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono", children: children })),
pre: ({ children }) => (_jsx("pre", { className: "mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto", children: children })),
strong: ({ children }) => _jsx("strong", { className: "font-bold", children: children }),
em: ({ children }) => _jsx("em", { className: "italic", children: children }),
}, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1", children: "(edited)" }))] })), message.attachments.length > 0 && (_jsx("div", { className: "mt-1 space-y-1", children: message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
if (isImage) {
return (_jsx("div", { className: "max-w-[400px]", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id));
}
return (_jsxs("a", { href: `/api/uploads/${att.filename}`, download: att.originalName, className: "flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]", children: [_jsx("svg", { className: "w-6 h-6 text-discord-text-muted flex-shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-[#00aff4] text-sm truncate hover:underline", children: att.originalName }), _jsx("p", { className: "text-xs text-discord-text-muted", children: att.size < 1024 ? `${att.size} B` :
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
`${(att.size / 1048576).toFixed(1)} MB` })] })] }, att.id));
}) }))] }))] }), isHovered && !isEditing && contextMenuItems.length > 0 && (_jsxs("div", { className: "absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md", children: [isAuthor && (_jsx("button", { onClick: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
}, className: "p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Edit", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" }) }) })), canDelete && (_jsx("button", { onClick: () => deleteMessage(message.id), className: "p-1.5 text-discord-text-muted hover:text-discord-red transition-colors", title: "Delete", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("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" }) }) }))] }))] }));
if (contextMenuItems.length > 0) {
return _jsx(ContextMenu, { items: contextMenuItems, children: content });
}
return content;
}
@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import type { MessageWithUser } from '@opencord/shared';
import { Avatar } from '../ui/Avatar';
import { ContextMenu } from '../ui/ContextMenu';
import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
interface MessageProps {
message: MessageWithUser;
isCompact: boolean;
isFirstInGroup: boolean;
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return `${date.toLocaleDateString()} ${time}`;
}
function formatHoverTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content ?? '');
const [isHovered, setIsHovered] = useState(false);
const currentUser = useAuthStore((s) => s.user);
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useServerStore((s) => s.members);
const openImagePreview = useUIStore((s) => s.openImagePreview);
const isAuthor = currentUser?.id === message.userId;
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
const canDelete = isAuthor || isAdminUser;
const contextMenuItems = [];
if (isAuthor) {
contextMenuItems.push({
label: 'Edit Message',
onClick: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
},
});
}
if (canDelete) {
contextMenuItems.push({
label: 'Delete Message',
onClick: () => deleteMessage(message.id),
danger: true,
});
}
const handleEditSubmit = async (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (editContent.trim()) {
await editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}
if (e.key === 'Escape') {
setIsEditing(false);
setEditContent(message.content ?? '');
}
};
const displayName = message.user.displayName ?? message.user.username;
const roleColor = (() => {
const member = members.find(m => m.userId === message.userId);
if (member?.role === 'owner') return 'text-discord-red';
if (member?.role === 'admin') return 'text-discord-blurple';
return 'text-white';
})();
const content = (
<div
className={`group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Avatar or timestamp column */}
<div className="w-[72px] flex-shrink-0 flex items-start justify-center">
{isFirstInGroup ? (
<Avatar
src={message.user.avatar}
name={displayName}
size={40}
className="mt-0.5 cursor-pointer"
/>
) : (
<span className={`text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`}>
{formatHoverTime(message.createdAt)}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{isFirstInGroup && (
<div className="flex items-baseline gap-2">
<span className={`font-medium cursor-pointer hover:underline ${roleColor}`}>
{displayName}
</span>
<span className="text-xs text-discord-text-muted">
{formatTime(message.createdAt)}
</span>
</div>
)}
{isEditing ? (
<div className="mt-1">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleEditSubmit}
className="w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm"
rows={2}
autoFocus
/>
<p className="text-xs text-discord-text-muted mt-1">
escape to <button onClick={() => setIsEditing(false)} className="text-[#00aff4] hover:underline">cancel</button>
{' '}&bull; enter to <button onClick={() => {
if (editContent.trim()) {
editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}} className="text-[#00aff4] hover:underline">save</button>
</p>
</div>
) : (
<>
{message.content && (
<div className="text-discord-text-primary text-sm leading-[1.375rem] break-words">
<ReactMarkdown
components={{
p: ({ children }) => <span>{children}</span>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-[#00aff4] hover:underline">
{children}
</a>
),
code: ({ children }) => (
<code className="px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto">
{children}
</pre>
),
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{message.content}
</ReactMarkdown>
{message.editedAt && (
<span className="text-[10px] text-discord-text-muted ml-1">(edited)</span>
)}
</div>
)}
{/* Attachments */}
{message.attachments.length > 0 && (
<div className="mt-1 space-y-1">
{message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
if (isImage) {
return (
<div key={att.id} className="max-w-[400px]">
<img
src={`/api/uploads/${att.filename}`}
alt={att.originalName}
className="max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => openImagePreview(`/api/uploads/${att.filename}`)}
loading="lazy"
/>
</div>
);
}
return (
<a
key={att.id}
href={`/api/uploads/${att.filename}`}
download={att.originalName}
className="flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]"
>
<svg className="w-6 h-6 text-discord-text-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
<div className="min-w-0">
<p className="text-[#00aff4] text-sm truncate hover:underline">{att.originalName}</p>
<p className="text-xs text-discord-text-muted">
{att.size < 1024 ? `${att.size} B` :
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
`${(att.size / 1048576).toFixed(1)} MB`}
</p>
</div>
</a>
);
})}
</div>
)}
</>
)}
</div>
{/* Action buttons on hover */}
{isHovered && !isEditing && contextMenuItems.length > 0 && (
<div className="absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md">
{isAuthor && (
<button
onClick={() => {
setEditContent(message.content ?? '');
setIsEditing(true);
}}
className="p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Edit"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" />
</svg>
</button>
)}
{canDelete && (
<button
onClick={() => deleteMessage(message.id)}
className="p-1.5 text-discord-text-muted hover:text-discord-red transition-colors"
title="Delete"
>
<svg width="16" height="16" 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" />
</svg>
</button>
)}
</div>
)}
</div>
);
if (contextMenuItems.length > 0) {
return <ContextMenu items={contextMenuItems}>{content}</ContextMenu>;
}
return content;
}
@@ -0,0 +1,99 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useRef, useCallback } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { wsSend } from '../../hooks/useWebSocket';
import { api } from '../../api/client';
export function MessageInput({ channelId, channelName }) {
const [content, setContent] = useState('');
const [files, setFiles] = useState([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef(null);
const textareaRef = useRef(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const typingTimeoutRef = useRef();
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current)
return;
wsSend({ type: 'typing_start', channelId });
typingTimeoutRef.current = setTimeout(() => {
typingTimeoutRef.current = undefined;
}, 3000);
}, [channelId]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed && files.length === 0)
return;
setIsUploading(true);
try {
// Upload files first
const attachmentIds = [];
for (const file of files) {
const attachment = await api.uploads.upload(file);
attachmentIds.push(attachment.id);
}
await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined);
setContent('');
setFiles([]);
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = undefined;
}
}
catch (err) {
console.error('Failed to send message:', err);
}
finally {
setIsUploading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handlePaste = (e) => {
const items = e.clipboardData.items;
const pastedFiles = [];
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 (pastedFiles.length > 0) {
setFiles((prev) => [...prev, ...pastedFiles]);
}
};
const handleDrop = (e) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
setFiles((prev) => [...prev, ...droppedFiles]);
}
};
const handleDragOver = (e) => {
e.preventDefault();
};
const removeFile = (index) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleChange = (e) => {
setContent(e.target.value);
handleTyping();
// Auto-resize textarea
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
return (_jsx("div", { className: "px-4 pb-6", children: _jsxs("div", { className: "bg-discord-bg-input rounded-lg", onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[100px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary", children: [_jsx("svg", { className: "w-5 h-5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("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" }) }), _jsx("span", { className: "truncate", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity", children: "\u2715" })] }, i))) })), _jsxs("div", { className: "flex items-end", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Attach file", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
e.target.value = '';
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message #${channelName}`, className: "flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) }))] })] }) }));
}
@@ -0,0 +1,193 @@
import React, { useState, useRef, useCallback } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { wsSend } from '../../hooks/useWebSocket';
import { api } from '../../api/client';
interface MessageInputProps {
channelId: string;
channelName: string;
}
export function MessageInput({ channelId, channelName }: MessageInputProps) {
const [content, setContent] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current) return;
wsSend({ type: 'typing_start', channelId });
typingTimeoutRef.current = setTimeout(() => {
typingTimeoutRef.current = undefined;
}, 3000);
}, [channelId]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed && files.length === 0) return;
setIsUploading(true);
try {
// Upload files first
const attachmentIds: string[] = [];
for (const file of files) {
const attachment = await api.uploads.upload(file);
attachmentIds.push(attachment.id);
}
await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined);
setContent('');
setFiles([]);
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = undefined;
}
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setIsUploading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
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 (pastedFiles.length > 0) {
setFiles((prev) => [...prev, ...pastedFiles]);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
setFiles((prev) => [...prev, ...droppedFiles]);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
handleTyping();
// Auto-resize textarea
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
return (
<div className="px-4 pb-6">
<div
className="bg-discord-bg-input rounded-lg"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* File previews */}
{files.length > 0 && (
<div className="p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2">
{files.map((file, i) => (
<div key={i} className="relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]">
{file.type.startsWith('image/') ? (
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="max-h-[100px] rounded object-cover"
/>
) : (
<div className="flex items-center gap-2 text-sm text-discord-text-secondary">
<svg className="w-5 h-5" 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" />
</svg>
<span className="truncate">{file.name}</span>
</div>
)}
<button
onClick={() => removeFile(i)}
className="absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
</div>
))}
</div>
)}
<div className="flex items-end">
{/* File attach button */}
<button
onClick={() => fileInputRef.current?.click()}
className="p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Attach file"
>
<svg width="24" height="24" 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"
multiple
className="hidden"
onChange={(e) => {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
e.target.value = '';
}}
/>
{/* Text input */}
<textarea
ref={textareaRef}
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={`Message #${channelName}`}
className="flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]"
rows={1}
disabled={isUploading}
/>
{/* Send indicator */}
{isUploading && (
<div className="p-3 text-discord-text-muted">
<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" />
</svg>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { Message } from './Message';
import { useChatStore } from '../../stores/chatStore';
import { LoadingSpinner } from '../ui/LoadingSpinner';
const EMPTY_MESSAGES = [];
function isSameGroup(prev, curr) {
if (prev.userId !== curr.userId)
return false;
const timeDiff = curr.createdAt - prev.createdAt;
return timeDiff < 5 * 60 * 1000; // 5 minutes
}
function formatDateDivider(timestamp) {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function shouldShowDateDivider(prev, curr) {
if (!prev)
return true;
const prevDate = new Date(prev.createdAt).toDateString();
const currDate = new Date(curr.createdAt).toDateString();
return prevDate !== currDate;
}
export function MessageList({ channelId }) {
const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES;
const loadMessages = useChatStore((s) => s.loadMessages);
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
const isLoading = useChatStore((s) => s.isLoading);
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
const bottomRef = useRef(null);
const containerRef = useRef(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const prevMessagesLength = useRef(0);
useEffect(() => {
loadMessages(channelId);
}, [channelId, loadMessages]);
// Auto-scroll to bottom on new messages (if near bottom)
useEffect(() => {
if (messages.length > prevMessagesLength.current && isNearBottom) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
prevMessagesLength.current = messages.length;
}, [messages.length, isNearBottom]);
// Scroll to bottom on initial load
useEffect(() => {
if (messages.length > 0 && prevMessagesLength.current === 0) {
bottomRef.current?.scrollIntoView();
}
}, [messages.length]);
const handleScroll = useCallback(async () => {
const container = containerRef.current;
if (!container)
return;
// Check if near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
setIsNearBottom(distanceFromBottom < 100);
// Load more when scrolled to top
if (container.scrollTop < 50 && hasMore && !isLoadingMore) {
setIsLoadingMore(true);
const prevScrollHeight = container.scrollHeight;
const loaded = await loadMoreMessages(channelId);
if (loaded) {
// Maintain scroll position
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight - prevScrollHeight;
});
}
setIsLoadingMore(false);
}
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]);
if (isLoading && messages.length === 0) {
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) }));
}
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-6 pb-4", children: [_jsx("h3", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-muted text-sm mt-1", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-4 border-b border-discord-bg-hover" })] })), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => {
const prevMsg = messages[i - 1];
const showDate = shouldShowDateDivider(prevMsg, msg);
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
return (_jsxs(React.Fragment, { children: [showDate && (_jsxs("div", { className: "flex items-center px-4 my-4", children: [_jsx("div", { className: "flex-1 border-t border-discord-bg-hover" }), _jsx("span", { className: "px-2 text-xs font-semibold text-discord-text-muted", children: formatDateDivider(msg.createdAt) }), _jsx("div", { className: "flex-1 border-t border-discord-bg-hover" })] })), _jsx(Message, { message: msg, isCompact: !isFirstInGroup, isFirstInGroup: isFirstInGroup })] }, msg.id));
}) }), _jsx("div", { ref: bottomRef })] }));
}
@@ -0,0 +1,148 @@
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { Message } from './Message';
import { useChatStore } from '../../stores/chatStore';
import { LoadingSpinner } from '../ui/LoadingSpinner';
import type { MessageWithUser } from '@opencord/shared';
const EMPTY_MESSAGES: MessageWithUser[] = [];
interface MessageListProps {
channelId: string;
}
function isSameGroup(prev: MessageWithUser, curr: MessageWithUser): boolean {
if (prev.userId !== curr.userId) return false;
const timeDiff = curr.createdAt - prev.createdAt;
return timeDiff < 5 * 60 * 1000; // 5 minutes
}
function formatDateDivider(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function shouldShowDateDivider(prev: MessageWithUser | undefined, curr: MessageWithUser): boolean {
if (!prev) return true;
const prevDate = new Date(prev.createdAt).toDateString();
const currDate = new Date(curr.createdAt).toDateString();
return prevDate !== currDate;
}
export function MessageList({ channelId }: MessageListProps) {
const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES;
const loadMessages = useChatStore((s) => s.loadMessages);
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
const isLoading = useChatStore((s) => s.isLoading);
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const prevMessagesLength = useRef(0);
useEffect(() => {
loadMessages(channelId);
}, [channelId, loadMessages]);
// Auto-scroll to bottom on new messages (if near bottom)
useEffect(() => {
if (messages.length > prevMessagesLength.current && isNearBottom) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
prevMessagesLength.current = messages.length;
}, [messages.length, isNearBottom]);
// Scroll to bottom on initial load
useEffect(() => {
if (messages.length > 0 && prevMessagesLength.current === 0) {
bottomRef.current?.scrollIntoView();
}
}, [messages.length]);
const handleScroll = useCallback(async () => {
const container = containerRef.current;
if (!container) return;
// Check if near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
setIsNearBottom(distanceFromBottom < 100);
// Load more when scrolled to top
if (container.scrollTop < 50 && hasMore && !isLoadingMore) {
setIsLoadingMore(true);
const prevScrollHeight = container.scrollHeight;
const loaded = await loadMoreMessages(channelId);
if (loaded) {
// Maintain scroll position
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight - prevScrollHeight;
});
}
setIsLoadingMore(false);
}
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]);
if (isLoading && messages.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div
ref={containerRef}
className="flex-1 overflow-y-auto overflow-x-hidden"
onScroll={handleScroll}
>
{isLoadingMore && (
<div className="py-4">
<LoadingSpinner size={24} />
</div>
)}
{!hasMore && (
<div className="px-4 pt-6 pb-4">
<h3 className="text-2xl font-bold text-discord-text-primary">Welcome to the channel!</h3>
<p className="text-discord-text-muted text-sm mt-1">This is the start of the conversation.</p>
<div className="mt-4 border-b border-discord-bg-hover" />
</div>
)}
<div className="pb-6">
{messages.map((msg, i) => {
const prevMsg = messages[i - 1];
const showDate = shouldShowDateDivider(prevMsg, msg);
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
return (
<React.Fragment key={msg.id}>
{showDate && (
<div className="flex items-center px-4 my-4">
<div className="flex-1 border-t border-discord-bg-hover" />
<span className="px-2 text-xs font-semibold text-discord-text-muted">
{formatDateDivider(msg.createdAt)}
</span>
<div className="flex-1 border-t border-discord-bg-hover" />
</div>
)}
<Message
message={msg}
isCompact={!isFirstInGroup}
isFirstInGroup={isFirstInGroup}
/>
</React.Fragment>
);
})}
</div>
<div ref={bottomRef} />
</div>
);
}
@@ -0,0 +1,29 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useMemo } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore';
export function TypingIndicator({ channelId }) {
const typingUsersRaw = useChatStore((s) => s.typingUsers.get(channelId));
const currentUserId = useAuthStore((s) => s.user?.id);
// Filter out current user and expired entries
const others = useMemo(() => {
if (!typingUsersRaw || typingUsersRaw.length === 0)
return [];
const now = Date.now();
return typingUsersRaw
.filter(t => now - t.timestamp < 5000 && t.userId !== currentUserId);
}, [typingUsersRaw, currentUserId]);
if (others.length === 0)
return null;
let text = '';
if (others.length === 1) {
text = `${others[0].username} is typing`;
}
else if (others.length === 2) {
text = `${others[0].username} and ${others[1].username} are typing`;
}
else {
text = 'Several people are typing';
}
return (_jsx("div", { className: "h-6 px-4 flex items-center text-xs text-discord-text-muted", children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs("span", { className: "flex gap-0.5", children: [_jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '0ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '150ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '300ms' } })] }), _jsx("span", { className: "font-medium", children: text }), _jsx("span", { children: "..." })] }) }));
}
@@ -0,0 +1,45 @@
import React, { useMemo } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore';
interface TypingIndicatorProps {
channelId: string;
}
export function TypingIndicator({ channelId }: TypingIndicatorProps) {
const typingUsersRaw = useChatStore((s) => s.typingUsers.get(channelId));
const currentUserId = useAuthStore((s) => s.user?.id);
// Filter out current user and expired entries
const others = useMemo(() => {
if (!typingUsersRaw || typingUsersRaw.length === 0) return [];
const now = Date.now();
return typingUsersRaw
.filter(t => now - t.timestamp < 5000 && t.userId !== currentUserId);
}, [typingUsersRaw, currentUserId]);
if (others.length === 0) return null;
let text = '';
if (others.length === 1) {
text = `${others[0]!.username} is typing`;
} else if (others.length === 2) {
text = `${others[0]!.username} and ${others[1]!.username} are typing`;
} else {
text = 'Several people are typing';
}
return (
<div className="h-6 px-4 flex items-center text-xs text-discord-text-muted">
<div className="flex items-center gap-1">
<span className="flex gap-0.5">
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
<span className="font-medium">{text}</span>
<span>...</span>
</div>
</div>
);
}