Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ const config = {
},
],
},

// XMTP WASM support
experimental: {
webpackBuildWorker: true,
},

webpack: (config) => {
config.experiments = {
...config.experiments,
asyncWebAssembly: true,
};
return config;
},
};

export default withMDX(config);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@tanstack/react-query-devtools": "^5.84.1",
"@types/mdx": "^2.0.13",
"@vercel/og": "^0.6.8",
"@xmtp/browser-sdk": "^5.3.0",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
9 changes: 9 additions & 0 deletions src/app/(messages)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { PropsWithChildren } from "react";

export default function MessagesLayout({ children }: PropsWithChildren) {
return (
<div className="px-4 max-w-3xl mx-auto h-[calc(100vh-80px)]">
{children}
</div>
);
}
16 changes: 16 additions & 0 deletions src/app/(messages)/messages/[conversationId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { ConversationView } from "~/components/messaging/ConversationView";

export const metadata: Metadata = {
title: "Conversation | Flow Talk",
description: "Direct message conversation",
};

interface PageProps {
params: Promise<{ conversationId: string }>;
}

export default async function ConversationPage({ params }: PageProps) {
const { conversationId } = await params;
return <ConversationView conversationId={conversationId} />;
}
11 changes: 11 additions & 0 deletions src/app/(messages)/messages/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { ConversationList } from "~/components/messaging/ConversationList";

export const metadata: Metadata = {
title: "Messages | Flow Talk",
description: "Your direct messages",
};

export default function MessagesPage() {
return <ConversationList />;
}
23 changes: 13 additions & 10 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { DeletedPostsProvider } from "~/components/DeletedPostsContext";
import { FilteredUsersProvider } from "~/components/FilteredUsersContext";
import { FloatingAudioPlayer } from "~/components/FloatingAudioPlayer";
import { XmtpProvider } from "~/components/messaging/XmtpContext";
import { NotificationsProvider } from "~/components/notifications/NotificationsContext";
import { Providers } from "~/components/Providers";
import { UserProvider } from "~/components/user/UserContext";
Expand Down Expand Up @@ -37,18 +38,20 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<FilteredUsersProvider>
<DeletedPostsProvider>
<NotificationsProvider>
{/* <BackgroundGradient /> */}
<RouteTracker />
<NavigationShortcuts />
<HistoryIndicator />
<UpdateNotification />
<Menu isAuthenticated={isAuthenticated} user={user} />
<XmtpProvider>
{/* <BackgroundGradient /> */}
<RouteTracker />
<NavigationShortcuts />
<HistoryIndicator />
<UpdateNotification />
<Menu isAuthenticated={isAuthenticated} user={user} />

{/* <PageTransition> */}
<div className="min-w-0 h-full">{children}</div>
{/* </PageTransition> */}
{/* <PageTransition> */}
<div className="min-w-0 h-full">{children}</div>
{/* </PageTransition> */}

<FloatingAudioPlayer />
<FloatingAudioPlayer />
</XmtpProvider>
</NotificationsProvider>
</DeletedPostsProvider>
</FilteredUsersProvider>
Expand Down
25 changes: 25 additions & 0 deletions src/atoms/messaging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { atom } from "jotai";
import { atomWithStorage } from "jotai/utils";

// Active conversation tracking
export const activeConversationIdAtom = atom<string | null>(null);

// Message draft persistence (per conversation)
export const messageDraftsAtom = atomWithStorage<Record<string, string>>(
"xmtp_message_drafts",
{},
);

// Consent filter state
export const consentFilterAtom = atom<"allowed" | "requests" | "all">(
"allowed",
);

// Unread tracking (conversation ID -> last read timestamp)
export const lastReadTimestampsAtom = atomWithStorage<Record<string, number>>(
"xmtp_last_read",
{},
);

// XMTP client initialized state
export const xmtpInitializedAtom = atom<boolean>(false);
46 changes: 33 additions & 13 deletions src/components/menu/Menu.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import type { User } from "@cartel-sh/ui";
import { Bookmark, BookOpenIcon, Heart, LogInIcon } from "lucide-react";
import { Bookmark, BookOpenIcon, Heart, LogInIcon, MessageSquare } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useRef, useState } from "react";
Expand All @@ -17,6 +17,7 @@ import { UsersIcon, type UsersIconHandle } from "~/components/icons/UsersIcon";
import { Dock } from "~/components/ui/dock";
import { useAuth } from "~/hooks/useSiweAuth";
import { cn } from "~/utils";
import { useXmtp } from "../messaging/XmtpContext";
import { useNotifications } from "../notifications/NotificationsContext";
import PostComposer, { type PostComposerHandle } from "../post/PostComposer";
import { Dialog, DialogContent } from "../ui/dialog";
Expand All @@ -32,6 +33,7 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) {
const router = useRouter();
const pathname = usePathname();
const { newCount } = useNotifications();
const { unreadCount: messagesUnreadCount } = useXmtp();
const { theme, setTheme } = useTheme();
const { signOut } = useAuth();
const composerRef = useRef<PostComposerHandle | null>(null);
Expand All @@ -57,6 +59,7 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) {
const isHome = pathname === "/home";
const isCommunities = pathname === "/communities";
const isActivity = pathname === "/activity";
const isMessages = pathname === "/messages" || pathname.startsWith("/messages/");
const isBookmarks = pathname === "/bookmarks";
const isSettings = pathname === "/settings";
const isDocs = pathname === "/docs" || pathname.startsWith("/docs/");
Expand All @@ -83,10 +86,6 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) {
</div>
);

