Add sidebar with friends list to chat window

- Added toggleable sidebar showing friends list on right side of chat
- Small avatars with online status and unread badges
- Click friend to switch conversation
- Toggle button in chat header
- Hidden on mobile (use contacts dropdown instead)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Sociowire Dev 2026-01-12 19:11:46 +00:00
parent f84888ea06
commit 9d207f53cf
9 changed files with 364 additions and 118 deletions

View File

@ -41,6 +41,7 @@ export default function ChatContainer() {
const [activeChats, setActiveChats] = useState([]); // [{ username, avatar }] const [activeChats, setActiveChats] = useState([]); // [{ username, avatar }]
const [minimizedChats, setMinimizedChats] = useState({}); // { username: boolean } const [minimizedChats, setMinimizedChats] = useState({}); // { username: boolean }
const [unreadCounts, setUnreadCounts] = useState({}); // { username: number } const [unreadCounts, setUnreadCounts] = useState({}); // { username: number }
const [showSidebar, setShowSidebar] = useState(false); // sidebar with friends list
const notificationPermissionRef = useRef("default"); const notificationPermissionRef = useRef("default");
const minimizedChatsRef = useRef(minimizedChats); const minimizedChatsRef = useRef(minimizedChats);
@ -573,7 +574,7 @@ export default function ChatContainer() {
<div <div
key={chat.username} key={chat.username}
className="chat-window-wrapper" className="chat-window-wrapper"
style={{ right: 16 + index * 340 }} style={{ right: 16 + index * (showSidebar ? 396 : 340) }}
> >
<ChatWindow <ChatWindow
recipientID={chat.username} recipientID={chat.username}
@ -587,6 +588,12 @@ export default function ChatContainer() {
onTyping={handleTyping(chat.username)} onTyping={handleTyping(chat.username)}
onClose={() => closeChat(chat.username)} onClose={() => closeChat(chat.username)}
onMinimize={() => toggleMinimize(chat.username)} onMinimize={() => toggleMinimize(chat.username)}
showSidebar={showSidebar}
friends={friends}
presence={presence}
unreadCounts={unreadCounts}
onSelectFriend={openChat}
onToggleSidebar={() => setShowSidebar(!showSidebar)}
/> />
</div> </div>
))} ))}

View File

