mirror of
https://github.com/supabase/supabase.git
synced 2026-10-17 07:09:07 +03:00
use multiplayer.dev cursor logic
This commit is contained in:
1 parent
50f2a5899f
commit
f5d3115d5d
8 files changed
+1192
-72
No files matched your search
@@ -1,10 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { useEffect, useRef, useState, useCallback, ReactElement } from 'react'
|
||||
import {
|
||||
PostgrestResponse,
|
||||
REALTIME_LISTEN_TYPES,
|
||||
REALTIME_POSTGRES_CHANGES_LISTEN_EVENT,
|
||||
REALTIME_PRESENCE_LISTEN_EVENTS,
|
||||
REALTIME_SUBSCRIBE_STATES,
|
||||
RealtimeChannel,
|
||||
RealtimeChannelSendResponse,
|
||||
RealtimePostgresInsertPayload,
|
||||
SupabaseClient,
|
||||
} from '@supabase/supabase-js'
|
||||
import useConfData from '~/components/LaunchWeek/hooks/use-conf-data'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useRouter } from 'next/router'
|
||||
import { getRandomColor, getRandomColors, getRandomUniqueColor } from './Multiplayer/randomColor'
|
||||
import { Coordinates, Message, Payload, User } from './Multiplayer/types'
|
||||
import { cloneDeep, throttle } from 'lodash'
|
||||
import { Badge } from 'ui'
|
||||
import Cursor from './Multiplayer/Cursor'
|
||||
import Chatbox from './Multiplayer/Chatbox'
|
||||
|
||||
const GRID_SIZE = 100
|
||||
const CELL_SIZE = 40
|
||||
@@ -13,6 +30,13 @@ const CANVAS_HEIGHT = 1600
|
||||
const HOVER_DURATION = 100
|
||||
const FADE_DURATION = 300
|
||||
|
||||
const LATENCY_THRESHOLD = 400
|
||||
const MAX_ROOM_USERS = 50
|
||||
const MAX_DISPLAY_MESSAGES = 50
|
||||
const MAX_EVENTS_PER_SECOND = 10
|
||||
const X_THRESHOLD = 25
|
||||
const Y_THRESHOLD = 35
|
||||
|
||||
interface CellState {
|
||||
isHovered: boolean
|
||||
fadeStartTime: number | null
|
||||
@@ -33,6 +57,9 @@ export const INTERACTIVE_GRID_COLORS = (isDark: boolean) => ({
|
||||
: ['#FF8166', '#5CD671', '#6E86F7', '#F999FF'],
|
||||
})
|
||||
|
||||
// Generate a random user id
|
||||
const userId = uuidv4()
|
||||
|
||||
export default function InteractiveGrid() {
|
||||
const { supabase, userData } = useConfData()
|
||||
const { resolvedTheme } = useTheme()
|
||||
@@ -43,13 +70,456 @@ export default function InteractiveGrid() {
|
||||
> | null>(null)
|
||||
const [hoveredCells, setHoveredCells] = useState<Map<string, CellState>>(new Map())
|
||||
const [userCursors, setUserCursors] = useState<Record<string, CursorPosition>>({})
|
||||
const [_onlineUsers, setOnlineUsers] = useState<any[]>([])
|
||||
// const [_onlineUsers, setOnlineUsers] = useState<any[]>([])
|
||||
const [userColors, setUserColors] = useState<Record<string, string>>({})
|
||||
const animationFrameRef = useRef<number>()
|
||||
|
||||
const currentUserIdRef = useRef(userData?.id || uuidv4())
|
||||
const CURRENT_USER_ID = currentUserIdRef.current
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const localColorBackup = getRandomColor()
|
||||
|
||||
const chatboxRef = useRef<any>()
|
||||
// [Joshen] Super hacky fix for a really weird bug for onKeyDown
|
||||
// input field. For some reason the first keydown event appends the character twice
|
||||
const chatInputFix = useRef<boolean>(true)
|
||||
|
||||
// These states will be managed via ref as they're mutated within event listeners
|
||||
const usersRef = useRef<{ [key: string]: User }>({})
|
||||
const isTypingRef = useRef<boolean>(false)
|
||||
const isCancelledRef = useRef<boolean>(false)
|
||||
const messageRef = useRef<string>()
|
||||
const messagesInTransitRef = useRef<string[]>()
|
||||
const mousePositionRef = useRef<Coordinates>()
|
||||
|
||||
const joinTimestampRef = useRef<number>()
|
||||
const insertMsgTimestampRef = useRef<number>()
|
||||
|
||||
// We manage the refs with a state so that the UI can re-render
|
||||
const [isTyping, _setIsTyping] = useState<boolean>(false)
|
||||
const [isCancelled, _setIsCancelled] = useState<boolean>(false)
|
||||
const [message, _setMessage] = useState<string>('')
|
||||
const [messagesInTransit, _setMessagesInTransit] = useState<string[]>([])
|
||||
const [mousePosition, _setMousePosition] = useState<Coordinates>()
|
||||
|
||||
const [areMessagesFetched, setAreMessagesFetched] = useState<boolean>(false)
|
||||
const [isInitialStateSynced, setIsInitialStateSynced] = useState<boolean>(false)
|
||||
const [latency, setLatency] = useState<number>(0)
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [roomId, setRoomId] = useState<undefined | string>(undefined)
|
||||
const [users, setUsers] = useState<{ [key: string]: User }>({})
|
||||
|
||||
const setIsTyping = (value: boolean) => {
|
||||
isTypingRef.current = value
|
||||
_setIsTyping(value)
|
||||
}
|
||||
|
||||
const setIsCancelled = (value: boolean) => {
|
||||
isCancelledRef.current = value
|
||||
_setIsCancelled(value)
|
||||
}
|
||||
|
||||
const setMessage = (value: string) => {
|
||||
messageRef.current = value
|
||||
_setMessage(value)
|
||||
}
|
||||
|
||||
const setMousePosition = (coordinates: Coordinates) => {
|
||||
mousePositionRef.current = coordinates
|
||||
_setMousePosition(coordinates)
|
||||
}
|
||||
|
||||
const setMessagesInTransit = (messages: string[]) => {
|
||||
messagesInTransitRef.current = messages
|
||||
_setMessagesInTransit(messages)
|
||||
}
|
||||
|
||||
const mapInitialUsers = (userChannel: RealtimeChannel, roomId: string) => {
|
||||
const state = userChannel.presenceState()
|
||||
const _users = state[roomId]
|
||||
|
||||
if (!_users) return
|
||||
|
||||
// Deconflict duplicate colours at the beginning of the browser session
|
||||
const colors = Object.keys(usersRef.current).length === 0 ? getRandomColors(_users.length) : []
|
||||
|
||||
if (_users) {
|
||||
setUsers((existingUsers) => {
|
||||
const updatedUsers = _users.reduce(
|
||||
(acc: { [key: string]: User }, { user_id: userId }: any, index: number) => {
|
||||
const userColors = Object.values(usersRef.current).map((user: any) => user.color)
|
||||
// Deconflict duplicate colors for incoming clients during the browser session
|
||||
const color = colors.length > 0 ? colors[index] : getRandomUniqueColor(userColors)
|
||||
|
||||
acc[userId] = existingUsers[userId] || {
|
||||
x: 0,
|
||||
y: 0,
|
||||
color: color.bg,
|
||||
hue: color.hue,
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
usersRef.current = updatedUsers
|
||||
return updatedUsers
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let roomChannel: RealtimeChannel
|
||||
|
||||
const { slug } = router.query
|
||||
const slugRoomId = Array.isArray(slug) ? slug[0] : undefined
|
||||
|
||||
if (!roomId) {
|
||||
// roomId is undefined when user first attempts to join a room
|
||||
|
||||
joinTimestampRef.current = performance.now()
|
||||
|
||||
/*
|
||||
Client is joining 'rooms' channel to examine existing rooms and their users
|
||||
and then the channel is removed once a room is selected
|
||||
*/
|
||||
roomChannel = supabase?.channel('rooms')!
|
||||
|
||||
roomChannel
|
||||
.on(REALTIME_LISTEN_TYPES.PRESENCE, { event: REALTIME_PRESENCE_LISTEN_EVENTS.SYNC }, () => {
|
||||
let newRoomId
|
||||
const state = roomChannel.presenceState()
|
||||
|
||||
// User attempting to navigate directly to an existing room with users
|
||||
if (slugRoomId && slugRoomId in state && state[slugRoomId].length < MAX_ROOM_USERS) {
|
||||
newRoomId = slugRoomId
|
||||
}
|
||||
|
||||
// User will be assigned an existing room with the fewest users
|
||||
if (!newRoomId) {
|
||||
const [mostVacantRoomId, users] =
|
||||
Object.entries(state).sort(([, a], [, b]) => a.length - b.length)[0] ?? []
|
||||
|
||||
if (users && users.length < MAX_ROOM_USERS) {
|
||||
newRoomId = mostVacantRoomId
|
||||
}
|
||||
}
|
||||
|
||||
// Generate an id if no existing rooms are available
|
||||
setRoomId(newRoomId ?? uuidv4())
|
||||
})
|
||||
.subscribe()
|
||||
} else {
|
||||
// When user has been placed in a room
|
||||
|
||||
// joinTimestampRef.current &&
|
||||
// sendLog(
|
||||
// `User ${userId} joined Room ${roomId} in ${(
|
||||
// performance.now() - joinTimestampRef.current
|
||||
// ).toFixed(1)} ms`
|
||||
// )
|
||||
|
||||
/*
|
||||
Client is re-joining 'rooms' channel and the user's id will be tracked with Presence.
|
||||
|
||||
Note: Realtime enforces unique channel names per client so the previous 'rooms' channel
|
||||
has already been removed in the cleanup function.
|
||||
*/
|
||||
roomChannel = supabase?.channel('rooms', { config: { presence: { key: roomId } } })!
|
||||
roomChannel.on(
|
||||
REALTIME_LISTEN_TYPES.PRESENCE,
|
||||
{ event: REALTIME_PRESENCE_LISTEN_EVENTS.SYNC },
|
||||
() => {
|
||||
setIsInitialStateSynced(true)
|
||||
mapInitialUsers(roomChannel, roomId)
|
||||
}
|
||||
)
|
||||
roomChannel.subscribe(async (status: `${REALTIME_SUBSCRIBE_STATES}`) => {
|
||||
if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {
|
||||
const resp: RealtimeChannelSendResponse = await roomChannel.track({ user_id: userId })
|
||||
|
||||
// if (resp === 'ok') {
|
||||
// router.push(`/${roomId}`)
|
||||
// } else {
|
||||
// router.push(`/`)
|
||||
// }
|
||||
}
|
||||
})
|
||||
|
||||
// Get the room's existing messages that were saved to database
|
||||
supabase
|
||||
?.from('messages')
|
||||
.select('id, user_id, message')
|
||||
.filter('room_id', 'eq', roomId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(MAX_DISPLAY_MESSAGES)
|
||||
.then((resp: PostgrestResponse<Message>) => {
|
||||
resp.data && setMessages(resp.data.reverse())
|
||||
setAreMessagesFetched(true)
|
||||
if (chatboxRef.current) chatboxRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
})
|
||||
}
|
||||
|
||||
// Must properly remove subscribed channel
|
||||
return () => {
|
||||
roomChannel && supabase?.removeChannel(roomChannel)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [supabase, roomId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId || !isInitialStateSynced) return
|
||||
|
||||
let pingIntervalId: ReturnType<typeof setInterval> | undefined
|
||||
let messageChannel: RealtimeChannel, pingChannel: RealtimeChannel
|
||||
let setMouseEvent: (e: MouseEvent) => void = () => {},
|
||||
onKeyDown: (e: KeyboardEvent) => void = () => {}
|
||||
|
||||
// Ping channel is used to calculate roundtrip time from client to server to client
|
||||
pingChannel = supabase?.channel(`ping:${userId}`, {
|
||||
config: { broadcast: { ack: true } },
|
||||
})!
|
||||
pingChannel.subscribe((status: `${REALTIME_SUBSCRIBE_STATES}`) => {
|
||||
if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {
|
||||
pingIntervalId = setInterval(async () => {
|
||||
const start = performance.now()
|
||||
const resp = await pingChannel.send({
|
||||
type: 'broadcast',
|
||||
event: 'PING',
|
||||
payload: {},
|
||||
})
|
||||
|
||||
if (resp !== 'ok') {
|
||||
console.log('pingChannel broadcast error')
|
||||
setLatency(-1)
|
||||
} else {
|
||||
const end = performance.now()
|
||||
const newLatency = end - start
|
||||
|
||||
// if (newLatency >= LATENCY_THRESHOLD) {
|
||||
// sendLog(
|
||||
// `Roundtrip Latency for User ${userId} surpassed ${LATENCY_THRESHOLD} ms at ${newLatency.toFixed(
|
||||
// 1
|
||||
// )} ms`
|
||||
// )
|
||||
// }
|
||||
|
||||
setLatency(newLatency)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
messageChannel = supabase?.channel(`chat_messages:${roomId}`)!
|
||||
|
||||
// Listen for messages inserted into the database
|
||||
messageChannel.on(
|
||||
REALTIME_LISTEN_TYPES.POSTGRES_CHANGES,
|
||||
{
|
||||
event: REALTIME_POSTGRES_CHANGES_LISTEN_EVENT.INSERT,
|
||||
schema: 'public',
|
||||
table: 'messages',
|
||||
filter: `room_id=eq.${roomId}`,
|
||||
},
|
||||
(
|
||||
payload: RealtimePostgresInsertPayload<{
|
||||
id: number
|
||||
created_at: string
|
||||
message: string
|
||||
user_id: string
|
||||
room_id: string
|
||||
}>
|
||||
) => {
|
||||
// if (payload.new.user_id === userId && insertMsgTimestampRef.current) {
|
||||
// sendLog(
|
||||
// `Message Latency for User ${userId} from insert to receive was ${(
|
||||
// performance.now() - insertMsgTimestampRef.current
|
||||
// ).toFixed(1)} ms`
|
||||
// )
|
||||
// insertMsgTimestampRef.current = undefined
|
||||
// }
|
||||
|
||||
setMessages((prevMsgs: Message[]) => {
|
||||
const messages = prevMsgs.slice(-MAX_DISPLAY_MESSAGES + 1)
|
||||
const msg = (({ id, message, room_id, user_id }) => ({
|
||||
id,
|
||||
message,
|
||||
room_id,
|
||||
user_id,
|
||||
}))(payload.new)
|
||||
messages.push(msg)
|
||||
|
||||
if (msg.user_id === userId) {
|
||||
const updatedMessagesInTransit = removeFirst(
|
||||
messagesInTransitRef?.current ?? [],
|
||||
msg.message
|
||||
)
|
||||
setMessagesInTransit(updatedMessagesInTransit)
|
||||
}
|
||||
|
||||
return messages
|
||||
})
|
||||
|
||||
if (chatboxRef.current) {
|
||||
chatboxRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Listen for cursor positions from other users in the room
|
||||
messageChannel.on(
|
||||
REALTIME_LISTEN_TYPES.BROADCAST,
|
||||
{ event: 'POS' },
|
||||
(payload: Payload<{ user_id: string } & Coordinates>) => {
|
||||
setUsers((users) => {
|
||||
const userId = payload!.payload!.user_id
|
||||
const existingUser = users[userId]
|
||||
|
||||
if (existingUser) {
|
||||
const x =
|
||||
(payload?.payload?.x ?? 0) - X_THRESHOLD > window.innerWidth
|
||||
? window.innerWidth - X_THRESHOLD
|
||||
: payload?.payload?.x
|
||||
const y =
|
||||
(payload?.payload?.y ?? 0 - Y_THRESHOLD) > window.innerHeight
|
||||
? window.innerHeight - Y_THRESHOLD
|
||||
: payload?.payload?.y
|
||||
|
||||
users[userId] = { ...existingUser, ...{ x, y } }
|
||||
users = cloneDeep(users)
|
||||
}
|
||||
|
||||
return users
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Listen for messages sent by other users directly via Broadcast
|
||||
messageChannel.on(
|
||||
REALTIME_LISTEN_TYPES.BROADCAST,
|
||||
{ event: 'MESSAGE' },
|
||||
(payload: Payload<{ user_id: string; isTyping: boolean; message: string }>) => {
|
||||
setUsers((users) => {
|
||||
const userId = payload!.payload!.user_id
|
||||
const existingUser = users[userId]
|
||||
|
||||
if (existingUser) {
|
||||
users[userId] = {
|
||||
...existingUser,
|
||||
...{ isTyping: payload?.payload?.isTyping, message: payload?.payload?.message },
|
||||
}
|
||||
users = cloneDeep(users)
|
||||
}
|
||||
|
||||
return users
|
||||
})
|
||||
}
|
||||
)
|
||||
messageChannel.subscribe((status: `${REALTIME_SUBSCRIBE_STATES}`) => {
|
||||
if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {
|
||||
// Lodash throttle will be removed once realtime-js client throttles on the channel level
|
||||
const sendMouseBroadcast = throttle(({ x, y }) => {
|
||||
messageChannel
|
||||
.send({
|
||||
type: 'broadcast',
|
||||
event: 'POS',
|
||||
payload: { user_id: userId, x, y },
|
||||
})
|
||||
.catch(() => {})
|
||||
}, 1000 / MAX_EVENTS_PER_SECOND)
|
||||
|
||||
setMouseEvent = (e: MouseEvent) => {
|
||||
const [x, y] = [e.clientX, e.clientY]
|
||||
sendMouseBroadcast({ x, y })
|
||||
setMousePosition({ x, y })
|
||||
}
|
||||
|
||||
onKeyDown = async (e: KeyboardEvent) => {
|
||||
if (document.activeElement?.id === 'email') return
|
||||
|
||||
// Start typing session
|
||||
if (e.code === 'Enter' || (e.key.length === 1 && !e.metaKey)) {
|
||||
if (!isTypingRef.current) {
|
||||
setIsTyping(true)
|
||||
setIsCancelled(false)
|
||||
|
||||
if (chatInputFix.current) {
|
||||
setMessage('')
|
||||
chatInputFix.current = false
|
||||
} else {
|
||||
setMessage(e.key.length === 1 ? e.key : '')
|
||||
}
|
||||
messageChannel
|
||||
.send({
|
||||
type: 'broadcast',
|
||||
event: 'MESSAGE',
|
||||
payload: { user_id: userId, isTyping: true, message: '' },
|
||||
})
|
||||
.catch(() => {})
|
||||
} else if (e.code === 'Enter') {
|
||||
// End typing session and send message
|
||||
setIsTyping(false)
|
||||
messageChannel
|
||||
.send({
|
||||
type: 'broadcast',
|
||||
event: 'MESSAGE',
|
||||
payload: { user_id: userId, isTyping: false, message: messageRef.current },
|
||||
})
|
||||
.catch(() => {})
|
||||
if (messageRef.current) {
|
||||
const updatedMessagesInTransit = (messagesInTransitRef?.current ?? []).concat([
|
||||
messageRef.current,
|
||||
])
|
||||
setMessagesInTransit(updatedMessagesInTransit)
|
||||
if (chatboxRef.current) chatboxRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
insertMsgTimestampRef.current = performance.now()
|
||||
await supabase?.from('messages').insert([
|
||||
{
|
||||
user_id: userId,
|
||||
room_id: roomId,
|
||||
message: messageRef.current,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End typing session without sending
|
||||
if (e.code === 'Escape' && isTypingRef.current) {
|
||||
setIsTyping(false)
|
||||
setIsCancelled(true)
|
||||
chatInputFix.current = true
|
||||
|
||||
messageChannel
|
||||
.send({
|
||||
type: 'broadcast',
|
||||
event: 'MESSAGE',
|
||||
payload: { user_id: userId, isTyping: false, message: '' },
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', setMouseEvent)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
pingIntervalId && clearInterval(pingIntervalId)
|
||||
|
||||
window.removeEventListener('mousemove', setMouseEvent)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
|
||||
pingChannel && supabase?.removeChannel(pingChannel)
|
||||
messageChannel && supabase?.removeChannel(messageChannel)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [roomId, isInitialStateSynced])
|
||||
|
||||
const getUserColor = useCallback(
|
||||
(userId: string | undefined) => {
|
||||
if (!userId) {
|
||||
@@ -87,13 +557,13 @@ export default function InteractiveGrid() {
|
||||
}
|
||||
|
||||
// Broadcast hover state change to others
|
||||
if (realtimeChannel) {
|
||||
realtimeChannel.send({
|
||||
type: 'broadcast',
|
||||
event: 'hover',
|
||||
payload: { cellKey: key, isHovered, color },
|
||||
})
|
||||
}
|
||||
// if (realtimeChannel) {
|
||||
// realtimeChannel.send({
|
||||
// type: 'broadcast',
|
||||
// event: 'hover',
|
||||
// payload: { cellKey: key, isHovered, color },
|
||||
// })
|
||||
// }
|
||||
|
||||
return newState
|
||||
})
|
||||
@@ -137,15 +607,15 @@ export default function InteractiveGrid() {
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(userCursors).forEach(([userId, cursor]) => {
|
||||
ctx.fillStyle =
|
||||
userId === CURRENT_USER_ID
|
||||
? INTERACTIVE_GRID_COLORS(isDarkTheme).CURRENT_USER_HOVER
|
||||
: getUserColor(userId)
|
||||
ctx.beginPath()
|
||||
ctx.arc(cursor.x, cursor.y, 5, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
})
|
||||
// Object.entries(userCursors).forEach(([userId, cursor]) => {
|
||||
// ctx.fillStyle =
|
||||
// userId === CURRENT_USER_ID
|
||||
// ? INTERACTIVE_GRID_COLORS(isDarkTheme).CURRENT_USER_HOVER
|
||||
// : getUserColor(userId)
|
||||
// ctx.beginPath()
|
||||
// ctx.arc(cursor.x, cursor.y, 5, 0, 2 * Math.PI)
|
||||
// ctx.fill()
|
||||
// })
|
||||
},
|
||||
[hoveredCells, userCursors, getUserColor, isDarkTheme]
|
||||
)
|
||||
@@ -188,17 +658,17 @@ export default function InteractiveGrid() {
|
||||
}
|
||||
})
|
||||
|
||||
if (realtimeChannel) {
|
||||
realtimeChannel.send({
|
||||
type: 'broadcast',
|
||||
event: 'cursor',
|
||||
payload: {
|
||||
x: e.nativeEvent.offsetX,
|
||||
y: e.nativeEvent.offsetY,
|
||||
userId: CURRENT_USER_ID,
|
||||
},
|
||||
})
|
||||
}
|
||||
// if (realtimeChannel) {
|
||||
// realtimeChannel.send({
|
||||
// type: 'broadcast',
|
||||
// event: 'cursor',
|
||||
// payload: {
|
||||
// x: e.nativeEvent.offsetX,
|
||||
// y: e.nativeEvent.offsetY,
|
||||
// userId: CURRENT_USER_ID,
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
}
|
||||
},
|
||||
[hoveredCells, setCellHovered, realtimeChannel, getUserColor, isDarkTheme]
|
||||
@@ -210,56 +680,112 @@ export default function InteractiveGrid() {
|
||||
})
|
||||
}, [hoveredCells, setCellHovered, getUserColor, userData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeChannel && supabase) {
|
||||
const hoverChannel = supabase.channel('hover_presence', {
|
||||
config: { broadcast: { ack: true } },
|
||||
})
|
||||
// useEffect(() => {
|
||||
// if (!realtimeChannel && supabase) {
|
||||
// const hoverChannel = supabase?.channel('hover_presence', {
|
||||
// config: { broadcast: { ack: true } },
|
||||
// })
|
||||
|
||||
setRealtimeChannel(hoverChannel)
|
||||
// setRealtimeChannel(hoverChannel)
|
||||
|
||||
hoverChannel
|
||||
.on('broadcast', { event: 'hover' }, ({ payload }) => {
|
||||
setCellHovered(payload.cellKey, payload.isHovered, payload.color)
|
||||
})
|
||||
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
|
||||
setUserCursors((prev) => ({
|
||||
...prev,
|
||||
[payload.userId]: { x: payload.x, y: payload.y },
|
||||
}))
|
||||
})
|
||||
.subscribe((status) => {
|
||||
if (status !== 'SUBSCRIBED') return
|
||||
hoverChannel.track({})
|
||||
})
|
||||
// hoverChannel
|
||||
// .on('broadcast', { event: 'hover' }, ({ payload }) => {
|
||||
// setCellHovered(payload.cellKey, payload.isHovered, payload.color)
|
||||
// })
|
||||
// .on('broadcast', { event: 'cursor' }, ({ payload }) => {
|
||||
// setUserCursors((prev) => ({
|
||||
// ...prev,
|
||||
// [payload.userId]: { x: payload.x, y: payload.y },
|
||||
// }))
|
||||
// })
|
||||
// .subscribe((status) => {
|
||||
// if (status !== 'SUBSCRIBED') return
|
||||
// hoverChannel.track({})
|
||||
// })
|
||||
|
||||
hoverChannel.on('presence', { event: 'sync' }, () => {
|
||||
const newState = hoverChannel.presenceState()
|
||||
setOnlineUsers(
|
||||
[...Object.entries(newState).map(([_, value]) => value[0])].filter(onlyUnique)
|
||||
)
|
||||
})
|
||||
}
|
||||
// // hoverChannel.on('presence', { event: 'sync' }, () => {
|
||||
// // const newState = hoverChannel.presenceState()
|
||||
// // setOnlineUsers(
|
||||
// // [...Object.entries(newState).map(([_, value]) => value[0])].filter(onlyUnique)
|
||||
// // )
|
||||
// // })
|
||||
// }
|
||||
|
||||
return () => {
|
||||
realtimeChannel?.unsubscribe()
|
||||
}
|
||||
}, [supabase, realtimeChannel])
|
||||
// return () => {
|
||||
// realtimeChannel?.unsubscribe()
|
||||
// }
|
||||
// }, [supabase, realtimeChannel])
|
||||
|
||||
console.log('users', users)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 w-full h-full flex justify-center items-center max-h-full lg:max-h-screen">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS_WIDTH}
|
||||
height={CANVAS_HEIGHT}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="absolute w-full !h-full inset-0 z-10 pointer-events-none">
|
||||
<div className="absolute left-4 bottom-4">
|
||||
<Badge>Latency: {latency.toFixed(1)}ms</Badge>
|
||||
</div>
|
||||
{/* <div className="absolute right-4 bottom-4">
|
||||
<Chatbox
|
||||
messages={messages || []}
|
||||
chatboxRef={chatboxRef}
|
||||
messagesInTransit={messagesInTransit}
|
||||
areMessagesFetched={areMessagesFetched}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="absolute inset-0 w-screen h-screen flex justify-center items-center max-w-screen max-h-screen">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS_WIDTH}
|
||||
height={CANVAS_HEIGHT}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="shadow-lg"
|
||||
/>
|
||||
|
||||
{Object.entries(users).reduce((acc, [userId, data]) => {
|
||||
const { x, y, color, message, isTyping, hue } = data
|
||||
if (x && y) {
|
||||
acc.push(
|
||||
<Cursor
|
||||
key={userId}
|
||||
x={x}
|
||||
y={y}
|
||||
color={color}
|
||||
hue={hue}
|
||||
message={message || ''}
|
||||
isTyping={isTyping || false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return acc
|
||||
}, [] as ReactElement[])}
|
||||
|
||||
{/* Cursor for local client: Shouldn't show the cursor itself, only the text bubble */}
|
||||
{Number.isInteger(mousePosition?.x) && Number.isInteger(mousePosition?.y) && (
|
||||
<Cursor
|
||||
isLocalClient
|
||||
x={mousePosition?.x}
|
||||
y={mousePosition?.y}
|
||||
color={users[userId]?.color ?? localColorBackup.bg}
|
||||
hue={users[userId]?.hue ?? localColorBackup.hue}
|
||||
isTyping={isTyping}
|
||||
isCancelled={isCancelled}
|
||||
message={message}
|
||||
onUpdateMessage={setMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function onlyUnique(value: any, index: number, array: any[]) {
|
||||
return array.indexOf(value) === index
|
||||
}
|
||||
|
||||
export const removeFirst = (src: any[], element: any) => {
|
||||
const index = src.indexOf(element)
|
||||
if (index === -1) return src
|
||||
return [...src.slice(0, index), ...src.slice(index + 1)]
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import useConfData from '~/components/LaunchWeek/hooks/use-conf-data'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
const GRID_SIZE = 100
|
||||
const CELL_SIZE = 40
|
||||
const CANVAS_WIDTH = 1800
|
||||
const CANVAS_HEIGHT = 1600
|
||||
const HOVER_DURATION = 100
|
||||
const FADE_DURATION = 300
|
||||
|
||||
interface CellState {
|
||||
isHovered: boolean
|
||||
fadeStartTime: number | null
|
||||
color: string
|
||||
}
|
||||
|
||||
interface CursorPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export const INTERACTIVE_GRID_COLORS = (isDark: boolean) => ({
|
||||
GRID_STROKE: isDark ? '#242424' : '#EDEDED',
|
||||
CURRENT_USER_HOVER: isDark ? '#242424' : '#d3d3d3',
|
||||
// HOVER_COLORS: isDark ? ['#242424'] : ['#D3D3D3'],
|
||||
HOVER_COLORS: isDark
|
||||
? ['#822A17', '#1F7A2F', '#172A82', '#520F57']
|
||||
: ['#FF8166', '#5CD671', '#6E86F7', '#F999FF'],
|
||||
})
|
||||
|
||||
export default function InteractiveGrid() {
|
||||
const { supabase, userData } = useConfData()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDarkTheme = resolvedTheme?.includes('dark')!
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [realtimeChannel, setRealtimeChannel] = useState<ReturnType<
|
||||
SupabaseClient['channel']
|
||||
> | null>(null)
|
||||
const [hoveredCells, setHoveredCells] = useState<Map<string, CellState>>(new Map())
|
||||
const [userCursors, setUserCursors] = useState<Record<string, CursorPosition>>({})
|
||||
const [_onlineUsers, setOnlineUsers] = useState<any[]>([])
|
||||
const [userColors, setUserColors] = useState<Record<string, string>>({})
|
||||
const animationFrameRef = useRef<number>()
|
||||
|
||||
const currentUserIdRef = useRef(userData?.id || uuidv4())
|
||||
const CURRENT_USER_ID = currentUserIdRef.current
|
||||
|
||||
const getUserColor = useCallback(
|
||||
(userId: string | undefined) => {
|
||||
if (!userId) {
|
||||
return INTERACTIVE_GRID_COLORS(isDarkTheme).CURRENT_USER_HOVER
|
||||
}
|
||||
|
||||
if (userColors[userId]) {
|
||||
return userColors[userId]
|
||||
}
|
||||
|
||||
const colors = INTERACTIVE_GRID_COLORS(isDarkTheme).HOVER_COLORS
|
||||
const color = colors[userId.charCodeAt(0) % colors.length]
|
||||
|
||||
setUserColors((prev) => ({ ...prev, [userId]: color }))
|
||||
return color
|
||||
},
|
||||
[userColors, isDarkTheme]
|
||||
)
|
||||
|
||||
const setCellHovered = useCallback(
|
||||
(key: string, isHovered: boolean, color: string) => {
|
||||
setHoveredCells((prev) => {
|
||||
const newState = new Map(prev)
|
||||
if (isHovered) {
|
||||
newState.set(key, { isHovered: true, fadeStartTime: null, color })
|
||||
} else {
|
||||
const existingCell = newState.get(key)
|
||||
if (existingCell && existingCell.isHovered) {
|
||||
newState.set(key, {
|
||||
isHovered: false,
|
||||
fadeStartTime: Date.now() + HOVER_DURATION,
|
||||
color,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast hover state change to others
|
||||
if (realtimeChannel) {
|
||||
realtimeChannel.send({
|
||||
type: 'broadcast',
|
||||
event: 'hover',
|
||||
payload: { cellKey: key, isHovered, color },
|
||||
})
|
||||
}
|
||||
|
||||
return newState
|
||||
})
|
||||
},
|
||||
[realtimeChannel]
|
||||
)
|
||||
|
||||
const drawGrid = useCallback(
|
||||
(ctx: CanvasRenderingContext2D, currentTime: number) => {
|
||||
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT)
|
||||
ctx.lineWidth = 0.2
|
||||
ctx.strokeStyle = INTERACTIVE_GRID_COLORS(isDarkTheme).GRID_STROKE
|
||||
|
||||
for (let x = 0; x < GRID_SIZE; x++) {
|
||||
for (let y = 0; y < GRID_SIZE; y++) {
|
||||
const cellX = x * CELL_SIZE
|
||||
const cellY = y * CELL_SIZE
|
||||
|
||||
ctx.strokeRect(cellX, cellY, CELL_SIZE, CELL_SIZE)
|
||||
|
||||
const cellKey = `${x},${y}`
|
||||
const cellState = hoveredCells.get(cellKey)
|
||||
if (cellState) {
|
||||
let opacity = 0.5
|
||||
if (!cellState.isHovered && cellState.fadeStartTime) {
|
||||
const fadeElapsed = currentTime - cellState.fadeStartTime
|
||||
if (fadeElapsed >= 0) {
|
||||
opacity = Math.max(0, 0.5 * (1 - fadeElapsed / FADE_DURATION))
|
||||
if (opacity === 0) {
|
||||
setHoveredCells((prev) => {
|
||||
const newState = new Map(prev)
|
||||
newState.delete(cellKey)
|
||||
return newState
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.fillStyle = `rgba(${parseInt(cellState.color.slice(1, 3), 16)}, ${parseInt(cellState.color.slice(3, 5), 16)}, ${parseInt(cellState.color.slice(5, 7), 16)}, ${opacity})`
|
||||
ctx.fillRect(cellX, cellY, CELL_SIZE, CELL_SIZE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.entries(userCursors).forEach(([userId, cursor]) => {
|
||||
ctx.fillStyle =
|
||||
userId === CURRENT_USER_ID
|
||||
? INTERACTIVE_GRID_COLORS(isDarkTheme).CURRENT_USER_HOVER
|
||||
: getUserColor(userId)
|
||||
ctx.beginPath()
|
||||
ctx.arc(cursor.x, cursor.y, 5, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
})
|
||||
},
|
||||
[hoveredCells, userCursors, getUserColor, isDarkTheme]
|
||||
)
|
||||
|
||||
const animate = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const currentTime = Date.now()
|
||||
drawGrid(ctx, currentTime)
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}, [drawGrid])
|
||||
|
||||
useEffect(() => {
|
||||
animate()
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [animate])
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const x = Math.floor(e.nativeEvent.offsetX / CELL_SIZE)
|
||||
const y = Math.floor(e.nativeEvent.offsetY / CELL_SIZE)
|
||||
const cellKey = `${x},${y}`
|
||||
const userColor = getUserColor(CURRENT_USER_ID)
|
||||
|
||||
if (x >= 0 && x < GRID_SIZE && y >= 0 && y < GRID_SIZE) {
|
||||
setCellHovered(cellKey, true, userColor)
|
||||
|
||||
hoveredCells.forEach((_, key) => {
|
||||
if (key !== cellKey) {
|
||||
setCellHovered(key, false, userColor)
|
||||
}
|
||||
})
|
||||
|
||||
if (realtimeChannel) {
|
||||
realtimeChannel.send({
|
||||
type: 'broadcast',
|
||||
event: 'cursor',
|
||||
payload: {
|
||||
x: e.nativeEvent.offsetX,
|
||||
y: e.nativeEvent.offsetY,
|
||||
userId: CURRENT_USER_ID,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[hoveredCells, setCellHovered, realtimeChannel, getUserColor, isDarkTheme]
|
||||
)
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
hoveredCells.forEach((_, key) => {
|
||||
setCellHovered(key, false, getUserColor(CURRENT_USER_ID))
|
||||
})
|
||||
}, [hoveredCells, setCellHovered, getUserColor, userData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeChannel && supabase) {
|
||||
const hoverChannel = supabase.channel('hover_presence', {
|
||||
config: { broadcast: { ack: true } },
|
||||
})
|
||||
|
||||
setRealtimeChannel(hoverChannel)
|
||||
|
||||
hoverChannel
|
||||
.on('broadcast', { event: 'hover' }, ({ payload }) => {
|
||||
setCellHovered(payload.cellKey, payload.isHovered, payload.color)
|
||||
})
|
||||
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
|
||||
setUserCursors((prev) => ({
|
||||
...prev,
|
||||
[payload.userId]: { x: payload.x, y: payload.y },
|
||||
}))
|
||||
})
|
||||
.subscribe((status) => {
|
||||
if (status !== 'SUBSCRIBED') return
|
||||
hoverChannel.track({})
|
||||
})
|
||||
|
||||
hoverChannel.on('presence', { event: 'sync' }, () => {
|
||||
const newState = hoverChannel.presenceState()
|
||||
setOnlineUsers(
|
||||
[...Object.entries(newState).map(([_, value]) => value[0])].filter(onlyUnique)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
realtimeChannel?.unsubscribe()
|
||||
}
|
||||
}, [supabase, realtimeChannel])
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 w-full h-full flex justify-center items-center max-h-full lg:max-h-screen">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS_WIDTH}
|
||||
height={CANVAS_HEIGHT}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function onlyUnique(value: any, index: number, array: any[]) {
|
||||
return array.indexOf(value) === index
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// import { IconLoader } from '@supabase/ui'
|
||||
import { FC, RefObject } from 'react'
|
||||
import { Message } from './types'
|
||||
|
||||
interface Props {
|
||||
messages: Message[]
|
||||
chatboxRef: RefObject<any>
|
||||
messagesInTransit: string[]
|
||||
areMessagesFetched: boolean
|
||||
}
|
||||
|
||||
const Chatbox: FC<Props> = ({ messages, chatboxRef, messagesInTransit, areMessagesFetched }) => {
|
||||
return (
|
||||
<div className="flex flex-col rounded-md break-all">
|
||||
<div
|
||||
className="space-y-1 py-2 px-4 w-[400px]"
|
||||
style={{ backgroundColor: 'rgba(0, 207, 144, 0.05)' }}
|
||||
>
|
||||
{!areMessagesFetched ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* <IconLoader className="animate-spin text-scale-1200" size={14} /> */}
|
||||
<p className="text-sm text-scale-1100">Loading messages</p>
|
||||
</div>
|
||||
) : messages.length === 0 && messagesInTransit.length === 0 ? (
|
||||
<div className="text-scale-1200 text-sm opacity-75">
|
||||
<span>Type anything to start chatting 🥳</span>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<p key={message.id} className="text-scale-1200 text-sm whitespace-pre-line">
|
||||
{message.message}
|
||||
</p>
|
||||
))}
|
||||
{messagesInTransit.map((message, idx: number) => (
|
||||
<p key={`transit-${idx}`} className="text-sm text-scale-1100">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
{/* <div ref={chatboxRef} className="!mt-0" /> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Chatbox
|
||||
@@ -0,0 +1,140 @@
|
||||
import { FC, FormEvent, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
x?: number
|
||||
y?: number
|
||||
color: string
|
||||
hue: string
|
||||
message: string
|
||||
isTyping: boolean
|
||||
isCancelled?: boolean
|
||||
isLocalClient?: boolean
|
||||
onUpdateMessage?: (message: string) => void
|
||||
}
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 70
|
||||
const MAX_DURATION = 4000
|
||||
const MAX_BUBBLE_WIDTH_THRESHOLD = 280 + 50
|
||||
const MAX_BUBBLE_HEIGHT_THRESHOLD = 40 + 50
|
||||
|
||||
const Cursor: FC<Props> = ({
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
hue,
|
||||
message,
|
||||
isTyping,
|
||||
isCancelled,
|
||||
isLocalClient,
|
||||
onUpdateMessage = () => {},
|
||||
}) => {
|
||||
// Don't show cursor for the local client
|
||||
const _isLocalClient = !x || !y || isLocalClient
|
||||
const inputRef = useRef() as any
|
||||
const timeoutRef = useRef() as any
|
||||
const chatBubbleRef = useRef() as any
|
||||
|
||||
const [flipX, setFlipX] = useState(false)
|
||||
const [flipY, setFlipY] = useState(false)
|
||||
const [hideInput, setHideInput] = useState(false)
|
||||
const [showMessageBubble, setShowMessageBubble] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isTyping) {
|
||||
setShowMessageBubble(true)
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
|
||||
if (isLocalClient) {
|
||||
if (inputRef.current) inputRef.current.focus()
|
||||
setHideInput(false)
|
||||
}
|
||||
} else {
|
||||
if (!message || isCancelled) {
|
||||
setShowMessageBubble(false)
|
||||
} else {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
if (isLocalClient) setHideInput(true)
|
||||
const timeoutId = setTimeout(() => {
|
||||
setShowMessageBubble(false)
|
||||
}, MAX_DURATION)
|
||||
timeoutRef.current = timeoutId
|
||||
}
|
||||
}
|
||||
}, [isLocalClient, isTyping, isCancelled, message, inputRef])
|
||||
|
||||
useEffect(() => {
|
||||
// [Joshen] Experimental: dynamic flipping to ensure that chat
|
||||
// bubble always stays within the viewport, comment this block
|
||||
// out if the effect seems weird.
|
||||
setFlipX((x || 0) + MAX_BUBBLE_WIDTH_THRESHOLD >= window.innerWidth)
|
||||
setFlipY((y || 0) + MAX_BUBBLE_HEIGHT_THRESHOLD >= window.innerHeight)
|
||||
}, [x, y, isTyping, chatBubbleRef])
|
||||
|
||||
return (
|
||||
<>
|
||||
{!_isLocalClient && (
|
||||
<svg
|
||||
width="18"
|
||||
height="24"
|
||||
viewBox="0 0 18 24"
|
||||
fill="none"
|
||||
className="absolute top-0 left-0 transform transition pointer-events-none"
|
||||
style={{ color, transform: `translateX(${x}px) translateY(${y}px)` }}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2.717 2.22918L15.9831 15.8743C16.5994 16.5083 16.1503 17.5714 15.2661 17.5714H9.35976C8.59988 17.5714 7.86831 17.8598 7.3128 18.3783L2.68232 22.7C2.0431 23.2966 1 22.8434 1 21.969V2.92626C1 2.02855 2.09122 1.58553 2.717 2.22918Z"
|
||||
fill={color}
|
||||
stroke={hue}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<div
|
||||
ref={chatBubbleRef}
|
||||
className={[
|
||||
'transition-all absolute top-0 left-0 py-2 rounded-full shadow-md',
|
||||
'flex items-center justify-between px-4 space-x-2 pointer-events-none',
|
||||
`${showMessageBubble ? 'opacity-100' : 'opacity-0'}`,
|
||||
`${_isLocalClient && !hideInput ? 'w-[280px]' : 'max-w-[280px] overflow-hidden'}`,
|
||||
].join(' ')}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
transform: `translateX(${
|
||||
(x || 0) + (flipX ? -chatBubbleRef.current.clientWidth - 20 : 20)
|
||||
}px) translateY(${(y || 0) + (flipY ? -chatBubbleRef.current.clientHeight - 20 : 20)}px)`,
|
||||
}}
|
||||
>
|
||||
{_isLocalClient && !hideInput ? (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
className="w-full !outline-none bg-transparent !border-none text-foreground"
|
||||
onChange={(e: FormEvent<HTMLInputElement>) => {
|
||||
const text = e.currentTarget.value
|
||||
if (text.length <= MAX_MESSAGE_LENGTH) onUpdateMessage(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs" style={{ color: hue }}>
|
||||
{message.length}/{MAX_MESSAGE_LENGTH}
|
||||
</p>
|
||||
</>
|
||||
) : message.length ? (
|
||||
<div className="truncate text-foreground-light">{message}</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="loader-dots block relative h-6 w-10">
|
||||
<div className="absolute top-0 my-2.5 w-1.5 h-1.5 rounded-full bg-foreground opacity-75"></div>
|
||||
<div className="absolute top-0 my-2.5 w-1.5 h-1.5 rounded-full bg-foreground opacity-75"></div>
|
||||
<div className="absolute top-0 my-2.5 w-1.5 h-1.5 rounded-full bg-foreground opacity-75"></div>
|
||||
<div className="absolute top-0 my-2.5 w-1.5 h-1.5 rounded-full bg-foreground opacity-75"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Cursor
|
||||
@@ -0,0 +1,56 @@
|
||||
import sampleSize from 'lodash/samplesize'
|
||||
|
||||
const colors = {
|
||||
tomato: {
|
||||
bg: 'var(--colors-tomato9)',
|
||||
hue: 'var(--colors-tomato7)',
|
||||
},
|
||||
crimson: {
|
||||
bg: 'var(--colors-crimson9)',
|
||||
hue: 'var(--colors-crimson7)',
|
||||
},
|
||||
pink: {
|
||||
bg: 'var(--colors-pink9)',
|
||||
hue: 'var(--colors-pink7)',
|
||||
},
|
||||
plum: {
|
||||
bg: 'var(--colors-plum9)',
|
||||
hue: 'var(--colors-plum7)',
|
||||
},
|
||||
indigo: {
|
||||
bg: 'var(--colors-indigo9)',
|
||||
hue: 'var(--colors-indigo7)',
|
||||
},
|
||||
blue: {
|
||||
bg: 'var(--colors-blue9)',
|
||||
hue: 'var(--colors-blue7)',
|
||||
},
|
||||
cyan: {
|
||||
bg: 'var(--colors-cyan9)',
|
||||
hue: 'var(--colors-cyan7)',
|
||||
},
|
||||
green: {
|
||||
bg: 'var(--colors-green9)',
|
||||
hue: 'var(--colors-green7)',
|
||||
},
|
||||
orange: {
|
||||
bg: 'var(--colors-orange9)',
|
||||
hue: 'var(--colors-orange7)',
|
||||
},
|
||||
}
|
||||
|
||||
export const getRandomUniqueColor = (currentColors: string[]) => {
|
||||
const colorNames = Object.values(colors).map((col) => col.bg)
|
||||
const uniqueColors = colorNames.filter((color: string) => !currentColors.includes(color))
|
||||
const uniqueColor = uniqueColors[Math.floor(Math.random() * uniqueColors.length)]
|
||||
const uniqueColorSet = Object.values(colors).find((color) => color.bg === uniqueColor)
|
||||
return uniqueColorSet || getRandomColor()
|
||||
}
|
||||
|
||||
export const getRandomColors = (qty: number) => {
|
||||
return sampleSize(Object.values(colors), qty)
|
||||
}
|
||||
|
||||
export const getRandomColor = () => {
|
||||
return Object.values(colors)[Math.floor(Math.random() * Object.values(colors).length)]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface Coordinates {
|
||||
x: number | undefined
|
||||
y: number | undefined
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number
|
||||
user_id: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Payload<T> {
|
||||
type: string
|
||||
event: string
|
||||
payload?: T
|
||||
}
|
||||
|
||||
export interface User extends Coordinates {
|
||||
color: string
|
||||
hue: string
|
||||
isTyping?: boolean
|
||||
message?: string
|
||||
}
|
||||
@@ -3,7 +3,14 @@ import { Database } from './database.types'
|
||||
|
||||
const supabase = createClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
realtime: {
|
||||
params: {
|
||||
eventsPerSecond: 1000,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type SupabaseClient = typeof supabase
|
||||
|
||||
@@ -733,3 +733,59 @@ launch week
|
||||
[data-rmiz-modal-img] {
|
||||
image-rendering: high-quality;
|
||||
}
|
||||
|
||||
/* Loader dots */
|
||||
|
||||
.loader-dots div {
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
}
|
||||
|
||||
.loader-dots div:nth-child(1) {
|
||||
left: 4px;
|
||||
animation: loader-dots1 0.6s infinite;
|
||||
}
|
||||
|
||||
.loader-dots div:nth-child(2) {
|
||||
left: 4px;
|
||||
animation: loader-dots2 0.6s infinite;
|
||||
}
|
||||
|
||||
.loader-dots div:nth-child(3) {
|
||||
left: 16px;
|
||||
animation: loader-dots2 0.6s infinite;
|
||||
}
|
||||
|
||||
.loader-dots div:nth-child(4) {
|
||||
left: 28px;
|
||||
animation: loader-dots3 0.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes loader-dots1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-dots3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes loader-dots2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(12px, 0);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user