diff --git a/common/group.ts b/common/group.ts
index 5220a1e8..8f5728d3 100644
--- a/common/group.ts
+++ b/common/group.ts
@@ -23,6 +23,7 @@ export type Group = {
score: number
}[]
}
+ pinnedItems: { itemId: string; type: 'post' | 'contract' }[]
}
export const MAX_GROUP_NAME_LENGTH = 75
diff --git a/firestore.rules b/firestore.rules
index 26649fa6..bf0375e6 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -176,7 +176,7 @@ service cloud.firestore {
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
.affectedKeys()
- .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
+ .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
match /groupContracts/{contractId} {
diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts
index 76dc1298..4b3f7446 100644
--- a/functions/src/create-group.ts
+++ b/functions/src/create-group.ts
@@ -62,6 +62,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
totalContracts: 0,
totalMembers: memberIds.length,
postIds: [],
+ pinnedItems: [],
}
await groupRef.create(group)
diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts
index b2e4c4d8..e1330fe1 100644
--- a/functions/src/scripts/convert-tag-to-group.ts
+++ b/functions/src/scripts/convert-tag-to-group.ts
@@ -42,6 +42,7 @@ const createGroup = async (
totalContracts: contracts.length,
totalMembers: 1,
postIds: [],
+ pinnedItems: [],
}
await groupRef.create(group)
// create a GroupMemberDoc for the creator
diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx
index 919cce86..23c348f0 100644
--- a/web/components/contract-search.tsx
+++ b/web/components/contract-search.tsx
@@ -3,10 +3,7 @@ import { SearchOptions } from '@algolia/client-search'
import { useRouter } from 'next/router'
import { Contract } from 'common/contract'
import { PAST_BETS, User } from 'common/user'
-import {
- ContractHighlightOptions,
- ContractsGrid,
-} from './contract/contracts-grid'
+import { CardHighlightOptions, ContractsGrid } from './contract/contracts-grid'
import { ShowTime } from './contract/contract-details'
import { Row } from './layout/row'
import {
@@ -81,7 +78,7 @@ export function ContractSearch(props: {
defaultFilter?: filter
defaultPill?: string
additionalFilter?: AdditionalFilter
- highlightOptions?: ContractHighlightOptions
+ highlightOptions?: CardHighlightOptions
onContractClick?: (contract: Contract) => void
hideOrderSelector?: boolean
cardUIOptions?: {
diff --git a/web/components/contract-select-modal.tsx b/web/components/contract-select-modal.tsx
index ea08de01..ea0505a8 100644
--- a/web/components/contract-select-modal.tsx
+++ b/web/components/contract-select-modal.tsx
@@ -91,7 +91,7 @@ export function SelectMarketsModal(props: {
noLinkAvatar: true,
}}
highlightOptions={{
- contractIds: contracts.map((c) => c.id),
+ itemIds: contracts.map((c) => c.id),
highlightClassName:
'!bg-indigo-100 outline outline-2 outline-indigo-300',
}}
diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx
index 0b93148d..8185996d 100644
--- a/web/components/contract/contracts-grid.tsx
+++ b/web/components/contract/contracts-grid.tsx
@@ -12,8 +12,8 @@ import { VisibilityObserver } from '../visibility-observer'
import Masonry from 'react-masonry-css'
import { CPMMBinaryContract } from 'common/contract'
-export type ContractHighlightOptions = {
- contractIds?: string[]
+export type CardHighlightOptions = {
+ itemIds?: string[]
highlightClassName?: string
}
@@ -28,7 +28,7 @@ export function ContractsGrid(props: {
noLinkAvatar?: boolean
showProbChange?: boolean
}
- highlightOptions?: ContractHighlightOptions
+ highlightOptions?: CardHighlightOptions
trackingPostfix?: string
breakpointColumns?: { [key: string]: number }
}) {
@@ -43,7 +43,7 @@ export function ContractsGrid(props: {
} = props
const { hideQuickBet, hideGroupLink, noLinkAvatar, showProbChange } =
cardUIOptions || {}
- const { contractIds, highlightClassName } = highlightOptions || {}
+ const { itemIds: contractIds, highlightClassName } = highlightOptions || {}
const onVisibilityUpdated = useCallback(
(visible) => {
if (visible && loadMore) {
diff --git a/web/components/groups/group-about-post.tsx b/web/components/groups/group-overview-post.tsx
similarity index 98%
rename from web/components/groups/group-about-post.tsx
rename to web/components/groups/group-overview-post.tsx
index 4d3046e9..55f0efca 100644
--- a/web/components/groups/group-about-post.tsx
+++ b/web/components/groups/group-overview-post.tsx
@@ -13,7 +13,7 @@ import { deletePost, updatePost } from 'web/lib/firebase/posts'
import { useState } from 'react'
import { usePost } from 'web/hooks/use-post'
-export function GroupAboutPost(props: {
+export function GroupOverviewPost(props: {
group: Group
isEditable: boolean
post: Post | null
diff --git a/web/components/groups/group-overview.tsx b/web/components/groups/group-overview.tsx
new file mode 100644
index 00000000..8e576c42
--- /dev/null
+++ b/web/components/groups/group-overview.tsx
@@ -0,0 +1,372 @@
+import { track } from '@amplitude/analytics-browser'
+import {
+ ArrowSmRightIcon,
+ PlusCircleIcon,
+ XCircleIcon,
+} from '@heroicons/react/outline'
+
+import PencilIcon from '@heroicons/react/solid/PencilIcon'
+
+import { Contract } from 'common/contract'
+import { Group } from 'common/group'
+import { Post } from 'common/post'
+import { useEffect, useState } from 'react'
+import { ReactNode } from 'react'
+import { getPost } from 'web/lib/firebase/posts'
+import { ContractSearch } from '../contract-search'
+import { ContractCard } from '../contract/contract-card'
+
+import Masonry from 'react-masonry-css'
+
+import { Col } from '../layout/col'
+import { Row } from '../layout/row'
+import { SiteLink } from '../site-link'
+import { GroupOverviewPost } from './group-overview-post'
+import { getContractFromId } from 'web/lib/firebase/contracts'
+import { groupPath, updateGroup } from 'web/lib/firebase/groups'
+import { PinnedSelectModal } from '../pinned-select-modal'
+import { Button } from '../button'
+import { User } from 'common/user'
+import { UserLink } from '../user-link'
+import { EditGroupButton } from './edit-group-button'
+import { JoinOrLeaveGroupButton } from './groups-button'
+import { Linkify } from '../linkify'
+import { ChoicesToggleGroup } from '../choices-toggle-group'
+import { CopyLinkButton } from '../copy-link-button'
+import { REFERRAL_AMOUNT } from 'common/economy'
+import toast from 'react-hot-toast'
+import { ENV_CONFIG } from 'common/envs/constants'
+import { PostCard } from '../post-card'
+
+const MAX_TRENDING_POSTS = 6
+
+export function GroupOverview(props: {
+ group: Group
+ isEditable: boolean
+ posts: Post[]
+ aboutPost: Post | null
+ creator: User
+ user: User | null | undefined
+ memberIds: string[]
+}) {
+ const { group, isEditable, posts, aboutPost, creator, user, memberIds } =
+ props
+ return (
+
+
+
+ {(group.aboutPostId != null || isEditable) && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+ )
+}
+
+function GroupOverviewPinned(props: {
+ group: Group
+ posts: Post[]
+ isEditable: boolean
+}) {
+ const { group, posts, isEditable } = props
+ const [pinned, setPinned] = useState([])
+ const [open, setOpen] = useState(false)
+ const [editMode, setEditMode] = useState(false)
+
+ useEffect(() => {
+ async function getPinned() {
+ if (group.pinnedItems == null) {
+ updateGroup(group, { pinnedItems: [] })
+ } else {
+ const itemComponents = await Promise.all(
+ group.pinnedItems.map(async (element) => {
+ if (element.type === 'post') {
+ const post = await getPost(element.itemId)
+ if (post) {
+ return
+ }
+ } else if (element.type === 'contract') {
+ const contract = await getContractFromId(element.itemId)
+ if (contract) {
+ return
+ }
+ }
+ })
+ )
+ setPinned(
+ itemComponents.filter(
+ (element) => element != undefined
+ ) as JSX.Element[]
+ )
+ }
+ }
+ getPinned()
+ }, [group, group.pinnedItems])
+
+ async function onSubmit(selectedItems: { itemId: string; type: string }[]) {
+ await updateGroup(group, {
+ pinnedItems: [
+ ...group.pinnedItems,
+ ...(selectedItems as { itemId: string; type: 'contract' | 'post' }[]),
+ ],
+ })
+ setOpen(false)
+ }
+
+ return isEditable || pinned.length > 0 ? (
+ <>
+
+
+ {isEditable && (
+
+ )}
+
+
+
+ {pinned.length == 0 && !editMode && (
+
+
+ No pinned items yet. Click the edit button to add some!
+
+
+ )}
+ {pinned.map((element, index) => (
+
+ {element}
+
+ {editMode && (
+
+
+
+ )}
+
+ ))}
+ {editMode && group.pinnedItems && pinned.length < 6 && (
+
+
+
+
+
+ )}
+
+
+
+ Pin posts or markets to the overview of this group.
+
+ }
+ onSubmit={onSubmit}
+ />
+ >
+ ) : (
+ <>>
+ )
+}
+
+function SectionHeader(props: {
+ label: string
+ href?: string
+ children?: ReactNode
+}) {
+ const { label, href, children } = props
+ const content = (
+ <>
+ {label}{' '}
+
+ >
+ )
+
+ return (
+
+ {href ? (
+ track('group click section header', { section: href })}
+ >
+ {content}
+
+ ) : (
+ {content}
+ )}
+ {children}
+
+ )
+}
+
+export function GroupAbout(props: {
+ group: Group
+ creator: User
+ user: User | null | undefined
+ isEditable: boolean
+ memberIds: string[]
+}) {
+ const { group, creator, isEditable, user, memberIds } = props
+ const anyoneCanJoinChoices: { [key: string]: string } = {
+ Closed: 'false',
+ Open: 'true',
+ }
+ const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin)
+ function updateAnyoneCanJoin(newVal: boolean) {
+ if (group.anyoneCanJoin == newVal || !isEditable) return
+ setAnyoneCanJoin(newVal)
+ toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), {
+ loading: 'Updating group...',
+ success: 'Updated group!',
+ error: "Couldn't update group",
+ })
+ }
+ const postFix = user ? '?referrer=' + user.username : ''
+ const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
+ group.slug
+ )}${postFix}`
+ const isMember = user ? memberIds.includes(user.id) : false
+
+ return (
+ <>
+
+
+
+ {isEditable ? (
+
+ ) : (
+ user && (
+
+
+
+ )
+ )}
+
+
+
+
+
+ Membership
+ {user && user.id === creator.id ? (
+
+ updateAnyoneCanJoin(choice.toString() === 'true')
+ }
+ toggleClassName={'h-10'}
+ className={'ml-2'}
+ />
+ ) : (
+
+ {anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'}
+
+ )}
+
+
+ {anyoneCanJoin && user && (
+
+ Invite
+
+ Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
+ sign up!
+
+
+
+
+ )}
+
+ >
+ )
+}
diff --git a/web/components/pinned-select-modal.tsx b/web/components/pinned-select-modal.tsx
new file mode 100644
index 00000000..98c91a7c
--- /dev/null
+++ b/web/components/pinned-select-modal.tsx
@@ -0,0 +1,164 @@
+import { Contract } from 'common/contract'
+import { Group } from 'common/group'
+import { Post } from 'common/post'
+import { useState } from 'react'
+import { PostCardList } from 'web/pages/group/[...slugs]'
+import { Button } from './button'
+import { PillButton } from './buttons/pill-button'
+import { ContractSearch } from './contract-search'
+import { Col } from './layout/col'
+import { Modal } from './layout/modal'
+import { Row } from './layout/row'
+import { LoadingIndicator } from './loading-indicator'
+
+export function PinnedSelectModal(props: {
+ title: string
+ description?: React.ReactNode
+ open: boolean
+ setOpen: (open: boolean) => void
+ onSubmit: (
+ selectedItems: { itemId: string; type: string }[]
+ ) => void | Promise
+ contractSearchOptions?: Partial[0]>
+ group: Group
+ posts: Post[]
+}) {
+ const {
+ title,
+ description,
+ open,
+ setOpen,
+ onSubmit,
+ contractSearchOptions,
+ posts,
+ group,
+ } = props
+
+ const [selectedItem, setSelectedItem] = useState<{
+ itemId: string
+ type: string
+ } | null>(null)
+ const [loading, setLoading] = useState(false)
+ const [selectedTab, setSelectedTab] = useState<'contracts' | 'posts'>('posts')
+
+ async function selectContract(contract: Contract) {
+ selectItem(contract.id, 'contract')
+ }
+
+ async function selectPost(post: Post) {
+ selectItem(post.id, 'post')
+ }
+
+ async function selectItem(itemId: string, type: string) {
+ setSelectedItem({ itemId: itemId, type: type })
+ }
+
+ async function onFinish() {
+ setLoading(true)
+ if (selectedItem) {
+ await onSubmit([
+ {
+ itemId: selectedItem.itemId,
+ type: selectedItem.type,
+ },
+ ])
+ setLoading(false)
+ setOpen(false)
+ setSelectedItem(null)
+ }
+ }
+
+ return (
+
+
+
+
+ {title}
+
+ {!loading && (
+
+ {selectedItem && (
+
+ )}
+
+
+ )}
+
+ {description}
+
+
+ {loading && (
+
+
+
+ )}
+
+
+ setSelectedTab('contracts')}
+ selected={selectedTab === 'contracts'}
+ >
+ Contracts
+
+ setSelectedTab('posts')}
+ selected={selectedTab === 'posts'}
+ >
+ Posts
+
+
+
+
+ {selectedTab === 'contracts' ? (
+
+
+
+ ) : (
+ <>
+
+
+ {posts.length === 0 && (
+
No posts yet
+ )}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/web/components/post-card.tsx b/web/components/post-card.tsx
new file mode 100644
index 00000000..a20748eb
--- /dev/null
+++ b/web/components/post-card.tsx
@@ -0,0 +1,82 @@
+import { track } from '@amplitude/analytics-browser'
+import clsx from 'clsx'
+import { Post } from 'common/post'
+import Link from 'next/link'
+import { useUserById } from 'web/hooks/use-user'
+import { postPath } from 'web/lib/firebase/posts'
+import { fromNow } from 'web/lib/util/time'
+import { Avatar } from './avatar'
+import { CardHighlightOptions } from './contract/contracts-grid'
+import { Row } from './layout/row'
+import { UserLink } from './user-link'
+
+export function PostCard(props: {
+ post: Post
+ onPostClick?: (post: Post) => void
+ highlightOptions?: CardHighlightOptions
+}) {
+ const { post, onPostClick, highlightOptions } = props
+ const creatorId = post.creatorId
+
+ const user = useUserById(creatorId)
+ const { itemIds: itemIds, highlightClassName } = highlightOptions || {}
+
+ if (!user) return <> >
+
+ return (
+
+ )
+}
diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx
index 0dfe40a0..4a67ae8d 100644
--- a/web/pages/group/[...slugs]/index.tsx
+++ b/web/pages/group/[...slugs]/index.tsx
@@ -11,12 +11,11 @@ import {
groupPath,
joinGroup,
listMemberIds,
- updateGroup,
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
-import { useUser, useUserById } from 'web/hooks/use-user'
+import { useUser } from 'web/hooks/use-user'
import {
useGroup,
useGroupContractIds,
@@ -24,27 +23,17 @@ import {
} from 'web/hooks/use-group'
import { Leaderboard } from 'web/components/leaderboard'
import { formatMoney } from 'common/util/format'
-import { EditGroupButton } from 'web/components/groups/edit-group-button'
import Custom404 from '../../404'
import { SEO } from 'web/components/SEO'
-import { Linkify } from 'web/components/linkify'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
-import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { ContractSearch } from 'web/components/contract-search'
-import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
-import { CopyLinkButton } from 'web/components/copy-link-button'
-import { ENV_CONFIG } from 'common/envs/constants'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { Button } from 'web/components/button'
import { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
import { GroupComment } from 'common/comment'
-import { REFERRAL_AMOUNT } from 'common/economy'
-import { UserLink } from 'web/components/user-link'
-import { GroupAboutPost } from 'web/components/groups/group-about-post'
-import { getPost, listPosts, postPath } from 'web/lib/firebase/posts'
+import { getPost, listPosts } from 'web/lib/firebase/posts'
import { Post } from 'common/post'
-import { Spacer } from 'web/components/layout/spacer'
import { usePost, usePosts } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser'
@@ -53,10 +42,11 @@ import { SelectMarketsModal } from 'web/components/contract-select-modal'
import { BETTORS } from 'common/user'
import { Page } from 'web/components/page'
import { Tabs } from 'web/components/layout/tabs'
-import { Avatar } from 'web/components/avatar'
import { Title } from 'web/components/title'
-import { fromNow } from 'web/lib/util/time'
import { CreatePost } from 'web/components/create-post'
+import { GroupOverview } from 'web/components/groups/group-overview'
+import { CardHighlightOptions } from 'web/components/contract/contracts-grid'
+import { PostCard } from 'web/components/post-card'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@@ -200,24 +190,18 @@ export default function GroupPage(props: {
>
)
- const aboutTab = (
-
- {(group.aboutPostId != null || isCreator || isAdmin) && (
-
- )}
-
+ const overviewPage = (
+ <>
-
+ >
)
const questionsTab = (
@@ -258,14 +242,14 @@ export default function GroupPage(props: {
title: 'Leaderboards',
content: leaderboardTab,
},
- {
- title: 'About',
- content: aboutTab,
- },
{
title: 'Posts',
content: postsPage,
},
+ {
+ title: 'Overview',
+ content: overviewPage,
+ },
]
return (
@@ -322,103 +306,6 @@ function JoinOrAddQuestionsButtons(props: {
) : null
}
-function GroupOverview(props: {
- group: Group
- creator: User
- user: User | null | undefined
- isCreator: boolean
- memberIds: string[]
-}) {
- const { group, creator, isCreator, user, memberIds } = props
- const anyoneCanJoinChoices: { [key: string]: string } = {
- Closed: 'false',
- Open: 'true',
- }
- const [anyoneCanJoin, setAnyoneCanJoin] = useState(group.anyoneCanJoin)
- function updateAnyoneCanJoin(newVal: boolean) {
- if (group.anyoneCanJoin == newVal || !isCreator) return
- setAnyoneCanJoin(newVal)
- toast.promise(updateGroup(group, { ...group, anyoneCanJoin: newVal }), {
- loading: 'Updating group...',
- success: 'Updated group!',
- error: "Couldn't update group",
- })
- }
- const postFix = user ? '?referrer=' + user.username : ''
- const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
- group.slug
- )}${postFix}`
- const isMember = user ? memberIds.includes(user.id) : false
-
- return (
- <>
-
-
-
- {isCreator ? (
-
- ) : (
- user && (
-
-
-
- )
- )}
-
-
-
-
-
- Membership
- {user && user.id === creator.id ? (
-
- updateAnyoneCanJoin(choice.toString() === 'true')
- }
- toggleClassName={'h-10'}
- className={'ml-2'}
- />
- ) : (
-
- {anyoneCanJoin ? 'Open to all' : 'Closed (by invite only)'}
-
- )}
-
-
- {anyoneCanJoin && user && (
-
- Invite
-
- Invite a friend to this group and get M${REFERRAL_AMOUNT} if they
- sign up!
-
-
-
-
- )}
-
- >
- )
-}
-
function GroupLeaderboard(props: {
topUsers: { user: User; score: number }[]
title: string
@@ -445,7 +332,7 @@ function GroupLeaderboard(props: {
)
}
-function GroupPosts(props: { posts: Post[]; group: Group }) {
+export function GroupPosts(props: { posts: Post[]; group: Group }) {
const { posts, group } = props
const [showCreatePost, setShowCreatePost] = useState(false)
const user = useUser()
@@ -471,9 +358,7 @@ function GroupPosts(props: { posts: Post[]; group: Group }) {
- {posts.map((post) => (
-
- ))}
+
{posts.length === 0 && (
No posts yet
)}
@@ -484,41 +369,22 @@ function GroupPosts(props: { posts: Post[]; group: Group }) {
return showCreatePost ? createPost : postList
}
-function PostCard(props: { post: Post }) {
- const { post } = props
- const creatorId = post.creatorId
-
- const user = useUserById(creatorId)
-
- if (!user) return <> >
-
+export function PostCardList(props: {
+ posts: Post[]
+ highlightOptions?: CardHighlightOptions
+ onPostClick?: (post: Post) => void
+}) {
+ const { posts, onPostClick, highlightOptions } = props
return (
-
-
-
-
-
-
-
- •
- {fromNow(post.createdTime)}
-
-
- {post.title}
-
-
-
-
+
+ {posts.map((post) => (
+
+ ))}
)
}