Revert "Notification detail, grouping, and settings control [wip] (#403)"

This reverts commit 37c7f909a3.
This commit is contained in:
Ian Philips 2022-06-06 10:54:25 -06:00
parent 37c7f909a3
commit 07f2d390e5
12 changed files with 211 additions and 964 deletions

View File

@ -9,7 +9,6 @@ export type Notification = {
sourceId?: string sourceId?: string
sourceType?: notification_source_types sourceType?: notification_source_types
sourceUpdateType?: notification_source_update_types
sourceContractId?: string sourceContractId?: string
sourceUserName?: string sourceUserName?: string
sourceUserUsername?: string sourceUserUsername?: string
@ -21,24 +20,10 @@ export type notification_source_types =
| 'bet' | 'bet'
| 'answer' | 'answer'
| 'liquidity' | 'liquidity'
| 'follow'
| 'tip'
| 'admin_message'
export type notification_source_update_types = export type notification_reason_types =
| 'created' | 'created'
| 'updated' | 'updated'
| 'resolved' | 'resolved'
| 'deleted' | 'tagged'
| 'closed' | 'replied'
export type notification_reason_types =
| 'tagged_user'
| 'on_users_contract'
| 'on_contract_with_users_shares_in'
| 'on_contract_with_users_shares_out'
| 'on_contract_with_users_answer'
| 'on_contract_with_users_comment'
| 'reply_to_users_answer'
| 'reply_to_users_comment'
| 'on_new_follow'

View File

@ -36,7 +36,4 @@ export type PrivateUser = {
initialDeviceToken?: string initialDeviceToken?: string
initialIpAddress?: string initialIpAddress?: string
apiKey?: string apiKey?: string
notificationPreferences?: notification_subscribe_types
} }
export type notification_subscribe_types = 'all' | 'less' | 'none'

View File

