feat: added mobile style and date handling

- Integrated `moment` library for improved date formatting in direct messages.
- Refactored timestamp display logic to show relative time for today's messages and full date for older messages.
- Made various UI adjustments for better responsiveness and consistency across components for the mobile version.
- Updated dependencies in `package.json` and `bun.lock` to include `moment`.
This commit is contained in:
Nixyan 2026-01-12 14:48:44 -03:00
parent f04b76dadc
commit af7142d3d0
10 changed files with 420 additions and 170 deletions

View file

@ -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=="],

View file

@ -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",

View file

@ -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
<div className="flex flex-col flex-1 h-svh min-h-0 overflow-hidden">
<header className="flex items-center justify-between md:justify-center gap-2 px-4 py-0.5 md:border-none border-b border-border backdrop-blur sticky top-0 z-10">
<div className="flex items-center gap-2 md:hidden">
<SidebarTrigger className="size-9" />
</div>
<div className="flex items-center gap-2 justify-end w-full select-none">
<div className="flex items-center justify-center gap-2 text-sm font-semibold w-full text-center">
{
@ -122,7 +118,6 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
{/* Socket connection status */}
<ConnectionStatusIndicator socketStatus={socketStatus} socketInfo={socketInfo} disconnectSocket={disconnectSocket} connectSocket={connectSocket} />
</div>
<div className="w-9 md:hidden" /> {/* Spacer for centering on mobile */}
</header>
<SidebarInset className="mr-0 mb-0 border-none flex-1 min-h-0 overflow-hidden rounded-l-lg">
<div className="w-full h-full bg-background border-border border rounded-l-lg rounded-bl-none overflow-hidden min-h-0">

View file

@ -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(
<div className="flex-1 min-h-0 overflow-hidden">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto"
className="h-full overflow-y-auto flex flex-col"
onScroll={handleScroll}
>
<div className="pt-4">
{/* Spacer to push messages to the bottom when there are few messages */}
<div className="flex-1 min-h-0" />
<div className="pt-2 md:pt-4">
{/* Load more indicator */}
{hasMoreMessages && (
<div className="flex justify-center py-4">
@ -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
</button>
@ -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 (
<div
key={msg.id}
className="group relative px-4 py-0.5 hover:bg-muted/50 transition-colors duration-100"
className="group relative px-2 md:px-4 py-0.5 hover:bg-muted/50 active:bg-muted/50 transition-colors duration-100"
>
{!isGrouped ? (
// Full message with avatar and header
<div className="flex gap-4 mt-[17px]">
<Avatar className="w-10 h-10 shrink-0 mt-0.5">
<div className="flex gap-2 md:gap-4 mt-3 md:mt-[17px]">
<Avatar className="w-8 h-8 md:w-10 md:h-10 shrink-0 mt-0.5">
<AvatarImage src={sender?.image ?? undefined} alt={displayName} />
<AvatarFallback className="text-xs">
{displayName.slice(0, 2).toUpperCase()}
@ -281,28 +286,30 @@ export default function DMChannelContent(
</Avatar>
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex items-baseline gap-2 leading-snug">
<span className="font-semibold text-[15px] text-foreground hover:underline cursor-pointer">
<div className="flex items-baseline gap-2 leading-snug flex-wrap">
<span className="font-semibold text-sm md:text-[15px] text-foreground hover:underline cursor-pointer">
{displayName}
</span>
<span className="text-[11px] text-muted-foreground font-medium">
<span className="text-[10px] md:text-[11px] text-muted-foreground font-medium">
{timeLabel}
</span>
</div>
<div className="text-[15px] leading-[1.375rem] text-foreground mt-0.5 wrap-break-word">
<div className="text-sm md:text-[15px] leading-[1.375rem] text-foreground mt-0.5 wrap-break-word">
{msg.content}
</div>
</div>
</div>
) : (
// Compact message without avatar (grouped)
<div className="flex gap-4 leading-[1.375rem]">
<div className="w-10 shrink-0 flex items-start justify-end pt-0.5">
<div className="flex gap-2 md:gap-4 leading-[1.375rem]">
<div className="w-8 md:w-10 shrink-0 flex items-start justify-end pt-0.5">
<span className="text-[10px] text-transparent group-hover:text-muted-foreground transition-colors duration-100 font-medium">
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
{
timeLabel
}
</span>
</div>
<div className="flex-1 min-w-0 text-[15px] leading-[1.375rem] text-foreground wrap-break-word">
<div className="flex-1 min-w-0 text-sm md:text-[15px] leading-[1.375rem] text-foreground wrap-break-word">
{msg.content}
</div>
</div>
@ -317,12 +324,17 @@ export default function DMChannelContent(
</div>
{/* Message input */}
<div className="shrink-0 px-4 pb-6 pt-2">
<div className="shrink-0 px-2 md:px-4 pb-4 md:pb-6 pt-2">
<Input
className="h-11 rounded-lg bg-muted border-0 focus-visible:ring-0 focus-visible:ring-offset-0 px-4 text-[15px]"
placeholder={`Message @${otherUser.username ?? otherUser.name}`}
className="h-10 md:h-11 rounded-lg bg-muted border-0 focus-visible:ring-0 focus-visible:ring-offset-0 px-3 md:px-4 text-sm md:text-[15px]"
placeholder={
otherUser.status === "offline" ?
"As of now, you cannot message offline users." :
`Message @${otherUser.username ?? otherUser.name}`
}
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
disabled={otherUser.status === "offline"}
onKeyDown={async (e) => {
if (e.key === 'Enter' && !e.shiftKey && messageInput.trim() && password) {
e.preventDefault();

View file

@ -48,7 +48,7 @@ export function FriendListItem({
return (
<div
className="flex flex-row items-center justify-between w-full p-3 rounded-md hover:bg-accent/50 transition-colors group border border-transparent hover:border-border/40 hover:cursor-pointer"
className="flex flex-row items-center justify-between w-full p-2 md:p-3 rounded-md hover:bg-accent/50 active:bg-accent/60 transition-colors group border border-transparent hover:border-border/40 hover:cursor-pointer"
onClick={() => {
// Call the db to create or get the dm channel
getOrCreateDmChannel(userId, {
@ -62,7 +62,7 @@ export function FriendListItem({
}}
>
{/* Left side: Avatar + Info */}
<div className="flex flex-row items-center gap-3 flex-1 min-w-0">
<div className="flex flex-row items-center gap-2 md:gap-3 flex-1 min-w-0">
<UserCard
userName={displayName ?? ""}
image={friend.image ?? undefined}
@ -79,13 +79,16 @@ export function FriendListItem({
</div>
</div>
{/* Right side: Actions Menu */}
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
{/* Right side: Actions Menu - always visible on mobile via opacity, hover on desktop */}
<div className="flex items-center gap-1 md:gap-2 md:opacity-0 md:group-hover:opacity-100 transition-opacity shrink-0">
<Button
variant="ghost"
size="icon-sm"
className="size-8 hover:bg-background/80"
onClick={() => onMessage?.(friend._id)}
className="size-9 md:size-8 hover:bg-background/80"
onClick={(e) => {
e.stopPropagation()
onMessage?.(friend._id)
}}
title="Message"
>
<MessageCircleIcon className="size-4" />

View file

@ -77,19 +77,19 @@ export function FriendsPage({
return (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Search Input - Sticky at top */}
<div className="flex flex-col p-4 pb-2 bg-background border-b border-border/40">
<div className="flex flex-col p-2 md:p-4 pb-2 bg-background border-b border-border/40">
<Input
placeholder="Search for a friend..."
value={friendsSearch}
onChange={(e) => setFriendsSearch(e.target.value)}
className="w-full"
className="w-full h-10 md:h-9"
/>
</div>
{/* Scrollable Friends List */}
<div className="flex flex-col flex-1 overflow-y-auto p-4">
<div className="flex flex-col items-start w-full gap-2">
<span className="text-sm text-start font-medium">
<div className="flex flex-col flex-1 overflow-y-auto p-2 md:p-4">
<div className="flex flex-col items-start w-full gap-1 md:gap-2">
<span className="text-xs md:text-sm text-start font-medium text-muted-foreground mb-1">
{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({
/>
))
) : (
<div className="flex flex-col items-center justify-center w-full py-12">
<span className="text-sm font-medium text-muted-foreground">
<div className="flex flex-col items-center justify-center w-full py-8 md:py-12">
<span className="text-sm font-medium text-muted-foreground text-center px-4">
{friendsSearch ? `No friends found matching "${friendsSearch}"` : emptyMessage}
</span>
</div>

View file

@ -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 (
<div className="flex flex-col shrink-0 max-w-72 min-w-72 border-r border-border/40">
{/* Channel List Header */}
<div className="flex justify-center items-center min-h-10 max-h-50 bg-background">
<div className="flex flex-col justify-start items-start p-1 gap-2 w-full">
<div className={`flex flex-col shrink-0 border-border/40 ${isMobile ? 'w-full h-full bg-transparent' : 'max-w-72 min-w-72 border-r bg-linear-to-b from-background to-muted/20'}`}>
{/* Channel List Header - Navigation Items (Desktop only) */}
{!isMobile && (
<>
<div className="flex flex-col p-2 gap-1">
<Button
variant="ghost"
className={`w-full justify-start gap-3 h-11 px-3 rounded-lg transition-all ${page === "friends"
? "bg-primary/10 text-primary hover:bg-primary/15 ring-1 ring-primary/20"
: "hover:bg-accent/60"
}`}
onClick={() => {
onPageChange("friends")
handleNavigation("/")
}}
>
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${page === "friends"
? "bg-primary/20"
: "bg-muted/50"
}`}>
<UsersIcon className="size-4" />
</div>
<span className="text-sm font-semibold">Friends</span>
</Button>
<Button
variant="ghost"
className={`w-full justify-start gap-3 h-11 px-3 rounded-lg transition-all ${page === "support"
? "bg-primary/10 text-primary hover:bg-primary/15 ring-1 ring-primary/20"
: "hover:bg-accent/60"
}`}
onClick={() => {
onPageChange("support")
onChannelSelect?.()
}}
>
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${page === "support"
? "bg-primary/20"
: "bg-muted/50"
}`}>
<SettingsIcon className="size-4" />
</div>
<span className="text-sm font-semibold">Settings</span>
</Button>
</div>
{/* Divider with label */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="h-px flex-1 bg-linear-to-r from-border/60 to-transparent" />
</div>
</>
)}
{/* Mobile Navigation Buttons */}
{isMobile && (
<div className="flex gap-2 px-2 py-2">
<Button
variant="ghost"
className="w-full h-full hover:cursor-pointer justify-start"
variant={page === "friends" ? "default" : "outline"}
size="sm"
className="flex-1 h-9 text-xs font-semibold"
onClick={() => {
onPageChange("friends")
router.push("/")
handleNavigation("/")
}}
>
<UsersIcon className="size-4" />
<span className="text-sm font-medium">Friends</span>
<UsersIcon className="size-3.5 mr-1.5" />
Friends
</Button>
<Button
variant="ghost"
className="w-full h-full hover:cursor-pointer justify-start"
onClick={() => onPageChange("support")}
variant={page === "support" ? "default" : "outline"}
size="sm"
className="flex-1 h-9 text-xs font-semibold"
onClick={() => {
onPageChange("support")
onChannelSelect?.()
}}
>
<SettingsIcon className="size-4" />
<span className="text-sm font-medium">Settings</span>
<SettingsIcon className="size-3.5 mr-1.5" />
Settings
</Button>
</div>
</div>
<div className="w-[calc(100%-0.8rem)] h-px bg-border/40 mx-2" />
)}
{/* Channel List */}
<div className="flex flex-col flex-1 overflow-y-auto">
<div className={`flex flex-col flex-1 overflow-y-auto ${isMobile ? 'px-2' : 'px-2'}`}>
{page === "friends" || !currentChannel ? (
<div className="flex flex-col w-full">
<div className="flex items-center w-full justify-between p-2 select-none">
<span className="text-xs font-semibold text-muted-foreground">
{/* DM Header */}
<div className="flex items-center justify-between px-1 py-2 select-none">
<span className={`font-bold uppercase tracking-wider text-muted-foreground/70 ${isMobile ? 'text-[10px]' : 'text-[11px]'}`}>
Direct Messages
</span>
<Button
variant="ghost"
size="icon-sm"
className="hover:cursor-pointer hover:bg-transparent!"
className="size-6 hover:bg-accent rounded-md"
title="New Message"
>
<PlusIcon className="size-4" />
<MessageSquarePlusIcon className="size-3.5" />
</Button>
</div>
{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;
<div className="flex flex-col gap-0.5">
{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 (
<div
key={channel.id}
className={`flex flex-row items-center gap-3 px-2 py-1.5 mx-2 mb-0.5 rounded-md transition-all cursor-pointer group ${isActive
? "bg-accent/60"
: "hover:bg-accent/40"
}`}
onClick={() => {
clearUnread(channel.id)
console.log("Cleared unread count for channel", channel.id)
router.push(`/channels/me/${channel.id}`)
}}
>
<div className="relative shrink-0">
<UserCard
userName={channel.name}
image={channel.metadata?.icon ?? undefined}
status={"none"}
/>
{channelUnreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full bg-red-500 text-[10px] font-bold text-white shadow-sm">
{channelUnreadCount > 99 ? '99+' : channelUnreadCount}
</span>
)}
</div>
{/* Channel Info */}
<div className="flex flex-col justify-center flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold truncate text-foreground">
{channel.name}
</span>
{lastMessageTime && (
<span className="text-[10px] text-muted-foreground/70 shrink-0">
{formatDistanceToNow(lastMessageTime, { addSuffix: false })}
return (
<div
key={channel.id}
className={`flex flex-row items-center gap-3 px-2 py-2.5 rounded-lg transition-all cursor-pointer group ${isActive
? "bg-accent/80 shadow-sm ring-1 ring-accent"
: "hover:bg-accent/40 active:bg-accent/60"
}`}
onClick={() => {
clearUnread(channel.id)
console.log("Cleared unread count for channel", channel.id)
handleNavigation(`/channels/me/${channel.id}`)
}}
>
<div className="relative shrink-0">
<UserCard
userName={channel.name}
image={channel.metadata?.icon ?? undefined}
status={"none"}
/>
{channelUnreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full bg-linear-to-br from-red-500 to-red-600 text-[10px] font-bold text-white shadow-md ring-2 ring-background">
{channelUnreadCount > 99 ? '99+' : channelUnreadCount}
</span>
)}
</div>
{lastMessage && (
<span className="text-xs text-muted-foreground/80 truncate">
{lastMessage.content}
</span>
)}
{/* Channel Info */}
<div className="flex flex-col justify-center flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<span className={`text-sm truncate ${isActive ? 'font-bold' : 'font-semibold'} text-foreground`}>
{channel.name}
</span>
{lastMessageTime && (
<span className="text-[10px] text-muted-foreground/60 shrink-0 font-medium">
{formatDistanceToNow(lastMessageTime, { addSuffix: false })}
</span>
)}
</div>
{lastMessage && (
<span className="text-xs text-muted-foreground/70 truncate mt-0.5">
{lastMessage.content}
</span>
)}
</div>
{/* Close button - always visible on mobile, hover-visible on desktop */}
<Button
variant="ghost"
size="icon"
className={`size-7 p-0 shrink-0 hover:bg-destructive/10 hover:text-destructive rounded-md transition-all ${isMobile ? 'opacity-60' : 'opacity-0 group-hover:opacity-100'}`}
onClick={(e) => {
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"
>
<XIcon className="size-3.5" />
</Button>
</div>
{/* Close button */}
<Button
variant="ghost"
size="icon"
className="size-5 p-0 shrink-0 opacity-0 group-hover:opacity-100 hover:bg-background/80 transition-opacity"
onClick={(e) => {
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"
>
<XIcon className="size-3.5" />
</Button>
</div>
)
})
)
})}
</div>
) : (
<div className="flex items-center min-h-10 max-h-10 p-2">
<span className="text-xs font-medium text-muted-foreground text-center text-wrap">
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-muted/50 mb-3">
<Sparkles className="size-5 text-muted-foreground/50" />
</div>
<span className="text-xs font-medium text-muted-foreground/70 leading-relaxed">
{emptyMessage}
</span>
</div>
)}
</div>
) : (
<div className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">No channels</span>
<div className="flex items-center justify-center py-8">
<span className="text-sm font-medium text-muted-foreground">No channels</span>
</div>
)}
</div>

View file

@ -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<SiPher.Channel | null>(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 = (
<ChannelList
currentChannel={currentChannel}
openDmChannels={openDmChannels}
page={page}
onPageChange={handlePageChange}
emptyMessage={emptyChannelMessage}
dmChannel={dmChannel}
onChannelSelect={handleMobileChannelSelect}
isMobile={isMobile}
/>
);
return (
<>
<div className="flex flex-col h-full">
@ -98,19 +136,70 @@ export function MainContentLayout({
dmChannel={dmChannel}
serverId={serverId}
serverChannelId={serverChannelId}
onToggleMobileChannelList={() => setMobileChannelListOpen(true)}
isMobile={isMobile}
/>
{/* Content Area - Channel List + Main Content */}
<div className="flex flex-1 overflow-hidden">
{/* Desktop: Always visible channel list */}
<div className="hidden md:flex">
{channelListContent}
</div>
<ChannelList
currentChannel={currentChannel}
openDmChannels={openDmChannels}
page={page}
onPageChange={setPage}
emptyMessage={emptyChannelMessage}
dmChannel={dmChannel}
/>
{/* Mobile: Sheet-based channel list - Discord-style two-panel layout */}
{isMobile && (
<Sheet open={mobileChannelListOpen} onOpenChange={setMobileChannelListOpen}>
<SheetContent side="left" className="w-[calc(100%-3rem)] max-w-[340px] p-0 [&>button]:hidden">
<SheetHeader className="sr-only">
<SheetTitle>Channels</SheetTitle>
<SheetDescription>Navigate between channels and DMs</SheetDescription>
</SheetHeader>
<div className="flex h-full">
{/* Left Rail - Server/Home Icons (Discord-style) */}
<div className="flex flex-col items-center w-[72px] shrink-0 bg-muted/50 py-3 gap-2">
{/* Home/DMs Button */}
<MobileServerIcon
isActive={true}
isHome
label="Direct Messages"
>
<LogoIcon className="size-6" />
</MobileServerIcon>
{/* Divider */}
<div className="w-8 h-0.5 rounded-full bg-border/60 my-1" />
{/* Discover */}
<MobileServerIcon label="Discover">
<CompassIcon className="size-5" weight="fill" />
</MobileServerIcon>
{/* Future: Server icons will go here */}
{/* Placeholder for servers */}
{/* Add Server Button */}
<MobileServerIcon label="Add a Server" isAddButton>
<Plus className="size-5" />
</MobileServerIcon>
</div>
{/* Right Panel - Channel List */}
<div className="flex-1 flex flex-col bg-background min-w-0 border-l border-border/30">
{/* Panel Header */}
<div className="flex items-center px-4 h-12 shrink-0 border-b border-border/30">
<span className="text-sm font-semibold text-foreground">Direct Messages</span>
</div>
{/* Channel List Content */}
<div className="flex-1 overflow-y-auto">
{channelListContent}
</div>
</div>
</div>
</SheetContent>
</Sheet>
)}
{/* Main Content */}
<div className="flex flex-col flex-1 overflow-hidden">
@ -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 (
<div className="relative flex items-center justify-center w-full group">
{/* Left pill indicator */}
<div
className={cn(
"absolute left-0 w-1 bg-foreground rounded-r-full transition-all duration-200",
isActive ? "h-9" : "h-0 group-active:h-5"
)}
/>
{/* Icon button */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClick}
className={cn(
"relative flex items-center justify-center size-12 transition-all duration-200 overflow-hidden",
isHome && isActive
? "bg-primary text-primary-foreground rounded-2xl"
: isHome
? "bg-primary/80 text-primary-foreground rounded-[24px] active:rounded-2xl"
: isAddButton
? "bg-muted text-emerald-500 rounded-[24px] active:rounded-2xl active:bg-emerald-500 active:text-white"
: isActive
? "bg-primary text-primary-foreground rounded-2xl"
: "bg-muted text-muted-foreground rounded-[24px] active:rounded-2xl active:bg-primary active:text-primary-foreground"
)}
>
{children}
</Button>
</div>
)
}

View file

@ -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 (
<div className="flex items-center min-h-10 max-h-10 border-b border-border/40 sticky top-0 z-10 bg-background">
{/* SCS or DM Selector */}
<div className="flex justify-center items-center gap-2 max-w-72 min-w-72 border-r h-10 border-border/40">
<div className="flex items-center min-h-12 md:min-h-10 max-h-12 md:max-h-10 border-b border-border/40 sticky top-0 z-10 bg-background">
{/* Mobile: Menu toggle button */}
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-10 w-10 shrink-0 ml-1"
onClick={onToggleMobileChannelList}
>
<MenuIcon className="size-5" />
</Button>
)}
{/* Desktop: SCS or DM Selector */}
<div className="hidden md:flex justify-center items-center gap-2 max-w-72 min-w-72 border-r h-10 border-border/40">
{!currentChannel || currentChannel.type === "DM" ? (
<Button
variant="outline"
@ -53,16 +73,16 @@ export function PageHeader({
</div>
{/* Page title/options */}
{dmChannel ? (
<div className="flex flex-row justify-start items-center gap-2 w-full px-4">
{dmChannel && otherParticipant ? (
<div className="flex flex-row justify-start items-center gap-2 w-full px-2 md:px-4">
<UserCard
userName={dmChannel.participantDetails[0].name}
image={dmChannel.participantDetails[0].image}
status={dmChannel.participantDetails[0].status}
userName={otherParticipant.name}
image={otherParticipant.image}
status={otherParticipant.status}
size="small"
/>
<span className="text-sm font-medium">{dmChannel.participantDetails[0].name}</span>
<div className="flex flex-row gap-2 ml-auto">
<span className="text-sm font-medium truncate">{otherParticipant.name}</span>
<div className="flex flex-row gap-1 md:gap-2 ml-auto shrink-0">
<Button
variant="ghost"
size="icon"
@ -80,48 +100,51 @@ export function PageHeader({
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-8 w-8 hidden sm:flex"
>
<UserIcon className="size-4" />
</Button>
</div>
</div>
) : serverChannelId ? (
<div className="flex flex-row justify-start items-center gap-2 w-full px-4">
<div className="flex flex-row justify-start items-center gap-2 w-full px-2 md:px-4">
<span className="text-sm font-medium">#{serverChannelId}</span>
</div>
) : page === "friends" ? (
<div className="flex flex-row justify-start items-center gap-2 w-full">
<div className="flex flex-row gap-2 justify-start p-2">
<div className="flex flex-row justify-start items-center gap-1 md:gap-2 w-full overflow-x-auto">
<div className="flex flex-row gap-2 justify-start p-2 shrink-0">
<UsersIcon className="size-4" />
<span className="text-sm font-medium">Friends</span>
<span className="text-sm font-medium hidden sm:inline">Friends</span>
</div>
<span className="text-sm font-medium"></span>
<div className="flex flex-row gap-2 h-full">
<span className="text-sm font-medium hidden sm:inline"></span>
<div className="flex flex-row gap-1 md:gap-2 h-full">
<Button
variant="ghost"
disabled={friendsPage === "available"}
className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "available" ? "bg-primary text-primary-foreground" : ""
className={`h-full hover:cursor-pointer justify-start px-2 md:p-2 text-xs md:text-sm ${friendsPage === "available" ? "bg-primary text-primary-foreground" : ""
}`}
onClick={() => onFriendsPageChange?.("available")}
>
Available
<span className="hidden sm:inline">Available</span>
<span className="sm:hidden">Online</span>
</Button>
<Button
variant="ghost"
disabled={friendsPage === "all"}
className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "all" ? "bg-primary text-primary-foreground" : ""
className={`h-full hover:cursor-pointer justify-start px-2 md:p-2 text-xs md:text-sm ${friendsPage === "all" ? "bg-primary text-primary-foreground" : ""
}`}
onClick={() => onFriendsPageChange?.("all")}
>
All Known
<span className="hidden sm:inline">All Known</span>
<span className="sm:hidden">All</span>
</Button>
<Button
variant="ghost"
className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start p-2"
className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start px-2 md:p-2 text-xs md:text-sm"
onClick={onAddFriend}
>
Add Friend
<UserPlusIcon className="size-4 sm:hidden" />
<span className="hidden sm:inline">Add Friend</span>
</Button>
</div>
</div>

View file

@ -8,7 +8,7 @@ export interface SettingsPageProps {
export function SettingsPage({}: SettingsPageProps) {
return (
<div className="flex flex-col flex-1 overflow-y-auto p-4">
<div className="flex flex-col flex-1 overflow-y-auto p-2 md:p-4">
<div className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">Servers</span>
</div>