From 706806dfbaf3fd6591450cfe38e7a54149597584 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 24 Jan 2026 12:34:48 -0600 Subject: [PATCH 1/2] add presence --- .../project/reconcile-tab/PresenceAvatars.jsx | 108 ++++++++ .../QuestionPresenceIndicator.jsx | 62 +++++ .../reconcile-tab/ReconciliationWithPdf.jsx | 88 ++++-- .../reconcile-tab/ReconciliationWrapper.jsx | 25 ++ .../project/reconcile-tab/RemoteCursors.jsx | 92 +++++++ .../amstar2-reconcile/Navbar.jsx | 31 ++- .../ROB2ReconciliationWithPdf.jsx | 78 ++++-- .../RobinsIReconciliationWithPdf.jsx | 78 ++++-- packages/web/src/lib/userColors.js | 80 ++++++ .../web/src/primitives/useProject/index.js | 3 + .../primitives/useReconciliationPresence.js | 253 ++++++++++++++++++ 11 files changed, 843 insertions(+), 55 deletions(-) create mode 100644 packages/web/src/components/project/reconcile-tab/PresenceAvatars.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/QuestionPresenceIndicator.jsx create mode 100644 packages/web/src/components/project/reconcile-tab/RemoteCursors.jsx create mode 100644 packages/web/src/lib/userColors.js create mode 100644 packages/web/src/primitives/useReconciliationPresence.js diff --git a/packages/web/src/components/project/reconcile-tab/PresenceAvatars.jsx b/packages/web/src/components/project/reconcile-tab/PresenceAvatars.jsx new file mode 100644 index 000000000..5e42c921b --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/PresenceAvatars.jsx @@ -0,0 +1,108 @@ +/** + * PresenceAvatars - Displays stacked avatars of users viewing the reconciliation + * + * Features: + * - Stacked avatar display with overlap + * - Tooltip showing user name and current question + * - Click to jump to user's current question + * - Overflow indicator for many users + */ + +import { For, Show } from 'solid-js'; +import { Avatar, AvatarImage, AvatarFallback, getInitials } from '@/components/ui/avatar'; +import { + Tooltip, + TooltipTrigger, + TooltipPositioner, + TooltipContent, +} from '@/components/ui/tooltip'; +import { API_BASE } from '@config/api.js'; + +/** + * @param {Object} props + * @param {Array} props.users - Array of presence users with { userId, name, image, currentPage, color } + * @param {Function} props.onUserClick - Callback when user avatar is clicked (receives userId, currentPage) + * @param {number} [props.maxVisible=4] - Maximum number of avatars to show before overflow + * @param {Function} [props.getPageLabel] - Function to convert page index to display label (default: "Question N") + */ +export default function PresenceAvatars(props) { + const maxVisible = () => props.maxVisible ?? 4; + const visibleUsers = () => props.users.slice(0, maxVisible()); + const overflowCount = () => Math.max(0, props.users.length - maxVisible()); + + const getPageLabel = pageIndex => { + if (props.getPageLabel) { + return props.getPageLabel(pageIndex); + } + return `Question ${pageIndex + 1}`; + }; + + // Build avatar URL from user data + const getAvatarUrl = user => { + if (user.image) { + // If it's a full URL, use it directly + if (user.image.startsWith('http')) { + return user.image; + } + // Otherwise, it's a path to our API + return `${API_BASE}/api/users/avatar/${user.userId}`; + } + return null; + }; + + return ( +
+
+ + {user => ( + + + + + + + {user.name} + + Viewing {getPageLabel(user.currentPage)} + + + + + )} + + + {/* Overflow indicator */} + 0}> + + +
+ +{overflowCount()} +
+
+ + + {overflowCount()} more {overflowCount() === 1 ? 'person' : 'people'} viewing + + +
+
+
+
+ ); +} diff --git a/packages/web/src/components/project/reconcile-tab/QuestionPresenceIndicator.jsx b/packages/web/src/components/project/reconcile-tab/QuestionPresenceIndicator.jsx new file mode 100644 index 000000000..3b0ed6d45 --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/QuestionPresenceIndicator.jsx @@ -0,0 +1,62 @@ +/** + * QuestionPresenceIndicator - Colored rings on question pills showing who's viewing + * + * Features: + * - Renders colored ring(s) around the parent element + * - Supports multiple users with stacked rings + * - Pulsing animation for visibility + */ + +import { For, Show } from 'solid-js'; + +/** + * @param {Object} props + * @param {Array} props.users - Array of users viewing this question with { userId, color, name } + * @param {number} [props.maxRings=2] - Maximum number of rings to show + * @param {string} [props.size='md'] - Ring size: 'sm', 'md', 'lg' + */ +export default function QuestionPresenceIndicator(props) { + const maxRings = () => props.maxRings ?? 2; + const visibleUsers = () => props.users.slice(0, maxRings()); + + // Ring offsets for stacking effect + const getOffset = index => { + const offsets = { + sm: 2, + md: 3, + lg: 4, + }; + const base = offsets[props.size || 'md'] || 3; + return index * base; + }; + + // Ring width - thicker for better visibility + const getRingWidth = () => { + const widths = { + sm: 2, + md: 3, + lg: 4, + }; + return widths[props.size || 'md'] || 3; + }; + + return ( + 0}> +
+ + {(user, index) => ( +
+ )} + +
+ + ); +} diff --git a/packages/web/src/components/project/reconcile-tab/ReconciliationWithPdf.jsx b/packages/web/src/components/project/reconcile-tab/ReconciliationWithPdf.jsx index 6c5463054..4608ed58b 100644 --- a/packages/web/src/components/project/reconcile-tab/ReconciliationWithPdf.jsx +++ b/packages/web/src/components/project/reconcile-tab/ReconciliationWithPdf.jsx @@ -1,15 +1,22 @@ /** * ReconciliationWithPdf - Wrapper that combines ChecklistReconciliation with a PDF viewer * in a split-screen layout. The PDF is read-only during reconciliation. + * + * Includes presence features: + * - Shows avatars of other users viewing the reconciliation + * - Shows colored rings on question pills indicating viewer positions + * - Shows remote mouse cursors in real-time */ -import { Show, createMemo, lazy, Suspense } from 'solid-js'; +import { Show, createMemo, createSignal, lazy, Suspense } from 'solid-js'; import { createStore } from 'solid-js/store'; import { FiArrowLeft } from 'solid-icons/fi'; import ChecklistReconciliation from './amstar2-reconcile/ChecklistReconciliation.jsx'; - import Navbar from './amstar2-reconcile/Navbar.jsx'; import SplitScreenLayout from '@/components/checklist/SplitScreenLayout.jsx'; +import { useReconciliationPresence } from '@primitives/useReconciliationPresence.js'; +import PresenceAvatars from './PresenceAvatars.jsx'; +import RemoteCursors from './RemoteCursors.jsx'; const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx')); @@ -33,9 +40,16 @@ const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx')); * @param {Function} props.onPdfSelect - Handler for PDF selection change * @param {Function} props.getQuestionNote - Function to get Y.Text for a question note (questionKey => Y.Text) * @param {Function} props.updateChecklistAnswer - Function to update a question answer (questionKey, questionData) + * @param {Function} props.getAwareness - Function to get Yjs awareness instance + * @param {Function} props.currentUser - Reactive getter for current user { id, name, image } + * @param {string} [props.checklistType='AMSTAR2'] - Checklist type for presence filtering * @returns {JSX.Element} */ export default function ReconciliationWithPdf(props) { + // Container ref for mouse cursor tracking + let containerRef; + const [containerScrollY, setContainerScrollY] = createSignal(0); + // Navbar store for deep reactivity - ChecklistReconciliation will update this const [navbarStore, setNavbarStore] = createStore({ questionKeys: [], @@ -51,10 +65,24 @@ export default function ReconciliationWithPdf(props) { onReset: null, }); + // Presence tracking + const presence = useReconciliationPresence({ + getAwareness: () => props.getAwareness?.(), + getCurrentPage: () => navbarStore.currentPage, + checklistType: () => props.checklistType || 'AMSTAR2', + currentUser: () => props.currentUser?.(), + containerRef: () => containerRef, + }); + + // Handle scroll updates for cursor position adjustment + function handleScroll(event) { + setContainerScrollY(event.target.scrollTop); + } + // Check if we have PDF to show (reactive) const hasPdf = createMemo(() => !!(props.pdfData || props.pdfLoading)); - // Build header content with back button, title, and navbar + // Build header content with back button, title, presence avatars, and navbar const headerContent = ( <> {/* Back button */} @@ -76,10 +104,22 @@ export default function ReconciliationWithPdf(props) {
+ {/* Presence avatars - show other users viewing this reconciliation */} + 0}> + { + navbarStore.goToQuestion?.(currentPage); + }} + getPageLabel={pageIndex => `Question ${pageIndex + 1}`} + /> +
+ + {/* Navbar - question navigation pills */} 0}>
- +
@@ -96,22 +136,30 @@ export default function ReconciliationWithPdf(props) { pdfUrl={props.pdfUrl} pdfData={props.pdfData} > - {/* First panel: Reconciliation view */} - - props.updateChecklistAnswer?.(questionKey, questionData) - } - /> + {/* First panel: Reconciliation view with cursor tracking */} +
+ {/* Remote cursors overlay */} + + + + props.updateChecklistAnswer?.(questionKey, questionData) + } + /> +
{/* Second panel: PDF Viewer (read-only) - only rendered when PDF exists */} diff --git a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx index a1230e615..d82a46c67 100644 --- a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx +++ b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx @@ -7,6 +7,7 @@ import { createSignal, createMemo, createEffect, Show } from 'solid-js'; import { useParams, useNavigate } from '@solidjs/router'; import { useProjectContext } from '@/components/project/ProjectContext.jsx'; import projectStore from '@/stores/projectStore.js'; +import { useBetterAuth } from '@api/better-auth-store.js'; import projectActionsStore from '@/stores/projectActionsStore'; import { ACCESS_DENIED_ERRORS } from '@/constants/errors.js'; import { CHECKLIST_STATUS } from '@/constants/checklist-status.js'; @@ -39,6 +40,9 @@ export default function ReconciliationWrapper() { const [error, setError] = createSignal(null); + // Get current user for presence + const { user } = useBetterAuth(); + // Destructure Y.js operations from parent ProjectView's connection const { createChecklist: createProjectChecklist, @@ -50,8 +54,20 @@ export default function ReconciliationWrapper() { getRobinsText, getRob2Text, saveReconciliationProgress, + getAwareness, } = projectOps || {}; + // Current user for presence features + const currentUser = createMemo(() => { + const u = user(); + if (!u) return null; + return { + id: u.id, + name: u.name || u.email || 'Unknown', + image: u.image, + }; + }); + // Set active project for action store createEffect(() => { const pid = params.projectId; @@ -494,6 +510,9 @@ export default function ReconciliationWrapper() { if (!id) return; updateChecklistAnswer(params.studyId, id, questionKey, questionData); }} + getAwareness={getAwareness} + currentUser={currentUser} + checklistType='AMSTAR2' /> } > @@ -527,6 +546,9 @@ export default function ReconciliationWrapper() { questionKey, ) } + getAwareness={getAwareness} + currentUser={currentUser} + checklistType='ROB2' /> } @@ -561,6 +583,9 @@ export default function ReconciliationWrapper() { questionKey, ) } + getAwareness={getAwareness} + currentUser={currentUser} + checklistType='ROBINS_I' /> diff --git a/packages/web/src/components/project/reconcile-tab/RemoteCursors.jsx b/packages/web/src/components/project/reconcile-tab/RemoteCursors.jsx new file mode 100644 index 000000000..6a3730fc1 --- /dev/null +++ b/packages/web/src/components/project/reconcile-tab/RemoteCursors.jsx @@ -0,0 +1,92 @@ +/** + * RemoteCursors - Floating cursor overlay showing other users' mouse positions + * + * Features: + * - Renders colored cursor arrows with name labels + * - Smooth position updates via CSS transforms + * - Fades out stale cursors + * - Handles scroll position differences + */ + +import { For } from 'solid-js'; + +/** + * Cursor icon SVG - Clean arrowhead pointer with rounded bottom + */ +function CursorIcon(props) { + return ( + + {/* Simple arrowhead */} + + + ); +} + +/** + * @param {Object} props + * @param {Array} props.users - Array of users with cursor data { userId, name, cursor: { x, y, scrollY, timestamp }, color } + * (already filtered by useReconciliationPresence for staleness) + * @param {number} props.containerScrollY - Current scroll position of the container + */ +export default function RemoteCursors(props) { + // Users are already filtered for staleness by useReconciliationPresence + const activeCursors = () => props.users || []; + + // Calculate adjusted Y position based on scroll difference + // cursor.y is the content position (viewport Y + scrollTop when recorded) + // To display: convert content position to viewport position by subtracting local scroll + const getAdjustedY = cursor => { + const localScroll = props.containerScrollY ?? 0; + // cursor.y is content position, subtract local scroll to get viewport position + return cursor.y - localScroll; + }; + + return ( +
+ + {user => { + const x = () => user.cursor.x; + const y = () => getAdjustedY(user.cursor); + + return ( +
+ {/* Cursor icon */} + + + {/* Name label */} +
+ {user.name} +
+
+ ); + }} +
+
+ ); +} diff --git a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/Navbar.jsx b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/Navbar.jsx index 94cb2594e..083a12581 100644 --- a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/Navbar.jsx +++ b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/Navbar.jsx @@ -8,6 +8,7 @@ import { TooltipContent, } from '@/components/ui/tooltip'; import { hasQuestionAnswer, getQuestionPillStyle, getQuestionTooltip } from './navbar-utils.js'; +import QuestionPresenceIndicator from '../QuestionPresenceIndicator.jsx'; /** * Navigation bar for checklist reconciliation @@ -24,12 +25,20 @@ import { hasQuestionAnswer, getQuestionPillStyle, getQuestionTooltip } from './n * - setViewMode: function to change view mode * - goToQuestion: function to go to a specific question * - onReset: function to reset all reconciliation answers + * - usersByPage: Map of page index to array of users viewing that page (for presence indicators) */ export default function Navbar(props) { return (