@ -2,34 +2,28 @@ import * as admin from 'firebase-admin'
import { import {
Notification, Notification,
notification_reason_types, notification_reason_types,
notification_source_update_types,
notification_source_types, notification_source_types,
} from '../../common/notification' } from '../../common/notification'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getUserByUsername, getValues } from './utils' import { getValues } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object'
const firestore = admin.firestore() const firestore = admin.firestore()
type user_to_reason_texts = { type user_to_reason_texts = {
[userId: string]: { reason: notification_reason_types } [userId: string]: { text: string; reason: notification_reason_types }
} }
export const createNotification = async ( export const createNotification = async (
sourceId: string, sourceId: string,
sourceType: notification_source_types, sourceType: notification_source_types,
sourceUpdateType: notification_source_update_types, reason: notification_reason_types,
sourceContract: Contract,
sourceUser: User, sourceUser: User,
idempotencyKey: string, idempotencyKey: string
sourceContract?: Contract,
relatedSourceType?: notification_source_types,
relatedUserId?: string,
sourceText?: string
) => { ) => {
const shouldGetNotification = ( const shouldGetNotification = (
userId: string, userId: string,
@ -52,170 +46,128 @@ export const createNotification = async (
const notification: Notification = { const notification: Notification = {
id: idempotencyKey, id: idempotencyKey,
userId, userId,
reasonText: userToReasonTexts[userId].text,
reason: userToReasonTexts[userId].reason, reason: userToReasonTexts[userId].reason,
createdTime: Date.now(), createdTime: Date.now(),
isSeen: false, isSeen: false,
sourceId, sourceId,
sourceType, sourceType,
sourceUpdateType, sourceContractId: sourceContract.id,
sourceContractId: sourceContract?.id,
sourceUserName: sourceUser.name, sourceUserName: sourceUser.name,
sourceUserUsername: sourceUser.username, sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl, sourceUserAvatarUrl: sourceUser.avatarUrl,
} }
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(notification)
}) })
) )
} }
const notifyRepliedUsers = async (
userToReasonTexts: user_to_reason_texts
) => {
if (
!relatedSourceType ||
!relatedUserId ||
!shouldGetNotification(relatedUserId, userToReasonTexts)
)
return
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
const notifyFollowedUser = async (
userToReasonTexts: user_to_reason_texts,
followedUserId: string
) => {
if (shouldGetNotification(followedUserId, userToReasonTexts))
userToReasonTexts[followedUserId] = {
reason: 'on_new_follow',
}
}
const notifyTaggedUsers = async (userToReasonTexts: user_to_reason_texts) => {
if (!sourceText) return
const taggedUsers = sourceText.match(/@\w+/g)
if (!taggedUsers) return
// await all get tagged users:
const users = await Promise.all(
taggedUsers.map(async (username) => {
return await getUserByUsername(username.slice(1))
})
)
users.forEach((taggedUser) => {
if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts))
userToReasonTexts[taggedUser.id] = {
reason: 'tagged_user',
}
})
}
const notifyContractCreator = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
if (shouldGetNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = {
reason: 'on_users_contract',
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
}
const notifyOtherBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
(userId) => {
return (
getContractBetMetrics(
sourceContract,
bets.filter((bet) => bet.userId === userId)
).invested > 0
)
}
)
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
// TODO: Update for liquidity. // TODO: Update for liquidity.
// TODO: Find tagged users.
// TODO: Find replies to comments.
// TODO: Filter bets for only open bets.
// TODO: Notify users of their own closed but not resolved contracts. // TODO: Notify users of their own closed but not resolved contracts.
const getUsersToNotify = async () => { if (
const userToReasonTexts: user_to_reason_texts = {} sourceType === 'comment' ||
// The following functions modify the userToReasonTexts object in place. sourceType === 'answer' ||
if ( sourceType === 'contract'
sourceContract && ) {
(sourceType === 'comment' || const reasonTextPretext = getReasonTextFromReason(sourceType, reason)
sourceType === 'answer' ||
sourceType === 'contract')
) {
if (sourceType === 'comment') {
await notifyRepliedUsers(userToReasonTexts)
await notifyTaggedUsers(userToReasonTexts)
}
await notifyContractCreator(userToReasonTexts, sourceContract)
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
await notifyOtherBettorsOnContract(userToReasonTexts, sourceContract)
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
} else if (sourceType === 'follow' && relatedUserId) {
await notifyFollowedUser(userToReasonTexts, relatedUserId)
}
return userToReasonTexts
}
const userToReasonTexts = await getUsersToNotify() const notifyContractCreator = async (
await createUsersNotifications(userToReasonTexts) userToReasonTexts: user_to_reason_texts
) => {
if (shouldGetNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = {
text: `${reasonTextPretext} your question`,
reason,
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
text: `${reasonTextPretext} a question you submitted an answer to`,
reason,
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
text: `${reasonTextPretext} a question you commented on`,
reason,
}
})
}
const notifyOtherBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const recipientUserIds = uniq(bets.map((bet) => bet.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
text: `${reasonTextPretext} a question you bet on`,
reason,
}
})
}
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
await notifyContractCreator(userToReasonTexts)
await notifyOtherAnswerersOnContract(userToReasonTexts)
await notifyOtherCommentersOnContract(userToReasonTexts)
await notifyOtherBettorsOnContract(userToReasonTexts)
return userToReasonTexts
}
const userToReasonTexts = await getUsersToNotify()
await createUsersNotifications(userToReasonTexts)
}
}
function getReasonTextFromReason(
source: notification_source_types,
reason: notification_reason_types
) {
// TODO: Find tagged users.
// TODO: Find replies to comments.
switch (source) {
case 'comment':
return 'commented on'
case 'contract':
return reason
case 'answer':
return 'answered'
default:
throw new Error('Invalid notification reason')
}
} }

View File

@ -30,4 +30,3 @@ export * from './market-close-emails'
export * from './add-liquidity' export * from './add-liquidity'
export * from './on-create-answer' export * from './on-create-answer'
export * from './on-update-contract' export * from './on-update-contract'
export * from './on-follow-user'

View File

@ -25,8 +25,8 @@ export const onCreateAnswer = functions.firestore
answer.id, answer.id,
'answer', 'answer',
'created', 'created',
contract,
answerCreator, answerCreator,
eventId, eventId
contract
) )
}) })

