V0.8.8 removed the pinning feature
This commit is contained in:
@@ -7,7 +7,7 @@ TZ=UTC
|
|||||||
# Copy this file to .env and customize
|
# Copy this file to .env and customize
|
||||||
|
|
||||||
# Image version to run (set by build.sh, or use 'latest')
|
# 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)
|
# Default admin credentials (used on FIRST RUN only)
|
||||||
ADMIN_NAME=Admin User
|
ADMIN_NAME=Admin User
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.8.7",
|
"version": "0.8.8",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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.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.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,
|
(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
|
FROM groups g
|
||||||
LEFT JOIN pinned_conversations pc ON pc.group_id = g.id AND pc.user_id = ?
|
|
||||||
WHERE g.type = 'public'
|
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);
|
`).all(userId);
|
||||||
|
|
||||||
// For direct messages, replace name with opposite user's display name
|
// 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.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.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,
|
(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
|
FROM groups g
|
||||||
JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
|
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 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'
|
WHERE g.type = 'private'
|
||||||
ORDER BY is_pinned DESC, last_message_at DESC NULLS LAST
|
ORDER BY last_message_at DESC NULLS LAST
|
||||||
`).all(userId, userId);
|
`).all(userId);
|
||||||
|
|
||||||
// For direct groups, set the name to the other user's display name
|
// 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
|
// Uses direct_peer1_id / direct_peer2_id so the name survives after a user leaves
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.8.7}"
|
VERSION="${1:-0.8.8}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.8.7",
|
"version": "0.8.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -154,55 +154,6 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.group-last-msg {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -299,25 +250,6 @@
|
|||||||
pointer-events: none;
|
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 right-click context menu */
|
||||||
.dm-context-menu {
|
.dm-context-menu {
|
||||||
|
|||||||
@@ -8,43 +8,49 @@ import './Sidebar.css';
|
|||||||
|
|
||||||
function useTheme() {
|
function useTheme() {
|
||||||
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
|
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||||
localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
|
localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
|
||||||
}, [dark]);
|
}, [dark]);
|
||||||
|
|
||||||
return [dark, setDark];
|
return [dark, setDark];
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAppSettings() {
|
function useAppSettings() {
|
||||||
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
|
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
|
||||||
|
|
||||||
const fetchSettings = () => {
|
const fetchSettings = () => {
|
||||||
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
window.addEventListener('jama:settings-changed', fetchSettings);
|
window.addEventListener('jama:settings-changed', fetchSettings);
|
||||||
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
|
return () => window.removeEventListener('jama:settings-changed', fetchSettings);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const name = settings.app_name || 'jama';
|
const name = settings.app_name || 'jama';
|
||||||
// Preserve any unread badge prefix already set by Chat.jsx
|
|
||||||
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
|
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
|
||||||
document.title = prefix + name;
|
document.title = prefix + name;
|
||||||
const logoUrl = settings.logo_url;
|
const faviconUrl = settings.logo_url || '/icons/jama.png';
|
||||||
const faviconUrl = logoUrl || '/icons/jama.png';
|
|
||||||
let link = document.querySelector("link[rel~='icon']");
|
let link = document.querySelector("link[rel~='icon']");
|
||||||
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
|
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
|
||||||
link.href = faviconUrl;
|
link.href = faviconUrl;
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
return 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() }) {
|
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 { user, logout } = useAuth();
|
||||||
const { connected } = useSocket();
|
const { connected } = useSocket();
|
||||||
@@ -55,8 +61,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
const footerBtnRef = 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(() => {
|
useEffect(() => {
|
||||||
if (!showMenu) return;
|
if (!showMenu) return;
|
||||||
const handler = (e) => {
|
const handler = (e) => {
|
||||||
@@ -73,59 +77,21 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
};
|
};
|
||||||
}, [showMenu]);
|
}, [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 = [
|
const allGroups = [
|
||||||
...(groups.publicGroups || []),
|
...(groups.publicGroups || []),
|
||||||
...(groups.privateGroups || [])
|
...(groups.privateGroups || [])
|
||||||
];
|
];
|
||||||
|
|
||||||
const publicFiltered = allGroups.filter(g => g.type === 'public');
|
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 privateFiltered = [...allGroups.filter(g => g.type === 'private')].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;
|
|
||||||
if (!a.last_message_at && !b.last_message_at) return 0;
|
if (!a.last_message_at && !b.last_message_at) return 0;
|
||||||
if (!a.last_message_at) return 1;
|
if (!a.last_message_at) return 1;
|
||||||
if (!b.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);
|
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 getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||||
|
|
||||||
const handleLogout = async () => { await logout(); };
|
const handleLogout = async () => { await logout(); };
|
||||||
|
|
||||||
const GroupItem = ({ group }) => {
|
const GroupItem = ({ group }) => {
|
||||||
@@ -134,33 +100,15 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
const hasUnread = unreadCount > 0;
|
const hasUnread = unreadCount > 0;
|
||||||
const isActive = group.id === activeGroupId;
|
const isActive = group.id === activeGroupId;
|
||||||
const isOnline = !!group.is_direct && !!group.peer_id && (onlineUserIds instanceof Set ? onlineUserIds.has(Number(group.peer_id)) : false);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''} ${isPinned ? 'is-pinned' : ''}`}
|
className={`group-item ${isActive ? 'active' : ''} ${hasUnread ? 'has-unread' : ''}`}
|
||||||
onClick={() => onSelectGroup(group.id)}
|
onClick={() => onSelectGroup(group.id)}
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchMove={handleTouchEnd}
|
|
||||||
>
|
>
|
||||||
<div className="group-icon-wrap">
|
<div className="group-icon-wrap">
|
||||||
{group.is_direct && group.peer_avatar ? (
|
{group.is_direct && group.peer_avatar ? (
|
||||||
<img
|
<img src={group.peer_avatar} alt={group.name} className="group-icon" style={{ objectFit: 'cover', padding: 0 }} />
|
||||||
src={group.peer_avatar}
|
|
||||||
alt={group.name}
|
|
||||||
className="group-icon"
|
|
||||||
style={{ objectFit: 'cover', padding: 0 }}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="group-icon" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}>
|
<div className="group-icon" style={{ background: group.type === 'public' ? '#1a73e8' : '#a142f4' }}>
|
||||||
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
{group.type === 'public' ? '#' : group.is_direct ? (group.peer_real_name || group.name)[0]?.toUpperCase() : group.name[0]?.toUpperCase()}
|
||||||
@@ -171,37 +119,21 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
<div className="group-info flex-1 overflow-hidden">
|
<div className="group-info flex-1 overflow-hidden">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>
|
<span className={`group-name truncate ${hasUnread ? 'unread-name' : ''}`}>
|
||||||
{isPinned && (
|
|
||||||
<svg className="conv-pin-indicator" width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M16 2v4l-3 3v6l-2-2-2 2V9L6 6V2h10z"/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
{group.is_direct && group.peer_display_name
|
{group.is_direct && group.peer_display_name
|
||||||
? <>{group.peer_display_name}<span className="dm-real-name"> ({group.peer_real_name})</span></>
|
? <>{group.peer_display_name}<span className="dm-real-name"> ({group.peer_real_name})</span></>
|
||||||
: group.is_direct && group.peer_real_name ? group.peer_real_name : group.name}
|
: group.is_direct && group.peer_real_name ? group.peer_real_name : group.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="group-item-actions">
|
{group.last_message_at && (
|
||||||
{group.last_message_at && (
|
<span className="group-time">{formatTime(group.last_message_at)}</span>
|
||||||
<span className="group-time">{formatTime(group.last_message_at)}</span>
|
)}
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className={`conv-pin-btn ${isPinned ? 'pinned' : ''}`}
|
|
||||||
onClick={(e) => isPinned ? handleUnpinConversation(e, group.id) : handlePinConversation(e, group.id)}
|
|
||||||
title={isPinned ? 'Unpin conversation' : 'Pin to top'}
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M16 2v4l-3 3v6l-2-2-2 2V9L6 6V2h10z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="group-last-msg truncate">
|
<span className="group-last-msg truncate">
|
||||||
{(() => {
|
{(() => {
|
||||||
const preview = (group.last_message || '').replace(/@\[([^\]]+)\]/g, '@$1');
|
const preview = (group.last_message || '').replace(/@\[([^\]]+)\]/g, '@$1');
|
||||||
if (!preview) return group.is_readonly ? '📢 Read-only' : 'No messages yet';
|
if (!preview) return group.is_readonly ? '📢 Read-only' : 'No messages yet';
|
||||||
const isOwn = group.last_message_user_id && user && group.last_message_user_id === user.id;
|
const isOwn = group.last_message_user_id && user && group.last_message_user_id === user.id;
|
||||||
return isOwn ? <><strong style={{fontWeight:600}}>You:</strong> {preview}</> : preview;
|
return isOwn ? <><strong style={{ fontWeight: 600 }}>You:</strong> {preview}</> : preview;
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
|
{notifs > 0 && <span className="badge shrink-0">{notifs}</span>}
|
||||||
@@ -214,7 +146,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<div className="sidebar">
|
||||||
{/* New Chat button replacing search bar */}
|
|
||||||
<div className="sidebar-newchat-bar">
|
<div className="sidebar-newchat-bar">
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<button className="newchat-btn" onClick={onNewChat}>
|
<button className="newchat-btn" onClick={onNewChat}>
|
||||||
@@ -226,7 +157,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Groups list */}
|
|
||||||
<div className="groups-list">
|
<div className="groups-list">
|
||||||
{publicFiltered.length > 0 && (
|
{publicFiltered.length > 0 && (
|
||||||
<div className="group-section">
|
<div className="group-section">
|
||||||
@@ -247,7 +177,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile FAB: New Chat button floats above user footer */}
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<button className="newchat-fab" onClick={onNewChat} title="New Chat">
|
<button className="newchat-fab" onClick={onNewChat} title="New Chat">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" width="24" height="24">
|
||||||
@@ -256,7 +185,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User footer */}
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<button ref={footerBtnRef} className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
|
<button ref={footerBtnRef} className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
|
||||||
@@ -333,18 +261,3 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,7 @@ export const api = {
|
|||||||
// Link preview
|
// Link preview
|
||||||
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
|
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
|
||||||
|
|
||||||
// Conversation pinning (pin a group to top of sidebar)
|
|
||||||
pinConversation: (groupId) => req('POST', `/groups/${groupId}/pin`),
|
|
||||||
unpinConversation: (groupId) => req('DELETE', `/groups/${groupId}/pin`),
|
|
||||||
|
|
||||||
// VAPID key management (admin only)
|
// VAPID key management (admin only)
|
||||||
generateVapidKeys: () => req('POST', '/push/generate-vapid'),
|
generateVapidKeys: () => req('POST', '/push/generate-vapid'),
|
||||||
|
|||||||
Reference in New Issue
Block a user