diff --git a/apps/www/components/LaunchWeek/13/InteractiveGridParty.tsx b/apps/www/components/LaunchWeek/13/InteractiveGridParty.tsx index d1a13468a65..62c954190b7 100644 --- a/apps/www/components/LaunchWeek/13/InteractiveGridParty.tsx +++ b/apps/www/components/LaunchWeek/13/InteractiveGridParty.tsx @@ -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>(new Map()) const [userCursors, setUserCursors] = useState>({}) - const [_onlineUsers, setOnlineUsers] = useState([]) + // const [_onlineUsers, setOnlineUsers] = useState([]) const [userColors, setUserColors] = useState>({}) const animationFrameRef = useRef() const currentUserIdRef = useRef(userData?.id || uuidv4()) const CURRENT_USER_ID = currentUserIdRef.current + const router = useRouter() + + const localColorBackup = getRandomColor() + + const chatboxRef = useRef() + // [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(true) + + // These states will be managed via ref as they're mutated within event listeners + const usersRef = useRef<{ [key: string]: User }>({}) + const isTypingRef = useRef(false) + const isCancelledRef = useRef(false) + const messageRef = useRef() + const messagesInTransitRef = useRef() + const mousePositionRef = useRef() + + const joinTimestampRef = useRef() + const insertMsgTimestampRef = useRef() + + // We manage the refs with a state so that the UI can re-render + const [isTyping, _setIsTyping] = useState(false) + const [isCancelled, _setIsCancelled] = useState(false) + const [message, _setMessage] = useState('') + const [messagesInTransit, _setMessagesInTransit] = useState([]) + const [mousePosition, _setMousePosition] = useState() + + const [areMessagesFetched, setAreMessagesFetched] = useState(false) + const [isInitialStateSynced, setIsInitialStateSynced] = useState(false) + const [latency, setLatency] = useState(0) + const [messages, setMessages] = useState([]) + const [roomId, setRoomId] = useState(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) => { + 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 | 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 ( -
- -
+ <> +
+
+ Latency: {latency.toFixed(1)}ms +
+ {/*
+ +
*/} +
+
+ + + {Object.entries(users).reduce((acc, [userId, data]) => { + const { x, y, color, message, isTyping, hue } = data + if (x && y) { + acc.push( + + ) + } + 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) && ( + + )} +
+ ) } 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)] +} diff --git a/apps/www/components/LaunchWeek/13/InteractiveGridPartyPrev.tsx b/apps/www/components/LaunchWeek/13/InteractiveGridPartyPrev.tsx new file mode 100644 index 00000000000..d1a13468a65 --- /dev/null +++ b/apps/www/components/LaunchWeek/13/InteractiveGridPartyPrev.tsx @@ -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(null) + const [realtimeChannel, setRealtimeChannel] = useState | null>(null) + const [hoveredCells, setHoveredCells] = useState>(new Map()) + const [userCursors, setUserCursors] = useState>({}) + const [_onlineUsers, setOnlineUsers] = useState([]) + const [userColors, setUserColors] = useState>({}) + const animationFrameRef = useRef() + + 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) => { + 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 ( +
+ +
+ ) +} + +function onlyUnique(value: any, index: number, array: any[]) { + return array.indexOf(value) === index +} diff --git a/apps/www/components/LaunchWeek/13/Multiplayer/Chatbox.tsx b/apps/www/components/LaunchWeek/13/Multiplayer/Chatbox.tsx new file mode 100644 index 00000000000..3c04542699c --- /dev/null +++ b/apps/www/components/LaunchWeek/13/Multiplayer/Chatbox.tsx @@ -0,0 +1,47 @@ +// import { IconLoader } from '@supabase/ui' +import { FC, RefObject } from 'react' +import { Message } from './types' + +interface Props { + messages: Message[] + chatboxRef: RefObject + messagesInTransit: string[] + areMessagesFetched: boolean +} + +const Chatbox: FC = ({ messages, chatboxRef, messagesInTransit, areMessagesFetched }) => { + return ( +
+
+ {!areMessagesFetched ? ( +
+ {/* */} +

Loading messages

+
+ ) : messages.length === 0 && messagesInTransit.length === 0 ? ( +
+ Type anything to start chatting 🥳 +
+ ) : ( +
+ )} + {messages.map((message) => ( +

+ {message.message} +

+ ))} + {messagesInTransit.map((message, idx: number) => ( +

+ {message} +

+ ))} + {/*
*/} +
+
+ ) +} + +export default Chatbox diff --git a/apps/www/components/LaunchWeek/13/Multiplayer/Cursor.tsx b/apps/www/components/LaunchWeek/13/Multiplayer/Cursor.tsx new file mode 100644 index 00000000000..3acb12e293e --- /dev/null +++ b/apps/www/components/LaunchWeek/13/Multiplayer/Cursor.tsx @@ -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 = ({ + 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 && ( + + + + )} +
+ {_isLocalClient && !hideInput ? ( + <> + ) => { + const text = e.currentTarget.value + if (text.length <= MAX_MESSAGE_LENGTH) onUpdateMessage(e.currentTarget.value) + }} + /> +

+ {message.length}/{MAX_MESSAGE_LENGTH} +

+ + ) : message.length ? ( +
{message}
+ ) : ( +
+
+
+
+
+
+
+
+ )} +
+ + ) +} + +export default Cursor diff --git a/apps/www/components/LaunchWeek/13/Multiplayer/randomColor.ts b/apps/www/components/LaunchWeek/13/Multiplayer/randomColor.ts new file mode 100644 index 00000000000..71fd5b6cb63 --- /dev/null +++ b/apps/www/components/LaunchWeek/13/Multiplayer/randomColor.ts @@ -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)] +} diff --git a/apps/www/components/LaunchWeek/13/Multiplayer/types.ts b/apps/www/components/LaunchWeek/13/Multiplayer/types.ts new file mode 100644 index 00000000000..d992d7a4ab0 --- /dev/null +++ b/apps/www/components/LaunchWeek/13/Multiplayer/types.ts @@ -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 { + type: string + event: string + payload?: T +} + +export interface User extends Coordinates { + color: string + hue: string + isTyping?: boolean + message?: string +} diff --git a/apps/www/lib/supabase.ts b/apps/www/lib/supabase.ts index 9eb2cf3fb03..9d8b881ebe4 100644 --- a/apps/www/lib/supabase.ts +++ b/apps/www/lib/supabase.ts @@ -3,7 +3,14 @@ import { Database } from './database.types' const supabase = createClient( 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 diff --git a/apps/www/styles/index.css b/apps/www/styles/index.css index 4a5a2aed3ae..c297faaed1b 100644 --- a/apps/www/styles/index.css +++ b/apps/www/styles/index.css @@ -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); + } +}