View File

@ -30,6 +30,15 @@ export const onCreateComment = functions
const commentCreator = await getUser(comment.userId) const commentCreator = await getUser(comment.userId)
if (!commentCreator) throw new Error('Could not find comment creator') if (!commentCreator) throw new Error('Could not find comment creator')
await createNotification(
comment.id,
'comment',
'created',
contract,
commentCreator,
eventId
)
await firestore await firestore
.collection('contracts') .collection('contracts')
.doc(contract.id) .doc(contract.id)
@ -62,27 +71,6 @@ export const onCreateComment = functions
const comments = await getValues<Comment>( const comments = await getValues<Comment>(
firestore.collection('contracts').doc(contractId).collection('comments') firestore.collection('contracts').doc(contractId).collection('comments')
) )
const relatedSourceType = comment.replyToCommentId
? 'comment'
: comment.answerOutcome
? 'answer'
: undefined
const relatedUser = comment.replyToCommentId
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId
await createNotification(
comment.id,
'comment',
'created',
commentCreator,
eventId,
contract,
relatedSourceType,
relatedUser,
comment.text
)
const recipientUserIds = uniq([ const recipientUserIds = uniq([
contract.creatorId, contract.creatorId,

View File

@ -1,28 +0,0 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
export const onFollowUser = functions.firestore
.document('users/{userId}/follows/{followedUserId}')
.onCreate(async (change, context) => {
const { userId } = context.params as {
userId: string
}
const { eventId } = context
const follow = change.data() as { userId: string; timestamp: number }
const followingUser = await getUser(userId)
if (!followingUser) throw new Error('Could not find following user')
await createNotification(
followingUser.id,
'follow',
'created',
followingUser,
eventId,
undefined,
undefined,
follow.userId
)
})

View File

@ -18,9 +18,9 @@ export const onUpdateContract = functions.firestore
contract.id, contract.id,
'contract', 'contract',
'resolved', 'resolved',
contract,
contractUpdater, contractUpdater,
eventId, eventId
contract
) )
} else if ( } else if (
previousValue.closeTime !== contract.closeTime || previousValue.closeTime !== contract.closeTime ||
@ -30,9 +30,9 @@ export const onUpdateContract = functions.firestore
contract.id, contract.id,
'contract', 'contract',
'updated', 'updated',
contract,
contractUpdater, contractUpdater,
eventId, eventId
contract
) )
} }
}) })

View File

