Order groups by most recent chat activity (#650)

* Order groups by most recent chat activity

* Use group chat slug constant

* Match source slug and isSeenOnHref

* Listen for group member changes
This commit is contained in:
Ian Philips 2022-07-14 16:46:45 -06:00 committed by GitHub
parent be64bf71a7
commit 6e1aa4b0f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 125 additions and 59 deletions

View File

@ -11,8 +11,11 @@ export type Group = {
contractIds: string[] contractIds: string[]
chatDisabled?: boolean chatDisabled?: boolean
mostRecentChatActivityTime?: number
mostRecentContractAddedTime?: number
} }
export const MAX_GROUP_NAME_LENGTH = 75 export const MAX_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140 export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60 export const MAX_ID_LENGTH = 60
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome'] export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']
export const GROUP_CHAT_SLUG = 'chat'

View File

@ -15,11 +15,11 @@ 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 { TipTxn } from '../../common/txn'
import { Group } from '../../common/group' import { Group, GROUP_CHAT_SLUG } from '../../common/group'
const firestore = admin.firestore() const firestore = admin.firestore()
type user_to_reason_texts = { type user_to_reason_texts = {
[userId: string]: { reason: notification_reason_types; isSeeOnHref?: string } [userId: string]: { reason: notification_reason_types }
} }
export const createNotification = async ( export const createNotification = async (
@ -72,7 +72,6 @@ export const createNotification = async (
sourceContractSlug: sourceContract?.slug, sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
isSeenOnHref: userToReasonTexts[userId].isSeeOnHref,
} }
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(removeUndefinedProps(notification))
}) })
@ -277,17 +276,6 @@ export const createNotification = async (
} }
} }
const notifyOtherGroupMembersOfComment = async (
userToReasons: user_to_reason_texts,
userId: string
) => {
if (shouldGetNotification(userId, userToReasons))
userToReasons[userId] = {
reason: 'on_group_you_are_member_of',
isSeeOnHref: sourceSlug,
}
}
const getUsersToNotify = async () => { const getUsersToNotify = async () => {
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.
@ -298,8 +286,6 @@ export const createNotification = async (
await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) await notifyUserAddedToGroup(userToReasonTexts, relatedUserId)
} else if (sourceType === 'user' && relatedUserId) { } else if (sourceType === 'user' && relatedUserId) {
await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId)
} else if (sourceType === 'comment' && !sourceContract && relatedUserId) {
await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId)
} }
// The following functions need sourceContract to be defined. // The following functions need sourceContract to be defined.
@ -417,3 +403,34 @@ export const createBetFillNotification = async (
} }
return await notificationRef.set(removeUndefinedProps(notification)) return await notificationRef.set(removeUndefinedProps(notification))
} }
export const createGroupCommentNotification = async (
fromUser: User,
toUserId: string,
comment: Comment,
group: Group,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${toUserId}/notifications`)
.doc(idempotencyKey)
const sourceSlug = `/group/${group.slug}/${GROUP_CHAT_SLUG}`
const notification: Notification = {
id: idempotencyKey,
userId: toUserId,
reason: 'on_group_you_are_member_of',
createdTime: Date.now(),
isSeen: false,
sourceId: comment.id,
sourceType: 'comment',
sourceUpdateType: 'created',
sourceUserName: fromUser.name,
sourceUserUsername: fromUser.username,
sourceUserAvatarUrl: fromUser.avatarUrl,
sourceText: comment.text,
sourceSlug,
sourceTitle: `${group.name}`,
isSeenOnHref: sourceSlug,
}
await notificationRef.set(removeUndefinedProps(notification))
}

View File

@ -3,7 +3,7 @@ import { Comment } from '../../common/comment'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Group } from '../../common/group' import { Group } from '../../common/group'
import { User } from '../../common/user' import { User } from '../../common/user'
import { createNotification } from './create-notification' import { createGroupCommentNotification } from './create-notification'
const firestore = admin.firestore() const firestore = admin.firestore()
export const onCreateCommentOnGroup = functions.firestore export const onCreateCommentOnGroup = functions.firestore
@ -29,23 +29,17 @@ export const onCreateCommentOnGroup = functions.firestore
const group = groupSnapshot.data() as Group const group = groupSnapshot.data() as Group
await firestore.collection('groups').doc(groupId).update({ await firestore.collection('groups').doc(groupId).update({
mostRecentActivityTime: comment.createdTime, mostRecentChatActivityTime: comment.createdTime,
}) })
await Promise.all( await Promise.all(
group.memberIds.map(async (memberId) => { group.memberIds.map(async (memberId) => {
return await createNotification( return await createGroupCommentNotification(
comment.id,
'comment',
'created',
creatorSnapshot.data() as User, creatorSnapshot.data() as User,
eventId,
comment.text,
undefined,
undefined,
memberId, memberId,
`/group/${group.slug}`, comment,
`${group.name}` group,
eventId
) )
}) })
) )

View File

@ -12,7 +12,15 @@ export const onUpdateGroup = functions.firestore
// ignore the update we just made // ignore the update we just made
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return return
// TODO: create notification with isSeeOnHref set to the group's /group/questions url
if (prevGroup.contractIds.length < group.contractIds.length) {
await firestore
.collection('groups')
.doc(group.id)
.update({ mostRecentContractAddedTime: Date.now() })
//TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url
// but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two
}
await firestore await firestore
.collection('groups') .collection('groups')

View File

@ -17,7 +17,9 @@ import toast from 'react-hot-toast'
export function GroupsButton(props: { user: User }) { export function GroupsButton(props: { user: User }) {
const { user } = props const { user } = props
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const groups = useMemberGroups(user.id) const groups = useMemberGroups(user.id, undefined, {
by: 'mostRecentChatActivityTime',
})
return ( return (
<> <>

View File

@ -24,7 +24,7 @@ import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group' import { useMemberGroups } from 'web/hooks/use-group'
import { groupPath } from 'web/lib/firebase/groups' import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics' import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group } from 'common/group' import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Spacer } from '../layout/spacer' import { Spacer } from '../layout/spacer'
import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications'
import { setNotificationsAsSeen } from 'web/pages/notifications' import { setNotificationsAsSeen } from 'web/pages/notifications'
@ -194,10 +194,14 @@ export default function Sidebar(props: { className?: string }) {
? signedOutMobileNavigation ? signedOutMobileNavigation
: signedInMobileNavigation : signedInMobileNavigation
const memberItems = ( const memberItems = (
useMemberGroups(user?.id, { withChatEnabled: true }) ?? [] useMemberGroups(
user?.id,
{ withChatEnabled: true },
{ by: 'mostRecentChatActivityTime' }
) ?? []
).map((group: Group) => ({ ).map((group: Group) => ({
name: group.name, name: group.name,
href: groupPath(group.slug), href: `${groupPath(group.slug)}/${GROUP_CHAT_SLUG}`,
})) }))
return ( return (
@ -278,8 +282,16 @@ function GroupsList(props: {
// Set notification as seen if our current page is equal to the isSeenOnHref property // Set notification as seen if our current page is equal to the isSeenOnHref property
useEffect(() => { useEffect(() => {
const currentPageGroupSlug = currentPage.split('/')[2]
preferredNotifications.forEach((notification) => { preferredNotifications.forEach((notification) => {
if (notification.isSeenOnHref === currentPage) { if (
notification.isSeenOnHref === currentPage ||
// Old chat style group chat notif ended just with the group slug
notification.isSeenOnHref?.endsWith(currentPageGroupSlug) ||
// They're on the home page, so if they've a chat notif, they're seeing the chat
(notification.isSeenOnHref?.endsWith(GROUP_CHAT_SLUG) &&
currentPage.endsWith(currentPageGroupSlug))
) {
setNotificationsAsSeen([notification]) setNotificationsAsSeen([notification])
} }
}) })

View File

@ -38,6 +38,7 @@ import { GroupsButton } from 'web/components/groups/groups-button'
import { PortfolioValueSection } from './portfolio/portfolio-value-section' import { PortfolioValueSection } from './portfolio/portfolio-value-section'
import { filterDefined } from 'common/util/array' import { filterDefined } from 'common/util/array'
import { useUserBets } from 'web/hooks/use-user-bets' import { useUserBets } from 'web/hooks/use-user-bets'
import { ReferralsButton } from 'web/components/referrals-button'
export function UserLink(props: { export function UserLink(props: {
name: string name: string
@ -202,7 +203,9 @@ export function UserPage(props: {
<Row className="gap-4"> <Row className="gap-4">
<FollowingButton user={user} /> <FollowingButton user={user} />
<FollowersButton user={user} /> <FollowersButton user={user} />
{/* <ReferralsButton user={user} currentUser={currentUser} /> */} {currentUser?.username === 'Ian' && (
<ReferralsButton user={user} currentUser={currentUser} />
)}
<GroupsButton user={user} /> <GroupsButton user={user} />
</Row> </Row>

View File

@ -32,19 +32,26 @@ export const useGroups = () => {
export const useMemberGroups = ( export const useMemberGroups = (
userId: string | null | undefined, userId: string | null | undefined,
options?: { withChatEnabled: boolean } options?: { withChatEnabled: boolean },
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
) => { ) => {
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>() const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
useEffect(() => { useEffect(() => {
if (userId) if (userId)
return listenForMemberGroups(userId, (groups) => { return listenForMemberGroups(
userId,
(groups) => {
if (options?.withChatEnabled) if (options?.withChatEnabled)
return setMemberGroups( return setMemberGroups(
filterDefined(groups.filter((group) => group.chatDisabled !== true)) filterDefined(
groups.filter((group) => group.chatDisabled !== true)
)
) )
return setMemberGroups(groups) return setMemberGroups(groups)
}) },
}, [options?.withChatEnabled, userId]) sort
)
}, [options?.withChatEnabled, sort, userId])
return memberGroups return memberGroups
} }
@ -88,7 +95,7 @@ export async function listMembers(group: Group, max?: number) {
const { memberIds } = group const { memberIds } = group
const numToRetrieve = max ?? memberIds.length const numToRetrieve = max ?? memberIds.length
if (memberIds.length === 0) return [] if (memberIds.length === 0) return []
if (numToRetrieve) if (numToRetrieve > 100)
return (await getUsers()).filter((user) => return (await getUsers()).filter((user) =>
group.memberIds.includes(user.id) group.memberIds.includes(user.id)
) )

View File

@ -7,7 +7,7 @@ import {
where, where,
} from 'firebase/firestore' } from 'firebase/firestore'
import { sortBy, uniq } from 'lodash' import { sortBy, uniq } from 'lodash'
import { Group } from 'common/group' import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { updateContract } from './contracts' import { updateContract } from './contracts'
import { import {
coll, coll,
@ -22,7 +22,7 @@ export const groups = coll<Group>('groups')
export function groupPath( export function groupPath(
groupSlug: string, groupSlug: string,
subpath?: 'edit' | 'questions' | 'about' | 'chat' | 'rankings' subpath?: 'edit' | 'questions' | 'about' | typeof GROUP_CHAT_SLUG | 'rankings'
) { ) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}` return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
} }
@ -62,12 +62,21 @@ export function listenForGroup(
export function listenForMemberGroups( export function listenForMemberGroups(
userId: string, userId: string,
setGroups: (groups: Group[]) => void setGroups: (groups: Group[]) => void,
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
) { ) {
const q = query(groups, where('memberIds', 'array-contains', userId)) const q = query(groups, where('memberIds', 'array-contains', userId))
const sorter = (group: Group) => {
if (sort?.by === 'mostRecentChatActivityTime') {
return group.mostRecentChatActivityTime ?? group.mostRecentActivityTime
}
if (sort?.by === 'mostRecentContractAddedTime') {
return group.mostRecentContractAddedTime ?? group.mostRecentActivityTime
}
return group.mostRecentActivityTime
}
return listenForValues<Group>(q, (groups) => { return listenForValues<Group>(q, (groups) => {
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime]) const sorted = sortBy(groups, [(group) => -sorter(group)])
setGroups(sorted) setGroups(sorted)
}) })
} }

