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 [minimizedChats, setMinimizedChats] = useState({}); // { username: boolean }
const [unreadCounts, setUnreadCounts] = useState({}); // { username: number }
const [showSidebar, setShowSidebar] = useState(false); // sidebar with friends list
const notificationPermissionRef = useRef("default");
const minimizedChatsRef = useRef(minimizedChats);
@ -573,7 +574,7 @@ export default function ChatContainer() {
<div
key={chat.username}
className="chat-window-wrapper"
style={{ right: 16 + index * 340 }}
style={{ right: 16 + index * (showSidebar ? 396 : 340) }}
>
<ChatWindow
recipientID={chat.username}
@ -587,6 +588,12 @@ export default function ChatContainer() {
onTyping={handleTyping(chat.username)}
onClose={() => closeChat(chat.username)}
onMinimize={() => toggleMinimize(chat.username)}
showSidebar={showSidebar}
friends={friends}
presence={presence}
unreadCounts={unreadCounts}
onSelectFriend={openChat}
onToggleSidebar={() => setShowSidebar(!showSidebar)}
/>
</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 {
width: 320px;
height: 420px;
@ -11,6 +22,134 @@
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 {
height: auto;
cursor: pointer;
@ -306,8 +445,30 @@ body[data-theme="light"] .chat-input {
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 */
@media (max-width: 640px) {
.chat-window-outer {
width: 100%;
}
.chat-window {
width: 100%;
max-width: 100vw;
@ -318,6 +479,19 @@ body[data-theme="light"] .chat-input {
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 {
padding-bottom: max(10px, env(safe-area-inset-bottom, 10px));
}

View File

@ -15,6 +15,13 @@ export default function ChatWindow({
onClose,
onMinimize,
minimized = false,
// Sidebar props
showSidebar = false,
friends = [],
presence = {},
unreadCounts = {},
onSelectFriend,
onToggleSidebar,
}) {
const { t } = useTranslation();
const [input, setInput] = useState("");
@ -106,6 +113,7 @@ export default function ChatWindow({
}
return (
<div className={"chat-window-outer" + (showSidebar ? " with-sidebar" : "")}>
<div className="chat-window">
{/* Header */}
<div className="chat-header">
@ -125,6 +133,16 @@ export default function ChatWindow({
</div>
</div>
<div className="chat-actions">
{onToggleSidebar && (
<button
type="button"
className={"chat-sidebar-toggle" + (showSidebar ? " active" : "")}
onClick={onToggleSidebar}
title={t("common.contacts")}
>
<i className="fa-solid fa-users" />
</button>
)}
<button
type="button"
className="chat-minimize"
@ -214,5 +232,39 @@ export default function ChatWindow({
</button>
</div>
</div>
{/* Friends Sidebar */}
{showSidebar && (
<div className="chat-sidebar">
<div className="chat-sidebar-list">
{friends.map((friend) => {
const friendUsername = friend.username || friend.name;
const avatar = friend.avatar_url || friend.avatar;
const isOnline = presence[friendUsername] || false;
const unread = unreadCounts[friendUsername] || 0;
const isActive = friendUsername === recipientName;
return (
<button
key={friendUsername}
className={"chat-sidebar-item" + (isActive ? " active" : "") + (unread > 0 ? " has-unread" : "")}
onClick={() => onSelectFriend?.(friend)}
title={friendUsername}
>
<div className={"chat-sidebar-avatar" + (isOnline ? " online" : "")}>
{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>
);
}

View File

@ -82,6 +82,7 @@ export default function RadialMenu({
const handleTimeClick = (hours) => {
onTime?.(hours);
setRightOpen(false); // Close menu after selection
};
const handleWeatherClick = () => {
@ -206,7 +207,7 @@ export default function RadialMenu({
</div>
{/* 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">
{/* Time options */}
{TIME_OPTIONS.map((t, i) => {

View File

@ -567,7 +567,7 @@ function openImageLightbox(src) {
overlay.style.position = "fixed";
overlay.style.inset = "0";
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.alignItems = "center";
overlay.style.justifyContent = "center";
@ -997,7 +997,7 @@ function openCameraOverlay({ map, post, theme, fullScreen, interactionState }) {
backdrop.style.cssText = `
position: fixed;
inset: 0;
z-index: 9999999;
z-index: 5000;
background: rgba(0,0,0,0.85);
display: flex;
align-items: center;
@ -1218,7 +1218,7 @@ export function openCenteredOverlay({ map, post, theme, fullScreen = false }) {
if (fullScreen) backdrop.classList.add("sw-modal-fullscreen");
backdrop.style.position = "fixed";
backdrop.style.inset = "0";
backdrop.style.zIndex = "9999999";
backdrop.style.zIndex = "5000";
backdrop.style.background = "rgba(0,0,0,0.08)";
backdrop.style.backdropFilter = "blur(1px)";
backdrop.style.display = "flex";

View File

@ -194,7 +194,7 @@ export function openImageLightbox(src) {
overlay.style.position = "fixed";
overlay.style.inset = "0";
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.alignItems = "center";
overlay.style.justifyContent = "center";

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 { createMarkerForPost, createSimpleMarkerForPost } from "./markerManager";
import { getTemplateKeyForPost } from "./templateSpecs";
@ -875,6 +875,12 @@ export function usePostsEngine({
// Apply content filter (links, video, image, live, event)
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) {
const id = Number(getId(p));
const clusterPostId = Number(p?.cluster_post_id);
@ -1521,12 +1527,14 @@ export function usePostsEngine({
const handlePostDelete = useCallback(
(payload) => {
const postId = payload?.post_id || payload?.postId || payload?.id;
if (!postId) return;
const rawId = payload?.post_id || payload?.postId || payload?.id;
if (!rawId) return;
const postId = Number(rawId); // Ensure numeric comparison
console.log("[PostsEngine] handlePostDelete postId=", postId);
const map = mapRef.current;
const overlay = map?.__swCenteredOverlay;
if (overlay && overlay.postId === postId) {
if (overlay && Number(overlay.postId) === postId) {
// Remove event listeners
try { if (overlay.onKey) window.removeEventListener("keydown", overlay.onKey); } catch {}
try { if (overlay.onAuth) window.removeEventListener("sociowire:auth-changed", overlay.onAuth); } catch {}
@ -1544,13 +1552,13 @@ export function usePostsEngine({
map.__swCenteredOverlay = null;
}
allPostsRef.current = (allPostsRef.current || []).filter((p) => (p?.id || p?.ID) !== postId);
setVisiblePosts((current) => 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) => Number(p?.id || p?.ID) !== postId));
const markers = markersRef.current || [];
const nextMarkers = [];
for (const item of markers) {
if (!item || item.id !== postId) {
if (!item || Number(item.id) !== postId) {
nextMarkers.push(item);
continue;
}

View File

@ -157,7 +157,7 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
comment: post?.comment_count ?? 0,
share: post?.share_count ?? 0,
}));
const { username, token } = useAuth();
const { username, token, userId } = useAuth();
// Edit/Delete state
const [showEditDelete, setShowEditDelete] = useState(false);
@ -232,8 +232,11 @@ export default function PostCard({ post, selected, onSelect, onPostDeleted, onPo
const title = post?.title || "Untitled";
const snippet = post?.snippet || "";
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 =
username && author && author.toLowerCase() === username.toLowerCase();
(username && author && author.toLowerCase() === username.toLowerCase()) ||
(userId && authorId && Number(userId) === Number(authorId));
const [authorAvatar, setAuthorAvatar] = useState("");
const sub = (post?.sub_category || post?.subCategory || "").toString();
const contentType = (post?.content_type || "").toString().toLowerCase();

View File

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