diff --git a/apps/backend/supabase/migrations/0008_user_presence.sql b/apps/backend/supabase/migrations/0008_user_presence.sql new file mode 100644 index 0000000000..5e0ce8cb39 --- /dev/null +++ b/apps/backend/supabase/migrations/0008_user_presence.sql @@ -0,0 +1,110 @@ +CREATE TABLE IF NOT EXISTS public.user_presence ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NOT NULL REFERENCES public.projects(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + last_seen timestamp with time zone DEFAULT now() NOT NULL, + is_online boolean DEFAULT true NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + UNIQUE(project_id, user_id) +); + +ALTER TABLE public.user_presence ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view presence for accessible projects" ON public.user_presence + FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM public.user_projects + WHERE user_projects.project_id = user_presence.project_id + AND user_projects.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can manage their own presence" ON public.user_presence + FOR ALL + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); + +CREATE OR REPLACE FUNCTION public.update_user_presence( + p_project_id uuid, + p_user_id uuid, + p_is_online boolean DEFAULT true +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + INSERT INTO public.user_presence (project_id, user_id, is_online, last_seen, updated_at) + VALUES (p_project_id, p_user_id, p_is_online, now(), now()) + ON CONFLICT (project_id, user_id) + DO UPDATE SET + is_online = p_is_online, + last_seen = now(), + updated_at = now(); +END; +$$; + +CREATE OR REPLACE FUNCTION public.cleanup_offline_users() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + UPDATE public.user_presence + SET is_online = false, updated_at = now() + WHERE is_online = true + AND last_seen < now() - interval '5 minutes'; +END; +$$; + +CREATE OR REPLACE FUNCTION public.presence_changes() +RETURNS TRIGGER +SECURITY DEFINER +LANGUAGE plpgsql +AS $$ +DECLARE + topic_project_id uuid; +BEGIN + topic_project_id := COALESCE(NEW.project_id, OLD.project_id); + + IF topic_project_id IS NOT NULL THEN + PERFORM realtime.broadcast_changes( + 'presence:' || topic_project_id::text, + TG_OP, + TG_OP, + TG_TABLE_NAME, + TG_TABLE_SCHEMA, + NEW, + OLD + ); + END IF; + + RETURN NULL; +END; +$$; + +DROP TRIGGER IF EXISTS handle_presence_changes ON public.user_presence; +CREATE TRIGGER handle_presence_changes +AFTER INSERT OR UPDATE OR DELETE +ON public.user_presence +FOR EACH ROW +EXECUTE FUNCTION presence_changes(); + +DROP POLICY IF EXISTS "Authenticated users can receive presence broadcasts" ON "realtime"."messages"; +CREATE POLICY "Authenticated users can receive presence broadcasts" +ON "realtime"."messages" +FOR SELECT +TO authenticated +USING ( + CASE + WHEN payload->>'table' = 'user_presence' THEN + EXISTS ( + SELECT 1 FROM public.user_projects + WHERE user_projects.project_id = (payload->>'project_id')::uuid + AND user_projects.user_id = auth.uid() + ) + ELSE false + END +); diff --git a/apps/web/client/src/app/project/[id]/_components/presence-display.tsx b/apps/web/client/src/app/project/[id]/_components/presence-display.tsx new file mode 100644 index 0000000000..1d8f501433 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/presence-display.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { usePresenceManager } from '@/components/store/presence'; +import { useEditorEngine } from '@/components/store/editor'; +import { Avatar, AvatarFallback, AvatarImage } from '@onlook/ui/avatar'; +import { Button } from '@onlook/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@onlook/ui/tooltip'; +import { observer } from 'mobx-react-lite'; +import { useEffect } from 'react'; + +interface PresenceDisplayProps { + className?: string; +} + +export const PresenceDisplay = observer(({ className = '' }: PresenceDisplayProps) => { + const presenceManager = usePresenceManager(); + const editorEngine = useEditorEngine(); + + useEffect(() => { + if (editorEngine.user && editorEngine.projectId) { + presenceManager.setContext(editorEngine.user.id, editorEngine.projectId); + } + }, [editorEngine.user?.id, editorEngine.projectId, presenceManager]); + + if (!presenceManager.isConnected || presenceManager.otherOnlineUsers.length === 0) { + return null; + } + + const onlineUsers = presenceManager.otherOnlineUsers; + + return ( +
+ {onlineUsers.slice(0, 3).map((user) => ( + + +
+ + + + {user.displayName.split(' ').map(n => n[0]).join('').slice(0, 2)} + + +
+
+ + +

{user.displayName} is online

+
+ + ))} + + {onlineUsers.length > 3 && ( + + + + + +
+ {onlineUsers.slice(3).map(user => ( +
{user.displayName}
+ ))} +
+
+
+ )} +
+ ); +}); diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx index ccc31fbd23..4622903a60 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/index.tsx @@ -15,6 +15,7 @@ import { motion } from 'motion/react'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; import { Members } from '../members'; +import { PresenceDisplay } from '../presence-display'; import { BranchDisplay } from './branch'; import { ModeToggle } from './mode-toggle'; import { ProjectBreadcrumb } from './project-breadcrumb'; @@ -53,6 +54,7 @@ export const TopBar = observer(() => {
+
diff --git a/apps/web/client/src/app/project/[id]/providers.tsx b/apps/web/client/src/app/project/[id]/providers.tsx index c8d57925ec..b8af2f06fd 100644 --- a/apps/web/client/src/app/project/[id]/providers.tsx +++ b/apps/web/client/src/app/project/[id]/providers.tsx @@ -2,6 +2,7 @@ import { EditorEngineProvider } from '@/components/store/editor'; import { HostingProvider } from '@/components/store/hosting'; +import { PresenceProvider } from '@/components/store/presence'; import type { Branch, Project } from '@onlook/models'; export const ProjectProviders = ({ @@ -16,7 +17,9 @@ export const ProjectProviders = ({ return ( - {children} + + {children} + ); diff --git a/apps/web/client/src/components/store/presence/index.ts b/apps/web/client/src/components/store/presence/index.ts new file mode 100644 index 0000000000..dfe29b77e3 --- /dev/null +++ b/apps/web/client/src/components/store/presence/index.ts @@ -0,0 +1 @@ +export { PresenceProvider, usePresenceManager } from './provider'; diff --git a/apps/web/client/src/components/store/presence/manager.ts b/apps/web/client/src/components/store/presence/manager.ts new file mode 100644 index 0000000000..f19613f4c9 --- /dev/null +++ b/apps/web/client/src/components/store/presence/manager.ts @@ -0,0 +1,163 @@ +import { makeAutoObservable, reaction } from 'mobx'; +import { api } from '@/trpc/react'; +import { createClient } from '@/utils/supabase/client'; + +export interface PresenceUser { + userId: string; + displayName: string; + avatarUrl?: string | null; + lastSeen: Date; +} + +export class PresenceManager { + onlineUsers: PresenceUser[] = []; + currentUserId: string | null = null; + currentProjectId: string | null = null; + isConnected = false; + + isLoading = false; + + private subscription: any = null; + + constructor() { + makeAutoObservable(this); + + reaction( + () => this.currentProjectId, + (projectId) => { + if (projectId) { + this.joinProject(projectId); + } else { + this.leaveProject(); + } + } + ); + } + + setContext(userId: string, projectId: string) { + this.currentUserId = userId; + this.currentProjectId = projectId; + } + + async joinProject(projectId: string) { + if (!this.currentUserId) return; + + try { + this.isLoading = true; + this.currentProjectId = projectId; + + await api.presence.joinProject.mutate({ projectId }); + + await this.loadProjectPresence(projectId); + this.subscribeToPresenceUpdates(projectId); + + this.isConnected = true; + } catch (error) { + console.error('Error joining project:', error); + } finally { + this.isLoading = false; + } + } + + async leaveProject() { + if (!this.currentProjectId || !this.currentUserId) return; + + try { + await api.presence.leaveProject.mutate({ projectId: this.currentProjectId }); + + this.unsubscribeFromPresenceUpdates(); + this.onlineUsers = []; + this.currentProjectId = null; + this.isConnected = false; + } catch (error) { + console.error('Error leaving project:', error); + } + } + + private async loadProjectPresence(projectId: string) { + try { + const presenceData = await api.presence.getProjectPresence.query({ projectId }); + this.onlineUsers = presenceData.map(p => ({ + userId: p.userId, + displayName: p.displayName, + avatarUrl: p.avatarUrl, + lastSeen: p.lastSeen, + })); + } catch (error) { + console.error('Error loading project presence:', error); + } + } + + private subscribeToPresenceUpdates(projectId: string) { + this.unsubscribeFromPresenceUpdates(); + + const supabase = createClient(); + const channel = supabase + .channel(`presence:${projectId}`) + .on( + 'postgres_changes', + { + event: '*', + schema: 'public', + table: 'user_presence', + filter: `project_id=eq.${projectId}`, + }, + (payload) => { + this.handlePresenceUpdate(payload); + } + ) + .subscribe(); + + this.subscription = channel; + } + + private unsubscribeFromPresenceUpdates() { + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = null; + } + } + + private handlePresenceUpdate(payload: any) { + const { eventType, new: newRecord, old: oldRecord } = payload; + + switch (eventType) { + case 'INSERT': + case 'UPDATE': + if (newRecord && newRecord.is_online) { + const existingIndex = this.onlineUsers.findIndex(u => u.userId === newRecord.user_id); + const user: PresenceUser = { + userId: newRecord.user_id, + displayName: newRecord.user?.display_name || `${newRecord.user?.first_name || ''} ${newRecord.user?.last_name || ''}`.trim(), + avatarUrl: newRecord.user?.avatar_url, + lastSeen: new Date(newRecord.last_seen), + }; + + if (existingIndex >= 0) { + this.onlineUsers[existingIndex] = user; + } else { + this.onlineUsers.push(user); + } + } else { + this.onlineUsers = this.onlineUsers.filter(u => u.userId !== newRecord?.user_id); + } + break; + + case 'DELETE': + this.onlineUsers = this.onlineUsers.filter(u => u.userId !== oldRecord?.user_id); + break; + } + } + + get otherOnlineUsers(): PresenceUser[] { + return this.onlineUsers.filter(u => u.userId !== this.currentUserId); + } + + isUserOnline(userId: string): boolean { + return this.onlineUsers.some(u => u.userId === userId); + } + + dispose() { + this.leaveProject(); + } +} diff --git a/apps/web/client/src/components/store/presence/provider.tsx b/apps/web/client/src/components/store/presence/provider.tsx new file mode 100644 index 0000000000..a15592e5b4 --- /dev/null +++ b/apps/web/client/src/components/store/presence/provider.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { PresenceManager } from './manager'; +import { useRef, useEffect, createContext, useContext } from 'react'; + +const PresenceContext = createContext(null); + +export const PresenceProvider = ({ children }: { children: React.ReactNode }) => { + const presenceManagerRef = useRef(null); + + useEffect(() => { + presenceManagerRef.current = new PresenceManager(); + + return () => { + presenceManagerRef.current?.dispose(); + }; + }, []); + + if (!presenceManagerRef.current) { + return null; // Or a loading state + } + + return ( + + {children} + + ); +}; + +export const usePresenceManager = () => { + const context = useContext(PresenceContext); + if (!context) { + throw new Error('usePresenceManager must be used within a PresenceProvider'); + } + return context; +}; diff --git a/apps/web/client/src/server/api/root.ts b/apps/web/client/src/server/api/root.ts index c6fbcd82a1..e6ffbe94b4 100644 --- a/apps/web/client/src/server/api/root.ts +++ b/apps/web/client/src/server/api/root.ts @@ -6,6 +6,7 @@ import { githubRouter, invitationRouter, memberRouter, + presenceRouter, projectRouter, publishRouter, sandboxRouter, @@ -35,6 +36,7 @@ export const appRouter = createTRPCRouter({ userCanvas: userCanvasRouter, utils: utilsRouter, member: memberRouter, + presence: presenceRouter, domain: domainRouter, github: githubRouter, subscription: subscriptionRouter, diff --git a/apps/web/client/src/server/api/routers/index.ts b/apps/web/client/src/server/api/routers/index.ts index a2f62588db..87ba995c2f 100644 --- a/apps/web/client/src/server/api/routers/index.ts +++ b/apps/web/client/src/server/api/routers/index.ts @@ -4,6 +4,7 @@ export * from './domain'; export * from './forward'; export * from './github'; export * from './image'; +export * from './presence'; export * from './project'; export * from './publish'; export * from './subscription'; diff --git a/apps/web/client/src/server/api/routers/presence.ts b/apps/web/client/src/server/api/routers/presence.ts new file mode 100644 index 0000000000..e67f80a2b1 --- /dev/null +++ b/apps/web/client/src/server/api/routers/presence.ts @@ -0,0 +1,141 @@ +import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; +import { userPresence, users, userProjects } from '@onlook/db'; +import { and, eq, desc } from 'drizzle-orm'; +import { z } from 'zod'; + +export const presenceRouter = createTRPCRouter({ + joinProject: protectedProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ ctx, input }) => { + const { projectId } = input; + const userId = ctx.user.id; + + const projectAccess = await ctx.db.query.userProjects.findFirst({ + where: and( + eq(userProjects.userId, userId), + eq(userProjects.projectId, projectId) + ), + }); + + if (!projectAccess) { + throw new Error('User does not have access to this project'); + } + + await ctx.db.insert(userPresence).values({ + projectId, + userId, + isOnline: true, + lastSeen: new Date(), + }).onConflictDoUpdate({ + target: [userPresence.projectId, userPresence.userId], + set: { + isOnline: true, + lastSeen: new Date(), + updatedAt: new Date(), + }, + }); + + return { success: true }; + }), + + leaveProject: protectedProcedure + .input(z.object({ projectId: z.string() })) + .mutation(async ({ ctx, input }) => { + const { projectId } = input; + const userId = ctx.user.id; + + await ctx.db.update(userPresence) + .set({ + isOnline: false, + lastSeen: new Date(), + updatedAt: new Date(), + }) + .where(and( + eq(userPresence.projectId, projectId), + eq(userPresence.userId, userId) + )); + + return { success: true }; + }), + + getProjectPresence: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const { projectId } = input; + + const projectAccess = await ctx.db.query.userProjects.findFirst({ + where: and( + eq(userProjects.userId, ctx.user.id), + eq(userProjects.projectId, projectId) + ), + }); + + if (!projectAccess) { + throw new Error('User does not have access to this project'); + } + + const presenceData = await ctx.db.query.userPresence.findMany({ + where: and( + eq(userPresence.projectId, projectId), + eq(userPresence.isOnline, true) + ), + with: { + user: { + columns: { + id: true, + displayName: true, + avatarUrl: true, + firstName: true, + lastName: true, + }, + }, + }, + orderBy: desc(userPresence.lastSeen), + }); + + return presenceData.map(p => ({ + userId: p.user.id, + displayName: p.user.displayName || `${p.user.firstName || ''} ${p.user.lastName || ''}`.trim(), + avatarUrl: p.user.avatarUrl, + lastSeen: p.lastSeen, + })); + }), + + getMyPresence: protectedProcedure + .query(async ({ ctx }) => { + const userId = ctx.user.id; + + const presenceData = await ctx.db.query.userPresence.findMany({ + where: eq(userPresence.userId, userId), + with: { + project: { + columns: { + id: true, + name: true, + }, + }, + }, + }); + + return presenceData.map(p => ({ + projectId: p.project.id, + projectName: p.project.name, + isOnline: p.isOnline, + lastSeen: p.lastSeen, + })); + }), + + cleanupOffline: protectedProcedure + .mutation(async ({ ctx }) => { + await ctx.db.update(userPresence) + .set({ + isOnline: false, + updatedAt: new Date(), + }) + .where(and( + eq(userPresence.isOnline, true), + )); + + return { success: true }; + }), +}); diff --git a/packages/db/src/schema/user/index.ts b/packages/db/src/schema/user/index.ts index d0095bd877..32d4ad0203 100644 --- a/packages/db/src/schema/user/index.ts +++ b/packages/db/src/schema/user/index.ts @@ -1,3 +1,4 @@ +export * from './presence'; export * from './settings'; export * from './user'; export * from './user-canvas'; diff --git a/packages/db/src/schema/user/presence.ts b/packages/db/src/schema/user/presence.ts new file mode 100644 index 0000000000..ac39052469 --- /dev/null +++ b/packages/db/src/schema/user/presence.ts @@ -0,0 +1,38 @@ +import { relations } from 'drizzle-orm'; +import { pgTable, timestamp, uuid, boolean } from 'drizzle-orm/pg-core'; +import { createInsertSchema } from 'drizzle-zod'; +import { projects } from '../project'; +import { users } from './user'; + +export const userPresence = pgTable('user_presence', { + id: uuid('id').primaryKey(), + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade', onUpdate: 'cascade' }), + lastSeen: timestamp('last_seen', { withTimezone: true }).defaultNow().notNull(), + isOnline: boolean('is_online').default(true).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => ({ + unique: { + projectUser: [table.projectId, table.userId], + }, +})).enableRLS(); + +export const userPresenceRelations = relations(userPresence, ({ one }) => ({ + project: one(projects, { + fields: [userPresence.projectId], + references: [projects.id], + }), + user: one(users, { + fields: [userPresence.userId], + references: [users.id], + }), +})); + +export const userPresenceInsertSchema = createInsertSchema(userPresence); +export type UserPresence = typeof userPresence.$inferSelect; +export type NewUserPresence = typeof userPresence.$inferInsert;