View File

@ -1,6 +1,6 @@
import { take, sortBy, debounce } from 'lodash' import { take, sortBy, debounce } from 'lodash'
import { Group } from 'common/group' import { Group, GROUP_CHAT_SLUG } from 'common/group'
import { Page } from 'web/components/page' import { Page } from 'web/components/page'
import { listAllBets } from 'web/lib/firebase/bets' import { listAllBets } from 'web/lib/firebase/bets'
import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts' import { Contract, listContractsByGroupSlug } from 'web/lib/firebase/contracts'
@ -21,7 +21,7 @@ import {
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user' import { useUser } from 'web/hooks/use-user'
import { listMembers, useGroup } from 'web/hooks/use-group' import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { scoreCreators, scoreTraders } from 'common/scoring' import { scoreCreators, scoreTraders } from 'common/scoring'
import { Leaderboard } from 'web/components/leaderboard' import { Leaderboard } from 'web/components/leaderboard'
@ -114,7 +114,7 @@ export async function getStaticPaths() {
} }
const groupSubpages = [ const groupSubpages = [
undefined, undefined,
'chat', GROUP_CHAT_SLUG,
'questions', 'questions',
'rankings', 'rankings',
'about', 'about',
@ -218,7 +218,7 @@ export default function GroupPage(props: {
) : ( ) : (
<LoadingIndicator /> <LoadingIndicator />
), ),
href: groupPath(group.slug, 'chat'), href: groupPath(group.slug, GROUP_CHAT_SLUG),
}, },
]), ]),
{ {
@ -246,7 +246,7 @@ export default function GroupPage(props: {
href: groupPath(group.slug, 'about'), href: groupPath(group.slug, 'about'),
}, },
] ]
const tabIndex = tabs.map((t) => t.title).indexOf(page ?? 'chat') const tabIndex = tabs.map((t) => t.title).indexOf(page ?? GROUP_CHAT_SLUG)
return ( return (
<Page rightSidebar={rightSidebar} className="!pb-0"> <Page rightSidebar={rightSidebar} className="!pb-0">
<SEO <SEO
@ -403,7 +403,7 @@ function GroupOverview(props: {
</Row> </Row>
)} )}
<Col className={'mt-2'}> <Col className={'mt-2'}>
<GroupMemberSearch members={members} /> <GroupMemberSearch members={members} group={group} />
</Col> </Col>
</Col> </Col>
</> </>
@ -426,9 +426,16 @@ function SearchBar(props: { setQuery: (query: string) => void }) {
) )
} }
function GroupMemberSearch(props: { members: User[] }) { function GroupMemberSearch(props: { members: User[]; group: Group }) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const { members } = props const { group } = props
let { members } = props
// Use static members on load, but also listen to member changes:
const listenToMembers = useMembers(group)
if (listenToMembers) {
members = listenToMembers
}
// TODO use find-active-contracts to sort by? // TODO use find-active-contracts to sort by?
const matches = sortBy(members, [(member) => member.name]).filter( const matches = sortBy(members, [(member) => member.name]).filter(

View File

@ -79,7 +79,11 @@ export default function Groups(props: {
) )
const matchesOrderedByRecentActivity = sortBy(groups, [ const matchesOrderedByRecentActivity = sortBy(groups, [
(group) => -1 * group.mostRecentActivityTime, (group) =>
-1 *
(group.mostRecentChatActivityTime ??
group.mostRecentContractAddedTime ??
group.mostRecentActivityTime),
]).filter( ]).filter(
(g) => (g) =>
checkAgainstQuery(query, g.name) || checkAgainstQuery(query, g.name) ||