Enable tipping on group chats w/ notif (#629)

This commit is contained in:
Ian Philips 2022-07-07 17:23:13 -06:00 committed by GitHub
parent d6136a9937
commit b1b016f9e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 160 additions and 65 deletions

View File

@ -36,8 +36,9 @@ type Tip = {
toType: 'USER' toType: 'USER'
category: 'TIP' category: 'TIP'
data: { data: {
contractId: string
commentId: string commentId: string
contractId?: string
groupId?: string
} }
} }

View File

@ -14,6 +14,8 @@ import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { getContractBetMetrics } from '../../common/calculate' import { getContractBetMetrics } from '../../common/calculate'
import { removeUndefinedProps } from '../../common/util/object' import { removeUndefinedProps } from '../../common/util/object'
import { TipTxn } from '../../common/txn'
import { Group } from '../../common/group'
const firestore = admin.firestore() const firestore = admin.firestore()
type user_to_reason_texts = { type user_to_reason_texts = {
@ -285,15 +287,6 @@ export const createNotification = async (
isSeeOnHref: sourceSlug, isSeeOnHref: sourceSlug,
} }
} }
const notifyTippedUserOfNewTip = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'tip_received',
}
}
const getUsersToNotify = async () => { const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {} const userToReasonTexts: user_to_reason_texts = {}
@ -346,8 +339,6 @@ export const createNotification = async (
userToReasonTexts, userToReasonTexts,
sourceContract.creatorId sourceContract.creatorId
) )
} else if (sourceType === 'tip' && relatedUserId) {
await notifyTippedUserOfNewTip(userToReasonTexts, relatedUserId)
} }
return userToReasonTexts return userToReasonTexts
} }
@ -355,3 +346,39 @@ export const createNotification = async (
const userToReasonTexts = await getUsersToNotify() const userToReasonTexts = await getUsersToNotify()
await createUsersNotifications(userToReasonTexts) await createUsersNotifications(userToReasonTexts)
} }
export const createTipNotification = async (
fromUser: User,
toUser: User,
tip: TipTxn,
idempotencyKey: string,
commentId: string,
contract?: Contract,
group?: Group
) => {
const slug = group ? group.slug + `#${commentId}` : commentId
const notificationRef = firestore
.collection(`/users/${toUser.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: toUser.id,
reason: 'tip_received',
createdTime: Date.now(),
isSeen: false,
sourceId: tip.id,
sourceType: 'tip',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: tip.amount.toString(),
sourceContractCreatorUsername: contract?.creatorUsername,
sourceContractTitle: contract?.question,
sourceContractSlug: contract?.slug,
sourceSlug: slug,
sourceTitle: group?.name,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -1,7 +1,7 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { Txn } from 'common/txn' import { TipTxn, Txn } from 'common/txn'
import { getContract, getUser, log } from './utils' import { getContract, getGroup, getUser, log } from './utils'
import { createNotification } from './create-notification' import { createTipNotification } from './create-notification'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
@ -18,7 +18,7 @@ export const onCreateTxn = functions.firestore
} }
}) })
async function handleTipTxn(txn: Txn, eventId: string) { async function handleTipTxn(txn: TipTxn, eventId: string) {
// get user sending and receiving tip // get user sending and receiving tip
const [sender, receiver] = await Promise.all([ const [sender, receiver] = await Promise.all([
getUser(txn.fromId), getUser(txn.fromId),
@ -29,40 +29,53 @@ async function handleTipTxn(txn: Txn, eventId: string) {
return return
} }
if (!txn.data?.contractId || !txn.data?.commentId) { if (!txn.data?.commentId) {
log('No contractId or comment id in tip txn.data') log('No comment id in tip txn.data')
return return
} }
let contract = undefined
let group = undefined
let commentSnapshot = undefined
const contract = await getContract(txn.data.contractId) if (txn.data.contractId) {
if (!contract) { contract = await getContract(txn.data.contractId)
log('Could not find contract') if (!contract) {
return log('Could not find contract')
return
}
commentSnapshot = await firestore
.collection('contracts')
.doc(contract.id)
.collection('comments')
.doc(txn.data.commentId)
.get()
} else if (txn.data.groupId) {
group = await getGroup(txn.data.groupId)
if (!group) {
log('Could not find group')
return
}
commentSnapshot = await firestore
.collection('groups')
.doc(group.id)
.collection('comments')
.doc(txn.data.commentId)
.get()
} }
const commentSnapshot = await firestore if (!commentSnapshot || !commentSnapshot.exists) {
.collection('contracts')
.doc(contract.id)
.collection('comments')
.doc(txn.data.commentId)
.get()
if (!commentSnapshot.exists) {
log('Could not find comment') log('Could not find comment')
return return
} }
const comment = commentSnapshot.data() as Comment const comment = commentSnapshot.data() as Comment
await createNotification( await createTipNotification(
txn.id,
'tip',
'created',
sender, sender,
receiver,
txn,
eventId, eventId,
txn.amount.toString(), comment.id,
contract, contract,
'comment', group
receiver.id,
txn.data?.commentId,
comment.text
) )
} }

View File

@ -3,6 +3,7 @@ import * as admin from 'firebase-admin'
import { chunk } from 'lodash' import { chunk } from 'lodash'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { Group } from '../../common/group'
export const log = (...args: unknown[]) => { export const log = (...args: unknown[]) => {
console.log(`[${new Date().toISOString()}]`, ...args) console.log(`[${new Date().toISOString()}]`, ...args)
@ -66,6 +67,10 @@ export const getContract = (contractId: string) => {
return getDoc<Contract>('contracts', contractId) return getDoc<Contract>('contracts', contractId)
} }
export const getGroup = (groupId: string) => {
return getDoc<Group>('groups', groupId)
}
export const getUser = (userId: string) => { export const getUser = (userId: string) => {
return getDoc<User>('users', userId) return getDoc<User>('users', userId)
} }

View File

@ -18,13 +18,18 @@ import { UserLink } from 'web/components/user-page'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { Tipper } from 'web/components/tipper'
import { sum } from 'lodash'
import { formatMoney } from 'common/util/format'
export function GroupChat(props: { export function GroupChat(props: {
messages: Comment[] messages: Comment[]
user: User | null | undefined user: User | null | undefined
group: Group group: Group
tips: CommentTipMap
}) { }) {
const { messages, user, group } = props const { messages, user, group, tips } = props
const [messageText, setMessageText] = useState('') const [messageText, setMessageText] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [scrollToBottomRef, setScrollToBottomRef] = const [scrollToBottomRef, setScrollToBottomRef] =
@ -117,6 +122,7 @@ export function GroupChat(props: {
? setScrollToMessageRef ? setScrollToMessageRef
: undefined : undefined
} }
tips={tips[message.id] ?? {}}
/> />
))} ))}
{messages.length === 0 && ( {messages.length === 0 && (
@ -166,8 +172,9 @@ const GroupMessage = memo(function GroupMessage_(props: {
onReplyClick?: (comment: Comment) => void onReplyClick?: (comment: Comment) => void
setRef?: (ref: HTMLDivElement) => void setRef?: (ref: HTMLDivElement) => void
highlight?: boolean highlight?: boolean
tips: CommentTips
}) { }) {
const { comment, onReplyClick, group, setRef, highlight, user } = props const { comment, onReplyClick, group, setRef, highlight, user, tips } = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
const isCreatorsComment = user && comment.userId === user.id const isCreatorsComment = user && comment.userId === user.id
return ( return (
@ -209,16 +216,24 @@ const GroupMessage = memo(function GroupMessage_(props: {
shouldTruncate={false} shouldTruncate={false}
/> />
</Row> </Row>
{!isCreatorsComment && onReplyClick && ( <Row>
<button {!isCreatorsComment && onReplyClick && (
className={ <button
'self-start py-1 text-xs font-bold text-gray-500 hover:underline' className={
} 'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
onClick={() => onReplyClick(comment)} }
> onClick={() => onReplyClick(comment)}
Reply >
</button> Reply
)} </button>
)}
{isCreatorsComment && sum(Object.values(tips)) > 0 && (
<span className={'text-primary'}>
{formatMoney(sum(Object.values(tips)))}
</span>
)}
{!isCreatorsComment && <Tipper comment={comment} tips={tips} />}
</Row>
</Col> </Col>
) )
}) })

View File

@ -53,6 +53,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
data: { data: {
contractId: comment.contractId, contractId: comment.contractId,
commentId: comment.id, commentId: comment.id,
groupId: comment.groupId,
}, },
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`, description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
}) })
@ -60,6 +61,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
track('send comment tip', { track('send comment tip', {
contractId: comment.contractId, contractId: comment.contractId,
commentId: comment.id, commentId: comment.id,
groupId: comment.groupId,
amount: change, amount: change,
fromId: user.id, fromId: user.id,
toId: comment.userId, toId: comment.userId,

View File

@ -1,17 +1,25 @@
import { TipTxn } from 'common/txn' import { TipTxn } from 'common/txn'
import { groupBy, mapValues, sumBy } from 'lodash' import { groupBy, mapValues, sumBy } from 'lodash'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { listenForTipTxns } from 'web/lib/firebase/txns' import {
listenForTipTxns,
listenForTipTxnsOnGroup,
} from 'web/lib/firebase/txns'
export type CommentTips = { [userId: string]: number } export type CommentTips = { [userId: string]: number }
export type CommentTipMap = { [commentId: string]: CommentTips } export type CommentTipMap = { [commentId: string]: CommentTips }
export function useTipTxns(contractId: string): CommentTipMap { export function useTipTxns(on: {
contractId?: string
groupId?: string
}): CommentTipMap {
const [txns, setTxns] = useState<TipTxn[]>([]) const [txns, setTxns] = useState<TipTxn[]>([])
const { contractId, groupId } = on
useEffect(() => { useEffect(() => {
return listenForTipTxns(contractId, setTxns) if (contractId) return listenForTipTxns(contractId, setTxns)
}, [contractId, setTxns]) if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns)
}, [contractId, groupId, setTxns])
return useMemo(() => { return useMemo(() => {
const byComment = groupBy(txns, 'data.commentId') const byComment = groupBy(txns, 'data.commentId')

View File

@ -27,18 +27,31 @@ export function getAllCharityTxns() {
return getValues<DonationTxn>(charitiesQuery) return getValues<DonationTxn>(charitiesQuery)
} }
const getTipsQuery = (contractId: string) => const getTipsOnContractQuery = (contractId: string) =>
query( query(
txns, txns,
where('category', '==', 'TIP'), where('category', '==', 'TIP'),
where('data.contractId', '==', contractId) where('data.contractId', '==', contractId)
) )
const getTipsOnGroupQuery = (groupId: string) =>
query(
txns,
where('category', '==', 'TIP'),
where('data.groupId', '==', groupId)
)
export function listenForTipTxns( export function listenForTipTxns(
contractId: string, contractId: string,
setTxns: (txns: TipTxn[]) => void setTxns: (txns: TipTxn[]) => void
) { ) {
return listenForValues<TipTxn>(getTipsQuery(contractId), setTxns) return listenForValues<TipTxn>(getTipsOnContractQuery(contractId), setTxns)
}
export function listenForTipTxnsOnGroup(
groupId: string,
setTxns: (txns: TipTxn[]) => void
) {
return listenForValues<TipTxn>(getTipsOnGroupQuery(groupId), setTxns)
} }
// Find all manalink Txns that are from or to this user // Find all manalink Txns that are from or to this user

View File

@ -124,7 +124,7 @@ export function ContractPageContent(
// Sort for now to see if bug is fixed. // Sort for now to see if bug is fixed.
comments.sort((c1, c2) => c1.createdTime - c2.createdTime) comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
const tips = useTipTxns(contract.id) const tips = useTipTxns({ contractId: contract.id })
const user = useUser() const user = useUser()
const { width, height } = useWindowSize() const { width, height } = useWindowSize()

View File

@ -53,6 +53,7 @@ import { ContractSearch } from 'web/components/contract-search'
import clsx from 'clsx' import clsx from 'clsx'
import { FollowList } from 'web/components/follow-list' import { FollowList } from 'web/components/follow-list'
import { SearchIcon } from '@heroicons/react/outline' import { SearchIcon } from '@heroicons/react/outline'
import { useTipTxns } from 'web/hooks/use-tip-txns'
export const getStaticProps = fromPropz(getStaticPropz) export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) { export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -149,6 +150,7 @@ export default function GroupPage(props: {
const group = useGroup(props.group?.id) ?? props.group const group = useGroup(props.group?.id) ?? props.group
const [contracts, setContracts] = useState<Contract[] | undefined>(undefined) const [contracts, setContracts] = useState<Contract[] | undefined>(undefined)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const tips = useTipTxns({ groupId: group?.id })
const messages = useCommentsOnGroup(group?.id) const messages = useCommentsOnGroup(group?.id)
const debouncedQuery = debounce(setQuery, 50) const debouncedQuery = debounce(setQuery, 50)
@ -263,7 +265,12 @@ export default function GroupPage(props: {
{ {
title: 'Chat', title: 'Chat',
content: messages ? ( content: messages ? (
<GroupChat messages={messages} user={user} group={group} /> <GroupChat
messages={messages}
user={user}
group={group}
tips={tips}
/>
) : ( ) : (
<LoadingIndicator /> <LoadingIndicator />
), ),

View File

@ -384,7 +384,10 @@ function IncomeNotificationItem(props: {
</div> </div>
<span className={'flex truncate'}> <span className={'flex truncate'}>
{getReasonForShowingIncomeNotification(true)} {getReasonForShowingIncomeNotification(true)}
<QuestionLink notification={notification} ignoreClick={true} /> <QuestionOrGroupLink
notification={notification}
ignoreClick={true}
/>
</span> </span>
</div> </div>
</div> </div>
@ -425,7 +428,7 @@ function IncomeNotificationItem(props: {
/> />
))} ))}
{getReasonForShowingIncomeNotification(false)} {' on'} {getReasonForShowingIncomeNotification(false)} {' on'}
<QuestionLink notification={notification} /> <QuestionOrGroupLink notification={notification} />
</span> </span>
</div> </div>
</Row> </Row>
@ -481,7 +484,7 @@ function NotificationGroupItem(props: {
<div className={'flex w-full flex-row justify-between'}> <div className={'flex w-full flex-row justify-between'}>
<div className={'ml-2'}> <div className={'ml-2'}>
Activity on Activity on
<QuestionLink notification={notifications[0]} /> <QuestionOrGroupLink notification={notifications[0]} />
</div> </div>
<div className={'hidden sm:inline-block'}> <div className={'hidden sm:inline-block'}>
<RelativeTimestamp time={notifications[0].createdTime} /> <RelativeTimestamp time={notifications[0].createdTime} />
@ -666,7 +669,7 @@ function NotificationItem(props: {
{isChildOfGroup ? ( {isChildOfGroup ? (
<RelativeTimestamp time={notification.createdTime} /> <RelativeTimestamp time={notification.createdTime} />
) : ( ) : (
<QuestionLink notification={notification} /> <QuestionOrGroupLink notification={notification} />
)} )}
</div> </div>
</div> </div>
@ -705,7 +708,7 @@ export const setNotificationsAsSeen = (notifications: Notification[]) => {
return notifications return notifications
} }
function QuestionLink(props: { function QuestionOrGroupLink(props: {
notification: Notification notification: Notification
ignoreClick?: boolean ignoreClick?: boolean
}) { }) {
@ -733,7 +736,7 @@ function QuestionLink(props: {
href={ href={
sourceContractCreatorUsername sourceContractCreatorUsername
? `/${sourceContractCreatorUsername}/${sourceContractSlug}` ? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
: sourceType === 'group' && sourceSlug : (sourceType === 'group' || sourceType === 'tip') && sourceSlug
? `${groupPath(sourceSlug)}` ? `${groupPath(sourceSlug)}`
: '' : ''
} }
@ -771,8 +774,9 @@ function getSourceUrl(notification: Notification) {
sourceType === 'user' sourceType === 'user'
) )
return `/${sourceContractCreatorUsername}/${sourceContractSlug}` return `/${sourceContractCreatorUsername}/${sourceContractSlug}`
if (sourceType === 'tip') if (sourceType === 'tip' && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
if (sourceContractCreatorUsername && sourceContractSlug) if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '', sourceId ?? '',