@ -1,3 +1,14 @@
/* Outer wrapper for chat + sidebar */
.chat-window-outer {
display: flex;
flex-direction: row-reverse;
align-items: flex-end;
}
.chat-window-outer.with-sidebar {
gap: 0;
}
.chat-window { .chat-window {
width: 320px; width: 320px;
height: 420px; height: 420px;
@ -11,6 +22,134 @@
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
} }
.chat-window-outer.with-sidebar .chat-window {
border-radius: 0 12px 0 0;
}
/* Sidebar toggle button */
.chat-sidebar-toggle {
width: 26px;
height: 26px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
transition: background 0.2s, color 0.2s;
}
.chat-sidebar-toggle:hover {
background: rgba(255, 255, 255, 0.2);
}
.chat-sidebar-toggle.active {
background: rgba(59, 130, 246, 0.4);
color: #60a5fa;
}
/* Friends Sidebar */
.chat-sidebar {
width: 56px;
height: 420px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(148, 163, 184, 0.3);
border-right: none;
border-radius: 12px 0 0 0;
backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
}
.chat-sidebar-list {
flex: 1;
overflow-y: auto;
padding: 8px 6px;
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-sidebar-item {
width: 42px;
height: 42px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: background 0.2s;
padding: 0;
}
.chat-sidebar-item:hover {
background: rgba(59, 130, 246, 0.15);
}
.chat-sidebar-item.active {
background: rgba(59, 130, 246, 0.25);
}
.chat-sidebar-item.has-unread {
background: rgba(239, 68, 68, 0.15);
}
.chat-sidebar-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
position: relative;
overflow: hidden;
}
.chat-sidebar-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.chat-sidebar-avatar.online::after {
content: "";
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
background: #22c55e;
border: 2px solid rgba(15, 23, 42, 0.95);
border-radius: 50%;
}
.chat-sidebar-unread {
position: absolute;
top: 0;
right: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: #ef4444;
color: white;
font-size: 10px;
font-weight: 700;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
}
.chat-window--minimized { .chat-window--minimized {
height: auto; height: auto;
cursor: pointer; cursor: pointer;
@ -306,8 +445,30 @@ body[data-theme="light"] .chat-input {
border-color: rgba(148, 163, 184, 0.4); border-color: rgba(148, 163, 184, 0.4);
} }
/* Light theme sidebar */
body[data-theme="light"] .chat-sidebar {
background: rgba(255, 255, 255, 0.98);
border-color: rgba(148, 163, 184, 0.4);
}
body[data-theme="light"] .chat-sidebar-item:hover {
background: rgba(59, 130, 246, 0.1);
}
body[data-theme="light"] .chat-sidebar-item.active {
background: rgba(59, 130, 246, 0.15);
}
body[data-theme="light"] .chat-sidebar-avatar.online::after {
border-color: rgba(255, 255, 255, 0.98);
}
/* Responsive */ /* Responsive */
@media (max-width: 640px) { @media (max-width: 640px) {
.chat-window-outer {
width: 100%;
}
.chat-window { .chat-window {
width: 100%; width: 100%;
max-width: 100vw; max-width: 100vw;
@ -318,6 +479,19 @@ body[data-theme="light"] .chat-input {
border-radius: 16px 16px 0 0; border-radius: 16px 16px 0 0;
} }
.chat-window-outer.with-sidebar .chat-window {
border-radius: 16px 16px 0 0;
}
/* Hide sidebar on mobile */
.chat-sidebar {
display: none;
}
.chat-sidebar-toggle {
display: none;
}
.chat-input-area { .chat-input-area {
padding-bottom: max(10px, env(safe-area-inset-bottom, 10px)); padding-bottom: max(10px, env(safe-area-inset-bottom, 10px));
} }

View File

@ -15,6 +15,13 @@ export default function ChatWindow({
onClose, onClose,
onMinimize, onMinimize,
minimized = false, minimized = false,
// Sidebar props
showSidebar = false,
friends = [],
presence = {},
unreadCounts = {},
onSelectFriend,
onToggleSidebar,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
@ -106,113 +113,158 @@ export default function ChatWindow({
} }
return ( return (
<div className="chat-window"> <div className={"chat-window-outer" + (showSidebar ? " with-sidebar" : "")}>
{/* Header */} <div className="chat-window">
<div className="chat-header"> {/* Header */}
<div className="chat-user"> <div className="chat-header">
<div className={"chat-avatar-sm" + (online ? " online" : "")}> <div className="chat-user">
{recipientAvatar ? ( <div className={"chat-avatar-sm" + (online ? " online" : "")}>
<img src={recipientAvatar} alt={recipientName} /> {recipientAvatar ? (
) : ( <img src={recipientAvatar} alt={recipientName} />
<span>{(recipientName || "?")[0].toUpperCase()}</span> ) : (
)} <span>{(recipientName || "?")[0].toUpperCase()}</span>
)}
</div>
<div className="chat-user-info">
<span className="chat-username">{recipientName}</span>
<span className="chat-status">
{online ? t("common.online") : ""}
</span>
</div>
</div> </div>
<div className="chat-user-info"> <div className="chat-actions">
<span className="chat-username">{recipientName}</span> {onToggleSidebar && (
<span className="chat-status"> <button
{online ? t("common.online") : ""} type="button"
</span> className={"chat-sidebar-toggle" + (showSidebar ? " active" : "")}
</div> onClick={onToggleSidebar}
</div> title={t("common.contacts")}
<div className="chat-actions">
<button
type="button"
className="chat-minimize"
onClick={onMinimize}
title={t("common.close")}
>
<i className="fa-solid fa-minus" />
</button>
<button
type="button"
className="chat-close"
onClick={onClose}
title={t("common.close")}
>
<i className="fa-solid fa-xmark" />
</button>
</div>
</div>
{/* Messages */}
<div className="chat-messages">
{messages.length === 0 ? (
<div className="chat-empty">
{t("chat.startConversation", "Start a conversation")}
</div>
) : (
messages.map((msg, idx) => {
// Compare by username (sender_name), case-insensitive
const isMine = msg.sender_name?.toLowerCase() !== recipientName?.toLowerCase();
// Determine message status for sent messages
const isSending = msg.id?.toString().startsWith("temp-");
const isRead = msg.read_at != null;
const isSent = !isSending && msg.id;
return (
<div
key={msg.id || idx}
className={"chat-msg" + (isMine ? " chat-msg--mine" : "")}
> >
<div className="chat-msg-content">{msg.content}</div> <i className="fa-solid fa-users" />
<div className="chat-msg-meta"> </button>
<span className="chat-msg-time">{formatTime(msg.created_at)}</span> )}
{isMine && ( <button
<span className="chat-msg-status"> type="button"
{isSending ? ( className="chat-minimize"
<i className="fa-regular fa-clock" title={t("chat.sending", "Sending...")} /> onClick={onMinimize}
) : isRead ? ( title={t("common.close")}
<i className="fa-solid fa-check-double read" title={t("chat.read", "Read")} /> >
) : isSent ? ( <i className="fa-solid fa-minus" />
<i className="fa-solid fa-check" title={t("chat.sent", "Sent")} /> </button>
) : null} <button
</span> type="button"
)} className="chat-close"
</div> onClick={onClose}
</div> title={t("common.close")}
); >
}) <i className="fa-solid fa-xmark" />
)} </button>
{typing && (
<div className="chat-typing">
<span className="chat-typing-dot" />
<span className="chat-typing-dot" />
<span className="chat-typing-dot" />
</div> </div>
)} </div>
<div ref={messagesEndRef} />
{/* Messages */}
<div className="chat-messages">
{messages.length === 0 ? (
<div className="chat-empty">
{t("chat.startConversation", "Start a conversation")}
</div>
) : (
messages.map((msg, idx) => {
// Compare by username (sender_name), case-insensitive
const isMine = msg.sender_name?.toLowerCase() !== recipientName?.toLowerCase();
// Determine message status for sent messages
const isSending = msg.id?.toString().startsWith("temp-");
const isRead = msg.read_at != null;
const isSent = !isSending && msg.id;
return (
<div
key={msg.id || idx}
className={"chat-msg" + (isMine ? " chat-msg--mine" : "")}
>
<div className="chat-msg-content">{msg.content}</div>
<div className="chat-msg-meta">
<span className="chat-msg-time">{formatTime(msg.created_at)}</span>
{isMine && (
<span className="chat-msg-status">
{isSending ? (
<i className="fa-regular fa-clock" title={t("chat.sending", "Sending...")} />
) : isRead ? (
<i className="fa-solid fa-check-double read" title={t("chat.read", "Read")} />
) : isSent ? (
<i className="fa-solid fa-check" title={t("chat.sent", "Sent")} />
) : null}
</span>
)}
</div>
</div>
);
})
)}
{typing && (
<div className="chat-typing">
<span className="chat-typing-dot" />
<span className="chat-typing-dot" />
<span className="chat-typing-dot" />
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="chat-input-area">
<input
ref={inputRef}
type="text"
className="chat-input"
placeholder={t("chat.typeMessage", "Type a message...")}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
<button
type="button"
className="chat-send"
onClick={handleSend}
disabled={!input.trim()}
>
<i className="fa-solid fa-paper-plane" />
</button>
</div>
</div> </div>
{/* Input */} {/* Friends Sidebar */}
<div className="chat-input-area"> {showSidebar && (
<input <div className="chat-sidebar">
ref={inputRef} <div className="chat-sidebar-list">
type="text" {friends.map((friend) => {
className="chat-input" const friendUsername = friend.username || friend.name;
placeholder={t("chat.typeMessage", "Type a message...")} const avatar = friend.avatar_url || friend.avatar;
value={input} const isOnline = presence[friendUsername] || false;
onChange={handleInputChange} const unread = unreadCounts[friendUsername] || 0;
onKeyDown={handleKeyDown} const isActive = friendUsername === recipientName;
/>
<button return (
type="button" <button
className="chat-send" key={friendUsername}
onClick={handleSend} className={"chat-sidebar-item" + (isActive ? " active" : "") + (unread > 0 ? " has-unread" : "")}
disabled={!input.trim()} onClick={() => onSelectFriend?.(friend)}
> title={friendUsername}
<i className="fa-solid fa-paper-plane" /> >
</button> <div className={"chat-sidebar-avatar" + (isOnline ? " online" : "")}>
</div> {avatar ? (
<img src={avatar} alt={friendUsername} />
) : (
<span>{(friendUsername || "?")[0].toUpperCase()}</span>
)}
</div>
{unread > 0 && <span className="chat-sidebar-unread">{unread}</span>}
</button>
);
})}
</div>
</div>
)}
</div> </div>
); );
} }

View File

@ -82,6 +82,7 @@ export default function RadialMenu({
const handleTimeClick = (hours) => { const handleTimeClick = (hours) => {
onTime?.(hours); onTime?.(hours);
setRightOpen(false); // Close menu after selection
}; };
const handleWeatherClick = () => { const handleWeatherClick = () => {
@ -206,7 +207,7 @@ export default function RadialMenu({
</div> </div>
{/* RIGHT FAB - Time & Weather */} {/* RIGHT FAB - Time & Weather */}
<div className={`fab-right ${rightOpen ? "is-open" : ""}`}> <div className={`fab-right ${rightOpen ? "is-open" : ""}`} style={rightOpen ? { zIndex: 9000 } : undefined}>
<div className="fab-ring"> <div className="fab-ring">
{/* Time options */} {/* Time options */}
{TIME_OPTIONS.map((t, i) => { {TIME_OPTIONS.map((t, i) => {

View File

@ -567,7 +567,7 @@ function openImageLightbox(src) {
overlay.style.position = "fixed"; overlay.style.position = "fixed";
overlay.style.inset = "0"; overlay.style.inset = "0";
overlay.style.background = "rgba(8,10,16,0.86)"; overlay.style.background = "rgba(8,10,16,0.86)";
overlay.style.zIndex = "10000000"; overlay.style.zIndex = "6000"; /* Image lightbox - above post modal (5000), below auth (10000) */
overlay.style.display = "flex"; overlay.style.display = "flex";
overlay.style.alignItems = "center"; overlay.style.alignItems = "center";
overlay.style.justifyContent = "center"; overlay.style.justifyContent = "center";
@ -997,7 +997,7 @@ function openCameraOverlay({ map, post, theme, fullScreen, interactionState }) {
backdrop.style.cssText = ` backdrop.style.cssText = `
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9999999; z-index: 5000;
background: rgba(0,0,0,0.85); background: rgba(0,0,0,0.85);
display: flex; display: flex;
align-items: center; align-items: center;
@ -1218,7 +1218,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
if (fullScreen) backdrop.classList.add("sw-modal-fullscreen"); if (fullScreen) backdrop.classList.add("sw-modal-fullscreen");
backdrop.style.position = "fixed"; backdrop.style.position = "fixed";
backdrop.style.inset = "0"; backdrop.style.inset = "0";
backdrop.style.zIndex = "9999999"; backdrop.style.zIndex = "5000";
backdrop.style.background = "rgba(0,0,0,0.08)"; backdrop.style.background = "rgba(0,0,0,0.08)";
backdrop.style.backdropFilter = "blur(1px)"; backdrop.style.backdropFilter = "blur(1px)";
backdrop.style.display = "flex"; backdrop.style.display = "flex";

View File

@ -194,7 +194,7 @@ export function openImageLightbox(src) {
overlay.style.position = "fixed"; overlay.style.position = "fixed";
overlay.style.inset = "0"; overlay.style.inset = "0";
overlay.style.background = "rgba(8,10,16,0.86)"; overlay.style.background = "rgba(8,10,16,0.86)";
overlay.style.zIndex = "10000000"; overlay.style.zIndex = "6000"; /* Image lightbox - above post modal (5000), below auth (10000) */
overlay.style.display = "flex"; overlay.style.display = "flex";
overlay.style.alignItems = "center"; overlay.style.alignItems = "center";
overlay.style.justifyContent = "center"; overlay.style.justifyContent = "center";

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { fetchPosts, fetchSmartFeed, fetchUnifiedSearch, resolveAssetRef } from "../../api/client"; import { fetchPosts, fetchSmartFeed, fetchUnifiedSearch, resolveAssetRef } from "../../api/client";
import { categoryCode, matchesSubFilter } from "./mapFilter"; import { categoryCode, matchesSubFilter, matchesTimeFilter, effectivePostTime } from "./mapFilter";
import { getCoords, getViewFromMap, haversineKm } from "./mapGeo"; import { getCoords, getViewFromMap, haversineKm } from "./mapGeo";
import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager"; import { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
import { getTemplateKeyForPost } from "./templateSpecs"; import { getTemplateKeyForPost } from "./templateSpecs";
@ -875,6 +875,12 @@ export function usePostsEngine({
// Apply content filter (links, video, image, live, event) // Apply content filter (links, video, image, live, event)
if (!matchesContentFilter(p, contentFilter)) return false; if (!matchesContentFilter(p, contentFilter)) return false;
// Apply time filter
if (tf && tf > 0) {
const postTime = effectivePostTime(p);
if (!matchesTimeFilter(postTime, tf)) return false;
}
if (isClusterFocus) { if (isClusterFocus) {
const id = Number(getId(p)); const id = Number(getId(p));
const clusterPostId = Number(p?.cluster_post_id); const clusterPostId = Number(p?.cluster_post_id);
@ -1521,12 +1527,14 @@ export function usePostsEngine({
const handlePostDelete = useCallback( const handlePostDelete = useCallback(
(payload) => { (payload) => {
const postId = payload?.post_id || payload?.postId || payload?.id; const rawId = payload?.post_id || payload?.postId || payload?.id;
if (!postId) return; if (!rawId) return;
const postId = Number(rawId); // Ensure numeric comparison
console.log("[PostsEngine] handlePostDelete postId=", postId);
const map = mapRef.current; const map = mapRef.current;
const overlay = map?.__swCenteredOverlay; const overlay = map?.__swCenteredOverlay;
if (overlay && overlay.postId === postId) { if (overlay && Number(overlay.postId) === postId) {
// Remove event listeners // Remove event listeners
try { if (overlay.onKey) window.removeEventListener("keydown", overlay.onKey); } catch {} try { if (overlay.onKey) window.removeEventListener("keydown", overlay.onKey); } catch {}
try { if (overlay.onAuth) window.removeEventListener("sociowire:auth-changed", overlay.onAuth); } catch {} try { if (overlay.onAuth) window.removeEventListener("sociowire:auth-changed", overlay.onAuth); } catch {}
@ -1544,13 +1552,13 @@ export function usePostsEngine({
map.__swCenteredOverlay = null; map.__swCenteredOverlay = null;
} }
allPostsRef.current = (allPostsRef.current || []).filter((p) => (p?.id || p?.ID) !== postId); allPostsRef.current = (allPostsRef.current || []).filter((p) => Number(p?.id || p?.ID) !== postId);
setVisiblePosts((current) => current.filter((p) => (p?.id || p?.ID) !== postId)); setVisiblePosts((current) => current.filter((p) => Number(p?.id || p?.ID) !== postId));
const markers = markersRef.current || []; const markers = markersRef.current || [];
const nextMarkers = []; const nextMarkers = [];
for (const item of markers) { for (const item of markers) {
if (!item || item.id !== postId) { if (!item || Number(item.id) !== postId) {
nextMarkers.push(item); nextMarkers.push(item);
continue; continue;
} }

View File

@ -157,7 +157,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
comment: post?.comment_count ?? 0, comment: post?.comment_count ?? 0,
share: post?.share_count ?? 0, share: post?.share_count ?? 0,
})); }));
const { username, token } = useAuth(); const { username, token, userId } = useAuth();
// Edit/Delete state // Edit/Delete state
const [showEditDelete, setShowEditDelete] = useState(false); const [showEditDelete, setShowEditDelete] = useState(false);
@ -232,8 +232,11 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
const title = post?.title || "Untitled"; const title = post?.title || "Untitled";
const snippet = post?.snippet || ""; const snippet = post?.snippet || "";
const author = (post?.author || post?.Author || "anon").toString(); const author = (post?.author || post?.Author || "anon").toString();
const authorId = post?.author_id || post?.authorId || null;
// Check if current user is the author (by username OR by user ID)
const isAuthor = const isAuthor =
username && author && author.toLowerCase() === username.toLowerCase(); (username && author && author.toLowerCase() === username.toLowerCase()) ||
(userId && authorId && Number(userId) === Number(authorId));
const [authorAvatar, setAuthorAvatar] = useState(""); const [authorAvatar, setAuthorAvatar] = useState("");
const sub = (post?.sub_category || post?.subCategory || "").toString(); const sub = (post?.sub_category || post?.subCategory || "").toString();
const contentType = (post?.content_type || "").toString().toLowerCase(); const contentType = (post?.content_type || "").toString().toLowerCase();

View File

@ -1,12 +1,13 @@
.auth-modal-backdrop { .auth-modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 10000; /* Auth modal - highest priority */ z-index: 10000; /* Auth modal - above post modals (5000) and chat (8000) */
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(2, 6, 23, 0.65); background: rgba(2, 6, 23, 0.65);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
isolation: isolate; /* Create new stacking context */
} }
.auth-modal { .auth-modal {