Skip to content

Instantly share code, notes, and snippets.

@harrisrobin
Last active September 16, 2024 13:17
Show Gist options
  • Select an option

  • Save harrisrobin/cfbdf7669efc2cb38d8002f3adf5b547 to your computer and use it in GitHub Desktop.

Select an option

Save harrisrobin/cfbdf7669efc2cb38d8002f3adf5b547 to your computer and use it in GitHub Desktop.
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>
)
}
"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>
)
}
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 }
"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>
)
}
"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
}
"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)
}
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>
)
}
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