Show avatars of tippers and unique bettors (#837)

* Show avatars of tippers and unique bettors

* Make transparent the avatar bg

* fix import
This commit is contained in:
Ian Philips 2022-09-01 16:23:12 -06:00 committed by GitHub
parent 7508d86c73
commit 00ba3b0c48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 162 additions and 146 deletions

View File

@ -15,6 +15,7 @@ export type Notification = {
sourceUserUsername?: string sourceUserUsername?: string
sourceUserAvatarUrl?: string sourceUserAvatarUrl?: string
sourceText?: string sourceText?: string
data?: string
sourceContractTitle?: string sourceContractTitle?: string
sourceContractCreatorUsername?: string sourceContractCreatorUsername?: string

View File

@ -151,15 +151,6 @@ export const createNotification = async (
} }
} }
const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
userToReasonTexts[userId] = {
reason: 'unique_bettors_on_your_contract',
}
}
const userToReasonTexts: user_to_reason_texts = {} const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place. // The following functions modify the userToReasonTexts object in place.
@ -192,16 +183,6 @@ export const createNotification = async (
sourceContract sourceContract
) { ) {
await notifyContractCreator(userToReasonTexts, sourceContract) await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (
sourceType === 'bonus' &&
sourceUpdateType === 'created' &&
sourceContract
) {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
} }
await createUsersNotifications(userToReasonTexts) await createUsersNotifications(userToReasonTexts)
@ -737,3 +718,38 @@ export async function filterUserIdsForOnlyFollowerIds(
) )
return userIds.filter((id) => contractFollowersIds.includes(id)) return userIds.filter((id) => contractFollowersIds.includes(id))
} }
export const createUniqueBettorBonusNotification = async (
contractCreatorId: string,
bettor: User,
txnId: string,
contract: Contract,
amount: number,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: contractCreatorId,
reason: 'unique_bettors_on_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: txnId,
sourceType: 'bonus',
sourceUpdateType: 'created',
sourceUserName: bettor.name,
sourceUserUsername: bettor.username,
sourceUserAvatarUrl: bettor.avatarUrl,
sourceText: amount.toString(),
sourceSlug: contract.slug,
sourceTitle: contract.question,
// Perhaps not necessary, but just in case
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceContractTitle: contract.question,
sourceContractCreatorUsername: contract.creatorUsername,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -7,7 +7,7 @@ import { getUser, getValues, isProd, log } from './utils'
import { import {
createBetFillNotification, createBetFillNotification,
createBettingStreakBonusNotification, createBettingStreakBonusNotification,
createNotification, createUniqueBettorBonusNotification,
} from './create-notification' } from './create-notification'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -54,11 +54,11 @@ export const onCreateBet = functions.firestore
log(`Could not find contract ${contractId}`) log(`Could not find contract ${contractId}`)
return return
} }
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
const bettor = await getUser(bet.userId) const bettor = await getUser(bet.userId)
if (!bettor) return if (!bettor) return
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
await notifyFills(bet, contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId) await updateBettingStreak(bettor, bet, contract, eventId)
@ -126,7 +126,7 @@ const updateBettingStreak = async (
const updateUniqueBettorsAndGiveCreatorBonus = async ( const updateUniqueBettorsAndGiveCreatorBonus = async (
contract: Contract, contract: Contract,
eventId: string, eventId: string,
bettorId: string bettor: User
) => { ) => {
let previousUniqueBettorIds = contract.uniqueBettorIds let previousUniqueBettorIds = contract.uniqueBettorIds
@ -147,13 +147,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
) )
} }
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors // Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) { if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`) log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await firestore.collection(`contracts`).doc(contract.id).update({ await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds, uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length, uniqueBettorCount: newUniqueBettorIds.length,
@ -161,7 +161,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
} }
// No need to give a bonus for the creator's bet // No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettorId == contract.creatorId) return if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
// Create combined txn for all new unique bettors // Create combined txn for all new unique bettors
const bonusTxnDetails = { const bonusTxnDetails = {
@ -192,18 +192,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
} else { } else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
await createNotification( await createUniqueBettorBonusNotification(
contract.creatorId,
bettor,
result.txn.id, result.txn.id,
'bonus', contract,
'created', result.txn.amount,
fromUser, eventId + '-unique-bettor-bonus'
eventId + '-bonus',
result.txn.amount + '',
{
contract,
slug: contract.slug,
title: contract.question,
}
) )
} }
} }

View File

