Social Features: Profile, Follow, Activity, Notifications, Chat & Presence + UI Overhaul#312
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR implements a major “social layer” expansion in the QuoteVote frontend (profiles, following, notifications, chat/presence, discussion UI) alongside a broad UI refresh and supporting infrastructure changes (Apollo v4 hook imports, new pages, and new dependencies).
Changes:
- Adds/updates dashboard pages and layout to support profile/notifications/search UX and a persistent chat/presence experience.
- Refactors multiple components for the new UI design system (PostCard/PostSkeleton, ProfileHeader/ProfileView, notifications list styling).
- Introduces new client-side functionality and dependencies (e.g., discussion tab using post chat rooms, mock feed fallback data, react-scrollable-feed, @tanstack/react-virtual).
Reviewed changes
Copilot reviewed 56 out of 58 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| quotevote-frontend/tsconfig.json | Excludes Storybook stories from TS compilation. |
| quotevote-frontend/src/types/react-scrollable-feed.d.ts | Updates local type stub comment after installing the package. |
| quotevote-frontend/src/lib/mock-data.ts | Adds large mock post dataset used as a feed fallback. |
| quotevote-frontend/src/hooks/useTypingIndicator.ts | Moves Apollo hook import to @apollo/client/react. |
| quotevote-frontend/src/hooks/useRosterManagement.ts | Migrates Apollo hook import and adjusts mutation result handling. |
| quotevote-frontend/src/hooks/usePresenceSubscription.ts | Moves Apollo hook import to @apollo/client/react. |
| quotevote-frontend/src/hooks/usePresenceHeartbeat.ts | Adds visibility pause/resume + import migration for heartbeat hook. |
| quotevote-frontend/src/graphql/queries.ts | Extends room messages query to include user fields. |
| quotevote-frontend/src/graphql/mutations.ts | Adds UPDATE_USER_AVATAR mutation. |
| quotevote-frontend/src/components/Sidebar/Sidebar.tsx | Replaces placeholder Chat/Notifications/SubmitPost with real components. |
| quotevote-frontend/src/components/settings/SettingsContent.tsx | Fixes avatar edit route under dashboard profile. |
| quotevote-frontend/src/components/SearchContainer/UsernameResults.tsx | Updates search dropdown styling and tokens. |
| quotevote-frontend/src/components/SearchContainer/SearchContainer.tsx | Adds mock feed fallback + search UI overhaul and tab styling changes. |
| quotevote-frontend/src/components/Profile/UserFollowDisplay.tsx | Updates profile link route to dashboard profile pages. |
| quotevote-frontend/src/components/Profile/ProfileView.tsx | Adds tabbed profile layout (Posts/Activity/About). |
| quotevote-frontend/src/components/Profile/ProfileHeader.tsx | Major profile header redesign and action controls. |
| quotevote-frontend/src/components/PostChat/PostChatReactions.tsx | Updates profile route navigation to dashboard profile. |
| quotevote-frontend/src/components/PostChat/PostChatMessage.tsx | Updates profile route navigation to dashboard profile. |
| quotevote-frontend/src/components/PostActions/PostActionCard.tsx | Updates profile route navigation to dashboard profile. |
| quotevote-frontend/src/components/Post/PostSkeleton.tsx | Redesigns skeleton to match new PostCard layout. |
| quotevote-frontend/src/components/Post/PostCard.tsx | PostCard UI redesign (author row + engagement bar + styling/token changes). |
| quotevote-frontend/src/components/Post/Post.tsx | Replaces stub auth dialog with RequestInviteDialog; updates profile routes. |
| quotevote-frontend/src/components/Notifications/NotificationLists.tsx | Notifications UI redesign and icon/text mapping updates. |
| quotevote-frontend/src/components/Navbars/MainNavBar.tsx | Replaces placeholder Chat/Notifications/SubmitPost with real components. |
| quotevote-frontend/src/components/ErrorBoundary.tsx | Updates production error tracking comment. |
| quotevote-frontend/src/components/CustomButtons/FollowButton.tsx | Adds guest guard + optimistic follow/unfollow behavior and loading disable. |
| quotevote-frontend/src/components/CustomButtons/BookmarkIconButton.tsx | Switches auth gating to shared useGuestGuard. |
| quotevote-frontend/src/components/Comment/Comment.tsx | Updates profile route navigation to dashboard profile. |
| quotevote-frontend/src/components/Chat/ChatMenu.tsx | Uses shared MobileDrawer instead of custom fullscreen overlay. |
| quotevote-frontend/src/components/Activity/PaginatedActivityList.tsx | Updates profile route navigation to dashboard profile. |
| quotevote-frontend/src/components/Activity/ActivityList.tsx | Updates profile route navigation to dashboard profile. |
| quotevote-frontend/src/app/globals.css | Updates background, typography, smoothing, selection, and scrolling styles. |
| quotevote-frontend/src/app/dashboard/search/page.tsx | Adds hero + quick action cards and removes SubHeader usage. |
| quotevote-frontend/src/app/dashboard/profile/[username]/ProfileUsernamePageContent.tsx | Adds client wrapper for profile route rendering. |
| quotevote-frontend/src/app/dashboard/profile/[username]/page.tsx | Adds generateMetadata + route refactor to server component. |
| quotevote-frontend/src/app/dashboard/profile/[username]/avatar/page.tsx | Implements full avatar editor UI + mutation/store updates. |
| quotevote-frontend/src/app/dashboard/post/[group]/[title]/[postId]/page.tsx | Adds tabbed Comments/Discussion, creates/reuses post chat room, polls messages. |
| quotevote-frontend/src/app/dashboard/notifications/page.tsx | Replaces placeholder with real notifications page content + metadata. |
| quotevote-frontend/src/app/dashboard/notifications/NotificationsPageContent.tsx | Implements notifications list view with real-time subscription refetch. |
| quotevote-frontend/src/app/dashboard/layout.tsx | Major dashboard layout overhaul (navbars, chat sheet, real-time hooks, logout). |
| quotevote-frontend/src/tests/hooks/useTypingIndicator.test.ts | Updates Apollo hook import/mock path. |
| quotevote-frontend/src/tests/hooks/useRosterManagement.test.ts | Updates Apollo hook import/mock path. |
| quotevote-frontend/src/tests/hooks/usePresenceSubscription.test.ts | Updates Apollo hook import/mock path. |
| quotevote-frontend/src/tests/hooks/usePresenceHeartbeat.test.ts | Updates Apollo hook import/mock path. |
| quotevote-frontend/src/tests/components/settings/SettingsContent.test.tsx | Updates expected avatar route. |
| quotevote-frontend/src/tests/components/SearchContainer/NewSearchContainer.test.tsx | Updates placeholder text + empty state expectations. |
| quotevote-frontend/src/tests/components/Profile/UserFollowDisplay.test.tsx | Updates expected profile link route. |
| quotevote-frontend/src/tests/components/Profile/ProfileView.test.tsx | Updates tests for tabbed profile layout. |
| quotevote-frontend/src/tests/components/Profile/ProfileHeader.test.tsx | Updates tests for redesigned header and routes. |
| quotevote-frontend/src/tests/components/Profile/FollowButton.test.tsx | Adds tests for optimistic follow/unfollow behavior. |
| quotevote-frontend/src/tests/components/PostChat/PostChatMessage.test.tsx | Updates expected profile route. |
| quotevote-frontend/src/tests/components/PostActions/PostActionCard.test.tsx | Updates expected profile route. |
| quotevote-frontend/src/tests/components/Post/PostSkeleton.test.tsx | Updates assertions for redesigned skeleton layout. |
| quotevote-frontend/src/tests/app/dashboard/search/page.test.tsx | Updates tests for new hero/quick actions layout. |
| quotevote-frontend/src/tests/app/dashboard/notifications/page.test.tsx | Adds notifications page test for new content wrapper. |
| quotevote-frontend/src/tests/app/dashboard/notifications/NotificationsPageContent.test.tsx | Adds tests for loading/data/empty states of notifications content. |
| quotevote-frontend/pnpm-lock.yaml | Adds lock entries for new dependencies. |
| quotevote-frontend/package.json | Adds react-scrollable-feed and @tanstack/react-virtual. |
Files not reviewed (1)
- quotevote-frontend/pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)
quotevote-frontend/src/components/Post/PostCard.tsx:55
getCardBgClassandactivityTypeare now unused, and thevoidstatements are a code smell that won’t preserve any behavior for “activity feed consumers” (the function isn’t exported). Either reapply the activityType styling to the Card (as before) or remove the dead code to avoid confusion and unnecessary bundle size.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let timer: ReturnType<typeof setInterval> | null = null | ||
|
|
||
| // Set up interval for periodic heartbeats | ||
| const timer = setInterval(() => { | ||
| const startHeartbeat = () => { | ||
| sendHeartbeat() | ||
| }, interval) | ||
| timer = setInterval(() => { | ||
| sendHeartbeat() | ||
| }, interval) | ||
| } | ||
|
|
||
| const stopHeartbeat = () => { | ||
| if (timer) { | ||
| clearInterval(timer) | ||
| timer = null | ||
| } | ||
| } | ||
|
|
||
| // Cleanup on unmount | ||
| return () => clearInterval(timer) | ||
| const handleVisibilityChange = () => { | ||
| if (document.hidden) { | ||
| stopHeartbeat() | ||
| } else { | ||
| startHeartbeat() | ||
| } | ||
| } |
There was a problem hiding this comment.
usePresenceHeartbeat can start multiple intervals: handleVisibilityChange calls startHeartbeat() without checking whether an interval is already running, so repeated “visible” events (or effect re-runs) can create duplicate timers and extra heartbeat mutations. Consider guarding startHeartbeat with if (timer) return; and also clearing any scheduled retry timeouts on cleanup to avoid background retries after stop/unmount.
| export const usePresenceHeartbeat = (interval: number = 45000): UsePresenceHeartbeatReturn => { | ||
| const [heartbeat, { error }] = useMutation(HEARTBEAT) | ||
| const retryCountRef = useRef<number>(0) | ||
| const maxRetries = 3 | ||
| const backoffMultiplier = 2 | ||
|
|
||
| useEffect(() => { | ||
| // Exponential backoff for retries | ||
| const getRetryDelay = (attempt: number): number => { | ||
| return Math.min(interval * Math.pow(backoffMultiplier, attempt), 300000) // Max 5 minutes | ||
| return Math.min(interval * Math.pow(backoffMultiplier, attempt), 300000) | ||
| } | ||
|
|
||
| const sendHeartbeat = async (): Promise<void> => { | ||
| try { | ||
| await heartbeat() | ||
| // Reset retry count on success | ||
| retryCountRef.current = 0 | ||
| } catch { |
There was a problem hiding this comment.
This hook sends heartbeats unconditionally (no auth/user check). Since DashboardLayout now mounts it globally, guests or logged-out users may trigger repeated UNAUTHENTICATED errors/retries and potentially redirect loops (Apollo error link) or unnecessary load. Gate heartbeat startup on a valid authenticated user (e.g., token/user id) or mount the hook only when logged in.
| const addBuddy = async (buddyId: string): Promise<RosterMutationResult> => { | ||
| try { | ||
| const result = await addBuddyMutation({ | ||
| variables: { roster: { buddyId } }, | ||
| }) | ||
| addPendingRequest(result.data.addBuddy) | ||
| return result.data.addBuddy | ||
| const data = result.data as RosterMutationData | ||
| addPendingRequest(data.addBuddy) | ||
| return data.addBuddy |
There was a problem hiding this comment.
Casting result.data to a broad RosterMutationData is unsafe and inaccurate: each mutation response typically only contains one field, and result.data can be undefined. This can lead to runtime errors when accessing data.addBuddy/etc. Prefer typing each useMutation with its specific result shape and use optional chaining with an explicit error when the expected field is missing.
| // Optimistic local state update | ||
| let newFollowingArray: string[]; | ||
| if (action === 'un-follow') { | ||
| newFollowingArray = followingArray.filter((id) => id !== profileUserId); | ||
| } else { | ||
| newFollowingArray = [...followingArray, profileUserId]; | ||
| } | ||
|
|
||
| // Update following array - store expects a single string or array | ||
| // For now, we'll update the store with the first ID or empty string | ||
| // TODO: Update store interface to accept string[] if needed | ||
| if (newFollowingArray.length > 0) { | ||
| updateFollowing(newFollowingArray[0]); | ||
| } else { | ||
| updateFollowing(''); | ||
| } | ||
| await followMutation({ variables: { user_id: profileUserId, action } }); | ||
|
|
||
| try { | ||
| await followMutation({ variables: { user_id: profileUserId, action } }); | ||
| } catch { | ||
| // Revert on error | ||
| if (action === 'un-follow') { | ||
| updateFollowing(profileUserId); | ||
| } else { | ||
| const reverted = followingArray.filter((id) => id !== profileUserId); | ||
| updateFollowing(reverted.length > 0 ? reverted[0] : ''); | ||
| } |
There was a problem hiding this comment.
The optimistic update logic doesn’t actually add/remove the target user reliably because updateFollowing in the store only accepts a single string and you’re always writing newFollowingArray[0]. If the user already follows anyone, a new follow won’t update local state at all, and the revert path for un-follow sets _followingId to the profileUserId (likely incorrect). Either update the store to support string[] for _followingId, or remove the array manipulation and rely solely on refetch/cache updates for correctness.
| function DashboardClient() { | ||
| usePresenceHeartbeat(); | ||
| usePresenceSubscription(); | ||
| useRosterManagement(); | ||
| return null; | ||
| } | ||
|
|
There was a problem hiding this comment.
DashboardClient mounts presence/roster hooks unconditionally. Given usePresenceHeartbeat currently sends mutations without an auth gate (and ChatContent also calls usePresenceHeartbeat), this can result in duplicate intervals and unnecessary network traffic or UNAUTHENTICATED retries. Consider mounting these real-time hooks only when loggedIn is true and ensuring they’re initialized in a single place.
| function DashboardClient() { | |
| usePresenceHeartbeat(); | |
| usePresenceSubscription(); | |
| useRosterManagement(); | |
| return null; | |
| } | |
| function DashboardClientInner() { | |
| usePresenceHeartbeat(); | |
| usePresenceSubscription(); | |
| useRosterManagement(); | |
| return null; | |
| } | |
| function DashboardClient() { | |
| const loggedIn = useAppStore((s) => s.loggedIn); | |
| if (!loggedIn) { | |
| return null; | |
| } | |
| return <DashboardClientInner />; | |
| } |
| interface Props { | ||
| params: Promise<{ username: string }>; | ||
| } | ||
|
|
||
| export async function generateMetadata({ params }: Props): Promise<Metadata> { | ||
| const { username } = await params; | ||
| return { | ||
| title: `@${username} — Quote.Vote`, | ||
| description: `View ${username}'s profile on Quote.Vote`, | ||
| }; | ||
| } | ||
|
|
||
| import { ProfileController } from '@/components/Profile/ProfileController'; | ||
| export const dynamic = 'force-dynamic'; | ||
|
|
||
| export default function ProfileUsernamePage(): React.ReactNode { | ||
| return <ProfileController />; | ||
| export default async function ProfileByUsernamePage({ params }: Props) { | ||
| const { username } = await params; | ||
| return <ProfileUsernamePage username={username} />; |
There was a problem hiding this comment.
The profile route types params as a Promise and then awaits it. In Next.js (next@16.1.1), params is a plain object; typing it as a Promise can break type-safety and may hide real runtime issues. Use params: { username: string } and remove the await in both generateMetadata and the page component.
| interface Props { | ||
| username: string; | ||
| } | ||
|
|
||
| export function ProfileUsernamePage({ username: _username }: Props) { |
There was a problem hiding this comment.
ProfileUsernamePage receives a username prop but deliberately ignores it and relies on ProfileController reading from useParams. This is confusing and makes the component harder to reuse/test. Either pass the username through to ProfileController (and have it accept an explicit username) or remove the unused prop/component wrapper entirely.
| interface Props { | |
| username: string; | |
| } | |
| export function ProfileUsernamePage({ username: _username }: Props) { | |
| export function ProfileUsernamePage() { |
| router.push(`/dashboard/profile/${username}`); | ||
| } catch (err) { | ||
| const message = | ||
| err instanceof Error ? err.message : 'Failed to update avatar.'; | ||
| toast.error(message); | ||
| } | ||
| }, [userId, avatar, updateUserAvatar, updateStoreAvatar, router, username]); | ||
|
|
||
| const handleBack = useCallback(() => { | ||
| router.push(`/dashboard/profile/${username}`); | ||
| }, [router, username]); |
There was a problem hiding this comment.
This page relies on the [username] route param for navigation (router.push(/dashboard/profile/${username})), but the actual mutation updates the currently logged-in user (userId from the store). If someone visits /dashboard/profile/otheruser/avatar, they’ll update their own avatar but be redirected to the other user’s profile, which is confusing and could be abused for misleading navigation. Consider validating that params.username matches the logged-in user’s username (or derive the redirect path from the updated user/store).
| router.push(`/dashboard/profile/${username}`); | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : 'Failed to update avatar.'; | |
| toast.error(message); | |
| } | |
| }, [userId, avatar, updateUserAvatar, updateStoreAvatar, router, username]); | |
| const handleBack = useCallback(() => { | |
| router.push(`/dashboard/profile/${username}`); | |
| }, [router, username]); | |
| router.back(); | |
| } catch (err) { | |
| const message = | |
| err instanceof Error ? err.message : 'Failed to update avatar.'; | |
| toast.error(message); | |
| } | |
| }, [userId, avatar, updateUserAvatar, updateStoreAvatar, router]); | |
| const handleBack = useCallback(() => { | |
| router.back(); | |
| }, [router]); |
| export function NotificationsPageContent() { | ||
| const userId = useAppStore((state) => state.user.data.id); | ||
|
|
||
| const { loading, data, refetch, error } = useQuery(GET_NOTIFICATIONS, { | ||
| skip: !userId, | ||
| }); | ||
|
|
||
| useSubscription(NEW_NOTIFICATION_SUBSCRIPTION, { | ||
| variables: { userId: userId || '' }, | ||
| skip: !userId, | ||
| onData: async () => { |
There was a problem hiding this comment.
NotificationsPageContent reads state.user.data.id only. Elsewhere the code frequently treats user identifiers as _id || id; if the store is populated with only _id, notifications and the subscription will never run. Consider using _id || id here for consistency and reliability.
| return ( | ||
| <div | ||
| className={cn( | ||
| 'bg-[var(--color-white)] relative overflow-auto', | ||
| pageView ? 'w-full' : 'w-[350px]', | ||
| notifications.length < 5 ? 'min-h-0' : 'h-[75vh]' | ||
| 'relative overflow-auto', | ||
| pageView ? 'w-full' : 'w-full max-w-sm', | ||
| notifications.length < 5 ? 'min-h-0' : 'max-h-[75vh]' | ||
| )} | ||
| > | ||
| <ul className="divide-y divide-[var(--color-gray-light)]"> | ||
| <div className="space-y-2"> | ||
| {notifications.map((notification) => { | ||
| const badgeIcon = getBadgeIcon(notification.notificationType); | ||
| const avatarUrl = | ||
| notification.userBy.avatar?.url || | ||
| (typeof notification.userBy.avatar === 'string' | ||
| ? notification.userBy.avatar | ||
| : undefined); | ||
|
|
||
| const actionText = getNotificationActionText(notification.notificationType); | ||
| const icon = getNotificationIcon(notification.notificationType); | ||
| const displayName = notification.userBy.name || notification.userBy.username; | ||
|
|
||
| return ( | ||
| <li | ||
| <div | ||
| key={notification._id} | ||
| className="flex items-start gap-3 p-3 hover:bg-[var(--color-gray-light)] transition-colors cursor-pointer group" | ||
| className="bg-card rounded-lg p-4 border border-border hover:bg-accent/50 transition-colors cursor-pointer group" | ||
| onClick={() => | ||
| handleNotificationClick( |
There was a problem hiding this comment.
The notifications container was changed from semantic ul/li markup to plain divs. This can reduce accessibility (screen readers lose list semantics) and makes keyboard navigation expectations less clear. Consider keeping ul/li (or adding appropriate ARIA roles) while styling the cards the same way.
Social Features
ReputationDisplay,
generateMetadatafor SEOActivityEmptyList, PaginatedActivityList
NEW_NOTIFICATION_SUBSCRIPTIONreact-scrollable-feed, MessageSend,TypingIndicator, message reactions, PostChat (discussion tab on post detail)
dialog
usePresenceHeartbeatwithvisibilitychangepause/resume + exponential backoff retryUI Overhaul
#FAFBFC)hover lift animation
navbar)
Code Quality
@apollo/client→@apollo/client/react)@ts-expect-errordirectives for Apollo importsuseGuestGuardhookRequestInviteDialogMobileDrawer(Sheet) in ChatMenu instead of raw fullscreen divreact-scrollable-feedpackageTest Plan
pnpm type-check→ 0 errorspnpm lint→ 0 errorscloses #311