From 82419d0b9272a57dd107cc4ee6949ef1d1d540c0 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 3 Aug 2022 15:38:35 -0600 Subject: [PATCH] Groups chat ux (#713) * Add in group chat bubble * Show chat bubble on nav with unseen notifs * Spacing * More spacing * Remove chat tab * Show chat on help/welcome/updates/features groups * Cleanup * Scroll with updated height --- web/components/groups/group-chat.tsx | 136 ++++++++++++++++++++++++--- web/components/nav/sidebar.tsx | 63 ++++++------- web/lib/firebase/comments.ts | 9 ++ web/pages/group/[...slugs]/index.tsx | 54 +++++------ 4 files changed, 184 insertions(+), 78 deletions(-) diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 2cf2d73d..b9b2b3ff 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -1,6 +1,6 @@ import { Row } from 'web/components/layout/row' import { Col } from 'web/components/layout/col' -import { User } from 'common/user' +import { PrivateUser, User } from 'common/user' import React, { useEffect, memo, useState, useMemo } from 'react' import { Avatar } from 'web/components/avatar' import { Group } from 'common/group' @@ -23,6 +23,9 @@ import { Tipper } from 'web/components/tipper' import { sum } from 'lodash' import { formatMoney } from 'common/util/format' import { useWindowSize } from 'web/hooks/use-window-size' +import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' +import { ChatIcon, ChevronDownIcon } from '@heroicons/react/outline' +import { setNotificationsAsSeen } from 'web/pages/notifications' export function GroupChat(props: { messages: Comment[] @@ -70,9 +73,10 @@ export function GroupChat(props: { }, [scrollToMessageRef]) useEffect(() => { - if (!isSubmitting) - scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 }) - }, [scrollToBottomRef, isSubmitting]) + if (scrollToBottomRef) + scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 }) + // Must also listen to groupedMessages as they update the height of the messaging window + }, [scrollToBottomRef, groupedMessages]) useEffect(() => { const elementInUrl = router.asPath.split('#')[1] @@ -81,6 +85,10 @@ export function GroupChat(props: { } }, [messages, router.asPath]) + useEffect(() => { + if (inputRef) inputRef.focus() + }, [inputRef]) + function onReplyClick(comment: Comment) { setReplyToUsername(comment.userUsername) } @@ -98,18 +106,13 @@ export function GroupChat(props: { setReplyToUsername('') inputRef?.focus() } - function focusInput() { - inputRef?.focus() - } const { width, height } = useWindowSize() const [containerRef, setContainerRef] = useState(null) // Subtract bottom bar when it's showing (less than lg screen) const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0 const remainingHeight = - (height ?? window.innerHeight) - - (containerRef?.offsetTop ?? 0) - - bottomBarHeight + (height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight return ( @@ -140,7 +143,7 @@ export function GroupChat(props: { No messages yet. Why not{isMember ? ` ` : ' join and '} @@ -175,6 +178,117 @@ export function GroupChat(props: { ) } +export function GroupChatInBubble(props: { + messages: Comment[] + user: User | null | undefined + privateUser: PrivateUser | null | undefined + group: Group + tips: CommentTipMap +}) { + const { messages, user, group, tips, privateUser } = props + const [shouldShowChat, setShouldShowChat] = useState(false) + const router = useRouter() + + useEffect(() => { + const groupsWithChatEmphasis = [ + 'welcome', + 'bugs', + 'manifold-features-25bad7c7792e', + 'updates', + ] + if ( + router.asPath.includes('/chat') || + groupsWithChatEmphasis.includes( + router.asPath.split('/group/')[1].split('/')[0] + ) + ) { + setShouldShowChat(true) + } + // Leave chat open between groups if user is using chat? + else { + setShouldShowChat(false) + } + }, [router.asPath]) + + return ( + + {shouldShowChat && ( + + )} + + + ) +} + +function GroupChatNotificationsIcon(props: { + group: Group + privateUser: PrivateUser + shouldSetAsSeen: boolean +}) { + const { privateUser, group, shouldSetAsSeen } = props + const preferredNotificationsForThisGroup = useUnseenPreferredNotifications( + privateUser, + { + customHref: `/group/${group.slug}`, + } + ) + useEffect(() => { + preferredNotificationsForThisGroup.forEach((notification) => { + if ( + (shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) || + // old style chat notif that simply ended with the group slug + notification.isSeenOnHref?.endsWith(group.slug) + ) { + setNotificationsAsSeen([notification]) + } + }) + }, [group.slug, preferredNotificationsForThisGroup, shouldSetAsSeen]) + + return ( +
0 && !shouldSetAsSeen + ? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500' + : 'hidden' + } + >
+ ) +} + const GroupMessage = memo(function GroupMessage_(props: { user: User | null | undefined comment: Comment diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 581dd5fa..de9fd1ba 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' -import React, { useEffect, useState } from 'react' +import React, { useMemo, useState } from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' @@ -27,7 +27,6 @@ import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Spacer } from '../layout/spacer' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' -import { setNotificationsAsSeen } from 'web/pages/notifications' import { PrivateUser } from 'common/user' import { useWindowSize } from 'web/hooks/use-window-size' @@ -216,7 +215,7 @@ export default function Sidebar(props: { className?: string }) { ) ?? [] ).map((group: Group) => ({ name: group.name, - href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`, + href: `${groupPath(group.slug)}`, })) return ( @@ -294,30 +293,22 @@ function GroupsList(props: { memberItems.length > 0 ? memberItems.length : undefined ) - // Set notification as seen if our current page is equal to the isSeenOnHref property - useEffect(() => { - const currentPageWithoutQuery = currentPage.split('?')[0] - const currentPageGroupSlug = currentPageWithoutQuery.split('/')[2] - preferredNotifications.forEach((notification) => { - if ( - notification.isSeenOnHref === currentPage || - // Old chat style group chat notif was just /group/slug - (notification.isSeenOnHref && - currentPageWithoutQuery.includes(notification.isSeenOnHref)) || - // They're on the home page, so if they've a chat notif, they're seeing the chat - (notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) && - currentPageWithoutQuery.endsWith(currentPageGroupSlug)) - ) { - setNotificationsAsSeen([notification]) - } - }) - }, [currentPage, preferredNotifications]) - const { height } = useWindowSize() const [containerRef, setContainerRef] = useState(null) const remainingHeight = (height ?? window.innerHeight) - (containerRef?.offsetTop ?? 0) + const notifIsForThisItem = useMemo( + () => (itemHref: string) => + preferredNotifications.some( + (n) => + !n.isSeen && + (n.isSeenOnHref === itemHref || + n.isSeenOnHref?.replace('/chat', '') === itemHref) + ), + [preferredNotifications] + ) + return ( <> {memberItems.map((item) => ( - - !n.isSeen && - (n.isSeenOnHref === item.href || - n.isSeenOnHref === item.href.replace('/chat', '')) - ) && 'font-bold' - )} > - {item.name} - + + {item.name} + + ))} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index 3093f764..5775a2bb 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -81,6 +81,7 @@ export async function createCommentOnGroup( function getCommentsCollection(contractId: string) { return collection(db, 'contracts', contractId, 'comments') } + function getCommentsOnGroupCollection(groupId: string) { return collection(db, 'groups', groupId, 'comments') } @@ -91,6 +92,14 @@ export async function listAllComments(contractId: string) { return comments } +export async function listAllCommentsOnGroup(groupId: string) { + const comments = await getValues( + getCommentsOnGroupCollection(groupId) + ) + comments.sort((c1, c2) => c1.createdTime - c2.createdTime) + return comments +} + export function listenForCommentsOnContract( contractId: string, setComments: (comments: Comment[]) => void diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 5c52c7dc..642a2afd 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -16,7 +16,7 @@ import { Row } from 'web/components/layout/row' import { UserLink } from 'web/components/user-page' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { listMembers, useGroup, useMembers } from 'web/hooks/use-group' import { useRouter } from 'next/router' import { scoreCreators, scoreTraders } from 'common/scoring' @@ -30,7 +30,7 @@ import { fromPropz, usePropz } from 'web/hooks/use-propz' import { Tabs } from 'web/components/layout/tabs' import { CreateQuestionButton } from 'web/components/create-question-button' import React, { useState } from 'react' -import { GroupChat } from 'web/components/groups/group-chat' +import { GroupChatInBubble } from 'web/components/groups/group-chat' import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { getSavedSort } from 'web/hooks/use-sort-and-query-params' @@ -45,11 +45,12 @@ import { SearchIcon } from '@heroicons/react/outline' import { useTipTxns } from 'web/hooks/use-tip-txns' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' import { searchInAny } from 'common/util/parse' -import { useWindowSize } from 'web/hooks/use-window-size' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' import { Button } from 'web/components/button' +import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' +import { Comment } from 'common/comment' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { params: { slugs: string[] } }) { @@ -65,6 +66,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const bets = await Promise.all( contracts.map((contract: Contract) => listAllBets(contract.id)) ) + const messages = group && (await listAllCommentsOnGroup(group.id)) const creatorScores = scoreCreators(contracts) const traderScores = scoreTraders(contracts, bets) @@ -86,6 +88,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { topTraders, creatorScores, topCreators, + messages, }, revalidate: 60, // regenerate after a minute @@ -123,6 +126,7 @@ export default function GroupPage(props: { topTraders: User[] creatorScores: { [userId: string]: number } topCreators: User[] + messages: Comment[] }) { props = usePropz(props, getStaticPropz) ?? { group: null, @@ -132,6 +136,7 @@ export default function GroupPage(props: { topTraders: [], creatorScores: {}, topCreators: [], + messages: [], } const { creator, @@ -149,19 +154,18 @@ export default function GroupPage(props: { const group = useGroup(props.group?.id) ?? props.group const tips = useTipTxns({ groupId: group?.id }) - const messages = useCommentsOnGroup(group?.id) + const messages = useCommentsOnGroup(group?.id) ?? props.messages const user = useUser() + const privateUser = usePrivateUser(user?.id) useSaveReferral(user, { defaultReferrer: creator.username, groupId: group?.id, }) - const { width } = useWindowSize() const chatDisabled = !group || group.chatDisabled - const showChatSidebar = !chatDisabled && (width ?? 1280) >= 1280 - const showChatTab = !chatDisabled && !showChatSidebar + const showChatBubble = !chatDisabled if (group === null || !groupSubpages.includes(page) || slugs[2]) { return @@ -195,16 +199,6 @@ export default function GroupPage(props: { ) - const chatTab = ( - - {messages ? ( - - ) : ( - - )} - - ) - const questionsTab = ( t.title).indexOf(page ?? GROUP_CHAT_SLUG) return ( - + - +
0 ? tabIndex : 0} tabs={tabs} /> + {showChatBubble && ( + + )} ) }