diff --git a/src/auth/AuthContext.jsx b/src/auth/AuthContext.jsx index e85a1c9..15f64ca 100644 --- a/src/auth/AuthContext.jsx +++ b/src/auth/AuthContext.jsx @@ -155,6 +155,7 @@ async function me(accessToken) { status: res.status, json, text, + userId: u && typeof u.user_id === "number" ? u.user_id : null, username: (u && (u.username || u.user || u.preferred_username)) || "", emailVerified, avatarUrl: u && typeof u.avatar_url === "string" ? u.avatar_url : "", @@ -212,7 +213,7 @@ function nowSec() { export function AuthProvider({ children }) { const [status, setStatus] = useState("loading"); // loading | anon | auth - const [user, setUser] = useState(null); // { username } + const [user, setUser] = useState(null); // { username, userId } const [tokens, setTokens] = useState(null); // { access_token, refresh_token, ... } const [avatarUrl, setAvatarUrl] = useState(""); const [profileLoaded, setProfileLoaded] = useState(false); @@ -261,7 +262,11 @@ export function AuthProvider({ children }) { setAvatarUrl(mr.avatarUrl); } if (mr.username) { - setUser({ username: mr.username }); + setUser((prev) => ({ + ...prev, + username: mr.username, + userId: mr.userId || prev?.userId || null, + })); } if (typeof mr.needsUsername === "boolean") { setNeedsUsername(mr.needsUsername); @@ -742,6 +747,7 @@ export function AuthProvider({ children }) { const loading = status === "loading"; const authenticated = status === "auth"; const username = user?.username || ""; + const userId = user?.userId || null; const token = tokens?.access_token || ""; return { @@ -758,6 +764,7 @@ export function AuthProvider({ children }) { loading, authenticated, username, + userId, token, lastError: error || "", diff --git a/src/components/Chat/ChatContainer.jsx b/src/components/Chat/ChatContainer.jsx index f456bfa..1252c9b 100644 --- a/src/components/Chat/ChatContainer.jsx +++ b/src/components/Chat/ChatContainer.jsx @@ -101,6 +101,9 @@ export default function ChatContainer() { } }, []); + // Reference to markAsRead function (will be set after useChat) + const markAsReadRef = useRef(null); + // Handle new incoming message const handleNewMessage = useCallback((msg) => { const senderName = msg.sender_name; @@ -110,18 +113,24 @@ export default function ChatContainer() { const isChatMinimized = minimizedChatsRef.current[senderName]; const isDocumentFocused = document.hasFocus(); - // Update unread count if chat is not visible - if (!isChatOpen || isChatMinimized || !isDocumentFocused) { + // Chat is visible = open + not minimized + document focused + const isChatVisible = isChatOpen && !isChatMinimized && isDocumentFocused; + + if (isChatVisible) { + // Chat is open and user can see the message - mark as read immediately + if (markAsReadRef.current) { + markAsReadRef.current(senderName); + } + } else { + // Update unread count if chat is not visible setUnreadCounts((prev) => ({ ...prev, [senderName]: (prev[senderName] || 0) + 1, })); } - // Show notification if: - // - Chat is not open, or is minimized, or document is not focused - // - Notification permission is granted - const shouldNotify = !isChatOpen || isChatMinimized || !isDocumentFocused; + // Show notification if chat is not visible + const shouldNotify = !isChatVisible; if (shouldNotify) { // Play notification sound @@ -168,6 +177,11 @@ export default function ChatContainer() { onNewMessage: handleNewMessage, }); + // Keep markAsReadRef updated so handleNewMessage can use it + useEffect(() => { + markAsReadRef.current = markAsRead; + }, [markAsRead]); + // Load friends when authenticated useEffect(() => { if (!authenticated) { @@ -243,22 +257,28 @@ export default function ChatContainer() { // Refresh conversations to update unread counts/badges const convs = await fetchConversations(); if (convs && convs.length > 0) { - const newUnread = {}; + // Build new unread counts from server (source of truth) + const serverUnread = {}; convs.forEach((c) => { - if (c.unread_count > 0) { - newUnread[c.username] = c.unread_count; - } + // Use case-insensitive key matching + serverUnread[c.username.toLowerCase()] = { username: c.username, count: c.unread_count || 0 }; }); + setUnreadCounts((prev) => { - // Merge but don't overwrite zeros (already read in this session) - const merged = { ...prev }; - Object.entries(newUnread).forEach(([user, count]) => { - // Only update if we don't have a local zero (user already read it) - if (merged[user] === undefined || merged[user] > 0) { - merged[user] = count; - } + const updated = {}; + // For each conversation from server, use server count + // unless the chat is currently open and not minimized (user is viewing it) + Object.values(serverUnread).forEach(({ username, count }) => { + const key = username; + const isOpenAndVisible = activeChatsRef.current.some( + (c) => c.username.toLowerCase() === username.toLowerCase() + ) && !minimizedChatsRef.current[username]; + + // If chat is open and visible, keep it at 0 (user can see messages) + // Otherwise use server count + updated[key] = isOpenAndVisible ? 0 : count; }); - return merged; + return updated; }); } } @@ -388,6 +408,10 @@ export default function ChatContainer() { return; } + // Refresh conversations when opening to get latest activity for sorting + if (!showContacts) { + fetchConversations(); + } setShowContacts(!showContacts); }; @@ -468,7 +492,22 @@ export default function ChatContainer() { ) : (
- {friends.map((friend) => { + {/* Sort friends by last activity: conversations first (by date), then others */} + {[...friends].sort((a, b) => { + const aUsername = a.username || a.name; + const bUsername = b.username || b.name; + const aConv = conversations.find(c => c.username?.toLowerCase() === aUsername?.toLowerCase()); + const bConv = conversations.find(c => c.username?.toLowerCase() === bUsername?.toLowerCase()); + + // Friends with conversations come first, sorted by last_message_at + if (aConv && bConv) { + return new Date(bConv.last_message_at) - new Date(aConv.last_message_at); + } + if (aConv) return -1; + if (bConv) return 1; + // Friends without conversations sorted alphabetically + return aUsername.localeCompare(bUsername); + }).map((friend) => { const friendUsername = friend.username || friend.name; const avatar = friend.avatar_url || friend.avatar; const isOnline = presence[friendUsername] || false; diff --git a/src/components/Layout/TopBar.jsx b/src/components/Layout/TopBar.jsx index 78f96f3..1ae1f69 100644 --- a/src/components/Layout/TopBar.jsx +++ b/src/components/Layout/TopBar.jsx @@ -532,7 +532,7 @@ export default function TopBar({ theme = "dark", onChangeTheme, onIslandsClick, ) : (
-
+
diff --git a/src/components/Map/MapView.jsx b/src/components/Map/MapView.jsx index 2780684..e555979 100644 --- a/src/components/Map/MapView.jsx +++ b/src/components/Map/MapView.jsx @@ -212,6 +212,70 @@ export default function MapView({ [mapRef, theme] ); + // Long-press detection for opening create post popup (1 second) + const longPressTimerRef = useRef(null); + const longPressStartRef = useRef(null); + const LONG_PRESS_DURATION = 1000; // 1 second + + const handleLongPressStart = useCallback((e) => { + // Only trigger on map container, not on overlays/buttons + if (e.target.closest('.map-overlay') || e.target.closest('.sw-marker') || e.target.closest('button')) { + return; + } + longPressStartRef.current = { x: e.clientX || e.touches?.[0]?.clientX, y: e.clientY || e.touches?.[0]?.clientY }; + longPressTimerRef.current = setTimeout(() => { + handleOpenCreate(); + }, LONG_PRESS_DURATION); + }, []); + + const handleLongPressEnd = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + longPressTimerRef.current = null; + } + longPressStartRef.current = null; + }, []); + + const handleLongPressMove = useCallback((e) => { + if (!longPressStartRef.current) return; + const x = e.clientX || e.touches?.[0]?.clientX; + const y = e.clientY || e.touches?.[0]?.clientY; + const dx = Math.abs(x - longPressStartRef.current.x); + const dy = Math.abs(y - longPressStartRef.current.y); + // If moved more than 10px, cancel the long press + if (dx > 10 || dy > 10) { + handleLongPressEnd(); + } + }, [handleLongPressEnd]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('mousedown', handleLongPressStart); + container.addEventListener('mouseup', handleLongPressEnd); + container.addEventListener('mouseleave', handleLongPressEnd); + container.addEventListener('mousemove', handleLongPressMove); + container.addEventListener('touchstart', handleLongPressStart, { passive: true }); + container.addEventListener('touchend', handleLongPressEnd); + container.addEventListener('touchcancel', handleLongPressEnd); + container.addEventListener('touchmove', handleLongPressMove, { passive: true }); + + return () => { + container.removeEventListener('mousedown', handleLongPressStart); + container.removeEventListener('mouseup', handleLongPressEnd); + container.removeEventListener('mouseleave', handleLongPressEnd); + container.removeEventListener('mousemove', handleLongPressMove); + container.removeEventListener('touchstart', handleLongPressStart); + container.removeEventListener('touchend', handleLongPressEnd); + container.removeEventListener('touchcancel', handleLongPressEnd); + container.removeEventListener('touchmove', handleLongPressMove); + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current); + } + }; + }, [containerRef, handleLongPressStart, handleLongPressEnd, handleLongPressMove]); + const fitBoundsWhenReady = useCallback( (bounds, opts = {}) => { const tryFit = () => { @@ -1671,8 +1735,11 @@ export default function MapView({ )} {modeSelectorVisible && ( -
-
+
+ +
Create a post
{CONTENT_CREATOR_MODES.map((mode) => (
- {`Share as ${currentContentMode.label}`} - + + {currentContentMode.label}
Step {createStep + 1} / 3
diff --git a/src/styles/map.css b/src/styles/map.css index 80a0dca..2b4067e 100644 --- a/src/styles/map.css +++ b/src/styles/map.css @@ -1350,3 +1350,442 @@ body[data-theme="light"] .map-action-btn * { .map-overlay.mode-selector-widget{ pointer-events:none; } + +/* ===== NEW CREATE PANEL V2 - IMPROVED DESIGN ===== */ + +.create-panel-v2{ + position:absolute; + left:50%; + top:50%; + transform:translate(-50%,-50%); + width:min(340px, 92vw); + max-height:80vh; + overflow:auto; + background: linear-gradient(145deg, rgba(15,23,42,0.97), rgba(7,10,14,0.98)); + backdrop-filter: blur(24px); + border-radius:24px; + border:1px solid rgba(56,189,248,0.35); + box-shadow: + 0 25px 60px rgba(0,0,0,0.65), + 0 0 40px rgba(56,189,248,0.15), + inset 0 1px 0 rgba(255,255,255,0.08); + padding: 0; + color: #e5e7eb; +} + +.create-panel-v2 .create-panel-header{ + display:flex; + align-items:center; + justify-content:center; + gap:0.6rem; + padding: 1rem 1.2rem 0.6rem; + font-size:1rem; + font-weight:700; + letter-spacing:0.02em; + text-transform:none; + color:#f8fafc; + border-bottom: 1px solid rgba(148,163,184,0.15); + margin-bottom:0; +} + +.create-panel-v2 .create-header-icon{ + font-size:1.1rem; + color:#38bdf8; +} + +.create-panel-v2 .create-panel-body{ + padding: 0.8rem 1rem 1rem; + display:flex; + flex-direction:column; + gap:0.7rem; +} + +.create-panel-v2 .create-step-label{ + font-size:0.7rem; + text-transform:uppercase; + letter-spacing:0.18em; + color:#64748b; + text-align:center; +} + +.create-panel-v2 .create-panel-hero{ + font-size:0.9rem; + color:#cbd5e1; + margin:0; + text-align:center; + line-height:1.4; +} + +.create-panel-v2 .create-panel-note{ + font-size:0.8rem; + color:#94a3b8; + margin:0; + text-align:center; + padding:0.5rem; + background:rgba(56,189,248,0.08); + border-radius:12px; + border:1px solid rgba(56,189,248,0.15); +} + +.create-panel-v2 .create-panel-field{ + display:flex; + flex-direction:column; + gap:0.4rem; +} + +.create-panel-v2 .create-panel-field label{ + font-size:0.75rem; + font-weight:600; + color:#94a3b8; + text-transform:uppercase; + letter-spacing:0.08em; +} + +.create-panel-v2 .create-panel-selects{ + display:flex; + gap:0.5rem; +} + +.create-panel-v2 .create-panel-selects select{ + flex:1; + background:rgba(15,23,42,0.8); + color:#e5e7eb; + border-radius:12px; + border:1px solid rgba(148,163,184,0.3); + padding:0.55rem 0.75rem; + font-size:0.85rem; + cursor:pointer; + transition:border-color 0.2s ease; +} + +.create-panel-v2 .create-panel-selects select:focus{ + outline:none; + border-color:rgba(56,189,248,0.6); +} + +.create-panel-v2 .create-input-narrow{ + background:rgba(15,23,42,0.8); + color:#e5e7eb; + border-radius:12px; + border:1px solid rgba(148,163,184,0.3); + padding:0.6rem 0.9rem; + font-size:0.9rem; + transition:border-color 0.2s ease, box-shadow 0.2s ease; +} + +.create-panel-v2 .create-input-narrow:focus{ + outline:none; + border-color:rgba(56,189,248,0.6); + box-shadow:0 0 0 3px rgba(56,189,248,0.12); +} + +.create-panel-v2 .create-input-narrow::placeholder{ + color:#64748b; +} + +.create-panel-v2 .create-hint-tight{ + font-size:0.75rem; + color:#64748b; + margin:0; +} + +.create-panel-v2 .create-textarea-tight{ + background:rgba(15,23,42,0.8); + border:1px solid rgba(148,163,184,0.3); + color:#e5e7eb; + border-radius:14px; + padding:0.7rem 0.9rem; + font-size:0.85rem; + min-height:90px; + resize:none; + transition:border-color 0.2s ease, box-shadow 0.2s ease; +} + +.create-panel-v2 .create-textarea-tight:focus{ + outline:none; + border-color:rgba(56,189,248,0.6); + box-shadow:0 0 0 3px rgba(56,189,248,0.12); +} + +.create-panel-v2 .create-panel-actions{ + display:flex; + gap:0.6rem; + margin-top:0.3rem; + padding-top:0.8rem; + border-top:1px solid rgba(148,163,184,0.15); +} + +.create-panel-v2 .create-panel-btn{ + flex:1; + padding:0.65rem 1rem; + font-size:0.85rem; + font-weight:600; + border-radius:14px; + border:1px solid rgba(148,163,184,0.3); + background:rgba(15,23,42,0.6); + color:#e5e7eb; + cursor:pointer; + transition:all 0.2s ease; +} + +.create-panel-v2 .create-panel-btn:hover:not(:disabled){ + background:rgba(30,41,59,0.8); + border-color:rgba(148,163,184,0.5); +} + +.create-panel-v2 .create-panel-btn:disabled{ + opacity:0.4; + cursor:not-allowed; +} + +.create-panel-v2 .create-panel-btn.primary{ + background:linear-gradient(135deg, #38bdf8, #22d3ee); + border:none; + color:#0f172a; + box-shadow:0 4px 14px rgba(56,189,248,0.35); +} + +.create-panel-v2 .create-panel-btn.primary:hover:not(:disabled){ + background:linear-gradient(135deg, #7dd3fc, #67e8f9); + box-shadow:0 6px 20px rgba(56,189,248,0.45); +} + +/* Close button - positioned top right */ +.create-close-btn{ + position:absolute; + top:0.6rem; + right:0.6rem; + width:32px; + height:32px; + border-radius:50%; + border:1px solid rgba(148,163,184,0.3); + background:rgba(15,23,42,0.8); + color:#94a3b8; + font-size:1rem; + display:flex; + align-items:center; + justify-content:center; + cursor:pointer; + transition:all 0.2s ease; + z-index:10; +} + +.create-close-btn:hover{ + background:rgba(239,68,68,0.15); + border-color:rgba(239,68,68,0.5); + color:#f87171; + transform:scale(1.05); +} + +.create-close-btn:active{ + transform:scale(0.95); +} + +/* Image choice buttons improved */ +.create-panel-v2 .create-image-choice{ + display:flex; + gap:0.5rem; + margin:0.3rem 0; +} + +.create-panel-v2 .create-image-choice-btn{ + flex:1; + padding:0.5rem 0.7rem; + border-radius:12px; + border:1px solid rgba(148,163,184,0.3); + background:rgba(15,23,42,0.6); + color:#cbd5e1; + font-size:0.8rem; + font-weight:500; + cursor:pointer; + transition:all 0.2s ease; +} + +.create-panel-v2 .create-image-choice-btn.is-active{ + background:rgba(56,189,248,0.15); + border-color:rgba(56,189,248,0.5); + color:#7dd3fc; +} + +/* File buttons improved */ +.create-panel-v2 .create-image-row{ + display:flex; + gap:0.5rem; + flex-wrap:wrap; +} + +.create-panel-v2 .create-file-btn{ + padding:0.5rem 0.8rem; + border-radius:12px; + border:1px solid rgba(148,163,184,0.3); + background:rgba(15,23,42,0.6); + color:#e5e7eb; + font-size:0.8rem; + font-weight:500; + cursor:pointer; + transition:all 0.2s ease; +} + +.create-panel-v2 .create-file-btn:hover{ + background:rgba(30,41,59,0.8); + border-color:rgba(148,163,184,0.5); +} + +.create-panel-v2 .create-file-btn--camera{ + border-color:rgba(56,189,248,0.4); + color:#7dd3fc; +} + +.create-panel-v2 .create-muted{ + font-size:0.75rem; + color:#64748b; + text-align:center; +} + +/* Link preview card improved */ +.create-panel-v2 .create-link-preview-card{ + border-radius:16px; + background:rgba(15,23,42,0.6); + border:1px solid rgba(148,163,184,0.25); + overflow:hidden; + margin-bottom:0.5rem; +} + +.create-panel-v2 .create-link-preview-card img{ + width:100%; + height:120px; + object-fit:cover; + display:block; +} + +.create-panel-v2 .create-link-preview-meta{ + padding:0.5rem 0.7rem; + font-size:0.75rem; + color:#94a3b8; +} + +/* Responsive adjustments */ +@media (max-width: 400px){ + .create-panel-v2{ + width:calc(100vw - 20px); + border-radius:18px; + } + .create-panel-v2 .create-panel-header{ + padding:0.8rem 1rem 0.5rem; + font-size:0.95rem; + } + .create-panel-v2 .create-panel-body{ + padding:0.6rem 0.8rem 0.8rem; + } +} + +/* ===== MODE SELECTOR V2 - IMPROVED DESIGN ===== */ + +.mode-selector-v2{ + position:absolute; + left:50%; + top:50%; + transform:translate(-50%,-50%); + pointer-events:auto; + background: linear-gradient(145deg, rgba(15,23,42,0.97), rgba(7,10,14,0.98)); + backdrop-filter: blur(24px); + border-radius:24px; + border:1px solid rgba(56,189,248,0.35); + box-shadow: + 0 25px 60px rgba(0,0,0,0.65), + 0 0 40px rgba(56,189,248,0.15), + inset 0 1px 0 rgba(255,255,255,0.08); + padding:1rem; + min-width:280px; +} + +.mode-selector-v2 .mode-selector-bg{ + display:none; +} + +.mode-selector-close{ + position:absolute; + top:0.6rem; + right:0.6rem; + width:32px; + height:32px; + border-radius:50%; + border:1px solid rgba(148,163,184,0.3); + background:rgba(15,23,42,0.8); + color:#94a3b8; + font-size:1rem; + display:flex; + align-items:center; + justify-content:center; + cursor:pointer; + transition:all 0.2s ease; + z-index:10; +} + +.mode-selector-close:hover{ + background:rgba(239,68,68,0.15); + border-color:rgba(239,68,68,0.5); + color:#f87171; + transform:scale(1.05); +} + +.mode-selector-title{ + text-align:center; + font-size:1.1rem; + font-weight:700; + color:#f8fafc; + margin-bottom:0.8rem; + padding-right:2rem; +} + +.mode-selector-v2 .mode-selector-chips{ + display:flex; + flex-direction:column; + gap:0.5rem; + padding:0; +} + +.mode-selector-v2 .mode-selector-chip{ + display:flex; + align-items:center; + gap:0.7rem; + padding:0.7rem 1rem; + border-radius:14px; + border:1px solid rgba(148,163,184,0.25); + background:rgba(15,23,42,0.6); + color:#e5e7eb; + font-size:0.9rem; + font-weight:500; + cursor:pointer; + transition:all 0.2s ease; + text-align:left; +} + +.mode-selector-v2 .mode-selector-chip i{ + font-size:1.15rem; + width:24px; + text-align:center; + color:#38bdf8; +} + +.mode-selector-v2 .mode-selector-chip:hover{ + background:rgba(30,41,59,0.8); + border-color:rgba(56,189,248,0.4); +} + +.mode-selector-v2 .mode-selector-chip.is-active{ + background:rgba(56,189,248,0.2); + border-color:rgba(56,189,248,0.6); +} + +@media (max-width: 360px){ + .mode-selector-v2{ + min-width:auto; + width:calc(100vw - 24px); + padding:0.8rem; + } + .mode-selector-v2 .mode-selector-chip{ + padding:0.6rem 0.8rem; + font-size:0.85rem; + } +} diff --git a/src/styles/topbar.css b/src/styles/topbar.css index 5a1b4bb..cd70425 100644 --- a/src/styles/topbar.css +++ b/src/styles/topbar.css @@ -496,7 +496,7 @@ body[data-theme="light"] .user-avatar{ .sw-avatar{ width: 26px; height: 26px; - border-radius: 8px; + border-radius: 50%; display: grid; place-items: center; background: rgba(56,189,248,0.18); @@ -507,6 +507,10 @@ body[data-theme="light"] .user-avatar{ flex-shrink: 0; } .sw-avatar i{ font-size: 12px; } +.sw-avatar-guest{ + background: transparent; + border: none; +} .sw-avatar img{ width: 100%; height: 100%;