From c1287a4a25afa7f9406929eca95420c72ba7c7c9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 12 Sep 2022 00:39:04 -0500 Subject: [PATCH 01/32] Small updates to experimental home (#870) * Line clamp question in prob change table * Tweaks * Expand option for daily movers * Snap scrolling for carousel * Add arrows to section headers * Remove carousel from experimental/home * React querify fetching your groups * Edit home is its own page * Add daily profit and balance * Merge branch 'main' into new-home * Make experimental search by your followed groups/creators * Just submit, allow xs on pills * Weigh in * Use next/future/image component to optimize avatar images * Inga/challenge icon (#857) * changed challenge icon to custom icon * fixed tip button alignment * weighing in and trading "weigh in" for "trade" * Delete closing soon, mark new as New for you, trending is site-wide * Delete your trades. Factor out section item * Don't allow hiding of home sections * Convert daily movers into a section * Tweaks for loading daily movers * Prob change table shows variable number of rows * Fix double negative Co-authored-by: Ian Philips Co-authored-by: Austin Chen Co-authored-by: ingawei <46611122+ingawei@users.noreply.github.com> Co-authored-by: mantikoros --- common/user.ts | 2 +- web/components/arrange-home.tsx | 103 +++++++--------- web/components/contract/prob-change-table.tsx | 113 +++++++++--------- web/pages/experimental/home/edit.tsx | 11 +- web/pages/experimental/home/index.tsx | 68 +++++++---- 5 files changed, 146 insertions(+), 151 deletions(-) diff --git a/common/user.ts b/common/user.ts index 0e333278..f15865cf 100644 --- a/common/user.ts +++ b/common/user.ts @@ -34,7 +34,7 @@ export type User = { followerCountCached: number followedCategories?: string[] - homeSections?: { visible: string[]; hidden: string[] } + homeSections?: string[] referredByUserId?: string referredByContractId?: string diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index ae02e3ea..25e814b8 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -13,19 +13,13 @@ import { Group } from 'common/group' export function ArrangeHome(props: { user: User | null | undefined - homeSections: { visible: string[]; hidden: string[] } - setHomeSections: (homeSections: { - visible: string[] - hidden: string[] - }) => void + homeSections: string[] + setHomeSections: (sections: string[]) => void }) { const { user, homeSections, setHomeSections } = props const groups = useMemberGroups(user?.id) ?? [] - const { itemsById, visibleItems, hiddenItems } = getHomeItems( - groups, - homeSections - ) + const { itemsById, sections } = getHomeItems(groups, homeSections) return ( item.id), - hidden: hiddenItems.map((item) => item.id), - } + const newHomeSections = sections.map((section) => section.id) - const sourceSection = source.droppableId as 'visible' | 'hidden' - newHomeSections[sourceSection].splice(source.index, 1) - - const destSection = destination.droppableId as 'visible' | 'hidden' - newHomeSections[destSection].splice(destination.index, 0, item.id) + newHomeSections.splice(source.index, 1) + newHomeSections.splice(destination.index, 0, item.id) setHomeSections(newHomeSections) }} > - - - + + ) @@ -64,16 +51,13 @@ function DraggableList(props: { const { title, items } = props return ( - {(provided, snapshot) => ( + {(provided) => ( - + {items.map((item, index) => ( {(provided, snapshot) => ( @@ -82,16 +66,13 @@ function DraggableList(props: { {...provided.draggableProps} {...provided.dragHandleProps} style={provided.draggableProps.style} - className={clsx( - 'flex flex-row items-center gap-4 rounded bg-gray-50 p-2', - snapshot.isDragging && 'z-[9000] bg-gray-300' - )} > - @@ -103,15 +84,33 @@ function DraggableList(props: { ) } -export const getHomeItems = ( - groups: Group[], - homeSections: { visible: string[]; hidden: string[] } -) => { +const SectionItem = (props: { + item: { id: string; label: string } + className?: string +}) => { + const { item, className } = props + + return ( +
+
+ ) +} + +export const getHomeItems = (groups: Group[], sections: string[]) => { const items = [ + { label: 'Daily movers', id: 'daily-movers' }, { label: 'Trending', id: 'score' }, - { label: 'Newest', id: 'newest' }, - { label: 'Close date', id: 'close-date' }, - { label: 'Your trades', id: 'your-bets' }, + { label: 'New for you', id: 'newest' }, ...groups.map((g) => ({ label: g.name, id: g.id, @@ -119,23 +118,13 @@ export const getHomeItems = ( ] const itemsById = keyBy(items, 'id') - const { visible, hidden } = homeSections + const sectionItems = filterDefined(sections.map((id) => itemsById[id])) - const [visibleItems, hiddenItems] = [ - filterDefined(visible.map((id) => itemsById[id])), - filterDefined(hidden.map((id) => itemsById[id])), - ] - - // Add unmentioned items to the visible list. - visibleItems.push( - ...items.filter( - (item) => !visibleItems.includes(item) && !hiddenItems.includes(item) - ) - ) + // Add unmentioned items to the end. + sectionItems.push(...items.filter((item) => !sectionItems.includes(item))) return { - visibleItems, - hiddenItems, + sections: sectionItems, itemsById, } } diff --git a/web/components/contract/prob-change-table.tsx b/web/components/contract/prob-change-table.tsx index f973d260..49216b88 100644 --- a/web/components/contract/prob-change-table.tsx +++ b/web/components/contract/prob-change-table.tsx @@ -2,74 +2,69 @@ import clsx from 'clsx' import { contractPath } from 'web/lib/firebase/contracts' import { CPMMContract } from 'common/contract' import { formatPercent } from 'common/util/format' -import { useProbChanges } from 'web/hooks/use-prob-changes' -import { linkClass, SiteLink } from '../site-link' +import { SiteLink } from '../site-link' import { Col } from '../layout/col' import { Row } from '../layout/row' -import { useState } from 'react' +import { LoadingIndicator } from '../loading-indicator' -export function ProbChangeTable(props: { userId: string | undefined }) { - const { userId } = props +export function ProbChangeTable(props: { + changes: + | { positiveChanges: CPMMContract[]; negativeChanges: CPMMContract[] } + | undefined +}) { + const { changes } = props - const changes = useProbChanges(userId ?? '') - const [expanded, setExpanded] = useState(false) - - if (!changes) { - return null - } - - const count = expanded ? 16 : 4 + if (!changes) return const { positiveChanges, negativeChanges } = changes - const filteredPositiveChanges = positiveChanges.slice(0, count / 2) - const filteredNegativeChanges = negativeChanges.slice(0, count / 2) - const filteredChanges = [ - ...filteredPositiveChanges, - ...filteredNegativeChanges, - ] + + const threshold = 0.075 + const countOverThreshold = Math.max( + positiveChanges.findIndex((c) => c.probChanges.day < threshold) + 1, + negativeChanges.findIndex((c) => c.probChanges.day > -threshold) + 1 + ) + const maxRows = Math.min(positiveChanges.length, negativeChanges.length) + const rows = Math.min(3, Math.min(maxRows, countOverThreshold)) + + const filteredPositiveChanges = positiveChanges.slice(0, rows) + const filteredNegativeChanges = negativeChanges.slice(0, rows) + + if (rows === 0) return
None
return ( - - - - {filteredChanges.slice(0, count / 2).map((contract) => ( - - - - {contract.question} - - - ))} - - - {filteredChanges.slice(count / 2).map((contract) => ( - - - - {contract.question} - - - ))} - + + + {filteredPositiveChanges.map((contract) => ( + + + + {contract.question} + + + ))} + + + {filteredNegativeChanges.map((contract) => ( + + + + {contract.question} + + + ))} -
setExpanded(!expanded)} - > - {expanded ? 'Show less' : 'Show more'} -
) } diff --git a/web/pages/experimental/home/edit.tsx b/web/pages/experimental/home/edit.tsx index 2cba3f19..2ed9d2dd 100644 --- a/web/pages/experimental/home/edit.tsx +++ b/web/pages/experimental/home/edit.tsx @@ -16,14 +16,9 @@ export default function Home() { useTracking('edit home') - const [homeSections, setHomeSections] = useState( - user?.homeSections ?? { visible: [], hidden: [] } - ) + const [homeSections, setHomeSections] = useState(user?.homeSections ?? []) - const updateHomeSections = (newHomeSections: { - visible: string[] - hidden: string[] - }) => { + const updateHomeSections = (newHomeSections: string[]) => { if (!user) return updateUser(user.id, { homeSections: newHomeSections }) setHomeSections(newHomeSections) @@ -31,7 +26,7 @@ export default function Home() { return ( - + <DoneButton /> diff --git a/web/pages/experimental/home/index.tsx b/web/pages/experimental/home/index.tsx index 90b4f888..08f502b6 100644 --- a/web/pages/experimental/home/index.tsx +++ b/web/pages/experimental/home/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React from 'react' import Router from 'next/router' import { PencilIcon, @@ -28,6 +28,7 @@ import { groupPath } from 'web/lib/firebase/groups' import { usePortfolioHistory } from 'web/hooks/use-portfolio-history' import { calculatePortfolioProfit } from 'common/calculate-metrics' import { formatMoney } from 'common/util/format' +import { useProbChanges } from 'web/hooks/use-prob-changes' const Home = () => { const user = useUser() @@ -38,10 +39,7 @@ const Home = () => { const groups = useMemberGroups(user?.id) ?? [] - const [homeSections] = useState( - user?.homeSections ?? { visible: [], hidden: [] } - ) - const { visibleItems } = getHomeItems(groups, homeSections) + const { sections } = getHomeItems(groups, user?.homeSections ?? []) return ( <Page> @@ -54,29 +52,19 @@ const Home = () => { <DailyProfitAndBalance userId={user?.id} /> - <div className="text-xl text-gray-800">Daily movers</div> - <ProbChangeTable userId={user?.id} /> - - {visibleItems.map((item) => { + {sections.map((item) => { const { id } = item - if (id === 'your-bets') { - return ( - <SearchSection - key={id} - label={'Your trades'} - sort={'newest'} - user={user} - yourBets - /> - ) + if (id === 'daily-movers') { + return <DailyMoversSection key={id} userId={user?.id} /> } const sort = SORTS.find((sort) => sort.value === id) if (sort) return ( <SearchSection key={id} - label={sort.label} + label={sort.value === 'newest' ? 'New for you' : sort.label} sort={sort.value} + followed={sort.value === 'newest'} user={user} /> ) @@ -103,11 +91,12 @@ const Home = () => { function SearchSection(props: { label: string - user: User | null | undefined + user: User | null | undefined | undefined sort: Sort yourBets?: boolean + followed?: boolean }) { - const { label, user, sort, yourBets } = props + const { label, user, sort, yourBets, followed } = props const href = `/home?s=${sort}` return ( @@ -122,7 +111,13 @@ function SearchSection(props: { <ContractSearch user={user} defaultSort={sort} - additionalFilter={yourBets ? { yourBets: true } : { followed: true }} + additionalFilter={ + yourBets + ? { yourBets: true } + : followed + ? { followed: true } + : undefined + } noControls maxResults={6} persistPrefix={`experimental-home-${sort}`} @@ -131,7 +126,10 @@ function SearchSection(props: { ) } -function GroupSection(props: { group: Group; user: User | null | undefined }) { +function GroupSection(props: { + group: Group + user: User | null | undefined | undefined +}) { const { group, user } = props return ( @@ -155,6 +153,24 @@ function GroupSection(props: { group: Group; user: User | null | undefined }) { ) } +function DailyMoversSection(props: { userId: string | null | undefined }) { + const { userId } = props + const changes = useProbChanges(userId ?? '') + + return ( + <Col className="gap-2"> + <SiteLink className="text-xl" href={'/daily-movers'}> + Daily movers{' '} + <ArrowSmRightIcon + className="mb-0.5 inline h-6 w-6 text-gray-500" + aria-hidden="true" + /> + </SiteLink> + <ProbChangeTable changes={changes} /> + </Col> + ) +} + function EditButton(props: { className?: string }) { const { className } = props @@ -186,14 +202,14 @@ function DailyProfitAndBalance(props: { return ( <div className={clsx(className, 'text-lg')}> <span className={clsx(profit >= 0 ? 'text-green-500' : 'text-red-500')}> - {profit >= 0 ? '+' : '-'} + {profit >= 0 && '+'} {formatMoney(profit)} </span>{' '} profit and{' '} <span className={clsx(balanceChange >= 0 ? 'text-green-500' : 'text-red-500')} > - {balanceChange >= 0 ? '+' : '-'} + {balanceChange >= 0 && '+'} {formatMoney(balanceChange)} </span>{' '} balance today From a6ed8c92282dd26310ee6b7c029365065ec53fdd Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Mon, 12 Sep 2022 16:44:24 +0100 Subject: [PATCH 02/32] Fix "500 internal error" in large groups (#856) * Members to memberIds * Moved to update-metrics --- common/group.ts | 15 ++ functions/src/update-metrics.ts | 72 +++++++- web/lib/firebase/groups.ts | 5 +- web/pages/group/[...slugs]/index.tsx | 263 +++++++-------------------- 4 files changed, 145 insertions(+), 210 deletions(-) diff --git a/common/group.ts b/common/group.ts index 19f3b7b8..36654101 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,7 +12,22 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number + /** @deprecated - members and contracts now stored as subcollections*/ + memberIds?: string[] // Deprecated + /** @deprecated - members and contracts now stored as subcollections*/ + contractIds?: string[] // Deprecated + cachedLeaderboard?: { + topTraders: { + userId: string + score: number + }[] + topCreators: { + userId: string + score: number + }[] + } } + export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 430f3d33..273cd098 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' + import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' +import { scoreTraders, scoreCreators } from '../../common/scoring' import { calculateCreatorVolume, calculateNewPortfolioMetrics, @@ -15,6 +17,7 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' +import { Group } from 'common/group' const firestore = admin.firestore() @@ -24,16 +27,29 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ - getValues<User>(firestore.collection('users')), - getValues<Contract>(firestore.collection('contracts')), - getValues<Bet>(firestore.collectionGroup('bets')), - getValues<PortfolioMetrics>( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - ]) + const [users, contracts, bets, allPortfolioHistories, groups] = + await Promise.all([ + getValues<User>(firestore.collection('users')), + getValues<Contract>(firestore.collection('contracts')), + getValues<Bet>(firestore.collectionGroup('bets')), + getValues<PortfolioMetrics>( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ), + getValues<Group>(firestore.collection('groups')), + ]) + + const contractsByGroup = await Promise.all( + groups.map((group) => { + return getValues( + firestore + .collection('groups') + .doc(group.id) + .collection('groupContracts') + ) + }) + ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` ) @@ -162,4 +178,40 @@ export async function updateMetricsCore() { 'set' ) log(`Updated metrics for ${users.length} users.`) + + const groupUpdates = groups.map((group, index) => { + const groupContractIds = contractsByGroup[index] as GroupContractDoc[] + const groupContracts = groupContractIds.map( + (e) => contractsById[e.contractId] + ) + const bets = groupContracts.map((e) => { + return betsByContract[e.id] ?? [] + }) + + const creatorScores = scoreCreators(groupContracts) + const traderScores = scoreTraders(groupContracts, bets) + + const topTraderScores = topUserScores(traderScores) + const topCreatorScores = topUserScores(creatorScores) + + return { + doc: firestore.collection('groups').doc(group.id), + fields: { + cachedLeaderboard: { + topTraders: topTraderScores, + topCreators: topCreatorScores, + }, + }, + } + }) + await writeAsync(firestore, groupUpdates) } + +const topUserScores = (scores: { [userId: string]: number }) => { + const top50 = Object.entries(scores) + .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) + .slice(0, 50) + return top50.map(([userId, score]) => ({ userId, score })) +} + +type GroupContractDoc = { contractId: string; createdTime: number } diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 7a372d9a..f27460d9 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -24,7 +24,6 @@ import { Contract } from 'common/contract' import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' -import { getUser } from 'web/lib/firebase/users' export const groups = coll<Group>('groups') export const groupMembers = (groupId: string) => @@ -253,7 +252,7 @@ export function getGroupLinkToDisplay(contract: Contract) { return groupToDisplay } -export async function listMembers(group: Group) { +export async function listMemberIds(group: Group) { const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) - return await Promise.all(members.map((m) => m.userId).map(getUser)) + return members.map((m) => m.userId) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 768e2f82..f5d68e57 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,28 +1,28 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import { debounce, sortBy, take } from 'lodash' -import { SearchIcon } from '@heroicons/react/outline' import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' -import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, getGroupBySlug, groupPath, joinGroup, - listMembers, + listMemberIds, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' -import { scoreCreators, scoreTraders } from 'common/scoring' +import { + useGroup, + useGroupContractIds, + useMemberIds, +} from 'web/hooks/use-group' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' import { EditGroupButton } from 'web/components/groups/edit-group-button' @@ -35,9 +35,7 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' -import { FollowList } from 'web/components/follow-list' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' @@ -59,7 +57,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const members = group && (await listMembers(group)) + const memberIds = group && (await listMemberIds(group)) const creatorPromise = group ? getUser(group.creatorId) : null const contracts = @@ -71,19 +69,15 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) - const bets = await Promise.all( - contracts.map((contract: Contract) => listAllBets(contract.id)) - ) const messages = group && (await listAllCommentsOnGroup(group.id)) - const creatorScores = scoreCreators(contracts) - const traderScores = scoreTraders(contracts, bets) - const [topCreators, topTraders] = - (members && [ - toTopUsers(creatorScores, members), - toTopUsers(traderScores, members), - ]) ?? - [] + const cachedTopTraderIds = + (group && group.cachedLeaderboard?.topTraders) ?? [] + const cachedTopCreatorIds = + (group && group.cachedLeaderboard?.topCreators) ?? [] + const topTraders = await toTopUsers(cachedTopTraderIds) + + const topCreators = await toTopUsers(cachedTopCreatorIds) const creator = await creatorPromise // Only count unresolved markets @@ -93,11 +87,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { props: { contractsCount, group, - members, + memberIds, creator, - traderScores, topTraders, - creatorScores, topCreators, messages, aboutPost, @@ -107,19 +99,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { revalidate: 60, // regenerate after a minute } } - -function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { - const topUserPairs = take( - sortBy(Object.entries(userScores), ([_, score]) => -1 * score), - 10 - ).filter(([_, score]) => score >= 0.5) - - const topUsers = topUserPairs.map( - ([userId]) => users.filter((user) => user.id === userId)[0] - ) - return topUsers.filter((user) => user) -} - export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } @@ -134,12 +113,10 @@ const groupSubpages = [ export default function GroupPage(props: { contractsCount: number group: Group | null - members: User[] + memberIds: string[] creator: User - traderScores: { [userId: string]: number } - topTraders: User[] - creatorScores: { [userId: string]: number } - topCreators: User[] + topTraders: { user: User; score: number }[] + topCreators: { user: User; score: number }[] messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' @@ -147,24 +124,15 @@ export default function GroupPage(props: { props = usePropz(props, getStaticPropz) ?? { contractsCount: 0, group: null, - members: [], + memberIds: [], creator: null, - traderScores: {}, topTraders: [], - creatorScores: {}, topCreators: [], messages: [], suggestedFilter: 'open', } - const { - contractsCount, - creator, - traderScores, - topTraders, - creatorScores, - topCreators, - suggestedFilter, - } = props + const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = + props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -175,7 +143,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() - const members = useMembers(group?.id) ?? props.members + const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -186,18 +154,25 @@ export default function GroupPage(props: { return <Custom404 /> } const isCreator = user && group && user.id === group.creatorId - const isMember = user && members.map((m) => m.id).includes(user.id) + const isMember = user && memberIds.includes(user.id) + const maxLeaderboardSize = 50 const leaderboard = ( <Col> - <GroupLeaderboards - traderScores={traderScores} - creatorScores={creatorScores} - topTraders={topTraders} - topCreators={topCreators} - members={members} - user={user} - /> + <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> + <GroupLeaderboard + topUsers={topTraders} + title="🏅 Top traders" + header="Profit" + maxToShow={maxLeaderboardSize} + /> + <GroupLeaderboard + topUsers={topCreators} + title="🏅 Top creators" + header="Market volume" + maxToShow={maxLeaderboardSize} + /> + </div> </Col> ) @@ -216,7 +191,7 @@ export default function GroupPage(props: { creator={creator} isCreator={!!isCreator} user={user} - members={members} + memberIds={memberIds} /> </Col> ) @@ -312,9 +287,9 @@ function GroupOverview(props: { creator: User user: User | null | undefined isCreator: boolean - members: User[] + memberIds: string[] }) { - const { group, creator, isCreator, user, members } = props + const { group, creator, isCreator, user, memberIds } = props const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', @@ -333,7 +308,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` - const isMember = user ? members.map((m) => m.id).includes(user.id) : false + const isMember = user ? memberIds.includes(user.id) : false return ( <> @@ -399,155 +374,37 @@ function GroupOverview(props: { /> </Col> )} - - <Col className={'mt-2'}> - <div className="mb-2 text-lg">Members</div> - <GroupMemberSearch members={members} group={group} /> - </Col> </Col> </> ) } -function SearchBar(props: { setQuery: (query: string) => void }) { - const { setQuery } = props - const debouncedQuery = debounce(setQuery, 50) - return ( - <div className={'relative'}> - <SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} /> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Find a member" - className="input input-bordered mb-4 w-full pl-12" - /> - </div> - ) -} - -function GroupMemberSearch(props: { members: User[]; group: Group }) { - const [query, setQuery] = useState('') - const { group } = props - let { members } = props - - // Use static members on load, but also listen to member changes: - const listenToMembers = useMembers(group.id) - if (listenToMembers) { - members = listenToMembers - } - - // TODO use find-active-contracts to sort by? - const matches = sortBy(members, [(member) => member.name]).filter((m) => - searchInAny(query, m.name, m.username) - ) - const matchLimit = 25 - - return ( - <div> - <SearchBar setQuery={setQuery} /> - <Col className={'gap-2'}> - {matches.length > 0 && ( - <FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} /> - )} - {matches.length > 25 && ( - <div className={'text-center'}> - And {matches.length - matchLimit} more... - </div> - )} - </Col> - </div> - ) -} - -function SortedLeaderboard(props: { - users: User[] - scoreFunction: (user: User) => number +function GroupLeaderboard(props: { + topUsers: { user: User; score: number }[] title: string + maxToShow: number header: string - maxToShow?: number }) { - const { users, scoreFunction, title, header, maxToShow } = props - const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) + const { topUsers, title, maxToShow, header } = props + + const scoresByUser = topUsers.reduce((acc, { user, score }) => { + acc[user.id] = score + return acc + }, {} as { [key: string]: number }) + return ( <Leaderboard className="max-w-xl" - users={sortedUsers} + users={topUsers.map((t) => t.user)} title={title} columns={[ - { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, + { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, ]} maxToShow={maxToShow} /> ) } -function GroupLeaderboards(props: { - traderScores: { [userId: string]: number } - creatorScores: { [userId: string]: number } - topTraders: User[] - topCreators: User[] - members: User[] - user: User | null | undefined -}) { - const { traderScores, creatorScores, members, topTraders, topCreators } = - props - const maxToShow = 50 - // Consider hiding M$0 - // If it's just one member (curator), show all bettors, otherwise just show members - return ( - <Col> - <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> - {members.length > 1 ? ( - <> - <SortedLeaderboard - users={members} - scoreFunction={(user) => traderScores[user.id] ?? 0} - title="🏅 Top traders" - header="Profit" - maxToShow={maxToShow} - /> - <SortedLeaderboard - users={members} - scoreFunction={(user) => creatorScores[user.id] ?? 0} - title="🏅 Top creators" - header="Market volume" - maxToShow={maxToShow} - /> - </> - ) : ( - <> - <Leaderboard - className="max-w-xl" - title="🏅 Top traders" - users={topTraders} - columns={[ - { - header: 'Profit', - renderCell: (user) => formatMoney(traderScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - <Leaderboard - className="max-w-xl" - title="🏅 Top creators" - users={topCreators} - columns={[ - { - header: 'Market volume', - renderCell: (user) => - formatMoney(creatorScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - </> - )} - </div> - </Col> - ) -} - function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) @@ -684,3 +541,15 @@ function JoinGroupButton(props: { </div> ) } + +const toTopUsers = async ( + cachedUserIds: { userId: string; score: number }[] +): Promise<{ user: User; score: number }[]> => + ( + await Promise.all( + cachedUserIds.map(async (e) => { + const user = await getUser(e.userId) + return { user, score: e.score ?? 0 } + }) + ) + ).filter((e) => e.user != null) From 28f0c6b1f8f14bd210d02b6032967822d1779b3b Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Mon, 12 Sep 2022 17:26:46 +0100 Subject: [PATCH 03/32] Revert "Fix "500 internal error" in large groups (#856)" (#871) This reverts commit a6ed8c92282dd26310ee6b7c029365065ec53fdd. --- common/group.ts | 15 -- functions/src/update-metrics.ts | 72 +------- web/lib/firebase/groups.ts | 5 +- web/pages/group/[...slugs]/index.tsx | 263 ++++++++++++++++++++------- 4 files changed, 210 insertions(+), 145 deletions(-) diff --git a/common/group.ts b/common/group.ts index 36654101..19f3b7b8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,22 +12,7 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number - /** @deprecated - members and contracts now stored as subcollections*/ - memberIds?: string[] // Deprecated - /** @deprecated - members and contracts now stored as subcollections*/ - contractIds?: string[] // Deprecated - cachedLeaderboard?: { - topTraders: { - userId: string - score: number - }[] - topCreators: { - userId: string - score: number - }[] - } } - export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 273cd098..430f3d33 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -4,11 +4,9 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' - import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' -import { scoreTraders, scoreCreators } from '../../common/scoring' import { calculateCreatorVolume, calculateNewPortfolioMetrics, @@ -17,7 +15,6 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' -import { Group } from 'common/group' const firestore = admin.firestore() @@ -27,29 +24,16 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories, groups] = - await Promise.all([ - getValues<User>(firestore.collection('users')), - getValues<Contract>(firestore.collection('contracts')), - getValues<Bet>(firestore.collectionGroup('bets')), - getValues<PortfolioMetrics>( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - getValues<Group>(firestore.collection('groups')), - ]) - - const contractsByGroup = await Promise.all( - groups.map((group) => { - return getValues( - firestore - .collection('groups') - .doc(group.id) - .collection('groupContracts') - ) - }) - ) + const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ + getValues<User>(firestore.collection('users')), + getValues<Contract>(firestore.collection('contracts')), + getValues<Bet>(firestore.collectionGroup('bets')), + getValues<PortfolioMetrics>( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ), + ]) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` ) @@ -178,40 +162,4 @@ export async function updateMetricsCore() { 'set' ) log(`Updated metrics for ${users.length} users.`) - - const groupUpdates = groups.map((group, index) => { - const groupContractIds = contractsByGroup[index] as GroupContractDoc[] - const groupContracts = groupContractIds.map( - (e) => contractsById[e.contractId] - ) - const bets = groupContracts.map((e) => { - return betsByContract[e.id] ?? [] - }) - - const creatorScores = scoreCreators(groupContracts) - const traderScores = scoreTraders(groupContracts, bets) - - const topTraderScores = topUserScores(traderScores) - const topCreatorScores = topUserScores(creatorScores) - - return { - doc: firestore.collection('groups').doc(group.id), - fields: { - cachedLeaderboard: { - topTraders: topTraderScores, - topCreators: topCreatorScores, - }, - }, - } - }) - await writeAsync(firestore, groupUpdates) } - -const topUserScores = (scores: { [userId: string]: number }) => { - const top50 = Object.entries(scores) - .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) - .slice(0, 50) - return top50.map(([userId, score]) => ({ userId, score })) -} - -type GroupContractDoc = { contractId: string; createdTime: number } diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index f27460d9..7a372d9a 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -24,6 +24,7 @@ import { Contract } from 'common/contract' import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' +import { getUser } from 'web/lib/firebase/users' export const groups = coll<Group>('groups') export const groupMembers = (groupId: string) => @@ -252,7 +253,7 @@ export function getGroupLinkToDisplay(contract: Contract) { return groupToDisplay } -export async function listMemberIds(group: Group) { +export async function listMembers(group: Group) { const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) - return members.map((m) => m.userId) + return await Promise.all(members.map((m) => m.userId).map(getUser)) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f5d68e57..768e2f82 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,28 +1,28 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' +import { debounce, sortBy, take } from 'lodash' +import { SearchIcon } from '@heroicons/react/outline' import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' +import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, getGroupBySlug, groupPath, joinGroup, - listMemberIds, + listMembers, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { - useGroup, - useGroupContractIds, - useMemberIds, -} from 'web/hooks/use-group' +import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' +import { scoreCreators, scoreTraders } from 'common/scoring' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' import { EditGroupButton } from 'web/components/groups/edit-group-button' @@ -35,7 +35,9 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' +import { FollowList } from 'web/components/follow-list' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' +import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' @@ -57,7 +59,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const memberIds = group && (await listMemberIds(group)) + const members = group && (await listMembers(group)) const creatorPromise = group ? getUser(group.creatorId) : null const contracts = @@ -69,15 +71,19 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) + const bets = await Promise.all( + contracts.map((contract: Contract) => listAllBets(contract.id)) + ) const messages = group && (await listAllCommentsOnGroup(group.id)) - const cachedTopTraderIds = - (group && group.cachedLeaderboard?.topTraders) ?? [] - const cachedTopCreatorIds = - (group && group.cachedLeaderboard?.topCreators) ?? [] - const topTraders = await toTopUsers(cachedTopTraderIds) - - const topCreators = await toTopUsers(cachedTopCreatorIds) + const creatorScores = scoreCreators(contracts) + const traderScores = scoreTraders(contracts, bets) + const [topCreators, topTraders] = + (members && [ + toTopUsers(creatorScores, members), + toTopUsers(traderScores, members), + ]) ?? + [] const creator = await creatorPromise // Only count unresolved markets @@ -87,9 +93,11 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { props: { contractsCount, group, - memberIds, + members, creator, + traderScores, topTraders, + creatorScores, topCreators, messages, aboutPost, @@ -99,6 +107,19 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { revalidate: 60, // regenerate after a minute } } + +function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { + const topUserPairs = take( + sortBy(Object.entries(userScores), ([_, score]) => -1 * score), + 10 + ).filter(([_, score]) => score >= 0.5) + + const topUsers = topUserPairs.map( + ([userId]) => users.filter((user) => user.id === userId)[0] + ) + return topUsers.filter((user) => user) +} + export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } @@ -113,10 +134,12 @@ const groupSubpages = [ export default function GroupPage(props: { contractsCount: number group: Group | null - memberIds: string[] + members: User[] creator: User - topTraders: { user: User; score: number }[] - topCreators: { user: User; score: number }[] + traderScores: { [userId: string]: number } + topTraders: User[] + creatorScores: { [userId: string]: number } + topCreators: User[] messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' @@ -124,15 +147,24 @@ export default function GroupPage(props: { props = usePropz(props, getStaticPropz) ?? { contractsCount: 0, group: null, - memberIds: [], + members: [], creator: null, + traderScores: {}, topTraders: [], + creatorScores: {}, topCreators: [], messages: [], suggestedFilter: 'open', } - const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = - props + const { + contractsCount, + creator, + traderScores, + topTraders, + creatorScores, + topCreators, + suggestedFilter, + } = props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -143,7 +175,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() - const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds + const members = useMembers(group?.id) ?? props.members useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -154,25 +186,18 @@ export default function GroupPage(props: { return <Custom404 /> } const isCreator = user && group && user.id === group.creatorId - const isMember = user && memberIds.includes(user.id) - const maxLeaderboardSize = 50 + const isMember = user && members.map((m) => m.id).includes(user.id) const leaderboard = ( <Col> - <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> - <GroupLeaderboard - topUsers={topTraders} - title="🏅 Top traders" - header="Profit" - maxToShow={maxLeaderboardSize} - /> - <GroupLeaderboard - topUsers={topCreators} - title="🏅 Top creators" - header="Market volume" - maxToShow={maxLeaderboardSize} - /> - </div> + <GroupLeaderboards + traderScores={traderScores} + creatorScores={creatorScores} + topTraders={topTraders} + topCreators={topCreators} + members={members} + user={user} + /> </Col> ) @@ -191,7 +216,7 @@ export default function GroupPage(props: { creator={creator} isCreator={!!isCreator} user={user} - memberIds={memberIds} + members={members} /> </Col> ) @@ -287,9 +312,9 @@ function GroupOverview(props: { creator: User user: User | null | undefined isCreator: boolean - memberIds: string[] + members: User[] }) { - const { group, creator, isCreator, user, memberIds } = props + const { group, creator, isCreator, user, members } = props const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', @@ -308,7 +333,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` - const isMember = user ? memberIds.includes(user.id) : false + const isMember = user ? members.map((m) => m.id).includes(user.id) : false return ( <> @@ -374,37 +399,155 @@ function GroupOverview(props: { /> </Col> )} + + <Col className={'mt-2'}> + <div className="mb-2 text-lg">Members</div> + <GroupMemberSearch members={members} group={group} /> + </Col> </Col> </> ) } -function GroupLeaderboard(props: { - topUsers: { user: User; score: number }[] +function SearchBar(props: { setQuery: (query: string) => void }) { + const { setQuery } = props + const debouncedQuery = debounce(setQuery, 50) + return ( + <div className={'relative'}> + <SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} /> + <input + type="text" + onChange={(e) => debouncedQuery(e.target.value)} + placeholder="Find a member" + className="input input-bordered mb-4 w-full pl-12" + /> + </div> + ) +} + +function GroupMemberSearch(props: { members: User[]; group: Group }) { + const [query, setQuery] = useState('') + const { group } = props + let { members } = props + + // Use static members on load, but also listen to member changes: + const listenToMembers = useMembers(group.id) + if (listenToMembers) { + members = listenToMembers + } + + // TODO use find-active-contracts to sort by? + const matches = sortBy(members, [(member) => member.name]).filter((m) => + searchInAny(query, m.name, m.username) + ) + const matchLimit = 25 + + return ( + <div> + <SearchBar setQuery={setQuery} /> + <Col className={'gap-2'}> + {matches.length > 0 && ( + <FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} /> + )} + {matches.length > 25 && ( + <div className={'text-center'}> + And {matches.length - matchLimit} more... + </div> + )} + </Col> + </div> + ) +} + +function SortedLeaderboard(props: { + users: User[] + scoreFunction: (user: User) => number title: string - maxToShow: number header: string + maxToShow?: number }) { - const { topUsers, title, maxToShow, header } = props - - const scoresByUser = topUsers.reduce((acc, { user, score }) => { - acc[user.id] = score - return acc - }, {} as { [key: string]: number }) - + const { users, scoreFunction, title, header, maxToShow } = props + const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) return ( <Leaderboard className="max-w-xl" - users={topUsers.map((t) => t.user)} + users={sortedUsers} title={title} columns={[ - { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, + { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, ]} maxToShow={maxToShow} /> ) } +function GroupLeaderboards(props: { + traderScores: { [userId: string]: number } + creatorScores: { [userId: string]: number } + topTraders: User[] + topCreators: User[] + members: User[] + user: User | null | undefined +}) { + const { traderScores, creatorScores, members, topTraders, topCreators } = + props + const maxToShow = 50 + // Consider hiding M$0 + // If it's just one member (curator), show all bettors, otherwise just show members + return ( + <Col> + <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> + {members.length > 1 ? ( + <> + <SortedLeaderboard + users={members} + scoreFunction={(user) => traderScores[user.id] ?? 0} + title="🏅 Top traders" + header="Profit" + maxToShow={maxToShow} + /> + <SortedLeaderboard + users={members} + scoreFunction={(user) => creatorScores[user.id] ?? 0} + title="🏅 Top creators" + header="Market volume" + maxToShow={maxToShow} + /> + </> + ) : ( + <> + <Leaderboard + className="max-w-xl" + title="🏅 Top traders" + users={topTraders} + columns={[ + { + header: 'Profit', + renderCell: (user) => formatMoney(traderScores[user.id] ?? 0), + }, + ]} + maxToShow={maxToShow} + /> + <Leaderboard + className="max-w-xl" + title="🏅 Top creators" + users={topCreators} + columns={[ + { + header: 'Market volume', + renderCell: (user) => + formatMoney(creatorScores[user.id] ?? 0), + }, + ]} + maxToShow={maxToShow} + /> + </> + )} + </div> + </Col> + ) +} + function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) @@ -541,15 +684,3 @@ function JoinGroupButton(props: { </div> ) } - -const toTopUsers = async ( - cachedUserIds: { userId: string; score: number }[] -): Promise<{ user: User; score: number }[]> => - ( - await Promise.all( - cachedUserIds.map(async (e) => { - const user = await getUser(e.userId) - return { user, score: e.score ?? 0 } - }) - ) - ).filter((e) => e.user != null) From 5c6328ffc263fb2bcbe8cbb6862b6bc488208fb5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 10:34:56 -0600 Subject: [PATCH 04/32] [WIP] Fully customizable notifications (#860) * Notifications Settings page working * Update import * Linked notification settings to notification rules * Add more subscribe types * It's alive... It's alive, it's moving, it's alive, it's alive, it's alive, it's alive, IT'S ALIVE' * UI Tweaks * Clean up comments * Direct & highlight sections for notif mgmt from emails * Comment cleanup * Comment cleanup, lint * More comment cleanup * Update email templates to predict * Move private user out of getDestinationsForUser * Fix resolution messages * Remove magic * Extract switch to switch-setting * Change tab in url * Show 0 as invested or payout * All emails use unsubscribeUrl --- common/notification.ts | 105 ++- common/user.ts | 190 +++++ firebase.json | 22 +- firestore.rules | 8 +- functions/.gitignore | 1 + functions/src/create-answer.ts | 7 +- functions/src/create-group.ts | 2 +- functions/src/create-notification.ts | 633 ++++++++------- functions/src/create-user.ts | 7 +- functions/src/email-templates/500-mana.html | 9 +- .../src/email-templates/creating-market.html | 4 +- .../email-templates/interesting-markets.html | 5 +- .../market-answer-comment.html | 17 +- .../src/email-templates/market-answer.html | 11 +- .../src/email-templates/market-close.html | 11 +- .../src/email-templates/market-comment.html | 11 +- .../src/email-templates/market-resolved.html | 11 +- functions/src/email-templates/one-week.html | 753 +++++++----------- functions/src/email-templates/thank-you.html | 10 +- functions/src/email-templates/welcome.html | 11 +- functions/src/emails.ts | 165 ++-- functions/src/market-close-notifications.ts | 2 - .../src/on-create-comment-on-contract.ts | 40 +- functions/src/on-update-contract.ts | 27 +- functions/src/resolve-market.ts | 95 +-- .../create-new-notification-preferences.ts | 30 + functions/src/scripts/create-private-users.ts | 3 +- web/components/NotificationSettings.tsx | 236 ------ ...arket-modal.tsx => watch-market-modal.tsx} | 11 +- web/components/follow-market-button.tsx | 4 +- web/components/notification-settings.tsx | 320 ++++++++ web/components/switch-setting.tsx | 34 + web/hooks/use-notifications.ts | 35 +- web/pages/notifications.tsx | 49 +- 34 files changed, 1554 insertions(+), 1325 deletions(-) create mode 100644 functions/src/scripts/create-new-notification-preferences.ts delete mode 100644 web/components/NotificationSettings.tsx rename web/components/contract/{follow-market-modal.tsx => watch-market-modal.tsx} (74%) create mode 100644 web/components/notification-settings.tsx create mode 100644 web/components/switch-setting.tsx diff --git a/common/notification.ts b/common/notification.ts index 9ec320fa..42dbbf35 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -1,3 +1,6 @@ +import { notification_subscription_types, PrivateUser } from './user' +import { DOMAIN } from './envs/constants' + export type Notification = { id: string userId: string @@ -51,28 +54,106 @@ export type notification_source_update_types = | 'deleted' | 'closed' +/* Optional - if possible use a keyof notification_subscription_types */ export type notification_reason_types = | 'tagged_user' - | 'on_users_contract' - | 'on_contract_with_users_shares_in' - | 'on_contract_with_users_shares_out' - | 'on_contract_with_users_answer' - | 'on_contract_with_users_comment' - | 'reply_to_users_answer' - | 'reply_to_users_comment' | 'on_new_follow' - | 'you_follow_user' - | 'added_you_to_group' + | 'contract_from_followed_user' | 'you_referred_user' | 'user_joined_to_bet_on_your_market' | 'unique_bettors_on_your_contract' - | 'on_group_you_are_member_of' | 'tip_received' | 'bet_fill' | 'user_joined_from_your_group_invite' | 'challenge_accepted' | 'betting_streak_incremented' | 'loan_income' - | 'you_follow_contract' - | 'liked_your_contract' | 'liked_and_tipped_your_contract' + | 'comment_on_your_contract' + | 'answer_on_your_contract' + | 'comment_on_contract_you_follow' + | 'answer_on_contract_you_follow' + | 'update_on_contract_you_follow' + | 'resolution_on_contract_you_follow' + | 'comment_on_contract_with_users_shares_in' + | 'answer_on_contract_with_users_shares_in' + | 'update_on_contract_with_users_shares_in' + | 'resolution_on_contract_with_users_shares_in' + | 'comment_on_contract_with_users_answer' + | 'update_on_contract_with_users_answer' + | 'resolution_on_contract_with_users_answer' + | 'answer_on_contract_with_users_answer' + | 'comment_on_contract_with_users_comment' + | 'answer_on_contract_with_users_comment' + | 'update_on_contract_with_users_comment' + | 'resolution_on_contract_with_users_comment' + | 'reply_to_users_answer' + | 'reply_to_users_comment' + | 'your_contract_closed' + | 'subsidized_your_market' + +// Adding a new key:value here is optional, you can just use a key of notification_subscription_types +// You might want to add a key:value here if there will be multiple notification reasons that map to the same +// subscription type, i.e. 'comment_on_contract_you_follow' and 'comment_on_contract_with_users_answer' both map to +// 'all_comments_on_watched_markets' subscription type +// TODO: perhaps better would be to map notification_subscription_types to arrays of notification_reason_types +export const notificationReasonToSubscriptionType: Partial< + Record<notification_reason_types, keyof notification_subscription_types> +> = { + you_referred_user: 'referral_bonuses', + user_joined_to_bet_on_your_market: 'referral_bonuses', + tip_received: 'tips_on_your_comments', + bet_fill: 'limit_order_fills', + user_joined_from_your_group_invite: 'referral_bonuses', + challenge_accepted: 'limit_order_fills', + betting_streak_incremented: 'betting_streaks', + liked_and_tipped_your_contract: 'tips_on_your_markets', + comment_on_your_contract: 'all_comments_on_my_markets', + answer_on_your_contract: 'all_answers_on_my_markets', + comment_on_contract_you_follow: 'all_comments_on_watched_markets', + answer_on_contract_you_follow: 'all_answers_on_watched_markets', + update_on_contract_you_follow: 'market_updates_on_watched_markets', + resolution_on_contract_you_follow: 'resolutions_on_watched_markets', + comment_on_contract_with_users_shares_in: + 'all_comments_on_contracts_with_shares_in_on_watched_markets', + answer_on_contract_with_users_shares_in: + 'all_answers_on_contracts_with_shares_in_on_watched_markets', + update_on_contract_with_users_shares_in: + 'market_updates_on_watched_markets_with_shares_in', + resolution_on_contract_with_users_shares_in: + 'resolutions_on_watched_markets_with_shares_in', + comment_on_contract_with_users_answer: 'all_comments_on_watched_markets', + update_on_contract_with_users_answer: 'market_updates_on_watched_markets', + resolution_on_contract_with_users_answer: 'resolutions_on_watched_markets', + answer_on_contract_with_users_answer: 'all_answers_on_watched_markets', + comment_on_contract_with_users_comment: 'all_comments_on_watched_markets', + answer_on_contract_with_users_comment: 'all_answers_on_watched_markets', + update_on_contract_with_users_comment: 'market_updates_on_watched_markets', + resolution_on_contract_with_users_comment: 'resolutions_on_watched_markets', + reply_to_users_answer: 'all_replies_to_my_answers_on_watched_markets', + reply_to_users_comment: 'all_replies_to_my_comments_on_watched_markets', +} + +export const getDestinationsForUser = async ( + privateUser: PrivateUser, + reason: notification_reason_types | keyof notification_subscription_types +) => { + const notificationSettings = privateUser.notificationSubscriptionTypes + let destinations + let subscriptionType: keyof notification_subscription_types | undefined + if (Object.keys(notificationSettings).includes(reason)) { + subscriptionType = reason as keyof notification_subscription_types + destinations = notificationSettings[subscriptionType] + } else { + const key = reason as notification_reason_types + subscriptionType = notificationReasonToSubscriptionType[key] + destinations = subscriptionType + ? notificationSettings[subscriptionType] + : [] + } + return { + sendToEmail: destinations.includes('email'), + sendToBrowser: destinations.includes('browser'), + urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, + } +} diff --git a/common/user.ts b/common/user.ts index f15865cf..f8b4f8d8 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,3 +1,5 @@ +import { filterDefined } from './util/array' + export type User = { id: string createdTime: number @@ -63,9 +65,60 @@ export type PrivateUser = { initialDeviceToken?: string initialIpAddress?: string apiKey?: string + /** @deprecated - use notificationSubscriptionTypes */ notificationPreferences?: notification_subscribe_types + notificationSubscriptionTypes: notification_subscription_types } +export type notification_destination_types = 'email' | 'browser' +export type notification_subscription_types = { + // Watched Markets + all_comments_on_watched_markets: notification_destination_types[] + all_answers_on_watched_markets: notification_destination_types[] + + // Comments + tipped_comments_on_watched_markets: notification_destination_types[] + comments_by_followed_users_on_watched_markets: notification_destination_types[] + all_replies_to_my_comments_on_watched_markets: notification_destination_types[] + all_replies_to_my_answers_on_watched_markets: notification_destination_types[] + all_comments_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // Answers + answers_by_followed_users_on_watched_markets: notification_destination_types[] + answers_by_market_creator_on_watched_markets: notification_destination_types[] + all_answers_on_contracts_with_shares_in_on_watched_markets: notification_destination_types[] + + // On users' markets + your_contract_closed: notification_destination_types[] + all_comments_on_my_markets: notification_destination_types[] + all_answers_on_my_markets: notification_destination_types[] + subsidized_your_market: notification_destination_types[] + + // Market updates + resolutions_on_watched_markets: notification_destination_types[] + resolutions_on_watched_markets_with_shares_in: notification_destination_types[] + market_updates_on_watched_markets: notification_destination_types[] + market_updates_on_watched_markets_with_shares_in: notification_destination_types[] + probability_updates_on_watched_markets: notification_destination_types[] + + // Balance Changes + loan_income: notification_destination_types[] + betting_streaks: notification_destination_types[] + referral_bonuses: notification_destination_types[] + unique_bettors_on_your_contract: notification_destination_types[] + tips_on_your_comments: notification_destination_types[] + tips_on_your_markets: notification_destination_types[] + limit_order_fills: notification_destination_types[] + + // General + tagged_user: notification_destination_types[] + on_new_follow: notification_destination_types[] + contract_from_followed_user: notification_destination_types[] + trending_markets: notification_destination_types[] + profit_loss_updates: notification_destination_types[] + onboarding_flow: notification_destination_types[] + thank_you_for_purchases: notification_destination_types[] +} export type notification_subscribe_types = 'all' | 'less' | 'none' export type PortfolioMetrics = { @@ -78,3 +131,140 @@ export type PortfolioMetrics = { export const MANIFOLD_USERNAME = 'ManifoldMarkets' export const MANIFOLD_AVATAR_URL = 'https://manifold.markets/logo-bg-white.png' + +export const getDefaultNotificationSettings = ( + userId: string, + privateUser?: PrivateUser, + noEmails?: boolean +) => { + const prevPref = privateUser?.notificationPreferences ?? 'all' + const wantsLess = prevPref === 'less' + const wantsAll = prevPref === 'all' + const { + unsubscribedFromCommentEmails, + unsubscribedFromAnswerEmails, + unsubscribedFromResolutionEmails, + unsubscribedFromWeeklyTrendingEmails, + unsubscribedFromGenericEmails, + } = privateUser || {} + + const constructPref = (browserIf: boolean, emailIf: boolean) => { + const browser = browserIf ? 'browser' : undefined + const email = noEmails ? undefined : emailIf ? 'email' : undefined + return filterDefined([browser, email]) as notification_destination_types[] + } + return { + // Watched Markets + all_comments_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromCommentEmails + ), + all_answers_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromAnswerEmails + ), + + // Comments + tips_on_your_comments: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + comments_by_followed_users_on_watched_markets: constructPref( + wantsAll, + false + ), + all_replies_to_my_comments_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + all_replies_to_my_answers_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + all_comments_on_contracts_with_shares_in_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromCommentEmails + ), + + // Answers + answers_by_followed_users_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromAnswerEmails + ), + answers_by_market_creator_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromAnswerEmails + ), + all_answers_on_contracts_with_shares_in_on_watched_markets: constructPref( + wantsAll, + !unsubscribedFromAnswerEmails + ), + + // On users' markets + your_contract_closed: constructPref( + wantsAll || wantsLess, + !unsubscribedFromResolutionEmails + ), // High priority + all_comments_on_my_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + all_answers_on_my_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromAnswerEmails + ), + subsidized_your_market: constructPref(wantsAll || wantsLess, true), + + // Market updates + resolutions_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromResolutionEmails + ), + market_updates_on_watched_markets: constructPref( + wantsAll || wantsLess, + false + ), + market_updates_on_watched_markets_with_shares_in: constructPref( + wantsAll || wantsLess, + false + ), + resolutions_on_watched_markets_with_shares_in: constructPref( + wantsAll || wantsLess, + !unsubscribedFromResolutionEmails + ), + + //Balance Changes + loan_income: constructPref(wantsAll || wantsLess, false), + betting_streaks: constructPref(wantsAll || wantsLess, false), + referral_bonuses: constructPref(wantsAll || wantsLess, true), + unique_bettors_on_your_contract: constructPref( + wantsAll || wantsLess, + false + ), + tipped_comments_on_watched_markets: constructPref( + wantsAll || wantsLess, + !unsubscribedFromCommentEmails + ), + tips_on_your_markets: constructPref(wantsAll || wantsLess, true), + limit_order_fills: constructPref(wantsAll || wantsLess, false), + + // General + tagged_user: constructPref(wantsAll || wantsLess, true), + on_new_follow: constructPref(wantsAll || wantsLess, true), + contract_from_followed_user: constructPref(wantsAll || wantsLess, true), + trending_markets: constructPref( + false, + !unsubscribedFromWeeklyTrendingEmails + ), + profit_loss_updates: constructPref(false, true), + probability_updates_on_watched_markets: constructPref( + wantsAll || wantsLess, + false + ), + thank_you_for_purchases: constructPref( + false, + !unsubscribedFromGenericEmails + ), + onboarding_flow: constructPref(false, !unsubscribedFromGenericEmails), + } as notification_subscription_types +} diff --git a/firebase.json b/firebase.json index 25f9b61f..5dea5ade 100644 --- a/firebase.json +++ b/firebase.json @@ -2,10 +2,30 @@ "functions": { "predeploy": "cd functions && yarn build", "runtime": "nodejs16", - "source": "functions/dist" + "source": "functions/dist", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log" + ] }, "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" + }, + "emulators": { + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "pubsub": { + "port": 8085 + }, + "ui": { + "enabled": true + } } } diff --git a/firestore.rules b/firestore.rules index 9a72e454..d24d4097 100644 --- a/firestore.rules +++ b/firestore.rules @@ -77,7 +77,7 @@ service cloud.firestore { allow read: if userId == request.auth.uid || isAdmin(); allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails','notificationSubscriptionTypes' ]); } match /private-users/{userId}/views/{viewId} { @@ -161,7 +161,7 @@ service cloud.firestore { && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['isSeen', 'viewTime']); } - + match /{somePath=**}/groupMembers/{memberId} { allow read; } @@ -170,7 +170,7 @@ service cloud.firestore { allow read; } - match /groups/{groupId} { + match /groups/{groupId} { allow read; allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) @@ -184,7 +184,7 @@ service cloud.firestore { match /groupMembers/{memberId}{ allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); - allow delete: if request.auth.uid == resource.data.userId; + allow delete: if request.auth.uid == resource.data.userId; } function isGroupMember() { diff --git a/functions/.gitignore b/functions/.gitignore index 58f30dcb..bd3d0c29 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -17,4 +17,5 @@ package-lock.json ui-debug.log firebase-debug.log firestore-debug.log +pubsub-debug.log firestore_export/ diff --git a/functions/src/create-answer.ts b/functions/src/create-answer.ts index 0b8b4e7a..cc05d817 100644 --- a/functions/src/create-answer.ts +++ b/functions/src/create-answer.ts @@ -5,8 +5,7 @@ import { Contract } from '../../common/contract' import { User } from '../../common/user' import { getNewMultiBetInfo } from '../../common/new-bet' import { Answer, MAX_ANSWER_LENGTH } from '../../common/answer' -import { getContract, getValues } from './utils' -import { sendNewAnswerEmail } from './emails' +import { getValues } from './utils' import { APIError, newEndpoint, validate } from './api' const bodySchema = z.object({ @@ -97,10 +96,6 @@ export const createanswer = newEndpoint(opts, async (req, auth) => { return answer }) - const contract = await getContract(contractId) - - if (answer && contract) await sendNewAnswerEmail(answer, contract) - return answer }) diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index fc64aeff..9d00bb0b 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -10,7 +10,7 @@ import { MAX_GROUP_NAME_LENGTH, MAX_ID_LENGTH, } from '../../common/group' -import { APIError, newEndpoint, validate } from '../../functions/src/api' +import { APIError, newEndpoint, validate } from './api' import { z } from 'zod' const bodySchema = z.object({ diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 131d6e85..356ad200 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1,13 +1,12 @@ import * as admin from 'firebase-admin' import { + getDestinationsForUser, Notification, notification_reason_types, - notification_source_update_types, - notification_source_types, } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getValues, log } from './utils' +import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -15,20 +14,25 @@ import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' -import { Group, GROUP_CHAT_SLUG } from '../../common/group' +import { Group } from '../../common/group' import { Challenge } from '../../common/challenge' -import { richTextToString } from '../../common/util/parse' import { Like } from '../../common/like' +import { + sendMarketCloseEmail, + sendMarketResolutionEmail, + sendNewAnswerEmail, + sendNewCommentEmail, +} from './emails' const firestore = admin.firestore() -type user_to_reason_texts = { +type recipients_to_reason_texts = { [userId: string]: { reason: notification_reason_types } } export const createNotification = async ( sourceId: string, - sourceType: notification_source_types, - sourceUpdateType: notification_source_update_types, + sourceType: 'contract' | 'liquidity' | 'follow', + sourceUpdateType: 'closed' | 'created', sourceUser: User, idempotencyKey: string, sourceText: string, @@ -41,9 +45,9 @@ export const createNotification = async ( ) => { const { contract: sourceContract, recipients, slug, title } = miscData ?? {} - const shouldGetNotification = ( + const shouldReceiveNotification = ( userId: string, - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { return ( sourceUser.id != userId && @@ -51,18 +55,25 @@ export const createNotification = async ( ) } - const createUsersNotifications = async ( - userToReasonTexts: user_to_reason_texts + const sendNotificationsIfSettingsPermit = async ( + userToReasonTexts: recipients_to_reason_texts ) => { - await Promise.all( - Object.keys(userToReasonTexts).map(async (userId) => { + for (const userId in userToReasonTexts) { + const { reason } = userToReasonTexts[userId] + const privateUser = await getPrivateUser(userId) + if (!privateUser) continue + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason + ) + if (sendToBrowser) { const notificationRef = firestore .collection(`/users/${userId}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId, - reason: userToReasonTexts[userId].reason, + reason, createdTime: Date.now(), isSeen: false, sourceId, @@ -80,12 +91,32 @@ export const createNotification = async ( sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) - }) - ) + } + + if (!sendToEmail) continue + + if (reason === 'your_contract_closed' && privateUser && sourceContract) { + // TODO: include number and names of bettors waiting for creator to resolve their market + await sendMarketCloseEmail( + reason, + sourceUser, + privateUser, + sourceContract + ) + } else if (reason === 'tagged_user') { + // TODO: send email to tagged user in new contract + } else if (reason === 'subsidized_your_market') { + // TODO: send email to creator of market that was subsidized + } else if (reason === 'contract_from_followed_user') { + // TODO: send email to follower of user who created market + } else if (reason === 'on_new_follow') { + // TODO: send email to user who was followed + } + } } const notifyUsersFollowers = async ( - userToReasonTexts: user_to_reason_texts + userToReasonTexts: recipients_to_reason_texts ) => { const followers = await firestore .collectionGroup('follows') @@ -96,72 +127,36 @@ export const createNotification = async ( const followerUserId = doc.ref.parent.parent?.id if ( followerUserId && - shouldGetNotification(followerUserId, userToReasonTexts) + shouldReceiveNotification(followerUserId, userToReasonTexts) ) { userToReasonTexts[followerUserId] = { - reason: 'you_follow_user', + reason: 'contract_from_followed_user', } } }) } - const notifyFollowedUser = ( - userToReasonTexts: user_to_reason_texts, - followedUserId: string - ) => { - if (shouldGetNotification(followedUserId, userToReasonTexts)) - userToReasonTexts[followedUserId] = { - reason: 'on_new_follow', - } - } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, + userToReasonTexts: recipients_to_reason_texts, userIds: (string | undefined)[] ) => { userIds.forEach((id) => { - if (id && shouldGetNotification(id, userToReasonTexts)) + if (id && shouldReceiveNotification(id, userToReasonTexts)) userToReasonTexts[id] = { reason: 'tagged_user', } }) } - const notifyContractCreator = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract, - options?: { force: boolean } - ) => { - if ( - options?.force || - shouldGetNotification(sourceContract.creatorId, userToReasonTexts) - ) - userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', - } - } - - const notifyUserAddedToGroup = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string - ) => { - if (shouldGetNotification(relatedUserId, userToReasonTexts)) - userToReasonTexts[relatedUserId] = { - reason: 'added_you_to_group', - } - } - - const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. + const userToReasonTexts: recipients_to_reason_texts = {} if (sourceType === 'follow' && recipients?.[0]) { - notifyFollowedUser(userToReasonTexts, recipients[0]) - } else if ( - sourceType === 'group' && - sourceUpdateType === 'created' && - recipients - ) { - recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) + if (shouldReceiveNotification(recipients[0], userToReasonTexts)) + userToReasonTexts[recipients[0]] = { + reason: 'on_new_follow', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'created' && @@ -169,123 +164,198 @@ export const createNotification = async ( ) { await notifyUsersFollowers(userToReasonTexts) notifyTaggedUsers(userToReasonTexts, recipients ?? []) + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'closed' && sourceContract ) { - await notifyContractCreator(userToReasonTexts, sourceContract, { - force: true, - }) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'your_contract_closed', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'liquidity' && sourceUpdateType === 'created' && sourceContract ) { - await notifyContractCreator(userToReasonTexts, sourceContract) + if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'subsidized_your_market', + } + return await sendNotificationsIfSettingsPermit(userToReasonTexts) } - - await createUsersNotifications(userToReasonTexts) } export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceId: string, - sourceType: notification_source_types, - sourceUpdateType: notification_source_update_types, + sourceType: 'comment' | 'answer' | 'contract', + sourceUpdateType: 'created' | 'updated' | 'resolved', sourceUser: User, idempotencyKey: string, sourceText: string, sourceContract: Contract, miscData?: { - relatedSourceType?: notification_source_types + repliedToType?: 'comment' | 'answer' + repliedToId?: string + repliedToContent?: string repliedUserId?: string taggedUserIds?: string[] + }, + resolutionData?: { + bets: Bet[] + userInvestments: { [userId: string]: number } + userPayouts: { [userId: string]: number } + creator: User + creatorPayout: number + contract: Contract + outcome: string + resolutionProbability?: number + resolutions?: { [outcome: string]: number } } ) => { - const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {} + const { + repliedToType, + repliedToContent, + repliedUserId, + taggedUserIds, + repliedToId, + } = miscData ?? {} - const createUsersNotifications = async ( - userToReasonTexts: user_to_reason_texts - ) => { - await Promise.all( - Object.keys(userToReasonTexts).map(async (userId) => { - const notificationRef = firestore - .collection(`/users/${userId}/notifications`) - .doc(idempotencyKey) - const notification: Notification = { - id: idempotencyKey, - userId, - reason: userToReasonTexts[userId].reason, - createdTime: Date.now(), - isSeen: false, - sourceId, - sourceType, - sourceUpdateType, - sourceContractId: sourceContract.id, - sourceUserName: sourceUser.name, - sourceUserUsername: sourceUser.username, - sourceUserAvatarUrl: sourceUser.avatarUrl, - sourceText, - sourceContractCreatorUsername: sourceContract.creatorUsername, - sourceContractTitle: sourceContract.question, - sourceContractSlug: sourceContract.slug, - sourceSlug: sourceContract.slug, - sourceTitle: sourceContract.question, - } - await notificationRef.set(removeUndefinedProps(notification)) - }) - ) - } + const recipientIdsList: string[] = [] - // get contract follower documents and check here if they're a follower const contractFollowersSnap = await firestore .collection(`contracts/${sourceContract.id}/follows`) .get() const contractFollowersIds = contractFollowersSnap.docs.map( (doc) => doc.data().id ) - log('contractFollowerIds', contractFollowersIds) + + const createBrowserNotification = async ( + userId: string, + reason: notification_reason_types + ) => { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId, + sourceType, + sourceUpdateType, + sourceContractId: sourceContract.id, + sourceUserName: sourceUser.name, + sourceUserUsername: sourceUser.username, + sourceUserAvatarUrl: sourceUser.avatarUrl, + sourceText, + sourceContractCreatorUsername: sourceContract.creatorUsername, + sourceContractTitle: sourceContract.question, + sourceContractSlug: sourceContract.slug, + sourceSlug: sourceContract.slug, + sourceTitle: sourceContract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) + } const stillFollowingContract = (userId: string) => { return contractFollowersIds.includes(userId) } - const shouldGetNotification = ( + const sendNotificationsIfSettingsPermit = async ( userId: string, - userToReasonTexts: user_to_reason_texts + reason: notification_reason_types ) => { - return ( - sourceUser.id != userId && - !Object.keys(userToReasonTexts).includes(userId) + if ( + !stillFollowingContract(sourceContract.creatorId) || + sourceUser.id == userId || + recipientIdsList.includes(userId) + ) + return + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason ) - } - const notifyContractFollowers = async ( - userToReasonTexts: user_to_reason_texts - ) => { - for (const userId of contractFollowersIds) { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'you_follow_contract', - } + if (sendToBrowser) { + await createBrowserNotification(userId, reason) + recipientIdsList.push(userId) + } + if (sendToEmail) { + if (sourceType === 'comment') { + // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment + await sendNewCommentEmail( + reason, + privateUser, + sourceUser, + sourceContract, + sourceText, + sourceId, + // TODO: Add any paired bets to the comment + undefined, + repliedToType === 'answer' ? repliedToContent : undefined, + repliedToType === 'answer' ? repliedToId : undefined + ) + } else if (sourceType === 'answer') + await sendNewAnswerEmail( + reason, + privateUser, + sourceUser.name, + sourceText, + sourceContract, + sourceUser.avatarUrl + ) + else if ( + sourceType === 'contract' && + sourceUpdateType === 'resolved' && + resolutionData + ) + await sendMarketResolutionEmail( + reason, + privateUser, + resolutionData.userInvestments[userId] ?? 0, + resolutionData.userPayouts[userId] ?? 0, + sourceUser, + resolutionData.creatorPayout, + sourceContract, + resolutionData.outcome, + resolutionData.resolutionProbability, + resolutionData.resolutions + ) + recipientIdsList.push(userId) } } - const notifyContractCreator = async ( - userToReasonTexts: user_to_reason_texts - ) => { - if ( - shouldGetNotification(sourceContract.creatorId, userToReasonTexts) && - stillFollowingContract(sourceContract.creatorId) - ) - userToReasonTexts[sourceContract.creatorId] = { - reason: 'on_users_contract', - } + const notifyContractFollowers = async () => { + for (const userId of contractFollowersIds) { + await sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_you_follow' + : sourceType === 'comment' + ? 'comment_on_contract_you_follow' + : sourceUpdateType === 'updated' + ? 'update_on_contract_you_follow' + : 'resolution_on_contract_you_follow' + ) + } } - const notifyOtherAnswerersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyContractCreator = async () => { + await sendNotificationsIfSettingsPermit( + sourceContract.creatorId, + sourceType === 'comment' + ? 'comment_on_your_contract' + : 'answer_on_your_contract' + ) + } + + const notifyOtherAnswerersOnContract = async () => { const answers = await getValues<Answer>( firestore .collection('contracts') @@ -293,20 +363,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('answers') ) const recipientUserIds = uniq(answers.map((answer) => answer.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_answer' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_answer' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_answer' + : 'resolution_on_contract_with_users_answer' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_answer', - } - }) + ) } - const notifyOtherCommentersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyOtherCommentersOnContract = async () => { const comments = await getValues<Comment>( firestore .collection('contracts') @@ -314,20 +387,23 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( .collection('comments') ) const recipientUserIds = uniq(comments.map((comment) => comment.userId)) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_comment' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_comment' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_comment' + : 'resolution_on_contract_with_users_comment' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_comment', - } - }) + ) } - const notifyBettorsOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyBettorsOnContract = async () => { const betsSnap = await firestore .collection(`contracts/${sourceContract.id}/bets`) .get() @@ -343,88 +419,73 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) } ) - recipientUserIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) + await Promise.all( + recipientUserIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) ) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) + ) } - const notifyRepliedUser = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string, - relatedSourceType: notification_source_types - ) => { - if ( - shouldGetNotification(relatedUserId, userToReasonTexts) && - stillFollowingContract(relatedUserId) - ) { - if (relatedSourceType === 'comment') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_comment', - } - } else if (relatedSourceType === 'answer') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_answer', - } - } - } + const notifyRepliedUser = async () => { + if (sourceType === 'comment' && repliedUserId && repliedToType) + await sendNotificationsIfSettingsPermit( + repliedUserId, + repliedToType === 'answer' + ? 'reply_to_users_answer' + : 'reply_to_users_comment' + ) } - const notifyTaggedUsers = ( - userToReasonTexts: user_to_reason_texts, - userIds: (string | undefined)[] - ) => { - userIds.forEach((id) => { - console.log('tagged user: ', id) - // Allowing non-following users to get tagged - if (id && shouldGetNotification(id, userToReasonTexts)) - userToReasonTexts[id] = { - reason: 'tagged_user', - } - }) + const notifyTaggedUsers = async () => { + if (sourceType === 'comment' && taggedUserIds && taggedUserIds.length > 0) + await Promise.all( + taggedUserIds.map((userId) => + sendNotificationsIfSettingsPermit(userId, 'tagged_user') + ) + ) } - const notifyLiquidityProviders = async ( - userToReasonTexts: user_to_reason_texts - ) => { + const notifyLiquidityProviders = async () => { const liquidityProviders = await firestore .collection(`contracts/${sourceContract.id}/liquidity`) .get() const liquidityProvidersIds = uniq( liquidityProviders.docs.map((doc) => doc.data().userId) ) - liquidityProvidersIds.forEach((userId) => { - if ( - shouldGetNotification(userId, userToReasonTexts) && - stillFollowingContract(userId) - ) { - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - } - }) + await Promise.all( + liquidityProvidersIds.map((userId) => + sendNotificationsIfSettingsPermit( + userId, + sourceType === 'answer' + ? 'answer_on_contract_with_users_shares_in' + : sourceType === 'comment' + ? 'comment_on_contract_with_users_shares_in' + : sourceUpdateType === 'updated' + ? 'update_on_contract_with_users_shares_in' + : 'resolution_on_contract_with_users_shares_in' + ) + ) + ) } - const userToReasonTexts: user_to_reason_texts = {} - if (sourceType === 'comment') { - if (repliedUserId && relatedSourceType) - notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType) - if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? []) - } - await notifyContractCreator(userToReasonTexts) - await notifyOtherAnswerersOnContract(userToReasonTexts) - await notifyLiquidityProviders(userToReasonTexts) - await notifyBettorsOnContract(userToReasonTexts) - await notifyOtherCommentersOnContract(userToReasonTexts) - // if they weren't added previously, add them now - await notifyContractFollowers(userToReasonTexts) - - await createUsersNotifications(userToReasonTexts) + await notifyRepliedUser() + await notifyTaggedUsers() + await notifyContractCreator() + await notifyOtherAnswerersOnContract() + await notifyLiquidityProviders() + await notifyBettorsOnContract() + await notifyOtherCommentersOnContract() + // if they weren't notified previously, notify them now + await notifyContractFollowers() } export const createTipNotification = async ( @@ -436,8 +497,15 @@ export const createTipNotification = async ( contract?: Contract, group?: Group ) => { - const slug = group ? group.slug + `#${commentId}` : commentId + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'tip_received' + ) + if (!sendToBrowser) return + const slug = group ? group.slug + `#${commentId}` : commentId const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -461,6 +529,9 @@ export const createTipNotification = async ( sourceTitle: group?.name, } return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO: send notification to users that are watching the contract and want highly tipped comments only + // maybe TODO: send email notification to bet creator } export const createBetFillNotification = async ( @@ -471,6 +542,14 @@ export const createBetFillNotification = async ( contract: Contract, idempotencyKey: string ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'bet_fill' + ) + if (!sendToBrowser) return + const fill = userBet.fills.find((fill) => fill.matchedBetId === bet.id) const fillAmount = fill?.amount ?? 0 @@ -496,38 +575,8 @@ export const createBetFillNotification = async ( sourceContractId: contract.id, } return await notificationRef.set(removeUndefinedProps(notification)) -} -export const createGroupCommentNotification = async ( - fromUser: User, - toUserId: string, - comment: Comment, - group: Group, - idempotencyKey: string -) => { - if (toUserId === fromUser.id) return - const notificationRef = firestore - .collection(`/users/${toUserId}/notifications`) - .doc(idempotencyKey) - const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}` - const notification: Notification = { - id: idempotencyKey, - userId: toUserId, - reason: 'on_group_you_are_member_of', - createdTime: Date.now(), - isSeen: false, - sourceId: comment.id, - sourceType: 'comment', - sourceUpdateType: 'created', - sourceUserName: fromUser.name, - sourceUserUsername: fromUser.username, - sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: richTextToString(comment.content), - sourceSlug, - sourceTitle: `${group.name}`, - isSeenOnHref: sourceSlug, - } - await notificationRef.set(removeUndefinedProps(notification)) + // maybe TODO: send email notification to bet creator } export const createReferralNotification = async ( @@ -538,6 +587,14 @@ export const createReferralNotification = async ( referredByContract?: Contract, referredByGroup?: Group ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'you_referred_user' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -575,6 +632,8 @@ export const createReferralNotification = async ( : referredByContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } export const createLoanIncomeNotification = async ( @@ -582,6 +641,14 @@ export const createLoanIncomeNotification = async ( idempotencyKey: string, income: number ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'loan_income' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) @@ -612,6 +679,14 @@ export const createChallengeAcceptedNotification = async ( acceptedAmount: number, contract: Contract ) => { + const privateUser = await getPrivateUser(challengeCreator.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'challenge_accepted' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${challengeCreator.id}/notifications`) .doc() @@ -645,6 +720,14 @@ export const createBettingStreakBonusNotification = async ( amount: number, idempotencyKey: string ) => { + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'betting_streak_incremented' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${user.id}/notifications`) .doc(idempotencyKey) @@ -680,13 +763,24 @@ export const createLikeNotification = async ( contract: Contract, tip?: TipTxn ) => { + const privateUser = await getPrivateUser(toUser.id) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'liked_and_tipped_your_contract' + ) + if (!sendToBrowser) return + + // not handling just likes, must include tip + if (!tip) return + const notificationRef = firestore .collection(`/users/${toUser.id}/notifications`) .doc(idempotencyKey) const notification: Notification = { id: idempotencyKey, userId: toUser.id, - reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract', + reason: 'liked_and_tipped_your_contract', createdTime: Date.now(), isSeen: false, sourceId: like.id, @@ -703,20 +797,8 @@ export const createLikeNotification = async ( sourceTitle: contract.question, } return await notificationRef.set(removeUndefinedProps(notification)) -} -export async function filterUserIdsForOnlyFollowerIds( - userIds: string[], - contractId: string -) { - // get contract follower documents and check here if they're a follower - const contractFollowersSnap = await firestore - .collection(`contracts/${contractId}/follows`) - .get() - const contractFollowersIds = contractFollowersSnap.docs.map( - (doc) => doc.data().id - ) - return userIds.filter((id) => contractFollowersIds.includes(id)) + // TODO send email notification } export const createUniqueBettorBonusNotification = async ( @@ -727,6 +809,15 @@ export const createUniqueBettorBonusNotification = async ( amount: number, idempotencyKey: string ) => { + console.log('createUniqueBettorBonusNotification') + const privateUser = await getPrivateUser(contractCreatorId) + if (!privateUser) return + const { sendToBrowser } = await getDestinationsForUser( + privateUser, + 'unique_bettors_on_your_contract' + ) + if (!sendToBrowser) return + const notificationRef = firestore .collection(`/users/${contractCreatorId}/notifications`) .doc(idempotencyKey) @@ -752,4 +843,6 @@ export const createUniqueBettorBonusNotification = async ( sourceContractCreatorUsername: contract.creatorUsername, } return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index eabe0fd0..71272222 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,7 +1,11 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { PrivateUser, User } from '../../common/user' +import { + getDefaultNotificationSettings, + PrivateUser, + User, +} from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' import { randomString } from '../../common/util/random' import { @@ -79,6 +83,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { email, initialIpAddress: req.ip, initialDeviceToken: deviceToken, + notificationSubscriptionTypes: getDefaultNotificationSettings(auth.uid), } await firestore.collection('private-users').doc(auth.uid).create(privateUser) diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html index 6c75f026..c8f6a171 100644 --- a/functions/src/email-templates/500-mana.html +++ b/functions/src/email-templates/500-mana.html @@ -284,9 +284,12 @@ 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:11px;letter-spacing:normal;line-height:22px;text-align:center;color:#000000;"> - <p style="margin: 10px 0;">This e-mail has been sent to {{name}}, <a - href="{{unsubscribeLink}}” style=" color:inherit;text-decoration:none;" - target="_blank">click here to unsubscribe</a>.</p> + <p style="margin: 10px 0;">This e-mail has been sent to {{name}}, + <a href="{{unsubscribeUrl}}" style=" + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. + </p> </div> </td> </tr> diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index df215bdc..c73f7458 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -491,10 +491,10 @@ "> <p style="margin: 10px 0"> This e-mail has been sent to {{name}}, - <a href="{{unsubscribeLink}}" style=" + <a href="{{unsubscribeUrl}}" style=" color: inherit; text-decoration: none; - " target="_blank">click here to unsubscribe</a>. + " target="_blank">click here to manage your notifications</a>. </p> </div> </td> diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html index d00b227e..7c3e653d 100644 --- a/functions/src/email-templates/interesting-markets.html +++ b/functions/src/email-templates/interesting-markets.html @@ -440,11 +440,10 @@ <p style="margin: 10px 0"> This e-mail has been sent to {{name}}, - <a href="{{unsubscribeLink}}" - style=" + <a href="{{unsubscribeUrl}}" style=" color: inherit; text-decoration: none; - " target="_blank">click here to unsubscribe</a> from future recommended markets. + " target="_blank">click here to manage your notifications</a>. </p> </div> </td> diff --git a/functions/src/email-templates/market-answer-comment.html b/functions/src/email-templates/market-answer-comment.html index 4e1a2bfa..a19aa7c3 100644 --- a/functions/src/email-templates/market-answer-comment.html +++ b/functions/src/email-templates/market-answer-comment.html @@ -526,19 +526,10 @@ " >our Discord</a >! Or, - <a - href="{{unsubscribeUrl}}" - style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - " - >unsubscribe</a - >. + <a href="{{unsubscribeUrl}}" style=" + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/market-answer.html b/functions/src/email-templates/market-answer.html index 1f7fa5fa..b2d7f727 100644 --- a/functions/src/email-templates/market-answer.html +++ b/functions/src/email-templates/market-answer.html @@ -367,14 +367,9 @@ margin: 0; ">our Discord</a>! Or, <a href="{{unsubscribeUrl}}" style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - ">unsubscribe</a>. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index fa44c1d5..ee7976b0 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -485,14 +485,9 @@ margin: 0; ">our Discord</a>! Or, <a href="{{unsubscribeUrl}}" style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - ">unsubscribe</a>. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/market-comment.html b/functions/src/email-templates/market-comment.html index 0b5b9a54..23e20dac 100644 --- a/functions/src/email-templates/market-comment.html +++ b/functions/src/email-templates/market-comment.html @@ -367,14 +367,9 @@ margin: 0; ">our Discord</a>! Or, <a href="{{unsubscribeUrl}}" style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - ">unsubscribe</a>. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/market-resolved.html b/functions/src/email-templates/market-resolved.html index c1ff3beb..de29a0f1 100644 --- a/functions/src/email-templates/market-resolved.html +++ b/functions/src/email-templates/market-resolved.html @@ -500,14 +500,9 @@ margin: 0; ">our Discord</a>! Or, <a href="{{unsubscribeUrl}}" style=" - font-family: 'Helvetica Neue', Helvetica, Arial, - sans-serif; - box-sizing: border-box; - font-size: 12px; - color: #999; - text-decoration: underline; - margin: 0; - ">unsubscribe</a>. + color: inherit; + text-decoration: none; + " target="_blank">click here to manage your notifications</a>. </td> </tr> </table> diff --git a/functions/src/email-templates/one-week.html b/functions/src/email-templates/one-week.html index 94889772..b8e233d5 100644 --- a/functions/src/email-templates/one-week.html +++ b/functions/src/email-templates/one-week.html @@ -1,519 +1,316 @@ <!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>7th Day Anniversary Gift! - - - - - - - - - + + + + - - + - - + + + + + +
+ +
+ + + +
+ +
+ + + + - - -
+ - - - - - - - - - - - - + + +
- - - - - - -
- -
-
-
-

- Hopefully you haven't gambled all your M$ - away already... but if you have I bring good - news! Click the link below to recieve a one time - gift of M$ 500 to your account! -

-
-
- - - - - - -
- - Get M$500 - << /td> -
-
-
-

- If you are still engaging with our markets then - at this point you might as well join our Discord server. - You can always leave if you dont like it but - I'd be willing to make a market betting - you'll stay. -

-

-
-

- Cheers, -

-

- David from Manifold -

-

-
-
- - -
-
- -
- - - - + + +
- -
- - + + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

Thanks for + using Manifold Markets. Running low + on mana (M$)? Click the link below to receive a one time gift of M$500!

+
+
+

+
+ + + + +
+ + + + +
+ + Claim M$500 + +
+
+
+
+

Did + you know, besides making correct predictions, there are + plenty of other ways to earn mana?

+ +

 

+

Cheers, +

+

David + from Manifold

+

 

+
+
+
+ +
+
+ +
+ + + + diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html index 366709e3..dccec695 100644 --- a/functions/src/email-templates/welcome.html +++ b/functions/src/email-templates/welcome.html @@ -137,7 +137,7 @@ style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;" data-testid="4XoHRGw1Y"> - Welcome! Manifold Markets is a play-money prediction market platform where you can bet on + Welcome! Manifold Markets is a play-money prediction market platform where you can predict anything, from elections to Elon Musk to scientific papers to the NBA.

@@ -286,9 +286,12 @@ style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
-

This e-mail has been sent to {{name}}, click here to unsubscribe.

+

This e-mail has been sent to {{name}}, + click here to manage your notifications. +

diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 2c9c6f12..b9d34363 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,10 +1,12 @@ import { DOMAIN } from '../../common/envs/constants' -import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' import { getProbability } from '../../common/calculate' -import { Comment } from '../../common/comment' import { Contract } from '../../common/contract' -import { PrivateUser, User } from '../../common/user' +import { + notification_subscription_types, + PrivateUser, + User, +} from '../../common/user' import { formatLargeNumber, formatMoney, @@ -14,15 +16,16 @@ import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' import { sendTemplateEmail, sendTextEmail } from './send-email' -import { getPrivateUser, getUser } from './utils' -import { getFunctionUrl } from '../../common/api' -import { richTextToString } from '../../common/util/parse' +import { getUser } from './utils' import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' - -const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') +import { + notification_reason_types, + getDestinationsForUser, +} from '../../common/notification' export const sendMarketResolutionEmail = async ( - userId: string, + reason: notification_reason_types, + privateUser: PrivateUser, investment: number, payout: number, creator: User, @@ -32,15 +35,11 @@ export const sendMarketResolutionEmail = async ( resolutionProbability?: number, resolutions?: { [outcome: string]: number } ) => { - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - privateUser.unsubscribedFromResolutionEmails || - !privateUser.email - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser || !privateUser.email || !sendToEmail) return - const user = await getUser(userId) + const user = await getUser(privateUser.id) if (!user) return const outcome = toDisplayResolution( @@ -53,13 +52,10 @@ export const sendMarketResolutionEmail = async ( const subject = `Resolved ${outcome}: ${contract.question}` const creatorPayoutText = - creatorPayout >= 1 && userId === creator.id + creatorPayout >= 1 && privateUser.id === creator.id ? ` (plus ${formatMoney(creatorPayout)} in commissions)` : '' - const emailType = 'market-resolved' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - const displayedInvestment = Number.isNaN(investment) || investment < 0 ? formatMoney(0) @@ -154,11 +150,12 @@ export const sendWelcomeEmail = async ( ) => { if (!privateUser || !privateUser.email) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, @@ -166,7 +163,7 @@ export const sendWelcomeEmail = async ( 'welcome', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -217,23 +214,23 @@ export const sendOneWeekBonusEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, 'Manifold Markets one week anniversary gift', 'one-week', { name: firstName, - unsubscribeLink, + unsubscribeUrl, manalink: 'https://manifold.markets/link/lj4JbBvE', }, { @@ -250,23 +247,23 @@ export const sendCreatorGuideEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationSubscriptionTypes.onboarding_flow.includes('email') ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'onboarding_flow' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, 'Create your own prediction market', 'creating-market', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -282,15 +279,18 @@ export const sendThankYouEmail = async ( if ( !privateUser || !privateUser.email || - privateUser.unsubscribedFromGenericEmails + !privateUser.notificationSubscriptionTypes.thank_you_for_purchases.includes( + 'email' + ) ) return - const { name, id: userId } = user + const { name } = user const firstName = name.split(' ')[0] - const emailType = 'generic' - const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'thank_you_for_purchases' as keyof notification_subscription_types + }` return await sendTemplateEmail( privateUser.email, @@ -298,7 +298,7 @@ export const sendThankYouEmail = async ( 'thank-you', { name: firstName, - unsubscribeLink, + unsubscribeUrl, }, { from: 'David from Manifold ', @@ -307,16 +307,15 @@ export const sendThankYouEmail = async ( } export const sendMarketCloseEmail = async ( + reason: notification_reason_types, user: User, privateUser: PrivateUser, contract: Contract ) => { - if ( - !privateUser || - privateUser.unsubscribedFromResolutionEmails || - !privateUser.email - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + + if (!privateUser.email || !sendToEmail) return const { username, name, id: userId } = user const firstName = name.split(' ')[0] @@ -324,8 +323,6 @@ export const sendMarketCloseEmail = async ( const { question, slug, volume } = contract const url = `https://${DOMAIN}/${username}/${slug}` - const emailType = 'market-resolve' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` return await sendTemplateEmail( privateUser.email, @@ -343,30 +340,24 @@ export const sendMarketCloseEmail = async ( } export const sendNewCommentEmail = async ( - userId: string, + reason: notification_reason_types, + privateUser: PrivateUser, commentCreator: User, contract: Contract, - comment: Comment, + commentText: string, + commentId: string, bet?: Bet, answerText?: string, answerId?: string ) => { - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - !privateUser.email || - privateUser.unsubscribedFromCommentEmails - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser || !privateUser.email || !sendToEmail) return - const { question, creatorUsername, slug } = contract - const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}#${comment.id}` - const emailType = 'market-comment' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + const { question } = contract + const marketUrl = `https://${DOMAIN}/${contract.creatorUsername}/${contract.slug}#${commentId}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { content } = comment - const text = richTextToString(content) let betDescription = '' if (bet) { @@ -380,7 +371,7 @@ export const sendNewCommentEmail = async ( const from = `${commentorName} on Manifold ` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { - const answerNumber = `#${answerId}` + const answerNumber = answerId ? `#${answerId}` : '' return await sendTemplateEmail( privateUser.email, @@ -391,7 +382,7 @@ export const sendNewCommentEmail = async ( answerNumber, commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -412,7 +403,7 @@ export const sendNewCommentEmail = async ( { commentorName, commentorAvatarUrl: commentorAvatarUrl ?? '', - comment: text, + comment: commentText, marketUrl, unsubscribeUrl, betDescription, @@ -423,29 +414,24 @@ export const sendNewCommentEmail = async ( } export const sendNewAnswerEmail = async ( - answer: Answer, - contract: Contract + reason: notification_reason_types, + privateUser: PrivateUser, + name: string, + text: string, + contract: Contract, + avatarUrl?: string ) => { - // Send to just the creator for now. - const { creatorId: userId } = contract - + const { creatorId } = contract // Don't send the creator's own answers. - if (answer.userId === userId) return + if (privateUser.id === creatorId) return - const privateUser = await getPrivateUser(userId) - if ( - !privateUser || - !privateUser.email || - privateUser.unsubscribedFromAnswerEmails - ) - return + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return const { question, creatorUsername, slug } = contract - const { name, avatarUrl, text } = answer const marketUrl = `https://${DOMAIN}/${creatorUsername}/${slug}` - const emailType = 'market-answer' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const subject = `New answer on ${question}` const from = `${name} ` @@ -474,12 +460,15 @@ export const sendInterestingMarketsEmail = async ( if ( !privateUser || !privateUser.email || - privateUser?.unsubscribedFromWeeklyTrendingEmails + !privateUser.notificationSubscriptionTypes.trending_markets.includes( + 'email' + ) ) return - const emailType = 'weekly-trending' - const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}` + const unsubscribeUrl = `${DOMAIN}/notifications?tab=settings§ion=${ + 'trending_markets' as keyof notification_subscription_types + }` const { name } = user const firstName = name.split(' ')[0] @@ -490,7 +479,7 @@ export const sendInterestingMarketsEmail = async ( 'interesting-markets', { name: firstName, - unsubscribeLink: unsubscribeUrl, + unsubscribeUrl, question1Title: contractsToSend[0].question, question1Link: contractUrl(contractsToSend[0]), diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index f31674a1..7878e410 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -3,7 +3,6 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' import { getPrivateUser, getUserByUsername } from './utils' -import { sendMarketCloseEmail } from './emails' import { createNotification } from './create-notification' export const marketCloseNotifications = functions @@ -56,7 +55,6 @@ async function sendMarketCloseEmails() { const privateUser = await getPrivateUser(user.id) if (!privateUser) continue - await sendMarketCloseEmail(user, privateUser, contract) await createNotification( contract.id, 'contract', diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index a36a8bca..a46420bc 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,15 +1,11 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { compact, uniq } from 'lodash' +import { compact } from 'lodash' import { getContract, getUser, getValues } from './utils' import { ContractComment } from '../../common/comment' -import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { - createCommentOrAnswerOrUpdatedContractNotification, - filterUserIdsForOnlyFollowerIds, -} from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -77,10 +73,10 @@ export const onCreateCommentOnContract = functions const comments = await getValues( firestore.collection('contracts').doc(contractId).collection('comments') ) - const relatedSourceType = comment.replyToCommentId - ? 'comment' - : comment.answerOutcome + const repliedToType = answer ? 'answer' + : comment.replyToCommentId + ? 'comment' : undefined const repliedUserId = comment.replyToCommentId @@ -96,31 +92,11 @@ export const onCreateCommentOnContract = functions richTextToString(comment.content), contract, { - relatedSourceType, + repliedToType, + repliedToId: comment.replyToCommentId || answer?.id, + repliedToContent: answer ? answer.text : undefined, repliedUserId, taggedUserIds: compact(parseMentions(comment.content)), } ) - - const recipientUserIds = await filterUserIdsForOnlyFollowerIds( - uniq([ - contract.creatorId, - ...comments.map((comment) => comment.userId), - ]).filter((id) => id !== comment.userId), - contractId - ) - - await Promise.all( - recipientUserIds.map((userId) => - sendNewCommentEmail( - userId, - commentCreator, - contract, - comment, - bet, - answer?.text, - answer?.id - ) - ) - ) }) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index d7ecd56e..2972a305 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -13,32 +13,7 @@ export const onUpdateContract = functions.firestore if (!contractUpdater) throw new Error('Could not find contract updater') const previousValue = change.before.data() as Contract - if (previousValue.isResolved !== contract.isResolved) { - let resolutionText = contract.resolution ?? contract.question - if (contract.outcomeType === 'FREE_RESPONSE') { - const answerText = contract.answers.find( - (answer) => answer.id === contract.resolution - )?.text - if (answerText) resolutionText = answerText - } else if (contract.outcomeType === 'BINARY') { - if (resolutionText === 'MKT' && contract.resolutionProbability) - resolutionText = `${contract.resolutionProbability}%` - else if (resolutionText === 'MKT') resolutionText = 'PROB' - } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { - if (resolutionText === 'MKT' && contract.resolutionValue) - resolutionText = `${contract.resolutionValue}` - } - - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'resolved', - contractUpdater, - eventId, - resolutionText, - contract - ) - } else if ( + if ( previousValue.closeTime !== contract.closeTime || previousValue.question !== contract.question ) { diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 6f8ea2e9..015ac72f 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,6 +1,6 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { difference, mapValues, groupBy, sumBy } from 'lodash' +import { mapValues, groupBy, sumBy } from 'lodash' import { Contract, @@ -8,10 +8,8 @@ import { MultipleChoiceContract, RESOLUTIONS, } from '../../common/contract' -import { User } from '../../common/user' import { Bet } from '../../common/bet' import { getUser, isProd, payUser } from './utils' -import { sendMarketResolutionEmail } from './emails' import { getLoanPayouts, getPayouts, @@ -23,7 +21,7 @@ import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' import { getContractBetMetrics } from '../../common/calculate' -import { floatingEqual } from '../../common/util/math' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' const bodySchema = z.object({ contractId: z.string(), @@ -163,15 +161,45 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) - await sendResolutionEmails( - bets, - userPayoutsWithoutLoans, + const userInvestments = mapValues( + groupBy(bets, (bet) => bet.userId), + (bets) => getContractBetMetrics(contract, bets).invested + ) + let resolutionText = outcome ?? contract.question + if (contract.outcomeType === 'FREE_RESPONSE') { + const answerText = contract.answers.find( + (answer) => answer.id === outcome + )?.text + if (answerText) resolutionText = answerText + } else if (contract.outcomeType === 'BINARY') { + if (resolutionText === 'MKT' && probabilityInt) + resolutionText = `${probabilityInt}%` + else if (resolutionText === 'MKT') resolutionText = 'PROB' + } else if (contract.outcomeType === 'PSEUDO_NUMERIC') { + if (resolutionText === 'MKT' && value) resolutionText = `${value}` + } + + // TODO: this actually may be too slow to complete with a ton of users to notify? + await createCommentOrAnswerOrUpdatedContractNotification( + contract.id, + 'contract', + 'resolved', creator, - creatorPayout, + contract.id + '-resolution', + resolutionText, contract, - outcome, - resolutionProbability, - resolutions + undefined, + { + bets, + userInvestments, + userPayouts: userPayoutsWithoutLoans, + creator, + creatorPayout, + contract, + outcome, + resolutionProbability, + resolutions, + } ) return updatedContract @@ -189,51 +217,6 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => { .then(() => ({ status: 'success' })) } -const sendResolutionEmails = async ( - bets: Bet[], - userPayouts: { [userId: string]: number }, - creator: User, - creatorPayout: number, - contract: Contract, - outcome: string, - resolutionProbability?: number, - resolutions?: { [outcome: string]: number } -) => { - const investedByUser = mapValues( - groupBy(bets, (bet) => bet.userId), - (bets) => getContractBetMetrics(contract, bets).invested - ) - const investedUsers = Object.keys(investedByUser).filter( - (userId) => !floatingEqual(investedByUser[userId], 0) - ) - - const nonWinners = difference(investedUsers, Object.keys(userPayouts)) - const emailPayouts = [ - ...Object.entries(userPayouts), - ...nonWinners.map((userId) => [userId, 0] as const), - ].map(([userId, payout]) => ({ - userId, - investment: investedByUser[userId] ?? 0, - payout, - })) - - await Promise.all( - emailPayouts.map(({ userId, investment, payout }) => - sendMarketResolutionEmail( - userId, - investment, - payout, - creator, - creatorPayout, - contract, - outcome, - resolutionProbability, - resolutions - ) - ) - ) -} - function getResolutionParams(contract: Contract, body: string) { const { outcomeType } = contract diff --git a/functions/src/scripts/create-new-notification-preferences.ts b/functions/src/scripts/create-new-notification-preferences.ts new file mode 100644 index 00000000..a6bd1a0b --- /dev/null +++ b/functions/src/scripts/create-new-notification-preferences.ts @@ -0,0 +1,30 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getDefaultNotificationSettings } from 'common/user' +import { getAllPrivateUsers, isProd } from 'functions/src/utils' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const privateUsers = await getAllPrivateUsers() + const disableEmails = !isProd() + await Promise.all( + privateUsers.map((privateUser) => { + if (!privateUser.id) return Promise.resolve() + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationSubscriptionTypes: getDefaultNotificationSettings( + privateUser.id, + privateUser, + disableEmails + ), + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts index acce446e..f9b8c3a1 100644 --- a/functions/src/scripts/create-private-users.ts +++ b/functions/src/scripts/create-private-users.ts @@ -3,7 +3,7 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' initAdmin() -import { PrivateUser, User } from 'common/user' +import { getDefaultNotificationSettings, PrivateUser, User } from 'common/user' import { STARTING_BALANCE } from 'common/economy' const firestore = admin.firestore() @@ -21,6 +21,7 @@ async function main() { id: user.id, email, username, + notificationSubscriptionTypes: getDefaultNotificationSettings(user.id), } if (user.totalDeposits === undefined) { diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx deleted file mode 100644 index 7ee27fb5..00000000 --- a/web/components/NotificationSettings.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useUser } from 'web/hooks/use-user' -import React, { useEffect, useState } from 'react' -import { notification_subscribe_types, PrivateUser } from 'common/user' -import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' -import toast from 'react-hot-toast' -import { track } from '@amplitude/analytics-browser' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { Row } from 'web/components/layout/row' -import clsx from 'clsx' -import { CheckIcon, XIcon } from '@heroicons/react/outline' -import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' -import { Col } from 'web/components/layout/col' -import { FollowMarketModal } from 'web/components/contract/follow-market-modal' - -export function NotificationSettings() { - const user = useUser() - const [notificationSettings, setNotificationSettings] = - useState('all') - const [emailNotificationSettings, setEmailNotificationSettings] = - useState('all') - const [privateUser, setPrivateUser] = useState(null) - const [showModal, setShowModal] = useState(false) - - useEffect(() => { - if (user) listenForPrivateUser(user.id, setPrivateUser) - }, [user]) - - useEffect(() => { - if (!privateUser) return - if (privateUser.notificationPreferences) { - setNotificationSettings(privateUser.notificationPreferences) - } - if ( - privateUser.unsubscribedFromResolutionEmails && - privateUser.unsubscribedFromCommentEmails && - privateUser.unsubscribedFromAnswerEmails - ) { - setEmailNotificationSettings('none') - } else if ( - !privateUser.unsubscribedFromResolutionEmails && - !privateUser.unsubscribedFromCommentEmails && - !privateUser.unsubscribedFromAnswerEmails - ) { - setEmailNotificationSettings('all') - } else { - setEmailNotificationSettings('less') - } - }, [privateUser]) - - const loading = 'Changing Notifications Settings' - const success = 'Notification Settings Changed!' - function changeEmailNotifications(newValue: notification_subscribe_types) { - if (!privateUser) return - if (newValue === 'all') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: false, - unsubscribedFromCommentEmails: false, - unsubscribedFromAnswerEmails: false, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } else if (newValue === 'less') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: false, - unsubscribedFromCommentEmails: true, - unsubscribedFromAnswerEmails: true, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } else if (newValue === 'none') { - toast.promise( - updatePrivateUser(privateUser.id, { - unsubscribedFromResolutionEmails: true, - unsubscribedFromCommentEmails: true, - unsubscribedFromAnswerEmails: true, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } - } - - function changeInAppNotificationSettings( - newValue: notification_subscribe_types - ) { - if (!privateUser) return - track('In-App Notification Preferences Changed', { - newPreference: newValue, - oldPreference: privateUser.notificationPreferences, - }) - toast.promise( - updatePrivateUser(privateUser.id, { - notificationPreferences: newValue, - }), - { - loading, - success, - error: (err) => `${err.message}`, - } - ) - } - - useEffect(() => { - if (privateUser && privateUser.notificationPreferences) - setNotificationSettings(privateUser.notificationPreferences) - else setNotificationSettings('all') - }, [privateUser]) - - if (!privateUser) { - return - } - - function NotificationSettingLine(props: { - label: string | React.ReactNode - highlight: boolean - onClick?: () => void - }) { - const { label, highlight, onClick } = props - return ( - - {highlight ? : } - {label} - - ) - } - - return ( -
-
In App Notifications
- - changeInAppNotificationSettings( - choice as notification_subscribe_types - ) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> -
-
- - You will receive notifications for these general events: - - - - You will receive new comment, answer, & resolution notifications on - questions: - - - That you watch - you - auto-watch questions if: - - } - onClick={() => setShowModal(true)} - /> - - • You create it - • You bet, comment on, or answer it - • You add liquidity to it - - • If you select 'Less' and you've commented on or answered a - question, you'll only receive notification on direct replies to - your comments or answers - - - - -
Email Notifications
- - changeEmailNotifications(choice as notification_subscribe_types) - } - className={'col-span-4 p-2'} - toggleClassName={'w-24'} - /> -
-
- You will receive emails for: - - - - -
-
- - - ) -} diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/watch-market-modal.tsx similarity index 74% rename from web/components/contract/follow-market-modal.tsx rename to web/components/contract/watch-market-modal.tsx index fb62ce9f..2fb9bc00 100644 --- a/web/components/contract/follow-market-modal.tsx +++ b/web/components/contract/watch-market-modal.tsx @@ -4,7 +4,7 @@ import { EyeIcon } from '@heroicons/react/outline' import React from 'react' import clsx from 'clsx' -export const FollowMarketModal = (props: { +export const WatchMarketModal = (props: { open: boolean setOpen: (b: boolean) => void title?: string @@ -18,20 +18,21 @@ export const FollowMarketModal = (props: {
• What is watching? - You can receive notifications on questions you're interested in by + You'll receive notifications on markets by betting, commenting, or clicking the • What types of notifications will I receive? - You'll receive in-app notifications for new comments, answers, and - updates to the question. + You'll receive notifications for new comments, answers, and updates + to the question. See the notifications settings pages to customize + which types of notifications you receive on watched markets. diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx index 332b044a..1dd261cb 100644 --- a/web/components/follow-market-button.tsx +++ b/web/components/follow-market-button.tsx @@ -11,7 +11,7 @@ import { User } from 'common/user' import { useContractFollows } from 'web/hooks/use-follows' import { firebaseLogin, updateUser } from 'web/lib/firebase/users' import { track } from 'web/lib/service/analytics' -import { FollowMarketModal } from 'web/components/contract/follow-market-modal' +import { WatchMarketModal } from 'web/components/contract/watch-market-modal' import { useState } from 'react' import { Col } from 'web/components/layout/col' @@ -65,7 +65,7 @@ export const FollowMarketButton = (props: { Watch )} - + } + + const emailsEnabled: Array = [ + 'all_comments_on_watched_markets', + 'all_replies_to_my_comments_on_watched_markets', + 'all_comments_on_contracts_with_shares_in_on_watched_markets', + + 'all_answers_on_watched_markets', + 'all_replies_to_my_answers_on_watched_markets', + 'all_answers_on_contracts_with_shares_in_on_watched_markets', + + 'your_contract_closed', + 'all_comments_on_my_markets', + 'all_answers_on_my_markets', + + 'resolutions_on_watched_markets_with_shares_in', + 'resolutions_on_watched_markets', + + 'tagged_user', + 'trending_markets', + 'onboarding_flow', + 'thank_you_for_purchases', + + // TODO: add these + // 'contract_from_followed_user', + // 'referral_bonuses', + // 'unique_bettors_on_your_contract', + // 'tips_on_your_markets', + // 'tips_on_your_comments', + // 'subsidized_your_market', + // 'on_new_follow', + // maybe the following? + // 'profit_loss_updates', + // 'probability_updates_on_watched_markets', + // 'limit_order_fills', + ] + const browserDisabled: Array = [ + 'trending_markets', + 'profit_loss_updates', + 'onboarding_flow', + 'thank_you_for_purchases', + ] + + type sectionData = { + label: string + subscriptionTypeToDescription: { + [key in keyof Partial]: string + } + } + + const comments: sectionData = { + label: 'New Comments', + subscriptionTypeToDescription: { + all_comments_on_watched_markets: 'All new comments', + all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + all_replies_to_my_comments_on_watched_markets: + 'Only replies to your comments', + // comments_by_followed_users_on_watched_markets: 'By followed users', + }, + } + + const answers: sectionData = { + label: 'New Answers', + subscriptionTypeToDescription: { + all_answers_on_watched_markets: 'All new answers', + all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + all_replies_to_my_answers_on_watched_markets: + 'Only replies to your answers', + // answers_by_followed_users_on_watched_markets: 'By followed users', + // answers_by_market_creator_on_watched_markets: 'By market creator', + }, + } + const updates: sectionData = { + label: 'Updates & Resolutions', + subscriptionTypeToDescription: { + market_updates_on_watched_markets: 'All creator updates', + market_updates_on_watched_markets_with_shares_in: `Only creator updates on markets you're invested in`, + resolutions_on_watched_markets: 'All market resolutions', + resolutions_on_watched_markets_with_shares_in: `Only market resolutions you're invested in`, + // probability_updates_on_watched_markets: 'Probability updates', + }, + } + const yourMarkets: sectionData = { + label: 'Markets You Created', + subscriptionTypeToDescription: { + your_contract_closed: 'Your market has closed (and needs resolution)', + all_comments_on_my_markets: 'Comments on your markets', + all_answers_on_my_markets: 'Answers on your markets', + subsidized_your_market: 'Your market was subsidized', + tips_on_your_markets: 'Likes on your markets', + }, + } + const bonuses: sectionData = { + label: 'Bonuses', + subscriptionTypeToDescription: { + betting_streaks: 'Betting streak bonuses', + referral_bonuses: 'Referral bonuses from referring users', + unique_bettors_on_your_contract: 'Unique bettor bonuses on your markets', + }, + } + const otherBalances: sectionData = { + label: 'Other', + subscriptionTypeToDescription: { + loan_income: 'Automatic loans from your profitable bets', + limit_order_fills: 'Limit order fills', + tips_on_your_comments: 'Tips on your comments', + }, + } + const userInteractions: sectionData = { + label: 'Users', + subscriptionTypeToDescription: { + tagged_user: 'A user tagged you', + on_new_follow: 'Someone followed you', + contract_from_followed_user: 'New markets created by users you follow', + }, + } + const generalOther: sectionData = { + label: 'Other', + subscriptionTypeToDescription: { + trending_markets: 'Weekly interesting markets', + thank_you_for_purchases: 'Thank you notes for your purchases', + onboarding_flow: 'Explanatory emails to help you get started', + // profit_loss_updates: 'Weekly profit/loss updates', + }, + } + + const NotificationSettingLine = ( + description: string, + key: keyof notification_subscription_types, + value: notification_destination_types[] + ) => { + const previousInAppValue = value.includes('browser') + const previousEmailValue = value.includes('email') + const [inAppEnabled, setInAppEnabled] = useState(previousInAppValue) + const [emailEnabled, setEmailEnabled] = useState(previousEmailValue) + const loading = 'Changing Notifications Settings' + const success = 'Changed Notification Settings!' + const highlight = navigateToSection === key + + useEffect(() => { + if ( + inAppEnabled !== previousInAppValue || + emailEnabled !== previousEmailValue + ) { + toast.promise( + updatePrivateUser(privateUser.id, { + notificationSubscriptionTypes: { + ...privateUser.notificationSubscriptionTypes, + [key]: filterDefined([ + inAppEnabled ? 'browser' : undefined, + emailEnabled ? 'email' : undefined, + ]), + }, + }), + { + success, + loading, + error: 'Error changing notification settings. Try again?', + } + ) + } + }, [ + inAppEnabled, + emailEnabled, + previousInAppValue, + previousEmailValue, + key, + ]) + + return ( + + + + {description} + + + {!browserDisabled.includes(key) && ( + + )} + {emailsEnabled.includes(key) && ( + + )} + + + + ) + } + + const getUsersSavedPreference = ( + key: keyof notification_subscription_types + ) => { + return privateUser.notificationSubscriptionTypes[key] ?? [] + } + + const Section = (icon: ReactNode, data: sectionData) => { + const { label, subscriptionTypeToDescription } = data + const expand = + navigateToSection && + Object.keys(subscriptionTypeToDescription).includes(navigateToSection) + const [expanded, setExpanded] = useState(expand) + + // Not working as the default value for expanded, so using a useEffect + useEffect(() => { + if (expand) setExpanded(true) + }, [expand]) + + return ( + + setExpanded(!expanded)} + > + {icon} + {label} + + {expanded ? ( + + Hide + + ) : ( + + Show + + )} + + + {Object.entries(subscriptionTypeToDescription).map(([key, value]) => + NotificationSettingLine( + value, + key as keyof notification_subscription_types, + getUsersSavedPreference( + key as keyof notification_subscription_types + ) + ) + )} + + + ) + } + + return ( +
+
+ + Notifications for Watched Markets + setShowWatchModal(true)} + /> + + {Section(, comments)} + {Section(, answers)} + {Section(, updates)} + {Section(, yourMarkets)} + + Balance Changes + + {Section(, bonuses)} + {Section(, otherBalances)} + + General + + {Section(, userInteractions)} + {Section(, generalOther)} + + + + ) +} diff --git a/web/components/switch-setting.tsx b/web/components/switch-setting.tsx new file mode 100644 index 00000000..0e93c420 --- /dev/null +++ b/web/components/switch-setting.tsx @@ -0,0 +1,34 @@ +import { Switch } from '@headlessui/react' +import clsx from 'clsx' +import React from 'react' + +export const SwitchSetting = (props: { + checked: boolean + onChange: (checked: boolean) => void + label: string +}) => { + const { checked, onChange, label } = props + return ( + + + + + {label} + + + ) +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index 473facd4..d8ce025e 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import { notification_subscribe_types, PrivateUser } from 'common/user' +import { PrivateUser } from 'common/user' import { Notification } from 'common/notification' import { getNotificationsQuery } from 'web/lib/firebase/notifications' import { groupBy, map, partition } from 'lodash' @@ -23,11 +23,8 @@ function useNotifications(privateUser: PrivateUser) { if (!result.data) return undefined const notifications = result.data as Notification[] - return getAppropriateNotifications( - notifications, - privateUser.notificationPreferences - ).filter((n) => !n.isSeenOnHref) - }, [privateUser.notificationPreferences, result.data]) + return notifications.filter((n) => !n.isSeenOnHref) + }, [result.data]) return notifications } @@ -111,29 +108,3 @@ export function groupNotifications(notifications: Notification[]) { }) return notificationGroups } - -const lessPriorityReasons = [ - 'on_contract_with_users_comment', - 'on_contract_with_users_answer', - // Notifications not currently generated for users who've sold their shares - 'on_contract_with_users_shares_out', - // Not sure if users will want to see these w/ less: - // 'on_contract_with_users_shares_in', -] - -function getAppropriateNotifications( - notifications: Notification[], - notificationPreferences?: notification_subscribe_types -) { - if (notificationPreferences === 'all') return notifications - if (notificationPreferences === 'less') - return notifications.filter( - (n) => - n.reason && - // Show all contract notifications and any that aren't in the above list: - (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason)) - ) - if (notificationPreferences === 'none') return [] - - return notifications -} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index d10812bf..57e13fb9 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1,6 +1,6 @@ -import { Tabs } from 'web/components/layout/tabs' +import { ControlledTabs } from 'web/components/layout/tabs' import React, { useEffect, useMemo, useState } from 'react' -import Router from 'next/router' +import Router, { useRouter } from 'next/router' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' @@ -40,7 +40,7 @@ import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' import { safeLocalStorage } from 'web/lib/util/local' import { SiteLink } from 'web/components/site-link' -import { NotificationSettings } from 'web/components/NotificationSettings' +import { NotificationSettings } from 'web/components/notification-settings' import { SEO } from 'web/components/SEO' import { usePrivateUser, useUser } from 'web/hooks/use-user' import { UserLink } from 'web/components/user-link' @@ -56,24 +56,49 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50' export default function Notifications() { const privateUser = usePrivateUser() + const router = useRouter() + const [navigateToSection, setNavigateToSection] = useState() + const [activeIndex, setActiveIndex] = useState(0) useEffect(() => { if (privateUser === null) Router.push('/') }) + useEffect(() => { + const query = { ...router.query } + if (query.section) { + setNavigateToSection(query.section as string) + setActiveIndex(1) + } + }, [router.query]) + return (
<SEO title="Notifications" description="Manifold user notifications" /> - {privateUser && ( + {privateUser && router.isReady && ( <div> - <Tabs + <ControlledTabs currentPageForAnalytics={'notifications'} labelClassName={'pb-2 pt-1 '} className={'mb-0 sm:mb-2'} - defaultIndex={0} + activeIndex={activeIndex} + onClick={(title, i) => { + router.replace( + { + query: { + ...router.query, + tab: title.toLowerCase(), + section: '', + }, + }, + undefined, + { shallow: true } + ) + setActiveIndex(i) + }} tabs={[ { title: 'Notifications', @@ -82,9 +107,9 @@ export default function Notifications() { { title: 'Settings', content: ( - <div className={''}> - <NotificationSettings /> - </div> + <NotificationSettings + navigateToSection={navigateToSection} + /> ), }, ]} @@ -992,6 +1017,7 @@ function getReasonForShowingNotification( ) { const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string + // TODO: we could leave out this switch and just use the reason field now that they have more information switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') @@ -1003,7 +1029,7 @@ function getReasonForShowingNotification( else reasonText = justSummary ? `commented` : `commented on` break case 'contract': - if (reason === 'you_follow_user') + if (reason === 'contract_from_followed_user') reasonText = justSummary ? 'asked the question' : 'asked' else if (sourceUpdateType === 'resolved') reasonText = justSummary ? `resolved the question` : `resolved` @@ -1011,7 +1037,8 @@ function getReasonForShowingNotification( else reasonText = justSummary ? 'updated the question' : `updated` break case 'answer': - if (reason === 'on_users_contract') reasonText = `answered your question ` + if (reason === 'answer_on_your_contract') + reasonText = `answered your question ` else reasonText = `answered` break case 'follow': From 4f19220778642317e01f7bff34a99cca08b581b9 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 12 Sep 2022 11:56:15 -0500 Subject: [PATCH 05/32] Experimental home: accommodate old saved sections. --- web/components/arrange-home.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/components/arrange-home.tsx b/web/components/arrange-home.tsx index 25e814b8..646d30fe 100644 --- a/web/components/arrange-home.tsx +++ b/web/components/arrange-home.tsx @@ -7,7 +7,7 @@ import { Row } from 'web/components/layout/row' import { Subtitle } from 'web/components/subtitle' import { useMemberGroups } from 'web/hooks/use-group' import { filterDefined } from 'common/util/array' -import { keyBy } from 'lodash' +import { isArray, keyBy } from 'lodash' import { User } from 'common/user' import { Group } from 'common/group' @@ -107,6 +107,9 @@ const SectionItem = (props: { } export const getHomeItems = (groups: Group[], sections: string[]) => { + // Accommodate old home sections. + if (!isArray(sections)) sections = [] + const items = [ { label: 'Daily movers', id: 'daily-movers' }, { label: 'Trending', id: 'score' }, From 3cb36a36ec35e439f0985a702a59f4e301108cd8 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 11:00:24 -0600 Subject: [PATCH 06/32] Separate email and browser ids list --- functions/src/create-notification.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 356ad200..03bbe8b5 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -222,7 +222,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( repliedToId, } = miscData ?? {} - const recipientIdsList: string[] = [] + const browserRecipientIdsList: string[] = [] + const emailRecipientIdsList: string[] = [] const contractFollowersSnap = await firestore .collection(`contracts/${sourceContract.id}/follows`) @@ -271,8 +272,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( ) => { if ( !stillFollowingContract(sourceContract.creatorId) || - sourceUser.id == userId || - recipientIdsList.includes(userId) + sourceUser.id == userId ) return const privateUser = await getPrivateUser(userId) @@ -282,11 +282,11 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( reason ) - if (sendToBrowser) { + if (sendToBrowser && !browserRecipientIdsList.includes(userId)) { await createBrowserNotification(userId, reason) - recipientIdsList.push(userId) + browserRecipientIdsList.push(userId) } - if (sendToEmail) { + if (sendToEmail && !emailRecipientIdsList.includes(userId)) { if (sourceType === 'comment') { // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment await sendNewCommentEmail( @@ -327,7 +327,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( resolutionData.resolutionProbability, resolutionData.resolutions ) - recipientIdsList.push(userId) + emailRecipientIdsList.push(userId) } } From ff81b859d1015b0de4025e3c26eb31e0c854a29b Mon Sep 17 00:00:00 2001 From: FRC <pico2x@gmail.com> Date: Mon, 12 Sep 2022 20:54:11 +0100 Subject: [PATCH 07/32] "Fix "500 internal error" in large groups (#872) * Fix "500 internal error" in large groups (#856) This reverts commit 28f0c6b1f8f14bd210d02b6032967822d1779b3b. * Ship without touching prod and with some logs. --- common/group.ts | 11 ++ functions/src/update-metrics.ts | 79 +++++++- web/lib/firebase/groups.ts | 5 +- web/pages/group/[...slugs]/index.tsx | 263 +++++++-------------------- 4 files changed, 148 insertions(+), 210 deletions(-) diff --git a/common/group.ts b/common/group.ts index 19f3b7b8..871bc821 100644 --- a/common/group.ts +++ b/common/group.ts @@ -12,7 +12,18 @@ export type Group = { aboutPostId?: string chatDisabled?: boolean mostRecentContractAddedTime?: number + cachedLeaderboard?: { + topTraders: { + userId: string + score: number + }[] + topCreators: { + userId: string + score: number + }[] + } } + export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 430f3d33..4352e872 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -4,9 +4,11 @@ import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash' import { getValues, log, logMemory, writeAsync } from './utils' import { Bet } from '../../common/bet' import { Contract, CPMM } from '../../common/contract' + import { PortfolioMetrics, User } from '../../common/user' import { DAY_MS } from '../../common/util/time' import { getLoanUpdates } from '../../common/loans' +import { scoreTraders, scoreCreators } from '../../common/scoring' import { calculateCreatorVolume, calculateNewPortfolioMetrics, @@ -15,6 +17,7 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' +import { Group } from 'common/group' const firestore = admin.firestore() @@ -24,16 +27,29 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ - getValues<User>(firestore.collection('users')), - getValues<Contract>(firestore.collection('contracts')), - getValues<Bet>(firestore.collectionGroup('bets')), - getValues<PortfolioMetrics>( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - ]) + const [users, contracts, bets, allPortfolioHistories, groups] = + await Promise.all([ + getValues<User>(firestore.collection('users')), + getValues<Contract>(firestore.collection('contracts')), + getValues<Bet>(firestore.collectionGroup('bets')), + getValues<PortfolioMetrics>( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ), + getValues<Group>(firestore.collection('groups')), + ]) + + const contractsByGroup = await Promise.all( + groups.map((group) => { + return getValues( + firestore + .collection('groups') + .doc(group.id) + .collection('groupContracts') + ) + }) + ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` ) @@ -41,6 +57,7 @@ export async function updateMetricsCore() { const now = Date.now() const betsByContract = groupBy(bets, (bet) => bet.contractId) + const contractUpdates = contracts .filter((contract) => contract.id) .map((contract) => { @@ -162,4 +179,46 @@ export async function updateMetricsCore() { 'set' ) log(`Updated metrics for ${users.length} users.`) + + try { + const groupUpdates = groups.map((group, index) => { + const groupContractIds = contractsByGroup[index] as GroupContractDoc[] + const groupContracts = groupContractIds.map( + (e) => contractsById[e.contractId] + ) + const bets = groupContracts.map((e) => { + return betsByContract[e.id] ?? [] + }) + + const creatorScores = scoreCreators(groupContracts) + const traderScores = scoreTraders(groupContracts, bets) + + const topTraderScores = topUserScores(traderScores) + const topCreatorScores = topUserScores(creatorScores) + + return { + doc: firestore.collection('groups').doc(group.id), + fields: { + cachedLeaderboard: { + topTraders: topTraderScores, + topCreators: topCreatorScores, + }, + }, + } + }) + // Shipping without this for now to check it's working as intended + console.log('Group Leaderboard Updates', groupUpdates) + //await writeAsync(firestore, groupUpdates) + } catch (e) { + console.log('Error While Updating Group Leaderboards', e) + } } + +const topUserScores = (scores: { [userId: string]: number }) => { + const top50 = Object.entries(scores) + .sort(([, scoreA], [, scoreB]) => scoreB - scoreA) + .slice(0, 50) + return top50.map(([userId, score]) => ({ userId, score })) +} + +type GroupContractDoc = { contractId: string; createdTime: number } diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index 7a372d9a..f27460d9 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -24,7 +24,6 @@ import { Contract } from 'common/contract' import { getContractFromId, updateContract } from 'web/lib/firebase/contracts' import { db } from 'web/lib/firebase/init' import { filterDefined } from 'common/util/array' -import { getUser } from 'web/lib/firebase/users' export const groups = coll<Group>('groups') export const groupMembers = (groupId: string) => @@ -253,7 +252,7 @@ export function getGroupLinkToDisplay(contract: Contract) { return groupToDisplay } -export async function listMembers(group: Group) { +export async function listMemberIds(group: Group) { const members = await getValues<GroupMemberDoc>(groupMembers(group.id)) - return await Promise.all(members.map((m) => m.userId).map(getUser)) + return members.map((m) => m.userId) } diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 768e2f82..f5d68e57 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -1,28 +1,28 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import { debounce, sortBy, take } from 'lodash' -import { SearchIcon } from '@heroicons/react/outline' import { toast } from 'react-hot-toast' import { Group, GROUP_CHAT_SLUG } from 'common/group' import { Page } from 'web/components/page' -import { listAllBets } from 'web/lib/firebase/bets' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { addContractToGroup, getGroupBySlug, groupPath, joinGroup, - listMembers, + listMemberIds, updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' -import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group' -import { scoreCreators, scoreTraders } from 'common/scoring' +import { + useGroup, + useGroupContractIds, + useMemberIds, +} from 'web/hooks/use-group' import { Leaderboard } from 'web/components/leaderboard' import { formatMoney } from 'common/util/format' import { EditGroupButton } from 'web/components/groups/edit-group-button' @@ -35,9 +35,7 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { Modal } from 'web/components/layout/modal' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' import { ContractSearch } from 'web/components/contract-search' -import { FollowList } from 'web/components/follow-list' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { searchInAny } from 'common/util/parse' import { CopyLinkButton } from 'web/components/copy-link-button' import { ENV_CONFIG } from 'common/envs/constants' import { useSaveReferral } from 'web/hooks/use-save-referral' @@ -59,7 +57,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const { slugs } = props.params const group = await getGroupBySlug(slugs[0]) - const members = group && (await listMembers(group)) + const memberIds = group && (await listMemberIds(group)) const creatorPromise = group ? getUser(group.creatorId) : null const contracts = @@ -71,19 +69,15 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { : 'open' const aboutPost = group && group.aboutPostId != null && (await getPost(group.aboutPostId)) - const bets = await Promise.all( - contracts.map((contract: Contract) => listAllBets(contract.id)) - ) const messages = group && (await listAllCommentsOnGroup(group.id)) - const creatorScores = scoreCreators(contracts) - const traderScores = scoreTraders(contracts, bets) - const [topCreators, topTraders] = - (members && [ - toTopUsers(creatorScores, members), - toTopUsers(traderScores, members), - ]) ?? - [] + const cachedTopTraderIds = + (group && group.cachedLeaderboard?.topTraders) ?? [] + const cachedTopCreatorIds = + (group && group.cachedLeaderboard?.topCreators) ?? [] + const topTraders = await toTopUsers(cachedTopTraderIds) + + const topCreators = await toTopUsers(cachedTopCreatorIds) const creator = await creatorPromise // Only count unresolved markets @@ -93,11 +87,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { props: { contractsCount, group, - members, + memberIds, creator, - traderScores, topTraders, - creatorScores, topCreators, messages, aboutPost, @@ -107,19 +99,6 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { revalidate: 60, // regenerate after a minute } } - -function toTopUsers(userScores: { [userId: string]: number }, users: User[]) { - const topUserPairs = take( - sortBy(Object.entries(userScores), ([_, score]) => -1 * score), - 10 - ).filter(([_, score]) => score >= 0.5) - - const topUsers = topUserPairs.map( - ([userId]) => users.filter((user) => user.id === userId)[0] - ) - return topUsers.filter((user) => user) -} - export async function getStaticPaths() { return { paths: [], fallback: 'blocking' } } @@ -134,12 +113,10 @@ const groupSubpages = [ export default function GroupPage(props: { contractsCount: number group: Group | null - members: User[] + memberIds: string[] creator: User - traderScores: { [userId: string]: number } - topTraders: User[] - creatorScores: { [userId: string]: number } - topCreators: User[] + topTraders: { user: User; score: number }[] + topCreators: { user: User; score: number }[] messages: GroupComment[] aboutPost: Post suggestedFilter: 'open' | 'all' @@ -147,24 +124,15 @@ export default function GroupPage(props: { props = usePropz(props, getStaticPropz) ?? { contractsCount: 0, group: null, - members: [], + memberIds: [], creator: null, - traderScores: {}, topTraders: [], - creatorScores: {}, topCreators: [], messages: [], suggestedFilter: 'open', } - const { - contractsCount, - creator, - traderScores, - topTraders, - creatorScores, - topCreators, - suggestedFilter, - } = props + const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = + props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -175,7 +143,7 @@ export default function GroupPage(props: { const user = useUser() const isAdmin = useAdmin() - const members = useMembers(group?.id) ?? props.members + const memberIds = useMemberIds(group?.id ?? null) ?? props.memberIds useSaveReferral(user, { defaultReferrerUsername: creator.username, @@ -186,18 +154,25 @@ export default function GroupPage(props: { return <Custom404 /> } const isCreator = user && group && user.id === group.creatorId - const isMember = user && members.map((m) => m.id).includes(user.id) + const isMember = user && memberIds.includes(user.id) + const maxLeaderboardSize = 50 const leaderboard = ( <Col> - <GroupLeaderboards - traderScores={traderScores} - creatorScores={creatorScores} - topTraders={topTraders} - topCreators={topCreators} - members={members} - user={user} - /> + <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> + <GroupLeaderboard + topUsers={topTraders} + title="🏅 Top traders" + header="Profit" + maxToShow={maxLeaderboardSize} + /> + <GroupLeaderboard + topUsers={topCreators} + title="🏅 Top creators" + header="Market volume" + maxToShow={maxLeaderboardSize} + /> + </div> </Col> ) @@ -216,7 +191,7 @@ export default function GroupPage(props: { creator={creator} isCreator={!!isCreator} user={user} - members={members} + memberIds={memberIds} /> </Col> ) @@ -312,9 +287,9 @@ function GroupOverview(props: { creator: User user: User | null | undefined isCreator: boolean - members: User[] + memberIds: string[] }) { - const { group, creator, isCreator, user, members } = props + const { group, creator, isCreator, user, memberIds } = props const anyoneCanJoinChoices: { [key: string]: string } = { Closed: 'false', Open: 'true', @@ -333,7 +308,7 @@ function GroupOverview(props: { const shareUrl = `https://${ENV_CONFIG.domain}${groupPath( group.slug )}${postFix}` - const isMember = user ? members.map((m) => m.id).includes(user.id) : false + const isMember = user ? memberIds.includes(user.id) : false return ( <> @@ -399,155 +374,37 @@ function GroupOverview(props: { /> </Col> )} - - <Col className={'mt-2'}> - <div className="mb-2 text-lg">Members</div> - <GroupMemberSearch members={members} group={group} /> - </Col> </Col> </> ) } -function SearchBar(props: { setQuery: (query: string) => void }) { - const { setQuery } = props - const debouncedQuery = debounce(setQuery, 50) - return ( - <div className={'relative'}> - <SearchIcon className={'absolute left-5 top-3.5 h-5 w-5 text-gray-500'} /> - <input - type="text" - onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Find a member" - className="input input-bordered mb-4 w-full pl-12" - /> - </div> - ) -} - -function GroupMemberSearch(props: { members: User[]; group: Group }) { - const [query, setQuery] = useState('') - const { group } = props - let { members } = props - - // Use static members on load, but also listen to member changes: - const listenToMembers = useMembers(group.id) - if (listenToMembers) { - members = listenToMembers - } - - // TODO use find-active-contracts to sort by? - const matches = sortBy(members, [(member) => member.name]).filter((m) => - searchInAny(query, m.name, m.username) - ) - const matchLimit = 25 - - return ( - <div> - <SearchBar setQuery={setQuery} /> - <Col className={'gap-2'}> - {matches.length > 0 && ( - <FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} /> - )} - {matches.length > 25 && ( - <div className={'text-center'}> - And {matches.length - matchLimit} more... - </div> - )} - </Col> - </div> - ) -} - -function SortedLeaderboard(props: { - users: User[] - scoreFunction: (user: User) => number +function GroupLeaderboard(props: { + topUsers: { user: User; score: number }[] title: string + maxToShow: number header: string - maxToShow?: number }) { - const { users, scoreFunction, title, header, maxToShow } = props - const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a)) + const { topUsers, title, maxToShow, header } = props + + const scoresByUser = topUsers.reduce((acc, { user, score }) => { + acc[user.id] = score + return acc + }, {} as { [key: string]: number }) + return ( <Leaderboard className="max-w-xl" - users={sortedUsers} + users={topUsers.map((t) => t.user)} title={title} columns={[ - { header, renderCell: (user) => formatMoney(scoreFunction(user)) }, + { header, renderCell: (user) => formatMoney(scoresByUser[user.id]) }, ]} maxToShow={maxToShow} /> ) } -function GroupLeaderboards(props: { - traderScores: { [userId: string]: number } - creatorScores: { [userId: string]: number } - topTraders: User[] - topCreators: User[] - members: User[] - user: User | null | undefined -}) { - const { traderScores, creatorScores, members, topTraders, topCreators } = - props - const maxToShow = 50 - // Consider hiding M$0 - // If it's just one member (curator), show all bettors, otherwise just show members - return ( - <Col> - <div className="mt-4 flex flex-col gap-8 px-4 md:flex-row"> - {members.length > 1 ? ( - <> - <SortedLeaderboard - users={members} - scoreFunction={(user) => traderScores[user.id] ?? 0} - title="🏅 Top traders" - header="Profit" - maxToShow={maxToShow} - /> - <SortedLeaderboard - users={members} - scoreFunction={(user) => creatorScores[user.id] ?? 0} - title="🏅 Top creators" - header="Market volume" - maxToShow={maxToShow} - /> - </> - ) : ( - <> - <Leaderboard - className="max-w-xl" - title="🏅 Top traders" - users={topTraders} - columns={[ - { - header: 'Profit', - renderCell: (user) => formatMoney(traderScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - <Leaderboard - className="max-w-xl" - title="🏅 Top creators" - users={topCreators} - columns={[ - { - header: 'Market volume', - renderCell: (user) => - formatMoney(creatorScores[user.id] ?? 0), - }, - ]} - maxToShow={maxToShow} - /> - </> - )} - </div> - </Col> - ) -} - function AddContractButton(props: { group: Group; user: User }) { const { group, user } = props const [open, setOpen] = useState(false) @@ -684,3 +541,15 @@ function JoinGroupButton(props: { </div> ) } + +const toTopUsers = async ( + cachedUserIds: { userId: string; score: number }[] +): Promise<{ user: User; score: number }[]> => + ( + await Promise.all( + cachedUserIds.map(async (e) => { + const user = await getUser(e.userId) + return { user, score: e.score ?? 0 } + }) + ) + ).filter((e) => e.user != null) From 7d9908dbd0dd46b73ffadfabaaec6ac415d20849 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 12 Sep 2022 20:58:12 +0100 Subject: [PATCH 08/32] Fix type error in update-metrics --- functions/src/update-metrics.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 4352e872..eb5f6fd8 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -187,7 +187,11 @@ export async function updateMetricsCore() { (e) => contractsById[e.contractId] ) const bets = groupContracts.map((e) => { - return betsByContract[e.id] ?? [] + if (e.id in betsByContract) { + return betsByContract[e.id] ?? [] + } else { + return [] + } }) const creatorScores = scoreCreators(groupContracts) From 86422f90eaf8c66a12cc93d2247ffca90ee7387f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:17:39 -0600 Subject: [PATCH 09/32] Set all overflow notifs to seen --- web/components/notification-settings.tsx | 7 +++---- web/pages/notifications.tsx | 21 ++++++++++++++++----- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 756adbfd..408cc245 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -54,21 +54,20 @@ export function NotificationSettings(props: { 'resolutions_on_watched_markets_with_shares_in', 'resolutions_on_watched_markets', - 'tagged_user', 'trending_markets', 'onboarding_flow', 'thank_you_for_purchases', // TODO: add these + 'tagged_user', // 'contract_from_followed_user', // 'referral_bonuses', // 'unique_bettors_on_your_contract', + // 'on_new_follow', + // 'profit_loss_updates', // 'tips_on_your_markets', // 'tips_on_your_comments', - // 'subsidized_your_market', - // 'on_new_follow', // maybe the following? - // 'profit_loss_updates', // 'probability_updates_on_watched_markets', // 'limit_order_fills', ] diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 57e13fb9..0c748ec7 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -26,6 +26,7 @@ import { import { NotificationGroup, useGroupedNotifications, + useUnseenGroupedNotification, } from 'web/hooks/use-notifications' import { TrendingUpIcon } from '@heroicons/react/outline' import { formatMoney } from 'common/util/format' @@ -153,16 +154,13 @@ function NotificationsList(props: { privateUser: PrivateUser }) { const { privateUser } = props const [page, setPage] = useState(0) const allGroupedNotifications = useGroupedNotifications(privateUser) + const unseenGroupedNotifications = useUnseenGroupedNotification(privateUser) 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', @@ -171,6 +169,19 @@ function NotificationsList(props: { privateUser: PrivateUser }) { return maxNotificationsToShow }, [allGroupedNotifications, page]) + // Set all notifications that don't fit on the first page to seen + useEffect(() => { + if ( + paginatedGroupedNotifications && + paginatedGroupedNotifications?.length >= NOTIFICATIONS_PER_PAGE + ) { + const allUnseenNotifications = unseenGroupedNotifications + ?.map((ng) => ng.notifications) + .flat() + allUnseenNotifications && setNotificationsAsSeen(allUnseenNotifications) + } + }, [paginatedGroupedNotifications, unseenGroupedNotifications]) + if (!paginatedGroupedNotifications || !allGroupedNotifications) return <LoadingIndicator /> From 4456a771fd624b3179d194f60a155ee85d3603c2 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 12 Sep 2022 21:25:45 +0100 Subject: [PATCH 10/32] fix type error in update-metrics pt.2 --- functions/src/update-metrics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index eb5f6fd8..bb037e9c 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -187,7 +187,7 @@ export async function updateMetricsCore() { (e) => contractsById[e.contractId] ) const bets = groupContracts.map((e) => { - if (e.id in betsByContract) { + if (e != null && e.id in betsByContract) { return betsByContract[e.id] ?? [] } else { return [] From 0af1ff112b9b8be8759308b38eea65627550ed00 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:30:15 -0600 Subject: [PATCH 11/32] Allow users to see 0% FR answers via show more button --- web/components/answers/answers-panel.tsx | 21 +++++++++++++++---- .../answers/create-answer-panel.tsx | 4 ++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 5811403f..e43305bb 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -23,6 +23,7 @@ import { Avatar } from 'web/components/avatar' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' import { UserLink } from 'web/components/user-link' +import { Button } from 'web/components/button' export function AnswersPanel(props: { contract: FreeResponseContract | MultipleChoiceContract @@ -30,13 +31,14 @@ export function AnswersPanel(props: { const { contract } = props const { creatorId, resolution, resolutions, totalBets, outcomeType } = contract + const [showAllAnswers, setShowAllAnswers] = useState(false) const answers = useAnswers(contract.id) ?? contract.answers const [winningAnswers, losingAnswers] = partition( - answers.filter( - (answer) => - (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && - totalBets[answer.id] > 0.000000001 + answers.filter((answer) => + (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && showAllAnswers + ? true + : totalBets[answer.id] > 0 ), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) @@ -127,6 +129,17 @@ export function AnswersPanel(props: { </div> </div> ))} + <Row className={'justify-end'}> + {!showAllAnswers && ( + <Button + color={'gray-white'} + onClick={() => setShowAllAnswers(true)} + size={'md'} + > + Show More + </Button> + )} + </Row> </div> </div> )} diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 7e20e92e..58f55327 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -76,7 +76,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { if (existingAnswer) { setAnswerError( existingAnswer - ? `"${existingAnswer.text}" already exists as an answer` + ? `"${existingAnswer.text}" already exists as an answer. Can't see it? Hit the 'Show More' button right above this box.` : '' ) return @@ -237,7 +237,7 @@ const AnswerError = (props: { text: string; level: answerErrorLevel }) => { }[level] ?? '' return ( <div - className={`${colorClass} mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide`} + className={`${colorClass} mb-2 mr-auto self-center text-xs font-medium tracking-wide`} > {text} </div> From e35c0b3b526d029d571e6172c0ef28405fa9be25 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:36:54 -0600 Subject: [PATCH 12/32] Only notify followers of new public markets --- functions/src/create-notification.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 03bbe8b5..c9f3ff8f 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -162,8 +162,9 @@ export const createNotification = async ( sourceUpdateType === 'created' && sourceContract ) { - await notifyUsersFollowers(userToReasonTexts) - notifyTaggedUsers(userToReasonTexts, recipients ?? []) + if (sourceContract.visibility === 'public') + await notifyUsersFollowers(userToReasonTexts) + await notifyTaggedUsers(userToReasonTexts, recipients ?? []) return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && From 3a814a5b5dd8ea6263883e621050b5a62d77a02f Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:41:30 -0600 Subject: [PATCH 13/32] Detect just settings tab w/o section --- web/pages/notifications.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 0c748ec7..a4c25ed3 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -67,9 +67,11 @@ export default function Notifications() { useEffect(() => { const query = { ...router.query } + if (query.tab === 'settings') { + setActiveIndex(1) + } if (query.section) { setNavigateToSection(query.section as string) - setActiveIndex(1) } }, [router.query]) From 747d5d7c7c875ab13203d8652694b13c28c1b129 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:48:16 -0600 Subject: [PATCH 14/32] In app => website --- web/components/notification-settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 408cc245..7aeef6ed 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -220,14 +220,14 @@ export function NotificationSettings(props: { <SwitchSetting checked={inAppEnabled} onChange={setInAppEnabled} - label={'In App'} + label={'Website'} /> )} {emailsEnabled.includes(key) && ( <SwitchSetting checked={emailEnabled} onChange={setEmailEnabled} - label={'Emails'} + label={'Email'} /> )} </Row> From 5c6fe08bdbbf9d369ffe4dcbfacd3dbeb9b590da Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 14:48:42 -0600 Subject: [PATCH 15/32] Website => Web --- web/components/notification-settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index 7aeef6ed..ac2b9ab0 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -220,7 +220,7 @@ export function NotificationSettings(props: { <SwitchSetting checked={inAppEnabled} onChange={setInAppEnabled} - label={'Website'} + label={'Web'} /> )} {emailsEnabled.includes(key) && ( From 2a96ee98f4e3abc050ad8963e82ab8a2e647890f Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 12 Sep 2022 21:49:15 +0100 Subject: [PATCH 16/32] Fix type error in update metrics pt.3 --- functions/src/update-metrics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index bb037e9c..99c8df96 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -183,9 +183,9 @@ export async function updateMetricsCore() { try { const groupUpdates = groups.map((group, index) => { const groupContractIds = contractsByGroup[index] as GroupContractDoc[] - const groupContracts = groupContractIds.map( - (e) => contractsById[e.contractId] - ) + const groupContracts = groupContractIds + .map((e) => contractsById[e.contractId]) + .filter((e) => e !== undefined) as Contract[] const bets = groupContracts.map((e) => { if (e != null && e.id in betsByContract) { return betsByContract[e.id] ?? [] From a3da8a7c3c217da252df1daef3b2baa18a6ba5b2 Mon Sep 17 00:00:00 2001 From: Pico2x <pico2x@gmail.com> Date: Mon, 12 Sep 2022 22:01:37 +0100 Subject: [PATCH 17/32] Make update-metrics actually write cached group leaderboards --- functions/src/update-metrics.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index 99c8df96..1de8056c 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -210,9 +210,7 @@ export async function updateMetricsCore() { }, } }) - // Shipping without this for now to check it's working as intended - console.log('Group Leaderboard Updates', groupUpdates) - //await writeAsync(firestore, groupUpdates) + await writeAsync(firestore, groupUpdates) } catch (e) { console.log('Error While Updating Group Leaderboards', e) } From 3d3caa7a428901d8876064952b4745fc82096fc3 Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 12 Sep 2022 16:50:31 -0500 Subject: [PATCH 18/32] remove comment bet area --- web/components/feed/feed-comments.tsx | 78 +++------------------------ 1 file changed, 8 insertions(+), 70 deletions(-) diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index a3e9f35a..f896ddb5 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -14,9 +14,7 @@ import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { firebaseLogin } from 'web/lib/firebase/users' import { createCommentOnContract } from 'web/lib/firebase/comments' -import { BetStatusText } from 'web/components/feed/feed-bets' import { Col } from 'web/components/layout/col' -import { getProbability } from 'common/calculate' import { track } from 'web/lib/service/analytics' import { Tipper } from '../tipper' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' @@ -301,74 +299,14 @@ export function ContractCommentInput(props: { const { id } = mostRecentCommentableBet || { id: undefined } return ( - <Col> - <CommentBetArea - betsByCurrentUser={props.betsByCurrentUser} - contract={props.contract} - commentsByCurrentUser={props.commentsByCurrentUser} - parentAnswerOutcome={props.parentAnswerOutcome} - user={useUser()} - className={props.className} - mostRecentCommentableBet={mostRecentCommentableBet} - /> - <CommentInput - replyToUser={props.replyToUser} - parentAnswerOutcome={props.parentAnswerOutcome} - parentCommentId={props.parentCommentId} - onSubmitComment={onSubmitComment} - className={props.className} - presetId={id} - /> - </Col> - ) -} - -function CommentBetArea(props: { - betsByCurrentUser: Bet[] - contract: Contract - commentsByCurrentUser: ContractComment[] - parentAnswerOutcome?: string - user?: User | null - className?: string - mostRecentCommentableBet?: Bet -}) { - const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props - - const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( - contract, - Date.now(), - betsByCurrentUser - ) - - const isNumeric = contract.outcomeType === 'NUMERIC' - - return ( - <Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}> - <div className="mb-1 text-gray-500"> - {mostRecentCommentableBet && ( - <BetStatusText - contract={contract} - bet={mostRecentCommentableBet} - isSelf={true} - hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'} - /> - )} - {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( - <> - {"You're"} - <CommentStatus - outcome={outcome} - contract={contract} - prob={ - contract.outcomeType === 'BINARY' - ? getProbability(contract) - : undefined - } - /> - </> - )} - </div> - </Row> + <CommentInput + replyToUser={props.replyToUser} + parentAnswerOutcome={props.parentAnswerOutcome} + parentCommentId={props.parentCommentId} + onSubmitComment={onSubmitComment} + className={props.className} + presetId={id} + /> ) } From 0e5b1a77421a5f0c406e4b164dbf91a65163843b Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 12 Sep 2022 17:30:51 -0500 Subject: [PATCH 19/32] market intro panel --- web/components/bet-panel.tsx | 6 ++---- web/components/market-intro-panel.tsx | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 web/components/market-intro-panel.tsx diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 78934389..00db5cb4 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -42,6 +42,7 @@ import { YesNoSelector } from './yes-no-selector' import { PlayMoneyDisclaimer } from './play-money-disclaimer' import { isAndroid, isIOS } from 'web/lib/util/device' import { WarningConfirmationButton } from './warning-confirmation-button' +import { MarketIntroPanel } from './market-intro-panel' export function BetPanel(props: { contract: CPMMBinaryContract | PseudoNumericContract @@ -90,10 +91,7 @@ export function BetPanel(props: { /> </> ) : ( - <> - <BetSignUpPrompt /> - <PlayMoneyDisclaimer /> - </> + <MarketIntroPanel /> )} </Col> diff --git a/web/components/market-intro-panel.tsx b/web/components/market-intro-panel.tsx new file mode 100644 index 00000000..a4ea698e --- /dev/null +++ b/web/components/market-intro-panel.tsx @@ -0,0 +1,26 @@ +import Image from 'next/future/image' + +import { Col } from './layout/col' +import { BetSignUpPrompt } from './sign-up-prompt' + +export function MarketIntroPanel() { + return ( + <Col> + <div className="text-xl">Play-money predictions</div> + + <Image + height={150} + width={150} + className="self-center" + src="/flappy-logo.gif" + /> + + <div className="text-sm mb-4"> + Manifold Markets is a play-money prediction market platform where you can + forecast anything. + </div> + + <BetSignUpPrompt /> + </Col> + ) +} From 8e41b399360d3c3cda95062be10fff58ee197c6c Mon Sep 17 00:00:00 2001 From: mantikoros <sgrugett@gmail.com> Date: Mon, 12 Sep 2022 17:34:13 -0500 Subject: [PATCH 20/32] landing page: use next image for logo --- web/components/landing-page-panel.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/components/landing-page-panel.tsx b/web/components/landing-page-panel.tsx index a5b46b08..f0dae17d 100644 --- a/web/components/landing-page-panel.tsx +++ b/web/components/landing-page-panel.tsx @@ -1,3 +1,4 @@ +import Image from 'next/future/image' import { SparklesIcon } from '@heroicons/react/solid' import { Contract } from 'common/contract' @@ -18,7 +19,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) { return ( <> <Col className="mb-6 rounded-xl sm:m-12 sm:mt-0"> - <img + <Image height={250} width={250} className="self-center" From d66a81bc6b330ad0ed00c38a30c9a8eeaba66bab Mon Sep 17 00:00:00 2001 From: mantikoros <mantikoros@users.noreply.github.com> Date: Mon, 12 Sep 2022 22:35:32 +0000 Subject: [PATCH 21/32] Auto-prettification --- web/components/market-intro-panel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/components/market-intro-panel.tsx b/web/components/market-intro-panel.tsx index a4ea698e..ef4d28a2 100644 --- a/web/components/market-intro-panel.tsx +++ b/web/components/market-intro-panel.tsx @@ -15,9 +15,9 @@ export function MarketIntroPanel() { src="/flappy-logo.gif" /> - <div className="text-sm mb-4"> - Manifold Markets is a play-money prediction market platform where you can - forecast anything. + <div className="mb-4 text-sm"> + Manifold Markets is a play-money prediction market platform where you + can forecast anything. </div> <BetSignUpPrompt /> From f49cb9b399d361ddc08fe7596b112626672a1ecc Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 12 Sep 2022 17:40:17 -0500 Subject: [PATCH 22/32] Only show 'Show more' for free response answers if there are more answers to show --- web/components/answers/answers-panel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index e43305bb..7ab5e804 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -34,6 +34,8 @@ export function AnswersPanel(props: { const [showAllAnswers, setShowAllAnswers] = useState(false) const answers = useAnswers(contract.id) ?? contract.answers + const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] === 0) + const [winningAnswers, losingAnswers] = partition( answers.filter((answer) => (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && showAllAnswers @@ -130,7 +132,7 @@ export function AnswersPanel(props: { </div> ))} <Row className={'justify-end'}> - {!showAllAnswers && ( + {hasZeroBetAnswers && !showAllAnswers && ( <Button color={'gray-white'} onClick={() => setShowAllAnswers(true)} From 018eb8fbfcec2b0f2c34cf6133e061fd61d8ae3a Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 17:01:59 -0600 Subject: [PATCH 23/32] Send notif to all users in reply chain as reply --- functions/src/create-notification.ts | 47 ++++++++++--------- .../src/on-create-comment-on-contract.ts | 42 ++++++++++++++--- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index c9f3ff8f..2815655f 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -188,6 +188,15 @@ export const createNotification = async ( } } +export type replied_users_info = { + [key: string]: { + repliedToType: 'comment' | 'answer' + repliedToAnswerText: string | undefined + repliedToId: string | undefined + bet: Bet | undefined + } +} + export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceId: string, sourceType: 'comment' | 'answer' | 'contract', @@ -197,11 +206,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceText: string, sourceContract: Contract, miscData?: { - repliedToType?: 'comment' | 'answer' - repliedToId?: string - repliedToContent?: string - repliedUserId?: string - taggedUserIds?: string[] + repliedUsersInfo: replied_users_info + taggedUserIds: string[] }, resolutionData?: { bets: Bet[] @@ -215,13 +221,7 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( resolutions?: { [outcome: string]: number } } ) => { - const { - repliedToType, - repliedToContent, - repliedUserId, - taggedUserIds, - repliedToId, - } = miscData ?? {} + const { repliedUsersInfo, taggedUserIds } = miscData ?? {} const browserRecipientIdsList: string[] = [] const emailRecipientIdsList: string[] = [] @@ -289,6 +289,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( } if (sendToEmail && !emailRecipientIdsList.includes(userId)) { if (sourceType === 'comment') { + const { repliedToType, repliedToAnswerText, repliedToId, bet } = + repliedUsersInfo?.[userId] ?? {} // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment await sendNewCommentEmail( reason, @@ -297,9 +299,8 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( sourceContract, sourceText, sourceId, - // TODO: Add any paired bets to the comment - undefined, - repliedToType === 'answer' ? repliedToContent : undefined, + bet, + repliedToAnswerText, repliedToType === 'answer' ? repliedToId : undefined ) } else if (sourceType === 'answer') @@ -437,12 +438,16 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( } const notifyRepliedUser = async () => { - if (sourceType === 'comment' && repliedUserId && repliedToType) - await sendNotificationsIfSettingsPermit( - repliedUserId, - repliedToType === 'answer' - ? 'reply_to_users_answer' - : 'reply_to_users_comment' + if (sourceType === 'comment' && repliedUsersInfo) + await Promise.all( + Object.keys(repliedUsersInfo).map((userId) => + sendNotificationsIfSettingsPermit( + userId, + repliedUsersInfo[userId].repliedToType === 'answer' + ? 'reply_to_users_answer' + : 'reply_to_users_comment' + ) + ) ) } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index a46420bc..65e32dca 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -5,7 +5,10 @@ import { getContract, getUser, getValues } from './utils' import { ContractComment } from '../../common/comment' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { + createCommentOrAnswerOrUpdatedContractNotification, + replied_users_info, +} from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' import { addUserToContractFollowers } from './follow-market' @@ -83,6 +86,36 @@ export const onCreateCommentOnContract = functions ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId + const mentionedUsers = compact(parseMentions(comment.content)) + const repliedUsers: replied_users_info = {} + + // The parent of the reply chain could be a comment or an answer + if (repliedUserId && repliedToType) + repliedUsers[repliedUserId] = { + repliedToType, + repliedToAnswerText: answer ? answer.text : undefined, + repliedToId: comment.replyToCommentId || answer?.id, + bet: bet, + } + + const commentsInSameReplyChain = comments.filter((c) => + repliedToType === 'answer' + ? c.answerOutcome === answer?.id + : repliedToType === 'comment' + ? c.replyToCommentId === comment.replyToCommentId + : false + ) + // The rest of the children in the chain are always comments + commentsInSameReplyChain.forEach((c) => { + if (c.userId !== comment.userId && c.userId !== repliedUserId) { + repliedUsers[c.userId] = { + repliedToType: 'comment', + repliedToAnswerText: undefined, + repliedToId: c.id, + bet: undefined, + } + } + }) await createCommentOrAnswerOrUpdatedContractNotification( comment.id, 'comment', @@ -92,11 +125,8 @@ export const onCreateCommentOnContract = functions richTextToString(comment.content), contract, { - repliedToType, - repliedToId: comment.replyToCommentId || answer?.id, - repliedToContent: answer ? answer.text : undefined, - repliedUserId, - taggedUserIds: compact(parseMentions(comment.content)), + repliedUsersInfo: repliedUsers, + taggedUserIds: mentionedUsers, } ) }) From 235140367465b028a063c04a3cb66dedb20dee5c Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Mon, 12 Sep 2022 17:04:06 -0600 Subject: [PATCH 24/32] Replies to answers are comments --- web/components/notification-settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index ac2b9ab0..c319bf32 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -92,6 +92,8 @@ export function NotificationSettings(props: { all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, all_replies_to_my_comments_on_watched_markets: 'Only replies to your comments', + all_replies_to_my_answers_on_watched_markets: + 'Only replies to your answers', // comments_by_followed_users_on_watched_markets: 'By followed users', }, } @@ -101,8 +103,6 @@ export function NotificationSettings(props: { subscriptionTypeToDescription: { all_answers_on_watched_markets: 'All new answers', all_answers_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, - all_replies_to_my_answers_on_watched_markets: - 'Only replies to your answers', // answers_by_followed_users_on_watched_markets: 'By followed users', // answers_by_market_creator_on_watched_markets: 'By market creator', }, From 22d224895164906c46950cd750bc8db9ab8efd53 Mon Sep 17 00:00:00 2001 From: Sinclair Chen <abc.sinclair@gmail.com> Date: Mon, 12 Sep 2022 16:10:32 -0700 Subject: [PATCH 25/32] Add floating menu (bold, italic, link) (#867) * Add floating menu (bold, italic, link) * Sanitize and href-ify user input --- common/util/parse.ts | 7 ++++ web/components/editor.tsx | 68 +++++++++++++++++++++++++++++++++++ web/lib/icons/bold-icon.tsx | 20 +++++++++++ web/lib/icons/italic-icon.tsx | 21 +++++++++++ web/lib/icons/link-icon.tsx | 20 +++++++++++ 5 files changed, 136 insertions(+) create mode 100644 web/lib/icons/bold-icon.tsx create mode 100644 web/lib/icons/italic-icon.tsx create mode 100644 web/lib/icons/link-icon.tsx diff --git a/common/util/parse.ts b/common/util/parse.ts index 4fac3225..0bbd5cd9 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -23,8 +23,15 @@ import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' +import { find } from 'linkifyjs' import { uniq } from 'lodash' +/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ +export function getUrl(text: string) { + const results = find(text, 'url') + return results.length ? results[0].href : null +} + export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi const matches = (text.match(regex) || []).map((match) => diff --git a/web/components/editor.tsx b/web/components/editor.tsx index bb947579..745fc3c5 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -2,6 +2,7 @@ import CharacterCount from '@tiptap/extension-character-count' import Placeholder from '@tiptap/extension-placeholder' import { useEditor, + BubbleMenu, EditorContent, JSONContent, Content, @@ -24,13 +25,19 @@ import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' import { + CheckIcon, CodeIcon, PhotographIcon, PresentationChartLineIcon, + TrashIcon, } from '@heroicons/react/solid' import { MarketModal } from './editor/market-modal' import { insertContent } from './editor/utils' import { Tooltip } from './tooltip' +import BoldIcon from 'web/lib/icons/bold-icon' +import ItalicIcon from 'web/lib/icons/italic-icon' +import LinkIcon from 'web/lib/icons/link-icon' +import { getUrl } from 'common/util/parse' const DisplayImage = Image.configure({ HTMLAttributes: { @@ -141,6 +148,66 @@ function isValidIframe(text: string) { return /^<iframe.*<\/iframe>$/.test(text) } +function FloatingMenu(props: { editor: Editor | null }) { + const { editor } = props + + const [url, setUrl] = useState<string | null>(null) + + if (!editor) return null + + // current selection + const isBold = editor.isActive('bold') + const isItalic = editor.isActive('italic') + const isLink = editor.isActive('link') + + const setLink = () => { + const href = url && getUrl(url) + if (href) { + editor.chain().focus().extendMarkRange('link').setLink({ href }).run() + } + } + + const unsetLink = () => editor.chain().focus().unsetLink().run() + + return ( + <BubbleMenu + editor={editor} + className="flex gap-2 rounded-sm bg-slate-700 p-1 text-white" + > + {url === null ? ( + <> + <button onClick={() => editor.chain().focus().toggleBold().run()}> + <BoldIcon className={clsx('h-5', isBold && 'text-indigo-200')} /> + </button> + <button onClick={() => editor.chain().focus().toggleItalic().run()}> + <ItalicIcon + className={clsx('h-5', isItalic && 'text-indigo-200')} + /> + </button> + <button onClick={() => (isLink ? unsetLink() : setUrl(''))}> + <LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} /> + </button> + </> + ) : ( + <> + <input + type="text" + className="h-5 border-0 bg-inherit text-sm !shadow-none !ring-0" + placeholder="Type or paste a link" + onChange={(e) => setUrl(e.target.value)} + /> + <button onClick={() => (setLink(), setUrl(null))}> + <CheckIcon className="h-5 w-5" /> + </button> + <button onClick={() => (unsetLink(), setUrl(null))}> + <TrashIcon className="h-5 w-5" /> + </button> + </> + )} + </BubbleMenu> + ) +} + export function TextEditor(props: { editor: Editor | null upload: ReturnType<typeof useUploadMutation> @@ -155,6 +222,7 @@ export function TextEditor(props: { {/* hide placeholder when focused */} <div className="relative w-full [&:focus-within_p.is-empty]:before:content-none"> <div className="rounded-lg border border-gray-300 bg-white shadow-sm focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"> + <FloatingMenu editor={editor} /> <EditorContent editor={editor} /> {/* Toolbar, with buttons for images and embeds */} <div className="flex h-9 items-center gap-5 pl-4 pr-1"> diff --git a/web/lib/icons/bold-icon.tsx b/web/lib/icons/bold-icon.tsx new file mode 100644 index 00000000..f4fec497 --- /dev/null +++ b/web/lib/icons/bold-icon.tsx @@ -0,0 +1,20 @@ +// from Feather: https://feathericons.com/ +export default function BoldIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + {...props} + > + <path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path> + <path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path> + </svg> + ) +} diff --git a/web/lib/icons/italic-icon.tsx b/web/lib/icons/italic-icon.tsx new file mode 100644 index 00000000..d412ed77 --- /dev/null +++ b/web/lib/icons/italic-icon.tsx @@ -0,0 +1,21 @@ +// from Feather: https://feathericons.com/ +export default function ItalicIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + {...props} + > + <line x1="19" y1="4" x2="10" y2="4"></line> + <line x1="14" y1="20" x2="5" y2="20"></line> + <line x1="15" y1="4" x2="9" y2="20"></line> + </svg> + ) +} diff --git a/web/lib/icons/link-icon.tsx b/web/lib/icons/link-icon.tsx new file mode 100644 index 00000000..6323344c --- /dev/null +++ b/web/lib/icons/link-icon.tsx @@ -0,0 +1,20 @@ +// from Feather: https://feathericons.com/ +export default function LinkIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + {...props} + > + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path> + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path> + </svg> + ) +} From cb143117e51767f0648deb7755665f62319391a8 Mon Sep 17 00:00:00 2001 From: Marshall Polaris <marshall@pol.rs> Date: Mon, 12 Sep 2022 16:11:03 -0700 Subject: [PATCH 26/32] Make `parse.richTextToString` more efficient (#848) --- common/package.json | 1 + common/util/parse.ts | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/common/package.json b/common/package.json index 52195398..c652cb69 100644 --- a/common/package.json +++ b/common/package.json @@ -13,6 +13,7 @@ "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/starter-kit": "2.0.0-beta.191", + "prosemirror-model": "1.18.1", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/util/parse.ts b/common/util/parse.ts index 0bbd5cd9..8efd88f6 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,5 +1,12 @@ import { MAX_TAG_LENGTH } from '../contract' -import { generateText, JSONContent } from '@tiptap/core' +import { + getText, + getTextSerializersFromSchema, + getSchema, + JSONContent, +} from '@tiptap/core' +import { Node as ProseMirrorNode } from 'prosemirror-model' + // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' import { Bold } from '@tiptap/extension-bold' @@ -97,7 +104,6 @@ export const exhibitExts = [ Paragraph, Strike, Text, - Image, Link, Mention, @@ -105,6 +111,15 @@ export const exhibitExts = [ TiptapTweet, ] +const exhibitSchema = getSchema(exhibitExts) + export function richTextToString(text?: JSONContent) { - return !text ? '' : generateText(text, exhibitExts) + if (!text) { + return '' + } + const contentNode = ProseMirrorNode.fromJSON(exhibitSchema, text) + return getText(contentNode, { + blockSeparator: '\n\n', + textSerializers: getTextSerializersFromSchema(exhibitSchema), + }) } From 483838c1b2b3a85769928f092114e46ff8d24457 Mon Sep 17 00:00:00 2001 From: James Grugett <jahooma@gmail.com> Date: Mon, 12 Sep 2022 19:06:37 -0500 Subject: [PATCH 27/32] Revert "Make `parse.richTextToString` more efficient (#848)" This reverts commit cb143117e51767f0648deb7755665f62319391a8. --- common/package.json | 1 - common/util/parse.ts | 21 +++------------------ 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/common/package.json b/common/package.json index c652cb69..52195398 100644 --- a/common/package.json +++ b/common/package.json @@ -13,7 +13,6 @@ "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/starter-kit": "2.0.0-beta.191", - "prosemirror-model": "1.18.1", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/util/parse.ts b/common/util/parse.ts index 8efd88f6..0bbd5cd9 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,12 +1,5 @@ import { MAX_TAG_LENGTH } from '../contract' -import { - getText, - getTextSerializersFromSchema, - getSchema, - JSONContent, -} from '@tiptap/core' -import { Node as ProseMirrorNode } from 'prosemirror-model' - +import { generateText, JSONContent } from '@tiptap/core' // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' import { Bold } from '@tiptap/extension-bold' @@ -104,6 +97,7 @@ export const exhibitExts = [ Paragraph, Strike, Text, + Image, Link, Mention, @@ -111,15 +105,6 @@ export const exhibitExts = [ TiptapTweet, ] -const exhibitSchema = getSchema(exhibitExts) - export function richTextToString(text?: JSONContent) { - if (!text) { - return '' - } - const contentNode = ProseMirrorNode.fromJSON(exhibitSchema, text) - return getText(contentNode, { - blockSeparator: '\n\n', - textSerializers: getTextSerializersFromSchema(exhibitSchema), - }) + return !text ? '' : generateText(text, exhibitExts) } From de8c27c97086ac9373fecdae00823f2d0dab124d Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 07:48:41 -0600 Subject: [PATCH 28/32] Filter None answer earlier --- web/components/answers/answers-panel.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 7ab5e804..444c5701 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -33,15 +33,13 @@ export function AnswersPanel(props: { contract const [showAllAnswers, setShowAllAnswers] = useState(false) - const answers = useAnswers(contract.id) ?? contract.answers - const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] === 0) + const answers = (useAnswers(contract.id) ?? contract.answers).filter( + (a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE' + ) + const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1) const [winningAnswers, losingAnswers] = partition( - answers.filter((answer) => - (answer.id !== '0' || outcomeType === 'MULTIPLE_CHOICE') && showAllAnswers - ? true - : totalBets[answer.id] > 0 - ), + answers.filter((a) => (showAllAnswers ? true : totalBets[a.id] > 0)), (answer) => answer.id === resolution || (resolutions && resolutions[answer.id]) ) From 8b1776fe3b8c7466456feea548d4850d548713b2 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 07:53:01 -0600 Subject: [PATCH 29/32] Remove contracts number badge from groups tab --- web/pages/group/[...slugs]/index.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index f5d68e57..f124e225 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -80,12 +80,9 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) { const topCreators = await toTopUsers(cachedTopCreatorIds) const creator = await creatorPromise - // Only count unresolved markets - const contractsCount = contracts.filter((c) => !c.isResolved).length return { props: { - contractsCount, group, memberIds, creator, @@ -111,7 +108,6 @@ const groupSubpages = [ ] as const export default function GroupPage(props: { - contractsCount: number group: Group | null memberIds: string[] creator: User @@ -122,7 +118,6 @@ export default function GroupPage(props: { suggestedFilter: 'open' | 'all' }) { props = usePropz(props, getStaticPropz) ?? { - contractsCount: 0, group: null, memberIds: [], creator: null, @@ -131,8 +126,7 @@ export default function GroupPage(props: { messages: [], suggestedFilter: 'open', } - const { contractsCount, creator, topTraders, topCreators, suggestedFilter } = - props + const { creator, topTraders, topCreators, suggestedFilter } = props const router = useRouter() const { slugs } = router.query as { slugs: string[] } @@ -208,7 +202,6 @@ export default function GroupPage(props: { const tabs = [ { - badge: `${contractsCount}`, title: 'Markets', content: questionsTab, href: groupPath(group.slug, 'markets'), From 55b895146b53ccf257698e9c97ca5cd565cfc3d5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 07:54:37 -0600 Subject: [PATCH 30/32] Find multiple choice resolution texts as well --- functions/src/resolve-market.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index 015ac72f..b867b609 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -166,7 +166,10 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { (bets) => getContractBetMetrics(contract, bets).invested ) let resolutionText = outcome ?? contract.question - if (contract.outcomeType === 'FREE_RESPONSE') { + if ( + contract.outcomeType === 'FREE_RESPONSE' || + contract.outcomeType === 'MULTIPLE_CHOICE' + ) { const answerText = contract.answers.find( (answer) => answer.id === outcome )?.text From 2c922cbae6cde42c82f114f84c32c1f5bc153da5 Mon Sep 17 00:00:00 2001 From: Ian Philips <iansphilips@gmail.com> Date: Tue, 13 Sep 2022 08:16:23 -0600 Subject: [PATCH 31/32] Send no-bet resolution emails to those without bets --- .../market-resolved-no-bets.html | 491 ++++++++++++++++++ functions/src/emails.ts | 9 +- 2 files changed, 495 insertions(+), 5 deletions(-) create mode 100644 functions/src/email-templates/market-resolved-no-bets.html diff --git a/functions/src/email-templates/market-resolved-no-bets.html b/functions/src/email-templates/market-resolved-no-bets.html new file mode 100644 index 00000000..ff5f541f --- /dev/null +++ b/functions/src/email-templates/market-resolved-no-bets.html @@ -0,0 +1,491 @@ +<!DOCTYPE html> +<html style=" + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + box-sizing: border-box; + font-size: 14px; + margin: 0; + "> + +<head> + <meta name="viewport" content="width=device-width" /> + <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> + <title>Market resolved + + + + + +
+ +
+ + + + + + +
+ + + +
+
+ + + +
+ +
+ + - - - -
- + - -
+ - - - + " target="_blank">click here to manage your notifications. +

+ + + + + +
-
+
-

- This e-mail has been sent to {{name}}, - +

+ This e-mail has been sent to {{name}}, + click here to unsubscribe. -

-
-
+
+
+
- - -
-
- - - - +
+ + +
+
+ + + + + \ No newline at end of file diff --git a/functions/src/email-templates/thank-you.html b/functions/src/email-templates/thank-you.html index c4ad7baa..7ac72d0a 100644 --- a/functions/src/email-templates/thank-you.html +++ b/functions/src/email-templates/thank-you.html @@ -214,10 +214,12 @@

This e-mail has been sent - to {{name}}, click here to - unsubscribe.

+ to {{name}}, + click here to manage your notifications. +

+ + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ + Manifold Markets + +
+ {{creatorName}} asked +
+ + {{question}} +
+

+ Resolved {{outcome}} +

+
+ + + + + + + +
+ Dear {{name}}, +
+
+ A market you were following has been resolved! +
+
+ Thanks, +
+ Manifold Team +
+
+
+ +
+
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index b9d34363..d1387ef9 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -56,10 +56,9 @@ export const sendMarketResolutionEmail = async ( ? ` (plus ${formatMoney(creatorPayout)} in commissions)` : '' - const displayedInvestment = - Number.isNaN(investment) || investment < 0 - ? formatMoney(0) - : formatMoney(investment) + const correctedInvestment = + Number.isNaN(investment) || investment < 0 ? 0 : investment + const displayedInvestment = formatMoney(correctedInvestment) const displayedPayout = formatMoney(payout) @@ -81,7 +80,7 @@ export const sendMarketResolutionEmail = async ( return await sendTemplateEmail( privateUser.email, subject, - 'market-resolved', + correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved', templateData ) } From 4398fa9bda37ec21d81023732c028d353b72c703 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 13 Sep 2022 09:54:51 -0600 Subject: [PATCH 32/32] Add new market from followed user email notification --- functions/src/create-notification.ts | 211 ++++++----- .../new-market-from-followed-user.html | 354 ++++++++++++++++++ functions/src/emails.ts | 34 ++ functions/src/on-create-contract.ts | 10 +- web/components/notification-settings.tsx | 5 +- web/pages/notifications.tsx | 95 ++--- 6 files changed, 567 insertions(+), 142 deletions(-) create mode 100644 functions/src/email-templates/new-market-from-followed-user.html diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 2815655f..84edf715 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -22,7 +22,9 @@ import { sendMarketResolutionEmail, sendNewAnswerEmail, sendNewCommentEmail, + sendNewFollowedMarketEmail, } from './emails' +import { filterDefined } from '../../common/util/array' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -103,51 +105,14 @@ export const createNotification = async ( privateUser, sourceContract ) - } else if (reason === 'tagged_user') { - // TODO: send email to tagged user in new contract } else if (reason === 'subsidized_your_market') { // TODO: send email to creator of market that was subsidized - } else if (reason === 'contract_from_followed_user') { - // TODO: send email to follower of user who created market } else if (reason === 'on_new_follow') { // TODO: send email to user who was followed } } } - const notifyUsersFollowers = async ( - userToReasonTexts: recipients_to_reason_texts - ) => { - const followers = await firestore - .collectionGroup('follows') - .where('userId', '==', sourceUser.id) - .get() - - followers.docs.forEach((doc) => { - const followerUserId = doc.ref.parent.parent?.id - if ( - followerUserId && - shouldReceiveNotification(followerUserId, userToReasonTexts) - ) { - userToReasonTexts[followerUserId] = { - reason: 'contract_from_followed_user', - } - } - }) - } - - const notifyTaggedUsers = ( - userToReasonTexts: recipients_to_reason_texts, - userIds: (string | undefined)[] - ) => { - userIds.forEach((id) => { - if (id && shouldReceiveNotification(id, userToReasonTexts)) - userToReasonTexts[id] = { - reason: 'tagged_user', - } - }) - } - // The following functions modify the userToReasonTexts object in place. const userToReasonTexts: recipients_to_reason_texts = {} @@ -157,15 +122,6 @@ export const createNotification = async ( reason: 'on_new_follow', } return await sendNotificationsIfSettingsPermit(userToReasonTexts) - } else if ( - sourceType === 'contract' && - sourceUpdateType === 'created' && - sourceContract - ) { - if (sourceContract.visibility === 'public') - await notifyUsersFollowers(userToReasonTexts) - await notifyTaggedUsers(userToReasonTexts, recipients ?? []) - return await sendNotificationsIfSettingsPermit(userToReasonTexts) } else if ( sourceType === 'contract' && sourceUpdateType === 'closed' && @@ -283,52 +239,57 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async ( reason ) + // Browser notifications if (sendToBrowser && !browserRecipientIdsList.includes(userId)) { await createBrowserNotification(userId, reason) browserRecipientIdsList.push(userId) } - if (sendToEmail && !emailRecipientIdsList.includes(userId)) { - if (sourceType === 'comment') { - const { repliedToType, repliedToAnswerText, repliedToId, bet } = - repliedUsersInfo?.[userId] ?? {} - // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment - await sendNewCommentEmail( - reason, - privateUser, - sourceUser, - sourceContract, - sourceText, - sourceId, - bet, - repliedToAnswerText, - repliedToType === 'answer' ? repliedToId : undefined - ) - } else if (sourceType === 'answer') - await sendNewAnswerEmail( - reason, - privateUser, - sourceUser.name, - sourceText, - sourceContract, - sourceUser.avatarUrl - ) - else if ( - sourceType === 'contract' && - sourceUpdateType === 'resolved' && - resolutionData + + // Emails notifications + if (!sendToEmail || emailRecipientIdsList.includes(userId)) return + if (sourceType === 'comment') { + const { repliedToType, repliedToAnswerText, repliedToId, bet } = + repliedUsersInfo?.[userId] ?? {} + // TODO: change subject of email title to be more specific, i.e.: replied to you on/tagged you on/comment + await sendNewCommentEmail( + reason, + privateUser, + sourceUser, + sourceContract, + sourceText, + sourceId, + bet, + repliedToAnswerText, + repliedToType === 'answer' ? repliedToId : undefined + ) + emailRecipientIdsList.push(userId) + } else if (sourceType === 'answer') { + await sendNewAnswerEmail( + reason, + privateUser, + sourceUser.name, + sourceText, + sourceContract, + sourceUser.avatarUrl + ) + emailRecipientIdsList.push(userId) + } else if ( + sourceType === 'contract' && + sourceUpdateType === 'resolved' && + resolutionData + ) { + await sendMarketResolutionEmail( + reason, + privateUser, + resolutionData.userInvestments[userId] ?? 0, + resolutionData.userPayouts[userId] ?? 0, + sourceUser, + resolutionData.creatorPayout, + sourceContract, + resolutionData.outcome, + resolutionData.resolutionProbability, + resolutionData.resolutions ) - await sendMarketResolutionEmail( - reason, - privateUser, - resolutionData.userInvestments[userId] ?? 0, - resolutionData.userPayouts[userId] ?? 0, - sourceUser, - resolutionData.creatorPayout, - sourceContract, - resolutionData.outcome, - resolutionData.resolutionProbability, - resolutionData.resolutions - ) emailRecipientIdsList.push(userId) } } @@ -852,3 +813,79 @@ export const createUniqueBettorBonusNotification = async ( // TODO send email notification } + +export const createNewContractNotification = async ( + contractCreator: User, + contract: Contract, + idempotencyKey: string, + text: string, + mentionedUserIds: string[] +) => { + if (contract.visibility !== 'public') return + + const sendNotificationsIfSettingsAllow = async ( + userId: string, + reason: notification_reason_types + ) => { + const privateUser = await getPrivateUser(userId) + if (!privateUser) return + const { sendToBrowser, sendToEmail } = await getDestinationsForUser( + privateUser, + reason + ) + if (sendToBrowser) { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: userId, + reason, + createdTime: Date.now(), + isSeen: false, + sourceId: contract.id, + sourceType: 'contract', + sourceUpdateType: 'created', + sourceUserName: contractCreator.name, + sourceUserUsername: contractCreator.username, + sourceUserAvatarUrl: contractCreator.avatarUrl, + sourceText: text, + sourceSlug: contract.slug, + sourceTitle: contract.question, + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + await notificationRef.set(removeUndefinedProps(notification)) + } + if (!sendToEmail) return + if (reason === 'contract_from_followed_user') + await sendNewFollowedMarketEmail(reason, userId, privateUser, contract) + } + const followersSnapshot = await firestore + .collectionGroup('follows') + .where('userId', '==', contractCreator.id) + .get() + + const followerUserIds = filterDefined( + followersSnapshot.docs.map((doc) => { + const followerUserId = doc.ref.parent.parent?.id + return followerUserId && followerUserId != contractCreator.id + ? followerUserId + : undefined + }) + ) + + // As it is coded now, the tag notification usurps the new contract notification + // It'd be easy to append the reason to the eventId if desired + for (const followerUserId of followerUserIds) { + await sendNotificationsIfSettingsAllow( + followerUserId, + 'contract_from_followed_user' + ) + } + for (const mentionedUserId of mentionedUserIds) { + await sendNotificationsIfSettingsAllow(mentionedUserId, 'tagged_user') + } +} diff --git a/functions/src/email-templates/new-market-from-followed-user.html b/functions/src/email-templates/new-market-from-followed-user.html new file mode 100644 index 00000000..877d554f --- /dev/null +++ b/functions/src/email-templates/new-market-from-followed-user.html @@ -0,0 +1,354 @@ + + + + + New market from {{creatorName}} + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ {{creatorName}}, (who you're following) just created a new market, check it out!

+
+
+
+ + {{questionTitle}} + +
+ + + + + +
+ + View market + +
+ +
+
+ +
+
+ + +
+ + + + + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to manage your notifications. +

+
+
+
+
+ +
+
+ +
+
+ + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index d1387ef9..da6a5b41 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -510,3 +510,37 @@ function contractUrl(contract: Contract) { function imageSourceUrl(contract: Contract) { return buildCardUrl(getOpenGraphProps(contract)) } + +export const sendNewFollowedMarketEmail = async ( + reason: notification_reason_types, + userId: string, + privateUser: PrivateUser, + contract: Contract +) => { + const { sendToEmail, urlToManageThisNotification: unsubscribeUrl } = + await getDestinationsForUser(privateUser, reason) + if (!privateUser.email || !sendToEmail) return + const user = await getUser(privateUser.id) + if (!user) return + + const { name } = user + const firstName = name.split(' ')[0] + const creatorName = contract.creatorName + + return await sendTemplateEmail( + privateUser.email, + `${creatorName} asked ${contract.question}`, + 'new-market-from-followed-user', + { + name: firstName, + creatorName, + unsubscribeUrl, + questionTitle: contract.question, + questionUrl: contractUrl(contract), + questionImgSrc: imageSourceUrl(contract), + }, + { + from: `${creatorName} on Manifold `, + } + ) +} diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index d9826f6c..b613142b 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,7 +1,7 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' -import { createNotification } from './create-notification' +import { createNewContractNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' @@ -21,13 +21,11 @@ export const onCreateContract = functions const mentioned = parseMentions(desc) await addUserToContractFollowers(contract.id, contractCreator.id) - await createNotification( - contract.id, - 'contract', - 'created', + await createNewContractNotification( contractCreator, + contract, eventId, richTextToString(desc), - { contract, recipients: mentioned } + mentioned ) }) diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index c319bf32..65804991 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -58,9 +58,9 @@ export function NotificationSettings(props: { 'onboarding_flow', 'thank_you_for_purchases', + 'tagged_user', // missing tagged on contract description email + 'contract_from_followed_user', // TODO: add these - 'tagged_user', - // 'contract_from_followed_user', // 'referral_bonuses', // 'unique_bettors_on_your_contract', // 'on_new_follow', @@ -90,6 +90,7 @@ export function NotificationSettings(props: { subscriptionTypeToDescription: { all_comments_on_watched_markets: 'All new comments', all_comments_on_contracts_with_shares_in_on_watched_markets: `Only on markets you're invested in`, + // TODO: combine these two all_replies_to_my_comments_on_watched_markets: 'Only replies to your comments', all_replies_to_my_answers_on_watched_markets: diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index a4c25ed3..7ebc473b 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1031,52 +1031,53 @@ function getReasonForShowingNotification( const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string // TODO: we could leave out this switch and just use the reason field now that they have more information - switch (sourceType) { - case 'comment': - if (reason === 'reply_to_users_answer') - reasonText = justSummary ? 'replied' : 'replied to you on' - else if (reason === 'tagged_user') - reasonText = justSummary ? 'tagged you' : 'tagged you on' - else if (reason === 'reply_to_users_comment') - reasonText = justSummary ? 'replied' : 'replied to you on' - else reasonText = justSummary ? `commented` : `commented on` - break - case 'contract': - if (reason === 'contract_from_followed_user') - reasonText = justSummary ? 'asked the question' : 'asked' - else if (sourceUpdateType === 'resolved') - reasonText = justSummary ? `resolved the question` : `resolved` - else if (sourceUpdateType === 'closed') reasonText = `Please resolve` - else reasonText = justSummary ? 'updated the question' : `updated` - break - case 'answer': - if (reason === 'answer_on_your_contract') - reasonText = `answered your question ` - else reasonText = `answered` - break - case 'follow': - reasonText = 'followed you' - break - case 'liquidity': - reasonText = 'added a subsidy to your question' - break - case 'group': - reasonText = 'added you to the group' - break - case 'user': - if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') - reasonText = 'joined to bet on your market' - else if (sourceSlug) reasonText = 'joined because you shared' - else reasonText = 'joined because of you' - break - case 'bet': - reasonText = 'bet against you' - break - case 'challenge': - reasonText = 'accepted your challenge' - break - default: - reasonText = '' - } + if (reason === 'tagged_user') + reasonText = justSummary ? 'tagged you' : 'tagged you on' + else + switch (sourceType) { + case 'comment': + if (reason === 'reply_to_users_answer') + reasonText = justSummary ? 'replied' : 'replied to you on' + else if (reason === 'reply_to_users_comment') + reasonText = justSummary ? 'replied' : 'replied to you on' + else reasonText = justSummary ? `commented` : `commented on` + break + case 'contract': + if (reason === 'contract_from_followed_user') + reasonText = justSummary ? 'asked the question' : 'asked' + else if (sourceUpdateType === 'resolved') + reasonText = justSummary ? `resolved the question` : `resolved` + else if (sourceUpdateType === 'closed') reasonText = `Please resolve` + else reasonText = justSummary ? 'updated the question' : `updated` + break + case 'answer': + if (reason === 'answer_on_your_contract') + reasonText = `answered your question ` + else reasonText = `answered` + break + case 'follow': + reasonText = 'followed you' + break + case 'liquidity': + reasonText = 'added a subsidy to your question' + break + case 'group': + reasonText = 'added you to the group' + break + case 'user': + if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') + reasonText = 'joined to bet on your market' + else if (sourceSlug) reasonText = 'joined because you shared' + else reasonText = 'joined because of you' + break + case 'bet': + reasonText = 'bet against you' + break + case 'challenge': + reasonText = 'accepted your challenge' + break + default: + reasonText = '' + } return reasonText }