import { Tabs } from 'web/components/layout/tabs'
import { useUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react'
import {
Notification,
notification_reason_types,
notification_source_types,
notification_source_update_types,
} from 'common/notification'
import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { doc, updateDoc } from 'firebase/firestore'
import { db } from 'web/lib/firebase/init'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { Answer } from 'common/answer'
import { Comment } from 'web/lib/firebase/comments'
import { getValue } from 'web/lib/firebase/utils'
import Custom404 from 'web/pages/404'
import { UserLink } from 'web/components/user-page'
import { notification_subscribe_types, PrivateUser } from 'common/user'
import { useContract } from 'web/hooks/use-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 { 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 {
groupNotifications,
NotificationGroup,
usePreferredGroupedNotifications,
} 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'
export default function Notifications() {
const user = useUser()
const [unseenNotificationGroups, setUnseenNotificationGroups] = useState<
NotificationGroup[] | undefined
>(undefined)
const allNotificationGroups = usePreferredGroupedNotifications(user?.id, {
unseenOnly: false,
})
useEffect(() => {
if (!allNotificationGroups) return
// Don't re-add notifications that are visible right now or have been seen already.
const currentlyVisibleUnseenNotificationIds = Object.values(
unseenNotificationGroups ?? []
)
.map((n) => n.notifications.map((n) => n.id))
.flat()
const unseenGroupedNotifications = groupNotifications(
allNotificationGroups
.map((notification: NotificationGroup) => notification.notifications)
.flat()
.filter(
(notification: Notification) =>
!notification.isSeen ||
currentlyVisibleUnseenNotificationIds.includes(notification.id)
)
)
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
}, [allNotificationGroups])
if (user === undefined) {
return
}
if (user === null) {
return
}
// TODO: use infinite scroll
return (
{unseenNotificationGroups.length === 0 &&
"You don't have any new notifications."}
{unseenNotificationGroups.map((notification) =>
notification.notifications.length === 1 ? (
) : (
)
)}
) : (
),
},
{
title: 'All Notifications',
content: allNotificationGroups ? (
{allNotificationGroups.length === 0 &&
"You don't have any notifications. Try changing your settings to see more."}
{allNotificationGroups.map((notification) =>
notification.notifications.length === 1 ? (
) : (
)
)}
) : (
),
},
{
title: 'Settings',
content: (
),
},
]}
/>
)
}
const setNotificationsAsSeen = (notifications: Notification[]) => {
notifications.forEach((notification) => {
if (!notification.isSeen)
updateDoc(
doc(db, `users/${notification.userId}/notifications/`, notification.id),
{
...notification,
isSeen: true,
viewTime: new Date(),
}
)
})
return notifications
}
function NotificationGroupItem(props: {
notificationGroup: NotificationGroup
className?: string
}) {
const { notificationGroup, className } = props
const { sourceContractId, notifications, timePeriod } = notificationGroup
const contract = useContract(sourceContractId ?? '')
const numSummaryLines = 3
const [expanded, setExpanded] = useState(false)
useEffect(() => {
if (!contract) return
setNotificationsAsSeen(notifications)
}, [contract, notifications])
return (
setExpanded(!expanded)}
>
{expanded && (
)}
setExpanded(!expanded)}
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
>
{contract ? (
{'Activity on '}
{contract?.question}
) : (
'Other activity'
)}
{' '}
{!expanded ? (
<>
{notifications.slice(0, numSummaryLines).map((notification) => {
return (
)
})}
{notifications.length - numSummaryLines > 0
? 'And ' +
(notifications.length - numSummaryLines) +
' more...'
: ''}
>
) : (
<>
{notifications.map((notification) => (
))}
>
)}
)
}
function NotificationSettings() {
const user = useUser()
const [notificationSettings, setNotificationSettings] =
useState('all')
const [emailNotificationSettings, setEmailNotificationSettings] =
useState('all')
const [privateUser, setPrivateUser] = useState(null)
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
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}`,
}
)
}
useEffect(() => {
if (privateUser && privateUser.notificationPreferences)
setNotificationSettings(privateUser.notificationPreferences)
else setNotificationSettings('all')
}, [privateUser])
if (!privateUser) {
return
}
function NotificationSettingLine(props: {
label: string
highlight: boolean
}) {
const { label, highlight } = props
return (
{highlight ? : }
{label}
)
}
return (
In App Notifications
changeInAppNotificationSettings(
choice as notification_subscribe_types
)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
You will receive notifications for:
Email Notifications
changeEmailNotifications(choice as notification_subscribe_types)
}
className={'col-span-4 p-2'}
toggleClassName={'w-24'}
/>
You will receive emails for:
)
}
function isNotificationAboutContractResolution(
sourceType: notification_source_types | undefined,
sourceUpdateType: notification_source_update_types | undefined,
contract: Contract | null | undefined
) {
return (
(sourceType === 'contract' && sourceUpdateType === 'resolved') ||
(sourceType === 'contract' && !sourceUpdateType && contract?.resolution)
)
}
function NotificationItem(props: {
notification: Notification
justSummary?: boolean
}) {
const { notification, justSummary } = props
const {
sourceType,
sourceContractId,
sourceId,
sourceUserName,
sourceUserAvatarUrl,
sourceUpdateType,
reasonText,
reason,
sourceUserUsername,
createdTime,
sourceText,
sourceContractTitle,
sourceContractCreatorUsername,
sourceContractSlug,
} = notification
const [notificationText, setNotificationText] = useState('')
const [contract, setContract] = useState(null)
useEffect(() => {
if (
!sourceContractId ||
(sourceContractSlug && sourceContractCreatorUsername)
)
return
getContractFromId(sourceContractId)
.then((contract) => {
if (contract) setContract(contract)
})
.catch((e) => console.log(e))
}, [
sourceContractCreatorUsername,
sourceContractId,
sourceContractSlug,
sourceContractTitle,
])
useEffect(() => {
if (
sourceText &&
(sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' && sourceUpdateType === 'updated'))
) {
setNotificationText(sourceText)
} else if (!contract || !sourceContractId || !sourceId) return
else if (
sourceType === 'answer' ||
sourceType === 'comment' ||
sourceType === 'contract'
) {
try {
getNotificationText(
sourceId,
sourceContractId,
sourceType,
sourceUpdateType,
setNotificationText,
contract
)
} catch (err) {
console.error(err)
}
} else if (reasonText) {
// Handle arbitrary notifications with reason text here.
setNotificationText(reasonText)
}
}, [
contract,
reasonText,
sourceContractId,
sourceId,
sourceText,
sourceType,
sourceUpdateType,
])
useEffect(() => {
setNotificationsAsSeen([notification])
}, [notification])
function getSourceUrl() {
if (sourceType === 'follow') return `/${sourceUserUsername}`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? ''
)}`
if (!contract) return ''
return `/${contract.creatorUsername}/${
contract.slug
}#${getSourceIdForLinkComponent(sourceId ?? '')}`
}
function getSourceIdForLinkComponent(sourceId: string) {
switch (sourceType) {
case 'answer':
return `answer-${sourceId}`
case 'comment':
return sourceId
case 'contract':
return ''
default:
return sourceId
}
}
async function getNotificationText(
sourceId: string,
sourceContractId: string,
sourceType: 'answer' | 'comment' | 'contract',
sourceUpdateType: notification_source_update_types | undefined,
setText: (text: string) => void,
contract: Contract
) {
if (sourceType === 'contract') {
if (
isNotificationAboutContractResolution(
sourceType,
sourceUpdateType,
contract
) &&
contract.resolution
)
setText(contract.resolution)
else setText(contract.question)
} else if (sourceType === 'answer') {
const answer = await getValue(
doc(db, `contracts/${sourceContractId}/answers/`, sourceId)
)
setText(answer?.text ?? '')
} else {
const comment = await getValue(
doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
)
setText(comment?.text ?? '')
}
}
if (justSummary) {
return (
{sourceType &&
reason &&
getReasonForShowingNotification(
sourceType,
reason,
sourceUpdateType,
contract,
true
).replace(' on', '')}
)
}
return (
)
}
function NotificationTextLabel(props: {
defaultText: string
contract?: Contract | null
notification: Notification
className?: string
}) {
const { contract, className, defaultText, notification } = props
const { sourceUpdateType, sourceType, sourceText, sourceContractTitle } =
notification
if (
!contract &&
!sourceContractTitle &&
!sourceText &&
sourceType !== 'follow'
)
return
if (
isNotificationAboutContractResolution(
sourceType,
sourceUpdateType,
contract
) &&
contract?.resolution
) {
if (contract.outcomeType === 'FREE_RESPONSE') {
return (
)
}
return (
)
} else if (sourceType === 'contract') {
return (
{defaultText}
)
} else {
return (
)
}
}
function getReasonForShowingNotification(
source: notification_source_types,
reason: notification_reason_types,
sourceUpdateType: notification_source_update_types | undefined,
contract: Contract | undefined | null,
simple?: boolean
) {
let reasonText: string
switch (source) {
case 'comment':
if (reason === 'reply_to_users_answer')
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':
if (reason === 'you_follow_user') reasonText = 'created a new question'
else if (
isNotificationAboutContractResolution(
source,
sourceUpdateType,
contract
)
)
reasonText = `resolved`
else reasonText = `updated`
break
case 'answer':
if (reason === 'on_users_contract') reasonText = `answered your question `
else 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:
reasonText = ''
}
return reasonText
}