diff --git a/bun.lock b/bun.lock
index 790e5a0..fd532e8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -36,6 +36,7 @@
"dexie-react-hooks": "^4.2.0",
"framer-motion": "^12.23.27",
"lucide-react": "^0.562.0",
+ "moment": "^2.30.1",
"nanostores": "^1.1.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
@@ -520,6 +521,8 @@
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+ "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
+
"motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="],
"motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="],
diff --git a/package.json b/package.json
index b2a0ff1..484376b 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"dexie-react-hooks": "^4.2.0",
"framer-motion": "^12.23.27",
"lucide-react": "^0.562.0",
+ "moment": "^2.30.1",
"nanostores": "^1.1.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
diff --git a/src/components/home/index.tsx b/src/components/home/index.tsx
index 69e557d..a59b1dd 100644
--- a/src/components/home/index.tsx
+++ b/src/components/home/index.tsx
@@ -8,8 +8,7 @@ import {
SidebarInset,
SidebarMenu,
SidebarMenuItem,
- SidebarProvider,
- SidebarTrigger
+ SidebarProvider
} from "@/components/ui/sidebar";
import { CompassIcon, HouseIcon } from "@phosphor-icons/react";
import { Plus } from "lucide-react";
@@ -85,9 +84,6 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
-
-
-
{
@@ -122,7 +118,6 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
{/* Socket connection status */}
-
{/* Spacer for centering on mobile */}
diff --git a/src/components/ui/dm/DmChannelContent.tsx b/src/components/ui/dm/DmChannelContent.tsx
index c8b7125..a99e08f 100644
--- a/src/components/ui/dm/DmChannelContent.tsx
+++ b/src/components/ui/dm/DmChannelContent.tsx
@@ -3,6 +3,7 @@ import { useSocketContext } from "@/contexts/socket-context";
import { clearUnread, db, sendMessage } from "@/lib/db";
import { useLiveQuery } from "dexie-react-hooks";
import { KeyRound } from "lucide-react";
+import moment from "moment";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
@@ -222,10 +223,13 @@ export default function DMChannelContent(
-
+ {/* Spacer to push messages to the bottom when there are few messages */}
+
+
+
{/* Load more indicator */}
{hasMoreMessages && (
@@ -244,7 +248,7 @@ export default function DMChannelContent(
setIsLoadingMore(false);
}, 100);
}}
- className="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-muted/50"
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-muted/50 active:bg-muted/70"
>
Load more messages
@@ -256,7 +260,8 @@ export default function DMChannelContent(
const selfDetail = participantDetails.find((p) => p.id === userId);
const isSelf = msg.fromUserId === userId;
const displayName = isSelf ? selfDetail?.displayUsername ?? selfDetail?.username ?? selfDetail?.name ?? "You" : (sender?.displayUsername ?? sender?.username ?? sender?.name ?? "Unknown");
- const timeLabel = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
+ const timestamp = moment(msg.timestamp);
+ const timeLabel = timestamp.isSame(moment(), "day") ? timestamp.format("h:mm A") : timestamp.format("MMM D, YYYY h:mm A");
// Check if this message is from the same user as the previous one within 5 minutes
const prevMsg = index > 0 ? messages[index - 1] : null;
@@ -268,12 +273,12 @@ export default function DMChannelContent(
return (
{!isGrouped ? (
// Full message with avatar and header
-
-
+
+
{displayName.slice(0, 2).toUpperCase()}
@@ -281,28 +286,30 @@ export default function DMChannelContent(
-
-
+
+
{displayName}
-
+
{timeLabel}
-
) : (
// Compact message without avatar (grouped)
-
-
+
+
- {new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
+ {
+ timeLabel
+ }
-
@@ -317,12 +324,17 @@ export default function DMChannelContent(
{/* Message input */}
-
+
setMessageInput(e.target.value)}
+ disabled={otherUser.status === "offline"}
onKeyDown={async (e) => {
if (e.key === 'Enter' && !e.shiftKey && messageInput.trim() && password) {
e.preventDefault();
diff --git a/src/components/ui/friends/friend-list-item.tsx b/src/components/ui/friends/friend-list-item.tsx
index 1474c29..03b4dd4 100644
--- a/src/components/ui/friends/friend-list-item.tsx
+++ b/src/components/ui/friends/friend-list-item.tsx
@@ -48,7 +48,7 @@ export function FriendListItem({
return (
{
// Call the db to create or get the dm channel
getOrCreateDmChannel(userId, {
@@ -62,7 +62,7 @@ export function FriendListItem({
}}
>
{/* Left side: Avatar + Info */}
-
+
- {/* Right side: Actions Menu */}
-
+ {/* Right side: Actions Menu - always visible on mobile via opacity, hover on desktop */}
+
onMessage?.(friend._id)}
+ className="size-9 md:size-8 hover:bg-background/80"
+ onClick={(e) => {
+ e.stopPropagation()
+ onMessage?.(friend._id)
+ }}
title="Message"
>
diff --git a/src/components/ui/friends/friends-page.tsx b/src/components/ui/friends/friends-page.tsx
index 06f6cbb..5282dd0 100644
--- a/src/components/ui/friends/friends-page.tsx
+++ b/src/components/ui/friends/friends-page.tsx
@@ -77,19 +77,19 @@ export function FriendsPage({
return (
{/* Search Input - Sticky at top */}
-
+
setFriendsSearch(e.target.value)}
- className="w-full"
+ className="w-full h-10 md:h-9"
/>
{/* Scrollable Friends List */}
-
-
-
+
+
+
{friendsPage === "all"
? `All Friends • ${filteredFriends.length} of ${friends?.length || 0}`
: `Available Friends • ${filteredFriends.length} of ${friends?.filter((f: FriendData) => f && f.status?.status !== "offline").length || 0}`
@@ -110,8 +110,8 @@ export function FriendsPage({
/>
))
) : (
-
-
+
+
{friendsSearch ? `No friends found matching "${friendsSearch}"` : emptyMessage}
diff --git a/src/components/ui/layout/channel-list.tsx b/src/components/ui/layout/channel-list.tsx
index aaf73cb..165f6b6 100644
--- a/src/components/ui/layout/channel-list.tsx
+++ b/src/components/ui/layout/channel-list.tsx
@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"
import { clearUnread, db } from "@/lib/db"
import { formatDistanceToNow } from "date-fns"
import { useLiveQuery } from "dexie-react-hooks"
-import { PlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react"
+import { MessageSquarePlusIcon, SettingsIcon, Sparkles, UsersIcon, XIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import UserCard from "../user/user-card"
@@ -23,8 +23,11 @@ export interface ChannelListProps {
displayUsername: string
image: string
status: "online" | "busy" | "offline" | "away"
+ isCurrentUser: boolean
}[]
}
+ onChannelSelect?: () => void
+ isMobile?: boolean
}
export function ChannelList({
@@ -34,6 +37,8 @@ export function ChannelList({
onPageChange,
emptyMessage = "No messages yet",
dmChannel,
+ onChannelSelect,
+ isMobile,
}: ChannelListProps) {
const router = useRouter()
@@ -42,141 +47,209 @@ export function ChannelList({
[]
)
+ const handleNavigation = (path: string) => {
+ router.push(path)
+ onChannelSelect?.()
+ }
+
return (
-
- {/* Channel List Header */}
-
-
+
+ {/* Channel List Header - Navigation Items (Desktop only) */}
+ {!isMobile && (
+ <>
+
+
{
+ onPageChange("friends")
+ handleNavigation("/")
+ }}
+ >
+
+
+
+ Friends
+
+
{
+ onPageChange("support")
+ onChannelSelect?.()
+ }}
+ >
+
+
+
+ Settings
+
+
+
+ {/* Divider with label */}
+
+ >
+ )}
+
+ {/* Mobile Navigation Buttons */}
+ {isMobile && (
+
{
onPageChange("friends")
- router.push("/")
+ handleNavigation("/")
}}
>
-
- Friends
+
+ Friends
onPageChange("support")}
+ variant={page === "support" ? "default" : "outline"}
+ size="sm"
+ className="flex-1 h-9 text-xs font-semibold"
+ onClick={() => {
+ onPageChange("support")
+ onChannelSelect?.()
+ }}
>
-
- Settings
+
+ Settings
-
-
-
+ )}
{/* Channel List */}
-
+
{page === "friends" || !currentChannel ? (
-
-
+ {/* DM Header */}
+
+
{openDmChannels.length > 0 ? (
- openDmChannels.map((channel) => {
- const isActive = dmChannel?.id === channel.id
- const lastMessage = channel.times?.lastMessage
- const lastMessageTime = channel.times?.lastMessageAt
- const channelUnreadCount = unreadCount?.find((unread) => unread.channelId === channel.id)?.count ?? 0
- if (!channel.isOpen) return null;
+
+ {openDmChannels.map((channel) => {
+ const participantDetails = dmChannel?.participantDetails.find((p) => p.id === channel.participants[0])
+ const isActive = dmChannel?.id === channel.id
+ const lastMessage = channel.times?.lastMessage
+ const lastMessageTime = channel.times?.lastMessageAt
+ const channelUnreadCount = unreadCount?.find((unread) => unread.channelId === channel.id)?.count ?? 0
+ if (!channel.isOpen) return null;
- return (
-
{
- clearUnread(channel.id)
- console.log("Cleared unread count for channel", channel.id)
- router.push(`/channels/me/${channel.id}`)
- }}
- >
-
-
- {channelUnreadCount > 0 && (
-
- {channelUnreadCount > 99 ? '99+' : channelUnreadCount}
-
- )}
-
-
- {/* Channel Info */}
-
-
-
- {channel.name}
-
- {lastMessageTime && (
-
- {formatDistanceToNow(lastMessageTime, { addSuffix: false })}
+ return (
+ {
+ clearUnread(channel.id)
+ console.log("Cleared unread count for channel", channel.id)
+ handleNavigation(`/channels/me/${channel.id}`)
+ }}
+ >
+
+
+ {channelUnreadCount > 0 && (
+
+ {channelUnreadCount > 99 ? '99+' : channelUnreadCount}
)}
- {lastMessage && (
-
- {lastMessage.content}
-
- )}
+
+ {/* Channel Info */}
+
+
+
+ {channel.name}
+
+ {lastMessageTime && (
+
+ {formatDistanceToNow(lastMessageTime, { addSuffix: false })}
+
+ )}
+
+ {lastMessage && (
+
+ {lastMessage.content}
+
+ )}
+
+
+ {/* Close button - always visible on mobile, hover-visible on desktop */}
+
{
+ e.stopPropagation()
+ const isCurrentlyViewing = isActive
+
+ db.channels.where("id").equals(channel.id).modify((channel) => {
+ channel.isOpen = false;
+ });
+
+ // Navigate away if we're closing the currently viewed channel
+ if (isCurrentlyViewing) {
+ console.log("Navigating away from channel")
+ handleNavigation("/")
+ }
+ }}
+ title="Close DM"
+ >
+
+
-
- {/* Close button */}
- {
- e.stopPropagation()
- const isCurrentlyViewing = isActive
-
- db.channels.where("id").equals(channel.id).modify((channel) => {
- channel.isOpen = false;
- });
-
- // Navigate away if we're closing the currently viewed channel
- if (isCurrentlyViewing) {
- console.log("Navigating away from channel")
- router.push("/")
- }
- }}
- title="Close DM"
- >
-
-
-
- )
- })
+ )
+ })}
+
) : (
-
) : (
-
-
No channels
+
+ No channels
)}
diff --git a/src/components/ui/layout/main-content-layout.tsx b/src/components/ui/layout/main-content-layout.tsx
index f61577d..5a0eb92 100644
--- a/src/components/ui/layout/main-content-layout.tsx
+++ b/src/components/ui/layout/main-content-layout.tsx
@@ -1,9 +1,16 @@
"use client"
import FriendRequestModal from "@/components/home/modals/friendRequest"
+import { Button } from "@/components/ui/button"
+import LogoIcon from "@/components/ui/logo-icon"
+import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
+import { useIsMobile } from "@/hooks/use-mobile"
import { db } from "@/lib/db"
+import { cn } from "@/lib/utils"
+import { CompassIcon } from "@phosphor-icons/react"
import { useQuery } from "convex/react"
import { useLiveQuery } from "dexie-react-hooks"
+import { Plus } from "lucide-react"
import * as React from "react"
import { useEffect, useMemo } from "react"
import { api } from "../../../../convex/_generated/api"
@@ -39,6 +46,8 @@ export function MainContentLayout({
const [friendsPage, setFriendsPage] = React.useState<"all" | "available">("all")
const [friendModal, setFriendModal] = React.useState(false)
const [currentChannel] = React.useState
(null)
+ const [mobileChannelListOpen, setMobileChannelListOpen] = React.useState(false)
+ const isMobile = useIsMobile()
// Use useLiveQuery to reactively fetch channels - automatically updates when DB changes
const openDmChannels = useLiveQuery(
@@ -70,6 +79,7 @@ export function MainContentLayout({
displayUsername: participant.displayUsername ?? "",
image: participant.image ?? "",
status: participant.status,
+ isCurrentUser: participant.id === userId,
}))
}
}, [openDmChannels, dmChannelId, getParticipantDetails])
@@ -85,6 +95,34 @@ export function MainContentLayout({
}
}, [dmChannelId, serverChannelId]);
+ // Close mobile channel list when navigating to a channel
+ const handlePageChange = React.useCallback((newPage: "friends" | "support" | "dm" | "server") => {
+ setPage(newPage);
+ if (isMobile) {
+ setMobileChannelListOpen(false);
+ }
+ }, [isMobile]);
+
+ // Close mobile sheet when a DM channel is selected
+ const handleMobileChannelSelect = React.useCallback(() => {
+ if (isMobile) {
+ setMobileChannelListOpen(false);
+ }
+ }, [isMobile]);
+
+ const channelListContent = (
+
+ );
+
return (
<>
@@ -98,19 +136,70 @@ export function MainContentLayout({
dmChannel={dmChannel}
serverId={serverId}
serverChannelId={serverChannelId}
+ onToggleMobileChannelList={() => setMobileChannelListOpen(true)}
+ isMobile={isMobile}
/>
{/* Content Area - Channel List + Main Content */}
+ {/* Desktop: Always visible channel list */}
+
+ {channelListContent}
+
-
+ {/* Mobile: Sheet-based channel list - Discord-style two-panel layout */}
+ {isMobile && (
+
+
+
+ Channels
+ Navigate between channels and DMs
+
+
+ {/* Left Rail - Server/Home Icons (Discord-style) */}
+
+ {/* Home/DMs Button */}
+
+
+
+
+ {/* Divider */}
+
+
+ {/* Discover */}
+
+
+
+
+ {/* Future: Server icons will go here */}
+ {/* Placeholder for servers */}
+
+ {/* Add Server Button */}
+
+
+
+
+
+ {/* Right Panel - Channel List */}
+
+ {/* Panel Header */}
+
+ Direct Messages
+
+
+ {/* Channel List Content */}
+
+ {channelListContent}
+
+
+
+
+
+ )}
{/* Main Content */}
@@ -151,4 +240,55 @@ export function MainContentLayout({
/>
>
)
+}
+
+// Discord-style mobile server icon component
+function MobileServerIcon({
+ children,
+ isActive,
+ isHome,
+ isAddButton,
+ label,
+ onClick
+}: {
+ children: React.ReactNode
+ isActive?: boolean
+ isHome?: boolean
+ isAddButton?: boolean
+ label?: string
+ onClick?: () => void
+}) {
+ return (
+
+ {/* Left pill indicator */}
+
+
+ {/* Icon button */}
+
+ {children}
+
+
+ )
}
\ No newline at end of file
diff --git a/src/components/ui/layout/page-header.tsx b/src/components/ui/layout/page-header.tsx
index db7073f..26ebca1 100644
--- a/src/components/ui/layout/page-header.tsx
+++ b/src/components/ui/layout/page-header.tsx
@@ -1,7 +1,7 @@
"use client"
import { Button } from "@/components/ui/button"
-import { PhoneIcon, SearchIcon, UserIcon, UsersIcon, VideoIcon } from "lucide-react"
+import { MenuIcon, PhoneIcon, SearchIcon, UserIcon, UserPlusIcon, UsersIcon, VideoIcon } from "lucide-react"
import UserCard from "../user/user-card"
export interface PageHeaderProps {
@@ -19,10 +19,13 @@ export interface PageHeaderProps {
displayUsername: string
image: string
status: "online" | "busy" | "offline" | "away"
+ isCurrentUser: boolean
}[]
}
serverId?: string
serverChannelId?: string
+ onToggleMobileChannelList?: () => void
+ isMobile?: boolean
}
export function PageHeader({
@@ -34,11 +37,28 @@ export function PageHeader({
dmChannel,
serverId,
serverChannelId,
+ onToggleMobileChannelList,
+ isMobile,
}: PageHeaderProps) {
+
+ const otherParticipant = dmChannel && dmChannel.participantDetails.find((p) => !p.isCurrentUser)
+
return (
-
- {/* SCS or DM Selector */}
-
+
+ {/* Mobile: Menu toggle button */}
+ {isMobile && (
+
+
+
+ )}
+
+ {/* Desktop: SCS or DM Selector */}
+
{!currentChannel || currentChannel.type === "DM" ? (
{/* Page title/options */}
- {dmChannel ? (
-
+ {dmChannel && otherParticipant ? (
+
-
{dmChannel.participantDetails[0].name}
-
+
{otherParticipant.name}
+
) : serverChannelId ? (
-
+
#{serverChannelId}
) : page === "friends" ? (
-
-
+
+
- Friends
+ Friends
-
•
-
+
•
+
onFriendsPageChange?.("available")}
>
- Available
+ Available
+ Online
onFriendsPageChange?.("all")}
>
- All Known
+ All Known
+ All
- Add Friend
+
+ Add Friend
diff --git a/src/components/ui/layout/settings-page.tsx b/src/components/ui/layout/settings-page.tsx
index 2a0b578..f4dc10c 100644
--- a/src/components/ui/layout/settings-page.tsx
+++ b/src/components/ui/layout/settings-page.tsx
@@ -8,7 +8,7 @@ export interface SettingsPageProps {
export function SettingsPage({}: SettingsPageProps) {
return (
-