@ -8,7 +8,6 @@ export function ChoicesToggleGroup(props: {
isSubmitting?: boolean isSubmitting?: boolean
setChoice: (p: number | string) => void setChoice: (p: number | string) => void
className?: string className?: string
toggleClassName?: string
children?: React.ReactNode children?: React.ReactNode
}) { }) {
const { const {
@ -18,7 +17,6 @@ export function ChoicesToggleGroup(props: {
choicesMap, choicesMap,
className, className,
children, children,
toggleClassName,
} = props } = props
return ( return (
<RadioGroup <RadioGroup
@ -39,7 +37,7 @@ export function ChoicesToggleGroup(props: {
: 'border-gray-200 bg-white text-gray-900 hover:bg-gray-50', : 'border-gray-200 bg-white text-gray-900 hover:bg-gray-50',
'flex cursor-pointer items-center justify-center rounded-md border py-3 px-3 text-sm font-medium normal-case', 'flex cursor-pointer items-center justify-center rounded-md border py-3 px-3 text-sm font-medium normal-case',
"hover:ring-offset-2' hover:ring-2 hover:ring-indigo-500", "hover:ring-offset-2' hover:ring-2 hover:ring-indigo-500",
toggleClassName className
) )
} }
disabled={isSubmitting} disabled={isSubmitting}

View File

@ -2,26 +2,32 @@ import { BellIcon } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import { Row } from 'web/components/layout/row' import { Row } from 'web/components/layout/row'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Notification } from 'common/notification'
import { listenForNotifications } from 'web/lib/firebase/notifications'
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'
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, setNotifications] = useState<
const [seen, setSeen] = useState(false) Notification[] | undefined
>()
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
if (router.pathname.endsWith('notifications')) return setSeen(true) if (router.pathname.endsWith('notifications')) return setNotifications([])
else setSeen(false)
}, [router.pathname]) }, [router.pathname])
useEffect(() => {
if (user) return listenForNotifications(user.id, setNotifications, true)
}, [user])
return ( return (
<Row className={clsx('justify-center')}> <Row className={clsx('justify-center')}>
<div className={'relative'}> <div className={'relative'}>
{!seen && notifications && notifications.length > 0 && ( {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

@ -1,66 +0,0 @@
import { useEffect, useState } from 'react'
import { listenForPrivateUser } from 'web/lib/firebase/users'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { Notification } from 'common/notification'
import { listenForNotifications } from 'web/lib/firebase/notifications'
export function useNotifications(
userId: string | undefined,
options: { unseenOnly: boolean }
) {
const { unseenOnly } = options
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
const [notifications, setNotifications] = useState<Notification[]>([])
const [userAppropriateNotifications, setUserAppropriateNotifications] =
useState<Notification[]>([])
useEffect(() => {
if (userId) listenForPrivateUser(userId, setPrivateUser)
}, [userId])
useEffect(() => {
if (privateUser)
return listenForNotifications(
privateUser.id,
setNotifications,
unseenOnly
)
}, [privateUser])
useEffect(() => {
if (!privateUser) return
const notificationsToShow = getAppropriateNotifications(
notifications,
privateUser.notificationPreferences
)
setUserAppropriateNotifications(notificationsToShow)
}, [privateUser, notifications])
return userAppropriateNotifications
}
const lessPriorityReasons = [
'on_contract_with_users_comment',
'on_contract_with_users_answer',
'on_contract_with_users_shares_out',
// Not sure if users will want to see these w/ less:
// 'on_contract_with_users_shares_in',
]
function getAppropriateNotifications(
notifications: Notification[],
notificationPreferences?: notification_subscribe_types
) {
if (notificationPreferences === 'all') return notifications
if (notificationPreferences === 'less')
return notifications.filter(
(n) =>
n.reason &&
// Show all contract notifications
(n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
)
if (notificationPreferences === 'none') return []
return notifications
}

View File

@ -6,6 +6,7 @@ import {
notification_reason_types, notification_reason_types,
notification_source_types, notification_source_types,
} from 'common/notification' } from 'common/notification'
import { listenForNotifications } from 'web/lib/firebase/notifications'
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'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
@ -18,90 +19,22 @@ import { Comment } from 'web/lib/firebase/comments'
import { getValue } from 'web/lib/firebase/utils' import { getValue } from 'web/lib/firebase/utils'
import Custom404 from 'web/pages/404' import Custom404 from 'web/pages/404'
import { UserLink } from 'web/components/user-page' import { UserLink } from 'web/components/user-page'
import { notification_subscribe_types, PrivateUser } from 'common/user' import { User } from 'common/user'
import { useContract } from 'web/hooks/use-contract' import { useContract } from 'web/hooks/use-contract'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
import { LoadingIndicator } from 'web/components/loading-indicator'
import clsx from 'clsx'
import { groupBy, map } from 'lodash'
import { UsersIcon } from '@heroicons/react/solid'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify'
import {
FreeResponseOutcomeLabel,
OutcomeLabel,
} from 'web/components/outcome-label'
import { useNotifications } from 'web/hooks/use-notifications'
import { getContractFromId } from 'web/lib/firebase/contracts'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
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< const [notifications, setNotifications] = useState<
NotificationGroup[] Notification[] | undefined
>([]) >()
const [unseenNotificationGroups, setUnseenNotificationGroups] = useState<
NotificationGroup[]
>([])
const notifications = useNotifications(user?.id, { unseenOnly: false })
useEffect(() => { useEffect(() => {
const notificationIdsToShow = notifications.map( if (user) return listenForNotifications(user.id, setNotifications)
(notification) => notification.id }, [user])
)
// 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 if (!user) {
setNotificationsAsSeen( // TODO: return sign in page
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(
unseenNotificationGroups
)
.map((n) => n.notifications.map((n) => n.id))
.flat()
const unseenGroupedNotifications = groupNotifications(
notifications.filter(
(notification) =>
!notification.isSeen ||
currentlyVisibleUnseenNotificationIds.includes(notification.id)
),
notificationIdsToHide
)
setAllNotificationsGroups(allGroupedNotifications)
setUnseenNotificationGroups(unseenGroupedNotifications)
// We don't want unseenNotificationsGroup to be in the dependencies as we update it here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notifications])
if (user === undefined) {
return <LoadingIndicator />
}
if (user === null) {
return <Custom404 /> return <Custom404 />
} }
@ -114,55 +47,18 @@ export default function Notifications() {
className={'pb-2 pt-1 '} className={'pb-2 pt-1 '}
defaultIndex={0} defaultIndex={0}
tabs={[ tabs={[
{
title: 'New Notifications',
content: (
<div className={''}>
{unseenNotificationGroups.length === 0 &&
"You don't have any new notifications."}
{unseenNotificationGroups.map((notification) =>
notification.notifications.length === 1 ? (
<NotificationItem
notification={notification.notifications[0]}
key={notification.notifications[0].id}
/>
) : (
<NotificationGroupItem
notificationGroup={notification}
key={notification.sourceContractId}
/>
)
)}
</div>
),
},
{ {
title: 'All Notifications', title: 'All Notifications',
content: ( content: (
<div className={''}> <div className={''}>
{allNotificationGroups.length === 0 && {notifications &&
"You don't have any notifications."} notifications.map((notification) => (
{allNotificationGroups.map((notification) => <Notification
notification.notifications.length === 1 ? ( currentUser={user}
<NotificationItem notification={notification}
notification={notification.notifications[0]} key={notification.id}
key={notification.notifications[0].id}
/> />
) : ( ))}
<NotificationGroupItem
notificationGroup={notification}
key={notification.sourceContractId}
/>
)
)}
</div>
),
},
{
title: 'Settings',
content: (
<div className={''}>
<NotificationSettings />
</div> </div>
), ),
}, },
@ -173,378 +69,17 @@ export default function Notifications() {
) )
} }
const setNotificationsAsSeen = (notifications: Notification[]) => { function Notification(props: {
notifications.forEach((notification) => { currentUser: User
if (!notification.isSeen)
updateDoc(
doc(db, `users/${notification.userId}/notifications/`, notification.id),
{
...notification,
isSeen: true,
viewTime: new Date(),
}
)
})
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: {
notificationGroup: NotificationGroup
className?: string
}) {
const { notificationGroup, className } = props
const { sourceContractId, notifications } = notificationGroup
const contract = useContract(sourceContractId ?? '')
const numSummaryLines = 3
const [expanded, setExpanded] = useState(false)
useEffect(() => {
if (!contract) return
setNotificationsAsSeen(notifications)
}, [contract, notifications])
return (
<div
className={clsx(
'relative cursor-pointer bg-white px-2 pt-6 text-sm',
className,
!expanded ? 'hover:bg-gray-100' : ''
)}
onClick={() => setExpanded(!expanded)}
>
{expanded && (
<span
className="absolute top-14 left-6 -ml-px h-[calc(100%-5rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
)}
<Row className={'items-center text-gray-500 sm:justify-start'}>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
<div className={'flex-1 overflow-hidden pl-2 sm:flex'}>
<div
onClick={() => setExpanded(!expanded)}
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
>
{'Activity on '}
<span className={'mx-1 font-bold'}>{contract?.question}</span>
</div>
<RelativeTimestamp time={notifications[0].createdTime} />
</div>
</Row>
<div>
<div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}>
{' '}
<div className={'line-clamp-4 mt-1 gap-1 whitespace-pre-line'}>
{!expanded ? (
<>
{notifications
.slice(0, numSummaryLines)
.map((notification, i) => {
return (
<NotificationItem
notification={notification}
justSummary={true}
/>
)
})}
<div className={'text-sm text-gray-500 hover:underline '}>
{notifications.length - numSummaryLines > 0
? 'And ' +
(notifications.length - numSummaryLines) +
' more...'
: ''}
</div>
</>
) : (
<>
{notifications.map((notification, i) => (
<NotificationItem
notification={notification}
key={notification.id}
justSummary={false}
/>
))}
</>
)}
</div>
</div>
<div className={'mt-6 border-b border-gray-300'} />
</div>
</div>
)
}
function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
useState<notification_subscribe_types>('all')
const [emailNotificationSettings, setEmailNotificationSettings] =
useState<notification_subscribe_types>('all')
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
const [showSettings, setShowSettings] = useState<'in-app' | 'email' | 'none'>(
'none'
)
useEffect(() => {
if (user) listenForPrivateUser(user.id, setPrivateUser)
}, [user])
useEffect(() => {
if (!privateUser) return
if (privateUser.notificationPreferences) {
setNotificationSettings(privateUser.notificationPreferences)
}
if (
privateUser.unsubscribedFromResolutionEmails &&
privateUser.unsubscribedFromCommentEmails &&
privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('none')
} else if (
!privateUser.unsubscribedFromResolutionEmails &&
!privateUser.unsubscribedFromCommentEmails &&
!privateUser.unsubscribedFromAnswerEmails
) {
setEmailNotificationSettings('all')
} else {
setEmailNotificationSettings('less')
}
}, [privateUser])
const loading = 'Changing Notifications Settings'
const success = 'Notification Settings Changed!'
function changeEmailNotifications(newValue: notification_subscribe_types) {
if (!privateUser) return
setShowSettings('email')
if (newValue === 'all') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: false,
unsubscribedFromAnswerEmails: false,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'less') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: false,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
} else if (newValue === 'none') {
toast.promise(
updatePrivateUser(privateUser.id, {
unsubscribedFromResolutionEmails: true,
unsubscribedFromCommentEmails: true,
unsubscribedFromAnswerEmails: true,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
}
}
function changeInAppNotificationSettings(
newValue: notification_subscribe_types
) {
if (!privateUser) return
toast.promise(
updatePrivateUser(privateUser.id, {
notificationPreferences: newValue,
}),
{
loading,
success,
error: (err) => `${err.message}`,
}
)
setShowSettings('in-app')
}
useEffect(() => {
if (privateUser && privateUser.notificationPreferences)
setNotificationSettings(privateUser.notificationPreferences)
else setNotificationSettings('all')
}, [privateUser])
if (!privateUser) {
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
}
function NotificationSettingLine(props: {
label: string
highlight: boolean
}) {
const { label, highlight } = props
return (
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
{label}
</Row>
)
}
return (
<div className={'p-2'}>
<div>In App Notifications</div>
<ChoicesToggleGroup
currentChoice={notificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeInAppNotificationSettings(
choice as notification_subscribe_types
)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4'}>Email Notifications</div>
<ChoicesToggleGroup
currentChoice={emailNotificationSettings}
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
setChoice={(choice) =>
changeEmailNotifications(choice as notification_subscribe_types)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
<div className={'mt-4 text-base'}>
{showSettings === 'in-app' ? (
<div>
<div className={''}>
You will receive notifications for:
<NotificationSettingLine
label={"Resolutions on questions you've interacted with"}
highlight={notificationSettings !== 'none'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={'Activity on your own questions, comments, & answers'}
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Activity on questions you're betting on"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
/>
</div>
</div>
) : showSettings === 'email' ? (
<div>
You will receive emails for:
<NotificationSettingLine
label={"Resolutions on questions you're betting on"}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Closure of your questions'}
highlight={emailNotificationSettings !== 'none'}
/>
<NotificationSettingLine
label={'Activity on your questions'}
highlight={emailNotificationSettings === 'all'}
/>
<NotificationSettingLine
label={"Activity on questions you've answered or commented on"}
highlight={emailNotificationSettings === 'all'}
/>
</div>
) : (
<div />
)}
</div>
</div>
)
}
async function getNotificationSummaryText(
sourceId: string,
sourceContractId: string,
sourceType: 'answer' | 'comment',
setText: (text: string) => void
) {
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 ?? '')
}
}
function NotificationItem(props: {
notification: Notification notification: Notification
justSummary?: boolean
}) { }) {
const { notification, justSummary } = props const { notification, currentUser } = props
const { const {
sourceType, sourceType,
sourceContractId, sourceContractId,
sourceId, sourceId,
userId,
id,
sourceUserName, sourceUserName,
sourceUserAvatarUrl, sourceUserAvatarUrl,
reasonText, reasonText,
@ -552,41 +87,42 @@ function NotificationItem(props: {
sourceUserUsername, sourceUserUsername,
createdTime, createdTime,
} = notification } = notification
const [notificationText, setNotificationText] = useState<string>('') const [subText, setSubText] = useState<string>('')
const [contract, setContract] = useState<Contract | null>(null) const contract = useContract(sourceContractId ?? '')
useEffect(() => {
if (!sourceContractId) return
getContractFromId(sourceContractId).then((contract) => {
if (contract) setContract(contract)
})
}, [sourceContractId])
useEffect(() => { useEffect(() => {
if (!contract || !sourceContractId) return if (!contract) return
if (sourceType === 'contract') { if (sourceType === 'contract') {
// We don't handle anything other than contract updates & resolution yet. setSubText(contract.question)
if (contract.resolution) setNotificationText(contract.resolution)
else setNotificationText(contract.question)
return
} }
if (!sourceId) return }, [contract, sourceType])
if (sourceType === 'answer' || sourceType === 'comment') {
getNotificationSummaryText(
sourceId,
sourceContractId,
sourceType,
setNotificationText
)
} else if (reasonText) {
// Handle arbitrary notifications with reason text here.
setNotificationText(reasonText)
}
}, [contract, reasonText, sourceContractId, sourceId, sourceType])
useEffect(() => { useEffect(() => {
setNotificationsAsSeen([notification]) if (!sourceContractId || !sourceId) return
}, [notification])
if (sourceType === 'answer') {
getValue<Answer>(
doc(db, `contracts/${sourceContractId}/answers/`, sourceId)
).then((answer) => {
setSubText(answer?.text || '')
})
} else if (sourceType === 'comment') {
getValue<Comment>(
doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
).then((comment) => {
setSubText(comment?.text || '')
})
}
}, [sourceContractId, sourceId, sourceType])
useEffect(() => {
if (!contract || !notification || notification.isSeen) return
updateDoc(doc(db, `users/${currentUser.id}/notifications/`, id), {
...notification,
isSeen: true,
viewTime: new Date(),
})
}, [notification, contract, currentUser, id, userId])
function getSourceUrl(sourceId?: string) { function getSourceUrl(sourceId?: string) {
if (!contract) return '' if (!contract) return ''
@ -608,53 +144,8 @@ function NotificationItem(props: {
} }
} }
function isNotificationContractResolution() {
return sourceType === 'contract' && contract?.resolution
}
if (justSummary) {
return (
<Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
<div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
<div className={'flex pl-1 sm:pl-0'}>
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'mr-0 flex-shrink-0'}
/>
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
{sourceType &&
reason &&
getReasonTextFromReason(
sourceType,
reason,
contract,
true
).replace(' on', '')}
<div className={'ml-1 text-black'}>
{contract ? (
<NotificationTextLabel
contract={contract}
notificationText={notificationText}
className={'line-clamp-1'}
/>
) : sourceType != 'follow' ? (
<LoadingIndicator
spinnerClassName={'border-gray-500 h-4 w-4'}
/>
) : (
<div />
)}
</div>
</div>
</div>
</div>
</Row>
)
}
return ( return (
<div className={'bg-white px-2 pt-6 text-sm sm:px-4'}> <div className={'bg-white px-1 pt-6 text-sm sm:px-4'}>
<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}
@ -665,7 +156,7 @@ function NotificationItem(props: {
<div className={'flex-1 overflow-hidden sm:flex'}> <div className={'flex-1 overflow-hidden sm:flex'}>
<div <div
className={ className={
'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0' 'flex max-w-sm shrink overflow-hidden text-ellipsis sm:max-w-md'
} }
> >
<UserLink <UserLink
@ -677,11 +168,12 @@ function NotificationItem(props: {
href={getSourceUrl(sourceId)} href={getSourceUrl(sourceId)}
className={'inline-flex overflow-hidden text-ellipsis pl-1'} 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)} {getReasonTextFromReason(sourceType, reason, contract)}
<span className={'mx-1 font-bold'}>{contract?.question}</span>
</div> </div>
) : (
reasonText
)} )}
</a> </a>
</div> </div>
@ -697,16 +189,11 @@ function NotificationItem(props: {
</Row> </Row>
<a href={getSourceUrl(sourceId)}> <a href={getSourceUrl(sourceId)}>
<div className={'mt-1 md:text-base'}> <div className={'mt-1 md:text-base'}>
{isNotificationContractResolution() && ' Resolved:'}{' '} {' '}
{contract ? ( {contract && subText === contract.question ? (
<NotificationTextLabel <div className={'text-indigo-700 hover:underline'}>{subText}</div>
contract={contract}
notificationText={notificationText}
/>
) : sourceType != 'follow' ? (
<LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
) : ( ) : (
<div /> <div className={'line-clamp-4 whitespace-pre-line'}>{subText}</div>
)} )}
</div> </div>
@ -716,90 +203,19 @@ function NotificationItem(props: {
) )
} }
function NotificationTextLabel(props: {
contract: Contract
notificationText: string
className?: string
}) {
const { contract, notificationText, className } = props
if (notificationText === contract.question) {
return (
<div className={clsx('text-indigo-700 hover:underline', className)}>
{notificationText}
</div>
)
} else if (notificationText === contract.resolution) {
if (contract.outcomeType === 'FREE_RESPONSE') {
return (
<FreeResponseOutcomeLabel
contract={contract}
resolution={contract.resolution}
truncate={'long'}
answerClassName={className}
/>
)
}
return (
<OutcomeLabel
contract={contract}
outcome={contract.resolution}
truncate={'long'}
/>
)
} else {
return (
<div
className={className ? className : 'line-clamp-4 whitespace-pre-line'}
>
<Linkify text={notificationText} />
</div>
)
}
}
function getReasonTextFromReason( function getReasonTextFromReason(
source: notification_source_types, source: notification_source_types,
reason: notification_reason_types, reason: notification_reason_types,
contract: Contract | undefined | null, contract: Contract | undefined
simple?: boolean
) { ) {
let reasonText = ''
switch (source) { switch (source) {
case 'comment': case 'comment':
if (reason === 'reply_to_users_answer') return `commented on ${contract?.question}`
reasonText = !simple ? 'replied to your answer on' : 'replied'
else if (reason === 'tagged_user')
reasonText = !simple ? 'tagged you in a comment on' : 'tagged you'
else if (reason === 'reply_to_users_comment')
reasonText = !simple ? 'replied to your comment on' : 'replied'
else if (reason === 'on_users_contract')
reasonText = !simple ? `commented on your question` : 'commented'
else if (reason === 'on_contract_with_users_comment')
reasonText = `commented on`
else if (reason === 'on_contract_with_users_answer')
reasonText = `commented on`
else if (reason === 'on_contract_with_users_shares_in')
reasonText = `commented`
else reasonText = `commented on`
break
case 'contract': case 'contract':
if (contract?.resolution) reasonText = `resolved` return `${reason} ${contract?.question}`
else reasonText = `updated`
break
case 'answer': case 'answer':
if (reason === 'on_users_contract') reasonText = `answered your question ` return `answered ${contract?.question}`
if (reason === 'on_contract_with_users_comment') reasonText = `answered`
else if (reason === 'on_contract_with_users_answer')
reasonText = `answered`
else if (reason === 'on_contract_with_users_shares_in')
reasonText = `answered`
else reasonText = `answered`
break
case 'follow':
reasonText = 'followed you'
break
default: default:
reasonText = '' return ''
} }
return reasonText
} }