From 45177466925527f2aba9f4f6a4b9b88b6efaf179 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Fri, 6 Mar 2026 11:54:19 -0500 Subject: [PATCH] Initial Commit --- frontend/index.html | 17 ++ frontend/package.json | 25 ++ frontend/public/icons/icon-192.png | Bin 0 -> 1021 bytes frontend/public/icons/icon-512.png | Bin 0 -> 4280 bytes frontend/public/manifest.json | 37 +++ frontend/public/sw.js | 80 ++++++ frontend/src/App.jsx | 45 +++ frontend/src/components/Avatar.jsx | 24 ++ frontend/src/components/ChatWindow.css | 142 +++++++++ frontend/src/components/ChatWindow.jsx | 252 ++++++++++++++++ frontend/src/components/GroupInfoModal.jsx | 171 +++++++++++ frontend/src/components/ImageLightbox.jsx | 78 +++++ frontend/src/components/Message.css | 266 +++++++++++++++++ frontend/src/components/Message.jsx | 249 ++++++++++++++++ frontend/src/components/MessageInput.css | 168 +++++++++++ frontend/src/components/MessageInput.jsx | 247 ++++++++++++++++ frontend/src/components/NewChatModal.jsx | 124 ++++++++ frontend/src/components/ProfileModal.jsx | 146 ++++++++++ frontend/src/components/SettingsModal.jsx | 243 ++++++++++++++++ frontend/src/components/Sidebar.css | 201 +++++++++++++ frontend/src/components/Sidebar.jsx | 222 ++++++++++++++ frontend/src/components/SupportModal.jsx | 199 +++++++++++++ frontend/src/components/UserManagerModal.jsx | 288 +++++++++++++++++++ frontend/src/components/UserProfilePopup.jsx | 81 ++++++ frontend/src/contexts/AuthContext.jsx | 58 ++++ frontend/src/contexts/SocketContext.jsx | 46 +++ frontend/src/contexts/ToastContext.jsx | 28 ++ frontend/src/index.css | 199 +++++++++++++ frontend/src/main.jsx | 25 ++ frontend/src/pages/ChangePassword.jsx | 60 ++++ frontend/src/pages/Chat.css | 12 + frontend/src/pages/Chat.jsx | 171 +++++++++++ frontend/src/pages/Login.css | 106 +++++++ frontend/src/pages/Login.jsx | 129 +++++++++ frontend/src/utils/api.js | 98 +++++++ frontend/vite.config.js | 25 ++ 36 files changed, 4262 insertions(+) create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/icons/icon-192.png create mode 100644 frontend/public/icons/icon-512.png create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/sw.js create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/components/Avatar.jsx create mode 100644 frontend/src/components/ChatWindow.css create mode 100644 frontend/src/components/ChatWindow.jsx create mode 100644 frontend/src/components/GroupInfoModal.jsx create mode 100644 frontend/src/components/ImageLightbox.jsx create mode 100644 frontend/src/components/Message.css create mode 100644 frontend/src/components/Message.jsx create mode 100644 frontend/src/components/MessageInput.css create mode 100644 frontend/src/components/MessageInput.jsx create mode 100644 frontend/src/components/NewChatModal.jsx create mode 100644 frontend/src/components/ProfileModal.jsx create mode 100644 frontend/src/components/SettingsModal.jsx create mode 100644 frontend/src/components/Sidebar.css create mode 100644 frontend/src/components/Sidebar.jsx create mode 100644 frontend/src/components/SupportModal.jsx create mode 100644 frontend/src/components/UserManagerModal.jsx create mode 100644 frontend/src/components/UserProfilePopup.jsx create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/contexts/SocketContext.jsx create mode 100644 frontend/src/contexts/ToastContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/ChangePassword.jsx create mode 100644 frontend/src/pages/Chat.css create mode 100644 frontend/src/pages/Chat.jsx create mode 100644 frontend/src/pages/Login.css create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/utils/api.js create mode 100644 frontend/vite.config.js diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1898338 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + TeamChat + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c99edac --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "teamchat-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0", + "socket.io-client": "^4.6.1", + "emoji-mart": "^5.5.2", + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", + "papaparse": "^5.4.1", + "date-fns": "^3.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.1.4" + } +} diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..7887c7ecf668f5cbd9a8266aeb08dcb422045cc0 GIT binary patch literal 1021 zcmZ{iYe*DP6vzK}_BCx=J62Yhk5P+WMr&G>TFu64nz^M!8OXLl_$k4lbO>2*n}${< zeOn~K$`Bz+p)xAmF*o~CmIb{ZR7ek$R!rQ`*6x_HmUQ9HIse~(&OO{SQ@zu^b(S&0 z2w+xLrY(;}McurfJ@eiiGyoc3md(0Le%1fBCa2Rfud=7Rqb}j${6bPK`+(I>~+;V9~PaUPOif+&j+Hkwcstw)8K z!(9VGPba9~3Tae0zzNIvv(V1ivKYiI%#;obHcp7(TjA4L6Od~aW=NT$j}y$=R@gKQ zwMq<$D$+6J14Ci?30U3UpCP(V{k1`f=*mu?ivK}VPGS{*4vXY-I`96ej?B3VeTZ;y%1^BS;|tAK?XD-re@dJ zSMF{;855$^!S3T+=L!)Vjb+z;8JK9$5X%PTshKQ^h?_KNDb7`}vcga7H^n<$vGIQ| z?jWZ##&Vym@eVdcLi=ok3nsc3LjLmrv0yi-lG0_Sq{Ya*Rl#0}E%pJ86PNWkcBI;C zVl+0G2jRt6mG0uGVm~uBhw0{leR^=IiNS0lIh*dWBZ`-R?`sUSu_Nu(<%^i82(+<+ zlKb8$Xm2D1;~|Mb8A8?OXlOsy8sPR5DvdJgf+%c;$j5;9nVDyR_Jan;*c7Y*P_Yb> zu!+^hK&UXobm5P!R$G5S5sxCyD*r+i_hE>L`pS{nO3~Sy``{r?J>A>mBH=&l;`cv_ QYiEN(R=VAGWsB4O8(!87SpWb4 literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..b0b341a9e942c6849b50444c0bab5ea7ea0c518c GIT binary patch literal 4280 zcmbVQe{2(F7=G_|+X@@(>W~#YcO8pDbO>|C%^$2cQY}*ie-JgIZZc_%A}isa>3;Oo z;UJkT#zBV8Vl){tx8e+9GG$f5it9F?@9VX@tz}I9S?})o z-sgRu_kF+izU_HdLX z08V+b0eIBn1fVnq=dnAF`V}%MO|s}D-^Ge-T@~0P#N!d-@d)u}GYb&kqBb+2QBDI8 zsjwj;A^-r)fEJZSC{5a|K(s>Q5d}QL66e>T)FcdNx@Qtb1$!b>P?h0LNs2JecI_qz z@49*mcP!#5hqRS4~mLU~6%0rtva5(t4=3p(IdRNJ}SwK3Kf3 zy*BV(uHbtNp7^rxNo4U~3_4Eqme%xsOh<1*75TD=SJH-DmtW#J&pl85tfhQeLy6cC zc14Q=xBqj}vGZ=);L&L{zZq;rp}4T2{dUS~JHFM+wRB0wH?WyN#u7+ry76&pq$}On zV@0NtSDy`c^~PicsO}z%U}IvawtWe=@`*6wwV1RNuX7}_*&EZola5_)ab>Av(Y+J< z+Ii{v^QqQtG1%vit}Nl@ZkK6ruZ7rB?8W*16TQ4aA4*24wLzA;?+OmyUcx(4DU5uQ z+N(vqUG`?3Yd{h;dNL&9M`Y^42zMDP>m?dy z4R|e!By4-@#pX*kjG}0WUBjQNwHOiV#QF=t{rXcBIknPvVqh$MbMQk$r1S zUGyLJ;A=sq#YeYZ7_ZB9QuMre`S5&l9ZBDQx*n=}=a_nmCNAMKA>=t?%DIdJ75M}m z8-LoxiG0)UzRyR89>SM=+({B03A(As2Bw&?2Pm>5ZqhTG$&sF?Lf4W(qw3c-Um9pD z#ScJM|KZW5WAkJ*DTzunOw2}H8G3?mxbFs0ecqlts&h@Ox3f;=p$F!(MDOhAjg(|> zuE`VqGYg-hQW_EzvAF<7oqSf%2bs7Lp8GZ$fjNl{P&&jj$r`|cxc4#^8 zMPpx`D{z1h&ggUXuE6xb^H&c_8v!d26(0c%I-CvvIhLHHwoICP_kN`f_h=ORXQhj_LpETV8Qiy<)SxNWF2<6?|VI zE-rbajqIksHHZFYgTQNmcK~bx)~mkUUDkZp8pp@r8g!yZ{7BMmWMJ;_)p85Q0q>XT>}Bo&G@g{n7x;%?z)`5=(>_`ktz z8A+K5CQ(pUlTt}NlVGda#pgfUA1 zWfRz9sE~}DtXQZG$_GNlFMyEMEC$4AS?T;{J*MTG0r;YJ9_QhNDvMD3tRZnA52p(| zx6@2|&nhMevfvOt;gI=sbDt()qlGR8FuiWTg^19H+DH}`&kKzd^2N9Vi%ZmOR2Idi z;L8|Gn_9sh#fjq?Sv)c#f(m$qNi6C;0IV6CiRFl8G=W$|90*0;K!o0t!>E|jW=<%J zERF#==OG zJrSpIL@-!)usC|1G6CB}r9md=eBUqtr_l}IOKQmnkfyu}V4?DgM)c0w?t?j)7F?1@ mm;-Db%nsW%*8dkw{++i{IsQatUi5uDr^@>_xIbN6{rZ1)!+G8S literal 0 HcmV?d00001 diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..a0c2d06 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "TeamChat", + "short_name": "TeamChat", + "description": "Modern team messaging application", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#ffffff", + "theme_color": "#1a73e8", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..9b9de64 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,80 @@ +const CACHE_NAME = 'teamchat-v2'; +const STATIC_ASSETS = ['/']; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const url = event.request.url; + if (url.includes('/api/') || url.includes('/socket.io/') || url.includes('/manifest.json')) { + return; + } + event.respondWith( + fetch(event.request).catch(() => caches.match(event.request)) + ); +}); + +// Track badge count in SW +let badgeCount = 0; + +self.addEventListener('push', (event) => { + if (!event.data) return; + const data = event.data.json(); + + badgeCount++; + + // Update app badge (supported on Android Chrome and some desktop) + if (navigator.setAppBadge) { + navigator.setAppBadge(badgeCount).catch(() => {}); + } + + event.waitUntil( + self.registration.showNotification(data.title || 'New Message', { + body: data.body || '', + icon: '/icons/icon-192.png', + badge: '/icons/icon-192.png', + data: { url: data.url || '/' }, + tag: 'teamchat-message', // replaces previous notification instead of stacking + renotify: true, // still vibrate/sound even if replacing + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + badgeCount = 0; + if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {}); + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + const url = event.notification.data?.url || '/'; + for (const client of clientList) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + client.focus(); + return; + } + } + return clients.openWindow(url); + }) + ); +}); + +// Clear badge when user opens the app +self.addEventListener('message', (event) => { + if (event.data?.type === 'CLEAR_BADGE') { + badgeCount = 0; + if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {}); + } +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..df4f2e9 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,45 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './contexts/AuthContext.jsx'; +import { SocketProvider } from './contexts/SocketContext.jsx'; +import { ToastProvider } from './contexts/ToastContext.jsx'; +import Login from './pages/Login.jsx'; +import Chat from './pages/Chat.jsx'; +import ChangePassword from './pages/ChangePassword.jsx'; + +function ProtectedRoute({ children }) { + const { user, loading, mustChangePassword } = useAuth(); + if (loading) return ( +
+
+
+ ); + if (!user) return ; + if (mustChangePassword) return ; + return children; +} + +function AuthRoute({ children }) { + const { user, loading, mustChangePassword } = useAuth(); + if (loading) return null; + if (user && !mustChangePassword) return ; + return children; +} + +export default function App() { + return ( + + + + + + } /> + } /> + } /> + } /> + + + + + + ); +} diff --git a/frontend/src/components/Avatar.jsx b/frontend/src/components/Avatar.jsx new file mode 100644 index 0000000..41ad42a --- /dev/null +++ b/frontend/src/components/Avatar.jsx @@ -0,0 +1,24 @@ +export default function Avatar({ user, size = 'md', className = '' }) { + if (!user) return null; + + const initials = (() => { + const name = user.display_name || user.name || ''; + const parts = name.trim().split(' ').filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return '??'; + })(); + + const colors = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7']; + const colorIdx = (user.name || '').charCodeAt(0) % colors.length; + const bg = colors[colorIdx]; + + return ( +
+ {user.avatar + ? {initials} + : initials + } +
+ ); +} diff --git a/frontend/src/components/ChatWindow.css b/frontend/src/components/ChatWindow.css new file mode 100644 index 0000000..065ee3f --- /dev/null +++ b/frontend/src/components/ChatWindow.css @@ -0,0 +1,142 @@ +.chat-window { + flex: 1; + display: flex; + flex-direction: column; + background: var(--surface-variant); + overflow: hidden; + min-width: 0; +} + +.chat-window.empty { + align-items: center; + justify-content: center; +} + +.empty-state { + text-align: center; + color: var(--text-secondary); +} + +.empty-icon { + margin-bottom: 16px; + opacity: 0.3; +} + +.empty-state h3 { + font-size: 18px; + margin-bottom: 8px; + color: var(--text-primary); +} + +.empty-state p { font-size: 14px; } + +/* Header */ +.chat-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: white; + border-bottom: 1px solid var(--border); + min-height: 64px; + position: relative; + z-index: 10; +} + +.group-icon-sm { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 700; + color: white; + flex-shrink: 0; +} + +.chat-header-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.chat-header-sub { + font-size: 12px; + color: var(--text-secondary); +} + +.readonly-badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background: #fff3e0; + color: #e65100; + font-weight: 500; +} + +/* Messages */ +.messages-container { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.load-more-btn { + align-self: center; + font-size: 13px; + color: var(--primary); + padding: 8px 16px; + border-radius: 20px; + background: var(--primary-light); + margin-bottom: 8px; + transition: var(--transition); +} +.load-more-btn:hover { background: #d2e3fc; } + +/* Typing indicator */ +.typing-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 13px; + color: var(--text-secondary); +} + +.dots { + display: flex; + gap: 3px; +} + +.dots span { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--text-tertiary); + animation: bounce 1.2s infinite; +} +.dots span:nth-child(2) { animation-delay: 0.2s; } +.dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes bounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-5px); } +} + +/* Readonly bar */ +.readonly-bar { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + background: white; + border-top: 1px solid var(--border); + font-size: 14px; + color: var(--text-secondary); +} diff --git a/frontend/src/components/ChatWindow.jsx b/frontend/src/components/ChatWindow.jsx new file mode 100644 index 0000000..3bcd600 --- /dev/null +++ b/frontend/src/components/ChatWindow.jsx @@ -0,0 +1,252 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useSocket } from '../contexts/SocketContext.jsx'; +import { useAuth } from '../contexts/AuthContext.jsx'; +import { useToast } from '../contexts/ToastContext.jsx'; +import { api } from '../utils/api.js'; +import Message from './Message.jsx'; +import MessageInput from './MessageInput.jsx'; +import GroupInfoModal from './GroupInfoModal.jsx'; +import './ChatWindow.css'; + +export default function ChatWindow({ group, onBack, onGroupUpdated }) { + const { socket } = useSocket(); + const { user } = useAuth(); + const toast = useToast(); + + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [replyTo, setReplyTo] = useState(null); + const [showInfo, setShowInfo] = useState(false); + const [iconGroupInfo, setIconGroupInfo] = useState(''); + const [typing, setTyping] = useState([]); + const messagesEndRef = useRef(null); + const messagesTopRef = useRef(null); + const typingTimers = useRef({}); + + useEffect(() => { + api.getSettings().then(({ settings }) => { + setIconGroupInfo(settings.icon_groupinfo || ''); + }).catch(() => {}); + const handler = () => api.getSettings().then(({ settings }) => setIconGroupInfo(settings.icon_groupinfo || '')).catch(() => {}); + window.addEventListener('teamchat:settings-changed', handler); + return () => window.removeEventListener('teamchat:settings-changed', handler); + }, []); + + const scrollToBottom = useCallback((smooth = false) => { + messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' }); + }, []); + + useEffect(() => { + if (!group) { setMessages([]); return; } + setMessages([]); + setHasMore(false); + setLoading(true); + api.getMessages(group.id) + .then(({ messages }) => { + setMessages(messages); + setHasMore(messages.length >= 50); + setTimeout(() => scrollToBottom(), 50); + }) + .catch(e => toast(e.message, 'error')) + .finally(() => setLoading(false)); + }, [group?.id]); + + // Socket events + useEffect(() => { + if (!socket || !group) return; + + const handleNew = (msg) => { + if (msg.group_id !== group.id) return; + setMessages(prev => { + if (prev.find(m => m.id === msg.id)) return prev; + return [...prev, msg]; + }); + setTimeout(() => scrollToBottom(true), 50); + }; + + const handleDeleted = ({ messageId }) => { + setMessages(prev => prev.filter(m => m.id !== messageId)); + }; + + const handleReaction = ({ messageId, reactions }) => { + setMessages(prev => prev.map(m => m.id === messageId ? { ...m, reactions } : m)); + }; + + const handleTypingStart = ({ userId: tid, user: tu }) => { + if (tid === user.id) return; + setTyping(prev => prev.find(t => t.userId === tid) ? prev : [...prev, { userId: tid, name: tu?.display_name || tu?.name || 'Someone' }]); + if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]); + typingTimers.current[tid] = setTimeout(() => { + setTyping(prev => prev.filter(t => t.userId !== tid)); + }, 3000); + }; + + const handleTypingStop = ({ userId: tid }) => { + setTyping(prev => prev.filter(t => t.userId !== tid)); + if (typingTimers.current[tid]) clearTimeout(typingTimers.current[tid]); + }; + + socket.on('message:new', handleNew); + socket.on('message:deleted', handleDeleted); + socket.on('reaction:updated', handleReaction); + socket.on('typing:start', handleTypingStart); + socket.on('typing:stop', handleTypingStop); + + return () => { + socket.off('message:new', handleNew); + socket.off('message:deleted', handleDeleted); + socket.off('reaction:updated', handleReaction); + socket.off('typing:start', handleTypingStart); + socket.off('typing:stop', handleTypingStop); + }; + }, [socket, group?.id, user.id]); + + const loadMore = async () => { + if (!messages.length) return; + const oldest = messages[0]; + const { messages: older } = await api.getMessages(group.id, oldest.id); + setMessages(prev => [...older, ...prev]); + setHasMore(older.length >= 50); + }; + + const handleSend = async ({ content, imageFile, linkPreview }) => { + if (!group) return; + const replyId = replyTo?.id; + setReplyTo(null); + + try { + if (imageFile) { + const { message } = await api.uploadImage(group.id, imageFile, { replyToId: replyId, content }); + // Add immediately to local state โ€” don't wait for socket (it may be slow for large files) + if (message) { + setMessages(prev => prev.find(m => m.id === message.id) ? prev : [...prev, message]); + setTimeout(() => scrollToBottom(true), 50); + } + } else { + socket?.emit('message:send', { + groupId: group.id, content, replyToId: replyId, linkPreview + }); + } + } catch (e) { + toast(e.message, 'error'); + } + }; + + if (!group) { + return ( +
+
+
+ + + +
+

