Free comments (#88)

* Allow free comments with optional bets

* Send emails for comments without bets

* Refactor to share logic

* No free comments on free response questions

* Minor fixes

* Condense line
This commit is contained in:
Boa 2022-04-21 11:09:06 -06:00 committed by GitHub
parent 9ce82b1b6f
commit 7b70b9b3bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 207 additions and 59 deletions

View File

@ -3,7 +3,7 @@
export type Comment = {
id: string
contractId: string
betId: string
betId?: string
userId: string
text: string

View File

@ -1,5 +1,6 @@
# Secrets
.env*
.runtimeconfig.json
# Compiled JavaScript files
lib/**/*.js
@ -13,4 +14,4 @@ node_modules/
package-lock.json
ui-debug.log
firebase-debug.log
firebase-debug.log

View File

@ -167,7 +167,7 @@ export const sendNewCommentEmail = async (
commentCreator: User,
contract: Contract,
comment: Comment,
bet: Bet,
bet?: Bet,
answer?: Answer
) => {
const privateUser = await getPrivateUser(userId)
@ -186,8 +186,11 @@ export const sendNewCommentEmail = async (
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
const { text } = comment
const { amount, sale, outcome } = bet
let betDescription = `${sale ? 'sold' : 'bought'} M$ ${Math.round(amount)}`
let betDescription = ''
if (bet) {
const { amount, sale } = bet
betDescription = `${sale ? 'sold' : 'bought'} M$ ${Math.round(amount)}`
}
const subject = `Comment on ${question}`
const from = `${commentorName} <info@manifold.markets>`
@ -213,11 +216,12 @@ export const sendNewCommentEmail = async (
{ from }
)
} else {
betDescription = `${betDescription} of ${toDisplayResolution(
contract,
outcome
)}`
if (bet) {
betDescription = `${betDescription} of ${toDisplayResolution(
contract,
bet.outcome
)}`
}
await sendTemplateEmail(
privateUser.email,
subject,

View File

@ -6,6 +6,7 @@ import { getContract, getUser, getValues } from './utils'
import { Comment } from '../../common/comment'
import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
const firestore = admin.firestore()
@ -24,18 +25,22 @@ export const onCreateComment = functions.firestore
const commentCreator = await getUser(comment.userId)
if (!commentCreator) return
const betSnapshot = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.doc(comment.betId)
.get()
const bet = betSnapshot.data() as Bet
let bet: Bet | undefined
let answer: Answer | undefined
if (comment.betId) {
const betSnapshot = await firestore
.collection('contracts')
.doc(contractId)
.collection('bets')
.doc(comment.betId)
.get()
bet = betSnapshot.data() as Bet
const answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet.outcome)
: undefined
answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined
}
const comments = await getValues<Comment>(
firestore.collection('contracts').doc(contractId).collection('comments')

View File

@ -22,12 +22,19 @@ export type ActivityItem =
| AnswerGroupItem
| CloseItem
| ResolveItem
| CommentInputItem
type BaseActivityItem = {
id: string
contract: Contract
}
export type CommentInputItem = BaseActivityItem & {
type: 'commentInput'
bets: Bet[]
commentsByBetId: Record<string, Comment>
}
export type DescriptionItem = BaseActivityItem & {
type: 'description'
}
@ -48,7 +55,7 @@ export type BetItem = BaseActivityItem & {
export type CommentItem = BaseActivityItem & {
type: 'comment'
comment: Comment
bet: Bet
bet: Bet | undefined
hideOutcome: boolean
truncate: boolean
smallAvatar: boolean
@ -279,26 +286,54 @@ export function getAllContractActivityItems(
]
: [{ type: 'description', id: '0', contract }]
items.push(
...(outcomeType === 'FREE_RESPONSE'
? getAnswerGroups(
contract as FullContract<DPM, FreeResponse>,
bets,
comments,
user,
{
sortByProb: true,
abbreviated,
reversed,
}
)
: groupBets(bets, comments, contract, user?.id, {
hideOutcome: false,
if (outcomeType === 'FREE_RESPONSE') {
items.push(
...getAnswerGroups(
contract as FullContract<DPM, FreeResponse>,
bets,
comments,
user,
{
sortByProb: true,
abbreviated,
smallAvatar: false,
reversed: false,
}))
)
reversed,
}
)
)
} else {
const commentsWithoutBets = comments
.filter((comment) => !comment.betId)
.map((comment) => ({
type: 'comment' as const,
id: comment.id,
contract: contract,
comment,
bet: undefined,
truncate: false,
hideOutcome: true,
smallAvatar: false,
}))
const groupedBets = groupBets(bets, comments, contract, user?.id, {
hideOutcome: false,
abbreviated,
smallAvatar: false,
reversed: false,
})
// iterate through the bets and comment activity items and add them to the items in order of comment creation time:
const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets]
const sortedBetsAndComments = _.sortBy(unorderedBetsAndComments, (item) => {
if (item.type === 'comment') {
return item.comment.createdTime
} else if (item.type === 'bet') {
return item.bet.createdTime
} else if (item.type === 'betgroup') {
return item.bets[0].createdTime
}
})
items.push(...sortedBetsAndComments)
}
if (contract.closeTime && contract.closeTime <= Date.now()) {
items.push({ type: 'close', id: `${contract.closeTime}`, contract })
@ -307,6 +342,15 @@ export function getAllContractActivityItems(
items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract })
}
const commentsByBetId = mapCommentsByBetId(comments)
items.push({
type: 'commentInput',
id: 'commentInput',
bets,
commentsByBetId,
contract,
})
if (reversed) items.reverse()
return items

View File

@ -47,6 +47,7 @@ import { useSaveSeenContract } from '../../hooks/use-seen-contracts'
import { User } from '../../../common/user'
import { Modal } from '../layout/modal'
import { trackClick } from '../../lib/firebase/tracking'
import { firebaseLogin } from '../../lib/firebase/users'
import { DAY_MS } from '../../../common/util/time'
import NewContractBadge from '../new-contract-badge'
@ -107,24 +108,30 @@ function FeedItem(props: { item: ActivityItem }) {
return <FeedClose {...item} />
case 'resolve':
return <FeedResolve {...item} />
case 'commentInput':
return <CommentInput {...item} />
}
}
export function FeedComment(props: {
contract: Contract
comment: Comment
bet: Bet
bet: Bet | undefined
hideOutcome: boolean
truncate: boolean
smallAvatar: boolean
}) {
const { contract, comment, bet, hideOutcome, truncate, smallAvatar } = props
const { amount, outcome } = bet
let money: string | undefined
let outcome: string | undefined
let bought: string | undefined
if (bet) {
outcome = bet.outcome
bought = bet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(bet.amount))
}
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
const bought = amount >= 0 ? 'bought' : 'sold'
const money = formatMoney(Math.abs(amount))
return (
<>
<Avatar
@ -147,7 +154,7 @@ export function FeedComment(props: {
{' '}
of{' '}
<OutcomeLabel
outcome={outcome}
outcome={outcome ? outcome : ''}
contract={contract}
truncate="short"
/>
@ -177,6 +184,78 @@ function RelativeTimestamp(props: { time: number }) {
)
}
export function CommentInput(props: {
contract: Contract
commentsByBetId: Record<string, Comment>
bets: Bet[]
}) {
// see if we can comment input on any bet:
const { contract, bets, commentsByBetId } = props
const { outcomeType } = contract
const user = useUser()
const [comment, setComment] = useState('')
if (outcomeType === 'FREE_RESPONSE') {
return <div />
}
let canCommentOnABet = false
bets.some((bet) => {
// make sure there is not already a comment with a matching bet id:
const matchingComment = commentsByBetId[bet.id]
if (matchingComment) {
return false
}
const { createdTime, userId } = bet
canCommentOnABet = canCommentOnBet(userId, createdTime, user)
return canCommentOnABet
})
if (canCommentOnABet) return <div />
async function submitComment() {
if (!comment) return
if (!user) {
return await firebaseLogin()
}
await createComment(contract.id, comment, user)
setComment('')
}
return (
<>
<div>
<Avatar avatarUrl={user?.avatarUrl} username={user?.username} />
</div>
<div className={'min-w-0 flex-1 py-1.5'}>
<div className="text-sm text-gray-500">
<div className="mt-2">
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
placeholder="Add a comment..."
rows={3}
maxLength={MAX_COMMENT_LENGTH}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
submitComment()
}
}}
/>
<button
className="btn btn-outline btn-sm mt-1"
onClick={submitComment}
>
Comment
</button>
</div>
</div>
</div>
</>
)
}
export function FeedBet(props: {
contract: Contract
bet: Bet
@ -188,14 +267,12 @@ export function FeedBet(props: {
const { id, amount, outcome, createdTime, userId } = bet
const user = useUser()
const isSelf = user?.id === userId
// You can comment if your bet was posted in the last hour
const canComment = isSelf && Date.now() - createdTime < 60 * 60 * 1000
const canComment = canCommentOnBet(userId, createdTime, user)
const [comment, setComment] = useState('')
async function submitComment() {
if (!user || !comment || !canComment) return
await createComment(contract.id, id, comment, user)
await createComment(contract.id, comment, user, id)
}
const bought = amount >= 0 ? 'bought' : 'sold'
@ -378,6 +455,16 @@ export function FeedQuestion(props: {
)
}
function canCommentOnBet(
userId: string,
createdTime: number,
user?: User | null
) {
const isSelf = user?.id === userId
// You can comment if your bet was posted in the last hour
return isSelf && Date.now() - createdTime < 60 * 60 * 1000
}
function FeedDescription(props: { contract: Contract }) {
const { contract } = props
const { creatorName, creatorUsername } = contract

View File

@ -19,16 +19,16 @@ export const MAX_COMMENT_LENGTH = 10000
export async function createComment(
contractId: string,
betId: string,
text: string,
commenter: User
commenter: User,
betId?: string
) {
const ref = doc(getCommentsCollection(contractId), betId)
const ref = betId
? doc(getCommentsCollection(contractId), betId)
: doc(getCommentsCollection(contractId))
const comment: Comment = {
id: ref.id,
contractId,
betId,
userId: commenter.id,
text: text.slice(0, MAX_COMMENT_LENGTH),
createdTime: Date.now(),
@ -36,7 +36,9 @@ export async function createComment(
userUsername: commenter.username,
userAvatarUrl: commenter.avatarUrl,
}
if (betId) {
comment.betId = betId
}
return await setDoc(ref, comment)
}
@ -67,7 +69,9 @@ export function listenForComments(
export function mapCommentsByBetId(comments: Comment[]) {
const map: Record<string, Comment> = {}
for (const comment of comments) {
map[comment.betId] = comment
if (comment.betId) {
map[comment.betId] = comment
}
}
return map
}

View File

@ -250,7 +250,10 @@ function ContractTopTrades(props: {
const topBettor = useUserById(betsById[topBetId]?.userId)
// And also the commentId of the comment with the highest profit
const topCommentId = _.sortBy(comments, (c) => -profitById[c.betId])[0]?.id
const topCommentId = _.sortBy(
comments,
(c) => c.betId && -profitById[c.betId]
)[0]?.id
return (
<div className="mt-12 max-w-sm">