@ -1,4 +1,4 @@
import { ReactNode } from 'react' import { MouseEventHandler, ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
@ -14,7 +14,7 @@ export type ColorType =
export function Button(props: { export function Button(props: {
className?: string className?: string
onClick?: () => void onClick?: MouseEventHandler<any> | undefined
children?: ReactNode children?: ReactNode
size?: SizeType size?: SizeType
color?: ColorType color?: ColorType

View File

@ -0,0 +1,74 @@
import { useState } from 'react'
import { Row } from 'web/components/layout/row'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { formatMoney } from 'common/util/format'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-link'
import { Button } from 'web/components/button'
export type MultiUserLinkInfo = {
name: string
username: string
avatarUrl: string | undefined
amount: number
}
export function MultiUserTransactionLink(props: {
userInfos: MultiUserLinkInfo[]
modalLabel: string
}) {
const { userInfos, modalLabel } = props
const [open, setOpen] = useState(false)
const maxShowCount = 5
return (
<Row>
<Button
size={'xs'}
color={'gray-white'}
className={'z-10 mr-1 gap-1 bg-transparent'}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
<Row className={'gap-1'}>
{userInfos.map((userInfo, index) =>
index < maxShowCount ? (
<Row key={userInfo.username + 'shortened'}>
<Avatar
username={userInfo.username}
size={'sm'}
avatarUrl={userInfo.avatarUrl}
noLink={userInfos.length > 1}
/>
</Row>
) : (
<span>& {userInfos.length - maxShowCount} more</span>
)
)}
</Row>
</Button>
<Modal open={open} setOpen={setOpen} size={'sm'}>
<Col className="items-start gap-4 rounded-md bg-white p-6">
<span className={'text-xl'}>{modalLabel}</span>
{userInfos.map((userInfo) => (
<Row
key={userInfo.username + 'list'}
className="w-full items-center gap-2"
>
<span className="text-primary min-w-[3.5rem]">
+{formatMoney(userInfo.amount)}
</span>
<Avatar
username={userInfo.username}
avatarUrl={userInfo.avatarUrl}
/>
<UserLink name={userInfo.name} username={userInfo.username} />
</Row>
))}
</Col>
</Modal>
</Row>
)
}

View File

@ -1,13 +1,7 @@
import { linkClass, SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import clsx from 'clsx' import clsx from 'clsx'
import { Row } from 'web/components/layout/row'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { useState } from 'react'
import { Avatar } from 'web/components/avatar'
import { formatMoney } from 'common/util/format'
function shortenName(name: string) { export function shortenName(name: string) {
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const maxLength = 11 const maxLength = 11
const shortName = const shortName =
@ -38,63 +32,3 @@ export function UserLink(props: {
</SiteLink> </SiteLink>
) )
} }
export type MultiUserLinkInfo = {
name: string
username: string
avatarUrl: string | undefined
amountTipped: number
}
export function MultiUserTipLink(props: {
userInfos: MultiUserLinkInfo[]
className?: string
}) {
const { userInfos, className } = props
const [open, setOpen] = useState(false)
const maxShowCount = 2
return (
<>
<Row
className={clsx('mr-1 inline-flex gap-1', linkClass, className)}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
{userInfos.map((userInfo, index) =>
index < maxShowCount ? (
<span key={userInfo.username + 'shortened'} className={linkClass}>
{shortenName(userInfo.name) +
(index < maxShowCount - 1 ? ', ' : '')}
</span>
) : (
<span className={linkClass}>
& {userInfos.length - maxShowCount} more
</span>
)
)}
</Row>
<Modal open={open} setOpen={setOpen} size={'sm'}>
<Col className="items-start gap-4 rounded-md bg-white p-6">
<span className={'text-xl'}>Who tipped you</span>
{userInfos.map((userInfo) => (
<Row
key={userInfo.username + 'list'}
className="w-full items-center gap-2"
>
<span className="text-primary min-w-[3.5rem]">
+{formatMoney(userInfo.amountTipped)}
</span>
<Avatar
username={userInfo.username}
avatarUrl={userInfo.avatarUrl}
/>
<UserLink name={userInfo.name} username={userInfo.username} />
</Row>
))}
</Col>
</Modal>
</>
)
}

View File

@ -43,12 +43,13 @@ import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings' import { NotificationSettings } from 'web/components/NotificationSettings'
import { SEO } from 'web/components/SEO' import { SEO } from 'web/components/SEO'
import { usePrivateUser, useUser } from 'web/hooks/use-user' import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { import { UserLink } from 'web/components/user-link'
MultiUserTipLink,
MultiUserLinkInfo,
UserLink,
} from 'web/components/user-link'
import { LoadingIndicator } from 'web/components/loading-indicator' import { LoadingIndicator } from 'web/components/loading-indicator'
import {
MultiUserLinkInfo,
MultiUserTransactionLink,
} from 'web/components/multi-user-transaction-link'
import { Col } from 'web/components/layout/col'
export const NOTIFICATIONS_PER_PAGE = 30 export const NOTIFICATIONS_PER_PAGE = 30
const HIGHLIGHT_CLASS = 'bg-indigo-50' const HIGHLIGHT_CLASS = 'bg-indigo-50'
@ -212,7 +213,7 @@ function IncomeNotificationGroupItem(props: {
function combineNotificationsByAddingNumericSourceTexts( function combineNotificationsByAddingNumericSourceTexts(
notifications: Notification[] notifications: Notification[]
) { ) {
const newNotifications = [] const newNotifications: Notification[] = []
const groupedNotificationsBySourceType = groupBy( const groupedNotificationsBySourceType = groupBy(
notifications, notifications,
(n) => n.sourceType (n) => n.sourceType
@ -228,10 +229,7 @@ function IncomeNotificationGroupItem(props: {
for (const sourceTitle in groupedNotificationsBySourceTitle) { for (const sourceTitle in groupedNotificationsBySourceTitle) {
const notificationsForSourceTitle = const notificationsForSourceTitle =
groupedNotificationsBySourceTitle[sourceTitle] groupedNotificationsBySourceTitle[sourceTitle]
if (notificationsForSourceTitle.length === 1) {
newNotifications.push(notificationsForSourceTitle[0])
continue
}
let sum = 0 let sum = 0
notificationsForSourceTitle.forEach( notificationsForSourceTitle.forEach(
(notification) => (notification) =>
@ -251,7 +249,7 @@ function IncomeNotificationGroupItem(props: {
username: notification.sourceUserUsername, username: notification.sourceUserUsername,
name: notification.sourceUserName, name: notification.sourceUserName,
avatarUrl: notification.sourceUserAvatarUrl, avatarUrl: notification.sourceUserAvatarUrl,
amountTipped: thisSum, amount: thisSum,
} as MultiUserLinkInfo } as MultiUserLinkInfo
}), }),
(n) => n.username (n) => n.username
@ -260,10 +258,8 @@ function IncomeNotificationGroupItem(props: {
const newNotification = { const newNotification = {
...notificationsForSourceTitle[0], ...notificationsForSourceTitle[0],
sourceText: sum.toString(), sourceText: sum.toString(),
sourceUserUsername: sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername,
uniqueUsers.length > 1 data: JSON.stringify(uniqueUsers),
? JSON.stringify(uniqueUsers)
: notificationsForSourceTitle[0].sourceType,
} }
newNotifications.push(newNotification) newNotifications.push(newNotification)
} }
@ -372,12 +368,15 @@ function IncomeNotificationItem(props: {
justSummary?: boolean justSummary?: boolean
}) { }) {
const { notification, justSummary } = props const { notification, justSummary } = props
const { sourceType, sourceUserName, sourceUserUsername, sourceText } = const { sourceType, sourceUserUsername, sourceText, data } = notification
notification
const [highlighted] = useState(!notification.isSeen) const [highlighted] = useState(!notification.isSeen)
const { width } = useWindowSize() const { width } = useWindowSize()
const isMobile = (width && width < 768) || false const isMobile = (width && width < 768) || false
const user = useUser() const user = useUser()
const isTip = sourceType === 'tip' || sourceType === 'tip_and_like'
const isUniqueBettorBonus = sourceType === 'bonus'
const userLinks: MultiUserLinkInfo[] =
isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : []
useEffect(() => { useEffect(() => {
setNotificationsAsSeen([notification]) setNotificationsAsSeen([notification])
@ -505,29 +504,26 @@ function IncomeNotificationItem(props: {
href={getIncomeSourceUrl() ?? ''} href={getIncomeSourceUrl() ?? ''}
className={'absolute left-0 right-0 top-0 bottom-0 z-0'} className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
/> />
<Row className={'items-center text-gray-500 sm:justify-start'}> <Col className={'justify-start text-gray-500'}>
<div className={'line-clamp-2 flex max-w-xl shrink '}> {(isTip || isUniqueBettorBonus) && (
<div className={'inline'}> <MultiUserTransactionLink
<span className={'mr-1'}>{incomeNotificationLabel()}</span> userInfos={userLinks}
</div> modalLabel={isTip ? 'Who tipped you' : 'Unique bettors'}
<span> />
{(sourceType === 'tip' || sourceType === 'tip_and_like') && )}
(sourceUserUsername?.includes(',') ? ( <Row className={'line-clamp-2 flex max-w-xl'}>
<MultiUserTipLink <span>{incomeNotificationLabel()}</span>
userInfos={JSON.parse(sourceUserUsername)} <span className={'mx-1'}>
/> {isTip &&
) : ( (userLinks.length > 1
<UserLink ? 'Multiple users'
name={sourceUserName || ''} : userLinks.length > 0
username={sourceUserUsername || ''} ? userLinks[0].name
className={'mr-1 flex-shrink-0'} : '')}
short={true}
/>
))}
{reasonAndLink(false)}
</span> </span>
</div> <span>{reasonAndLink(false)}</span>
</Row> </Row>
</Col>
<div className={'border-b border-gray-300 pt-4'} /> <div className={'border-b border-gray-300 pt-4'} />
</div> </div>
</div> </div>