diff --git a/common/comment.ts b/common/comment.ts
index cdb62fd3..71c04af4 100644
--- a/common/comment.ts
+++ b/common/comment.ts
@@ -18,6 +18,7 @@ export type Comment = {
userName: string
userUsername: string
userAvatarUrl?: string
+ bountiesAwarded?: number
} & T
export type OnContract = {
diff --git a/common/contract.ts b/common/contract.ts
index 248c9745..1255874d 100644
--- a/common/contract.ts
+++ b/common/contract.ts
@@ -62,6 +62,9 @@ export type Contract = {
featuredOnHomeRank?: number
likedByUserIds?: string[]
likedByUserCount?: number
+ flaggedByUsernames?: string[]
+ openCommentBounties?: number
+ unlistedById?: string
} & T
export type BinaryContract = Contract & Binary
diff --git a/common/economy.ts b/common/economy.ts
index 7ec52b30..d9a9433a 100644
--- a/common/economy.ts
+++ b/common/economy.ts
@@ -11,7 +11,8 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250
export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10
export const BETTING_STREAK_BONUS_AMOUNT =
- econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10
-export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50
+ econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5
+export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25
export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7
export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5
+export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250
diff --git a/common/envs/prod.ts b/common/envs/prod.ts
index d0469d84..38dd4feb 100644
--- a/common/envs/prod.ts
+++ b/common/envs/prod.ts
@@ -41,6 +41,7 @@ export type Economy = {
BETTING_STREAK_BONUS_MAX?: number
BETTING_STREAK_RESET_HOUR?: number
FREE_MARKETS_PER_USER_MAX?: number
+ COMMENT_BOUNTY_AMOUNT?: number
}
type FirebaseConfig = {
diff --git a/common/group.ts b/common/group.ts
index 5220a1e8..8f5728d3 100644
--- a/common/group.ts
+++ b/common/group.ts
@@ -23,6 +23,7 @@ export type Group = {
score: number
}[]
}
+ pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
}
export const MAX_GROUP_NAME_LENGTH = 75
diff --git a/common/like.ts b/common/like.ts
index 38b25dad..7ec14726 100644
--- a/common/like.ts
+++ b/common/like.ts
@@ -5,4 +5,4 @@ export type Like = {
createdTime: number
tipTxnId?: string // only holds most recent tip txn id
}
-export const LIKE_TIP_AMOUNT = 5
+export const LIKE_TIP_AMOUNT = 10
diff --git a/common/new-contract.ts b/common/new-contract.ts
index 431f435e..3580b164 100644
--- a/common/new-contract.ts
+++ b/common/new-contract.ts
@@ -12,7 +12,6 @@ import {
visibility,
} from './contract'
import { User } from './user'
-import { parseTags, richTextToString } from './util/parse'
import { removeUndefinedProps } from './util/object'
import { JSONContent } from '@tiptap/core'
@@ -38,15 +37,6 @@ export function getNewContract(
answers: string[],
visibility: visibility
) {
- const tags = parseTags(
- [
- question,
- richTextToString(description),
- ...extraTags.map((tag) => `#${tag}`),
- ].join(' ')
- )
- const lowercaseTags = tags.map((tag) => tag.toLowerCase())
-
const propsByOutcomeType =
outcomeType === 'BINARY'
? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante)
@@ -70,8 +60,8 @@ export function getNewContract(
question: question.trim(),
description,
- tags,
- lowercaseTags,
+ tags: [],
+ lowercaseTags: [],
visibility,
isResolved: false,
createdTime: Date.now(),
diff --git a/common/notification.ts b/common/notification.ts
index b42df541..b75e3d4a 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -96,6 +96,7 @@ type notification_descriptions = {
[key in notification_preference]: {
simple: string
detailed: string
+ necessary?: boolean
}
}
export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
@@ -116,8 +117,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: "Only answers by market creator on markets you're watching",
},
betting_streaks: {
- simple: 'For predictions made over consecutive days',
- detailed: 'Bonuses for predictions made over consecutive days',
+ simple: `For prediction streaks`,
+ detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`,
},
comments_by_followed_users_on_watched_markets: {
simple: 'Only comments by users you follow',
@@ -159,8 +160,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Large changes in probability on markets that you watch',
},
profit_loss_updates: {
- simple: 'Weekly profit and loss updates',
- detailed: 'Weekly profit and loss updates',
+ simple: 'Weekly portfolio updates',
+ detailed: 'Weekly portfolio updates',
},
referral_bonuses: {
simple: 'For referring new users',
@@ -208,8 +209,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
detailed: 'Bonuses for unique predictors on your markets',
},
your_contract_closed: {
- simple: 'Your market has closed and you need to resolve it',
- detailed: 'Your market has closed and you need to resolve it',
+ simple: 'Your market has closed and you need to resolve it (necessary)',
+ detailed: 'Your market has closed and you need to resolve it (necessary)',
+ necessary: true,
},
all_comments_on_watched_markets: {
simple: 'All new comments',
@@ -235,6 +237,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = {
simple: `Only on markets you're invested in`,
detailed: `Answers on markets that you're watching and that you're invested in`,
},
+ opt_out_all: {
+ simple: 'Opt out of all notifications (excludes when your markets close)',
+ detailed:
+ 'Opt out of all notifications excluding your own market closure notifications',
+ },
}
export type BettingStreakData = {
diff --git a/common/post.ts b/common/post.ts
index 13a90821..45503b22 100644
--- a/common/post.ts
+++ b/common/post.ts
@@ -12,7 +12,6 @@ export type Post = {
export type DateDoc = Post & {
bounty: number
birthday: number
- photoUrl: string
type: 'date-doc'
contractSlug: string
}
diff --git a/common/txn.ts b/common/txn.ts
index 2b7a32e8..c404059d 100644
--- a/common/txn.ts
+++ b/common/txn.ts
@@ -8,6 +8,7 @@ type AnyTxnType =
| UniqueBettorBonus
| BettingStreakBonus
| CancelUniqueBettorBonus
+ | CommentBountyRefund
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn = {
@@ -31,6 +32,8 @@ export type Txn = {
| 'UNIQUE_BETTOR_BONUS'
| 'BETTING_STREAK_BONUS'
| 'CANCEL_UNIQUE_BETTOR_BONUS'
+ | 'COMMENT_BOUNTY'
+ | 'REFUND_COMMENT_BOUNTY'
// Any extra data
data?: { [key: string]: any }
@@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = {
}
}
+type CommentBountyDeposit = {
+ fromType: 'USER'
+ toType: 'BANK'
+ category: 'COMMENT_BOUNTY'
+ data: {
+ contractId: string
+ }
+}
+
+type CommentBountyWithdrawal = {
+ fromType: 'BANK'
+ toType: 'USER'
+ category: 'COMMENT_BOUNTY'
+ data: {
+ contractId: string
+ commentId: string
+ }
+}
+
+type CommentBountyRefund = {
+ fromType: 'BANK'
+ toType: 'USER'
+ category: 'REFUND_COMMENT_BOUNTY'
+ data: {
+ contractId: string
+ }
+}
+
export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink
@@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral
export type BettingStreakBonusTxn = Txn & BettingStreakBonus
export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus
export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus
+export type CommentBountyDepositTxn = Txn & CommentBountyDeposit
+export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal
diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts
index 3fc0fb2f..ae199e77 100644
--- a/common/user-notification-preferences.ts
+++ b/common/user-notification-preferences.ts
@@ -53,6 +53,9 @@ export type notification_preferences = {
profit_loss_updates: notification_destination_types[]
onboarding_flow: notification_destination_types[]
thank_you_for_purchases: notification_destination_types[]
+
+ opt_out_all: notification_destination_types[]
+ // When adding a new notification preference, use add-new-notification-preference.ts to existing users
}
export const getDefaultNotificationPreferences = (
@@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = (
const email = noEmails ? undefined : emailIf ? 'email' : undefined
return filterDefined([browser, email]) as notification_destination_types[]
}
- return {
+ const defaults: notification_preferences = {
// Watched Markets
all_comments_on_watched_markets: constructPref(true, false),
all_answers_on_watched_markets: constructPref(true, false),
@@ -107,7 +110,7 @@ export const getDefaultNotificationPreferences = (
loan_income: constructPref(true, false),
betting_streaks: constructPref(true, false),
referral_bonuses: constructPref(true, true),
- unique_bettors_on_your_contract: constructPref(true, false),
+ unique_bettors_on_your_contract: constructPref(true, true),
tipped_comments_on_watched_markets: constructPref(true, true),
tips_on_your_markets: constructPref(true, true),
limit_order_fills: constructPref(true, false),
@@ -121,7 +124,10 @@ export const getDefaultNotificationPreferences = (
probability_updates_on_watched_markets: constructPref(true, false),
thank_you_for_purchases: constructPref(false, false),
onboarding_flow: constructPref(false, false),
- } as notification_preferences
+
+ opt_out_all: [],
+ }
+ return defaults
}
// Adding a new key:value here is optional, you can just use a key of notification_subscription_types
@@ -184,10 +190,18 @@ export const getNotificationDestinationsForUser = (
? notificationSettings[subscriptionType]
: []
}
+ const optOutOfAllSettings = notificationSettings['opt_out_all']
+ // Your market closure notifications are high priority, opt-out doesn't affect their delivery
+ const optedOutOfEmail =
+ optOutOfAllSettings.includes('email') &&
+ subscriptionType !== 'your_contract_closed'
+ const optedOutOfBrowser =
+ optOutOfAllSettings.includes('browser') &&
+ subscriptionType !== 'your_contract_closed'
const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
return {
- sendToEmail: destinations.includes('email'),
- sendToBrowser: destinations.includes('browser'),
+ sendToEmail: destinations.includes('email') && !optedOutOfEmail,
+ sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser,
unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`,
urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`,
}
diff --git a/common/user.ts b/common/user.ts
index b1365929..233fe4cc 100644
--- a/common/user.ts
+++ b/common/user.ts
@@ -33,6 +33,8 @@ export type User = {
allTime: number
}
+ fractionResolvedCorrectly: number
+
nextLoanCached: number
followerCountCached: number
diff --git a/common/util/format.ts b/common/util/format.ts
index 4f123535..fd3e7551 100644
--- a/common/util/format.ts
+++ b/common/util/format.ts
@@ -8,7 +8,14 @@ const formatter = new Intl.NumberFormat('en-US', {
})
export function formatMoney(amount: number) {
- const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case
+ const newAmount =
+ // handle -0 case
+ Math.round(amount) === 0
+ ? 0
+ : // Handle 499.9999999999999 case
+ (amount > 0 ? Math.floor : Math.ceil)(
+ amount + 0.00000000001 * Math.sign(amount)
+ )
return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '')
}
diff --git a/common/util/parse.ts b/common/util/parse.ts
index 0bbd5cd9..0c50e07d 100644
--- a/common/util/parse.ts
+++ b/common/util/parse.ts
@@ -1,4 +1,3 @@
-import { MAX_TAG_LENGTH } from '../contract'
import { generateText, JSONContent } from '@tiptap/core'
// Tiptap starter extensions
import { Blockquote } from '@tiptap/extension-blockquote'
@@ -24,7 +23,8 @@ 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'
+import { cloneDeep, uniq } from 'lodash'
+import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
export function getUrl(text: string) {
@@ -32,34 +32,6 @@ export function getUrl(text: string) {
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) =>
- match.trim().substring(1).substring(0, MAX_TAG_LENGTH)
- )
- const tagSet = new Set()
- const uniqueTags: string[] = []
- // Keep casing of last tag.
- matches.reverse()
- for (const tag of matches) {
- const lowercase = tag.toLowerCase()
- if (!tagSet.has(lowercase)) {
- tagSet.add(lowercase)
- uniqueTags.push(tag)
- }
- }
- uniqueTags.reverse()
- return uniqueTags
-}
-
-export function parseWordsAsTags(text: string) {
- const taggedText = text
- .split(/\s+/)
- .map((tag) => (tag.startsWith('#') ? tag : `#${tag}`))
- .join(' ')
- return parseTags(taggedText)
-}
-
// TODO: fuzzy matching
export const wordIn = (word: string, corpus: string) =>
corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase())
@@ -103,8 +75,22 @@ export const exhibitExts = [
Mention,
Iframe,
TiptapTweet,
+ TiptapSpoiler,
]
export function richTextToString(text?: JSONContent) {
- return !text ? '' : generateText(text, exhibitExts)
+ if (!text) return ''
+ // remove spoiler tags.
+ const newText = cloneDeep(text)
+ dfs(newText, (current) => {
+ if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) {
+ current.text = '[spoiler]'
+ }
+ })
+ return generateText(newText, exhibitExts)
+}
+
+const dfs = (data: JSONContent, f: (current: JSONContent) => any) => {
+ data.content?.forEach((d) => dfs(d, f))
+ f(data)
}
diff --git a/common/util/tiptap-spoiler.ts b/common/util/tiptap-spoiler.ts
new file mode 100644
index 00000000..c8944a46
--- /dev/null
+++ b/common/util/tiptap-spoiler.ts
@@ -0,0 +1,116 @@
+// adapted from @n8body/tiptap-spoiler
+
+import {
+ Mark,
+ markInputRule,
+ markPasteRule,
+ mergeAttributes,
+} from '@tiptap/core'
+import type { ElementType } from 'react'
+
+declare module '@tiptap/core' {
+ interface Commands {
+ spoilerEditor: {
+ setSpoiler: () => ReturnType
+ toggleSpoiler: () => ReturnType
+ unsetSpoiler: () => ReturnType
+ }
+ }
+}
+
+export type SpoilerOptions = {
+ HTMLAttributes: Record
+ spoilerOpenClass: string
+ spoilerCloseClass?: string
+ inputRegex: RegExp
+ pasteRegex: RegExp
+ as: ElementType
+}
+
+const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/
+const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g
+
+export const TiptapSpoiler = Mark.create({
+ name: 'spoiler',
+
+ inline: true,
+ group: 'inline',
+ inclusive: false,
+ exitable: true,
+ content: 'inline*',
+
+ priority: 1001, // higher priority than other formatting so they go inside
+
+ addOptions() {
+ return {
+ HTMLAttributes: { 'aria-label': 'spoiler' },
+ spoilerOpenClass: '',
+ spoilerCloseClass: undefined,
+ inputRegex: spoilerInputRegex,
+ pasteRegex: spoilerPasteRegex,
+ as: 'span',
+ editing: false,
+ }
+ },
+
+ addCommands() {
+ return {
+ setSpoiler:
+ () =>
+ ({ commands }) =>
+ commands.setMark(this.name),
+ toggleSpoiler:
+ () =>
+ ({ commands }) =>
+ commands.toggleMark(this.name),
+ unsetSpoiler:
+ () =>
+ ({ commands }) =>
+ commands.unsetMark(this.name),
+ }
+ },
+
+ addInputRules() {
+ return [
+ markInputRule({
+ find: this.options.inputRegex,
+ type: this.type,
+ }),
+ ]
+ },
+
+ addPasteRules() {
+ return [
+ markPasteRule({
+ find: this.options.pasteRegex,
+ type: this.type,
+ }),
+ ]
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: 'span',
+ getAttrs: (node) =>
+ (node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null,
+ },
+ ]
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ const elem = document.createElement(this.options.as as string)
+
+ Object.entries(
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass,
+ })
+ ).forEach(([attr, val]) => elem.setAttribute(attr, val))
+
+ elem.addEventListener('click', () => {
+ elem.setAttribute('class', this.options.spoilerOpenClass)
+ })
+
+ return elem
+ },
+})
diff --git a/docs/docs/api.md b/docs/docs/api.md
index 5fc95a4a..d25a18be 100644
--- a/docs/docs/api.md
+++ b/docs/docs/api.md
@@ -65,21 +65,21 @@ Requires no authorization.
Gets a group by its slug.
-Requires no authorization.
+Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID.
-Requires no authorization.
+Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
-Requires no authorization.
+Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/markets`
@@ -158,13 +158,16 @@ Requires no authorization.
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
url: string
- outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
+ outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC
mechanism: string // dpm-2 or cpmm-1
probability: number
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
+ min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value
+ max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value
+ isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability`
volume: number
volume7Days: number
@@ -408,7 +411,7 @@ Requires no authorization.
type FullMarket = LiteMarket & {
bets: Bet[]
comments: Comment[]
- answers?: Answer[]
+ answers?: Answer[] // dpm-2 markets only
description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json
textDescription: string // string description without formatting, images, or embeds
}
@@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user.
Parameters:
-- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`.
+- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_NUMERIC`.
- `question`: Required. The headline question for the market.
- `description`: Required. A long description describing the rules for the market.
- Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json).
@@ -569,6 +572,12 @@ For numeric markets, you must also provide:
- `min`: The minimum value that the market may resolve to.
- `max`: The maximum value that the market may resolve to.
+- `isLogScale`: If true, your numeric market will increase exponentially from min to max.
+- `initialValue`: An initial value for the market, between min and max, exclusive.
+
+For multiple choice markets, you must also provide:
+
+- `answers`: An array of strings, each of which will be a valid answer for the market.
Example request:
@@ -605,15 +614,18 @@ For binary markets:
- `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`.
- `probabilityInt`: Optional. The probability to use for `MKT` resolution.
-For free response markets:
+For free response or multiple choice markets:
- `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index.
-- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome.
+- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. Note that the total weights must add to 100.
For numeric markets:
- `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID.
- `value`: The value that the market may resolves to.
+- `probabilityInt`: Required if `value` is present. Should be equal to
+ - If log scale: `log10(value - min + 1) / log10(max - min + 1)`
+ - Otherwise: `(value - min) / (max - min)`
Example request:
@@ -757,6 +769,7 @@ Requires no authorization.
## Changelog
+- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`)
- 2022-07-15: Add user by username and user by ID APIs
- 2022-06-08: Add paging to markets endpoint
- 2022-06-05: Add new authorized write endpoints
diff --git a/firestore.rules b/firestore.rules
index 26649fa6..50f93e1f 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -102,7 +102,7 @@ service cloud.firestore {
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']);
allow update: if request.resource.data.diff(resource.data).affectedKeys()
- .hasOnly(['description', 'closeTime', 'question'])
+ .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById'])
&& resource.data.creatorId == request.auth.uid;
allow update: if isAdmin();
match /comments/{commentId} {
@@ -176,7 +176,7 @@ service cloud.firestore {
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
.affectedKeys()
- .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
+ .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} {
diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts
index 76dc1298..4b3f7446 100644
--- a/functions/src/create-group.ts
+++ b/functions/src/create-group.ts
@@ -62,6 +62,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
totalContracts: 0,
totalMembers: memberIds.length,
postIds: [],
+ pinnedItems: [],
}
await groupRef.create(group)
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index 038e0142..9bd73d05 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -1046,3 +1046,47 @@ export const createContractResolvedNotifications = async (
)
)
}
+
+export const createBountyNotification = async (
+ fromUser: User,
+ toUserId: string,
+ amount: number,
+ idempotencyKey: string,
+ contract: Contract,
+ commentId?: string
+) => {
+ const privateUser = await getPrivateUser(toUserId)
+ if (!privateUser) return
+ const { sendToBrowser } = getNotificationDestinationsForUser(
+ privateUser,
+ 'tip_received'
+ )
+ if (!sendToBrowser) return
+
+ const slug = commentId
+ const notificationRef = firestore
+ .collection(`/users/${toUserId}/notifications`)
+ .doc(idempotencyKey)
+ const notification: Notification = {
+ id: idempotencyKey,
+ userId: toUserId,
+ reason: 'tip_received',
+ createdTime: Date.now(),
+ isSeen: false,
+ sourceId: commentId ? commentId : contract.id,
+ sourceType: 'tip',
+ sourceUpdateType: 'created',
+ sourceUserName: fromUser.name,
+ sourceUserUsername: fromUser.username,
+ sourceUserAvatarUrl: fromUser.avatarUrl,
+ sourceText: amount.toString(),
+ sourceContractCreatorUsername: contract.creatorUsername,
+ sourceContractTitle: contract.question,
+ sourceContractSlug: contract.slug,
+ sourceSlug: slug,
+ sourceTitle: contract.question,
+ }
+ return await notificationRef.set(removeUndefinedProps(notification))
+
+ // maybe TODO: send email notification to comment creator
+}
diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts
index a342dc05..e9d6ae8f 100644
--- a/functions/src/create-post.ts
+++ b/functions/src/create-post.ts
@@ -42,7 +42,6 @@ const postSchema = z.object({
// Date doc fields:
bounty: z.number().optional(),
birthday: z.number().optional(),
- photoUrl: z.string().optional(),
type: z.string().optional(),
question: z.string().optional(),
})
@@ -76,6 +75,8 @@ export const createpost = newEndpoint({}, async (req, auth) => {
outcomeType: 'BINARY',
visibility: 'unlisted',
initialProb: 50,
+ // Dating group!
+ groupId: 'j3ZE8fkeqiKmRGumy3O1',
},
auth
)
diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts
index ab70b4e6..c3b7ba1d 100644
--- a/functions/src/create-user.ts
+++ b/functions/src/create-user.ts
@@ -69,6 +69,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
followerCountCached: 0,
followedCategories: DEFAULT_CATEGORIES,
shouldShowWelcome: true,
+ fractionResolvedCorrectly: 1,
}
await firestore.collection('users').doc(auth.uid).create(user)
diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html
index 4abd225e..b742c533 100644
--- a/functions/src/email-templates/market-close.html
+++ b/functions/src/email-templates/market-close.html
@@ -483,11 +483,7 @@
color: #999;
text-decoration: underline;
margin: 0;
- ">our Discord! Or,
- click here to unsubscribe from this type of notification.
+ ">our Discord!
diff --git a/functions/src/email-templates/weekly-portfolio-update.html b/functions/src/email-templates/weekly-portfolio-update.html
index fd99837f..921a58e5 100644
--- a/functions/src/email-templates/weekly-portfolio-update.html
+++ b/functions/src/email-templates/weekly-portfolio-update.html
@@ -320,7 +320,7 @@
style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;"
data-testid="4XoHRGw1Y">
- And here's some of the biggest changes in your portfolio:
+ And here's some recent changes in your investments:
diff --git a/functions/src/emails.ts b/functions/src/emails.ts
index dd91789a..993fac81 100644
--- a/functions/src/emails.ts
+++ b/functions/src/emails.ts
@@ -20,7 +20,7 @@ import { getNotificationDestinationsForUser } from '../../common/user-notificati
import {
PerContractInvestmentsData,
OverallPerformanceData,
-} from 'functions/src/weekly-portfolio-emails'
+} from './weekly-portfolio-emails'
export const sendMarketResolutionEmail = async (
reason: notification_reason_types,
@@ -643,8 +643,8 @@ export const sendWeeklyPortfolioUpdateEmail = async (
templateData[`question${i + 1}Title`] = investment.questionTitle
templateData[`question${i + 1}Url`] = investment.questionUrl
templateData[`question${i + 1}Prob`] = investment.questionProb
- templateData[`question${i + 1}Change`] = investment.questionChange
- templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle
+ templateData[`question${i + 1}Change`] = formatMoney(investment.profit)
+ templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle
})
await sendTemplateEmail(
diff --git a/functions/src/index.ts b/functions/src/index.ts
index 9a8ec232..f5c45004 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -52,6 +52,7 @@ export * from './unsubscribe'
export * from './stripe'
export * from './mana-bonus-email'
export * from './close-market'
+export * from './update-comment-bounty'
import { health } from './health'
import { transact } from './transact'
@@ -65,6 +66,7 @@ import { sellshares } from './sell-shares'
import { claimmanalink } from './claim-manalink'
import { createmarket } from './create-market'
import { addliquidity } from './add-liquidity'
+import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
import { withdrawliquidity } from './withdraw-liquidity'
import { creategroup } from './create-group'
import { resolvemarket } from './resolve-market'
@@ -91,6 +93,8 @@ const sellSharesFunction = toCloudFunction(sellshares)
const claimManalinkFunction = toCloudFunction(claimmanalink)
const createMarketFunction = toCloudFunction(createmarket)
const addLiquidityFunction = toCloudFunction(addliquidity)
+const addCommentBounty = toCloudFunction(addcommentbounty)
+const awardCommentBounty = toCloudFunction(awardcommentbounty)
const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity)
const createGroupFunction = toCloudFunction(creategroup)
const resolveMarketFunction = toCloudFunction(resolvemarket)
@@ -127,4 +131,6 @@ export {
acceptChallenge as acceptchallenge,
createPostFunction as createpost,
saveTwitchCredentials as savetwitchcredentials,
+ addCommentBounty as addcommentbounty,
+ awardCommentBounty as awardcommentbounty,
}
diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts
index 5e2a94c0..301d6286 100644
--- a/functions/src/on-update-contract.ts
+++ b/functions/src/on-update-contract.ts
@@ -7,38 +7,47 @@ export const onUpdateContract = functions.firestore
.document('contracts/{contractId}')
.onUpdate(async (change, context) => {
const contract = change.after.data() as Contract
+ const previousContract = change.before.data() as Contract
const { eventId } = context
-
- const contractUpdater = await getUser(contract.creatorId)
- if (!contractUpdater) throw new Error('Could not find contract updater')
-
- const previousValue = change.before.data() as Contract
-
- // Resolution is handled in resolve-market.ts
- if (!previousValue.isResolved && contract.isResolved) return
+ const { openCommentBounties, closeTime, question } = contract
if (
- previousValue.closeTime !== contract.closeTime ||
- previousValue.question !== contract.question
+ !previousContract.isResolved &&
+ contract.isResolved &&
+ (openCommentBounties ?? 0) > 0
) {
- let sourceText = ''
- if (
- previousValue.closeTime !== contract.closeTime &&
- contract.closeTime
- ) {
- sourceText = contract.closeTime.toString()
- } else if (previousValue.question !== contract.question) {
- sourceText = contract.question
- }
-
- await createCommentOrAnswerOrUpdatedContractNotification(
- contract.id,
- 'contract',
- 'updated',
- contractUpdater,
- eventId,
- sourceText,
- contract
- )
+ // No need to notify users of resolution, that's handled in resolve-market
+ return
+ }
+ if (
+ previousContract.closeTime !== closeTime ||
+ previousContract.question !== question
+ ) {
+ await handleUpdatedCloseTime(previousContract, contract, eventId)
}
})
+
+async function handleUpdatedCloseTime(
+ previousContract: Contract,
+ contract: Contract,
+ eventId: string
+) {
+ const contractUpdater = await getUser(contract.creatorId)
+ if (!contractUpdater) throw new Error('Could not find contract updater')
+ let sourceText = ''
+ if (previousContract.closeTime !== contract.closeTime && contract.closeTime) {
+ sourceText = contract.closeTime.toString()
+ } else if (previousContract.question !== contract.question) {
+ sourceText = contract.question
+ }
+
+ await createCommentOrAnswerOrUpdatedContractNotification(
+ contract.id,
+ 'contract',
+ 'updated',
+ contractUpdater,
+ eventId,
+ sourceText,
+ contract
+ )
+}
diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts
new file mode 100644
index 00000000..d7e7072b
--- /dev/null
+++ b/functions/src/scripts/add-new-notification-preference.ts
@@ -0,0 +1,27 @@
+import * as admin from 'firebase-admin'
+
+import { initAdmin } from './script-init'
+import { getAllPrivateUsers } from 'functions/src/utils'
+initAdmin()
+
+const firestore = admin.firestore()
+
+async function main() {
+ const privateUsers = await getAllPrivateUsers()
+ await Promise.all(
+ privateUsers.map((privateUser) => {
+ if (!privateUser.id) return Promise.resolve()
+ return firestore
+ .collection('private-users')
+ .doc(privateUser.id)
+ .update({
+ notificationPreferences: {
+ ...privateUser.notificationPreferences,
+ opt_out_all: [],
+ },
+ })
+ })
+ )
+}
+
+if (require.main === module) main().then(() => process.exit())
diff --git a/functions/src/scripts/contest/bulk-add-liquidity.ts b/functions/src/scripts/contest/bulk-add-liquidity.ts
index 99d5f12b..e29fb0a9 100644
--- a/functions/src/scripts/contest/bulk-add-liquidity.ts
+++ b/functions/src/scripts/contest/bulk-add-liquidity.ts
@@ -50,3 +50,5 @@ async function main() {
}
}
main()
+
+export {}
diff --git a/functions/src/scripts/contest/bulk-resolve-markets.ts b/functions/src/scripts/contest/bulk-resolve-markets.ts
new file mode 100644
index 00000000..8008db8b
--- /dev/null
+++ b/functions/src/scripts/contest/bulk-resolve-markets.ts
@@ -0,0 +1,65 @@
+// Run with `npx ts-node src/scripts/contest/resolve-markets.ts`
+
+const DOMAIN = 'dev.manifold.markets'
+// Dev API key for Cause Exploration Prizes (@CEP)
+const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf'
+const GROUP_SLUG = 'cart-contest'
+
+// Can just curl /v0/group/{slug} to get a group
+async function getGroupBySlug(slug: string) {
+ const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`)
+ return await resp.json()
+}
+
+async function getMarketsByGroupId(id: string) {
+ // API structure: /v0/group/by-id/[id]/markets
+ const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`)
+ return await resp.json()
+}
+
+/* Example curl request:
+# Resolve a binary market
+$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \
+ -H 'Content-Type: application/json' \
+ -H 'Authorization: Key {...}' \
+ --data-raw '{"outcome": "YES"}'
+*/
+async function resolveMarketById(
+ id: string,
+ outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL'
+) {
+ const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Key ${API_KEY}`,
+ },
+ body: JSON.stringify({
+ outcome,
+ }),
+ })
+ return await resp.json()
+}
+
+async function main() {
+ const group = await getGroupBySlug(GROUP_SLUG)
+ const markets = await getMarketsByGroupId(group.id)
+
+ // Count up some metrics
+ console.log('Number of markets', markets.length)
+ console.log(
+ 'Number of resolved markets',
+ markets.filter((m: any) => m.isResolved).length
+ )
+
+ // Resolve each market to NO
+ for (const market of markets) {
+ if (!market.isResolved) {
+ console.log(`Resolving market ${market.url} to NO`)
+ await resolveMarketById(market.id, 'NO')
+ }
+ }
+}
+main()
+
+export {}
diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts
index b2e4c4d8..e1330fe1 100644
--- a/functions/src/scripts/convert-tag-to-group.ts
+++ b/functions/src/scripts/convert-tag-to-group.ts
@@ -42,6 +42,7 @@ const createGroup = async (
totalContracts: contracts.length,
totalMembers: 1,
postIds: [],
+ pinnedItems: [],
}
await groupRef.create(group)
// create a GroupMemberDoc for the creator
diff --git a/functions/src/scripts/update-contract-tags.ts b/functions/src/scripts/update-contract-tags.ts
deleted file mode 100644
index 37a2b60a..00000000
--- a/functions/src/scripts/update-contract-tags.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import * as admin from 'firebase-admin'
-import { uniq } from 'lodash'
-
-import { initAdmin } from './script-init'
-initAdmin()
-
-import { Contract } from '../../../common/contract'
-import { parseTags } from '../../../common/util/parse'
-import { getValues } from '../utils'
-
-async function updateContractTags() {
- const firestore = admin.firestore()
- console.log('Updating contracts tags')
-
- const contracts = await getValues(firestore.collection('contracts'))
-
- console.log('Loaded', contracts.length, 'contracts')
-
- for (const contract of contracts) {
- const contractRef = firestore.doc(`contracts/${contract.id}`)
-
- const tags = uniq([
- ...parseTags(contract.question + contract.description),
- ...(contract.tags ?? []),
- ])
- const lowercaseTags = tags.map((tag) => tag.toLowerCase())
-
- console.log(
- 'Updating tags',
- contract.slug,
- 'from',
- contract.tags,
- 'to',
- tags
- )
-
- await contractRef.update({
- tags,
- lowercaseTags,
- } as Partial)
- }
-}
-
-if (require.main === module) {
- updateContractTags().then(() => process.exit())
-}
diff --git a/functions/src/serve.ts b/functions/src/serve.ts
index 99ac6281..d861dcbc 100644
--- a/functions/src/serve.ts
+++ b/functions/src/serve.ts
@@ -29,6 +29,7 @@ import { getcurrentuser } from './get-current-user'
import { createpost } from './create-post'
import { savetwitchcredentials } from './save-twitch-credentials'
import { testscheduledfunction } from './test-scheduled-function'
+import { addcommentbounty, awardcommentbounty } from './update-comment-bounty'
type Middleware = (req: Request, res: Response, next: NextFunction) => void
const app = express()
@@ -61,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares)
addJsonEndpointRoute('/claimmanalink', claimmanalink)
addJsonEndpointRoute('/createmarket', createmarket)
addJsonEndpointRoute('/addliquidity', addliquidity)
+addJsonEndpointRoute('/addCommentBounty', addcommentbounty)
+addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty)
addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity)
addJsonEndpointRoute('/creategroup', creategroup)
addJsonEndpointRoute('/resolvemarket', resolvemarket)
diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts
index 418282c7..57a6d183 100644
--- a/functions/src/unsubscribe.ts
+++ b/functions/src/unsubscribe.ts
@@ -4,6 +4,7 @@ import { getPrivateUser } from './utils'
import { PrivateUser } from '../../common/user'
import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification'
import { notification_preference } from '../../common/user-notification-preferences'
+import { getFunctionUrl } from '../../common/api'
export const unsubscribe: EndpointDefinition = {
opts: { method: 'GET', minInstances: 1 },
@@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = {
res.status(400).send('Invalid subscription type parameter.')
return
}
+ const optOutAllType: notification_preference = 'opt_out_all'
+ const wantsToOptOutAll = notificationSubscriptionType === optOutAllType
const user = await getPrivateUser(id)
@@ -37,17 +40,22 @@ export const unsubscribe: EndpointDefinition = {
const update: Partial = {
notificationPreferences: {
...user.notificationPreferences,
- [notificationSubscriptionType]: previousDestinations.filter(
- (destination) => destination !== 'email'
- ),
+ [notificationSubscriptionType]: wantsToOptOutAll
+ ? previousDestinations.push('email')
+ : previousDestinations.filter(
+ (destination) => destination !== 'email'
+ ),
},
}
await firestore.collection('private-users').doc(id).update(update)
+ const unsubscribeEndpoint = getFunctionUrl('unsubscribe')
- res.send(
- `
-
+ const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}`
+ if (wantsToOptOutAll) {
+ res.send(
+ `
+
@@ -163,19 +171,6 @@ export const unsubscribe: EndpointDefinition = {
-
-
-
- |
-
@@ -186,20 +181,9 @@ export const unsubscribe: EndpointDefinition = {
data-testid="4XoHRGw1Y">
- ${email} has been unsubscribed from email notifications related to:
+ ${email} has opted out of receiving unnecessary email notifications
-
-
- ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}.
-
-
-
-
- Click
- here
- to manage the rest of your notification settings.
-
|
@@ -219,9 +203,193 @@ export const unsubscribe: EndpointDefinition = {
+