Merge branch 'manifoldmarkets:main' into main
This commit is contained in:
commit
325961fb5b
|
@ -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
|
||||
|
|
|
@ -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}`,
|
||||
}
|
||||
}
|
||||
|
|
192
common/user.ts
192
common/user.ts
|
@ -1,3 +1,5 @@
|
|||
import { filterDefined } from './util/array'
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
createdTime: number
|
||||
|
@ -34,7 +36,7 @@ export type User = {
|
|||
followerCountCached: number
|
||||
|
||||
followedCategories?: string[]
|
||||
homeSections?: { visible: string[]; hidden: string[] }
|
||||
homeSections?: string[]
|
||||
|
||||
referredByUserId?: string
|
||||
referredByContractId?: string
|
||||
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) =>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} {
|
||||
|
|
1
functions/.gitignore
vendored
1
functions/.gitignore
vendored
|
@ -17,4 +17,5 @@ package-lock.json
|
|||
ui-debug.log
|
||||
firebase-debug.log
|
||||
firestore-debug.log
|
||||
pubsub-debug.log
|
||||
firestore_export/
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,27 @@ 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,
|
||||
sendNewFollowedMarketEmail,
|
||||
} from './emails'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
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 +47,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 +57,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,142 +93,113 @@ 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 === 'subsidized_your_market') {
|
||||
// TODO: send email to creator of market that was subsidized
|
||||
} else if (reason === 'on_new_follow') {
|
||||
// TODO: send email to user who was followed
|
||||
}
|
||||
|
||||
const notifyUsersFollowers = async (
|
||||
userToReasonTexts: user_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 &&
|
||||
shouldGetNotification(followerUserId, userToReasonTexts)
|
||||
) {
|
||||
userToReasonTexts[followerUserId] = {
|
||||
reason: 'you_follow_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,
|
||||
userIds: (string | undefined)[]
|
||||
) => {
|
||||
userIds.forEach((id) => {
|
||||
if (id && shouldGetNotification(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))
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
if (shouldReceiveNotification(recipients[0], userToReasonTexts))
|
||||
userToReasonTexts[recipients[0]] = {
|
||||
reason: 'on_new_follow',
|
||||
}
|
||||
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 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: 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
|
||||
repliedUserId?: string
|
||||
taggedUserIds?: string[]
|
||||
repliedUsersInfo: replied_users_info
|
||||
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 { repliedUsersInfo, taggedUserIds } = miscData ?? {}
|
||||
|
||||
const createUsersNotifications = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
const browserRecipientIdsList: string[] = []
|
||||
const emailRecipientIdsList: string[] = []
|
||||
|
||||
const contractFollowersSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/follows`)
|
||||
.get()
|
||||
const contractFollowersIds = contractFollowersSnap.docs.map(
|
||||
(doc) => doc.data().id
|
||||
)
|
||||
|
||||
const createBrowserNotification = async (
|
||||
userId: string,
|
||||
reason: notification_reason_types
|
||||
) => {
|
||||
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,
|
||||
reason,
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId,
|
||||
|
@ -232,60 +216,109 @@ export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
|||
sourceSlug: sourceContract.slug,
|
||||
sourceTitle: sourceContract.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
})
|
||||
)
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
}
|
||||
|
||||
// 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 stillFollowingContract = (userId: string) => {
|
||||
return contractFollowersIds.includes(userId)
|
||||
}
|
||||
|
||||
const shouldGetNotification = (
|
||||
const sendNotificationsIfSettingsPermit = async (
|
||||
userId: string,
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
return (
|
||||
sourceUser.id != userId &&
|
||||
!Object.keys(userToReasonTexts).includes(userId)
|
||||
)
|
||||
}
|
||||
|
||||
const notifyContractFollowers = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
for (const userId of contractFollowersIds) {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'you_follow_contract',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyContractCreator = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
reason: notification_reason_types
|
||||
) => {
|
||||
if (
|
||||
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
|
||||
stillFollowingContract(sourceContract.creatorId)
|
||||
!stillFollowingContract(sourceContract.creatorId) ||
|
||||
sourceUser.id == userId
|
||||
)
|
||||
userToReasonTexts[sourceContract.creatorId] = {
|
||||
reason: 'on_users_contract',
|
||||
return
|
||||
const privateUser = await getPrivateUser(userId)
|
||||
if (!privateUser) return
|
||||
const { sendToBrowser, sendToEmail } = await getDestinationsForUser(
|
||||
privateUser,
|
||||
reason
|
||||
)
|
||||
|
||||
// Browser notifications
|
||||
if (sendToBrowser && !browserRecipientIdsList.includes(userId)) {
|
||||
await createBrowserNotification(userId, reason)
|
||||
browserRecipientIdsList.push(userId)
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
emailRecipientIdsList.push(userId)
|
||||
}
|
||||
}
|
||||
|
||||
const notifyOtherAnswerersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
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 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 +326,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 +350,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 +382,77 @@ 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' && repliedUsersInfo)
|
||||
await Promise.all(
|
||||
Object.keys(repliedUsersInfo).map((userId) =>
|
||||
sendNotificationsIfSettingsPermit(
|
||||
userId,
|
||||
repliedUsersInfo[userId].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 +464,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 +496,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 +509,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 +542,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 +554,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 +599,8 @@ export const createReferralNotification = async (
|
|||
: referredByContract?.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
|
||||
// TODO send email notification
|
||||
}
|
||||
|
||||
export const createLoanIncomeNotification = async (
|
||||
|
@ -582,6 +608,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 +646,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 +687,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 +730,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 +764,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 +776,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 +810,82 @@ export const createUniqueBettorBonusNotification = async (
|
|||
sourceContractCreatorUsername: contract.creatorUsername,
|
||||
}
|
||||
return await notificationRef.set(removeUndefinedProps(notification))
|
||||
|
||||
// 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')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
491
functions/src/email-templates/market-resolved-no-bets.html
Normal file
491
functions/src/email-templates/market-resolved-no-bets.html
Normal file
|
@ -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</title>
|
||||
|
||||
<style type="text/css">
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 640px) {
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.invoice {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body itemscope itemtype="http://schema.org/EmailMessage" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
line-height: 1.6em;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
" bgcolor="#f6f6f6">
|
||||
<table class="body-wrap" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
background-color: #f6f6f6;
|
||||
margin: 0;
|
||||
" bgcolor="#f6f6f6">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
" valign="top"></td>
|
||||
<td class="container" width="600" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
display: block !important;
|
||||
max-width: 600px !important;
|
||||
clear: both !important;
|
||||
margin: 0 auto;
|
||||
" valign="top">
|
||||
<div class="content" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
max-width: 600px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
border-radius: 3px;
|
||||
background-color: #fff;
|
||||
margin: 0;
|
||||
border: 1px solid #e9e9e9;
|
||||
" bgcolor="#fff">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
">
|
||||
<td class="content-wrap aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
" align="center" valign="top">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
width: 90%;
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 0 0 40px 0;
|
||||
text-align: left;
|
||||
" valign="top">
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
<img src="https://manifold.markets/logo-banner.png" width="300" style="height: auto"
|
||||
alt="Manifold Markets" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 0 0 6px 0;
|
||||
text-align: left;
|
||||
" valign="top">
|
||||
{{creatorName}} asked
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 0 0 20px;
|
||||
" valign="top">
|
||||
<a href="{{url}}" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
'Lucida Grande', sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 24px;
|
||||
color: #000;
|
||||
line-height: 1.2em;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
margin: 0 0 0 0;
|
||||
color: #4337c9;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
">
|
||||
{{question}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
">
|
||||
<td class="content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 0 0 0px;
|
||||
" valign="top">
|
||||
<h2 class="aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
'Lucida Grande', sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 24px;
|
||||
color: #000;
|
||||
line-height: 1.2em;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin: 10px 0 0;
|
||||
" align="center">
|
||||
Resolved {{outcome}}
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
">
|
||||
<td class="content-block aligncenter" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
vertical-align: top;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
" align="center" valign="top">
|
||||
<table class="invoice" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
text-align: left;
|
||||
width: 80%;
|
||||
margin: 40px auto;
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
">
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
padding: 5px 0;
|
||||
" valign="top">
|
||||
Dear {{name}},
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
" />
|
||||
A market you were following has been resolved!
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
" />
|
||||
Thanks,
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
" />
|
||||
Manifold Team
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
" />
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
">
|
||||
<td style="padding: 10px 0 0 0; margin: 0">
|
||||
<div align="center">
|
||||
<!--[if mso]><table width="100%" cellpadding="0" cellspacing="0" border="0" style="border-spacing: 0; border-collapse: collapse; mso-table-lspace:0pt; mso-table-rspace:0pt;font-family:arial,helvetica,sans-serif;"><tr><td style="font-family:arial,helvetica,sans-serif;" align="center"><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="" style="height:37px; v-text-anchor:middle; width:110px;" arcsize="11%" stroke="f" fillcolor="#3AAEE0"><w:anchorlock/><center style="color:#FFFFFF;font-family:arial,helvetica,sans-serif;"><![endif]-->
|
||||
<a href="{{url}}" target="_blank" style="
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
font-family: arial, helvetica, sans-serif;
|
||||
text-decoration: none;
|
||||
-webkit-text-size-adjust: none;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
background-color: #11b981;
|
||||
border-radius: 4px;
|
||||
-webkit-border-radius: 4px;
|
||||
-moz-border-radius: 4px;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
mso-border-alt: none;
|
||||
">
|
||||
<span style="
|
||||
display: block;
|
||||
padding: 10px 20px;
|
||||
line-height: 120%;
|
||||
"><span style="
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
line-height: 18.8px;
|
||||
">View market</span></span>
|
||||
</a>
|
||||
<!--[if mso]></center></v:roundrect></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="footer" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
clear: both;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
">
|
||||
<table width="100%" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
">
|
||||
<tr style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
">
|
||||
<td class="aligncenter content-block" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
vertical-align: top;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0 0 20px;
|
||||
" align="center" valign="top">
|
||||
Questions? Come ask in
|
||||
<a href="https://discord.gg/eHQBNBqXuh" style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-decoration: underline;
|
||||
margin: 0;
|
||||
">our Discord</a>! Or,
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
" valign="top"></td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</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>
|
||||
|
|
354
functions/src/email-templates/new-market-from-followed-user.html
Normal file
354
functions/src/email-templates/new-market-from-followed-user.html
Normal file
|
@ -0,0 +1,354 @@
|
|||
<!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>New market from {{creatorName}}</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG />
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Readex+Pro" rel="stylesheet" type="text/css" />
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
@import url(https://fonts.googleapis.com/css?family=Readex+Pro);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width: 480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
[owa] .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
@media only screen and (max-width: 480px) {
|
||||
table.mj-full-width-mobile {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
td.mj-full-width-mobile {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="direction:ltr;font-size:0px;padding:20px 0px 5px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="background:#ffffff;font-size:0px;padding:10px 25px 10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;">
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width:550px;">
|
||||
|
||||
<a href="https://manifold.markets" target="_blank">
|
||||
|
||||
<img alt="banner logo" height="auto"
|
||||
src="https://manifold.markets/logo-banner.png"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
title="" width="550">
|
||||
|
||||
</a>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%">
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 20px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="vertical-align: top" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
</span>Hi {{name}},</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
{{creatorName}}, (who you're following) just created a new market, check it out!</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:20px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:center;color:#000000;">
|
||||
<a href="{{questionUrl}}">
|
||||
<img alt="{{questionTitle}}" width="375" height="200"
|
||||
style="border: 1px solid #4337c9;" src="{{questionImgSrc}}">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 4px;" bgcolor="#4337c9">
|
||||
<a href="{{questionUrl}}" target="_blank"
|
||||
style="padding: 6px 10px; border: 1px solid #4337c9;border-radius: 12px;font-family: Helvetica, Arial, sans-serif;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;">
|
||||
View market
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0 0 20px 0;
|
||||
text-align: center;
|
||||
">
|
||||
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
">
|
||||
<div style="
|
||||
font-family: Ubuntu, Helvetica, Arial,
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
color: #000000;
|
||||
">
|
||||
<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>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
word-break: break-word;
|
||||
"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -1,16 +1,14 @@
|
|||
<!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"
|
||||
>
|
||||
<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!</title>
|
||||
<title>Manifold Markets 7th Day Anniversary Gift!</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
|
@ -51,14 +49,12 @@
|
|||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml> </noscript
|
||||
>z
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix {
|
||||
width: 100% !important;
|
||||
}
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
|
@ -94,314 +90,135 @@
|
|||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing: normal; background-color: #f4f4f4">
|
||||
<div style="background-color: #f4f4f4">
|
||||
<body style="word-spacing:normal;background-color:#F4F4F4;">
|
||||
<div style="background-color:#F4F4F4;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div
|
||||
style="
|
||||
background: #ffffff;
|
||||
background-color: #ffffff;
|
||||
margin: 0px auto;
|
||||
max-width: 600px;
|
||||
"
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="background: #ffffff; background-color: #ffffff; width: 100%"
|
||||
>
|
||||
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="background:#ffffff;background-color:#ffffff;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 0px 0px 0px 0px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 0px;
|
||||
padding-right: 0px;
|
||||
padding-top: 0px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
style="direction:ltr;font-size:0px;padding:0px 0px 0px 0px;padding-bottom:0px;padding-left:0px;padding-right:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="vertical-align: top"
|
||||
width="100%"
|
||||
>
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 0px 25px 0px 25px;
|
||||
padding-top: 0px;
|
||||
padding-right: 25px;
|
||||
padding-bottom: 0px;
|
||||
padding-left: 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0px;
|
||||
"
|
||||
>
|
||||
<td align="center"
|
||||
style="font-size:0px;padding:0px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 550px">
|
||||
<a
|
||||
href="https://manifold.markets/home"
|
||||
target="_blank"
|
||||
><img
|
||||
alt=""
|
||||
height="auto"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjvu.gif"
|
||||
style="
|
||||
border: none;
|
||||
display: block;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
"
|
||||
width="550"
|
||||
/></a>
|
||||
</td>
|
||||
<td style="width:550px;"><a href="https://manifold.markets/home" target="_blank"><img
|
||||
alt="" height="auto" src="https://i.imgur.com/8EP8Y8q.gif"
|
||||
style="border:none;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;"
|
||||
width="550"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
align="left"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
letter-spacing: normal;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
color: #000000;
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
"
|
||||
data-testid="4XoHRGw1Y"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 18px;
|
||||
"
|
||||
>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!</span
|
||||
>
|
||||
</p>
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
Hi {{name}},</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px 25px 25px;
|
||||
padding-top: 10px;
|
||||
padding-right: 25px;
|
||||
padding-bottom: 25px;
|
||||
padding-left: 25px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0px;
|
||||
"
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 550px">
|
||||
<a href="{{manalink}}" target="_blank">
|
||||
<img
|
||||
alt="Get M$500"
|
||||
height="auto"
|
||||
src="https://03jlj.mjt.lu/img/03jlj/b/u71/sjgt.png"
|
||||
style="
|
||||
border: none;
|
||||
display: block;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
"
|
||||
width="550"
|
||||
/></a>
|
||||
<< /td>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content"
|
||||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">Thanks for
|
||||
using Manifold Markets. Running low
|
||||
on mana (M$)? Click the link below to receive a one time gift of M$500!</span></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<p></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td>
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="border-radius: 2px;" bgcolor="#4337c9">
|
||||
<a href="{{manalink}}" target="_blank"
|
||||
style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;">
|
||||
Claim M$500
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
align="left"
|
||||
style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<td align="left"
|
||||
style="font-size:0px;padding:15px 25px 0px 25px;padding-top:15px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">
|
||||
<div
|
||||
style="
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
letter-spacing: normal;
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
color: #000000;
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="text-build-content"
|
||||
style="
|
||||
line-height: 23px;
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
margin-top: 10px;
|
||||
"
|
||||
data-testid="3Q8BP69fq"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 18px;
|
||||
"
|
||||
>If you are still engaging with our markets then
|
||||
at this point you might as well join our </span
|
||||
><a
|
||||
class="link-build-content"
|
||||
style="color: inherit; text-decoration: none"
|
||||
style="font-family:Arial, sans-serif;font-size:18px;letter-spacing:normal;line-height:1;text-align:left;color:#000000;">
|
||||
<p class="text-build-content" style="line-height: 23px; margin: 10px 0; margin-top: 10px;"
|
||||
data-testid="3Q8BP69fq"><span style="font-family:Arial, sans-serif;font-size:18px;">Did
|
||||
you know, besides making correct predictions, there are
|
||||
plenty of other ways to earn mana?</span></p>
|
||||
<ul>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Predicting
|
||||
consecutive days to earn streak rewards</span></li>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Receiving
|
||||
tips on comments and markets</span></li>
|
||||
<li style="line-height:23px;"><span
|
||||
style="font-family:Arial, sans-serif;font-size:18px;">Unique
|
||||
predictor bonus for each user who predicts on your
|
||||
markets</span></li>
|
||||
<li style="line-height:23px;"><span style="font-family:Arial, sans-serif;font-size:18px;"><a
|
||||
class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank" href="https://manifold.markets/referrals"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Referring
|
||||
friends</u></span></a></span></li>
|
||||
<li style="line-height:23px;"><a class="link-build-content"
|
||||
style="color:inherit;; text-decoration: none;" target="_blank"
|
||||
href="https://manifold.markets/group/bugs?s=most-traded"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>Reporting
|
||||
bugs</u></span></a><span style="font-family:Arial, sans-serif;font-size:18px;">
|
||||
and </span><a class="link-build-content" style="color:inherit;; text-decoration: none;"
|
||||
target="_blank"
|
||||
href="https://discord.gg/VARzUpyCSa"
|
||||
><span
|
||||
style="
|
||||
color: #0c21bf;
|
||||
font-family: Arial;
|
||||
font-size: 18px;
|
||||
"
|
||||
><u>Discord server</u></span
|
||||
><span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Arial;
|
||||
font-size: 18px;
|
||||
"
|
||||
><u>.</u>
|
||||
</span></a
|
||||
><span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Arial;
|
||||
font-size: 18px;
|
||||
"
|
||||
>You can always leave if you dont like it but
|
||||
I'd be willing to make a market betting
|
||||
you'll stay.</span
|
||||
>
|
||||
href="https://manifold.markets/group/manifold-features-25bad7c7792e/chat?s=most-traded"><span
|
||||
style="color:#55575d;font-family:Arial;font-size:18px;"><u>giving
|
||||
feedback</u></span></a></li>
|
||||
</ul>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"> </p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">Cheers,</span>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0"
|
||||
></p>
|
||||
<br />
|
||||
<p
|
||||
class="text-build-content"
|
||||
data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Arial;
|
||||
font-size: 18px;
|
||||
"
|
||||
>Cheers,</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0"
|
||||
>
|
||||
<span
|
||||
style="
|
||||
color: #000000;
|
||||
font-family: Arial;
|
||||
font-size: 18px;
|
||||
"
|
||||
>David from Manifold</span
|
||||
>
|
||||
</p>
|
||||
<p
|
||||
class="text-build-content"
|
||||
data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0; margin-bottom: 10px"
|
||||
></p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq" style="margin: 10px 0;"><span
|
||||
style="color:#000000;font-family:Arial;font-size:18px;">David
|
||||
from Manifold</span></p>
|
||||
<p class="text-build-content" data-testid="3Q8BP69fq"
|
||||
style="margin: 10px 0; margin-bottom: 10px;"> </p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -415,91 +232,70 @@
|
|||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin: 0px auto; max-width: 600px">
|
||||
<table
|
||||
align="center"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
style="width: 100%"
|
||||
>
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style="
|
||||
direction: ltr;
|
||||
font-size: 0px;
|
||||
padding: 20px 0px 20px 0px;
|
||||
text-align: center;
|
||||
"
|
||||
>
|
||||
<td style="direction:ltr;font-size:0px;padding:0 0 20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div
|
||||
class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="
|
||||
font-size: 0px;
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: 100%;
|
||||
"
|
||||
>
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||
width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0">
|
||||
<table
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
role="presentation"
|
||||
width="100%"
|
||||
>
|
||||
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="border-collapse:collapse;border-spacing:0px;">
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||
style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
align="center"
|
||||
style="
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0px 20px 0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding:0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="
|
||||
font-size: 0px;
|
||||
padding: 10px 25px;
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
word-break: break-word;
|
||||
"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
font-family: Arial, sans-serif;
|
||||
">
|
||||
<div style="
|
||||
font-family: Ubuntu, Helvetica, 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="
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"
|
||||
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;">
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
|
@ -516,4 +312,5 @@
|
|||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -214,10 +214,12 @@
|
|||
<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>
|
||||
to {{name}},
|
||||
<a href="{{unsubscribeUrl}}" style="
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
" target="_blank">click here to manage your notifications</a>.
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -137,7 +137,7 @@
|
|||
style="line-height: 24px; margin: 10px 0; margin-top: 10px; margin-bottom: 10px;"
|
||||
data-testid="4XoHRGw1Y"><span
|
||||
style="color:#000000;font-family:Arial, Helvetica, sans-serif;font-size:18px;">
|
||||
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. </span></p>
|
||||
</div>
|
||||
</td>
|
||||
|
@ -286,9 +286,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>
|
||||
|
|
|
@ -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,17 +52,13 @@ 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)
|
||||
: formatMoney(investment)
|
||||
const correctedInvestment =
|
||||
Number.isNaN(investment) || investment < 0 ? 0 : investment
|
||||
const displayedInvestment = formatMoney(correctedInvestment)
|
||||
|
||||
const displayedPayout = formatMoney(payout)
|
||||
|
||||
|
@ -85,7 +80,7 @@ export const sendMarketResolutionEmail = async (
|
|||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
subject,
|
||||
'market-resolved',
|
||||
correctedInvestment === 0 ? 'market-resolved-no-bets' : 'market-resolved',
|
||||
templateData
|
||||
)
|
||||
}
|
||||
|
@ -154,11 +149,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 +162,7 @@ export const sendWelcomeEmail = async (
|
|||
'welcome',
|
||||
{
|
||||
name: firstName,
|
||||
unsubscribeLink,
|
||||
unsubscribeUrl,
|
||||
},
|
||||
{
|
||||
from: 'David from Manifold <david@manifold.markets>',
|
||||
|
@ -217,23 +213,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 +246,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 <david@manifold.markets>',
|
||||
|
@ -282,15 +278,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 +297,7 @@ export const sendThankYouEmail = async (
|
|||
'thank-you',
|
||||
{
|
||||
name: firstName,
|
||||
unsubscribeLink,
|
||||
unsubscribeUrl,
|
||||
},
|
||||
{
|
||||
from: 'David from Manifold <david@manifold.markets>',
|
||||
|
@ -307,16 +306,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 +322,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 +339,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 +370,7 @@ export const sendNewCommentEmail = async (
|
|||
const from = `${commentorName} on Manifold <no-reply@manifold.markets>`
|
||||
|
||||
if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) {
|
||||
const answerNumber = `#${answerId}`
|
||||
const answerNumber = answerId ? `#${answerId}` : ''
|
||||
|
||||
return await sendTemplateEmail(
|
||||
privateUser.email,
|
||||
|
@ -391,7 +381,7 @@ export const sendNewCommentEmail = async (
|
|||
answerNumber,
|
||||
commentorName,
|
||||
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
||||
comment: text,
|
||||
comment: commentText,
|
||||
marketUrl,
|
||||
unsubscribeUrl,
|
||||
betDescription,
|
||||
|
@ -412,7 +402,7 @@ export const sendNewCommentEmail = async (
|
|||
{
|
||||
commentorName,
|
||||
commentorAvatarUrl: commentorAvatarUrl ?? '',
|
||||
comment: text,
|
||||
comment: commentText,
|
||||
marketUrl,
|
||||
unsubscribeUrl,
|
||||
betDescription,
|
||||
|
@ -423,29 +413,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} <info@manifold.markets>`
|
||||
|
@ -474,12 +459,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 +478,7 @@ export const sendInterestingMarketsEmail = async (
|
|||
'interesting-markets',
|
||||
{
|
||||
name: firstName,
|
||||
unsubscribeLink: unsubscribeUrl,
|
||||
unsubscribeUrl,
|
||||
|
||||
question1Title: contractsToSend[0].question,
|
||||
question1Link: contractUrl(contractsToSend[0]),
|
||||
|
@ -522,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 <no-reply@manifold.markets>`,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
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,
|
||||
replied_users_info,
|
||||
} from './create-notification'
|
||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||
import { addUserToContractFollowers } from './follow-market'
|
||||
|
@ -77,16 +76,46 @@ export const onCreateCommentOnContract = functions
|
|||
const comments = await getValues<ContractComment>(
|
||||
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
|
||||
? 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',
|
||||
|
@ -96,31 +125,8 @@ export const onCreateCommentOnContract = functions
|
|||
richTextToString(comment.content),
|
||||
contract,
|
||||
{
|
||||
relatedSourceType,
|
||||
repliedUserId,
|
||||
taggedUserIds: compact(parseMentions(comment.content)),
|
||||
repliedUsersInfo: repliedUsers,
|
||||
taggedUserIds: mentionedUsers,
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -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,48 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => {
|
|||
|
||||
const userPayoutsWithoutLoans = groupPayoutsByUser(payouts)
|
||||
|
||||
await sendResolutionEmails(
|
||||
const userInvestments = mapValues(
|
||||
groupBy(bets, (bet) => bet.userId),
|
||||
(bets) => getContractBetMetrics(contract, bets).invested
|
||||
)
|
||||
let resolutionText = outcome ?? contract.question
|
||||
if (
|
||||
contract.outcomeType === 'FREE_RESPONSE' ||
|
||||
contract.outcomeType === 'MULTIPLE_CHOICE'
|
||||
) {
|
||||
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,
|
||||
contract.id + '-resolution',
|
||||
resolutionText,
|
||||
contract,
|
||||
undefined,
|
||||
{
|
||||
bets,
|
||||
userPayoutsWithoutLoans,
|
||||
userInvestments,
|
||||
userPayouts: userPayoutsWithoutLoans,
|
||||
creator,
|
||||
creatorPayout,
|
||||
contract,
|
||||
outcome,
|
||||
resolutionProbability,
|
||||
resolutions
|
||||
resolutions,
|
||||
}
|
||||
)
|
||||
|
||||
return updatedContract
|
||||
|
@ -189,51 +220,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
|
||||
|
||||
|
|
30
functions/src/scripts/create-new-notification-preferences.ts
Normal file
30
functions/src/scripts/create-new-notification-preferences.ts
Normal file
|
@ -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())
|
|
@ -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) {
|
||||
|
|
|
@ -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,7 +27,8 @@ export const updateMetrics = functions
|
|||
.onRun(updateMetricsCore)
|
||||
|
||||
export async function updateMetricsCore() {
|
||||
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
|
||||
const [users, contracts, bets, allPortfolioHistories, groups] =
|
||||
await Promise.all([
|
||||
getValues<User>(firestore.collection('users')),
|
||||
getValues<Contract>(firestore.collection('contracts')),
|
||||
getValues<Bet>(firestore.collectionGroup('bets')),
|
||||
|
@ -33,7 +37,19 @@ export async function updateMetricsCore() {
|
|||
.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,48 @@ 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])
|
||||
.filter((e) => e !== undefined) as Contract[]
|
||||
const bets = groupContracts.map((e) => {
|
||||
if (e != null && e.id in betsByContract) {
|
||||
return betsByContract[e.id] ?? []
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
} 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 }
|
||||
|
|
|
@ -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<notification_subscribe_types>('all')
|
||||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
||||
useState<notification_subscribe_types>('all')
|
||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(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 <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
|
||||
}
|
||||
|
||||
function NotificationSettingLine(props: {
|
||||
label: string | React.ReactNode
|
||||
highlight: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const { label, highlight, onClick } = props
|
||||
return (
|
||||
<Row
|
||||
className={clsx(
|
||||
'my-1 gap-1 text-gray-300',
|
||||
highlight && '!text-black',
|
||||
onClick ? 'cursor-pointer' : ''
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
||||
{label}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'p-2'}>
|
||||
<div>In App Notifications</div>
|
||||
<ChoicesToggleGroup
|
||||
currentChoice={notificationSettings}
|
||||
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
||||
setChoice={(choice) =>
|
||||
changeInAppNotificationSettings(
|
||||
choice as notification_subscribe_types
|
||||
)
|
||||
}
|
||||
className={'col-span-4 p-2'}
|
||||
toggleClassName={'w-24'}
|
||||
/>
|
||||
<div className={'mt-4 text-sm'}>
|
||||
<Col className={''}>
|
||||
<Row className={'my-1'}>
|
||||
You will receive notifications for these general events:
|
||||
</Row>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Income & referral bonuses you've received"}
|
||||
/>
|
||||
<Row className={'my-1'}>
|
||||
You will receive new comment, answer, & resolution notifications on
|
||||
questions:
|
||||
</Row>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={
|
||||
<span>
|
||||
That <span className={'font-bold'}>you watch </span>- you
|
||||
auto-watch questions if:
|
||||
</span>
|
||||
}
|
||||
onClick={() => setShowModal(true)}
|
||||
/>
|
||||
<Col
|
||||
className={clsx(
|
||||
'mb-2 ml-8',
|
||||
'gap-1 text-gray-300',
|
||||
notificationSettings !== 'none' && '!text-black'
|
||||
)}
|
||||
>
|
||||
<Row>• You create it</Row>
|
||||
<Row>• You bet, comment on, or answer it</Row>
|
||||
<Row>• You add liquidity to it</Row>
|
||||
<Row>
|
||||
• 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
|
||||
</Row>
|
||||
</Col>
|
||||
</Col>
|
||||
</div>
|
||||
<div className={'mt-4'}>Email Notifications</div>
|
||||
<ChoicesToggleGroup
|
||||
currentChoice={emailNotificationSettings}
|
||||
choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
|
||||
setChoice={(choice) =>
|
||||
changeEmailNotifications(choice as notification_subscribe_types)
|
||||
}
|
||||
className={'col-span-4 p-2'}
|
||||
toggleClassName={'w-24'}
|
||||
/>
|
||||
<div className={'mt-4 text-sm'}>
|
||||
<div>
|
||||
You will receive emails for:
|
||||
<NotificationSettingLine
|
||||
label={"Resolution of questions you're betting on"}
|
||||
highlight={emailNotificationSettings !== 'none'}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
label={'Closure of your questions'}
|
||||
highlight={emailNotificationSettings !== 'none'}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
label={'Activity on your questions'}
|
||||
highlight={emailNotificationSettings === 'all'}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
label={"Activity on questions you've answered or commented on"}
|
||||
highlight={emailNotificationSettings === 'all'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FollowMarketModal setOpen={setShowModal} open={showModal} />
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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,14 +31,15 @@ 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).filter(
|
||||
(a) => a.number != 0 || contract.outcomeType === 'MULTIPLE_CHOICE'
|
||||
)
|
||||
const hasZeroBetAnswers = answers.some((answer) => totalBets[answer.id] < 1)
|
||||
|
||||
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((a) => (showAllAnswers ? true : totalBets[a.id] > 0)),
|
||||
(answer) =>
|
||||
answer.id === resolution || (resolutions && resolutions[answer.id])
|
||||
)
|
||||
|
@ -127,6 +129,17 @@ export function AnswersPanel(props: {
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Row className={'justify-end'}>
|
||||
{hasZeroBetAnswers && !showAllAnswers && (
|
||||
<Button
|
||||
color={'gray-white'}
|
||||
onClick={() => setShowAllAnswers(true)}
|
||||
size={'md'}
|
||||
>
|
||||
Show More
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -7,25 +7,19 @@ 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'
|
||||
|
||||
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 (
|
||||
<DragDropContext
|
||||
|
@ -35,23 +29,16 @@ export function ArrangeHome(props: {
|
|||
|
||||
const item = itemsById[draggableId]
|
||||
|
||||
const newHomeSections = {
|
||||
visible: visibleItems.map((item) => 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)
|
||||
}}
|
||||
>
|
||||
<Row className="relative max-w-lg gap-4">
|
||||
<DraggableList items={visibleItems} title="Visible" />
|
||||
<DraggableList items={hiddenItems} title="Hidden" />
|
||||
<Row className="relative max-w-md gap-4">
|
||||
<DraggableList items={sections} title="Sections" />
|
||||
</Row>
|
||||
</DragDropContext>
|
||||
)
|
||||
|
@ -64,16 +51,13 @@ function DraggableList(props: {
|
|||
const { title, items } = props
|
||||
return (
|
||||
<Droppable droppableId={title.toLowerCase()}>
|
||||
{(provided, snapshot) => (
|
||||
{(provided) => (
|
||||
<Col
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
className={clsx(
|
||||
'width-[220px] flex-1 items-start rounded bg-gray-50 p-2',
|
||||
snapshot.isDraggingOver && 'bg-gray-100'
|
||||
)}
|
||||
className={clsx('flex-1 items-stretch gap-1 rounded bg-gray-100 p-4')}
|
||||
>
|
||||
<Subtitle text={title} className="mx-2 !my-2" />
|
||||
<Subtitle text={title} className="mx-2 !mt-0 !mb-4" />
|
||||
{items.map((item, index) => (
|
||||
<Draggable key={item.id} draggableId={item.id} index={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'
|
||||
)}
|
||||
>
|
||||
<MenuIcon
|
||||
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
{item.label}
|
||||
<SectionItem
|
||||
className={clsx(
|
||||
snapshot.isDragging && 'z-[9000] bg-gray-200'
|
||||
)}
|
||||
item={item}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
|
@ -103,15 +84,36 @@ 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 (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2'
|
||||
)}
|
||||
>
|
||||
<MenuIcon
|
||||
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
{item.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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' },
|
||||
{ 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 +121,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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -2,37 +2,39 @@ 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 <LoadingIndicator />
|
||||
|
||||
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 <div className="px-4 text-gray-500">None</div>
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
|
||||
<Col className="flex-1 divide-y">
|
||||
{filteredChanges.slice(0, count / 2).map((contract) => (
|
||||
{filteredPositiveChanges.map((contract) => (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
|
@ -48,7 +50,7 @@ export function ProbChangeTable(props: { userId: string | undefined }) {
|
|||
))}
|
||||
</Col>
|
||||
<Col className="flex-1 divide-y">
|
||||
{filteredChanges.slice(count / 2).map((contract) => (
|
||||
{filteredNegativeChanges.map((contract) => (
|
||||
<Row className="items-center hover:bg-gray-100">
|
||||
<ProbChange
|
||||
className="p-4 text-right text-xl"
|
||||
|
@ -64,13 +66,6 @@ export function ProbChangeTable(props: { userId: string | undefined }) {
|
|||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
<div
|
||||
className={clsx(linkClass, 'cursor-pointer self-end')}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</div>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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: {
|
|||
<Col className={'gap-2'}>
|
||||
<span className={'text-indigo-700'}>• What is watching?</span>
|
||||
<span className={'ml-2'}>
|
||||
You can receive notifications on questions you're interested in by
|
||||
You'll receive notifications on markets by betting, commenting, or
|
||||
clicking the
|
||||
<EyeIcon
|
||||
className={clsx('ml-1 inline h-6 w-6 align-top')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
️ button on a question.
|
||||
️ button on them.
|
||||
</span>
|
||||
<span className={'text-indigo-700'}>
|
||||
• What types of notifications will I receive?
|
||||
</span>
|
||||
<span className={'ml-2'}>
|
||||
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.
|
||||
</span>
|
||||
</Col>
|
||||
</Col>
|
|
@ -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">
|
||||
|
|
|
@ -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,16 +299,6 @@ 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}
|
||||
|
@ -319,56 +307,6 @@ export function ContractCommentInput(props: {
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
</Col>
|
||||
)}
|
||||
<FollowMarketModal
|
||||
<WatchMarketModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={`You ${
|
||||
|
|
|
@ -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"
|
||||
|
|
26
web/components/market-intro-panel.tsx
Normal file
26
web/components/market-intro-panel.tsx
Normal file
|
@ -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="mb-4 text-sm">
|
||||
Manifold Markets is a play-money prediction market platform where you
|
||||
can forecast anything.
|
||||
</div>
|
||||
|
||||
<BetSignUpPrompt />
|
||||
</Col>
|
||||
)
|
||||
}
|
320
web/components/notification-settings.tsx
Normal file
320
web/components/notification-settings.tsx
Normal file
|
@ -0,0 +1,320 @@
|
|||
import { usePrivateUser } from 'web/hooks/use-user'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
notification_subscription_types,
|
||||
notification_destination_types,
|
||||
} from 'common/user'
|
||||
import { updatePrivateUser } from 'web/lib/firebase/users'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import {
|
||||
CashIcon,
|
||||
ChatIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CurrencyDollarIcon,
|
||||
InboxInIcon,
|
||||
InformationCircleIcon,
|
||||
LightBulbIcon,
|
||||
TrendingUpIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import { WatchMarketModal } from 'web/components/contract/watch-market-modal'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import toast from 'react-hot-toast'
|
||||
import { SwitchSetting } from 'web/components/switch-setting'
|
||||
|
||||
export function NotificationSettings(props: {
|
||||
navigateToSection: string | undefined
|
||||
}) {
|
||||
const { navigateToSection } = props
|
||||
const privateUser = usePrivateUser()
|
||||
const [showWatchModal, setShowWatchModal] = useState(false)
|
||||
|
||||
if (!privateUser || !privateUser.notificationSubscriptionTypes) {
|
||||
return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
|
||||
}
|
||||
|
||||
const emailsEnabled: Array<keyof notification_subscription_types> = [
|
||||
'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',
|
||||
|
||||
'trending_markets',
|
||||
'onboarding_flow',
|
||||
'thank_you_for_purchases',
|
||||
|
||||
'tagged_user', // missing tagged on contract description email
|
||||
'contract_from_followed_user',
|
||||
// TODO: add these
|
||||
// 'referral_bonuses',
|
||||
// 'unique_bettors_on_your_contract',
|
||||
// 'on_new_follow',
|
||||
// 'profit_loss_updates',
|
||||
// 'tips_on_your_markets',
|
||||
// 'tips_on_your_comments',
|
||||
// maybe the following?
|
||||
// 'probability_updates_on_watched_markets',
|
||||
// 'limit_order_fills',
|
||||
]
|
||||
const browserDisabled: Array<keyof notification_subscription_types> = [
|
||||
'trending_markets',
|
||||
'profit_loss_updates',
|
||||
'onboarding_flow',
|
||||
'thank_you_for_purchases',
|
||||
]
|
||||
|
||||
type sectionData = {
|
||||
label: string
|
||||
subscriptionTypeToDescription: {
|
||||
[key in keyof Partial<notification_subscription_types>]: 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`,
|
||||
// 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:
|
||||
'Only replies to your answers',
|
||||
// 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`,
|
||||
// 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 (
|
||||
<Row
|
||||
className={clsx(
|
||||
'my-1 gap-1 text-gray-300',
|
||||
highlight ? 'rounded-md bg-indigo-100 p-1' : ''
|
||||
)}
|
||||
>
|
||||
<Col className="ml-3 gap-2 text-sm">
|
||||
<Row className="gap-2 font-medium text-gray-700">
|
||||
<span>{description}</span>
|
||||
</Row>
|
||||
<Row className={'gap-4'}>
|
||||
{!browserDisabled.includes(key) && (
|
||||
<SwitchSetting
|
||||
checked={inAppEnabled}
|
||||
onChange={setInAppEnabled}
|
||||
label={'Web'}
|
||||
/>
|
||||
)}
|
||||
{emailsEnabled.includes(key) && (
|
||||
<SwitchSetting
|
||||
checked={emailEnabled}
|
||||
onChange={setEmailEnabled}
|
||||
label={'Email'}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Col className={clsx('ml-2 gap-2')}>
|
||||
<Row
|
||||
className={'mt-1 cursor-pointer items-center gap-2 text-gray-600'}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
|
||||
{expanded ? (
|
||||
<ChevronUpIcon className="h-5 w-5 text-xs text-gray-500">
|
||||
Hide
|
||||
</ChevronUpIcon>
|
||||
) : (
|
||||
<ChevronDownIcon className="h-5 w-5 text-xs text-gray-500">
|
||||
Show
|
||||
</ChevronDownIcon>
|
||||
)}
|
||||
</Row>
|
||||
<Col className={clsx(expanded ? 'block' : 'hidden', 'gap-2 p-2')}>
|
||||
{Object.entries(subscriptionTypeToDescription).map(([key, value]) =>
|
||||
NotificationSettingLine(
|
||||
value,
|
||||
key as keyof notification_subscription_types,
|
||||
getUsersSavedPreference(
|
||||
key as keyof notification_subscription_types
|
||||
)
|
||||
)
|
||||
)}
|
||||
</Col>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'p-2'}>
|
||||
<Col className={'gap-6'}>
|
||||
<Row className={'gap-2 text-xl text-gray-700'}>
|
||||
<span>Notifications for Watched Markets</span>
|
||||
<InformationCircleIcon
|
||||
className="-mb-1 h-5 w-5 cursor-pointer text-gray-500"
|
||||
onClick={() => setShowWatchModal(true)}
|
||||
/>
|
||||
</Row>
|
||||
{Section(<ChatIcon className={'h-6 w-6'} />, comments)}
|
||||
{Section(<LightBulbIcon className={'h-6 w-6'} />, answers)}
|
||||
{Section(<TrendingUpIcon className={'h-6 w-6'} />, updates)}
|
||||
{Section(<UserIcon className={'h-6 w-6'} />, yourMarkets)}
|
||||
<Row className={'gap-2 text-xl text-gray-700'}>
|
||||
<span>Balance Changes</span>
|
||||
</Row>
|
||||
{Section(<CurrencyDollarIcon className={'h-6 w-6'} />, bonuses)}
|
||||
{Section(<CashIcon className={'h-6 w-6'} />, otherBalances)}
|
||||
<Row className={'gap-2 text-xl text-gray-700'}>
|
||||
<span>General</span>
|
||||
</Row>
|
||||
{Section(<UsersIcon className={'h-6 w-6'} />, userInteractions)}
|
||||
{Section(<InboxInIcon className={'h-6 w-6'} />, generalOther)}
|
||||
<WatchMarketModal open={showWatchModal} setOpen={setShowWatchModal} />
|
||||
</Col>
|
||||
</div>
|
||||
)
|
||||
}
|
34
web/components/switch-setting.tsx
Normal file
34
web/components/switch-setting.tsx
Normal file
|
@ -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 (
|
||||
<Switch.Group as="div" className="flex items-center">
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className={clsx(
|
||||
checked ? 'bg-indigo-600' : 'bg-gray-200',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
checked ? 'translate-x-5' : 'translate-x-0',
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm font-medium text-gray-900">{label}</span>
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
20
web/lib/icons/bold-icon.tsx
Normal file
20
web/lib/icons/bold-icon.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
21
web/lib/icons/italic-icon.tsx
Normal file
21
web/lib/icons/italic-icon.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
20
web/lib/icons/link-icon.tsx
Normal file
20
web/lib/icons/link-icon.tsx
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<Page>
|
||||
<Col className="pm:mx-10 gap-4 px-4 pb-12">
|
||||
<Col className="pm:mx-10 gap-4 px-4 pb-6 pt-2">
|
||||
<Row className={'w-full items-center justify-between'}>
|
||||
<Title text="Edit your home page" />
|
||||
<DoneButton />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,33 +69,24 @@ 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
|
||||
const contractsCount = contracts.filter((c) => !c.isResolved).length
|
||||
|
||||
return {
|
||||
props: {
|
||||
contractsCount,
|
||||
group,
|
||||
members,
|
||||
memberIds,
|
||||
creator,
|
||||
traderScores,
|
||||
topTraders,
|
||||
creatorScores,
|
||||
topCreators,
|
||||
messages,
|
||||
aboutPost,
|
||||
|
@ -107,19 +96,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' }
|
||||
}
|
||||
|
@ -132,39 +108,25 @@ const groupSubpages = [
|
|||
] as const
|
||||
|
||||
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'
|
||||
}) {
|
||||
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 { creator, topTraders, topCreators, suggestedFilter } = props
|
||||
|
||||
const router = useRouter()
|
||||
const { slugs } = router.query as { slugs: string[] }
|
||||
|
@ -175,7 +137,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 +148,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 +185,7 @@ export default function GroupPage(props: {
|
|||
creator={creator}
|
||||
isCreator={!!isCreator}
|
||||
user={user}
|
||||
members={members}
|
||||
memberIds={memberIds}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
|
@ -233,7 +202,6 @@ export default function GroupPage(props: {
|
|||
|
||||
const tabs = [
|
||||
{
|
||||
badge: `${contractsCount}`,
|
||||
title: 'Markets',
|
||||
content: questionsTab,
|
||||
href: groupPath(group.slug, 'markets'),
|
||||
|
@ -312,9 +280,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 +301,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 +367,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 +534,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)
|
||||
|
|
|
@ -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'
|
||||
|
@ -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'
|
||||
|
@ -40,7 +41,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 +57,51 @@ const HIGHLIGHT_CLASS = 'bg-indigo-50'
|
|||
|
||||
export default function Notifications() {
|
||||
const privateUser = usePrivateUser()
|
||||
const router = useRouter()
|
||||
const [navigateToSection, setNavigateToSection] = useState<string>()
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (privateUser === null) Router.push('/')
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const query = { ...router.query }
|
||||
if (query.tab === 'settings') {
|
||||
setActiveIndex(1)
|
||||
}
|
||||
if (query.section) {
|
||||
setNavigateToSection(query.section as string)
|
||||
}
|
||||
}, [router.query])
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
|
||||
<Title text={'Notifications'} className={'hidden md:block'} />
|
||||
<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 +110,9 @@ export default function Notifications() {
|
|||
{
|
||||
title: 'Settings',
|
||||
content: (
|
||||
<div className={''}>
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
<NotificationSettings
|
||||
navigateToSection={navigateToSection}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
@ -128,16 +156,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',
|
||||
|
@ -146,6 +171,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 />
|
||||
|
||||
|
@ -992,18 +1030,20 @@ 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
|
||||
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 === '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 === '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 +1051,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':
|
||||
|
|
Loading…
Reference in New Issue
Block a user