diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts index c99e670f..73f9fd01 100644 --- a/common/pseudo-numeric.ts +++ b/common/pseudo-numeric.ts @@ -16,8 +16,8 @@ export const getMappedValue = const { min, max, isLogScale } = contract if (isLogScale) { - const logValue = p * Math.log10(max - min) - return 10 ** logValue + min + const logValue = p * Math.log10(max - min + 1) + return 10 ** logValue + min - 1 } return p * (max - min) + min @@ -38,7 +38,7 @@ export const getPseudoProbability = ( isLogScale = false ) => { if (isLogScale) { - return Math.log10(value - min) / Math.log10(max - min) + return Math.log10(value - min + 1) / Math.log10(max - min + 1) } return (value - min) / (max - min) diff --git a/common/util/format.ts b/common/util/format.ts index 7dc1a341..4f123535 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -33,20 +33,24 @@ export function formatPercent(zeroToOne: number) { return (zeroToOne * 100).toFixed(decimalPlaces) + '%' } +const showPrecision = (x: number, sigfigs: number) => + // convert back to number for weird formatting reason + `${Number(x.toPrecision(sigfigs))}` + // Eg 1234567.89 => 1.23M; 5678 => 5.68K export function formatLargeNumber(num: number, sigfigs = 2): string { const absNum = Math.abs(num) - if (absNum < 1) return num.toPrecision(sigfigs) + if (absNum < 1) return showPrecision(num, sigfigs) - if (absNum < 100) return num.toPrecision(2) - if (absNum < 1000) return num.toPrecision(3) - if (absNum < 10000) return num.toPrecision(4) + if (absNum < 100) return showPrecision(num, 2) + if (absNum < 1000) return showPrecision(num, 3) + if (absNum < 10000) return showPrecision(num, 4) const suffix = ['', 'K', 'M', 'B', 'T', 'Q'] const i = Math.floor(Math.log10(absNum) / 3) - const numStr = (num / Math.pow(10, 3 * i)).toPrecision(sigfigs) - return `${numStr}${suffix[i]}` + const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs) + return `${numStr}${suffix[i] ?? ''}` } export function toCamelCase(words: string) { diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 60534679..a29f982c 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -302,7 +302,7 @@ export const sendNewCommentEmail = async ( )}` } - const subject = `Comment from ${commentorName} on ${question}` + const subject = `Comment on ${question}` const from = `${commentorName} on Manifold ` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 351b012e..0cbee7b5 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -50,14 +50,10 @@ export function BetPanel(props: { const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) const unfilledBets = useUnfilledBets(contract.id) ?? [] - const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const [isLimitOrder, setIsLimitOrder] = useState(false) - const showLimitOrders = - (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 - return ( - {showLimitOrders && ( + {unfilledBets.length > 0 && ( )} @@ -105,9 +101,6 @@ export function SimpleBetPanel(props: { const [isLimitOrder, setIsLimitOrder] = useState(false) const unfilledBets = useUnfilledBets(contract.id) ?? [] - const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) - const showLimitOrders = - (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 return ( @@ -138,7 +131,7 @@ export function SimpleBetPanel(props: { - {showLimitOrders && ( + {unfilledBets.length > 0 && ( )} diff --git a/web/components/contract/contract-prob-graph.tsx b/web/components/contract/contract-prob-graph.tsx index a9d26e2e..c6e17cd6 100644 --- a/web/components/contract/contract-prob-graph.tsx +++ b/web/components/contract/contract-prob-graph.tsx @@ -7,7 +7,6 @@ import { Bet } from 'common/bet' import { getInitialProbability } from 'common/calculate' import { BinaryContract, PseudoNumericContract } from 'common/contract' import { useWindowSize } from 'web/hooks/use-window-size' -import { getMappedValue } from 'common/pseudo-numeric' import { formatLargeNumber } from 'common/util/format' export const ContractProbGraph = memo(function ContractProbGraph(props: { @@ -29,7 +28,11 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { ...bets.map((bet) => bet.createdTime), ].map((time) => new Date(time)) - const f = getMappedValue(contract) + const f: (p: number) => number = isBinary + ? (p) => p + : isLogScale + ? (p) => p * Math.log10(contract.max - contract.min + 1) + : (p) => p * (contract.max - contract.min) + contract.min const probs = [startProb, ...bets.map((bet) => bet.probAfter)].map(f) @@ -69,10 +72,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const points: { x: Date; y: number }[] = [] const s = isBinary ? 100 : 1 - const c = isLogScale && contract.min === 0 ? 1 : 0 for (let i = 0; i < times.length - 1; i++) { - points[points.length] = { x: times[i], y: s * probs[i] + c } + points[points.length] = { x: times[i], y: s * probs[i] } const numPoints: number = Math.floor( dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep ) @@ -84,7 +86,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { x: dayjs(times[i]) .add(thisTimeStep * n, 'ms') .toDate(), - y: s * probs[i] + c, + y: s * probs[i], } } } @@ -99,6 +101,9 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { const formatter = isBinary ? formatPercent + : isLogScale + ? (x: DatumValue) => + formatLargeNumber(10 ** +x.valueOf() + contract.min - 1) : (x: DatumValue) => formatLargeNumber(+x.valueOf()) return ( @@ -111,11 +116,13 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: { yScale={ isBinary ? { min: 0, max: 100, type: 'linear' } - : { - min: contract.min + c, - max: contract.max + c, - type: contract.isLogScale ? 'log' : 'linear', + : isLogScale + ? { + min: 0, + max: Math.log10(contract.max - contract.min + 1), + type: 'linear', } + : { min: contract.min, max: contract.max, type: 'linear' } } yFormat={formatter} gridYValues={yTickValues} diff --git a/web/components/groups/group-selector.tsx b/web/components/groups/group-selector.tsx index 2417403a..c7b4cb39 100644 --- a/web/components/groups/group-selector.tsx +++ b/web/components/groups/group-selector.tsx @@ -53,9 +53,8 @@ export function GroupSelector(props: { nullable={true} className={'text-sm'} > - {({ open }) => ( + {() => ( <> - {!open && setQuery('')} Add to Group diff --git a/web/components/limit-bets.tsx b/web/components/limit-bets.tsx index 7e32db25..7aaa0601 100644 --- a/web/components/limit-bets.tsx +++ b/web/components/limit-bets.tsx @@ -78,8 +78,8 @@ export function LimitOrderTable(props: { {!isYou && } Outcome - Amount {isPseudoNumeric ? 'Value' : 'Prob'} + Amount {isYou && } @@ -129,12 +129,12 @@ function LimitBet(props: { )} - {formatMoney(orderAmount - amount)} {isPseudoNumeric ? getFormattedMappedValue(contract)(limitProb) : formatPercent(limitProb)} + {formatMoney(orderAmount - amount)} {isYou && ( {isCancelling ? ( diff --git a/web/components/pagination.tsx b/web/components/pagination.tsx index a585985d..069ebda7 100644 --- a/web/components/pagination.tsx +++ b/web/components/pagination.tsx @@ -4,8 +4,18 @@ export function Pagination(props: { totalItems: number setPage: (page: number) => void scrollToTop?: boolean + nextTitle?: string + prevTitle?: string }) { - const { page, itemsPerPage, totalItems, setPage, scrollToTop } = props + const { + page, + itemsPerPage, + totalItems, + setPage, + scrollToTop, + nextTitle, + prevTitle, + } = props const maxPage = Math.ceil(totalItems / itemsPerPage) - 1 @@ -25,19 +35,21 @@ export function Pagination(props: {

diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index f5502b85..a3ddeb29 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { notification_subscribe_types, PrivateUser } from 'common/user' import { Notification } from 'common/notification' import { @@ -6,7 +6,7 @@ import { listenForNotifications, } from 'web/lib/firebase/notifications' import { groupBy, map } from 'lodash' -import { useFirestoreQuery } from '@react-query-firebase/firestore' +import { useFirestoreQueryData } from '@react-query-firebase/firestore' import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' export type NotificationGroup = { @@ -19,36 +19,33 @@ export type NotificationGroup = { // For some reason react-query subscriptions don't actually listen for notifications // Use useUnseenPreferredNotificationGroups to listen for new notifications -export function usePreferredGroupedNotifications(privateUser: PrivateUser) { - const [notificationGroups, setNotificationGroups] = useState< - NotificationGroup[] | undefined - >(undefined) - const [notifications, setNotifications] = useState([]) - const key = `notifications-${privateUser.id}-all` +export function usePreferredGroupedNotifications( + privateUser: PrivateUser, + cachedNotifications?: Notification[] +) { + const result = useFirestoreQueryData( + ['notifications-all', privateUser.id], + getNotificationsQuery(privateUser.id) + ) + const notifications = useMemo(() => { + if (result.isLoading) return cachedNotifications ?? [] + if (!result.data) return cachedNotifications ?? [] + const notifications = result.data as Notification[] - const result = useFirestoreQuery([key], getNotificationsQuery(privateUser.id)) - useEffect(() => { - if (result.isLoading) return - if (!result.data) return setNotifications([]) - const notifications = result.data.docs.map( - (doc) => doc.data() as Notification - ) - - const notificationsToShow = getAppropriateNotifications( + return getAppropriateNotifications( notifications, privateUser.notificationPreferences ).filter((n) => !n.isSeenOnHref) - setNotifications(notificationsToShow) - }, [privateUser.notificationPreferences, result.data, result.isLoading]) + }, [ + cachedNotifications, + privateUser.notificationPreferences, + result.data, + result.isLoading, + ]) - useEffect(() => { - if (!notifications) return - - const groupedNotifications = groupNotifications(notifications) - setNotificationGroups(groupedNotifications) + return useMemo(() => { + if (notifications) return groupNotifications(notifications) }, [notifications]) - - return notificationGroups } export function useUnseenPreferredNotificationGroups(privateUser: PrivateUser) { diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index a2248c2e..5c9a247f 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -53,9 +53,12 @@ export function useInitialQueryAndSort(options?: { console.log('ready loading from storage ', sort ?? defaultSort) const localSort = getSavedSort() if (localSort) { - router.query.s = localSort // Use replace to not break navigating back. - router.replace(router, undefined, { shallow: true }) + router.replace( + { query: { ...router.query, s: localSort } }, + undefined, + { shallow: true } + ) } setInitialSort(localSort ?? defaultSort) } else { @@ -79,7 +82,9 @@ export function useUpdateQueryAndSort(props: { const setSort = (sort: Sort | undefined) => { if (sort !== router.query.s) { router.query.s = sort - router.push(router, undefined, { shallow: true }) + router.replace({ query: { ...router.query, s: sort } }, undefined, { + shallow: true, + }) if (shouldLoadFromStorage) { localStorage.setItem(MARKETS_SORT, sort || '') } @@ -97,7 +102,9 @@ export function useUpdateQueryAndSort(props: { } else { delete router.query.q } - router.push(router, undefined, { shallow: true }) + router.replace({ query: router.query }, undefined, { + shallow: true, + }) track('search', { query }) }, 500), [router] diff --git a/web/lib/firebase/auth.ts b/web/lib/firebase/auth.ts index d1c440ec..b6daea6e 100644 --- a/web/lib/firebase/auth.ts +++ b/web/lib/firebase/auth.ts @@ -6,7 +6,7 @@ const TOKEN_KINDS = ['refresh', 'id'] as const type TokenKind = typeof TOKEN_KINDS[number] const getAuthCookieName = (kind: TokenKind) => { - const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replaceAll('-', '_') + const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_') return `FIREBASE_TOKEN_${suffix}` } diff --git a/web/pages/create.tsx b/web/pages/create.tsx index a978af9f..b3a9e662 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -206,7 +206,7 @@ export function NewContract(props: { min, max, initialValue, - isLogScale: (min ?? 0) < 0 ? false : isLogScale, + isLogScale, groupId: selectedGroup?.id, }) ) @@ -293,15 +293,13 @@ export function NewContract(props: { /> - {!(min !== undefined && min < 0) && ( - setIsLogScale(!isLogScale)} - disabled={isSubmitting} - /> - )} + setIsLogScale(!isLogScale)} + disabled={isSubmitting} + /> {min !== undefined && max !== undefined && min >= max && (
diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 9166109f..7867e197 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,6 +1,6 @@ import { Tabs } from 'web/components/layout/tabs' import { usePrivateUser, useUser } from 'web/hooks/use-user' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' @@ -14,9 +14,14 @@ import { MANIFOLD_USERNAME, notification_subscribe_types, PrivateUser, + User, } from 'common/user' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' +import { + getUser, + listenForPrivateUser, + updatePrivateUser, +} from 'web/lib/firebase/users' import { LoadingIndicator } from 'web/components/loading-indicator' import clsx from 'clsx' import { RelativeTimestamp } from 'web/components/relative-timestamp' @@ -42,15 +47,39 @@ import Custom404 from 'web/pages/404' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' -import Router from 'next/router' +import { safeLocalStorage } from 'web/lib/util/local' +import { + getServerAuthenticatedUid, + redirectIfLoggedOut, +} from 'web/lib/firebase/server-auth' +import { SiteLink } from 'web/components/site-link' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' const HIGHLIGHT_CLASS = 'bg-indigo-50' -export default function Notifications() { - const user = useUser() +export const getServerSideProps = redirectIfLoggedOut('/', async (ctx) => { + const uid = await getServerAuthenticatedUid(ctx) + if (!uid) { + return { props: { user: null } } + } + const user = await getUser(uid) + return { props: { user } } +}) + +export default function Notifications(props: { user: User }) { + const { user } = props const privateUser = usePrivateUser(user?.id) + const local = safeLocalStorage() + let localNotifications = [] as Notification[] + const localSavedNotificationGroups = local?.getItem('notification-groups') + let localNotificationGroups = [] as NotificationGroup[] + if (localSavedNotificationGroups) { + localNotificationGroups = JSON.parse(localSavedNotificationGroups) + localNotifications = localNotificationGroups + .map((g) => g.notifications) + .flat() + } if (!user) return return ( @@ -67,7 +96,17 @@ export default function Notifications() { { title: 'Notifications', content: privateUser ? ( - + + ) : localNotificationGroups && + localNotificationGroups.length > 0 ? ( +
+ +
) : ( ), @@ -88,39 +127,13 @@ export default function Notifications() { ) } -function NotificationsList(props: { privateUser: PrivateUser }) { - const { privateUser } = props - const [page, setPage] = useState(0) - const allGroupedNotifications = usePreferredGroupedNotifications(privateUser) - const [paginatedGroupedNotifications, setPaginatedGroupedNotifications] = - useState(undefined) - - useEffect(() => { - if (!allGroupedNotifications) return - const start = page * 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 || !allGroupedNotifications) - return - +function RenderNotificationGroups(props: { + notificationGroups: NotificationGroup[] +}) { + const { notificationGroups } = props return ( -
- {paginatedGroupedNotifications.length === 0 && ( -
- You don't have any notifications. Try changing your settings to see - more. -
- )} - - {paginatedGroupedNotifications.map((notification) => + <> + {notificationGroups.map((notification) => notification.type === 'income' ? ( ) )} + + ) +} + +function NotificationsList(props: { + privateUser: PrivateUser + cachedNotifications: Notification[] +}) { + const { privateUser, cachedNotifications } = props + const [page, setPage] = useState(0) + const allGroupedNotifications = usePreferredGroupedNotifications( + privateUser, + cachedNotifications + ) + const paginatedGroupedNotifications = useMemo(() => { + if (!allGroupedNotifications) return + const start = page * 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) + } + const local = safeLocalStorage() + local?.setItem( + 'notification-groups', + JSON.stringify(allGroupedNotifications) + ) + return maxNotificationsToShow + }, [allGroupedNotifications, page]) + + if (!paginatedGroupedNotifications || !allGroupedNotifications) return
+ + return ( +
+ {paginatedGroupedNotifications.length === 0 && ( +
+ You don't have any notifications. Try changing your settings to see + more. +
+ )} + + {paginatedGroupedNotifications.length > 0 && allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( )}
@@ -382,7 +443,11 @@ function IncomeNotificationItem(props: { highlighted && HIGHLIGHT_CLASS )} > - +
+ ) } @@ -597,24 +662,24 @@ function NotificationItem(props: { highlighted && HIGHLIGHT_CLASS )} > -
{ - event.stopPropagation() - Router.push(getSourceUrl(notification) ?? '') - track('Notification Clicked', { - type: 'notification item', - sourceType, - sourceUserName, - sourceUserAvatarUrl, - sourceUpdateType, - reasonText, - reason, - sourceUserUsername, - sourceText, - }) - }} - > +
+ + track('Notification Clicked', { + type: 'notification item', + sourceType, + sourceUserName, + sourceUserAvatarUrl, + sourceUpdateType, + reasonText, + reason, + sourceUserUsername, + sourceText, + }) + } + /> )} @@ -706,10 +771,8 @@ function QuestionOrGroupLink(props: { ) return ( - {sourceContractTitle || sourceTitle} - + ) }