diff --git a/.env.example b/.env.example index 28631ee..e3cbe39 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ TZ=UTC # Copy this file to .env and customize # Image version to run (set by build.sh, or use 'latest') -JAMA_VERSION=0.8.7 +JAMA_VERSION=0.8.8 # Default admin credentials (used on FIRST RUN only) ADMIN_NAME=Admin User diff --git a/backend/package.json b/backend/package.json index c7e0a8b..d6bca30 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.8.7", + "version": "0.8.8", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/groups.js b/backend/src/routes/groups.js index 3e96f92..09269eb 100644 --- a/backend/src/routes/groups.js +++ b/backend/src/routes/groups.js @@ -54,11 +54,9 @@ router.get('/', authMiddleware, (req, res) => { (SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message, (SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at, (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id, - CASE WHEN pc.group_id IS NOT NULL THEN 1 ELSE 0 END as is_pinned FROM groups g - LEFT JOIN pinned_conversations pc ON pc.group_id = g.id AND pc.user_id = ? WHERE g.type = 'public' - ORDER BY is_pinned DESC, g.is_default DESC, g.name ASC + ORDER BY g.is_default DESC, g.name ASC `).all(userId); // For direct messages, replace name with opposite user's display name @@ -69,14 +67,12 @@ router.get('/', authMiddleware, (req, res) => { (SELECT m.content FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message, (SELECT m.created_at FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_at, (SELECT m.user_id FROM messages m WHERE m.group_id = g.id AND m.is_deleted = 0 ORDER BY m.created_at DESC LIMIT 1) as last_message_user_id, - CASE WHEN pc.group_id IS NOT NULL THEN 1 ELSE 0 END as is_pinned FROM groups g JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ? LEFT JOIN users u ON g.owner_id = u.id - LEFT JOIN pinned_conversations pc ON pc.group_id = g.id AND pc.user_id = ? WHERE g.type = 'private' - ORDER BY is_pinned DESC, last_message_at DESC NULLS LAST - `).all(userId, userId); + ORDER BY last_message_at DESC NULLS LAST + `).all(userId); // For direct groups, set the name to the other user's display name // Uses direct_peer1_id / direct_peer2_id so the name survives after a user leaves diff --git a/build.sh b/build.sh index e383258..93a661f 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.8.7}" +VERSION="${1:-0.8.8}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 81fca89..b197ef1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.8.7", + "version": "0.8.8", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/Sidebar.css b/frontend/src/components/Sidebar.css index b01db65..c87126e 100644 --- a/frontend/src/components/Sidebar.css +++ b/frontend/src/components/Sidebar.css @@ -154,55 +154,6 @@ flex-shrink: 0; } -/* Conversation pin button — visible on hover (desktop) or always when pinned */ -.group-item-actions { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; -} - -.conv-pin-btn { - display: none; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: 4px; - color: var(--text-tertiary); - opacity: 0; - transition: opacity var(--transition), color var(--transition), background var(--transition); - flex-shrink: 0; -} -.conv-pin-btn:hover { background: var(--border); color: var(--primary); } -.conv-pin-btn.pinned { - display: flex; - opacity: 1; - color: var(--primary); -} -.group-item:hover .conv-pin-btn { - display: flex; - opacity: 0.7; -} -.group-item:hover .conv-pin-btn:hover { opacity: 1; } - -/* Small pin icon inline before the name when conversation is pinned */ -.conv-pin-indicator { - display: inline; - vertical-align: middle; - margin-right: 3px; - color: var(--primary); - opacity: 0.7; - position: relative; - top: -1px; -} - -/* Pinned conversations get a subtle left accent */ -.group-item.is-pinned { - border-left: 2px solid var(--primary); - padding-left: 14px; -} - .group-last-msg { font-size: 13px; color: var(--text-secondary); @@ -299,25 +250,6 @@ pointer-events: none; } -/* Pin sublabel */ -.section-sublabel { - display: flex; - align-items: center; - font-size: 10px; - font-weight: 600; - letter-spacing: 0.06em; - color: var(--text-tertiary); - padding: 4px 12px 2px; - text-transform: uppercase; -} - -/* Thin divider between pinned and unpinned */ -.section-divider { - height: 1px; - background: var(--border); - margin: 4px 12px; - opacity: 0.6; -} /* DM right-click context menu */ .dm-context-menu { diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index c897edd..1c34ffc 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -8,43 +8,49 @@ import './Sidebar.css'; function useTheme() { const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark'); - useEffect(() => { document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light'); localStorage.setItem('jama-theme', dark ? 'dark' : 'light'); }, [dark]); - return [dark, setDark]; } function useAppSettings() { const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' }); - const fetchSettings = () => { api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {}); }; - useEffect(() => { fetchSettings(); window.addEventListener('jama:settings-changed', fetchSettings); return () => window.removeEventListener('jama:settings-changed', fetchSettings); }, []); - useEffect(() => { const name = settings.app_name || 'jama'; - // Preserve any unread badge prefix already set by Chat.jsx const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || ''; document.title = prefix + name; - const logoUrl = settings.logo_url; - const faviconUrl = logoUrl || '/icons/jama.png'; + const faviconUrl = settings.logo_url || '/icons/jama.png'; let link = document.querySelector("link[rel~='icon']"); if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); } link.href = faviconUrl; }, [settings]); - return settings; } +function formatTime(dateStr) { + if (!dateStr) return ''; + const date = parseTS(dateStr); + const now = new Date(); + const diff = now - date; + if (diff < 86400000 && date.getDate() === now.getDate()) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + if (diff < 604800000) { + return date.toLocaleDateString([], { weekday: 'short' }); + } + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set() }) { const { user, logout } = useAuth(); const { connected } = useSocket(); @@ -55,8 +61,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica const menuRef = useRef(null); const footerBtnRef = useRef(null); - // Fix 6: swipe right to go back on mobile — handled in ChatWindow, but prevent sidebar swipe exit - // Close menu on click outside useEffect(() => { if (!showMenu) return; const handler = (e) => { @@ -73,59 +77,21 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica }; }, [showMenu]); - const appName = settings.app_name || 'jama'; - const logoUrl = settings.logo_url; - - // Conversation pinning — derive from groups data (is_pinned flag from backend) - const [pinnedConvIds, setPinnedConvIds] = useState(new Set()); - - // Sync pinnedConvIds whenever groups data changes - useEffect(() => { - const allG = [...(groups.publicGroups || []), ...(groups.privateGroups || [])]; - setPinnedConvIds(new Set(allG.filter(g => g.is_pinned).map(g => g.id))); - }, [groups]); - - const handlePinConversation = async (e, groupId) => { - e.stopPropagation(); - try { - await api.pinConversation(groupId); - setPinnedConvIds(prev => new Set([...prev, groupId])); - } catch (err) { - toast('Could not pin conversation', 'error'); - } - }; - - const handleUnpinConversation = async (e, groupId) => { - e.stopPropagation(); - try { - await api.unpinConversation(groupId); - setPinnedConvIds(prev => { const n = new Set(prev); n.delete(groupId); return n; }); - } catch (err) { - toast('Could not unpin conversation', 'error'); - } - }; - const allGroups = [ ...(groups.publicGroups || []), ...(groups.privateGroups || []) ]; const publicFiltered = allGroups.filter(g => g.type === 'public'); - // All private groups (DMs + group chats) sorted together by most recent message - const sortWithPinned = (arr) => [...arr].sort((a, b) => { - const aPinned = pinnedConvIds.has(a.id) ? 1 : 0; - const bPinned = pinnedConvIds.has(b.id) ? 1 : 0; - if (bPinned !== aPinned) return bPinned - aPinned; + + const privateFiltered = [...allGroups.filter(g => g.type === 'private')].sort((a, b) => { if (!a.last_message_at && !b.last_message_at) return 0; if (!a.last_message_at) return 1; if (!b.last_message_at) return -1; return new Date(b.last_message_at) - new Date(a.last_message_at); }); - const privateFiltered = sortWithPinned(allGroups.filter(g => g.type === 'private')); - const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length; - const handleLogout = async () => { await logout(); }; const GroupItem = ({ group }) => { @@ -134,33 +100,15 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica const hasUnread = unreadCount > 0; const isActive = group.id === activeGroupId; const isOnline = !!group.is_direct && !!group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false); - const isPinned = pinnedConvIds.has(group.id); - // Long-press for mobile pin - const longPressTimer = useRef(null); - const handleTouchStart = () => { - longPressTimer.current = setTimeout(() => { - isPinned ? handleUnpinConversation({ stopPropagation: () => {} }, group.id) - : handlePinConversation({ stopPropagation: () => {} }, group.id); - }, 600); - }; - const handleTouchEnd = () => clearTimeout(longPressTimer.current); return (