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:
parent
9ce82b1b6f
commit
7b70b9b3bd
|
@ -3,7 +3,7 @@
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
id: string
|
id: string
|
||||||
contractId: string
|
contractId: string
|
||||||
betId: string
|
betId?: string
|
||||||
userId: string
|
userId: string
|
||||||
|
|
||||||
text: string
|
text: string
|
||||||
|
|
3
functions/.gitignore
vendored
3
functions/.gitignore
vendored
|
@ -1,5 +1,6 @@
|
||||||
# Secrets
|
# Secrets
|
||||||
.env*
|
.env*
|
||||||
|
.runtimeconfig.json
|
||||||
|
|
||||||
# Compiled JavaScript files
|
# Compiled JavaScript files
|
||||||
lib/**/*.js
|
lib/**/*.js
|
||||||
|
@ -13,4 +14,4 @@ node_modules/
|
||||||
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
ui-debug.log
|
ui-debug.log
|
||||||
firebase-debug.log
|
firebase-debug.log
|
||||||
|
|
|
@ -167,7 +167,7 @@ export const sendNewCommentEmail = async (
|
||||||
commentCreator: User,
|
commentCreator: User,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
comment: Comment,
|
comment: Comment,
|
||||||
bet: Bet,
|
bet?: Bet,
|
||||||
answer?: Answer
|
answer?: Answer
|
||||||
) => {
|
) => {
|
||||||
const privateUser = await getPrivateUser(userId)
|
const privateUser = await getPrivateUser(userId)
|
||||||
|
@ -186,8 +186,11 @@ export const sendNewCommentEmail = async (
|
||||||
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator
|
||||||
const { text } = comment
|
const { text } = comment
|
||||||
|
|
||||||
const { amount, sale, outcome } = bet
|
let betDescription = ''
|
||||||
let betDescription = `${sale ? 'sold' : 'bought'} M$ ${Math.round(amount)}`
|
if (bet) {
|
||||||
|
const { amount, sale } = bet
|
||||||
|
betDescription = `${sale ? 'sold' : 'bought'} M$ ${Math.round(amount)}`
|
||||||
|
}
|
||||||
|
|
||||||
const subject = `Comment on ${question}`
|
const subject = `Comment on ${question}`
|
||||||
const from = `${commentorName} <info@manifold.markets>`
|
const from = `${commentorName} <info@manifold.markets>`
|
||||||
|
@ -213,11 +216,12 @@ export const sendNewCommentEmail = async (
|
||||||
{ from }
|
{ from }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
betDescription = `${betDescription} of ${toDisplayResolution(
|
if (bet) {
|
||||||
contract,
|
betDescription = `${betDescription} of ${toDisplayResolution(
|
||||||
outcome
|
contract,
|
||||||
)}`
|
bet.outcome
|
||||||
|
)}`
|
||||||
|
}
|
||||||
await sendTemplateEmail(
|
await sendTemplateEmail(
|
||||||
privateUser.email,
|
privateUser.email,
|
||||||
subject,
|
subject,
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { getContract, getUser, getValues } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { sendNewCommentEmail } from './emails'
|
import { sendNewCommentEmail } from './emails'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
|
import { Answer } from '../../common/answer'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -24,18 +25,22 @@ export const onCreateComment = functions.firestore
|
||||||
const commentCreator = await getUser(comment.userId)
|
const commentCreator = await getUser(comment.userId)
|
||||||
if (!commentCreator) return
|
if (!commentCreator) return
|
||||||
|
|
||||||
const betSnapshot = await firestore
|
let bet: Bet | undefined
|
||||||
.collection('contracts')
|
let answer: Answer | undefined
|
||||||
.doc(contractId)
|
if (comment.betId) {
|
||||||
.collection('bets')
|
const betSnapshot = await firestore
|
||||||
.doc(comment.betId)
|
.collection('contracts')
|
||||||
.get()
|
.doc(contractId)
|
||||||
const bet = betSnapshot.data() as Bet
|
.collection('bets')
|
||||||
|
.doc(comment.betId)
|
||||||
|
.get()
|
||||||
|
bet = betSnapshot.data() as Bet
|
||||||
|
|
||||||
const answer =
|
answer =
|
||||||
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
|
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
|
||||||
? contract.answers.find((answer) => answer.id === bet.outcome)
|
? contract.answers.find((answer) => answer.id === bet?.outcome)
|
||||||
: undefined
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
const comments = await getValues<Comment>(
|
const comments = await getValues<Comment>(
|
||||||
firestore.collection('contracts').doc(contractId).collection('comments')
|
firestore.collection('contracts').doc(contractId).collection('comments')
|
||||||
|
|
|
@ -22,12 +22,19 @@ export type ActivityItem =
|
||||||
| AnswerGroupItem
|
| AnswerGroupItem
|
||||||
| CloseItem
|
| CloseItem
|
||||||
| ResolveItem
|
| ResolveItem
|
||||||
|
| CommentInputItem
|
||||||
|
|
||||||
type BaseActivityItem = {
|
type BaseActivityItem = {
|
||||||
id: string
|
id: string
|
||||||
contract: Contract
|
contract: Contract
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CommentInputItem = BaseActivityItem & {
|
||||||
|
type: 'commentInput'
|
||||||
|
bets: Bet[]
|
||||||
|
commentsByBetId: Record<string, Comment>
|
||||||
|
}
|
||||||
|
|
||||||
export type DescriptionItem = BaseActivityItem & {
|
export type DescriptionItem = BaseActivityItem & {
|
||||||
type: 'description'
|
type: 'description'
|
||||||
}
|
}
|
||||||
|
@ -48,7 +55,7 @@ export type BetItem = BaseActivityItem & {
|
||||||
export type CommentItem = BaseActivityItem & {
|
export type CommentItem = BaseActivityItem & {
|
||||||
type: 'comment'
|
type: 'comment'
|
||||||
comment: Comment
|
comment: Comment
|
||||||
bet: Bet
|
bet: Bet | undefined
|
||||||
hideOutcome: boolean
|
hideOutcome: boolean
|
||||||
truncate: boolean
|
truncate: boolean
|
||||||
smallAvatar: boolean
|
smallAvatar: boolean
|
||||||
|
@ -279,26 +286,54 @@ export function getAllContractActivityItems(
|
||||||
]
|
]
|
||||||
: [{ type: 'description', id: '0', contract }]
|
: [{ type: 'description', id: '0', contract }]
|
||||||
|
|
||||||
items.push(
|
if (outcomeType === 'FREE_RESPONSE') {
|
||||||
...(outcomeType === 'FREE_RESPONSE'
|
items.push(
|
||||||
? getAnswerGroups(
|
...getAnswerGroups(
|
||||||
contract as FullContract<DPM, FreeResponse>,
|
contract as FullContract<DPM, FreeResponse>,
|
||||||
bets,
|
bets,
|
||||||
comments,
|
comments,
|
||||||
user,
|
user,
|
||||||
{
|
{
|
||||||
sortByProb: true,
|
sortByProb: true,
|
||||||
abbreviated,
|
|
||||||
reversed,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
: groupBets(bets, comments, contract, user?.id, {
|
|
||||||
hideOutcome: false,
|
|
||||||
abbreviated,
|
abbreviated,
|
||||||
smallAvatar: false,
|
reversed,
|
||||||
reversed: false,
|
}
|
||||||
}))
|
)
|
||||||
)
|
)
|
||||||
|
} 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()) {
|
if (contract.closeTime && contract.closeTime <= Date.now()) {
|
||||||
items.push({ type: 'close', id: `${contract.closeTime}`, contract })
|
items.push({ type: 'close', id: `${contract.closeTime}`, contract })
|
||||||
|
@ -307,6 +342,15 @@ export function getAllContractActivityItems(
|
||||||
items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract })
|
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()
|
if (reversed) items.reverse()
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
|
@ -47,6 +47,7 @@ import { useSaveSeenContract } from '../../hooks/use-seen-contracts'
|
||||||
import { User } from '../../../common/user'
|
import { User } from '../../../common/user'
|
||||||
import { Modal } from '../layout/modal'
|
import { Modal } from '../layout/modal'
|
||||||
import { trackClick } from '../../lib/firebase/tracking'
|
import { trackClick } from '../../lib/firebase/tracking'
|
||||||
|
import { firebaseLogin } from '../../lib/firebase/users'
|
||||||
import { DAY_MS } from '../../../common/util/time'
|
import { DAY_MS } from '../../../common/util/time'
|
||||||
import NewContractBadge from '../new-contract-badge'
|
import NewContractBadge from '../new-contract-badge'
|
||||||
|
|
||||||
|
@ -107,24 +108,30 @@ function FeedItem(props: { item: ActivityItem }) {
|
||||||
return <FeedClose {...item} />
|
return <FeedClose {...item} />
|
||||||
case 'resolve':
|
case 'resolve':
|
||||||
return <FeedResolve {...item} />
|
return <FeedResolve {...item} />
|
||||||
|
case 'commentInput':
|
||||||
|
return <CommentInput {...item} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedComment(props: {
|
export function FeedComment(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
comment: Comment
|
comment: Comment
|
||||||
bet: Bet
|
bet: Bet | undefined
|
||||||
hideOutcome: boolean
|
hideOutcome: boolean
|
||||||
truncate: boolean
|
truncate: boolean
|
||||||
smallAvatar: boolean
|
smallAvatar: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, comment, bet, hideOutcome, truncate, smallAvatar } = props
|
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 { text, userUsername, userName, userAvatarUrl, createdTime } = comment
|
||||||
|
|
||||||
const bought = amount >= 0 ? 'bought' : 'sold'
|
|
||||||
const money = formatMoney(Math.abs(amount))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
@ -147,7 +154,7 @@ export function FeedComment(props: {
|
||||||
{' '}
|
{' '}
|
||||||
of{' '}
|
of{' '}
|
||||||
<OutcomeLabel
|
<OutcomeLabel
|
||||||
outcome={outcome}
|
outcome={outcome ? outcome : ''}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
truncate="short"
|
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: {
|
export function FeedBet(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bet: Bet
|
bet: Bet
|
||||||
|
@ -188,14 +267,12 @@ export function FeedBet(props: {
|
||||||
const { id, amount, outcome, createdTime, userId } = bet
|
const { id, amount, outcome, createdTime, userId } = bet
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const isSelf = user?.id === userId
|
const isSelf = user?.id === userId
|
||||||
|
const canComment = canCommentOnBet(userId, createdTime, user)
|
||||||
// You can comment if your bet was posted in the last hour
|
|
||||||
const canComment = isSelf && Date.now() - createdTime < 60 * 60 * 1000
|
|
||||||
|
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
async function submitComment() {
|
async function submitComment() {
|
||||||
if (!user || !comment || !canComment) return
|
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'
|
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 }) {
|
function FeedDescription(props: { contract: Contract }) {
|
||||||
const { contract } = props
|
const { contract } = props
|
||||||
const { creatorName, creatorUsername } = contract
|
const { creatorName, creatorUsername } = contract
|
||||||
|
|
|
@ -19,16 +19,16 @@ export const MAX_COMMENT_LENGTH = 10000
|
||||||
|
|
||||||
export async function createComment(
|
export async function createComment(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
betId: string,
|
|
||||||
text: 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 = {
|
const comment: Comment = {
|
||||||
id: ref.id,
|
id: ref.id,
|
||||||
contractId,
|
contractId,
|
||||||
betId,
|
|
||||||
userId: commenter.id,
|
userId: commenter.id,
|
||||||
text: text.slice(0, MAX_COMMENT_LENGTH),
|
text: text.slice(0, MAX_COMMENT_LENGTH),
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
|
@ -36,7 +36,9 @@ export async function createComment(
|
||||||
userUsername: commenter.username,
|
userUsername: commenter.username,
|
||||||
userAvatarUrl: commenter.avatarUrl,
|
userAvatarUrl: commenter.avatarUrl,
|
||||||
}
|
}
|
||||||
|
if (betId) {
|
||||||
|
comment.betId = betId
|
||||||
|
}
|
||||||
return await setDoc(ref, comment)
|
return await setDoc(ref, comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,7 +69,9 @@ export function listenForComments(
|
||||||
export function mapCommentsByBetId(comments: Comment[]) {
|
export function mapCommentsByBetId(comments: Comment[]) {
|
||||||
const map: Record<string, Comment> = {}
|
const map: Record<string, Comment> = {}
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
map[comment.betId] = comment
|
if (comment.betId) {
|
||||||
|
map[comment.betId] = comment
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
|
@ -250,7 +250,10 @@ function ContractTopTrades(props: {
|
||||||
const topBettor = useUserById(betsById[topBetId]?.userId)
|
const topBettor = useUserById(betsById[topBetId]?.userId)
|
||||||
|
|
||||||
// And also the commentId of the comment with the highest profit
|
// 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 (
|
return (
|
||||||
<div className="mt-12 max-w-sm">
|
<div className="mt-12 max-w-sm">
|
||||||
|
|
Loading…
Reference in New Issue
Block a user