manifold/web/components/groups/group-chat.tsx
Marshall Polaris 3cb28cdecb
Teach AuthContext to manage the private user doc (#738)
* Return both user and privateUser from `createuser`

* Make `useStateCheckEquality` more flexible

* Make `AuthContext` track the private user doc

* Change `usePrivateUser` hook to use the auth context data

* Pass both user and private user through SSR to auth context

* Fix bug in create user flow
2022-08-12 13:41:00 -07:00

386 lines
12 KiB
TypeScript

import { Row } from 'web/components/layout/row'
import { Col } from 'web/components/layout/col'
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'
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
import { CommentInputTextArea } from 'web/components/feed/feed-comments'
import { track } from 'web/lib/service/analytics'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { UserLink } from 'web/components/user-page'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
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 { Content, useTextEditor } from 'web/components/editor'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
import { setNotificationsAsSeen } from 'web/pages/notifications'
import { usePrivateUser } from 'web/hooks/use-user'
export function GroupChat(props: {
messages: Comment[]
user: User | null | undefined
group: Group
tips: CommentTipMap
}) {
const { messages, user, group, tips } = props
const privateUser = usePrivateUser()
const { editor, upload } = useTextEditor({
simple: true,
placeholder: 'Send a message',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const [scrollToBottomRef, setScrollToBottomRef] =
useState<HTMLDivElement | null>(null)
const [scrollToMessageId, setScrollToMessageId] = useState('')
const [scrollToMessageRef, setScrollToMessageRef] =
useState<HTMLDivElement | null>(null)
const [replyToUser, setReplyToUser] = useState<any>()
const router = useRouter()
const isMember = user && group.memberIds.includes(user?.id)
const { width, height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
// Subtract bottom bar when it's showing (less than lg screen)
const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0
const remainingHeight =
(height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight
// array of groups, where each group is an array of messages that are displayed as one
const groupedMessages = useMemo(() => {
// Group messages with createdTime within 2 minutes of each other.
const tempGrouped: Comment[][] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (i === 0) tempGrouped.push([message])
else {
const prevMessage = messages[i - 1]
const diff = message.createdTime - prevMessage.createdTime
const creatorsMatch = message.userId === prevMessage.userId
if (diff < 2 * 60 * 1000 && creatorsMatch) {
tempGrouped.at(-1)?.push(message)
} else {
tempGrouped.push([message])
}
}
}
return tempGrouped
}, [messages])
useEffect(() => {
scrollToMessageRef?.scrollIntoView()
}, [scrollToMessageRef])
useEffect(() => {
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]
if (messages.map((m) => m.id).includes(elementInUrl)) {
setScrollToMessageId(elementInUrl)
}
}, [messages, router.asPath])
useEffect(() => {
// is mobile?
if (width && width > 720) focusInput()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [width])
function onReplyClick(comment: Comment) {
setReplyToUser({ id: comment.userId, username: comment.userUsername })
}
async function submitMessage() {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnGroup(group.id, editor.getJSON(), user)
editor.commands.clearContent()
setIsSubmitting(false)
setReplyToUser(undefined)
focusInput()
}
function focusInput() {
editor?.commands.focus()
}
return (
<Col ref={setContainerRef} style={{ height: remainingHeight }}>
<Col
className={
'w-full flex-1 space-y-2 overflow-x-hidden overflow-y-scroll pt-2'
}
ref={setScrollToBottomRef}
>
{groupedMessages.map((messages) => (
<GroupMessage
user={user}
key={`group ${messages[0].id}`}
comments={messages}
group={group}
onReplyClick={onReplyClick}
highlight={messages[0].id === scrollToMessageId}
setRef={
scrollToMessageId === messages[0].id
? setScrollToMessageRef
: undefined
}
tips={tips[messages[0].id] ?? {}}
/>
))}
{messages.length === 0 && (
<div className="p-2 text-gray-500">
No messages yet. Why not{isMember ? ` ` : ' join and '}
<button
className={'cursor-pointer font-bold text-gray-700'}
onClick={focusInput}
>
add one?
</button>
</div>
)}
</Col>
{user && group.memberIds.includes(user.id) && (
<div className="flex w-full justify-start gap-2 p-2">
<div className="mt-1">
<Avatar
username={user?.username}
avatarUrl={user?.avatarUrl}
size={'sm'}
/>
</div>
<div className={'flex-1'}>
<CommentInputTextArea
editor={editor}
upload={upload}
user={user}
replyToUser={replyToUser}
submitComment={submitMessage}
isSubmitting={isSubmitting}
submitOnEnter
/>
</div>
</div>
)}
{privateUser && (
<GroupChatNotificationsIcon
group={group}
privateUser={privateUser}
shouldSetAsSeen={true}
hidden={true}
/>
)}
</Col>
)
}
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 (
<Col
className={clsx(
'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4',
shouldShowChat ? 'p-2m z-10 h-screen bg-white' : ''
)}
>
{shouldShowChat && (
<GroupChat messages={messages} user={user} group={group} tips={tips} />
)}
<button
type="button"
className={clsx(
'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' +
' border-transparent p-3 text-white shadow-sm lg:p-4' +
' focus:outline-none focus:ring-2 focus:ring-offset-2 ' +
' bottom-[70px] ',
shouldShowChat
? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto '
: ' bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500'
)}
onClick={() => {
// router.push('/chat')
setShouldShowChat(!shouldShowChat)
track('mobile group chat button')
}}
>
{!shouldShowChat ? (
<UsersIcon className="h-10 w-10" aria-hidden="true" />
) : (
<ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} />
)}
{privateUser && (
<GroupChatNotificationsIcon
group={group}
privateUser={privateUser}
shouldSetAsSeen={shouldShowChat}
hidden={false}
/>
)}
</button>
</Col>
)
}
function GroupChatNotificationsIcon(props: {
group: Group
privateUser: PrivateUser
shouldSetAsSeen: boolean
hidden: boolean
}) {
const { privateUser, group, shouldSetAsSeen, hidden } = 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 (
<div
className={
!hidden &&
preferredNotificationsForThisGroup.length > 0 &&
!shouldSetAsSeen
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
: 'hidden'
}
></div>
)
}
const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined
comments: Comment[]
group: Group
onReplyClick?: (comment: Comment) => void
setRef?: (ref: HTMLDivElement) => void
highlight?: boolean
tips: CommentTips
}) {
const { comments, onReplyClick, group, setRef, highlight, user, tips } = props
const first = comments[0]
const { id, userUsername, userName, userAvatarUrl, createdTime } = first
const isCreatorsComment = user && first.userId === user.id
return (
<Col
ref={setRef}
className={clsx(
isCreatorsComment ? 'mr-2 self-end' : '',
'w-fit max-w-sm gap-1 space-x-3 rounded-md bg-white p-1 text-sm text-gray-500 transition-colors duration-1000 sm:max-w-md sm:p-3 sm:leading-[1.3rem]',
highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : ''
)}
>
<Row className={'items-center'}>
{!isCreatorsComment && (
<Col>
<Avatar
className={'mx-2 ml-2.5'}
size={'xs'}
username={userUsername}
avatarUrl={userAvatarUrl}
/>
</Col>
)}
{!isCreatorsComment ? (
<UserLink username={userUsername} name={userName} />
) : (
<span className={'ml-2.5'}>{'You'}</span>
)}
<CopyLinkDateTimeComponent
prefix={'group'}
slug={group.slug}
createdTime={createdTime}
elementId={id}
/>
</Row>
<div className="mt-2 text-black">
{comments.map((comment) => (
<Content
key={comment.id}
content={comment.content || comment.text}
smallImage
/>
))}
</div>
<Row>
{!isCreatorsComment && onReplyClick && (
<button
className={
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => onReplyClick(first)}
>
Reply
</button>
)}
{isCreatorsComment && sum(Object.values(tips)) > 0 && (
<span className={'text-primary'}>
{formatMoney(sum(Object.values(tips)))}
</span>
)}
{!isCreatorsComment && <Tipper comment={first} tips={tips} />}
</Row>
</Col>
)
})