diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index b9449ea0..0739da75 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -12,7 +12,7 @@ import { import clsx from 'clsx' import Link from 'next/link' import { useRouter } from 'next/router' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { firebaseLogout, User } from 'web/lib/firebase/users' import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' @@ -26,8 +26,9 @@ import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' import { Spacer } from '../layout/spacer' -import { usePreferredNotifications } from 'web/hooks/use-notifications' +import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { setNotificationsAsSeen } from 'web/pages/notifications' +import { PrivateUser } from 'common/lib/user' function getNavigation() { return [ @@ -186,6 +187,7 @@ export default function Sidebar(props: { className?: string }) { const currentPage = router.pathname const user = useUser() + const privateUser = usePrivateUser(user?.id) const navigationOptions = !user ? signedOutNavigation : getNavigation() const mobileNavigationOptions = !user ? signedOutMobileNavigation @@ -220,11 +222,13 @@ export default function Sidebar(props: { className?: string }) { /> )} - + {privateUser && ( + + )} {/* Desktop navigation */} @@ -243,11 +247,13 @@ export default function Sidebar(props: { className?: string }) {
)} - + {privateUser && ( + + )} ) @@ -256,11 +262,10 @@ export default function Sidebar(props: { className?: string }) { function GroupsList(props: { currentPage: string memberItems: Item[] - user: User | null | undefined + privateUser: PrivateUser }) { - const { currentPage, memberItems, user } = props - const preferredNotifications = usePreferredNotifications(user?.id, { - unseenOnly: true, + const { currentPage, memberItems, privateUser } = props + const preferredNotifications = useUnseenPreferredNotifications(privateUser, { customHref: '/group/', }) diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index 8f45a054..5f36bb46 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -4,45 +4,57 @@ import { Row } from 'web/components/layout/row' import { useEffect, useState } from 'react' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useRouter } from 'next/router' -import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications' +import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' import { requestBonuses } from 'web/lib/firebase/api-call' +import { PrivateUser } from 'common/lib/user' export default function NotificationsIcon(props: { className?: string }) { const user = useUser() const privateUser = usePrivateUser(user?.id) - const notifications = usePreferredGroupedNotifications(privateUser?.id, { - unseenOnly: true, - }) - const [seen, setSeen] = useState(false) useEffect(() => { - if (!privateUser) return - - if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 65 * 1000) - requestBonuses({}).catch((error) => { - console.log("couldn't get bonuses:", error.message) - }) - }, [privateUser]) - - const router = useRouter() - useEffect(() => { - if (router.pathname.endsWith('notifications')) return setSeen(true) - else setSeen(false) - }, [router.pathname]) + if (user) { + const bonusChecker = setTimeout(() => { + requestBonuses({}).catch((error) => { + console.log("couldn't get bonuses:", error.message) + }) + return () => { + clearInterval(bonusChecker) + } + }, 1000 * 120) + } + }, [user]) return (
- {!seen && notifications && notifications.length > 0 && ( -
- {notifications.length > NOTIFICATIONS_PER_PAGE - ? `${NOTIFICATIONS_PER_PAGE}+` - : notifications.length} -
- )} + {privateUser && }
) } +function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) { + const router = useRouter() + const { privateUser } = props + const [seen, setSeen] = useState(false) + + useEffect(() => { + if (router.pathname.endsWith('notifications')) return setSeen(true) + else setSeen(false) + }, [router.pathname]) + + const notifications = useUnseenPreferredNotificationGroups(privateUser) + if (!notifications || notifications.length === 0 || seen) { + return
+ } + + return ( +
+ {notifications.length > NOTIFICATIONS_PER_PAGE + ? `${NOTIFICATIONS_PER_PAGE}+` + : notifications.length} +
+ ) +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 98b0f2fd..8e68fdcb 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,9 +1,12 @@ 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' +import { + getNotificationsQuery, + listenForNotifications, +} from 'web/lib/firebase/notifications' import { groupBy, map } from 'lodash' +import { useFirestoreQuery } from '@react-query-firebase/firestore' export type NotificationGroup = { notifications: Notification[] @@ -13,15 +16,36 @@ export type NotificationGroup = { type: 'income' | 'normal' } -export function usePreferredGroupedNotifications( - userId: string | undefined, - options: { unseenOnly: boolean } -) { +// This doesn't listen for new notifications, use firebase listener for that +export function usePreferredGroupedNotifications(privateUser: PrivateUser) { const [notificationGroups, setNotificationGroups] = useState< NotificationGroup[] | undefined >(undefined) + const [notifications, setNotifications] = useState([]) + const key = `notifications-${privateUser.id}-all` + + const result = useFirestoreQuery( + [key], + getNotificationsQuery(privateUser.id, false), + { + // subscribe: false, + // includeMetadataChanges: true, + } + ) + useEffect(() => { + if (result.isLoading) return + if (!result.data) return setNotifications([]) + const notifications = result.data.docs.map( + (doc) => doc.data() as Notification + ) + + const notificationsToShow = getAppropriateNotifications( + notifications, + privateUser.notificationPreferences + ).filter((n) => !n.isSeenOnHref) + setNotifications(notificationsToShow) + }, [privateUser.notificationPreferences, result.data, result.isLoading]) - const notifications = usePreferredNotifications(userId, options) useEffect(() => { if (!notifications) return @@ -32,6 +56,20 @@ export function usePreferredGroupedNotifications( return notificationGroups } +export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) { + const notifications = useUnseenPreferredNotifications(privateUser, {}) + const [notificationGroups, setNotificationGroups] = useState< + NotificationGroup[] | undefined + >(undefined) + 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) => @@ -85,32 +123,17 @@ export function groupNotifications(notifications: Notification[]) { return notificationGroups } -export function usePreferredNotifications( - userId: string | undefined, - options: { unseenOnly: boolean; customHref?: string } +export function useUnseenPreferredNotifications( + privateUser: PrivateUser, + options: { customHref?: string } ) { - const { unseenOnly, customHref } = options - const [privateUser, setPrivateUser] = useState(null) + const { customHref } = options const [notifications, setNotifications] = useState([]) const [userAppropriateNotifications, setUserAppropriateNotifications] = useState([]) + listenForNotifications(privateUser.id, setNotifications, true) useEffect(() => { - if (userId) listenForPrivateUser(userId, setPrivateUser) - }, [userId]) - - useEffect(() => { - if (privateUser) - return listenForNotifications( - privateUser.id, - setNotifications, - unseenOnly - ) - }, [privateUser, unseenOnly]) - - useEffect(() => { - if (!privateUser) return - const notificationsToShow = getAppropriateNotifications( notifications, privateUser.notificationPreferences @@ -118,7 +141,7 @@ export function usePreferredNotifications( customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref ) setUserAppropriateNotifications(notificationsToShow) - }, [privateUser, notifications, customHref]) + }, [notifications, customHref, privateUser.notificationPreferences]) return userAppropriateNotifications } @@ -131,7 +154,7 @@ const lessPriorityReasons = [ // 'on_contract_with_users_shares_in', ] -function getAppropriateNotifications( +export function getAppropriateNotifications( notifications: Notification[], notificationPreferences?: notification_subscribe_types ) { diff --git a/web/lib/firebase/notifications.ts b/web/lib/firebase/notifications.ts index c0dca8be..302c4d55 100644 --- a/web/lib/firebase/notifications.ts +++ b/web/lib/firebase/notifications.ts @@ -1,12 +1,17 @@ -import { collection, query, where } from 'firebase/firestore' +import { collection, orderBy, query, where } from 'firebase/firestore' import { Notification } from 'common/notification' import { db } from 'web/lib/firebase/init' import { listenForValues } from 'web/lib/firebase/utils' -function getNotificationsQuery(userId: string, unseenOnly?: boolean) { +export function getNotificationsQuery(userId: string, unseenOnly?: boolean) { const notifsCollection = collection(db, `/users/${userId}/notifications`) - if (unseenOnly) return query(notifsCollection, where('isSeen', '==', false)) - return query(notifsCollection) + if (unseenOnly) + return query( + notifsCollection, + where('isSeen', '==', false), + orderBy('createdTime', 'desc') + ) + return query(notifsCollection, orderBy('createdTime', 'desc')) } export function listenForNotifications( @@ -19,6 +24,7 @@ export function listenForNotifications( (notifs) => { notifs.sort((n1, n2) => n2.createdTime - n1.createdTime) setNotifications(notifs) - } + }, + true ) } diff --git a/web/lib/firebase/utils.ts b/web/lib/firebase/utils.ts index e63c2d96..38bcb493 100644 --- a/web/lib/firebase/utils.ts +++ b/web/lib/firebase/utils.ts @@ -39,12 +39,13 @@ export function listenForValue( export function listenForValues( query: Query, - setValues: (values: T[]) => void + setValues: (values: T[]) => void, + enableCache?: boolean ) { // Exclude cached snapshots so we only trigger on fresh data. // includeMetadataChanges ensures listener is called even when server data is the same as cached data. return onSnapshot(query, { includeMetadataChanges: true }, (snapshot) => { - if (snapshot.metadata.fromCache) return + if (snapshot.metadata.fromCache && !enableCache) return const values = snapshot.docs.map((doc) => doc.data() as T) setValues(values) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 08ef9bb8..c9c8ace5 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,5 +1,5 @@ import { Tabs } from 'web/components/layout/tabs' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import React, { useEffect, useState } from 'react' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' @@ -9,7 +9,6 @@ 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 Custom404 from 'web/pages/404' import { UserLink } from 'web/components/user-page' import { notification_subscribe_types, PrivateUser } from 'common/user' import { Contract } from 'common/contract' @@ -35,137 +34,144 @@ import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' import { groupBy, sum, uniq } from 'lodash' +import Custom404 from 'web/pages/404' +import { Col } from 'web/components/layout/col' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' export default function Notifications() { const user = useUser() - const [page, setPage] = useState(1) - - const groupedNotifications = usePreferredGroupedNotifications(user?.id, { - unseenOnly: false, - }) - const [paginatedNotificationGroups, setPaginatedNotificationGroups] = - useState([]) - useEffect(() => { - if (!groupedNotifications) return - const start = (page - 1) * NOTIFICATIONS_PER_PAGE - const end = start + NOTIFICATIONS_PER_PAGE - const maxNotificationsToShow = groupedNotifications.slice(start, end) - const remainingNotification = groupedNotifications.slice(end) - for (const notification of remainingNotification) { - if (notification.isSeen) break - else setNotificationsAsSeen(notification.notifications) - } - setPaginatedNotificationGroups(maxNotificationsToShow) - }, [groupedNotifications, page]) - - if (user === undefined) { - return - } - if (user === null) { - return - } + const privateUser = usePrivateUser(user?.id) + if (!user) return return (
- <Tabs - labelClassName={'pb-2 pt-1 '} - defaultIndex={0} - tabs={[ - { - title: 'Notifications', - content: groupedNotifications ? ( - <div className={''}> - {paginatedNotificationGroups.length === 0 && - "You don't have any notifications. Try changing your settings to see more."} - {paginatedNotificationGroups.map((notification) => - notification.type === 'income' ? ( - <IncomeNotificationGroupItem - notificationGroup={notification} - key={notification.groupedById + notification.timePeriod} - /> - ) : notification.notifications.length === 1 ? ( - <NotificationItem - notification={notification.notifications[0]} - key={notification.notifications[0].id} - /> - ) : ( - <NotificationGroupItem - notificationGroup={notification} - key={notification.groupedById + notification.timePeriod} - /> - ) - )} - {groupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( - <nav - className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" - aria-label="Pagination" - > - <div className="hidden sm:block"> - <p className="text-sm text-gray-700"> - Showing{' '} - <span className="font-medium"> - {page === 1 - ? page - : (page - 1) * NOTIFICATIONS_PER_PAGE} - </span>{' '} - to{' '} - <span className="font-medium"> - {page * NOTIFICATIONS_PER_PAGE} - </span>{' '} - of{' '} - <span className="font-medium"> - {groupedNotifications.length} - </span>{' '} - results - </p> - </div> - <div className="flex flex-1 justify-between sm:justify-end"> - <a - href="#" - className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => page > 1 && setPage(page - 1)} - > - Previous - </a> - <a - href="#" - className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" - onClick={() => - page < - groupedNotifications?.length / - NOTIFICATIONS_PER_PAGE && setPage(page + 1) - } - > - Next - </a> - </div> - </nav> - )} - </div> - ) : ( - <LoadingIndicator /> - ), - }, - { - title: 'Settings', - content: ( - <div className={''}> - <NotificationSettings /> - </div> - ), - }, - ]} - /> + <div> + <Tabs + labelClassName={'pb-2 pt-1 '} + className={'mb-0 sm:mb-2'} + defaultIndex={0} + tabs={[ + { + title: 'Notifications', + content: privateUser ? ( + <NotificationsList privateUser={privateUser} /> + ) : ( + <LoadingIndicator /> + ), + }, + { + title: 'Settings', + content: ( + <div className={''}> + <NotificationSettings /> + </div> + ), + }, + ]} + /> + </div> </div> </Page> ) } +function NotificationsList(props: { privateUser: PrivateUser }) { + const { privateUser } = props + const [page, setPage] = useState(1) + const allGroupedNotifications = usePreferredGroupedNotifications(privateUser) + const [paginatedGroupedNotifications, setPaginatedGroupedNotifications] = + useState<NotificationGroup[] | undefined>(undefined) + + useEffect(() => { + if (!allGroupedNotifications) return + const start = (page - 1) * NOTIFICATIONS_PER_PAGE + const end = start + NOTIFICATIONS_PER_PAGE + const maxNotificationsToShow = allGroupedNotifications.slice(start, end) + const remainingNotification = allGroupedNotifications.slice(end) + for (const notification of remainingNotification) { + if (notification.isSeen) break + else setNotificationsAsSeen(notification.notifications) + } + setPaginatedGroupedNotifications(maxNotificationsToShow) + }, [allGroupedNotifications, page]) + + if (!paginatedGroupedNotifications) return <LoadingIndicator /> + + return ( + <Col className={'min-h-screen'}> + {paginatedGroupedNotifications.length === 0 && + "You don't have any notifications. Try changing your settings to see more."} + {paginatedGroupedNotifications.map((notification) => + notification.type === 'income' ? ( + <IncomeNotificationGroupItem + notificationGroup={notification} + key={notification.groupedById + notification.timePeriod} + /> + ) : notification.notifications.length === 1 ? ( + <NotificationItem + notification={notification.notifications[0]} + key={notification.notifications[0].id} + /> + ) : ( + <NotificationGroupItem + notificationGroup={notification} + key={notification.groupedById + notification.timePeriod} + /> + ) + )} + {allGroupedNotifications && + allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( + <nav + className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6" + aria-label="Pagination" + > + <div className="hidden sm:block"> + <p className="text-sm text-gray-700"> + Showing{' '} + <span className="font-medium"> + {page === 1 ? page : (page - 1) * NOTIFICATIONS_PER_PAGE} + </span>{' '} + to{' '} + <span className="font-medium"> + {page * NOTIFICATIONS_PER_PAGE} + </span>{' '} + of{' '} + <span className="font-medium"> + {allGroupedNotifications.length} + </span>{' '} + results + </p> + </div> + <div className="flex flex-1 justify-between sm:justify-end"> + <a + href="#" + className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => page > 1 && setPage(page - 1)} + > + Previous + </a> + <a + href="#" + className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50" + onClick={() => + page < + allGroupedNotifications?.length / NOTIFICATIONS_PER_PAGE && + setPage(page + 1) + } + > + Next + </a> + </div> + </nav> + )} + </Col> + ) +} + function IncomeNotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string