Last active
September 16, 2024 13:17
-
-
Save harrisrobin/cfbdf7669efc2cb38d8002f3adf5b547 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { seo } from "@/config/seo" | |
| import "./globals.css" | |
| import { Providers } from "@/app/providers" | |
| import { Provider as AnalyticsProvider } from "@repo/analytics/client" | |
| import type { Metadata, Viewport } from "next" | |
| import { SiteChat } from "@/components/site-chat" | |
| import { SupabaseAuthListener } from "@/components/supabase-auth-listener" | |
| import { Toaster } from "@/components/ui/sonner" | |
| import { fontSans } from "@/config/fonts" | |
| import { cn } from "@/lib/utils" | |
| import { Suspense } from "react" | |
| import { SiteChatStoreProvider } from "@/stores/site-chat" | |
| export const metadata: Metadata = { | |
| title: seo.title, | |
| description: seo.description, | |
| } | |
| export const viewport: Viewport = { | |
| width: "device-width", | |
| initialScale: 1, | |
| maximumScale: 1, | |
| userScalable: false, | |
| viewportFit: "cover", | |
| themeColor: [ | |
| { media: "(prefers-color-scheme: light)", color: "white" }, | |
| { media: "(prefers-color-scheme: dark)", color: "black" }, | |
| ], | |
| } | |
| export default function RootLayout({ | |
| children, | |
| }: { | |
| children: React.ReactNode | |
| }): JSX.Element { | |
| return ( | |
| <html lang="en"> | |
| <body className={cn("antialiased", fontSans.variable)}> | |
| <Providers> | |
| {children} | |
| <Suspense fallback={<div>Loading...</div>}> | |
| <SiteChatStoreProvider> | |
| <SiteChat /> | |
| </SiteChatStoreProvider> | |
| </Suspense> | |
| <Suspense> | |
| <SupabaseAuthListener /> | |
| </Suspense> | |
| </Providers> | |
| <AnalyticsProvider /> | |
| <Toaster /> | |
| </body> | |
| </html> | |
| ) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client" | |
| import { SiteChatContentLoggedOut } from "@/components/site-chat/site-chat-content-logged-out" | |
| import { SiteChatHeader } from "@/components/site-chat/site-chat-header" | |
| import { SiteChatMessagesList } from "@/components/site-chat/site-chat-messages-list" | |
| import { SheetContent, SheetHeader } from "@/components/ui/sheet" | |
| import { useUser } from "@supabase/auth-helpers-react" | |
| import type { User } from "@supabase/supabase-js" | |
| interface SiteChatContentProps { | |
| initialUser: User | null | |
| } | |
| export function SiteChatContent({ initialUser }: SiteChatContentProps) { | |
| const clientUser = useUser() | |
| const user = initialUser ?? clientUser | |
| const isLoggedIn = !!user | |
| return ( | |
| <SheetContent className="w-full md:w-3/4"> | |
| <SheetHeader> | |
| <SiteChatHeader initialUser={user} /> | |
| </SheetHeader> | |
| <div className="h-full"> | |
| {isLoggedIn ? ( | |
| <SiteChatMessagesList initialUser={user} /> | |
| ) : ( | |
| <SiteChatContentLoggedOut /> | |
| )} | |
| </div> | |
| </SheetContent> | |
| ) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { CHAT_ROOMS } from "@/components/site-chat/site-chat.constants" | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select" | |
| import { useSiteChatStore } from "@/stores/site-chat" | |
| import { useQueryState } from "nuqs" | |
| const SiteChatHeader = () => { | |
| const [room, setRoom] = useQueryState("chatRoom", { | |
| defaultValue: "US", | |
| }) | |
| const { presenceIdsByRoom } = useSiteChatStore((state) => state) | |
| return ( | |
| <div> | |
| <Select onValueChange={setRoom} defaultValue={room ?? "US"}> | |
| <SelectTrigger className="w-[150px]"> | |
| <SelectValue className="w-full" placeholder="Room" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| {CHAT_ROOMS.map((room) => { | |
| const channelName = `chat:${room.value}` | |
| const presenceIdsForRoom = presenceIdsByRoom[channelName] ?? [] | |
| return ( | |
| <SiteChatRoomPickerDropdownItem | |
| key={room.value} | |
| option={room} | |
| presenceCount={presenceIdsForRoom.length} | |
| /> | |
| ) | |
| })} | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| ) | |
| } | |
| interface SiteChatRoomPickerDropdownItemProps { | |
| option: { | |
| value: string | |
| label: string | |
| } | |
| presenceCount: number | |
| } | |
| export const SiteChatRoomPickerDropdownItem = ( | |
| props: SiteChatRoomPickerDropdownItemProps, | |
| ) => { | |
| const { option, presenceCount } = props | |
| return ( | |
| <SelectItem value={option.value} className="w-full" key={option.value}> | |
| <div className="space-x-1"> | |
| <span className="text-muted-foreground">{option.label}</span> | |
| <span className="text-muted-foreground text-xs">{presenceCount}</span> | |
| </div> | |
| </SelectItem> | |
| ) | |
| } | |
| export { SiteChatHeader } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client" | |
| import { SiteChatMessagesListItem } from "@/components/site-chat/site-chat-messages-list-item" | |
| import { Button } from "@/components/ui/button" | |
| import { Textarea } from "@/components/ui/textarea" | |
| import { useCallback, useEffect, useRef, useState } from "react" | |
| import { Virtuoso } from "react-virtuoso" | |
| import type { VirtuosoHandle } from "react-virtuoso" | |
| import { useUser } from "@supabase/auth-helpers-react" | |
| import { useChatClient } from "@/components/site-chat/site-chat-provider" | |
| import type { User } from "@supabase/supabase-js" | |
| import { useSiteChatStore } from "@/stores/site-chat" | |
| interface SiteChatMessagesListProps { | |
| initialUser: User | null | |
| } | |
| export function SiteChatMessagesList(props: SiteChatMessagesListProps) { | |
| const { initialUser } = props | |
| const { messages } = useSiteChatStore((state) => state) | |
| const [text, setText] = useState("") | |
| const authUser = useUser() | |
| const virtuoso = useRef<VirtuosoHandle>(null) | |
| const user = authUser || initialUser | |
| const { currentRoom } = useChatClient() | |
| const publishMessage = async (text: string) => { | |
| if (!user || !currentRoom) { | |
| return | |
| } | |
| await currentRoom.messages.send({ | |
| text, | |
| metadata: { | |
| avatarUrl: user.user_metadata?.avatar_url, | |
| username: user.user_metadata?.username, | |
| userId: user.id, | |
| }, | |
| }) | |
| } | |
| const scrollToBottom = useCallback(() => { | |
| if (virtuoso.current) { | |
| virtuoso.current.scrollToIndex({ | |
| index: messages.length - 1, | |
| }) | |
| } | |
| }, [messages.length]) | |
| useEffect(() => { | |
| setTimeout(() => { | |
| scrollToBottom() | |
| }, 100) | |
| }, [messages.length]) | |
| const sendMessage = () => { | |
| if (text.trim()) { | |
| publishMessage(text) | |
| setText("") | |
| } | |
| } | |
| return ( | |
| <div className="h-full"> | |
| <div className="h-full flex flex-col justify-between"> | |
| <div className="flex-grow relative pt-2 pb-4"> | |
| <div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-background to-transparent z-10 pointer-events-none" /> | |
| <Virtuoso | |
| data={messages} | |
| style={{ | |
| height: "100%", | |
| }} | |
| ref={virtuoso} | |
| itemContent={(_, message) => ( | |
| <SiteChatMessagesListItem | |
| key={message.timeserial} | |
| message={message} | |
| /> | |
| )} | |
| /> | |
| <div className="absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-background to-transparent z-10 pointer-events-none" /> | |
| </div> | |
| <div className="h-[200px] pt-1"> | |
| <div className="flex gap-2 items-center"> | |
| <Textarea | |
| placeholder="Type your message here." | |
| value={text} | |
| onChange={(e) => setText(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter") { | |
| e.preventDefault() | |
| sendMessage() | |
| } | |
| }} | |
| /> | |
| <Button onClick={sendMessage}>Send</Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client" | |
| import { useUser } from "@supabase/auth-helpers-react" | |
| import { ChatClient, RoomOptionsDefaults } from "@ably/chat" | |
| import type { Room } from "@ably/chat" | |
| import type { User } from "@supabase/supabase-js" | |
| import * as Ably from "ably" | |
| import { AblyProvider, ChannelProvider } from "ably/react" | |
| import { useQueryState } from "nuqs" | |
| import { | |
| createContext, | |
| useCallback, | |
| useContext, | |
| useEffect, | |
| useMemo, | |
| } from "react" | |
| import { useSiteChatStore } from "@/stores/site-chat" | |
| import { usePresenceListeners } from "@/hooks/site-chat/use-presence-listeners" | |
| const ChatClientContext = createContext<{ | |
| chatClient?: ChatClient | |
| currentRoom: Room | null | |
| currentChannelName: string | |
| } | null>(null) | |
| interface SiteChatProviderProps { | |
| children: React.ReactNode | |
| initialUser: User | null | |
| } | |
| export function SiteChatProvider({ | |
| children, | |
| initialUser, | |
| }: SiteChatProviderProps) { | |
| const [chatRoom] = useQueryState("chatRoom", { | |
| defaultValue: "US", | |
| }) | |
| const clientUser = useUser() | |
| const user = clientUser ?? initialUser | |
| const isLoggedIn = !!user | |
| const channelName = useMemo(() => { | |
| return `chat:${chatRoom}` | |
| }, [chatRoom]) | |
| const client = useMemo(() => { | |
| return new Ably.Realtime({ | |
| authUrl: isLoggedIn ? "/api/ably" : undefined, | |
| autoConnect: typeof window !== "undefined", | |
| clientId: isLoggedIn ? user.id : "anonymous", | |
| key: process.env.NEXT_PUBLIC_ABLY_API_KEY, | |
| }) | |
| }, [user, isLoggedIn]) | |
| const chatClient = useMemo(() => { | |
| if (client) { | |
| return new ChatClient(client) | |
| } | |
| }, [client]) | |
| usePresenceListeners(chatClient) | |
| const { setMessages, addMessage } = useSiteChatStore((state) => state) | |
| const room = useMemo(() => { | |
| if (!chatClient) { | |
| return null | |
| } | |
| return chatClient.rooms.get(channelName, { | |
| presence: RoomOptionsDefaults.presence, | |
| occupancy: RoomOptionsDefaults.occupancy, | |
| typing: RoomOptionsDefaults.typing, | |
| reactions: RoomOptionsDefaults.reactions, | |
| }) | |
| }, [chatClient, channelName]) | |
| const leaveChatRoom = useCallback(async () => { | |
| if (!user) { | |
| return | |
| } | |
| const isUserPresent = await room?.presence.isUserPresent(user.id) | |
| if (isUserPresent) { | |
| await room?.presence.leave() | |
| } | |
| }, [user, room]) | |
| useEffect(() => { | |
| const fetchHist = async () => { | |
| if (!room) { | |
| return | |
| } | |
| const historicalMessages = await room.messages.get({ | |
| direction: "forwards", | |
| limit: 50, | |
| }) | |
| setMessages(historicalMessages.items) | |
| } | |
| fetchHist() | |
| }, [room]) | |
| useEffect(() => { | |
| if (!room) { | |
| return | |
| } | |
| const { unsubscribe } = room.messages.subscribe((messageEvent) => { | |
| addMessage(messageEvent.message) | |
| }) | |
| const attachRoom = async () => { | |
| await room.attach() | |
| await room.presence.enter() | |
| } | |
| attachRoom() | |
| return () => { | |
| unsubscribe() | |
| leaveChatRoom() | |
| } | |
| }, [room]) | |
| return ( | |
| <ChatClientContext.Provider | |
| value={{ chatClient, currentRoom: room, currentChannelName: channelName }} | |
| > | |
| <AblyProvider client={client}> | |
| <ChannelProvider channelName={channelName}>{children}</ChannelProvider> | |
| </AblyProvider> | |
| </ChatClientContext.Provider> | |
| ) | |
| } | |
| // Create a custom hook to use the ChatClient | |
| export function useChatClient() { | |
| const context = useContext(ChatClientContext) | |
| if (!context) { | |
| throw new Error("useChatClient must be used within a ChatClientProvider") | |
| } | |
| return context | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client" | |
| import { type ReactNode, createContext, useRef, useContext } from "react" | |
| import { useStore } from "zustand" | |
| import { type SiteChatStore, createSiteChatStore } from "./site-chat.store" | |
| export type SiteChatStoreApi = ReturnType<typeof createSiteChatStore> | |
| export const SiteChatStoreContext = createContext<SiteChatStoreApi | undefined>( | |
| undefined, | |
| ) | |
| export interface SiteChatStoreProviderProps { | |
| children: ReactNode | |
| } | |
| export const SiteChatStoreProvider = ({ | |
| children, | |
| }: SiteChatStoreProviderProps) => { | |
| const storeRef = useRef<SiteChatStoreApi>() | |
| if (!storeRef.current) { | |
| storeRef.current = createSiteChatStore() | |
| } | |
| return ( | |
| <SiteChatStoreContext.Provider value={storeRef.current}> | |
| {children} | |
| </SiteChatStoreContext.Provider> | |
| ) | |
| } | |
| export const useSiteChatStore = <T,>( | |
| selector: (store: SiteChatStore) => T, | |
| ): T => { | |
| const siteChatStoreContext = useContext(SiteChatStoreContext) | |
| if (!siteChatStoreContext) { | |
| throw new Error( | |
| "useSiteChatStore must be used within SiteChatStoreProvider", | |
| ) | |
| } | |
| return useStore(siteChatStoreContext, selector) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { SiteChatContent } from "@/components/site-chat/site-chat-content" | |
| import { SiteChatProvider } from "@/components/site-chat/site-chat-provider" | |
| import { SupportChatWidget } from "@/components/support-chat-widget" | |
| import { Sheet } from "@/components/ui/sheet" | |
| import { getAuthUser } from "@repo/supabase/queries" | |
| export async function SiteChat() { | |
| const { data } = await getAuthUser() | |
| const user = data.user | |
| return ( | |
| <Sheet> | |
| <SupportChatWidget /> | |
| <SiteChatProvider initialUser={user}> | |
| <SiteChatContent initialUser={user} /> | |
| </SiteChatProvider> | |
| </Sheet> | |
| ) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { CHAT_ROOMS } from "@/components/site-chat/site-chat.constants" | |
| import { useSiteChatStore } from "@/stores/site-chat" | |
| import { RoomOptionsDefaults } from "@ably/chat" | |
| import type { | |
| PresenceEvent, | |
| ChatClient, | |
| PresenceSubscriptionResponse, | |
| } from "@ably/chat" | |
| import { useEffect, useRef } from "react" | |
| export function usePresenceListeners(chatClient?: ChatClient) { | |
| const { addPresenceIdToRoom, removePresenceIdFromRoom } = useSiteChatStore( | |
| (state) => state, | |
| ) | |
| const subscriptionsRef = useRef<Map<string, PresenceSubscriptionResponse>>( | |
| new Map(), | |
| ) | |
| useEffect(() => { | |
| if (!chatClient) { | |
| return | |
| } | |
| for (const chatRoom of CHAT_ROOMS) { | |
| const channelName = `chat:${chatRoom.value}` | |
| const room = chatClient.rooms.get(channelName, { | |
| ...RoomOptionsDefaults, | |
| }) | |
| const subscription = room.presence.subscribe((event: PresenceEvent) => { | |
| switch (event.action) { | |
| case "enter": | |
| console.info(`${event.clientId} entered ${channelName}`) | |
| addPresenceIdToRoom(channelName, event.clientId) | |
| break | |
| case "leave": | |
| console.info(`${event.clientId} left ${channelName}`) | |
| removePresenceIdFromRoom(channelName, event.clientId) | |
| break | |
| // 'update' action doesn't change the member list | |
| } | |
| }) | |
| subscriptionsRef.current.set(channelName, subscription) | |
| } | |
| return () => { | |
| for (const subscription of subscriptionsRef.current.values()) { | |
| subscription.unsubscribe() | |
| } | |
| subscriptionsRef.current.clear() | |
| } | |
| }, [chatClient]) | |
| return null | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment