From b1b016f9e0732f87c68b084e8b0763f1c0e35c0f Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 7 Jul 2022 17:23:13 -0600 Subject: [PATCH] Enable tipping on group chats w/ notif (#629) --- common/txn.ts | 3 +- functions/src/create-notification.ts | 49 ++++++++++++++----- functions/src/on-create-txn.ts | 65 +++++++++++++++---------- functions/src/utils.ts | 5 ++ web/components/groups/group-chat.tsx | 39 ++++++++++----- web/components/tipper.tsx | 2 + web/hooks/use-tip-txns.ts | 16 ++++-- web/lib/firebase/txns.ts | 17 ++++++- web/pages/[username]/[contractSlug].tsx | 2 +- web/pages/group/[...slugs]/index.tsx | 9 +++- web/pages/notifications.tsx | 18 ++++--- 11 files changed, 160 insertions(+), 65 deletions(-) diff --git a/common/txn.ts b/common/txn.ts index 53b08501..701b67fe 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -36,8 +36,9 @@ type Tip = { toType: 'USER' category: 'TIP' data: { - contractId: string commentId: string + contractId?: string + groupId?: string } } diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 49bff5f7..519720fd 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -14,6 +14,8 @@ import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' import { getContractBetMetrics } from '../../common/calculate' import { removeUndefinedProps } from '../../common/util/object' +import { TipTxn } from '../../common/txn' +import { Group } from '../../common/group' const firestore = admin.firestore() type user_to_reason_texts = { @@ -285,15 +287,6 @@ export const createNotification = async ( 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 userToReasonTexts: user_to_reason_texts = {} @@ -346,8 +339,6 @@ export const createNotification = async ( userToReasonTexts, sourceContract.creatorId ) - } else if (sourceType === 'tip' && relatedUserId) { - await notifyTippedUserOfNewTip(userToReasonTexts, relatedUserId) } return userToReasonTexts } @@ -355,3 +346,39 @@ export const createNotification = async ( const userToReasonTexts = await getUsersToNotify() 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)) +} diff --git a/functions/src/on-create-txn.ts b/functions/src/on-create-txn.ts index d877ecac..b915cfa1 100644 --- a/functions/src/on-create-txn.ts +++ b/functions/src/on-create-txn.ts @@ -1,7 +1,7 @@ import * as functions from 'firebase-functions' -import { Txn } from 'common/txn' -import { getContract, getUser, log } from './utils' -import { createNotification } from './create-notification' +import { TipTxn, Txn } from 'common/txn' +import { getContract, getGroup, getUser, log } from './utils' +import { createTipNotification } from './create-notification' import * as admin from 'firebase-admin' 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 const [sender, receiver] = await Promise.all([ getUser(txn.fromId), @@ -29,40 +29,53 @@ async function handleTipTxn(txn: Txn, eventId: string) { return } - if (!txn.data?.contractId || !txn.data?.commentId) { - log('No contractId or comment id in tip txn.data') + if (!txn.data?.commentId) { + log('No comment id in tip txn.data') return } + let contract = undefined + let group = undefined + let commentSnapshot = undefined - const contract = await getContract(txn.data.contractId) - if (!contract) { - log('Could not find contract') - return + if (txn.data.contractId) { + contract = await getContract(txn.data.contractId) + if (!contract) { + 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 - .collection('contracts') - .doc(contract.id) - .collection('comments') - .doc(txn.data.commentId) - .get() - if (!commentSnapshot.exists) { + if (!commentSnapshot || !commentSnapshot.exists) { log('Could not find comment') return } const comment = commentSnapshot.data() as Comment - await createNotification( - txn.id, - 'tip', - 'created', + await createTipNotification( sender, + receiver, + txn, eventId, - txn.amount.toString(), + comment.id, contract, - 'comment', - receiver.id, - txn.data?.commentId, - comment.text + group ) } diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 29f0db00..0414b01e 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' import { chunk } from 'lodash' import { Contract } from '../../common/contract' import { PrivateUser, User } from '../../common/user' +import { Group } from '../../common/group' export const log = (...args: unknown[]) => { console.log(`[${new Date().toISOString()}]`, ...args) @@ -66,6 +67,10 @@ export const getContract = (contractId: string) => { return getDoc('contracts', contractId) } +export const getGroup = (groupId: string) => { + return getDoc('groups', groupId) +} + export const getUser = (userId: string) => { return getDoc('users', userId) } diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 1298065d..6e82b05c 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -18,13 +18,18 @@ import { UserLink } from 'web/components/user-page' import { groupPath } from 'web/lib/firebase/groups' 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: { messages: Comment[] user: User | null | undefined group: Group + tips: CommentTipMap }) { - const { messages, user, group } = props + const { messages, user, group, tips } = props const [messageText, setMessageText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [scrollToBottomRef, setScrollToBottomRef] = @@ -117,6 +122,7 @@ export function GroupChat(props: { ? setScrollToMessageRef : undefined } + tips={tips[message.id] ?? {}} /> ))} {messages.length === 0 && ( @@ -166,8 +172,9 @@ const GroupMessage = memo(function GroupMessage_(props: { onReplyClick?: (comment: Comment) => void setRef?: (ref: HTMLDivElement) => void 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 isCreatorsComment = user && comment.userId === user.id return ( @@ -209,16 +216,24 @@ const GroupMessage = memo(function GroupMessage_(props: { shouldTruncate={false} /> - {!isCreatorsComment && onReplyClick && ( - - )} + + {!isCreatorsComment && onReplyClick && ( + + )} + {isCreatorsComment && sum(Object.values(tips)) > 0 && ( + + {formatMoney(sum(Object.values(tips)))} + + )} + {!isCreatorsComment && } + ) }) diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx index 64bad4eb..6f7dfbcb 100644 --- a/web/components/tipper.tsx +++ b/web/components/tipper.tsx @@ -53,6 +53,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) { data: { contractId: comment.contractId, commentId: comment.id, + groupId: comment.groupId, }, 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', { contractId: comment.contractId, commentId: comment.id, + groupId: comment.groupId, amount: change, fromId: user.id, toId: comment.userId, diff --git a/web/hooks/use-tip-txns.ts b/web/hooks/use-tip-txns.ts index 13ef3d34..50542402 100644 --- a/web/hooks/use-tip-txns.ts +++ b/web/hooks/use-tip-txns.ts @@ -1,17 +1,25 @@ import { TipTxn } from 'common/txn' import { groupBy, mapValues, sumBy } from 'lodash' 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 CommentTipMap = { [commentId: string]: CommentTips } -export function useTipTxns(contractId: string): CommentTipMap { +export function useTipTxns(on: { + contractId?: string + groupId?: string +}): CommentTipMap { const [txns, setTxns] = useState([]) + const { contractId, groupId } = on useEffect(() => { - return listenForTipTxns(contractId, setTxns) - }, [contractId, setTxns]) + if (contractId) return listenForTipTxns(contractId, setTxns) + if (groupId) return listenForTipTxnsOnGroup(groupId, setTxns) + }, [contractId, groupId, setTxns]) return useMemo(() => { const byComment = groupBy(txns, 'data.commentId') diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index 17e9a09b..88ab1352 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -27,18 +27,31 @@ export function getAllCharityTxns() { return getValues(charitiesQuery) } -const getTipsQuery = (contractId: string) => +const getTipsOnContractQuery = (contractId: string) => query( txns, where('category', '==', 'TIP'), where('data.contractId', '==', contractId) ) +const getTipsOnGroupQuery = (groupId: string) => + query( + txns, + where('category', '==', 'TIP'), + where('data.groupId', '==', groupId) + ) + export function listenForTipTxns( contractId: string, setTxns: (txns: TipTxn[]) => void ) { - return listenForValues(getTipsQuery(contractId), setTxns) + return listenForValues(getTipsOnContractQuery(contractId), setTxns) +} +export function listenForTipTxnsOnGroup( + groupId: string, + setTxns: (txns: TipTxn[]) => void +) { + return listenForValues(getTipsOnGroupQuery(groupId), setTxns) } // Find all manalink Txns that are from or to this user diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 2576c2e3..e33c116e 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -124,7 +124,7 @@ export function ContractPageContent( // Sort for now to see if bug is fixed. comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - const tips = useTipTxns(contract.id) + const tips = useTipTxns({ contractId: contract.id }) const user = useUser() const { width, height } = useWindowSize() diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 73d7819a..dec25ab1 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -53,6 +53,7 @@ import { ContractSearch } from 'web/components/contract-search' import clsx from 'clsx' import { FollowList } from 'web/components/follow-list' import { SearchIcon } from '@heroicons/react/outline' +import { useTipTxns } from 'web/hooks/use-tip-txns' export const getStaticProps = fromPropz(getStaticPropz) 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 [contracts, setContracts] = useState(undefined) const [query, setQuery] = useState('') + const tips = useTipTxns({ groupId: group?.id }) const messages = useCommentsOnGroup(group?.id) const debouncedQuery = debounce(setQuery, 50) @@ -263,7 +265,12 @@ export default function GroupPage(props: { { title: 'Chat', content: messages ? ( - + ) : ( ), diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 0924fbdd..3a8e4bc0 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -384,7 +384,10 @@ function IncomeNotificationItem(props: { {getReasonForShowingIncomeNotification(true)} - + @@ -425,7 +428,7 @@ function IncomeNotificationItem(props: { /> ))} {getReasonForShowingIncomeNotification(false)} {' on'} - + @@ -481,7 +484,7 @@ function NotificationGroupItem(props: {
Activity on - +
@@ -666,7 +669,7 @@ function NotificationItem(props: { {isChildOfGroup ? ( ) : ( - + )}
@@ -705,7 +708,7 @@ export const setNotificationsAsSeen = (notifications: Notification[]) => { return notifications } -function QuestionLink(props: { +function QuestionOrGroupLink(props: { notification: Notification ignoreClick?: boolean }) { @@ -733,7 +736,7 @@ function QuestionLink(props: { href={ sourceContractCreatorUsername ? `/${sourceContractCreatorUsername}/${sourceContractSlug}` - : sourceType === 'group' && sourceSlug + : (sourceType === 'group' || sourceType === 'tip') && sourceSlug ? `${groupPath(sourceSlug)}` : '' } @@ -771,8 +774,9 @@ function getSourceUrl(notification: Notification) { sourceType === 'user' ) return `/${sourceContractCreatorUsername}/${sourceContractSlug}` - if (sourceType === 'tip') + if (sourceType === 'tip' && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` + if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '',