From 899c6ab0e05442f02c60140bb17f355a4ee6d575 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Wed, 4 May 2022 11:07:00 -0700 Subject: [PATCH 1/3] Add script to denormalize avatars into other docs (#127) * Add script to denormalize avatars into contracts/comments * Also handle denormalizing answer avatar URLs * Small fixups --- .../src/scripts/denormalize-avatar-urls.ts | 125 ++++++++++++++++++ functions/src/scripts/denormalize.ts | 48 +++++++ 2 files changed, 173 insertions(+) create mode 100644 functions/src/scripts/denormalize-avatar-urls.ts create mode 100644 functions/src/scripts/denormalize.ts diff --git a/functions/src/scripts/denormalize-avatar-urls.ts b/functions/src/scripts/denormalize-avatar-urls.ts new file mode 100644 index 00000000..23b7dfc9 --- /dev/null +++ b/functions/src/scripts/denormalize-avatar-urls.ts @@ -0,0 +1,125 @@ +// Script for lining up users and contracts/comments to make sure the denormalized avatar URLs in the contracts and +// comments match the user avatar URLs. + +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { + DocumentCorrespondence, + findDiffs, + describeDiff, + applyDiff, +} from './denormalize' +import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' + +initAdmin() +const firestore = admin.firestore() + +async function getUsersById(transaction: Transaction) { + const results = new Map() + const users = await transaction.get(firestore.collection('users')) + users.forEach((doc) => { + results.set(doc.get('id'), doc) + }) + console.log(`Found ${results.size} unique users.`) + return results +} + +async function getContractsByUserId(transaction: Transaction) { + let n = 0 + const results = new Map() + const contracts = await transaction.get(firestore.collection('contracts')) + contracts.forEach((doc) => { + const creatorId = doc.get('creatorId') + const creatorContracts = results.get(creatorId) || [] + creatorContracts.push(doc) + results.set(creatorId, creatorContracts) + n++ + }) + console.log(`Found ${n} contracts from ${results.size} unique users.`) + return results +} + +async function getCommentsByUserId(transaction: Transaction) { + let n = 0 + const results = new Map() + const comments = await transaction.get(firestore.collectionGroup('comments')) + comments.forEach((doc) => { + const userId = doc.get('userId') + const userComments = results.get(userId) || [] + userComments.push(doc) + results.set(userId, userComments) + n++ + }) + console.log(`Found ${n} comments from ${results.size} unique users.`) + return results +} + +async function getAnswersByUserId(transaction: Transaction) { + let n = 0 + const results = new Map() + const answers = await transaction.get(firestore.collectionGroup('answers')) + answers.forEach((doc) => { + const userId = doc.get('userId') + const userAnswers = results.get(userId) || [] + userAnswers.push(doc) + results.set(userId, userAnswers) + n++ + }) + console.log(`Found ${n} answers from ${results.size} unique users.`) + return results +} + +if (require.main === module) { + admin.firestore().runTransaction(async (transaction) => { + const [usersById, contractsByUserId, commentsByUserId, answersByUserId] = + await Promise.all([ + getUsersById(transaction), + getContractsByUserId(transaction), + getCommentsByUserId(transaction), + getAnswersByUserId(transaction), + ]) + + const usersContracts = Array.from( + usersById.entries(), + ([id, doc]): DocumentCorrespondence => { + return [doc, contractsByUserId.get(id) || []] + } + ) + const contractDiffs = findDiffs( + usersContracts, + 'avatarUrl', + 'creatorAvatarUrl' + ) + console.log(`Found ${contractDiffs.length} contracts with mismatches.`) + contractDiffs.forEach((d) => { + console.log(describeDiff(d)) + applyDiff(transaction, d) + }) + + const usersComments = Array.from( + usersById.entries(), + ([id, doc]): DocumentCorrespondence => { + return [doc, commentsByUserId.get(id) || []] + } + ) + const commentDiffs = findDiffs(usersComments, 'avatarUrl', 'userAvatarUrl') + console.log(`Found ${commentDiffs.length} comments with mismatches.`) + commentDiffs.forEach((d) => { + console.log(describeDiff(d)) + applyDiff(transaction, d) + }) + + const usersAnswers = Array.from( + usersById.entries(), + ([id, doc]): DocumentCorrespondence => { + return [doc, answersByUserId.get(id) || []] + } + ) + const answerDiffs = findDiffs(usersAnswers, 'avatarUrl', 'avatarUrl') + console.log(`Found ${answerDiffs.length} answers with mismatches.`) + answerDiffs.forEach((d) => { + console.log(describeDiff(d)) + applyDiff(transaction, d) + }) + }) +} diff --git a/functions/src/scripts/denormalize.ts b/functions/src/scripts/denormalize.ts new file mode 100644 index 00000000..ca4111b0 --- /dev/null +++ b/functions/src/scripts/denormalize.ts @@ -0,0 +1,48 @@ +// Helper functions for maintaining the relationship between fields in one set of documents and denormalized copies in +// another set of documents. + +import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' + +export type DocumentValue = { + doc: DocumentSnapshot + field: string + val: any +} +export type DocumentCorrespondence = [DocumentSnapshot, DocumentSnapshot[]] +export type DocumentDiff = { + src: DocumentValue + dest: DocumentValue +} + +export function findDiffs( + docs: DocumentCorrespondence[], + srcPath: string, + destPath: string +) { + const diffs: DocumentDiff[] = [] + for (let [srcDoc, destDocs] of docs) { + const srcVal = srcDoc.get(srcPath) + for (let destDoc of destDocs) { + const destVal = destDoc.get(destPath) + if (destVal !== srcVal) { + diffs.push({ + src: { doc: srcDoc, field: srcPath, val: srcVal }, + dest: { doc: destDoc, field: destPath, val: destVal }, + }) + } + } + } + return diffs +} + +export function describeDiff(diff: DocumentDiff) { + function describeDocVal(x: DocumentValue): string { + return `${x.doc.ref.path}.${x.field}: ${x.val}` + } + return `${describeDocVal(diff.src)} -> ${describeDocVal(diff.dest)}` +} + +export function applyDiff(transaction: Transaction, diff: DocumentDiff) { + const { src, dest } = diff + transaction.update(dest.doc.ref, dest.field, src.val) +} From bf8e09b6c1f2c5d1d9dcf672f210bba6d7a0a2e4 Mon Sep 17 00:00:00 2001 From: Marshall Polaris Date: Wed, 4 May 2022 11:07:22 -0700 Subject: [PATCH 2/3] Represent DB avatar URLs as non-null (#128) --- common/answer.ts | 2 +- common/comment.ts | 2 +- common/contract.ts | 2 +- common/user.ts | 2 +- web/components/SEO.tsx | 9 ++------- web/pages/api/v0/_types.ts | 2 +- 6 files changed, 7 insertions(+), 12 deletions(-) diff --git a/common/answer.ts b/common/answer.ts index 9dcc3828..87e7d05f 100644 --- a/common/answer.ts +++ b/common/answer.ts @@ -9,7 +9,7 @@ export type Answer = { userId: string username: string name: string - avatarUrl?: string + avatarUrl: string text: string } diff --git a/common/comment.ts b/common/comment.ts index 15cfbcb5..95c2ec4a 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -13,5 +13,5 @@ export type Comment = { // Denormalized, for rendering comments userName: string userUsername: string - userAvatarUrl?: string + userAvatarUrl: string } diff --git a/common/contract.ts b/common/contract.ts index 82a330b5..ee3034de 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -11,7 +11,7 @@ export type FullContract< creatorId: string creatorName: string creatorUsername: string - creatorAvatarUrl?: string // Start requiring after 2022-03-01 + creatorAvatarUrl: string question: string description: string // More info about what the contract is about diff --git a/common/user.ts b/common/user.ts index 8f8e6d0d..ce586774 100644 --- a/common/user.ts +++ b/common/user.ts @@ -4,7 +4,7 @@ export type User = { name: string username: string - avatarUrl?: string + avatarUrl: string // For their user page bio?: string diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 8987d671..8420b199 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -6,7 +6,7 @@ export type OgCardProps = { metadata: string creatorName: string creatorUsername: string - creatorAvatarUrl?: string + creatorAvatarUrl: string } function buildCardUrl(props: OgCardProps) { @@ -14,11 +14,6 @@ function buildCardUrl(props: OgCardProps) { props.probability === undefined ? '' : `&probability=${encodeURIComponent(props.probability ?? '')}` - const creatorAvatarUrlParam = - props.creatorAvatarUrl === undefined - ? '' - : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` - // URL encode each of the props, then add them as query params return ( `https://manifold-og-image.vercel.app/m.png` + @@ -26,7 +21,7 @@ function buildCardUrl(props: OgCardProps) { probabilityParam + `&metadata=${encodeURIComponent(props.metadata)}` + `&creatorName=${encodeURIComponent(props.creatorName)}` + - creatorAvatarUrlParam + + `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl)}` + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` ) } diff --git a/web/pages/api/v0/_types.ts b/web/pages/api/v0/_types.ts index 8f2976fa..4569576c 100644 --- a/web/pages/api/v0/_types.ts +++ b/web/pages/api/v0/_types.ts @@ -12,7 +12,7 @@ export type LiteMarket = { creatorUsername: string creatorName: string createdTime: number - creatorAvatarUrl?: string + creatorAvatarUrl: string // Market attributes. All times are in milliseconds since epoch closeTime?: number From 9480f9f34c19d82d02e5c9003475e9a029c44847 Mon Sep 17 00:00:00 2001 From: Boa Date: Wed, 4 May 2022 16:03:06 -0600 Subject: [PATCH 3/3] Improve free response answer ux (#131) * Remove number from chosen FR answer * Distinguish wining and losing FR answers * Show no answers text * Simplify get answer items logic * Show answer number * Show answer # when resolving --- web/components/answers/answer-item.tsx | 2 +- web/components/answers/answers-panel.tsx | 29 ++++++++++++------------ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/web/components/answers/answer-item.tsx b/web/components/answers/answer-item.tsx index fdeafea0..96746b62 100644 --- a/web/components/answers/answer-item.tsx +++ b/web/components/answers/answer-item.tsx @@ -68,7 +68,7 @@ export function AnswerItem(props: { {/* TODO: Show total pool? */} -
#{number}
+
{showChoice && '#' + number}
diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 3af2c286..f315b514 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -24,7 +24,7 @@ export function AnswersPanel(props: { const { creatorId, resolution, resolutions, totalBets } = contract const answers = useAnswers(contract.id) ?? contract.answers - const [winningAnswers, otherAnswers] = _.partition( + const [winningAnswers, losingAnswers] = _.partition( answers.filter( (answer) => answer.id !== '0' && totalBets[answer.id] > 0.000000001 ), @@ -36,7 +36,7 @@ export function AnswersPanel(props: { resolutions ? -1 * resolutions[answer.id] : 0 ), ..._.sortBy( - resolution ? [] : otherAnswers, + resolution ? [] : losingAnswers, (answer) => -1 * getDpmOutcomeProbability(contract.totalShares, answer.id) ), ] @@ -52,7 +52,11 @@ export function AnswersPanel(props: { const chosenTotal = _.sum(Object.values(chosenAnswers)) - const answerItems = getAnswers(contract, user) + const answerItems = getAnswerItems( + contract, + losingAnswers.length > 0 ? losingAnswers : sortedAnswers, + user + ) const onChoose = (answerId: string, prob: number) => { if (resolveOption === 'CHOOSE') { @@ -89,9 +93,7 @@ export function AnswersPanel(props: { return ( - {(resolveOption === 'CHOOSE' || - resolveOption === 'CHOOSE_MULTIPLE' || - resolution === 'MKT') && + {(resolveOption || resolution) && sortedAnswers.map((answer) => ( ))} - {sortedAnswers.length === 0 && ( -
No answers yet...
- )} - - {!resolveOption && sortedAnswers.length > 0 && ( + {!resolveOption && ( )} + {answers.length <= 1 && ( +
No answers yet...
+ )} + {tradingAllowed(contract) && (!resolveOption || resolveOption === 'CANCEL') && ( @@ -138,12 +140,11 @@ export function AnswersPanel(props: { ) } -function getAnswers( +function getAnswerItems( contract: FullContract, + answers: Answer[], user: User | undefined | null ) { - const { answers } = contract - let outcomes = _.uniq( answers.map((answer) => answer.number.toString()) ).filter((outcome) => getOutcomeProbability(contract, outcome) > 0.0001)