From f1ae54355d2d0d38e8b73f34069f1ccfc2277cbd Mon Sep 17 00:00:00 2001 From: mantikoros Date: Sun, 2 Oct 2022 20:44:16 -0500 Subject: [PATCH 001/202] cowp: pointer cursor --- web/pages/cowp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/cowp.tsx b/web/pages/cowp.tsx index 21494c37..a854f141 100644 --- a/web/pages/cowp.tsx +++ b/web/pages/cowp.tsx @@ -11,7 +11,7 @@ const App = () => { url="/cowp" /> - + ) From 80693620f00da10afc38ec1bc9970ec84264d787 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 2 Oct 2022 22:46:04 -0500 Subject: [PATCH 002/202] Make alt contract card listen for updates --- web/components/contract/contract-card.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index a8caf7bd..4b4a32b6 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -393,7 +393,9 @@ export function ContractCardProbChange(props: { noLinkAvatar?: boolean className?: string }) { - const { contract, noLinkAvatar, className } = props + const { noLinkAvatar, className } = props + const contract = useContractWithPreload(props.contract) as CPMMBinaryContract + return ( Date: Mon, 3 Oct 2022 08:47:21 +0100 Subject: [PATCH 003/202] Fix indentation iphone --- web/pages/group/[...slugs]/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 658f1809..9ad72e85 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -263,7 +263,7 @@ export default function GroupPage(props: { url={groupPath(group.slug)} /> -
+
{/* TODO: Switching tabs should also update the group path */}
From 3fb43c16c41681c565ab20a7c1ea861e918dcf8a Mon Sep 17 00:00:00 2001 From: Pico2x Date: Mon, 3 Oct 2022 10:02:38 +0100 Subject: [PATCH 004/202] Revert "Merge branch 'main' of https://github.com/manifoldmarkets/manifold" This reverts commit 603201a00ff3ea4ae145c907fefa0c5ccce33c0c, reversing changes made to b517817ee395217dc0f4f853c8efa7801db9f482. --- web/components/analytics/charts.tsx | 139 ++++++++++++++++++ web/components/charts/contract/binary.tsx | 2 - web/components/charts/contract/choice.tsx | 2 - .../charts/contract/pseudo-numeric.tsx | 8 +- web/components/charts/generic-charts.tsx | 19 +-- web/components/charts/helpers.tsx | 12 +- web/components/charts/stats.tsx | 76 ---------- web/components/contract/contract-card.tsx | 4 +- web/components/contract/contract-overview.tsx | 35 +++-- web/components/contract/contract-tabs.tsx | 52 +++---- web/components/sized-container.tsx | 35 ----- web/pages/cowp.tsx | 2 +- web/pages/labs/index.tsx | 2 +- web/pages/stats.tsx | 137 ++++++++++------- 14 files changed, 288 insertions(+), 237 deletions(-) create mode 100644 web/components/analytics/charts.tsx delete mode 100644 web/components/charts/stats.tsx delete mode 100644 web/components/sized-container.tsx diff --git a/web/components/analytics/charts.tsx b/web/components/analytics/charts.tsx new file mode 100644 index 00000000..131ce2a0 --- /dev/null +++ b/web/components/analytics/charts.tsx @@ -0,0 +1,139 @@ +import { Point, ResponsiveLine } from '@nivo/line' +import clsx from 'clsx' +import { formatPercent } from 'common/util/format' +import dayjs from 'dayjs' +import { zip } from 'lodash' +import { useWindowSize } from 'web/hooks/use-window-size' +import { Col } from '../layout/col' + +export function DailyCountChart(props: { + startDate: number + dailyCounts: number[] + small?: boolean +}) { + const { dailyCounts, startDate, small } = props + const { width } = useWindowSize() + + const dates = dailyCounts.map((_, i) => + dayjs(startDate).add(i, 'day').toDate() + ) + + const points = zip(dates, dailyCounts).map(([date, betCount]) => ({ + x: date, + y: betCount, + })) + const data = [{ id: 'Count', data: points, color: '#11b981' }] + + const bottomAxisTicks = width && width < 600 ? 6 : undefined + + return ( +
+ dayjs(date).format('MMM DD'), + }} + colors={{ datum: 'color' }} + pointSize={0} + pointBorderWidth={1} + pointBorderColor="#fff" + enableSlices="x" + enableGridX={!!width && width >= 800} + enableArea + margin={{ top: 20, right: 28, bottom: 22, left: 40 }} + sliceTooltip={({ slice }) => { + const point = slice.points[0] + return + }} + /> +
+ ) +} + +export function DailyPercentChart(props: { + startDate: number + dailyPercent: number[] + small?: boolean + excludeFirstDays?: number +}) { + const { dailyPercent, startDate, small, excludeFirstDays } = props + const { width } = useWindowSize() + + const dates = dailyPercent.map((_, i) => + dayjs(startDate).add(i, 'day').toDate() + ) + + const points = zip(dates, dailyPercent) + .map(([date, percent]) => ({ + x: date, + y: percent, + })) + .slice(excludeFirstDays ?? 0) + const data = [{ id: 'Percent', data: points, color: '#11b981' }] + + const bottomAxisTicks = width && width < 600 ? 6 : undefined + + return ( +
+ dayjs(date).format('MMM DD'), + }} + colors={{ datum: 'color' }} + pointSize={0} + pointBorderWidth={1} + pointBorderColor="#fff" + enableSlices="x" + enableGridX={!!width && width >= 800} + enableArea + margin={{ top: 20, right: 28, bottom: 22, left: 40 }} + sliceTooltip={({ slice }) => { + const point = slice.points[0] + return + }} + /> +
+ ) +} + +function Tooltip(props: { point: Point; isPercent?: boolean }) { + const { point, isPercent } = props + return ( + +
+ {point.serieId}{' '} + {isPercent ? formatPercent(+point.data.y) : Math.round(+point.data.y)} +
+
{dayjs(point.data.x).format('MMM DD')}
+ + ) +} diff --git a/web/components/charts/contract/binary.tsx b/web/components/charts/contract/binary.tsx index c9b3bb0b..7e192767 100644 --- a/web/components/charts/contract/binary.tsx +++ b/web/components/charts/contract/binary.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react' import { last, sortBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3-scale' -import { curveStepAfter } from 'd3-shape' import { Bet } from 'common/bet' import { getProbability, getInitialProbability } from 'common/calculate' @@ -77,7 +76,6 @@ export const BinaryContractChart = (props: { yScale={yScale} data={data} color="#11b981" - curve={curveStepAfter} onMouseOver={onMouseOver} Tooltip={BinaryChartTooltip} pct diff --git a/web/components/charts/contract/choice.tsx b/web/components/charts/contract/choice.tsx index 99e02fa8..65279b70 100644 --- a/web/components/charts/contract/choice.tsx +++ b/web/components/charts/contract/choice.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react' import { last, sum, sortBy, groupBy } from 'lodash' import { scaleTime, scaleLinear } from 'd3-scale' -import { curveStepAfter } from 'd3-shape' import { Bet } from 'common/bet' import { Answer } from 'common/answer' @@ -215,7 +214,6 @@ export const ChoiceContractChart = (props: { yScale={yScale} data={data} colors={CATEGORY_COLORS} - curve={curveStepAfter} onMouseOver={onMouseOver} Tooltip={ChoiceTooltip} pct diff --git a/web/components/charts/contract/pseudo-numeric.tsx b/web/components/charts/contract/pseudo-numeric.tsx index e3edb11f..e03d4ad9 100644 --- a/web/components/charts/contract/pseudo-numeric.tsx +++ b/web/components/charts/contract/pseudo-numeric.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react' import { last, sortBy } from 'lodash' import { scaleTime, scaleLog, scaleLinear } from 'd3-scale' -import { curveStepAfter } from 'd3-shape' import { Bet } from 'common/bet' import { DAY_MS } from 'common/util/time' @@ -86,11 +85,11 @@ export const PseudoNumericContractChart = (props: { Date.now() ) const visibleRange = [start, rightmostDate] - const xScale = scaleTime(visibleRange, [0, width - MARGIN_X]) + const xScale = scaleTime(visibleRange, [0, width ?? 0 - MARGIN_X]) // clamp log scale to make sure zeroes go to the bottom const yScale = isLogScale - ? scaleLog([Math.max(min, 1), max], [height - MARGIN_Y, 0]).clamp(true) - : scaleLinear([min, max], [height - MARGIN_Y, 0]) + ? scaleLog([Math.max(min, 1), max], [height ?? 0 - MARGIN_Y, 0]).clamp(true) + : scaleLinear([min, max], [height ?? 0 - MARGIN_Y, 0]) return ( (props: { color: string xScale: ScaleContinuousNumeric yScale: ScaleContinuousNumeric - curve?: CurveFactory onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent }) => { - const { color, data, yScale, w, h, curve, Tooltip } = props + const { color, data, yScale, w, h, Tooltip } = props const [viewXScale, setViewXScale] = useState>() @@ -101,7 +100,7 @@ export const DistributionChart =

(props: { px={px} py0={py0} py1={py1} - curve={curve ?? curveLinear} + curve={curveLinear} /> ) @@ -114,12 +113,11 @@ export const MultiValueHistoryChart =

(props: { colors: readonly string[] xScale: ScaleTime yScale: ScaleContinuousNumeric - curve?: CurveFactory onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent pct?: boolean }) => { - const { colors, data, yScale, w, h, curve, Tooltip, pct } = props + const { colors, data, yScale, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState>() const xScale = viewXScale ?? props.xScale @@ -179,7 +177,7 @@ export const MultiValueHistoryChart =

(props: { px={px} py0={py0} py1={py1} - curve={curve ?? curveLinear} + curve={curveStepAfter} fill={colors[i]} /> ))} @@ -194,12 +192,11 @@ export const SingleValueHistoryChart =

(props: { color: string xScale: ScaleTime yScale: ScaleContinuousNumeric - curve?: CurveFactory onMouseOver?: (p: P | undefined) => void Tooltip?: TooltipComponent pct?: boolean }) => { - const { color, data, yScale, w, h, curve, Tooltip, pct } = props + const { color, data, yScale, w, h, Tooltip, pct } = props const [viewXScale, setViewXScale] = useState>() const xScale = viewXScale ?? props.xScale @@ -249,7 +246,7 @@ export const SingleValueHistoryChart =

(props: { px={px} py0={py0} py1={py1} - curve={curve ?? curveLinear} + curve={curveStepAfter} /> ) diff --git a/web/components/charts/helpers.tsx b/web/components/charts/helpers.tsx index b40ab7db..96115dc0 100644 --- a/web/components/charts/helpers.tsx +++ b/web/components/charts/helpers.tsx @@ -10,7 +10,7 @@ import { import { pointer, select } from 'd3-selection' import { Axis, AxisScale } from 'd3-axis' import { brushX, D3BrushEvent } from 'd3-brush' -import { area, line, CurveFactory } from 'd3-shape' +import { area, line, curveStepAfter, CurveFactory } from 'd3-shape' import { nanoid } from 'nanoid' import dayjs from 'dayjs' import clsx from 'clsx' @@ -73,11 +73,11 @@ const LinePathInternal = ( data: P[] px: number | ((p: P) => number) py: number | ((p: P) => number) - curve: CurveFactory + curve?: CurveFactory } & SVGProps ) => { const { data, px, py, curve, ...rest } = props - const d3Line = line

(px, py).curve(curve) + const d3Line = line

(px, py).curve(curve ?? curveStepAfter) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return } @@ -89,11 +89,11 @@ const AreaPathInternal = ( px: number | ((p: P) => number) py0: number | ((p: P) => number) py1: number | ((p: P) => number) - curve: CurveFactory + curve?: CurveFactory } & SVGProps ) => { const { data, px, py0, py1, curve, ...rest } = props - const d3Area = area

(px, py0, py1).curve(curve) + const d3Area = area

(px, py0, py1).curve(curve ?? curveStepAfter) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return } @@ -105,7 +105,7 @@ export const AreaWithTopStroke = (props: { px: number | ((p: P) => number) py0: number | ((p: P) => number) py1: number | ((p: P) => number) - curve: CurveFactory + curve?: CurveFactory }) => { const { color, data, px, py0, py1, curve } = props return ( diff --git a/web/components/charts/stats.tsx b/web/components/charts/stats.tsx deleted file mode 100644 index a630657a..00000000 --- a/web/components/charts/stats.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { useMemo } from 'react' -import { scaleTime, scaleLinear } from 'd3-scale' -import { min, max } from 'lodash' -import dayjs from 'dayjs' - -import { formatPercent } from 'common/util/format' -import { Row } from '../layout/row' -import { HistoryPoint, SingleValueHistoryChart } from './generic-charts' -import { TooltipProps, MARGIN_X, MARGIN_Y } from './helpers' -import { SizedContainer } from 'web/components/sized-container' - -const getPoints = (startDate: number, dailyValues: number[]) => { - const startDateDayJs = dayjs(startDate) - return dailyValues.map((y, i) => ({ - x: startDateDayJs.add(i, 'day').toDate(), - y: y, - })) -} - -const DailyCountTooltip = (props: TooltipProps) => { - const { data, mouseX, xScale } = props - const d = xScale.invert(mouseX) - return ( - - {dayjs(d).format('MMM DD')} - {data.y} - - ) -} - -const DailyPercentTooltip = (props: TooltipProps) => { - const { data, mouseX, xScale } = props - const d = xScale.invert(mouseX) - return ( - - {dayjs(d).format('MMM DD')} - {formatPercent(data.y)} - - ) -} - -export function DailyChart(props: { - startDate: number - dailyValues: number[] - excludeFirstDays?: number - pct?: boolean -}) { - const { dailyValues, startDate, excludeFirstDays, pct } = props - - const data = useMemo( - () => getPoints(startDate, dailyValues).slice(excludeFirstDays ?? 0), - [startDate, dailyValues, excludeFirstDays] - ) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const minDate = min(data.map((d) => d.x))! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const maxDate = max(data.map((d) => d.x))! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const maxValue = max(data.map((d) => d.y))! - return ( - - {(width, height) => ( - - )} - - ) -} diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 4b4a32b6..a8caf7bd 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -393,9 +393,7 @@ export function ContractCardProbChange(props: { noLinkAvatar?: boolean className?: string }) { - const { noLinkAvatar, className } = props - const contract = useContractWithPreload(props.contract) as CPMMBinaryContract - + const { contract, noLinkAvatar, className } = props return ( ( @@ -48,18 +49,32 @@ const SizedContractChart = (props: { fullHeight: number mobileHeight: number }) => { - const { fullHeight, mobileHeight, contract, bets } = props + const { contract, bets, fullHeight, mobileHeight } = props + const containerRef = useRef(null) + const [chartWidth, setChartWidth] = useState() + const [chartHeight, setChartHeight] = useState() + useEffect(() => { + const handleResize = () => { + setChartHeight(window.innerWidth < 800 ? mobileHeight : fullHeight) + setChartWidth(containerRef.current?.clientWidth) + } + handleResize() + window.addEventListener('resize', handleResize) + return () => { + window.removeEventListener('resize', handleResize) + } + }, [fullHeight, mobileHeight]) return ( - - {(width, height) => ( +

+ {chartWidth != null && chartHeight != null && ( )} - +
) } @@ -99,11 +114,7 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { - + ('Newest') const me = useUser() - if (comments == null) { return } - - const tipsOrBountiesAwarded = - Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) - - const sortedComments = sortBy(comments, (c) => - sort === 'Newest' - ? c.createdTime - : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score - tipsOrBountiesAwarded && - c.createdTime > Date.now() - 10 * MINUTE_MS && - c.userId === me?.id - ? -Infinity - : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) - ) - - const commentsByParent = groupBy( - sortedComments, - (c) => c.replyToCommentId ?? '_' - ) - const topLevelComments = commentsByParent['_'] ?? [] - // Top level comments are reverse-chronological, while replies are chronological - if (sort === 'Newest') topLevelComments.reverse() - if (contract.outcomeType === 'FREE_RESPONSE') { + const generalComments = comments.filter( + (c) => c.answerOutcome === undefined && c.betId === undefined + ) const sortedAnswers = sortBy( contract.answers, (a) => -getOutcomeProbability(contract, a.id) @@ -113,9 +92,6 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { comments, (c) => c.answerOutcome ?? c.betOutcome ?? '_' ) - const generalTopLevelComments = topLevelComments.filter( - (c) => c.answerOutcome === undefined && c.betId === undefined - ) return ( <> {sortedAnswers.map((answer) => ( @@ -139,12 +115,12 @@ const CommentsTabContent = memo(function CommentsTabContent(props: {
General Comments
- {generalTopLevelComments.map((comment) => ( + {generalComments.map((comment) => ( ))} @@ -152,6 +128,24 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { ) } else { + const tipsOrBountiesAwarded = + Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) + + const commentsByParent = groupBy( + sortBy(comments, (c) => + sort === 'Newest' + ? -c.createdTime + : // Is this too magic? If there are tips/bounties, 'Best' shows your own comments made within the last 10 minutes first, then sorts by score + tipsOrBountiesAwarded && + c.createdTime > Date.now() - 10 * MINUTE_MS && + c.userId === me?.id + ? -Infinity + : -((c.bountiesAwarded ?? 0) + sum(Object.values(tips[c.id] ?? []))) + ), + (c) => c.replyToCommentId ?? '_' + ) + + const topLevelComments = commentsByParent['_'] ?? [] return ( <> diff --git a/web/components/sized-container.tsx b/web/components/sized-container.tsx deleted file mode 100644 index 26532047..00000000 --- a/web/components/sized-container.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { ReactNode, useEffect, useRef, useState } from 'react' - -export const SizedContainer = (props: { - fullHeight: number - mobileHeight: number - mobileThreshold?: number - children: (width: number, height: number) => ReactNode -}) => { - const { children, fullHeight, mobileHeight } = props - const threshold = props.mobileThreshold ?? 800 - const containerRef = useRef(null) - const [width, setWidth] = useState() - const [height, setHeight] = useState() - useEffect(() => { - if (containerRef.current) { - const handleResize = () => { - setHeight(window.innerWidth <= threshold ? mobileHeight : fullHeight) - setWidth(containerRef.current?.clientWidth) - } - handleResize() - const resizeObserver = new ResizeObserver(handleResize) - resizeObserver.observe(containerRef.current) - window.addEventListener('resize', handleResize) - return () => { - window.removeEventListener('resize', handleResize) - resizeObserver.disconnect() - } - } - }, [threshold, fullHeight, mobileHeight]) - return ( -
- {width != null && height != null && children(width, height)} -
- ) -} diff --git a/web/pages/cowp.tsx b/web/pages/cowp.tsx index a854f141..21494c37 100644 --- a/web/pages/cowp.tsx +++ b/web/pages/cowp.tsx @@ -11,7 +11,7 @@ const App = () => { url="/cowp" /> - + ) diff --git a/web/pages/labs/index.tsx b/web/pages/labs/index.tsx index 79f44a64..bd1dbb35 100644 --- a/web/pages/labs/index.tsx +++ b/web/pages/labs/index.tsx @@ -16,7 +16,7 @@ export default function LabsPage() { url="/labs" /> - + <Title className="sm:!mt-0" text="Manifold Labs" /> <Masonry breakpointCols={{ default: 2, 768: 1 }} diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 125af4bd..19fab509 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -1,5 +1,8 @@ import { useEffect, useState } from 'react' -import { DailyChart } from 'web/components/charts/stats' +import { + DailyCountChart, + DailyPercentChart, +} from 'web/components/analytics/charts' import { Col } from 'web/components/layout/col' import { Spacer } from 'web/components/layout/spacer' import { Tabs } from 'web/components/layout/tabs' @@ -93,36 +96,40 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily', content: ( - <DailyChart - dailyValues={dailyActiveUsers} + <DailyCountChart + dailyCounts={dailyActiveUsers} startDate={startDate} + small /> ), }, { title: 'Daily (7d avg)', content: ( - <DailyChart - dailyValues={dailyActiveUsersWeeklyAvg} + <DailyCountChart + dailyCounts={dailyActiveUsersWeeklyAvg} startDate={startDate} + small /> ), }, { title: 'Weekly', content: ( - <DailyChart - dailyValues={weeklyActiveUsers} + <DailyCountChart + dailyCounts={weeklyActiveUsers} startDate={startDate} + small /> ), }, { title: 'Monthly', content: ( - <DailyChart - dailyValues={monthlyActiveUsers} + <DailyCountChart + dailyCounts={monthlyActiveUsers} startDate={startDate} + small /> ), }, @@ -142,44 +149,44 @@ export function CustomAnalytics(props: Stats) { { title: 'D1', content: ( - <DailyChart - dailyValues={d1} + <DailyPercentChart + dailyPercent={d1} startDate={startDate} + small excludeFirstDays={1} - pct /> ), }, { title: 'D1 (7d avg)', content: ( - <DailyChart - dailyValues={d1WeeklyAvg} + <DailyPercentChart + dailyPercent={d1WeeklyAvg} startDate={startDate} + small excludeFirstDays={7} - pct /> ), }, { title: 'W1', content: ( - <DailyChart - dailyValues={weekOnWeekRetention} + <DailyPercentChart + dailyPercent={weekOnWeekRetention} startDate={startDate} + small excludeFirstDays={14} - pct /> ), }, { title: 'M1', content: ( - <DailyChart - dailyValues={monthlyRetention} + <DailyPercentChart + dailyPercent={monthlyRetention} startDate={startDate} + small excludeFirstDays={60} - pct /> ), }, @@ -200,33 +207,33 @@ export function CustomAnalytics(props: Stats) { { title: 'ND1', content: ( - <DailyChart - dailyValues={nd1} + <DailyPercentChart + dailyPercent={nd1} startDate={startDate} excludeFirstDays={1} - pct + small /> ), }, { title: 'ND1 (7d avg)', content: ( - <DailyChart - dailyValues={nd1WeeklyAvg} + <DailyPercentChart + dailyPercent={nd1WeeklyAvg} startDate={startDate} excludeFirstDays={7} - pct + small /> ), }, { title: 'NW1', content: ( - <DailyChart - dailyValues={nw1} + <DailyPercentChart + dailyPercent={nw1} startDate={startDate} excludeFirstDays={14} - pct + small /> ), }, @@ -242,31 +249,41 @@ export function CustomAnalytics(props: Stats) { { title: capitalize(PAST_BETS), content: ( - <DailyChart dailyValues={dailyBetCounts} startDate={startDate} /> + <DailyCountChart + dailyCounts={dailyBetCounts} + startDate={startDate} + small + /> ), }, { title: 'Markets created', content: ( - <DailyChart - dailyValues={dailyContractCounts} + <DailyCountChart + dailyCounts={dailyContractCounts} startDate={startDate} + small /> ), }, { title: 'Comments', content: ( - <DailyChart - dailyValues={dailyCommentCounts} + <DailyCountChart + dailyCounts={dailyCommentCounts} startDate={startDate} + small /> ), }, { title: 'Signups', content: ( - <DailyChart dailyValues={dailySignups} startDate={startDate} /> + <DailyCountChart + dailyCounts={dailySignups} + startDate={startDate} + small + /> ), }, ]} @@ -287,22 +304,22 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily', content: ( - <DailyChart - dailyValues={dailyActivationRate} + <DailyPercentChart + dailyPercent={dailyActivationRate} startDate={startDate} excludeFirstDays={1} - pct + small /> ), }, { title: 'Daily (7d avg)', content: ( - <DailyChart - dailyValues={dailyActivationRateWeeklyAvg} + <DailyPercentChart + dailyPercent={dailyActivationRateWeeklyAvg} startDate={startDate} excludeFirstDays={7} - pct + small /> ), }, @@ -318,33 +335,33 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily / Weekly', content: ( - <DailyChart - dailyValues={dailyDividedByWeekly} + <DailyPercentChart + dailyPercent={dailyDividedByWeekly} startDate={startDate} + small excludeFirstDays={7} - pct /> ), }, { title: 'Daily / Monthly', content: ( - <DailyChart - dailyValues={dailyDividedByMonthly} + <DailyPercentChart + dailyPercent={dailyDividedByMonthly} startDate={startDate} + small excludeFirstDays={30} - pct /> ), }, { title: 'Weekly / Monthly', content: ( - <DailyChart - dailyValues={weeklyDividedByMonthly} + <DailyPercentChart + dailyPercent={weeklyDividedByMonthly} startDate={startDate} + small excludeFirstDays={30} - pct /> ), }, @@ -363,19 +380,31 @@ export function CustomAnalytics(props: Stats) { { title: 'Daily', content: ( - <DailyChart dailyValues={manaBet.daily} startDate={startDate} /> + <DailyCountChart + dailyCounts={manaBet.daily} + startDate={startDate} + small + /> ), }, { title: 'Weekly', content: ( - <DailyChart dailyValues={manaBet.weekly} startDate={startDate} /> + <DailyCountChart + dailyCounts={manaBet.weekly} + startDate={startDate} + small + /> ), }, { title: 'Monthly', content: ( - <DailyChart dailyValues={manaBet.monthly} startDate={startDate} /> + <DailyCountChart + dailyCounts={manaBet.monthly} + startDate={startDate} + small + /> ), }, ]} From 06571a3657e0b9eb9472eb5d02bf321b8017b526 Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Mon, 3 Oct 2022 10:49:19 +0100 Subject: [PATCH 005/202] Flag incorrectly resolved markets, warn about unreliable creators (#945) * Flag incorrectly resolved markets, warn about unreliable creators * Address James' review nits * Added a loading state and some copy-changes * Fix missing refactor * Fix vercel error * Fix merging issues --- common/contract.ts | 1 + common/user.ts | 2 + functions/src/create-user.ts | 1 + functions/src/update-metrics.ts | 27 +++++++ web/components/confirmation-button.tsx | 12 ++- web/components/contract/contract-card.tsx | 22 +++--- web/components/contract/contract-details.tsx | 17 +++- web/components/contract/contract-overview.tsx | 20 ++++- .../contract/contract-report-resolution.tsx | 77 +++++++++++++++++++ 9 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 web/components/contract/contract-report-resolution.tsx diff --git a/common/contract.ts b/common/contract.ts index 2e9d94c4..fb430067 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -62,6 +62,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = { featuredOnHomeRank?: number likedByUserIds?: string[] likedByUserCount?: number + flaggedByUsernames?: string[] openCommentBounties?: number } & T diff --git a/common/user.ts b/common/user.ts index b1365929..233fe4cc 100644 --- a/common/user.ts +++ b/common/user.ts @@ -33,6 +33,8 @@ export type User = { allTime: number } + fractionResolvedCorrectly: number + nextLoanCached: number followerCountCached: number diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index ab70b4e6..c3b7ba1d 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -69,6 +69,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { followerCountCached: 0, followedCategories: DEFAULT_CATEGORIES, shouldShowWelcome: true, + fractionResolvedCorrectly: 1, } await firestore.collection('users').doc(auth.uid).create(user) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 12f41453..70c7c742 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -135,6 +135,28 @@ export async function updateMetricsCore() { lastPortfolio.investmentValue !== newPortfolio.investmentValue const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) + const contractRatios = userContracts + .map((contract) => { + if ( + !contract.flaggedByUsernames || + contract.flaggedByUsernames?.length === 0 + ) { + return 0 + } + const contractRatio = + contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1) + + return contractRatio + }) + .filter((ratio) => ratio > 0) + const badResolutions = contractRatios.filter( + (ratio) => ratio > BAD_RESOLUTION_THRESHOLD + ) + let newFractionResolvedCorrectly = 0 + if (userContracts.length > 0) { + newFractionResolvedCorrectly = + (userContracts.length - badResolutions.length) / userContracts.length + } return { user, @@ -142,6 +164,7 @@ export async function updateMetricsCore() { newPortfolio, newProfit, didPortfolioChange, + newFractionResolvedCorrectly, } }) @@ -163,6 +186,7 @@ export async function updateMetricsCore() { newPortfolio, newProfit, didPortfolioChange, + newFractionResolvedCorrectly, }) => { const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 return { @@ -172,6 +196,7 @@ export async function updateMetricsCore() { creatorVolumeCached: newCreatorVolume, profitCached: newProfit, nextLoanCached, + fractionResolvedCorrectly: newFractionResolvedCorrectly, }, }, @@ -243,3 +268,5 @@ const topUserScores = (scores: { [userId: string]: number }) => { } type GroupContractDoc = { contractId: string; createdTime: number } + +const BAD_RESOLUTION_THRESHOLD = 0.1 diff --git a/web/components/confirmation-button.tsx b/web/components/confirmation-button.tsx index 2ad0cb3d..87c98f0a 100644 --- a/web/components/confirmation-button.tsx +++ b/web/components/confirmation-button.tsx @@ -26,6 +26,7 @@ export function ConfirmationButton(props: { onSubmit?: () => void onOpenChanged?: (isOpen: boolean) => void onSubmitWithSuccess?: () => Promise<boolean> + disabled?: boolean }) { const { openModalBtn, @@ -35,6 +36,7 @@ export function ConfirmationButton(props: { children, onOpenChanged, onSubmitWithSuccess, + disabled, } = props const [open, setOpen] = useState(false) @@ -72,9 +74,15 @@ export function ConfirmationButton(props: { </Row> </Col> </Modal> + <Button - className={clsx(openModalBtn.className)} - onClick={() => updateOpen(true)} + className={openModalBtn.className} + onClick={() => { + if (disabled) { + return + } + updateOpen(true) + }} disabled={openModalBtn.disabled} color={openModalBtn.color} size={openModalBtn.size} diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index a8caf7bd..b783b4e0 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -218,17 +218,19 @@ export function BinaryResolutionOrChance(props: { className={clsx('items-end', large ? 'text-4xl' : 'text-3xl', className)} > {resolution ? ( - <> - <div - className={clsx('text-gray-500', large ? 'text-xl' : 'text-base')} - > - Resolved + <Row className="flex items-start"> + <div> + <div + className={clsx('text-gray-500', large ? 'text-xl' : 'text-base')} + > + Resolved + </div> + <BinaryContractOutcomeLabel + contract={contract} + resolution={resolution} + /> </div> - <BinaryContractOutcomeLabel - contract={contract} - resolution={resolution} - /> - </> + </Row> ) : ( <> {probAfter && probChanged ? ( diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index d0aa0ee9..06fce6aa 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -14,7 +14,7 @@ import { useState } from 'react' import NewContractBadge from '../new-contract-badge' import { MiniUserFollowButton } from '../follow-button' import { DAY_MS } from 'common/util/time' -import { useUser } from 'web/hooks/use-user' +import { useUser, useUserById } from 'web/hooks/use-user' import { exhibitExts } from 'common/util/parse' import { Button } from 'web/components/button' import { Modal } from 'web/components/layout/modal' @@ -28,7 +28,7 @@ import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { Tooltip } from 'web/components/tooltip' import { ExtraContractActionsRow } from './extra-contract-actions-row' -import { PlusCircleIcon } from '@heroicons/react/solid' +import { ExclamationIcon, PlusCircleIcon } from '@heroicons/react/solid' import { GroupLink } from 'common/group' import { Subtitle } from '../subtitle' import { useIsMobile } from 'web/hooks/use-is-mobile' @@ -149,6 +149,8 @@ export function MarketSubheader(props: { const { creatorName, creatorUsername, creatorId, creatorAvatarUrl } = contract const { resolvedDate } = contractMetrics(contract) const user = useUser() + const correctResolutionPercentage = + useUserById(creatorId)?.fractionResolvedCorrectly const isCreator = user?.id === creatorId const isMobile = useIsMobile() return ( @@ -160,13 +162,14 @@ export function MarketSubheader(props: { size={9} className="mr-1.5" /> + {!disabled && ( <div className="absolute mt-3 ml-[11px]"> <MiniUserFollowButton userId={creatorId} /> </div> )} <Col className="text-greyscale-6 ml-2 flex-1 flex-wrap text-sm"> - <Row className="w-full justify-between "> + <Row className="w-full space-x-1 "> {disabled ? ( creatorName ) : ( @@ -177,6 +180,12 @@ export function MarketSubheader(props: { short={isMobile} /> )} + {correctResolutionPercentage != null && + correctResolutionPercentage < BAD_CREATOR_THRESHOLD && ( + <Tooltip text="This creator has a track record of creating contracts that are resolved incorrectly."> + <ExclamationIcon className="h-6 w-6 text-yellow-500" /> + </Tooltip> + )} </Row> <Row className="text-2xs text-greyscale-4 flex-wrap gap-2 sm:text-xs"> <CloseOrResolveTime @@ -487,3 +496,5 @@ function EditableCloseDate(props: { </> ) } + +const BAD_CREATOR_THRESHOLD = 0.8 diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 2a6d5172..9b1461a9 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -24,6 +24,7 @@ import { BinaryContract, } from 'common/contract' import { ContractDetails } from './contract-details' +import { ContractReportResolution } from './contract-report-resolution' const OverviewQuestion = (props: { text: string }) => ( <Linkify className="text-lg text-indigo-700 sm:text-2xl" text={props.text} /> @@ -114,7 +115,16 @@ const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => { <ContractDetails contract={contract} /> <Row className="justify-between gap-4"> <OverviewQuestion text={contract.question} /> - <BinaryResolutionOrChance contract={contract} large /> + <Row> + <BinaryResolutionOrChance + className="flex items-end" + contract={contract} + large + /> + {contract.isResolved && ( + <ContractReportResolution contract={contract} /> + )} + </Row> </Row> </Col> <SizedContractChart @@ -144,7 +154,13 @@ const ChoiceOverview = (props: { <ContractDetails contract={contract} /> <OverviewQuestion text={question} /> {resolution && ( - <FreeResponseResolutionOrChance contract={contract} truncate="none" /> + <Row> + <FreeResponseResolutionOrChance + contract={contract} + truncate="none" + /> + <ContractReportResolution contract={contract} /> + </Row> )} </Col> <SizedContractChart diff --git a/web/components/contract/contract-report-resolution.tsx b/web/components/contract/contract-report-resolution.tsx new file mode 100644 index 00000000..3ae13f93 --- /dev/null +++ b/web/components/contract/contract-report-resolution.tsx @@ -0,0 +1,77 @@ +import { Contract } from 'common/contract' +import { useUser } from 'web/hooks/use-user' +import clsx from 'clsx' +import { updateContract } from 'web/lib/firebase/contracts' +import { Tooltip } from '../tooltip' +import { ConfirmationButton } from '../confirmation-button' +import { Row } from '../layout/row' +import { FlagIcon } from '@heroicons/react/solid' +import { buildArray } from 'common/util/array' +import { useState } from 'react' + +export function ContractReportResolution(props: { contract: Contract }) { + const { contract } = props + const user = useUser() + const [reporting, setReporting] = useState(false) + if (!user) { + return <></> + } + const userReported = contract.flaggedByUsernames?.includes(user.id) + + const onSubmit = async () => { + if (!user || userReported) { + return true + } + setReporting(true) + + await updateContract(contract.id, { + flaggedByUsernames: buildArray(contract.flaggedByUsernames, user.id), + }) + setReporting(false) + return true + } + + const flagClass = clsx( + 'mx-2 flex flex-col items-center gap-1 w-6 h-6 rounded-md !bg-gray-100 px-2 py-1 hover:bg-gray-300', + userReported ? '!text-red-500' : '!text-gray-500' + ) + + return ( + <Tooltip + text={ + userReported + ? "You've reported this market as incorrectly resolved" + : 'Flag this market as incorrectly resolved ' + } + > + <ConfirmationButton + openModalBtn={{ + label: '', + icon: <FlagIcon className="h-5 w-5" />, + className: clsx(flagClass, reporting && 'btn-disabled loading'), + }} + cancelBtn={{ + label: 'Cancel', + className: 'border-none btn-sm btn-ghost self-center', + }} + submitBtn={{ + label: 'Submit', + className: 'btn-secondary', + }} + onSubmitWithSuccess={onSubmit} + disabled={userReported} + > + <div> + <Row className="items-center text-xl"> + Flag this market as incorrectly resolved + </Row> + <Row className="text-sm text-gray-500"> + Report that the market was not resolved according to its resolution + criteria. If a creator's markets get flagged too often, they'll be + marked as unreliable. + </Row> + </div> + </ConfirmationButton> + </Tooltip> + ) +} From 1f7b9174b3b79f6f991c3af2c6d00260bf695776 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 3 Oct 2022 13:45:38 +0100 Subject: [PATCH 006/202] Update index.tsx --- web/pages/group/[...slugs]/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 9ad72e85..35c47010 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -237,6 +237,10 @@ export default function GroupPage(props: { ) const tabs = [ + { + title: 'Overview', + content: overviewPage, + }, { title: 'Markets', content: questionsTab, @@ -249,10 +253,6 @@ export default function GroupPage(props: { title: 'Posts', content: postsPage, }, - { - title: 'Overview', - content: overviewPage, - }, ] return ( From 051c2905e145b72cdb7453d3a614aa29fb246168 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 3 Oct 2022 07:41:39 -0600 Subject: [PATCH 007/202] Allow user to opt out of all unnecessary notifications (#974) * Allow user to opt out of all unnecessary notifications * Unsubscribe from all response ux * Only send one response --- common/notification.ts | 11 +- common/user-notification-preferences.ts | 22 +- .../add-new-notification-preference.ts | 27 ++ functions/src/unsubscribe.ts | 232 +++++++++++++++--- web/components/notification-settings.tsx | 56 ++++- web/components/switch-setting.tsx | 49 ++-- 6 files changed, 341 insertions(+), 56 deletions(-) create mode 100644 functions/src/scripts/add-new-notification-preference.ts diff --git a/common/notification.ts b/common/notification.ts index d91dc300..b75e3d4a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -96,6 +96,7 @@ type notification_descriptions = { [key in notification_preference]: { simple: string detailed: string + necessary?: boolean } } export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { @@ -208,8 +209,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: 'Bonuses for unique predictors on your markets', }, your_contract_closed: { - simple: 'Your market has closed and you need to resolve it', - detailed: 'Your market has closed and you need to resolve it', + simple: 'Your market has closed and you need to resolve it (necessary)', + detailed: 'Your market has closed and you need to resolve it (necessary)', + necessary: true, }, all_comments_on_watched_markets: { simple: 'All new comments', @@ -235,6 +237,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { simple: `Only on markets you're invested in`, detailed: `Answers on markets that you're watching and that you're invested in`, }, + opt_out_all: { + simple: 'Opt out of all notifications (excludes when your markets close)', + detailed: + 'Opt out of all notifications excluding your own market closure notifications', + }, } export type BettingStreakData = { diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index 3fc0fb2f..ba9ade9d 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -53,6 +53,9 @@ export type notification_preferences = { profit_loss_updates: notification_destination_types[] onboarding_flow: notification_destination_types[] thank_you_for_purchases: notification_destination_types[] + + opt_out_all: notification_destination_types[] + // When adding a new notification preference, use add-new-notification-preference.ts to existing users } export const getDefaultNotificationPreferences = ( @@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = ( const email = noEmails ? undefined : emailIf ? 'email' : undefined return filterDefined([browser, email]) as notification_destination_types[] } - return { + const defaults: notification_preferences = { // Watched Markets all_comments_on_watched_markets: constructPref(true, false), all_answers_on_watched_markets: constructPref(true, false), @@ -121,7 +124,10 @@ export const getDefaultNotificationPreferences = ( probability_updates_on_watched_markets: constructPref(true, false), thank_you_for_purchases: constructPref(false, false), onboarding_flow: constructPref(false, false), - } as notification_preferences + + opt_out_all: [], + } + return defaults } // Adding a new key:value here is optional, you can just use a key of notification_subscription_types @@ -184,10 +190,18 @@ export const getNotificationDestinationsForUser = ( ? notificationSettings[subscriptionType] : [] } + const optOutOfAllSettings = notificationSettings['opt_out_all'] + // Your market closure notifications are high priority, opt-out doesn't affect their delivery + const optedOutOfEmail = + optOutOfAllSettings.includes('email') && + subscriptionType !== 'your_contract_closed' + const optedOutOfBrowser = + optOutOfAllSettings.includes('browser') && + subscriptionType !== 'your_contract_closed' const unsubscribeEndpoint = getFunctionUrl('unsubscribe') return { - sendToEmail: destinations.includes('email'), - sendToBrowser: destinations.includes('browser'), + sendToEmail: destinations.includes('email') && !optedOutOfEmail, + sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, } diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts new file mode 100644 index 00000000..d7e7072b --- /dev/null +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -0,0 +1,27 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getAllPrivateUsers } from 'functions/src/utils' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const privateUsers = await getAllPrivateUsers() + await Promise.all( + privateUsers.map((privateUser) => { + if (!privateUser.id) return Promise.resolve() + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationPreferences: { + ...privateUser.notificationPreferences, + opt_out_all: [], + }, + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index 418282c7..57a6d183 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -4,6 +4,7 @@ import { getPrivateUser } from './utils' import { PrivateUser } from '../../common/user' import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' import { notification_preference } from '../../common/user-notification-preferences' +import { getFunctionUrl } from '../../common/api' export const unsubscribe: EndpointDefinition = { opts: { method: 'GET', minInstances: 1 }, @@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = { res.status(400).send('Invalid subscription type parameter.') return } + const optOutAllType: notification_preference = 'opt_out_all' + const wantsToOptOutAll = notificationSubscriptionType === optOutAllType const user = await getPrivateUser(id) @@ -37,17 +40,22 @@ export const unsubscribe: EndpointDefinition = { const update: Partial<PrivateUser> = { notificationPreferences: { ...user.notificationPreferences, - [notificationSubscriptionType]: previousDestinations.filter( - (destination) => destination !== 'email' - ), + [notificationSubscriptionType]: wantsToOptOutAll + ? previousDestinations.push('email') + : previousDestinations.filter( + (destination) => destination !== 'email' + ), }, } await firestore.collection('private-users').doc(id).update(update) + const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - res.send( - ` -<!DOCTYPE html> + const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}` + if (wantsToOptOutAll) { + res.send( + ` + <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> @@ -163,19 +171,6 @@ export const unsubscribe: EndpointDefinition = { </a> </td> </tr> - <tr> - <td align="left" - style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> - <div - style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;"> - <p class="text-build-content" - style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" - data-testid="4XoHRGw1Y"><span - style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> - Hello!</span></p> - </div> - </td> - </tr> <tr> <td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;"> @@ -186,20 +181,9 @@ export const unsubscribe: EndpointDefinition = { data-testid="4XoHRGw1Y"> <span style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;"> - ${email} has been unsubscribed from email notifications related to: + ${email} has opted out of receiving unnecessary email notifications </span> - <br/> - <br/> - <span style="font-weight: bold; color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.</span> - </p> - <br/> - <br/> - <br/> - <span>Click - <a href='https://manifold.markets/notifications?tab=settings'>here</a> - to manage the rest of your notification settings. - </span> </div> </td> @@ -219,9 +203,193 @@ export const unsubscribe: EndpointDefinition = { </div> </div> </body> +</html>` + ) + } else { + res.send( + ` +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" + xmlns:o="urn:schemas-microsoft-com:office:office"> + +<head> + <title>Manifold Markets 7th Day Anniversary Gift! + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + banner logo + +
+
+

+ Hello!

+
+
+
+

+ + ${email} has been unsubscribed from email notifications related to: + +
+
+ + ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}. +

+
+
+
+ Click + here + to unsubscribe from all unnecessary emails. + +
+
+ Click + here + to manage the rest of your notification settings. + +
+ +
+

+
+
+
+
+
+ ` - ) + ) + } }, } diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index f0b9591e..1b5cac40 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -10,6 +10,7 @@ import { ChevronDownIcon, ChevronUpIcon, CurrencyDollarIcon, + ExclamationIcon, InboxInIcon, InformationCircleIcon, LightBulbIcon, @@ -63,6 +64,7 @@ export function NotificationSettings(props: { 'contract_from_followed_user', 'unique_bettors_on_your_contract', 'profit_loss_updates', + 'opt_out_all', // TODO: add these // biggest winner, here are the rest of your markets @@ -157,20 +159,56 @@ export function NotificationSettings(props: { ], } + const optOut: SectionData = { + label: 'Opt Out', + subscriptionTypes: ['opt_out_all'], + } + function NotificationSettingLine(props: { description: string subscriptionTypeKey: notification_preference destinations: notification_destination_types[] + optOutAll: notification_destination_types[] }) { - const { description, subscriptionTypeKey, destinations } = props + const { description, subscriptionTypeKey, destinations, optOutAll } = props const previousInAppValue = destinations.includes('browser') const previousEmailValue = destinations.includes('email') const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue) const [emailEnabled, setEmailEnabled] = useState(previousEmailValue) + const [error, setError] = useState('') const loading = 'Changing Notifications Settings' const success = 'Changed Notification Settings!' const highlight = navigateToSection === subscriptionTypeKey + const attemptToChangeSetting = ( + setting: 'browser' | 'email', + newValue: boolean + ) => { + const necessaryError = + 'This notification type is necessary. At least one destination must be enabled.' + const necessarySetting = + NOTIFICATION_DESCRIPTIONS[subscriptionTypeKey].necessary + if ( + necessarySetting && + setting === 'browser' && + !emailEnabled && + !newValue + ) { + setError(necessaryError) + return + } else if ( + necessarySetting && + setting === 'email' && + !inAppEnabled && + !newValue + ) { + setError(necessaryError) + return + } + + changeSetting(setting, newValue) + } + const changeSetting = (setting: 'browser' | 'email', newValue: boolean) => { toast .promise( @@ -212,18 +250,21 @@ export function NotificationSettings(props: { {!browserDisabled.includes(subscriptionTypeKey) && ( changeSetting('browser', newVal)} + onChange={(newVal) => attemptToChangeSetting('browser', newVal)} label={'Web'} + disabled={optOutAll.includes('browser')} /> )} {emailsEnabled.includes(subscriptionTypeKey) && ( changeSetting('email', newVal)} + onChange={(newVal) => attemptToChangeSetting('email', newVal)} label={'Email'} + disabled={optOutAll.includes('email')} /> )} + {error && {error}} ) @@ -283,6 +324,11 @@ export function NotificationSettings(props: { subType as notification_preference )} description={NOTIFICATION_DESCRIPTIONS[subType].simple} + optOutAll={ + subType === 'opt_out_all' || subType === 'your_contract_closed' + ? [] + : getUsersSavedPreference('opt_out_all') + } /> ))} @@ -332,6 +378,10 @@ export function NotificationSettings(props: { icon={} data={generalOther} /> +
} + data={optOut} + />
diff --git a/web/components/switch-setting.tsx b/web/components/switch-setting.tsx index 0e93c420..608b936b 100644 --- a/web/components/switch-setting.tsx +++ b/web/components/switch-setting.tsx @@ -1,33 +1,52 @@ import { Switch } from '@headlessui/react' import clsx from 'clsx' import React from 'react' +import { Tooltip } from 'web/components/tooltip' export const SwitchSetting = (props: { checked: boolean onChange: (checked: boolean) => void label: string + disabled: boolean }) => { - const { checked, onChange, label } = props + const { checked, onChange, label, disabled } = props return ( - - + disabled={disabled} + > + ) From 1caf75d3b56d082f75c23e971a3328e9a862aa42 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 3 Oct 2022 07:49:26 -0600 Subject: [PATCH 008/202] Do not refund comment bounties --- functions/src/on-update-contract.ts | 67 +---------------------------- 1 file changed, 1 insertion(+), 66 deletions(-) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index d667f0d2..301d6286 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,11 +1,7 @@ import * as functions from 'firebase-functions' -import { getUser, getValues, log } from './utils' +import { getUser } from './utils' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Contract } from '../../common/contract' -import { Txn } from '../../common/txn' -import { partition, sortBy } from 'lodash' -import { runTxn, TxnData } from './transact' -import * as admin from 'firebase-admin' export const onUpdateContract = functions.firestore .document('contracts/{contractId}') @@ -20,7 +16,6 @@ export const onUpdateContract = functions.firestore contract.isResolved && (openCommentBounties ?? 0) > 0 ) { - await handleUnusedCommentBountyRefunds(contract) // No need to notify users of resolution, that's handled in resolve-market return } @@ -56,63 +51,3 @@ async function handleUpdatedCloseTime( contract ) } - -async function handleUnusedCommentBountyRefunds(contract: Contract) { - const outstandingCommentBounties = await getValues( - firestore.collection('txns').where('category', '==', 'COMMENT_BOUNTY') - ) - - const commentBountiesOnThisContract = sortBy( - outstandingCommentBounties.filter( - (bounty) => bounty.data?.contractId === contract.id - ), - (bounty) => bounty.createdTime - ) - - const [toBank, fromBank] = partition( - commentBountiesOnThisContract, - (bounty) => bounty.toType === 'BANK' - ) - if (toBank.length <= fromBank.length) return - - await firestore - .collection('contracts') - .doc(contract.id) - .update({ openCommentBounties: 0 }) - - const refunds = toBank.slice(fromBank.length) - await Promise.all( - refunds.map(async (extraBountyTxn) => { - const result = await firestore.runTransaction(async (trans) => { - const bonusTxn: TxnData = { - fromId: extraBountyTxn.toId, - fromType: 'BANK', - toId: extraBountyTxn.fromId, - toType: 'USER', - amount: extraBountyTxn.amount, - token: 'M$', - category: 'REFUND_COMMENT_BOUNTY', - data: { - contractId: contract.id, - }, - } - return await runTxn(trans, bonusTxn) - }) - - if (result.status != 'success' || !result.txn) { - log( - `Couldn't refund bonus for user: ${extraBountyTxn.fromId} - status:`, - result.status - ) - log('message:', result.message) - } else { - log( - `Refund bonus txn for user: ${extraBountyTxn.fromId} completed:`, - result.txn?.id - ) - } - }) - ) -} - -const firestore = admin.firestore() From 27e6534d94d09673337d8b110784c4625eb36b7b Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 3 Oct 2022 08:15:27 -0600 Subject: [PATCH 009/202] Persist preferred comment sort order by contract --- web/components/contract/contract-tabs.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index bf18fdbe..0b3585ca 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -31,6 +31,11 @@ import { useUser } from 'web/hooks/use-user' import { Tooltip } from 'web/components/tooltip' import { BountiedContractSmallBadge } from 'web/components/contract/bountied-contract-badge' import { Row } from '../layout/row' +import { + storageStore, + usePersistentState, +} from 'web/hooks/use-persistent-state' +import { safeLocalStorage } from 'web/lib/util/local' export function ContractTabs(props: { contract: Contract @@ -75,7 +80,10 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { const { contract } = props const tips = useTipTxns({ contractId: contract.id }) const comments = useComments(contract.id) ?? props.comments - const [sort, setSort] = useState<'Newest' | 'Best'>('Newest') + const [sort, setSort] = usePersistentState<'Newest' | 'Best'>('Newest', { + key: `contract-${contract.id}-comments-sort`, + store: storageStore(safeLocalStorage()), + }) const me = useUser() if (comments == null) { return From f5a3abf0bc0e2198dc6e5eeee24b8c9efc99690b Mon Sep 17 00:00:00 2001 From: FRC Date: Mon, 3 Oct 2022 15:27:15 +0100 Subject: [PATCH 010/202] Add spinner (#987) --- web/components/groups/group-overview.tsx | 171 ++++++++++++----------- 1 file changed, 88 insertions(+), 83 deletions(-) diff --git a/web/components/groups/group-overview.tsx b/web/components/groups/group-overview.tsx index 9b0f7240..6da9540a 100644 --- a/web/components/groups/group-overview.tsx +++ b/web/components/groups/group-overview.tsx @@ -37,6 +37,7 @@ import { REFERRAL_AMOUNT } from 'common/economy' import toast from 'react-hot-toast' import { ENV_CONFIG } from 'common/envs/constants' import { PostCard } from '../post-card' +import { LoadingIndicator } from '../loading-indicator' const MAX_TRENDING_POSTS = 6 @@ -140,93 +141,97 @@ function GroupOverviewPinned(props: { setOpen(false) } - return isEditable || pinned.length > 0 ? ( - <> - - - {isEditable && ( - - )} - + return isEditable || (group.pinnedItems && group.pinnedItems.length > 0) ? ( + pinned.length > 0 || isEditable ? (
- - {pinned.length == 0 && !editMode && ( -
-

- No pinned items yet. Click the edit button to add some! -

-
- )} - {pinned.map((element, index) => ( -
- {element} - - {editMode && ( - { - const newPinned = group.pinnedItems.filter((item) => { - return item.itemId !== group.pinnedItems[index].itemId - }) - updateGroup(group, { pinnedItems: newPinned }) - }} - /> + + + {isEditable && ( +
- ))} - {editMode && group.pinnedItems && pinned.length < 6 && ( -
- - - -
+ )} -
+ +
+ + {pinned.length == 0 && !editMode && ( +
+

+ No pinned items yet. Click the edit button to add some! +

+
+ )} + {pinned.map((element, index) => ( +
+ {element} + + {editMode && ( + { + const newPinned = group.pinnedItems.filter((item) => { + return item.itemId !== group.pinnedItems[index].itemId + }) + updateGroup(group, { pinnedItems: newPinned }) + }} + /> + )} +
+ ))} + {editMode && group.pinnedItems && pinned.length < 6 && ( +
+ + + +
+ )} +
+
+ + Pin posts or markets to the overview of this group. +
+ } + onSubmit={onSubmit} + />
- - Pin posts or markets to the overview of this group. - - } - onSubmit={onSubmit} - /> - + ) : ( + + ) ) : ( <> ) From 370edec89052cc298b288ac6490a6364af11e8a2 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 3 Oct 2022 08:30:21 -0600 Subject: [PATCH 011/202] Remove unsubscribe options for market closure --- functions/src/email-templates/market-close.html | 6 +----- web/components/contract/contract-tabs.tsx | 2 ++ web/components/notification-settings.tsx | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 4abd225e..b742c533 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -483,11 +483,7 @@ color: #999; text-decoration: underline; margin: 0; - ">our Discord! Or, - click here to unsubscribe from this type of notification. + ">our Discord! diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 0b3585ca..c4fddca5 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -136,6 +136,8 @@ const CommentsTabContent = memo(function CommentsTabContent(props: { ) } else { + // TODO: links to comments are broken because tips load after render and + // comments will reorganize themselves if there are tips/bounties awarded const tipsOrBountiesAwarded = Object.keys(tips).length > 0 || comments.some((c) => c.bountiesAwarded) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 1b5cac40..ad9adbdf 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -118,7 +118,7 @@ export function NotificationSettings(props: { const yourMarkets: SectionData = { label: 'Markets You Created', subscriptionTypes: [ - 'your_contract_closed', + // 'your_contract_closed', 'all_comments_on_my_markets', 'all_answers_on_my_markets', 'subsidized_your_market', From f92f098f829777a94b869a4255d62dc604786b5e Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 3 Oct 2022 09:26:39 -0600 Subject: [PATCH 012/202] Allo creators to unlist markets --- common/contract.ts | 1 + firestore.rules | 2 +- .../contract/contract-info-dialog.tsx | 43 +++++++++++-------- .../contract/extra-contract-actions-row.tsx | 2 +- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/common/contract.ts b/common/contract.ts index fb430067..1255874d 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -64,6 +64,7 @@ export type Contract = { likedByUserCount?: number flaggedByUsernames?: string[] openCommentBounties?: number + unlistedById?: string } & T export type BinaryContract = Contract & Binary diff --git a/firestore.rules b/firestore.rules index bf0375e6..50f93e1f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -102,7 +102,7 @@ service cloud.firestore { allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['description', 'closeTime', 'question']) + .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById']) && resource.data.creatorId == request.auth.uid; allow update: if isAdmin(); match /comments/{commentId} { diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index df6695ed..1cae98f9 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -19,7 +19,7 @@ import { deleteField } from 'firebase/firestore' import ShortToggle from '../widgets/short-toggle' import { DuplicateContractButton } from '../copy-contract-button' import { Row } from '../layout/row' -import { BETTORS } from 'common/user' +import { BETTORS, User } from 'common/user' import { Button } from '../button' export const contractDetailsButtonClassName = @@ -27,9 +27,10 @@ export const contractDetailsButtonClassName = export function ContractInfoDialog(props: { contract: Contract + user: User | null | undefined className?: string }) { - const { contract, className } = props + const { contract, className, user } = props const [open, setOpen] = useState(false) const [featured, setFeatured] = useState( @@ -37,6 +38,10 @@ export function ContractInfoDialog(props: { ) const isDev = useDev() const isAdmin = useAdmin() + const isCreator = user?.id === contract.creatorId + const isUnlisted = contract.visibility === 'unlisted' + const wasUnlistedByCreator = + contract.unlistedById && contract.unlistedById === contract.creatorId const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a') @@ -175,21 +180,25 @@ export function ContractInfoDialog(props: { )} - {isAdmin && ( - - [ADMIN] Unlisted - - - updateContract(id, { - visibility: b ? 'unlisted' : 'public', - }) - } - /> - - - )} + {user && + (isAdmin || + (isCreator && + (isUnlisted ? wasUnlistedByCreator : true))) && ( + + {isAdmin ? '[ADMIN]' : ''} Unlisted + + + updateContract(id, { + visibility: b ? 'unlisted' : 'public', + unlistedById: b ? user.id : '', + }) + } + /> + + + )} diff --git a/web/components/contract/extra-contract-actions-row.tsx b/web/components/contract/extra-contract-actions-row.tsx index d9474806..809c6172 100644 --- a/web/components/contract/extra-contract-actions-row.tsx +++ b/web/components/contract/extra-contract-actions-row.tsx @@ -35,7 +35,7 @@ export function ExtraContractActionsRow(props: { contract: Contract }) { /> - + ) } From adb8bc476f25a4c555d59cb433fdb493ed968b85 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Mon, 3 Oct 2022 09:36:49 -0600 Subject: [PATCH 013/202] Show whether market is unlisted --- .../contract/contract-info-dialog.tsx | 49 ++++++++++--------- web/components/widgets/short-toggle.tsx | 21 +++++--- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 1cae98f9..306b2587 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -40,8 +40,9 @@ export function ContractInfoDialog(props: { const isAdmin = useAdmin() const isCreator = user?.id === contract.creatorId const isUnlisted = contract.visibility === 'unlisted' - const wasUnlistedByCreator = - contract.unlistedById && contract.unlistedById === contract.creatorId + const wasUnlistedByCreator = contract.unlistedById + ? contract.unlistedById === contract.creatorId + : true const formatTime = (dt: number) => dayjs(dt).format('MMM DD, YYYY hh:mm a') @@ -173,32 +174,34 @@ export function ContractInfoDialog(props: { [ADMIN] Featured )} - {user && - (isAdmin || - (isCreator && - (isUnlisted ? wasUnlistedByCreator : true))) && ( - - {isAdmin ? '[ADMIN]' : ''} Unlisted - - - updateContract(id, { - visibility: b ? 'unlisted' : 'public', - unlistedById: b ? user.id : '', - }) - } - /> - - - )} + {user && ( + + {isAdmin ? '[ADMIN]' : ''} Unlisted + + + updateContract(id, { + visibility: b ? 'unlisted' : 'public', + unlistedById: b ? user.id : '', + }) + } + /> + + + )} diff --git a/web/components/widgets/short-toggle.tsx b/web/components/widgets/short-toggle.tsx index 339de361..d8baeebe 100644 --- a/web/components/widgets/short-toggle.tsx +++ b/web/components/widgets/short-toggle.tsx @@ -3,22 +3,27 @@ import { Switch } from '@headlessui/react' import clsx from 'clsx' export default function ShortToggle(props: { - enabled: boolean - setEnabled: (enabled: boolean) => void + on: boolean + setOn: (enabled: boolean) => void + disabled?: boolean onChange?: (enabled: boolean) => void }) { - const { enabled, setEnabled } = props + const { on, setOn, disabled } = props return ( { - setEnabled(e) + setOn(e) if (props.onChange) { props.onChange(e) } }} - className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" + className={clsx( + 'group relative inline-flex h-5 w-10 flex-shrink-0 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2', + !disabled ? 'cursor-pointer' : 'cursor-not-allowed opacity-50' + )} > Use setting