const bookmarkIcon = (
<Bookmark className={cn("w-5 h-5 md:w-6 md:h-6", isBookmarks && "fill-current")} strokeWidth={2.25} />
);

const homeExtra = (
<div className="flex flex-col w-48 p-1 gap-1">
<button
Expand Down Expand Up @@ -144,6 +143,22 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) {
isActive: isActivity,
} as const;

const messagesDockItem = {
customIcon: (
<div className="relative w-full h-full flex items-center justify-center">
<MessageSquare className={cn("w-5 h-5 md:w-6 md:h-6", isMessages && "fill-current")} strokeWidth={2.25} />
{messagesUnreadCount > 0 && (
<span className="absolute -bottom-2 -right-2 w-4 h-4 rounded-full bg-primary text-primary-foreground text-[9px] md:text-[10px] flex items-center justify-center font-medium">
{messagesUnreadCount > 9 ? "9+" : messagesUnreadCount}
</span>
)}
</div>
),
label: "Messages",
onClick: () => router.push("/messages"),
isActive: isMessages,
} as const;

const postDockItem = {
customIcon: <PlusIcon size={20} />,
label: "Post",
Expand Down Expand Up @@ -216,6 +231,18 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) {
{theme === "light" ? <SunIcon ref={sunIconRef} size={16} /> : <MoonIcon ref={moonIconRef} size={16} />}
<span className="ml-3">Theme</span>
</button>
<button
type="button"
aria-current={isBookmarks ? "page" : undefined}
className={cn(
"relative flex cursor-default select-none items-center rounded-lg px-3 py-1.5 text-base outline-none transition-all duration-200 active:scale-[0.96] hover:bg-accent hover:text-accent-foreground w-full text-left",
isBookmarks && "bg-accent/50 text-accent-foreground",
)}
onClick={() => router.push("/bookmarks")}
>
<Bookmark size={16} />
<span className="ml-3">Bookmarks</span>
</button>
<button
type="button"
aria-current={isDocs ? "page" : undefined}
Expand Down Expand Up @@ -245,13 +272,6 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) {
isActive: isProfile,
} as const;

const bookmarksDockItem = {
customIcon: bookmarkIcon,
label: "Bookmarks",
onClick: () => router.push("/bookmarks"),
isActive: isBookmarks,
} as const;

const loginDockItem = {
icon: LogInIcon,
label: "Log in",
Expand All @@ -260,7 +280,7 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) {
} as const;

const dockItems = isAuthenticated
? [homeDockItem, activityDockItem, postDockItem, profileDockItem, bookmarksDockItem]
? [homeDockItem, activityDockItem, postDockItem, messagesDockItem, profileDockItem]
: [homeDockItem, loginDockItem];

return (
Expand Down
71 changes: 71 additions & 0 deletions src/components/messaging/ConversationItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

import { ConsentState } from "@xmtp/browser-sdk";
import { useQuery } from "@tanstack/react-query";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
import type { ConversationWithMeta } from "~/hooks/useConversations";
import { fetchEnsUser } from "~/utils/ens/converters/userConverter";
import { UserAvatar } from "../user/UserAvatar";

interface ConversationItemProps {
conversation: ConversationWithMeta;
}

export function ConversationItem({ conversation }: ConversationItemProps) {
const lastMessageTime = conversation.lastMessage?.sentAtNs
? new Date(Number(conversation.lastMessage.sentAtNs) / 1_000_000)
: null;

// Fetch peer user data
const { data: peerUser } = useQuery({
queryKey: ["ens-user", conversation.peerInboxId],
queryFn: async () => {
return fetchEnsUser(conversation.peerInboxId);
},
staleTime: 5 * 60 * 1000,
});

const displayName =
peerUser?.username ||
(conversation.peerInboxId
? `${conversation.peerInboxId.slice(0, 6)}...${conversation.peerInboxId.slice(-4)}`
: "Unknown");

return (
<Link
href={`/messages/${conversation.id}`}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-accent transition-colors"
>
<div className="w-10 h-10 shrink-0">
{peerUser ? (
<UserAvatar user={peerUser} link={false} card={false} />
) : (
<div className="w-10 h-10 rounded-full bg-muted" />
)}
</div>

<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-medium truncate">{displayName}</span>
{lastMessageTime && (
<span className="text-xs text-muted-foreground shrink-0">
{formatDistanceToNow(lastMessageTime, { addSuffix: true })}
</span>
)}
</div>
{conversation.lastMessage?.content && (
<p className="text-sm text-muted-foreground truncate">
{conversation.lastMessage.content}
</p>
)}
</div>

{conversation.consentState === ConsentState.Unknown && (
<span className="px-2 py-0.5 text-xs bg-secondary rounded-full">
Request
</span>
)}
</Link>
);
}
Loading