Select a conversation

+

Choose from your existing chats or start a new one

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ {onBack && ( + + )} +
+ {group.type === 'public' ? '#' : group.name[0]?.toUpperCase()} +
+
+
+ {group.name} + {group.is_readonly ? ( + Read-only + ) : null} +
+ + {group.type === 'public' ? 'Public channel' : 'Private group'} + +
+ +
+ + {/* Messages */} +
+ {hasMore && ( + + )} + {loading ? ( +
+
+
+ ) : ( + <> + {messages.map((msg, i) => ( + setReplyTo(m)} + onDelete={(id) => socket?.emit('message:delete', { messageId: id })} + onReact={(id, emoji) => socket?.emit('reaction:toggle', { messageId: id, emoji })} + /> + ))} + {typing.length > 0 && ( +
+ {typing.map(t => t.name).join(', ')} {typing.length === 1 ? 'is' : 'are'} typing + +
+ )} +
+ + )} +
+ + {/* Input */} + {(!group.is_readonly || user.role === 'admin') ? ( + setReplyTo(null)} + onSend={handleSend} + onTyping={(isTyping) => { + if (socket) { + if (isTyping) socket.emit('typing:start', { groupId: group.id }); + else socket.emit('typing:stop', { groupId: group.id }); + } + }} + /> + ) : ( +
+ + This channel is read-only +
+ )} + + {showInfo && ( + setShowInfo(false)} + onUpdated={onGroupUpdated} + /> + )} +
+ ); +} diff --git a/frontend/src/components/GroupInfoModal.jsx b/frontend/src/components/GroupInfoModal.jsx new file mode 100644 index 0000000..3a011cd --- /dev/null +++ b/frontend/src/components/GroupInfoModal.jsx @@ -0,0 +1,171 @@ +import { useState, useEffect } from 'react'; +import { useAuth } from '../contexts/AuthContext.jsx'; +import { api } from '../utils/api.js'; +import { useToast } from '../contexts/ToastContext.jsx'; +import Avatar from './Avatar.jsx'; + +export default function GroupInfoModal({ group, onClose, onUpdated }) { + const { user } = useAuth(); + const toast = useToast(); + const [members, setMembers] = useState([]); + const [editing, setEditing] = useState(false); + const [newName, setNewName] = useState(group.name); + const [addSearch, setAddSearch] = useState(''); + const [addResults, setAddResults] = useState([]); + const [loading, setLoading] = useState(false); + + const isOwner = group.owner_id === user.id; + const isAdmin = user.role === 'admin'; + const canManage = (group.type === 'private' && isOwner) || (group.type === 'public' && isAdmin); + const canRename = !group.is_default && ((group.type === 'public' && isAdmin) || (group.type === 'private' && isOwner)); + + useEffect(() => { + if (group.type === 'private') { + api.getMembers(group.id).then(({ members }) => setMembers(members)).catch(() => {}); + } + }, [group.id]); + + useEffect(() => { + if (addSearch) { + api.searchUsers(addSearch).then(({ users }) => setAddResults(users)).catch(() => {}); + } + }, [addSearch]); + + const handleRename = async () => { + if (!newName.trim() || newName === group.name) { setEditing(false); return; } + try { + await api.renameGroup(group.id, newName.trim()); + toast('Group renamed', 'success'); + onUpdated(); + setEditing(false); + } catch (e) { toast(e.message, 'error'); } + }; + + const handleLeave = async () => { + if (!confirm('Leave this group?')) return; + try { + await api.leaveGroup(group.id); + toast('Left group', 'success'); + onUpdated(); + onClose(); + } catch (e) { toast(e.message, 'error'); } + }; + + const handleTakeOwnership = async () => { + if (!confirm('Take ownership of this private group? You will be able to see all messages.')) return; + try { + await api.takeOwnership(group.id); + toast('Ownership taken', 'success'); + onUpdated(); + onClose(); + } catch (e) { toast(e.message, 'error'); } + }; + + const handleAdd = async (u) => { + try { + await api.addMember(group.id, u.id); + toast(`${u.display_name || u.name} added`, 'success'); + api.getMembers(group.id).then(({ members }) => setMembers(members)); + setAddSearch(''); + setAddResults([]); + } catch (e) { toast(e.message, 'error'); } + }; + + const handleDelete = async () => { + if (!confirm('Delete this group? This cannot be undone.')) return; + try { + await api.deleteGroup(group.id); + toast('Group deleted', 'success'); + onUpdated(); + onClose(); + } catch (e) { toast(e.message, 'error'); } + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

Group Info

+ +
+ + {/* Name */} +
+ {editing ? ( +
+ setNewName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && handleRename()} /> + + +
+ ) : ( +
+

{group.name}

+ {canRename && ( + + )} +
+ )} +
+ + {group.type === 'public' ? 'Public channel' : 'Private group'} + + {group.is_readonly && Read-only} +
+
+ + {/* Members (private groups) */} + {group.type === 'private' && ( +
+
+ Members ({members.length}) +
+
+ {members.map(m => ( +
+ + {m.display_name || m.name} + {m.id === group.owner_id && Owner} +
+ ))} +
+ + {canManage && ( +
+ setAddSearch(e.target.value)} /> + {addResults.length > 0 && addSearch && ( +
+ {addResults.filter(u => !members.find(m => m.id === u.id)).map(u => ( + + ))} +
+ )} +
+ )} +
+ )} + + {/* Actions */} +
+ {group.type === 'private' && group.owner_id !== user.id && ( + + )} + {isAdmin && group.type === 'private' && group.owner_id !== user.id && ( + + )} + {(isOwner || (isAdmin && group.type === 'public')) && !group.is_default && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/ImageLightbox.jsx b/frontend/src/components/ImageLightbox.jsx new file mode 100644 index 0000000..de843f0 --- /dev/null +++ b/frontend/src/components/ImageLightbox.jsx @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from 'react'; + +export default function ImageLightbox({ src, onClose }) { + const overlayRef = useRef(null); + const imgRef = useRef(null); + + // Close on Escape + useEffect(() => { + const handler = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onClose]); + + return ( +
e.target === overlayRef.current && onClose()} + style={{ + position: 'fixed', inset: 0, zIndex: 2000, + background: 'rgba(0,0,0,0.92)', + display: 'flex', alignItems: 'center', justifyContent: 'center', + touchAction: 'pinch-zoom', + }} + > + {/* Close button */} + + + {/* Download button */} + + + + + + + + + {/* Image โ€” fit to screen, browser handles pinch-zoom natively */} + Full size e.stopPropagation()} + /> +
+ ); +} diff --git a/frontend/src/components/Message.css b/frontend/src/components/Message.css new file mode 100644 index 0000000..1f7f573 --- /dev/null +++ b/frontend/src/components/Message.css @@ -0,0 +1,266 @@ +.date-separator { + display: flex; + align-items: center; + justify-content: center; + margin: 12px 0 8px; +} + +.date-separator span { + background: rgba(0,0,0,0.06); + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + color: var(--text-secondary); + font-weight: 500; +} + +.message-wrapper { + display: flex; + align-items: flex-end; + gap: 8px; + padding: 1px 0; + position: relative; +} + +.message-wrapper.own { flex-direction: row-reverse; } +.message-wrapper.grouped { margin-top: 1px; } +.message-wrapper:not(.grouped) { margin-top: 8px; } + +.msg-avatar { flex-shrink: 0; } + +.message-body { + display: flex; + flex-direction: column; + max-width: 65%; + min-width: 0; +} + +.own .message-body { align-items: flex-end; } + +.msg-name { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 3px; + padding: 0 12px; +} + +/* Reply preview */ +.reply-preview { + display: flex; + gap: 8px; + background: rgba(0,0,0,0.05); + border-radius: 8px 8px 0 0; + padding: 6px 10px; + margin-bottom: -4px; + max-width: 280px; +} + +.reply-bar { width: 3px; background: var(--primary); border-radius: 2px; flex-shrink: 0; } + +.reply-name { font-size: 11px; font-weight: 600; color: var(--primary); } +.reply-text { font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; } + +/* Bubble row */ +.msg-bubble-wrap { + display: flex; + align-items: flex-end; + gap: 6px; +} + +.own .msg-bubble-wrap { flex-direction: row-reverse; } + +/* Wrapper that holds the actions toolbar + bubble together */ +.msg-bubble-with-actions { + position: relative; + display: flex; + flex-direction: column; +} + +/* Actions toolbar โ€” floats above the bubble */ +.msg-actions { + display: flex; + align-items: center; + gap: 2px; + background: white; + border-radius: 20px; + padding: 4px 6px; + box-shadow: var(--shadow-md); + position: absolute; + top: -36px; + z-index: 20; + white-space: nowrap; +} + +/* Own messages: toolbar anchors to the right edge of bubble */ +.msg-actions.actions-left { right: 0; } +/* Other messages: toolbar anchors to the left edge of bubble */ +.msg-actions.actions-right { left: 0; } + +.quick-emoji { + font-size: 16px; + padding: 4px; + border-radius: 50%; + transition: var(--transition); + cursor: pointer; + line-height: 1; +} +.quick-emoji:hover { background: var(--background); transform: scale(1.2); } + +.action-btn { + width: 28px; + height: 28px; + color: var(--text-secondary); +} +.action-btn:hover { color: var(--text-primary); } +.action-btn.danger:hover { color: var(--error); } + +/* Emoji picker โ€” anchored relative to the toolbar */ +.emoji-picker-wrap { + position: absolute; + top: -360px; /* above the toolbar by default */ + z-index: 100; +} +.emoji-picker-wrap.picker-right { left: 0; } +.emoji-picker-wrap.picker-left { right: 0; } +/* When message is near top of window, open picker downward instead */ +.emoji-picker-wrap.picker-down { + top: 36px; +} + +/* Bubble */ +.msg-bubble { + padding: 8px 12px; + border-radius: 18px; + max-width: 100%; + word-break: break-word; + position: relative; +} + +.msg-bubble.out { + background: var(--primary); + color: white; + border-bottom-right-radius: 4px; +} + +.msg-bubble.in { + background: white; + color: var(--text-primary); + border-bottom-left-radius: 4px; + box-shadow: var(--shadow-sm); +} + +.msg-bubble.deleted { + background: transparent !important; + border: 1px dashed var(--border); +} + +.deleted-text { font-size: 13px; color: var(--text-tertiary); font-style: italic; } + +.msg-text { + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; +} + +.mention { + color: #1a5ca8; + font-weight: 600; + background: rgba(26,92,168,0.1); + border-radius: 3px; + padding: 0 2px; +} + +.out .mention { color: #afd0ff; background: rgba(255,255,255,0.15); } + +.msg-image { + max-width: 240px; + max-height: 240px; + border-radius: 12px; + display: block; + cursor: pointer; + object-fit: cover; +} + +.msg-time { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + flex-shrink: 0; + padding-bottom: 4px; +} + +/* Reactions */ +.reactions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; + padding: 0 4px; +} + +.reaction-btn { + display: flex; + align-items: center; + gap: 3px; + padding: 3px 8px; + border-radius: 12px; + background: white; + border: 1px solid var(--border); + font-size: 14px; + cursor: pointer; + transition: var(--transition); +} +.reaction-count { font-size: 12px; color: var(--text-secondary); } +.reaction-btn.active { background: var(--primary-light); border-color: var(--primary); } +.reaction-btn.active .reaction-count { color: var(--primary); } +.reaction-btn:hover { background: var(--primary-light); } +.reaction-remove { + font-size: 13px; + color: var(--primary); + font-weight: 700; + margin-left: 1px; + line-height: 1; + opacity: 0; + transition: opacity 0.15s; +} +.reaction-btn:hover .reaction-remove { opacity: 1; } + +/* Link preview */ +.link-preview { + display: flex; + gap: 10px; + background: rgba(0,0,0,0.06); + border-radius: 10px; + padding: 10px; + margin-top: 6px; + text-decoration: none; + max-width: 280px; + overflow: hidden; + transition: var(--transition); +} +.link-preview:hover { background: rgba(0,0,0,0.1); } + +.link-preview-img { + width: 60px; + height: 60px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; +} + +.link-preview-content { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.link-site { font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; } +.link-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +.link-desc { font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.out .link-preview { background: rgba(255,255,255,0.15); } +.out .link-title { color: white; } +.out .link-desc { color: rgba(255,255,255,0.8); } diff --git a/frontend/src/components/Message.jsx b/frontend/src/components/Message.jsx new file mode 100644 index 0000000..8a96e2b --- /dev/null +++ b/frontend/src/components/Message.jsx @@ -0,0 +1,249 @@ +import { useState, useRef, useEffect } from 'react'; +import Avatar from './Avatar.jsx'; +import UserProfilePopup from './UserProfilePopup.jsx'; +import ImageLightbox from './ImageLightbox.jsx'; +import Picker from '@emoji-mart/react'; +import data from '@emoji-mart/data'; +import './Message.css'; + +const QUICK_EMOJIS = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐Ÿ˜ฎ', '๐Ÿ˜ข', '๐Ÿ™']; + +function formatMsgContent(content) { + if (!content) return ''; + return content.replace(/@\[([^\]]+)\]\(\d+\)/g, (_, name) => `@${name}`); +} + +export default function Message({ message: msg, prevMessage, currentUser, onReply, onDelete, onReact }) { + const [showActions, setShowActions] = useState(false); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const wrapperRef = useRef(null); + const pickerRef = useRef(null); + const avatarRef = useRef(null); + const [showProfile, setShowProfile] = useState(false); + const [lightboxSrc, setLightboxSrc] = useState(null); + const [pickerOpensDown, setPickerOpensDown] = useState(false); + + const isOwn = msg.user_id === currentUser.id; + const isDeleted = !!msg.is_deleted; + + // Deleted messages are filtered out by ChatWindow, but guard here too + if (isDeleted) return null; + + const canDelete = ( + msg.user_id === currentUser.id || + (currentUser.role === 'admin' && msg.group_type !== 'private') || + (msg.group_owner_id === currentUser.id) + ); + + const prevSameUser = prevMessage && prevMessage.user_id === msg.user_id && + new Date(msg.created_at) - new Date(prevMessage.created_at) < 60000; + + const showDateSep = !prevMessage || + new Date(msg.created_at).toDateString() !== new Date(prevMessage.created_at).toDateString(); + + const reactionMap = {}; + for (const r of (msg.reactions || [])) { + if (!reactionMap[r.emoji]) reactionMap[r.emoji] = { count: 0, users: [], hasMe: false }; + reactionMap[r.emoji].count++; + reactionMap[r.emoji].users.push(r.user_name); + if (r.user_id === currentUser.id) reactionMap[r.emoji].hasMe = true; + } + + // Close emoji picker when clicking outside + useEffect(() => { + if (!showEmojiPicker) return; + const handler = (e) => { + if (pickerRef.current && !pickerRef.current.contains(e.target)) { + setShowEmojiPicker(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showEmojiPicker]); + + const handleReact = (emoji) => { + onReact(msg.id, emoji); + setShowEmojiPicker(false); + }; + + const handleTogglePicker = () => { + if (!showEmojiPicker && wrapperRef.current) { + // If the message is in the top 400px of viewport, open picker downward + const rect = wrapperRef.current.getBoundingClientRect(); + setPickerOpensDown(rect.top < 400); + } + setShowEmojiPicker(p => !p); + }; + + const msgUser = { + id: msg.user_id, + name: msg.user_name, + display_name: msg.user_display_name, + avatar: msg.user_avatar, + role: msg.user_role, + status: msg.user_status, + hide_admin_tag: msg.user_hide_admin_tag, + about_me: msg.user_about_me, + }; + + return ( + <> + {showDateSep && ( +
+ {formatDate(msg.created_at)} +
+ )} + +
setShowActions(true)} + onMouseLeave={() => { if (!showEmojiPicker) setShowActions(false); }} + > + {!isOwn && !prevSameUser && ( +
setShowProfile(p => !p)}> + +
+ )} + {!isOwn && prevSameUser &&
} + +
+ {!isOwn && !prevSameUser && ( +
+ {msgUser.display_name || msgUser.name} + {msgUser.role === 'admin' && !msgUser.hide_admin_tag && Admin} + {msgUser.status !== 'active' && (inactive)} +
+ )} + + {/* Reply preview */} + {msg.reply_to_id && ( +
+
+
+
{msg.reply_user_display_name || msg.reply_user_name}
+
+ {msg.reply_is_deleted ? Deleted message + : msg.reply_image_url ? '๐Ÿ“ท Image' + : msg.reply_content} +
+
+
+ )} + + {/* Bubble + actions together so actions hover above bubble */} +
+
+ {/* Actions toolbar โ€” floats above the bubble, aligned to correct side */} + {!isDeleted && (showActions || showEmojiPicker) && ( +
+ {QUICK_EMOJIS.map(e => ( + + ))} + + + {canDelete && ( + + )} + + {/* Emoji picker anchored to the toolbar */} + {showEmojiPicker && ( +
e.stopPropagation()} + > + handleReact(e.native)} theme="light" previewPosition="none" skinTonePosition="none" /> +
+ )} +
+ )} + +
+ {msg.image_url && ( + attachment setLightboxSrc(msg.image_url)} + /> + )} + {msg.content && ( +

+ )} + {msg.link_preview && } +

