Various notifications bugfixes/improvements (#442)

* Various notifications bugfixes/improvements

* eslint
This commit is contained in:
Ian Philips 2022-06-06 16:15:36 -06:00 committed by GitHub
parent 849e7d03a8
commit a7a482eecd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 253 additions and 190 deletions

View File

@ -4,11 +4,13 @@ import { Row } from 'web/components/layout/row'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useNotifications } from 'web/hooks/use-notifications' import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications'
export default function NotificationsIcon(props: { className?: string }) { export default function NotificationsIcon(props: { className?: string }) {
const user = useUser() const user = useUser()
const notifications = useNotifications(user?.id, { unseenOnly: true }) const notifications = usePreferredGroupedNotifications(user?.id, {
unseenOnly: true,
})
const [seen, setSeen] = useState(false) const [seen, setSeen] = useState(false)
const router = useRouter() const router = useRouter()
@ -21,7 +23,9 @@ export default function NotificationsIcon(props: { className?: string }) {
<Row className={clsx('justify-center')}> <Row className={clsx('justify-center')}>
<div className={'relative'}> <div className={'relative'}>
{!seen && notifications && notifications.length > 0 && ( {!seen && notifications && notifications.length > 0 && (
<div className="absolute mt-0.5 ml-3.5 min-h-[10px] min-w-[10px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-0 lg:ml-2.5"></div> <div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
{notifications.length}
</div>
)} )}
<BellIcon className={clsx(props.className)} /> <BellIcon className={clsx(props.className)} />
</div> </div>

View File

@ -3,8 +3,68 @@ import { listenForPrivateUser } from 'web/lib/firebase/users'
import { notification_subscribe_types, PrivateUser } from 'common/user' import { notification_subscribe_types, PrivateUser } from 'common/user'
import { Notification } from 'common/notification' import { Notification } from 'common/notification'
import { listenForNotifications } from 'web/lib/firebase/notifications' import { listenForNotifications } from 'web/lib/firebase/notifications'
import { groupBy, map } from 'lodash'
export function useNotifications( export type NotificationGroup = {
notifications: Notification[]
sourceContractId: string
isSeen: boolean
timePeriod: string
}
export function usePreferredGroupedNotifications(
userId: string | undefined,
options: { unseenOnly: boolean }
) {
const [notificationGroups, setNotificationGroups] = useState<
NotificationGroup[]
>([])
const notifications = usePreferredNotifications(userId, options)
useEffect(() => {
if (!notifications) return
const groupedNotifications = groupNotifications(notifications)
setNotificationGroups(groupedNotifications)
}, [notifications])
return notificationGroups
}
export function groupNotifications(notifications: Notification[]) {
let notificationGroups: NotificationGroup[] = []
const notificationGroupsByDay = groupBy(notifications, (notification) =>
new Date(notification.createdTime).toDateString()
)
Object.keys(notificationGroupsByDay).forEach((day) => {
// Group notifications by contract:
const groupedNotificationsByContractId = groupBy(
notificationGroupsByDay[day],
(notification) => {
return notification.sourceContractId
}
)
notificationGroups = notificationGroups.concat(
map(groupedNotificationsByContractId, (notifications, contractId) => {
// Create a notification group for each contract within each day
const notificationGroup: NotificationGroup = {
notifications: groupedNotificationsByContractId[contractId].sort(
(a, b) => {
return b.createdTime - a.createdTime
}
),
sourceContractId: contractId,
isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
timePeriod: day,
}
return notificationGroup
})
)
})
return notificationGroups
}
function usePreferredNotifications(
userId: string | undefined, userId: string | undefined,
options: { unseenOnly: boolean } options: { unseenOnly: boolean }
) { ) {
@ -25,7 +85,7 @@ export function useNotifications(
setNotifications, setNotifications,
unseenOnly unseenOnly
) )
}, [privateUser]) }, [privateUser, unseenOnly])
useEffect(() => { useEffect(() => {
if (!privateUser) return if (!privateUser) return

View File

@ -5,6 +5,7 @@ import {
Notification, Notification,
notification_reason_types, notification_reason_types,
notification_source_types, notification_source_types,
notification_source_update_types,
} from 'common/notification' } from 'common/notification'
import { Avatar } from 'web/components/avatar' import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
@ -25,7 +26,6 @@ import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import clsx from 'clsx' import clsx from 'clsx'
import { groupBy, map } from 'lodash'
import { UsersIcon } from '@heroicons/react/solid' import { UsersIcon } from '@heroicons/react/solid'
import { RelativeTimestamp } from 'web/components/relative-timestamp' import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
@ -33,70 +33,46 @@ import {
FreeResponseOutcomeLabel, FreeResponseOutcomeLabel,
OutcomeLabel, OutcomeLabel,
} from 'web/components/outcome-label' } from 'web/components/outcome-label'
import { useNotifications } from 'web/hooks/use-notifications' import {
groupNotifications,
NotificationGroup,
usePreferredGroupedNotifications,
} from 'web/hooks/use-notifications'
import { getContractFromId } from 'web/lib/firebase/contracts' import { getContractFromId } from 'web/lib/firebase/contracts'
import { CheckIcon, XIcon } from '@heroicons/react/outline' import { CheckIcon, XIcon } from '@heroicons/react/outline'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
type NotificationGroup = {
notifications: Notification[]
sourceContractId: string
isSeen: boolean
timePeriod: string
}
export default function Notifications() { export default function Notifications() {
const user = useUser() const user = useUser()
const [allNotificationGroups, setAllNotificationsGroups] = useState<
NotificationGroup[]
>([])
const [unseenNotificationGroups, setUnseenNotificationGroups] = useState< const [unseenNotificationGroups, setUnseenNotificationGroups] = useState<
NotificationGroup[] NotificationGroup[]
>([]) >([])
const notifications = useNotifications(user?.id, { unseenOnly: false }) const allNotificationGroups = usePreferredGroupedNotifications(user?.id, {
unseenOnly: false,
})
useEffect(() => { useEffect(() => {
const notificationIdsToShow = notifications.map( // Don't re-add notifications that are visible right now or have been seen already.
(notification) => notification.id
)
// Hide notifications the user doesn't want to see.
const notificationIdsToHide = notifications
.filter(
(notification) => !notificationIdsToShow.includes(notification.id)
)
.map((notification) => notification.id)
// Because hidden notifications won't be rendered, set them to seen here
setNotificationsAsSeen(
notifications.filter((n) => notificationIdsToHide.includes(n.id))
)
// Group notifications by contract and 24-hour time period.
const allGroupedNotifications = groupNotifications(
notifications,
notificationIdsToHide
)
// Don't add notifications that are already visible or have been seen.
const currentlyVisibleUnseenNotificationIds = Object.values( const currentlyVisibleUnseenNotificationIds = Object.values(
unseenNotificationGroups unseenNotificationGroups
) )
.map((n) => n.notifications.map((n) => n.id)) .map((n) => n.notifications.map((n) => n.id))
.flat() .flat()
const unseenGroupedNotifications = groupNotifications( const unseenGroupedNotifications = groupNotifications(
notifications.filter( allNotificationGroups
(notification) => .map((notification: NotificationGroup) => notification.notifications)
.flat()
.filter(
(notification: Notification) =>
!notification.isSeen || !notification.isSeen ||
currentlyVisibleUnseenNotificationIds.includes(notification.id) currentlyVisibleUnseenNotificationIds.includes(notification.id)
),
notificationIdsToHide
) )
setAllNotificationsGroups(allGroupedNotifications) )
setUnseenNotificationGroups(unseenGroupedNotifications) setUnseenNotificationGroups(unseenGroupedNotifications)
// We don't want unseenNotificationsGroup to be in the dependencies as we update it here. // We don't want unseenNotificationsGroup to be in the dependencies as we update it here.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [notifications]) }, [allNotificationGroups])
if (user === undefined) { if (user === undefined) {
return <LoadingIndicator /> return <LoadingIndicator />
@ -129,7 +105,10 @@ export default function Notifications() {
) : ( ) : (
<NotificationGroupItem <NotificationGroupItem
notificationGroup={notification} notificationGroup={notification}
key={notification.sourceContractId} key={
notification.sourceContractId +
notification.timePeriod
}
/> />
) )
)} )}
@ -151,7 +130,10 @@ export default function Notifications() {
) : ( ) : (
<NotificationGroupItem <NotificationGroupItem
notificationGroup={notification} notificationGroup={notification}
key={notification.sourceContractId} key={
notification.sourceContractId +
notification.timePeriod
}
/> />
) )
)} )}
@ -188,47 +170,6 @@ const setNotificationsAsSeen = (notifications: Notification[]) => {
return notifications return notifications
} }
function groupNotifications(
notifications: Notification[],
hideNotificationIds: string[]
) {
// Then remove them from the list of notifications to show
notifications = notifications.filter(
(notification) => !hideNotificationIds.includes(notification.id)
)
let notificationGroups: NotificationGroup[] = []
const notificationGroupsByDay = groupBy(notifications, (notification) =>
new Date(notification.createdTime).toDateString()
)
Object.keys(notificationGroupsByDay).forEach((day) => {
// Group notifications by contract:
const groupedNotificationsByContractId = groupBy(
notificationGroupsByDay[day],
(notification) => {
return notification.sourceContractId
}
)
notificationGroups = notificationGroups.concat(
map(groupedNotificationsByContractId, (notifications, contractId) => {
// Create a notification group for each contract within each day
const notificationGroup: NotificationGroup = {
notifications: groupedNotificationsByContractId[contractId].sort(
(a, b) => {
return b.createdTime - a.createdTime
}
),
sourceContractId: contractId,
isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
timePeriod: day,
}
return notificationGroup
})
)
})
return notificationGroups
}
function NotificationGroupItem(props: { function NotificationGroupItem(props: {
notificationGroup: NotificationGroup notificationGroup: NotificationGroup
className?: string className?: string
@ -280,13 +221,12 @@ function NotificationGroupItem(props: {
<div className={'line-clamp-4 mt-1 gap-1 whitespace-pre-line'}> <div className={'line-clamp-4 mt-1 gap-1 whitespace-pre-line'}>
{!expanded ? ( {!expanded ? (
<> <>
{notifications {notifications.slice(0, numSummaryLines).map((notification) => {
.slice(0, numSummaryLines)
.map((notification, i) => {
return ( return (
<NotificationItem <NotificationItem
notification={notification} notification={notification}
justSummary={true} justSummary={true}
key={notification.id}
/> />
) )
})} })}
@ -300,7 +240,7 @@ function NotificationGroupItem(props: {
</> </>
) : ( ) : (
<> <>
{notifications.map((notification, i) => ( {notifications.map((notification) => (
<NotificationItem <NotificationItem
notification={notification} notification={notification}
key={notification.id} key={notification.id}
@ -517,23 +457,17 @@ function NotificationSettings() {
) )
} }
async function getNotificationSummaryText( function isNotificationAboutContractResolution(
sourceId: string, sourceType: notification_source_types | undefined,
sourceContractId: string, sourceUpdateType: notification_source_update_types | undefined,
sourceType: 'answer' | 'comment', contract: Contract | null | undefined
setText: (text: string) => void
) { ) {
if (sourceType === 'answer') { return (
const answer = await getValue<Answer>( (sourceType === 'contract' && !sourceUpdateType && contract?.resolution) ||
doc(db, `contracts/${sourceContractId}/answers/`, sourceId) (sourceType === 'contract' &&
sourceUpdateType === 'resolved' &&
contract?.resolution)
) )
setText(answer?.text ?? '')
} else {
const comment = await getValue<Comment>(
doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
)
setText(comment?.text ?? '')
}
} }
function NotificationItem(props: { function NotificationItem(props: {
@ -547,6 +481,7 @@ function NotificationItem(props: {
sourceId, sourceId,
sourceUserName, sourceUserName,
sourceUserAvatarUrl, sourceUserAvatarUrl,
sourceUpdateType,
reasonText, reasonText,
reason, reason,
sourceUserUsername, sourceUserUsername,
@ -554,6 +489,7 @@ function NotificationItem(props: {
} = notification } = notification
const [notificationText, setNotificationText] = useState<string>('') const [notificationText, setNotificationText] = useState<string>('')
const [contract, setContract] = useState<Contract | null>(null) const [contract, setContract] = useState<Contract | null>(null)
useEffect(() => { useEffect(() => {
if (!sourceContractId) return if (!sourceContractId) return
getContractFromId(sourceContractId).then((contract) => { getContractFromId(sourceContractId).then((contract) => {
@ -562,27 +498,32 @@ function NotificationItem(props: {
}, [sourceContractId]) }, [sourceContractId])
useEffect(() => { useEffect(() => {
if (!contract || !sourceContractId) return if (!contract || !sourceContractId || !sourceId) return
if (sourceType === 'contract') { if (
// We don't handle anything other than contract updates & resolution yet. sourceType === 'answer' ||
if (contract.resolution) setNotificationText(contract.resolution) sourceType === 'comment' ||
else setNotificationText(contract.question) sourceType === 'contract'
return ) {
} getNotificationText(
if (!sourceId) return
if (sourceType === 'answer' || sourceType === 'comment') {
getNotificationSummaryText(
sourceId, sourceId,
sourceContractId, sourceContractId,
sourceType, sourceType,
setNotificationText sourceUpdateType,
setNotificationText,
contract
) )
} else if (reasonText) { } else if (reasonText) {
// Handle arbitrary notifications with reason text here. // Handle arbitrary notifications with reason text here.
setNotificationText(reasonText) setNotificationText(reasonText)
} }
}, [contract, reasonText, sourceContractId, sourceId, sourceType]) }, [
contract,
reasonText,
sourceContractId,
sourceId,
sourceType,
sourceUpdateType,
])
useEffect(() => { useEffect(() => {
setNotificationsAsSeen([notification]) setNotificationsAsSeen([notification])
@ -608,8 +549,39 @@ function NotificationItem(props: {
} }
} }
function isNotificationContractResolution() { async function getNotificationText(
return sourceType === 'contract' && contract?.resolution sourceId: string,
sourceContractId: string,
sourceType: 'answer' | 'comment' | 'contract',
sourceUpdateType: notification_source_update_types | undefined,
setText: (text: string) => void,
contract?: Contract
) {
if (sourceType === 'contract' && !contract)
contract = await getContractFromId(sourceContractId)
if (sourceType === 'contract' && contract) {
if (
isNotificationAboutContractResolution(
sourceType,
sourceUpdateType,
contract
) &&
contract?.resolution
)
setText(contract.resolution)
else setText(contract.question)
} else if (sourceType === 'answer') {
const answer = await getValue<Answer>(
doc(db, `contracts/${sourceContractId}/answers/`, sourceId)
)
setText(answer?.text ?? '')
} else {
const comment = await getValue<Comment>(
doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
)
setText(comment?.text ?? '')
}
} }
if (justSummary) { if (justSummary) {
@ -625,9 +597,10 @@ function NotificationItem(props: {
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}> <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
{sourceType && {sourceType &&
reason && reason &&
getReasonTextFromReason( getReasonForShowingNotification(
sourceType, sourceType,
reason, reason,
sourceUpdateType,
contract, contract,
true true
).replace(' on', '')} ).replace(' on', '')}
@ -635,8 +608,10 @@ function NotificationItem(props: {
{contract ? ( {contract ? (
<NotificationTextLabel <NotificationTextLabel
contract={contract} contract={contract}
notificationText={notificationText} defaultText={notificationText}
className={'line-clamp-1'} className={'line-clamp-1'}
sourceUpdateType={sourceUpdateType}
sourceType={sourceType}
/> />
) : sourceType != 'follow' ? ( ) : sourceType != 'follow' ? (
<LoadingIndicator <LoadingIndicator
@ -655,6 +630,7 @@ function NotificationItem(props: {
return ( return (
<div className={'bg-white px-2 pt-6 text-sm sm:px-4'}> <div className={'bg-white px-2 pt-6 text-sm sm:px-4'}>
<a href={getSourceUrl(sourceId)}>
<Row className={'items-center text-gray-500 sm:justify-start'}> <Row className={'items-center text-gray-500 sm:justify-start'}>
<Avatar <Avatar
avatarUrl={sourceUserAvatarUrl} avatarUrl={sourceUserAvatarUrl}
@ -673,17 +649,21 @@ function NotificationItem(props: {
username={sourceUserUsername || ''} username={sourceUserUsername || ''}
className={'mr-0 flex-shrink-0'} className={'mr-0 flex-shrink-0'}
/> />
<a <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
href={getSourceUrl(sourceId)}
className={'inline-flex overflow-hidden text-ellipsis pl-1'}
>
{sourceType && reason && ( {sourceType && reason && (
<div className={'inline truncate'}> <div className={'inline truncate'}>
{getReasonTextFromReason(sourceType, reason, contract)} {getReasonForShowingNotification(
<span className={'mx-1 font-bold'}>{contract?.question}</span> sourceType,
reason,
sourceUpdateType,
contract
)}
<span className={'mx-1 font-bold'}>
{contract?.question}
</span>
</div> </div>
)} )}
</a> </div>
</div> </div>
{contract && sourceId && ( {contract && sourceId && (
<CopyLinkDateTimeComponent <CopyLinkDateTimeComponent
@ -695,13 +675,13 @@ function NotificationItem(props: {
)} )}
</div> </div>
</Row> </Row>
<a href={getSourceUrl(sourceId)}>
<div className={'mt-1 md:text-base'}> <div className={'mt-1 md:text-base'}>
{isNotificationContractResolution() && ' Resolved:'}{' '}
{contract ? ( {contract ? (
<NotificationTextLabel <NotificationTextLabel
contract={contract} contract={contract}
notificationText={notificationText} defaultText={notificationText}
sourceType={sourceType}
sourceUpdateType={sourceUpdateType}
/> />
) : sourceType != 'follow' ? ( ) : sourceType != 'follow' ? (
<LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} /> <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
@ -718,17 +698,22 @@ function NotificationItem(props: {
function NotificationTextLabel(props: { function NotificationTextLabel(props: {
contract: Contract contract: Contract
notificationText: string defaultText: string
sourceType?: notification_source_types
sourceUpdateType?: notification_source_update_types
className?: string className?: string
}) { }) {
const { contract, notificationText, className } = props const { contract, className, sourceUpdateType, sourceType, defaultText } =
if (notificationText === contract.question) { props
return (
<div className={clsx('text-indigo-700 hover:underline', className)}> if (
{notificationText} isNotificationAboutContractResolution(
</div> sourceType,
) sourceUpdateType,
} else if (notificationText === contract.resolution) { contract
) &&
contract.resolution
) {
if (contract.outcomeType === 'FREE_RESPONSE') { if (contract.outcomeType === 'FREE_RESPONSE') {
return ( return (
<FreeResponseOutcomeLabel <FreeResponseOutcomeLabel
@ -746,24 +731,31 @@ function NotificationTextLabel(props: {
truncate={'long'} truncate={'long'}
/> />
) )
} else if (sourceType === 'contract') {
return (
<div className={clsx('text-indigo-700 hover:underline', className)}>
{defaultText}
</div>
)
} else { } else {
return ( return (
<div <div
className={className ? className : 'line-clamp-4 whitespace-pre-line'} className={className ? className : 'line-clamp-4 whitespace-pre-line'}
> >
<Linkify text={notificationText} /> <Linkify text={defaultText} />
</div> </div>
) )
} }
} }
function getReasonTextFromReason( function getReasonForShowingNotification(
source: notification_source_types, source: notification_source_types,
reason: notification_reason_types, reason: notification_reason_types,
sourceUpdateType: notification_source_update_types | undefined,
contract: Contract | undefined | null, contract: Contract | undefined | null,
simple?: boolean simple?: boolean
) { ) {
let reasonText = '' let reasonText: string
switch (source) { switch (source) {
case 'comment': case 'comment':
if (reason === 'reply_to_users_answer') if (reason === 'reply_to_users_answer')
@ -783,7 +775,14 @@ function getReasonTextFromReason(
else reasonText = `commented on` else reasonText = `commented on`
break break
case 'contract': case 'contract':
if (contract?.resolution) reasonText = `resolved` if (
isNotificationAboutContractResolution(
source,
sourceUpdateType,
contract
)
)
reasonText = `resolved`
else reasonText = `updated` else reasonText = `updated`
break break
case 'answer': case 'answer':