+
+ + {formatTime(msg.created_at)} +
+ + {Object.keys(reactionMap).length > 0 && ( +
+ {Object.entries(reactionMap).map(([emoji, { count, users, hasMe }]) => ( + + ))} +
+ )} +
+
+ {showProfile && ( + setShowProfile(false)} + /> + )} + {lightboxSrc && ( + setLightboxSrc(null)} /> + )} + + ); +} + +function LinkPreview({ data: raw }) { + let d; + try { d = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; } + if (!d?.title) return null; + + return ( + + {d.image && e.target.style.display = 'none'} />} +
+ {d.siteName && {d.siteName}} + {d.title} + {d.description && {d.description}} +
+
+ ); +} + +function formatTime(dateStr) { + return new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function formatDate(dateStr) { + const d = new Date(dateStr); + const now = new Date(); + if (d.toDateString() === now.toDateString()) return 'Today'; + const yest = new Date(now); yest.setDate(yest.getDate() - 1); + if (d.toDateString() === yest.toDateString()) return 'Yesterday'; + return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }); +} diff --git a/frontend/src/components/MessageInput.css b/frontend/src/components/MessageInput.css new file mode 100644 index 0000000..17df452 --- /dev/null +++ b/frontend/src/components/MessageInput.css @@ -0,0 +1,168 @@ +.message-input-area { + background: white; + border-top: 1px solid var(--border); + padding: 12px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.reply-bar-input { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--primary-light); + border-radius: var(--radius); + border-left: 3px solid var(--primary); +} + +.reply-indicator { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + overflow: hidden; + font-size: 13px; + color: var(--primary); +} + +.reply-preview-text { + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 200px; +} + +.img-preview-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px; + background: var(--background); + border-radius: var(--radius); +} + +.img-preview { + width: 56px; + height: 56px; + object-fit: cover; + border-radius: var(--radius); +} + +.link-preview-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--background); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.link-prev-img { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.mention-dropdown { + background: white; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; + max-height: 200px; + overflow-y: auto; +} + +.mention-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + width: 100%; + font-size: 14px; + transition: var(--transition); + cursor: pointer; +} +.mention-item:hover, .mention-item.active { background: var(--primary-light); } + +.mention-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--primary); + color: white; + font-size: 12px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.mention-role { + margin-left: auto; + font-size: 11px; + color: var(--text-tertiary); + text-transform: capitalize; +} + +.input-row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.input-action { + color: var(--text-secondary); + flex-shrink: 0; + margin-bottom: 2px; +} +.input-action:hover { color: var(--primary); } + +.input-wrap { + flex: 1; + min-width: 0; +} + +.msg-input { + width: 100%; + min-height: 40px; + max-height: 120px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 20px; + font-size: 14px; + line-height: 1.4; + font-family: var(--font); + color: var(--text-primary); + background: var(--surface-variant); + transition: border-color var(--transition); + overflow-y: auto; +} +.msg-input:focus { outline: none; border-color: var(--primary); background: white; } +.msg-input::placeholder { color: var(--text-tertiary); } + +.send-btn { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-tertiary); + transition: var(--transition); + background: var(--background); +} +.send-btn.active { + background: var(--primary); + color: white; +} +.send-btn.active:hover { background: var(--primary-dark); } +.send-btn:disabled { opacity: 0.4; cursor: default; } diff --git a/frontend/src/components/MessageInput.jsx b/frontend/src/components/MessageInput.jsx new file mode 100644 index 0000000..6c71901 --- /dev/null +++ b/frontend/src/components/MessageInput.jsx @@ -0,0 +1,247 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import { api } from '../utils/api.js'; +import './MessageInput.css'; + +const URL_REGEX = /https?:\/\/[^\s]+/g; + +export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping }) { + const [text, setText] = useState(''); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [mentionSearch, setMentionSearch] = useState(''); + const [mentionResults, setMentionResults] = useState([]); + const [mentionIndex, setMentionIndex] = useState(-1); + const [showMention, setShowMention] = useState(false); + const [linkPreview, setLinkPreview] = useState(null); + const [loadingPreview, setLoadingPreview] = useState(false); + const inputRef = useRef(null); + const typingTimer = useRef(null); + const wasTyping = useRef(false); + const mentionStart = useRef(-1); + const fileInput = useRef(null); + + // Handle typing notification + const handleTypingChange = (value) => { + if (value && !wasTyping.current) { + wasTyping.current = true; + onTyping(true); + } + if (typingTimer.current) clearTimeout(typingTimer.current); + typingTimer.current = setTimeout(() => { + if (wasTyping.current) { + wasTyping.current = false; + onTyping(false); + } + }, 2000); + }; + + // Link preview + const fetchPreview = useCallback(async (url) => { + setLoadingPreview(true); + try { + const { preview } = await api.getLinkPreview(url); + if (preview) setLinkPreview(preview); + } catch {} + setLoadingPreview(false); + }, []); + + const handleChange = (e) => { + const val = e.target.value; + setText(val); + handleTypingChange(val); + + // Detect @mention + const cur = e.target.selectionStart; + const lastAt = val.lastIndexOf('@', cur - 1); + if (lastAt !== -1) { + const between = val.slice(lastAt + 1, cur); + if (!between.includes(' ') && !between.includes('\n')) { + mentionStart.current = lastAt; + setMentionSearch(between); + setShowMention(true); + api.searchUsers(between).then(({ users }) => { + setMentionResults(users); + setMentionIndex(0); + }).catch(() => {}); + return; + } + } + setShowMention(false); + + // Link preview + const urls = val.match(URL_REGEX); + if (urls && urls[0] !== linkPreview?.url) { + fetchPreview(urls[0]); + } else if (!urls) { + setLinkPreview(null); + } + }; + + const insertMention = (user) => { + const before = text.slice(0, mentionStart.current); + const after = text.slice(inputRef.current.selectionStart); + const mention = `@[${user.display_name || user.name}](${user.id}) `; + setText(before + mention + after); + setShowMention(false); + setMentionResults([]); + inputRef.current.focus(); + }; + + const handleKeyDown = (e) => { + if (showMention && mentionResults.length > 0) { + if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(i => Math.min(i + 1, mentionResults.length - 1)); return; } + if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(i => Math.max(i - 1, 0)); return; } + if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); if (mentionIndex >= 0) insertMention(mentionResults[mentionIndex]); return; } + if (e.key === 'Escape') { setShowMention(false); return; } + } + + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleSend = async () => { + const trimmed = text.trim(); + if (!trimmed && !imageFile) return; + + const lp = linkPreview; + setText(''); + setLinkPreview(null); + setImageFile(null); + setImagePreview(null); + wasTyping.current = false; + onTyping(false); + + await onSend({ content: trimmed || null, imageFile, linkPreview: lp }); + }; + + const compressImage = (file) => new Promise((resolve) => { + const MAX_PX = 1920; + const QUALITY = 0.82; + const img = new Image(); + const url = URL.createObjectURL(file); + img.onload = () => { + URL.revokeObjectURL(url); + let { width, height } = img; + if (width <= MAX_PX && height <= MAX_PX) { + // Already small enough โ€” still re-encode to strip EXIF and reduce size + } else { + const ratio = Math.min(MAX_PX / width, MAX_PX / height); + width = Math.round(width * ratio); + height = Math.round(height * ratio); + } + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.getContext('2d').drawImage(img, 0, 0, width, height); + canvas.toBlob(blob => { + resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' })); + }, 'image/jpeg', QUALITY); + }; + img.src = url; + }); + + const handleImageSelect = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + const compressed = await compressImage(file); + setImageFile(compressed); + const reader = new FileReader(); + reader.onload = (e) => setImagePreview(e.target.result); + reader.readAsDataURL(compressed); + }; + + const displayText = (t) => { + // Convert @[name](id) to @name for display + return t.replace(/@\[([^\]]+)\]\(\d+\)/g, '@$1'); + }; + + return ( +
+ {/* Reply preview */} + {replyTo && ( +
+
+ + Replying to {replyTo.user_display_name || replyTo.user_name} + {replyTo.content?.slice(0, 60) || (replyTo.image_url ? '๐Ÿ“ท Image' : '')} +
+ +
+ )} + + {/* Image preview */} + {imagePreview && ( +
+ preview + +
+ )} + + {/* Link preview */} + {linkPreview && ( +
+ {linkPreview.image && e.target.style.display='none'} />} +
+ {linkPreview.siteName && {linkPreview.siteName}} + {linkPreview.title} +
+ +
+ )} + + {/* Mention dropdown */} + {showMention && mentionResults.length > 0 && ( +
+ {mentionResults.map((u, i) => ( + + ))} +
+ )} + +
+ + + +
+