Merge branch 'main' into resolve-market-v2-final
This commit is contained in:
commit
6175336af0
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -48,7 +48,7 @@ jobs:
|
||||||
- name: Run Typescript checker on web client
|
- name: Run Typescript checker on web client
|
||||||
if: ${{ success() || failure() }}
|
if: ${{ success() || failure() }}
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: tsc -b -v --pretty
|
run: tsc --pretty --project tsconfig.json --noEmit
|
||||||
- name: Run Typescript checker on cloud functions
|
- name: Run Typescript checker on cloud functions
|
||||||
if: ${{ success() || failure() }}
|
if: ${{ success() || failure() }}
|
||||||
working-directory: functions
|
working-directory: functions
|
||||||
|
|
|
@ -516,6 +516,22 @@ The American Civil Liberties Union is our nation's guardian of liberty, working
|
||||||
|
|
||||||
The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`,
|
The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'The Center for Election Science',
|
||||||
|
website: 'https://electionscience.org/',
|
||||||
|
photo: 'https://i.imgur.com/WvdHHZa.png',
|
||||||
|
preview:
|
||||||
|
'The Center for Election Science is a nonpartisan nonprofit dedicated to empowering voters with voting methods that strengthen democracy. We believe you deserve a vote that empowers you to impact the world you live in.',
|
||||||
|
description: `Founded in 2011, The Center for Election Science is a national, nonpartisan nonprofit focused on voting reform.
|
||||||
|
|
||||||
|
Our Mission — To empower people with voting methods that strengthen democracy.
|
||||||
|
|
||||||
|
Our Vision — A world where democracies thrive because voters’ voices are heard.
|
||||||
|
|
||||||
|
With an emphasis on approval voting, we bring better elections to people across the country through both advocacy and research.
|
||||||
|
|
||||||
|
The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that don’t represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`,
|
||||||
|
},
|
||||||
].map((charity) => {
|
].map((charity) => {
|
||||||
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"verify": "(cd .. && yarn verify)"
|
"verify": "(cd .. && yarn verify)",
|
||||||
|
"verify:dir": "npx eslint . --max-warnings 0"
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -17,7 +17,8 @@
|
||||||
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||||
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
|
||||||
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
|
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
|
||||||
"verify": "(cd .. && yarn verify)"
|
"verify": "(cd .. && yarn verify)",
|
||||||
|
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
|
||||||
},
|
},
|
||||||
"main": "functions/src/index.js",
|
"main": "functions/src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
"web"
|
"web"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"verify": "(cd web && npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit); (cd common && npx eslint . --max-warnings 0); (cd functions && npx eslint . --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit)"
|
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -22,7 +22,7 @@ export function GroupSelector(props: {
|
||||||
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
const [isCreatingNewGroup, setIsCreatingNewGroup] = useState(false)
|
||||||
|
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const memberGroups = useMemberGroups(creator)
|
const memberGroups = useMemberGroups(creator?.id)
|
||||||
const filteredGroups = memberGroups
|
const filteredGroups = memberGroups
|
||||||
? query === ''
|
? query === ''
|
||||||
? memberGroups
|
? memberGroups
|
||||||
|
|
144
web/components/groups/groups-button.tsx
Normal file
144
web/components/groups/groups-button.tsx
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
|
import { TextButton } from 'web/components/text-button'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
import { Modal } from 'web/components/layout/modal'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { joinGroup, leaveGroup } from 'web/lib/firebase/groups'
|
||||||
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
|
import { GroupLink } from 'web/pages/groups'
|
||||||
|
|
||||||
|
export function GroupsButton(props: { user: User }) {
|
||||||
|
const { user } = props
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const groups = useMemberGroups(user.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextButton onClick={() => setIsOpen(true)}>
|
||||||
|
<span className="font-semibold">{groups?.length ?? ''}</span> Groups
|
||||||
|
</TextButton>
|
||||||
|
|
||||||
|
<GroupsDialog
|
||||||
|
user={user}
|
||||||
|
groups={groups ?? []}
|
||||||
|
isOpen={isOpen}
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupsDialog(props: {
|
||||||
|
user: User
|
||||||
|
groups: Group[]
|
||||||
|
isOpen: boolean
|
||||||
|
setIsOpen: (isOpen: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { user, groups, isOpen, setIsOpen } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||||
|
<Col className="rounded bg-white p-6">
|
||||||
|
<div className="p-2 pb-1 text-xl">{user.name}</div>
|
||||||
|
<div className="p-2 pt-0 text-sm text-gray-500">@{user.username}</div>
|
||||||
|
<GroupsList groups={groups} />
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupsList(props: { groups: Group[] }) {
|
||||||
|
const { groups } = props
|
||||||
|
return (
|
||||||
|
<Col className="gap-2">
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<div className="text-gray-500">No groups yet...</div>
|
||||||
|
)}
|
||||||
|
{groups
|
||||||
|
.sort((group1, group2) => group2.createdTime - group1.createdTime)
|
||||||
|
.map((group) => (
|
||||||
|
<GroupItem key={group.id} group={group} />
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupItem(props: { group: Group; className?: string }) {
|
||||||
|
const { group, className } = props
|
||||||
|
return (
|
||||||
|
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
||||||
|
<Row className="line-clamp-1 items-center gap-2">
|
||||||
|
<GroupLink group={group} />
|
||||||
|
</Row>
|
||||||
|
<JoinOrLeaveGroupButton group={group} />
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JoinOrLeaveGroupButton(props: {
|
||||||
|
group: Group
|
||||||
|
small?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { group, small, className } = props
|
||||||
|
const currentUser = useUser()
|
||||||
|
const isFollowing = currentUser
|
||||||
|
? group.memberIds.includes(currentUser.id)
|
||||||
|
: false
|
||||||
|
const onJoinGroup = () => {
|
||||||
|
if (!currentUser) return
|
||||||
|
joinGroup(group, currentUser.id)
|
||||||
|
}
|
||||||
|
const onLeaveGroup = () => {
|
||||||
|
if (!currentUser) return
|
||||||
|
leaveGroup(group, currentUser.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const smallStyle =
|
||||||
|
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
|
||||||
|
|
||||||
|
if (!currentUser || isFollowing === undefined) {
|
||||||
|
if (!group.anyoneCanJoin)
|
||||||
|
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={firebaseLogin}
|
||||||
|
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||||
|
>
|
||||||
|
Login to Join
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFollowing) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'btn btn-outline btn-sm',
|
||||||
|
small && smallStyle,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={withTracking(onLeaveGroup, 'leave group')}
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!group.anyoneCanJoin)
|
||||||
|
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||||
|
onClick={withTracking(onJoinGroup, 'join group')}
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,7 +3,6 @@ import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
MenuAlt3Icon,
|
MenuAlt3Icon,
|
||||||
PresentationChartLineIcon,
|
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
|
@ -19,14 +18,9 @@ import NotificationsIcon from 'web/components/notifications-icon'
|
||||||
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
import { useIsIframe } from 'web/hooks/use-is-iframe'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
function getNavigation(username: string) {
|
function getNavigation() {
|
||||||
return [
|
return [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||||
{
|
|
||||||
name: 'Portfolio',
|
|
||||||
href: `/${username}?tab=bets`,
|
|
||||||
icon: PresentationChartLineIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Notifications',
|
name: 'Notifications',
|
||||||
href: `/notifications`,
|
href: `/notifications`,
|
||||||
|
@ -55,38 +49,39 @@ export function BottomNavBar() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationOptions =
|
const navigationOptions =
|
||||||
user === null
|
user === null ? signedOutNavigation : getNavigation()
|
||||||
? signedOutNavigation
|
|
||||||
: getNavigation(user?.username || 'error')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
|
<nav className="fixed inset-x-0 bottom-0 z-20 flex justify-between border-t-2 bg-white text-xs text-gray-700 lg:hidden">
|
||||||
{navigationOptions.map((item) => (
|
{navigationOptions.map((item) => (
|
||||||
<NavBarItem key={item.name} item={item} currentPage={currentPage} />
|
<NavBarItem key={item.name} item={item} currentPage={currentPage} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<NavBarItem
|
||||||
|
key={'profile'}
|
||||||
|
currentPage={currentPage}
|
||||||
|
item={{
|
||||||
|
name: formatMoney(user.balance),
|
||||||
|
href: `/${user.username}?tab=bets`,
|
||||||
|
icon: () => (
|
||||||
|
<Avatar
|
||||||
|
className="mx-auto my-1"
|
||||||
|
size="xs"
|
||||||
|
username={user.username}
|
||||||
|
avatarUrl={user.avatarUrl}
|
||||||
|
noLink
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700"
|
className="w-full select-none py-1 px-3 text-center hover:cursor-pointer hover:bg-indigo-200 hover:text-indigo-700"
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
>
|
>
|
||||||
{user === null ? (
|
<MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
||||||
<>
|
More
|
||||||
<MenuAlt3Icon className="my-1 mx-auto h-6 w-6" aria-hidden="true" />
|
|
||||||
More
|
|
||||||
</>
|
|
||||||
) : user ? (
|
|
||||||
<>
|
|
||||||
<Avatar
|
|
||||||
className="mx-auto my-1"
|
|
||||||
size="xs"
|
|
||||||
username={user.username}
|
|
||||||
avatarUrl={user.avatarUrl}
|
|
||||||
noLink
|
|
||||||
/>
|
|
||||||
{formatMoney(user.balance)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileSidebar
|
<MobileSidebar
|
||||||
|
@ -109,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) {
|
||||||
)}
|
)}
|
||||||
onClick={trackCallback('navbar: ' + item.name)}
|
onClick={trackCallback('navbar: ' + item.name)}
|
||||||
>
|
>
|
||||||
<item.icon className="my-1 mx-auto h-6 w-6" />
|
{item.icon && <item.icon className="my-1 mx-auto h-6 w-6" />}
|
||||||
{item.name}
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
|
||||||
export function ProfileSummary(props: { user: User }) {
|
export function ProfileSummary(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
return (
|
return (
|
||||||
<Link href={`/${user.username}`}>
|
<Link href={`/${user.username}?tab=bets`}>
|
||||||
<a
|
<a
|
||||||
onClick={trackCallback('sidebar: profile')}
|
onClick={trackCallback('sidebar: profile')}
|
||||||
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||||
|
|
|
@ -5,10 +5,9 @@ import {
|
||||||
DotsHorizontalIcon,
|
DotsHorizontalIcon,
|
||||||
CashIcon,
|
CashIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
PresentationChartLineIcon,
|
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
ChevronDownIcon,
|
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
|
ChatIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
@ -26,15 +25,11 @@ 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 } from 'common/group'
|
||||||
|
import { Spacer } from '../layout/spacer'
|
||||||
|
|
||||||
function getNavigation(username: string) {
|
function getNavigation() {
|
||||||
return [
|
return [
|
||||||
{ name: 'Home', href: '/home', icon: HomeIcon },
|
{ name: 'Home', href: '/home', icon: HomeIcon },
|
||||||
{
|
|
||||||
name: 'Portfolio',
|
|
||||||
href: `/${username}?tab=bets`,
|
|
||||||
icon: PresentationChartLineIcon,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Notifications',
|
name: 'Notifications',
|
||||||
href: `/notifications`,
|
href: `/notifications`,
|
||||||
|
@ -63,12 +58,10 @@ function getMoreNavigation(user?: User | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
{ name: 'Send M$', href: '/links' },
|
||||||
{ name: 'Leaderboards', href: '/leaderboards' },
|
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||||
{ name: 'Charity', href: '/charity' },
|
{ name: 'Charity', href: '/charity' },
|
||||||
{ name: 'Blog', href: 'https://news.manifold.markets' },
|
|
||||||
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
|
|
||||||
{ name: 'Statistics', href: '/stats' },
|
|
||||||
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
|
||||||
{
|
{
|
||||||
name: 'Sign out',
|
name: 'Sign out',
|
||||||
|
@ -90,8 +83,20 @@ const signedOutNavigation = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const signedOutMobileNavigation = [
|
const signedOutMobileNavigation = [
|
||||||
|
{
|
||||||
|
name: 'About',
|
||||||
|
href: 'https://docs.manifold.markets/$how-to',
|
||||||
|
icon: BookOpenIcon,
|
||||||
|
},
|
||||||
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
{ name: 'Charity', href: '/charity', icon: HeartIcon },
|
||||||
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
|
||||||
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh', icon: ChatIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
const signedInMobileNavigation = [
|
||||||
|
...(IS_PRIVATE_MANIFOLD
|
||||||
|
? []
|
||||||
|
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
||||||
{
|
{
|
||||||
name: 'About',
|
name: 'About',
|
||||||
href: 'https://docs.manifold.markets/$how-to',
|
href: 'https://docs.manifold.markets/$how-to',
|
||||||
|
@ -99,17 +104,24 @@ const signedOutMobileNavigation = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const signedInMobileNavigation = [
|
function getMoreMobileNav() {
|
||||||
...(IS_PRIVATE_MANIFOLD
|
return [
|
||||||
? []
|
{ name: 'Send M$', href: '/links' },
|
||||||
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
|
{ name: 'Charity', href: '/charity' },
|
||||||
...signedOutMobileNavigation,
|
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
|
||||||
]
|
{ name: 'Leaderboards', href: '/leaderboards' },
|
||||||
|
{
|
||||||
|
name: 'Sign out',
|
||||||
|
href: '#',
|
||||||
|
onClick: withTracking(firebaseLogout, 'sign out'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export type Item = {
|
export type Item = {
|
||||||
name: string
|
name: string
|
||||||
href: string
|
href: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarItem(props: { item: Item; currentPage: string }) {
|
function SidebarItem(props: { item: Item; currentPage: string }) {
|
||||||
|
@ -126,15 +138,17 @@ function SidebarItem(props: { item: Item; currentPage: string }) {
|
||||||
)}
|
)}
|
||||||
aria-current={item.href == currentPage ? 'page' : undefined}
|
aria-current={item.href == currentPage ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
<item.icon
|
{item.icon && (
|
||||||
className={clsx(
|
<item.icon
|
||||||
item.href == currentPage
|
className={clsx(
|
||||||
? 'text-gray-500'
|
item.href == currentPage
|
||||||
: 'text-gray-400 group-hover:text-gray-500',
|
? 'text-gray-500'
|
||||||
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
: 'text-gray-400 group-hover:text-gray-500',
|
||||||
)}
|
'-ml-1 mr-3 h-6 w-6 flex-shrink-0'
|
||||||
aria-hidden="true"
|
)}
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className="truncate">{item.name}</span>
|
<span className="truncate">{item.name}</span>
|
||||||
</a>
|
</a>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -163,27 +177,17 @@ function MoreButton() {
|
||||||
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
|
return <SidebarButton text={'More'} icon={DotsHorizontalIcon} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function GroupsButton() {
|
|
||||||
return (
|
|
||||||
<SidebarButton icon={UserGroupIcon} text={'Groups'}>
|
|
||||||
<ChevronDownIcon className=" mt-0.5 ml-2 h-5 w-5" aria-hidden="true" />
|
|
||||||
</SidebarButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Sidebar(props: { className?: string }) {
|
export default function Sidebar(props: { className?: string }) {
|
||||||
const { className } = props
|
const { className } = props
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const currentPage = router.pathname
|
const currentPage = router.pathname
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const navigationOptions = !user
|
const navigationOptions = !user ? signedOutNavigation : getNavigation()
|
||||||
? signedOutNavigation
|
|
||||||
: getNavigation(user?.username || 'error')
|
|
||||||
const mobileNavigationOptions = !user
|
const mobileNavigationOptions = !user
|
||||||
? signedOutMobileNavigation
|
? signedOutMobileNavigation
|
||||||
: signedInMobileNavigation
|
: signedInMobileNavigation
|
||||||
const memberItems = (useMemberGroups(user) ?? []).map((group: Group) => ({
|
const memberItems = (useMemberGroups(user?.id) ?? []).map((group: Group) => ({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
href: groupPath(group.slug),
|
href: groupPath(group.slug),
|
||||||
}))
|
}))
|
||||||
|
@ -191,95 +195,73 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Sidebar" className={className}>
|
<nav aria-label="Sidebar" className={className}>
|
||||||
<ManifoldLogo className="pb-6" twoLine />
|
<ManifoldLogo className="pb-6" twoLine />
|
||||||
|
|
||||||
|
<CreateQuestionButton user={user} />
|
||||||
|
<Spacer h={4} />
|
||||||
{user && (
|
{user && (
|
||||||
<div className="mb-2" style={{ minHeight: 80 }}>
|
<div className="w-full" style={{ minHeight: 80 }}>
|
||||||
<ProfileSummary user={user} />
|
<ProfileSummary user={user} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile navigation */}
|
{/* Mobile navigation */}
|
||||||
<div className="space-y-1 lg:hidden">
|
<div className="space-y-1 lg:hidden">
|
||||||
{user && (
|
|
||||||
<MenuButton
|
|
||||||
buttonContent={<GroupsButton />}
|
|
||||||
menuItems={[{ name: 'Explore', href: '/groups' }, ...memberItems]}
|
|
||||||
className={'relative z-50 flex-shrink-0'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{mobileNavigationOptions.map((item) => (
|
{mobileNavigationOptions.map((item) => (
|
||||||
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||||
))}
|
))}
|
||||||
{!user && (
|
|
||||||
<SidebarItem
|
|
||||||
key={'Groups'}
|
|
||||||
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
|
|
||||||
currentPage={currentPage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<MenuButton
|
<MenuButton
|
||||||
menuItems={[
|
menuItems={getMoreMobileNav()}
|
||||||
{
|
|
||||||
name: 'Blog',
|
|
||||||
href: 'https://news.manifold.markets',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Discord',
|
|
||||||
href: 'https://discord.gg/eHQBNBqXuh',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Twitter',
|
|
||||||
href: 'https://twitter.com/ManifoldMarkets',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Statistics',
|
|
||||||
href: '/stats',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Sign out',
|
|
||||||
href: '#',
|
|
||||||
onClick: withTracking(firebaseLogout, 'sign out'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
buttonContent={<MoreButton />}
|
buttonContent={<MoreButton />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<GroupsList currentPage={currentPage} memberItems={memberItems} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop navigation */}
|
{/* Desktop navigation */}
|
||||||
<div className="hidden space-y-1 lg:block">
|
<div className="hidden space-y-1 lg:block">
|
||||||
{navigationOptions.map((item) =>
|
{navigationOptions.map((item) => (
|
||||||
item.name === 'Notifications' ? (
|
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||||
<div key={item.href}>
|
))}
|
||||||
<SidebarItem item={item} currentPage={currentPage} />
|
|
||||||
{user && (
|
|
||||||
<MenuButton
|
|
||||||
key={'groupsdropdown'}
|
|
||||||
buttonContent={<GroupsButton />}
|
|
||||||
menuItems={[
|
|
||||||
{ name: 'Explore', href: '/groups' },
|
|
||||||
...memberItems,
|
|
||||||
]}
|
|
||||||
className={'relative z-50 flex-shrink-0'}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<SidebarItem
|
|
||||||
key={item.href}
|
|
||||||
item={item}
|
|
||||||
currentPage={currentPage}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MenuButton
|
<MenuButton
|
||||||
menuItems={getMoreNavigation(user)}
|
menuItems={getMoreNavigation(user)}
|
||||||
buttonContent={<MoreButton />}
|
buttonContent={<MoreButton />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Spacer if there are any groups */}
|
||||||
|
{memberItems.length > 0 && (
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="h-[1px] bg-gray-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<GroupsList currentPage={currentPage} memberItems={memberItems} />
|
||||||
</div>
|
</div>
|
||||||
<CreateQuestionButton user={user} />
|
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GroupsList(props: { currentPage: string; memberItems: Item[] }) {
|
||||||
|
const { currentPage, memberItems } = props
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarItem
|
||||||
|
item={{ name: 'Groups', href: '/groups', icon: UserGroupIcon }}
|
||||||
|
currentPage={currentPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{memberItems.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className="group flex items-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<span className="truncate"> {item.name}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -7,8 +7,8 @@ import clsx from 'clsx'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { debounce, sumBy } from 'lodash'
|
import { debounce, sum } from 'lodash'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { CommentTips } from 'web/hooks/use-tip-txns'
|
import { CommentTips } from 'web/hooks/use-tip-txns'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { transact } from 'web/lib/firebase/fn-call'
|
import { transact } from 'web/lib/firebase/fn-call'
|
||||||
|
@ -16,33 +16,24 @@ import { track } from 'web/lib/service/analytics'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { Tooltip } from './tooltip'
|
import { Tooltip } from './tooltip'
|
||||||
|
|
||||||
// xth triangle number * 5 = 5 + 10 + 15 + ... + (x * 5)
|
|
||||||
const quad = (x: number) => (5 / 2) * x * (x + 1)
|
|
||||||
|
|
||||||
// inverse (see https://math.stackexchange.com/questions/2041988/how-to-get-inverse-of-formula-for-sum-of-integers-from-1-to-nsee )
|
|
||||||
const invQuad = (y: number) => Math.sqrt((2 / 5) * y + 1 / 4) - 1 / 2
|
|
||||||
|
|
||||||
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
const { comment, tips } = prop
|
const { comment, tips } = prop
|
||||||
|
|
||||||
const me = useUser()
|
const me = useUser()
|
||||||
const myId = me?.id ?? ''
|
const myId = me?.id ?? ''
|
||||||
const savedTip = tips[myId] as number | undefined
|
const savedTip = tips[myId] ?? 0
|
||||||
|
|
||||||
// optimistically increase the tip count, but debounce the update
|
const [localTip, setLocalTip] = useState(savedTip)
|
||||||
const [localTip, setLocalTip] = useState(savedTip ?? 0)
|
// listen for user being set
|
||||||
const initialized = useRef(false)
|
const initialized = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (savedTip && !initialized.current) {
|
if (tips[myId] && !initialized.current) {
|
||||||
setLocalTip(savedTip)
|
setLocalTip(tips[myId])
|
||||||
initialized.current = true
|
initialized.current = true
|
||||||
}
|
}
|
||||||
}, [savedTip])
|
}, [tips, myId])
|
||||||
|
|
||||||
const score = useMemo(() => {
|
const total = sum(Object.values(tips)) - savedTip + localTip
|
||||||
const tipVals = Object.values({ ...tips, [myId]: localTip })
|
|
||||||
return sumBy(tipVals, invQuad)
|
|
||||||
}, [localTip, tips, myId])
|
|
||||||
|
|
||||||
// declare debounced function only on first render
|
// declare debounced function only on first render
|
||||||
const [saveTip] = useState(() =>
|
const [saveTip] = useState(() =>
|
||||||
|
@ -80,7 +71,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
|
|
||||||
const changeTip = (tip: number) => {
|
const changeTip = (tip: number) => {
|
||||||
setLocalTip(tip)
|
setLocalTip(tip)
|
||||||
me && saveTip(me, tip - (savedTip ?? 0))
|
me && saveTip(me, tip - savedTip)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -88,13 +79,13 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
<DownTip
|
<DownTip
|
||||||
value={localTip}
|
value={localTip}
|
||||||
onChange={changeTip}
|
onChange={changeTip}
|
||||||
disabled={!me || localTip <= 0}
|
disabled={!me || localTip <= savedTip}
|
||||||
/>
|
/>
|
||||||
<span className="font-bold">{Math.floor(score)} </span>
|
<span className="font-bold">{Math.floor(total)}</span>
|
||||||
<UpTip
|
<UpTip
|
||||||
value={localTip}
|
value={localTip}
|
||||||
onChange={changeTip}
|
onChange={changeTip}
|
||||||
disabled={!me || me.id === comment.userId}
|
disabled={!me || me.id === comment.userId || me.balance < localTip + 5}
|
||||||
/>
|
/>
|
||||||
{localTip === 0 ? (
|
{localTip === 0 ? (
|
||||||
''
|
''
|
||||||
|
@ -118,16 +109,15 @@ function DownTip(prop: {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { onChange, value, disabled } = prop
|
const { onChange, value, disabled } = prop
|
||||||
const marginal = 5 * invQuad(value)
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
className="tooltip-bottom"
|
className="tooltip-bottom"
|
||||||
text={!disabled && `Refund ${formatMoney(marginal)}`}
|
text={!disabled && `-${formatMoney(5)}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="flex h-max items-center hover:text-red-600 disabled:text-gray-300"
|
className="flex h-max items-center hover:text-red-600 disabled:text-gray-300"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onChange(value - marginal)}
|
onClick={() => onChange(value - 5)}
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="h-6 w-6" />
|
<ChevronLeftIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -141,19 +131,18 @@ function UpTip(prop: {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { onChange, value, disabled } = prop
|
const { onChange, value, disabled } = prop
|
||||||
const marginal = 5 * invQuad(value) + 5
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
className="tooltip-bottom"
|
className="tooltip-bottom"
|
||||||
text={!disabled && `Tip ${formatMoney(marginal)}`}
|
text={!disabled && `Tip ${formatMoney(5)}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="hover:text-primary flex h-max items-center disabled:text-gray-300"
|
className="hover:text-primary flex h-max items-center disabled:text-gray-300"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onChange(value + marginal)}
|
onClick={() => onChange(value + 5)}
|
||||||
>
|
>
|
||||||
{value >= quad(2) ? (
|
{value >= 10 ? (
|
||||||
<ChevronDoubleRightIcon className="text-primary mx-1 h-6 w-6" />
|
<ChevronDoubleRightIcon className="text-primary mx-1 h-6 w-6" />
|
||||||
) : value > 0 ? (
|
) : value > 0 ? (
|
||||||
<ChevronRightIcon className="text-primary h-6 w-6" />
|
<ChevronRightIcon className="text-primary h-6 w-6" />
|
||||||
|
|
|
@ -36,6 +36,7 @@ import { FollowersButton, FollowingButton } from './following-button'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import { FollowButton } from './follow-button'
|
import { FollowButton } from './follow-button'
|
||||||
import { PortfolioMetrics } from 'common/user'
|
import { PortfolioMetrics } from 'common/user'
|
||||||
|
import { GroupsButton } from 'web/components/groups/groups-button'
|
||||||
|
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
|
@ -197,6 +198,7 @@ export function UserPage(props: {
|
||||||
<Row className="gap-4">
|
<Row className="gap-4">
|
||||||
<FollowingButton user={user} />
|
<FollowingButton user={user} />
|
||||||
<FollowersButton user={user} />
|
<FollowersButton user={user} />
|
||||||
|
<GroupsButton user={user} />
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{user.website && (
|
{user.website && (
|
||||||
|
|
|
@ -2,16 +2,16 @@ import { useEffect } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import {
|
import {
|
||||||
Contract,
|
Contract,
|
||||||
contractDocRef,
|
contracts,
|
||||||
listenForContract,
|
listenForContract,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { useStateCheckEquality } from './use-state-check-equality'
|
import { useStateCheckEquality } from './use-state-check-equality'
|
||||||
import { DocumentData } from 'firebase/firestore'
|
import { doc, DocumentData } from 'firebase/firestore'
|
||||||
|
|
||||||
export const useContract = (contractId: string) => {
|
export const useContract = (contractId: string) => {
|
||||||
const result = useFirestoreDocumentData<DocumentData, Contract>(
|
const result = useFirestoreDocumentData<DocumentData, Contract>(
|
||||||
['contracts', contractId],
|
['contracts', contractId],
|
||||||
contractDocRef(contractId),
|
doc(contracts, contractId),
|
||||||
{ subscribe: true, includeMetadataChanges: true }
|
{ subscribe: true, includeMetadataChanges: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -29,11 +29,11 @@ export const useGroups = () => {
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMemberGroups = (user: User | null | undefined) => {
|
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||||
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) return listenForMemberGroups(user.id, setMemberGroups)
|
if (userId) return listenForMemberGroups(userId, setMemberGroups)
|
||||||
}, [user])
|
}, [userId])
|
||||||
return memberGroups
|
return memberGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import { QueryClient } from 'react-query'
|
import { QueryClient } from 'react-query'
|
||||||
|
|
||||||
import { DocumentData } from 'firebase/firestore'
|
import { doc, DocumentData } from 'firebase/firestore'
|
||||||
import { PrivateUser } from 'common/user'
|
import { PrivateUser } from 'common/user'
|
||||||
import {
|
import {
|
||||||
getUser,
|
getUser,
|
||||||
|
@ -10,7 +10,7 @@ import {
|
||||||
listenForPrivateUser,
|
listenForPrivateUser,
|
||||||
listenForUser,
|
listenForUser,
|
||||||
User,
|
User,
|
||||||
userDocRef,
|
users,
|
||||||
} from 'web/lib/firebase/users'
|
} from 'web/lib/firebase/users'
|
||||||
import { useStateCheckEquality } from './use-state-check-equality'
|
import { useStateCheckEquality } from './use-state-check-equality'
|
||||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||||
|
@ -49,7 +49,7 @@ export const usePrivateUser = (userId?: string) => {
|
||||||
export const useUserById = (userId: string) => {
|
export const useUserById = (userId: string) => {
|
||||||
const result = useFirestoreDocumentData<DocumentData, User>(
|
const result = useFirestoreDocumentData<DocumentData, User>(
|
||||||
['users', userId],
|
['users', userId],
|
||||||
userDocRef(userId),
|
doc(users, userId),
|
||||||
{ subscribe: true, includeMetadataChanges: true }
|
{ subscribe: true, includeMetadataChanges: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
getFirestore,
|
|
||||||
doc,
|
doc,
|
||||||
setDoc,
|
setDoc,
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
|
@ -16,8 +15,7 @@ import {
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy, sum } from 'lodash'
|
import { sortBy, sum } from 'lodash'
|
||||||
|
|
||||||
import { app } from './init'
|
import { coll, getValues, listenForValue, listenForValues } from './utils'
|
||||||
import { getValues, listenForValue, listenForValues } from './utils'
|
|
||||||
import { BinaryContract, Contract } from 'common/contract'
|
import { BinaryContract, Contract } from 'common/contract'
|
||||||
import { getDpmProbability } from 'common/calculate-dpm'
|
import { getDpmProbability } from 'common/calculate-dpm'
|
||||||
import { createRNG, shuffle } from 'common/util/random'
|
import { createRNG, shuffle } from 'common/util/random'
|
||||||
|
@ -28,6 +26,9 @@ import { MAX_FEED_CONTRACTS } from 'common/recommended-contracts'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
|
|
||||||
|
export const contracts = coll<Contract>('contracts')
|
||||||
|
|
||||||
export type { Contract }
|
export type { Contract }
|
||||||
|
|
||||||
export function contractPath(contract: Contract) {
|
export function contractPath(contract: Contract) {
|
||||||
|
@ -86,83 +87,72 @@ export function tradingAllowed(contract: Contract) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getFirestore(app)
|
|
||||||
export const contractCollection = collection(db, 'contracts')
|
|
||||||
export const contractDocRef = (contractId: string) =>
|
|
||||||
doc(db, 'contracts', contractId)
|
|
||||||
|
|
||||||
// Push contract to Firestore
|
// Push contract to Firestore
|
||||||
export async function setContract(contract: Contract) {
|
export async function setContract(contract: Contract) {
|
||||||
const docRef = doc(db, 'contracts', contract.id)
|
await setDoc(doc(contracts, contract.id), contract)
|
||||||
await setDoc(docRef, contract)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContract(
|
export async function updateContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
update: Partial<Contract>
|
update: Partial<Contract>
|
||||||
) {
|
) {
|
||||||
const docRef = doc(db, 'contracts', contractId)
|
await updateDoc(doc(contracts, contractId), update)
|
||||||
await updateDoc(docRef, update)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractFromId(contractId: string) {
|
export async function getContractFromId(contractId: string) {
|
||||||
const docRef = doc(db, 'contracts', contractId)
|
const result = await getDoc(doc(contracts, contractId))
|
||||||
const result = await getDoc(docRef)
|
return result.exists() ? result.data() : undefined
|
||||||
|
|
||||||
return result.exists() ? (result.data() as Contract) : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractFromSlug(slug: string) {
|
export async function getContractFromSlug(slug: string) {
|
||||||
const q = query(contractCollection, where('slug', '==', slug))
|
const q = query(contracts, where('slug', '==', slug))
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
|
return snapshot.empty ? undefined : snapshot.docs[0].data()
|
||||||
return snapshot.empty ? undefined : (snapshot.docs[0].data() as Contract)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContract(contractId: string) {
|
export async function deleteContract(contractId: string) {
|
||||||
const docRef = doc(db, 'contracts', contractId)
|
await deleteDoc(doc(contracts, contractId))
|
||||||
await deleteDoc(docRef)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listContracts(creatorId: string): Promise<Contract[]> {
|
export async function listContracts(creatorId: string): Promise<Contract[]> {
|
||||||
const q = query(
|
const q = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('creatorId', '==', creatorId),
|
where('creatorId', '==', creatorId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
return snapshot.docs.map((doc) => doc.data())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listTaggedContractsCaseInsensitive(
|
export async function listTaggedContractsCaseInsensitive(
|
||||||
tag: string
|
tag: string
|
||||||
): Promise<Contract[]> {
|
): Promise<Contract[]> {
|
||||||
const q = query(
|
const q = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('lowercaseTags', 'array-contains', tag.toLowerCase()),
|
where('lowercaseTags', 'array-contains', tag.toLowerCase()),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
return snapshot.docs.map((doc) => doc.data())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllContracts(
|
export async function listAllContracts(
|
||||||
n: number,
|
n: number,
|
||||||
before?: string
|
before?: string
|
||||||
): Promise<Contract[]> {
|
): Promise<Contract[]> {
|
||||||
let q = query(contractCollection, orderBy('createdTime', 'desc'), limit(n))
|
let q = query(contracts, orderBy('createdTime', 'desc'), limit(n))
|
||||||
if (before != null) {
|
if (before != null) {
|
||||||
const snap = await getDoc(doc(db, 'contracts', before))
|
const snap = await getDoc(doc(contracts, before))
|
||||||
q = query(q, startAfter(snap))
|
q = query(q, startAfter(snap))
|
||||||
}
|
}
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
return snapshot.docs.map((doc) => doc.data() as Contract)
|
return snapshot.docs.map((doc) => doc.data())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForContracts(
|
export function listenForContracts(
|
||||||
setContracts: (contracts: Contract[]) => void
|
setContracts: (contracts: Contract[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(contractCollection, orderBy('createdTime', 'desc'))
|
const q = query(contracts, orderBy('createdTime', 'desc'))
|
||||||
return listenForValues<Contract>(q, setContracts)
|
return listenForValues<Contract>(q, setContracts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,7 +161,7 @@ export function listenForUserContracts(
|
||||||
setContracts: (contracts: Contract[]) => void
|
setContracts: (contracts: Contract[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(
|
const q = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('creatorId', '==', creatorId),
|
where('creatorId', '==', creatorId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
|
@ -179,7 +169,7 @@ export function listenForUserContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeContractsQuery = query(
|
const activeContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
where('volume7Days', '>', 0)
|
where('volume7Days', '>', 0)
|
||||||
|
@ -196,7 +186,7 @@ export function listenForActiveContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
const inactiveContractsQuery = query(
|
const inactiveContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('closeTime', '>', Date.now()),
|
where('closeTime', '>', Date.now()),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
|
@ -214,7 +204,7 @@ export function listenForInactiveContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
const newContractsQuery = query(
|
const newContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('volume7Days', '==', 0),
|
where('volume7Days', '==', 0),
|
||||||
where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
where('createdTime', '>', Date.now() - 7 * DAY_MS)
|
||||||
|
@ -230,7 +220,7 @@ export function listenForContract(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setContract: (contract: Contract | null) => void
|
setContract: (contract: Contract | null) => void
|
||||||
) {
|
) {
|
||||||
const contractRef = doc(contractCollection, contractId)
|
const contractRef = doc(contracts, contractId)
|
||||||
return listenForValue<Contract>(contractRef, setContract)
|
return listenForValue<Contract>(contractRef, setContract)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,7 +232,7 @@ function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotContractsQuery = query(
|
const hotContractsQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
orderBy('volume24Hours', 'desc'),
|
orderBy('volume24Hours', 'desc'),
|
||||||
|
@ -262,22 +252,22 @@ export function listenForHotContracts(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHotContracts() {
|
export async function getHotContracts() {
|
||||||
const contracts = await getValues<Contract>(hotContractsQuery)
|
const data = await getValues<Contract>(hotContractsQuery)
|
||||||
return sortBy(
|
return sortBy(
|
||||||
chooseRandomSubset(contracts, 10),
|
chooseRandomSubset(data, 10),
|
||||||
(contract) => -1 * contract.volume24Hours
|
(contract) => -1 * contract.volume24Hours
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractsBySlugs(slugs: string[]) {
|
export async function getContractsBySlugs(slugs: string[]) {
|
||||||
const q = query(contractCollection, where('slug', 'in', slugs))
|
const q = query(contracts, where('slug', 'in', slugs))
|
||||||
const snapshot = await getDocs(q)
|
const snapshot = await getDocs(q)
|
||||||
const contracts = snapshot.docs.map((doc) => doc.data() as Contract)
|
const data = snapshot.docs.map((doc) => doc.data())
|
||||||
return sortBy(contracts, (contract) => -1 * contract.volume24Hours)
|
return sortBy(data, (contract) => -1 * contract.volume24Hours)
|
||||||
}
|
}
|
||||||
|
|
||||||
const topWeeklyQuery = query(
|
const topWeeklyQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
orderBy('volume7Days', 'desc'),
|
orderBy('volume7Days', 'desc'),
|
||||||
limit(MAX_FEED_CONTRACTS)
|
limit(MAX_FEED_CONTRACTS)
|
||||||
|
@ -287,7 +277,7 @@ export async function getTopWeeklyContracts() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const closingSoonQuery = query(
|
const closingSoonQuery = query(
|
||||||
contractCollection,
|
contracts,
|
||||||
where('isResolved', '==', false),
|
where('isResolved', '==', false),
|
||||||
where('visibility', '==', 'public'),
|
where('visibility', '==', 'public'),
|
||||||
where('closeTime', '>', Date.now()),
|
where('closeTime', '>', Date.now()),
|
||||||
|
@ -296,15 +286,12 @@ const closingSoonQuery = query(
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function getClosingSoonContracts() {
|
export async function getClosingSoonContracts() {
|
||||||
const contracts = await getValues<Contract>(closingSoonQuery)
|
const data = await getValues<Contract>(closingSoonQuery)
|
||||||
return sortBy(
|
return sortBy(chooseRandomSubset(data, 2), (contract) => contract.closeTime)
|
||||||
chooseRandomSubset(contracts, 2),
|
|
||||||
(contract) => contract.closeTime
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentBetsAndComments(contract: Contract) {
|
export async function getRecentBetsAndComments(contract: Contract) {
|
||||||
const contractDoc = doc(db, 'contracts', contract.id)
|
const contractDoc = doc(contracts, contract.id)
|
||||||
|
|
||||||
const [recentBets, recentComments] = await Promise.all([
|
const [recentBets, recentComments] = await Promise.all([
|
||||||
getValues<Bet>(
|
getValues<Bet>(
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {
|
import {
|
||||||
collection,
|
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
doc,
|
doc,
|
||||||
|
getDocs,
|
||||||
query,
|
query,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
where,
|
where,
|
||||||
|
@ -9,11 +9,16 @@ import {
|
||||||
import { sortBy } from 'lodash'
|
import { sortBy } from 'lodash'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { getContractFromId } from './contracts'
|
import { getContractFromId } from './contracts'
|
||||||
import { db } from './init'
|
import {
|
||||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
coll,
|
||||||
|
getValue,
|
||||||
|
getValues,
|
||||||
|
listenForValue,
|
||||||
|
listenForValues,
|
||||||
|
} from './utils'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
|
|
||||||
const groupCollection = collection(db, 'groups')
|
export const groups = coll<Group>('groups')
|
||||||
|
|
||||||
export function groupPath(
|
export function groupPath(
|
||||||
groupSlug: string,
|
groupSlug: string,
|
||||||
|
@ -23,30 +28,29 @@ export function groupPath(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateGroup(group: Group, updates: Partial<Group>) {
|
export function updateGroup(group: Group, updates: Partial<Group>) {
|
||||||
return updateDoc(doc(groupCollection, group.id), updates)
|
return updateDoc(doc(groups, group.id), updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteGroup(group: Group) {
|
export function deleteGroup(group: Group) {
|
||||||
return deleteDoc(doc(groupCollection, group.id))
|
return deleteDoc(doc(groups, group.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllGroups() {
|
export async function listAllGroups() {
|
||||||
return getValues<Group>(groupCollection)
|
return getValues<Group>(groups)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
||||||
return listenForValues(groupCollection, setGroups)
|
return listenForValues(groups, setGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGroup(groupId: string) {
|
export function getGroup(groupId: string) {
|
||||||
return getValue<Group>(doc(groupCollection, groupId))
|
return getValue<Group>(doc(groups, groupId))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupBySlug(slug: string) {
|
export async function getGroupBySlug(slug: string) {
|
||||||
const q = query(groupCollection, where('slug', '==', slug))
|
const q = query(groups, where('slug', '==', slug))
|
||||||
const groups = await getValues<Group>(q)
|
const docs = (await getDocs(q)).docs
|
||||||
|
return docs.length === 0 ? null : docs[0].data()
|
||||||
return groups.length === 0 ? null : groups[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getGroupContracts(group: Group) {
|
export async function getGroupContracts(group: Group) {
|
||||||
|
@ -68,14 +72,14 @@ export function listenForGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
setGroup: (group: Group | null) => void
|
setGroup: (group: Group | null) => void
|
||||||
) {
|
) {
|
||||||
return listenForValue(doc(groupCollection, groupId), setGroup)
|
return listenForValue(doc(groups, groupId), setGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForMemberGroups(
|
export function listenForMemberGroups(
|
||||||
userId: string,
|
userId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
setGroups: (groups: Group[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(groupCollection, where('memberIds', 'array-contains', userId))
|
const q = query(groups, where('memberIds', 'array-contains', userId))
|
||||||
|
|
||||||
return listenForValues<Group>(q, (groups) => {
|
return listenForValues<Group>(q, (groups) => {
|
||||||
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
|
const sorted = sortBy(groups, [(group) => -group.mostRecentActivityTime])
|
||||||
|
@ -87,10 +91,27 @@ export async function getGroupsWithContractId(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
setGroups: (groups: Group[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(
|
const q = query(groups, where('contractIds', 'array-contains', contractId))
|
||||||
groupCollection,
|
setGroups(await getValues<Group>(q))
|
||||||
where('contractIds', 'array-contains', contractId)
|
}
|
||||||
)
|
|
||||||
const groups = await getValues<Group>(q)
|
export async function joinGroup(group: Group, userId: string): Promise<Group> {
|
||||||
setGroups(groups)
|
const { memberIds } = group
|
||||||
|
if (memberIds.includes(userId)) {
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
const newMemberIds = [...memberIds, userId]
|
||||||
|
const newGroup = { ...group, memberIds: newMemberIds }
|
||||||
|
await updateGroup(newGroup, { memberIds: newMemberIds })
|
||||||
|
return newGroup
|
||||||
|
}
|
||||||
|
export async function leaveGroup(group: Group, userId: string): Promise<Group> {
|
||||||
|
const { memberIds } = group
|
||||||
|
if (!memberIds.includes(userId)) {
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
const newMemberIds = memberIds.filter((id) => id !== userId)
|
||||||
|
const newGroup = { ...group, memberIds: newMemberIds }
|
||||||
|
await updateGroup(newGroup, { memberIds: newMemberIds })
|
||||||
|
return newGroup
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,12 @@
|
||||||
import {
|
import { getDoc, orderBy, query, setDoc, where } from 'firebase/firestore'
|
||||||
collection,
|
|
||||||
getDoc,
|
|
||||||
orderBy,
|
|
||||||
query,
|
|
||||||
setDoc,
|
|
||||||
where,
|
|
||||||
} from 'firebase/firestore'
|
|
||||||
import { doc } from 'firebase/firestore'
|
import { doc } from 'firebase/firestore'
|
||||||
import { Manalink } from '../../../common/manalink'
|
import { Manalink } from '../../../common/manalink'
|
||||||
import { db } from './init'
|
|
||||||
import { customAlphabet } from 'nanoid'
|
import { customAlphabet } from 'nanoid'
|
||||||
import { listenForValues } from './utils'
|
import { coll, listenForValues } from './utils'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export const manalinks = coll<Manalink>('manalinks')
|
||||||
|
|
||||||
export async function createManalink(data: {
|
export async function createManalink(data: {
|
||||||
fromId: string
|
fromId: string
|
||||||
amount: number
|
amount: number
|
||||||
|
@ -45,29 +39,25 @@ export async function createManalink(data: {
|
||||||
message,
|
message,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ref = doc(db, 'manalinks', slug)
|
await setDoc(doc(manalinks, slug), manalink)
|
||||||
await setDoc(ref, manalink)
|
|
||||||
return slug
|
return slug
|
||||||
}
|
}
|
||||||
|
|
||||||
const manalinkCol = collection(db, 'manalinks')
|
|
||||||
|
|
||||||
// TODO: This required an index, make sure to also set up in prod
|
// TODO: This required an index, make sure to also set up in prod
|
||||||
function listUserManalinks(fromId?: string) {
|
function listUserManalinks(fromId?: string) {
|
||||||
return query(
|
return query(
|
||||||
manalinkCol,
|
manalinks,
|
||||||
where('fromId', '==', fromId),
|
where('fromId', '==', fromId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getManalink(slug: string) {
|
export async function getManalink(slug: string) {
|
||||||
const docSnap = await getDoc(doc(db, 'manalinks', slug))
|
return (await getDoc(doc(manalinks, slug))).data()
|
||||||
return docSnap.data() as Manalink
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useManalink(slug: string) {
|
export function useManalink(slug: string) {
|
||||||
const [manalink, setManalink] = useState<Manalink | null>(null)
|
const [manalink, setManalink] = useState<Manalink | undefined>(undefined)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (slug) {
|
if (slug) {
|
||||||
getManalink(slug).then(setManalink)
|
getManalink(slug).then(setManalink)
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
import { ManalinkTxn, DonationTxn, TipTxn } from 'common/txn'
|
import { ManalinkTxn, DonationTxn, TipTxn, Txn } from 'common/txn'
|
||||||
import { collection, orderBy, query, where } from 'firebase/firestore'
|
import { orderBy, query, where } from 'firebase/firestore'
|
||||||
import { db } from './init'
|
import { coll, getValues, listenForValues } from './utils'
|
||||||
import { getValues, listenForValues } from './utils'
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { orderBy as _orderBy } from 'lodash'
|
import { orderBy as _orderBy } from 'lodash'
|
||||||
|
|
||||||
const txnCollection = collection(db, 'txns')
|
export const txns = coll<Txn>('txns')
|
||||||
|
|
||||||
const getCharityQuery = (charityId: string) =>
|
const getCharityQuery = (charityId: string) =>
|
||||||
query(
|
query(
|
||||||
txnCollection,
|
txns,
|
||||||
where('toType', '==', 'CHARITY'),
|
where('toType', '==', 'CHARITY'),
|
||||||
where('toId', '==', charityId),
|
where('toId', '==', charityId),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
|
@ -22,7 +21,7 @@ export function listenForCharityTxns(
|
||||||
return listenForValues<DonationTxn>(getCharityQuery(charityId), setTxns)
|
return listenForValues<DonationTxn>(getCharityQuery(charityId), setTxns)
|
||||||
}
|
}
|
||||||
|
|
||||||
const charitiesQuery = query(txnCollection, where('toType', '==', 'CHARITY'))
|
const charitiesQuery = query(txns, where('toType', '==', 'CHARITY'))
|
||||||
|
|
||||||
export function getAllCharityTxns() {
|
export function getAllCharityTxns() {
|
||||||
return getValues<DonationTxn>(charitiesQuery)
|
return getValues<DonationTxn>(charitiesQuery)
|
||||||
|
@ -30,7 +29,7 @@ export function getAllCharityTxns() {
|
||||||
|
|
||||||
const getTipsQuery = (contractId: string) =>
|
const getTipsQuery = (contractId: string) =>
|
||||||
query(
|
query(
|
||||||
txnCollection,
|
txns,
|
||||||
where('category', '==', 'TIP'),
|
where('category', '==', 'TIP'),
|
||||||
where('data.contractId', '==', contractId)
|
where('data.contractId', '==', contractId)
|
||||||
)
|
)
|
||||||
|
@ -50,13 +49,13 @@ export function useManalinkTxns(userId: string) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TODO: Need to instantiate these indexes too
|
// TODO: Need to instantiate these indexes too
|
||||||
const fromQuery = query(
|
const fromQuery = query(
|
||||||
txnCollection,
|
txns,
|
||||||
where('fromId', '==', userId),
|
where('fromId', '==', userId),
|
||||||
where('category', '==', 'MANALINK'),
|
where('category', '==', 'MANALINK'),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
const toQuery = query(
|
const toQuery = query(
|
||||||
txnCollection,
|
txns,
|
||||||
where('toId', '==', userId),
|
where('toId', '==', userId),
|
||||||
where('category', '==', 'MANALINK'),
|
where('category', '==', 'MANALINK'),
|
||||||
orderBy('createdTime', 'desc')
|
orderBy('createdTime', 'desc')
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import {
|
import {
|
||||||
getFirestore,
|
|
||||||
doc,
|
doc,
|
||||||
setDoc,
|
setDoc,
|
||||||
getDoc,
|
getDoc,
|
||||||
|
@ -23,58 +22,62 @@ import {
|
||||||
} from 'firebase/auth'
|
} from 'firebase/auth'
|
||||||
import { throttle, zip } from 'lodash'
|
import { throttle, zip } from 'lodash'
|
||||||
|
|
||||||
import { app } from './init'
|
import { app, db } from './init'
|
||||||
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
|
||||||
import { createUser } from './fn-call'
|
import { createUser } from './fn-call'
|
||||||
import { getValue, getValues, listenForValue, listenForValues } from './utils'
|
import {
|
||||||
|
coll,
|
||||||
|
getValue,
|
||||||
|
getValues,
|
||||||
|
listenForValue,
|
||||||
|
listenForValues,
|
||||||
|
} from './utils'
|
||||||
import { feed } from 'common/feed'
|
import { feed } from 'common/feed'
|
||||||
import { CATEGORY_LIST } from 'common/categories'
|
import { CATEGORY_LIST } from 'common/categories'
|
||||||
import { safeLocalStorage } from '../util/local'
|
import { safeLocalStorage } from '../util/local'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
|
|
||||||
|
export const users = coll<User>('users')
|
||||||
|
export const privateUsers = coll<PrivateUser>('private-users')
|
||||||
|
|
||||||
export type { User }
|
export type { User }
|
||||||
|
|
||||||
export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime'
|
export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime'
|
||||||
|
|
||||||
const db = getFirestore(app)
|
|
||||||
export const auth = getAuth(app)
|
export const auth = getAuth(app)
|
||||||
|
|
||||||
export const userDocRef = (userId: string) => doc(db, 'users', userId)
|
|
||||||
|
|
||||||
export async function getUser(userId: string) {
|
export async function getUser(userId: string) {
|
||||||
const docSnap = await getDoc(userDocRef(userId))
|
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||||
return docSnap.data() as User
|
return (await getDoc(doc(users, userId))).data()!
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserByUsername(username: string) {
|
export async function getUserByUsername(username: string) {
|
||||||
// Find a user whose username matches the given username, or null if no such user exists.
|
// Find a user whose username matches the given username, or null if no such user exists.
|
||||||
const userCollection = collection(db, 'users')
|
const q = query(users, where('username', '==', username), limit(1))
|
||||||
const q = query(userCollection, where('username', '==', username), limit(1))
|
const docs = (await getDocs(q)).docs
|
||||||
const docs = await getDocs(q)
|
return docs.length > 0 ? docs[0].data() : null
|
||||||
const users = docs.docs.map((doc) => doc.data() as User)
|
|
||||||
return users[0] || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setUser(userId: string, user: User) {
|
export async function setUser(userId: string, user: User) {
|
||||||
await setDoc(doc(db, 'users', userId), user)
|
await setDoc(doc(users, userId), user)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(userId: string, update: Partial<User>) {
|
export async function updateUser(userId: string, update: Partial<User>) {
|
||||||
await updateDoc(doc(db, 'users', userId), { ...update })
|
await updateDoc(doc(users, userId), { ...update })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePrivateUser(
|
export async function updatePrivateUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
update: Partial<PrivateUser>
|
update: Partial<PrivateUser>
|
||||||
) {
|
) {
|
||||||
await updateDoc(doc(db, 'private-users', userId), { ...update })
|
await updateDoc(doc(privateUsers, userId), { ...update })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForUser(
|
export function listenForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
setUser: (user: User | null) => void
|
setUser: (user: User | null) => void
|
||||||
) {
|
) {
|
||||||
const userRef = doc(db, 'users', userId)
|
const userRef = doc(users, userId)
|
||||||
return listenForValue<User>(userRef, setUser)
|
return listenForValue<User>(userRef, setUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,7 +85,7 @@ export function listenForPrivateUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
setPrivateUser: (privateUser: PrivateUser | null) => void
|
setPrivateUser: (privateUser: PrivateUser | null) => void
|
||||||
) {
|
) {
|
||||||
const userRef = doc(db, 'private-users', userId)
|
const userRef = doc(privateUsers, userId)
|
||||||
return listenForValue<PrivateUser>(userRef, setPrivateUser)
|
return listenForValue<PrivateUser>(userRef, setPrivateUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -152,36 +155,29 @@ export async function listUsers(userIds: string[]) {
|
||||||
if (userIds.length > 10) {
|
if (userIds.length > 10) {
|
||||||
throw new Error('Too many users requested at once; Firestore limits to 10')
|
throw new Error('Too many users requested at once; Firestore limits to 10')
|
||||||
}
|
}
|
||||||
const userCollection = collection(db, 'users')
|
const q = query(users, where('id', 'in', userIds))
|
||||||
const q = query(userCollection, where('id', 'in', userIds))
|
const docs = (await getDocs(q)).docs
|
||||||
const docs = await getDocs(q)
|
return docs.map((doc) => doc.data())
|
||||||
return docs.docs.map((doc) => doc.data() as User)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllUsers() {
|
export async function listAllUsers() {
|
||||||
const userCollection = collection(db, 'users')
|
const docs = (await getDocs(users)).docs
|
||||||
const q = query(userCollection)
|
return docs.map((doc) => doc.data())
|
||||||
const docs = await getDocs(q)
|
|
||||||
return docs.docs.map((doc) => doc.data() as User)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForAllUsers(setUsers: (users: User[]) => void) {
|
export function listenForAllUsers(setUsers: (users: User[]) => void) {
|
||||||
const userCollection = collection(db, 'users')
|
listenForValues(users, setUsers)
|
||||||
const q = query(userCollection)
|
|
||||||
listenForValues(q, setUsers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForPrivateUsers(
|
export function listenForPrivateUsers(
|
||||||
setUsers: (users: PrivateUser[]) => void
|
setUsers: (users: PrivateUser[]) => void
|
||||||
) {
|
) {
|
||||||
const userCollection = collection(db, 'private-users')
|
listenForValues(privateUsers, setUsers)
|
||||||
const q = query(userCollection)
|
|
||||||
listenForValues(q, setUsers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopTraders(period: Period) {
|
export function getTopTraders(period: Period) {
|
||||||
const topTraders = query(
|
const topTraders = query(
|
||||||
collection(db, 'users'),
|
users,
|
||||||
orderBy('profitCached.' + period, 'desc'),
|
orderBy('profitCached.' + period, 'desc'),
|
||||||
limit(20)
|
limit(20)
|
||||||
)
|
)
|
||||||
|
@ -191,7 +187,7 @@ export function getTopTraders(period: Period) {
|
||||||
|
|
||||||
export function getTopCreators(period: Period) {
|
export function getTopCreators(period: Period) {
|
||||||
const topCreators = query(
|
const topCreators = query(
|
||||||
collection(db, 'users'),
|
users,
|
||||||
orderBy('creatorVolumeCached.' + period, 'desc'),
|
orderBy('creatorVolumeCached.' + period, 'desc'),
|
||||||
limit(20)
|
limit(20)
|
||||||
)
|
)
|
||||||
|
@ -199,22 +195,21 @@ export function getTopCreators(period: Period) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopFollowed() {
|
export async function getTopFollowed() {
|
||||||
const users = await getValues<User>(topFollowedQuery)
|
return (await getValues<User>(topFollowedQuery)).slice(0, 20)
|
||||||
return users.slice(0, 20)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const topFollowedQuery = query(
|
const topFollowedQuery = query(
|
||||||
collection(db, 'users'),
|
users,
|
||||||
orderBy('followerCountCached', 'desc'),
|
orderBy('followerCountCached', 'desc'),
|
||||||
limit(20)
|
limit(20)
|
||||||
)
|
)
|
||||||
|
|
||||||
export function getUsers() {
|
export function getUsers() {
|
||||||
return getValues<User>(collection(db, 'users'))
|
return getValues<User>(users)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserFeed(userId: string) {
|
export async function getUserFeed(userId: string) {
|
||||||
const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed')
|
const feedDoc = doc(privateUsers, userId, 'cache', 'feed')
|
||||||
const userFeed = await getValue<{
|
const userFeed = await getValue<{
|
||||||
feed: feed
|
feed: feed
|
||||||
}>(feedDoc)
|
}>(feedDoc)
|
||||||
|
@ -222,7 +217,7 @@ export async function getUserFeed(userId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCategoryFeeds(userId: string) {
|
export async function getCategoryFeeds(userId: string) {
|
||||||
const cacheCollection = collection(db, 'private-users', userId, 'cache')
|
const cacheCollection = collection(privateUsers, userId, 'cache')
|
||||||
const feedData = await Promise.all(
|
const feedData = await Promise.all(
|
||||||
CATEGORY_LIST.map((category) =>
|
CATEGORY_LIST.map((category) =>
|
||||||
getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`))
|
getValue<{ feed: feed }>(doc(cacheCollection, `feed-${category}`))
|
||||||
|
@ -233,7 +228,7 @@ export async function getCategoryFeeds(userId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function follow(userId: string, followedUserId: string) {
|
export async function follow(userId: string, followedUserId: string) {
|
||||||
const followDoc = doc(db, 'users', userId, 'follows', followedUserId)
|
const followDoc = doc(collection(users, userId, 'follows'), followedUserId)
|
||||||
await setDoc(followDoc, {
|
await setDoc(followDoc, {
|
||||||
userId: followedUserId,
|
userId: followedUserId,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
@ -241,7 +236,7 @@ export async function follow(userId: string, followedUserId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unfollow(userId: string, unfollowedUserId: string) {
|
export async function unfollow(userId: string, unfollowedUserId: string) {
|
||||||
const followDoc = doc(db, 'users', userId, 'follows', unfollowedUserId)
|
const followDoc = doc(collection(users, userId, 'follows'), unfollowedUserId)
|
||||||
await deleteDoc(followDoc)
|
await deleteDoc(followDoc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -259,7 +254,7 @@ export function listenForFollows(
|
||||||
userId: string,
|
userId: string,
|
||||||
setFollowIds: (followIds: string[]) => void
|
setFollowIds: (followIds: string[]) => void
|
||||||
) {
|
) {
|
||||||
const follows = collection(db, 'users', userId, 'follows')
|
const follows = collection(users, userId, 'follows')
|
||||||
return listenForValues<{ userId: string }>(follows, (docs) =>
|
return listenForValues<{ userId: string }>(follows, (docs) =>
|
||||||
setFollowIds(docs.map(({ userId }) => userId))
|
setFollowIds(docs.map(({ userId }) => userId))
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
import {
|
import {
|
||||||
|
collection,
|
||||||
getDoc,
|
getDoc,
|
||||||
getDocs,
|
getDocs,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
Query,
|
Query,
|
||||||
|
CollectionReference,
|
||||||
DocumentReference,
|
DocumentReference,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
|
import { db } from './init'
|
||||||
|
|
||||||
|
export const coll = <T>(path: string, ...rest: string[]) => {
|
||||||
|
return collection(db, path, ...rest) as CollectionReference<T>
|
||||||
|
}
|
||||||
|
|
||||||
export const getValue = async <T>(doc: DocumentReference) => {
|
export const getValue = async <T>(doc: DocumentReference) => {
|
||||||
const snap = await getDoc(doc)
|
const snap = await getDoc(doc)
|
||||||
|
|
|
@ -4,9 +4,6 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api'
|
||||||
module.exports = {
|
module.exports = {
|
||||||
staticPageGenerationTimeout: 600, // e.g. stats page
|
staticPageGenerationTimeout: 600, // e.g. stats page
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
typescript: {
|
|
||||||
ignoreBuildErrors: true,
|
|
||||||
},
|
|
||||||
experimental: {
|
experimental: {
|
||||||
externalDir: true,
|
externalDir: true,
|
||||||
optimizeCss: true,
|
optimizeCss: true,
|
||||||
|
|
|
@ -14,7 +14,8 @@
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "npx prettier --write .",
|
"format": "npx prettier --write .",
|
||||||
"postbuild": "next-sitemap",
|
"postbuild": "next-sitemap",
|
||||||
"verify": "(cd .. && yarn verify)"
|
"verify": "(cd .. && yarn verify)",
|
||||||
|
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@amplitude/analytics-browser": "0.4.1",
|
"@amplitude/analytics-browser": "0.4.1",
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { GroupSelector } from 'web/components/groups/group-selector'
|
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||||
import { CATEGORIES } from 'common/categories'
|
import { CATEGORIES } from 'common/categories'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
|
||||||
export default function Create() {
|
export default function Create() {
|
||||||
const [question, setQuestion] = useState('')
|
const [question, setQuestion] = useState('')
|
||||||
|
@ -33,7 +34,13 @@ export default function Create() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { groupId } = router.query as { groupId: string }
|
const { groupId } = router.query as { groupId: string }
|
||||||
useTracking('view create page')
|
useTracking('view create page')
|
||||||
if (!router.isReady) return <div />
|
const creator = useUser()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (creator === null) router.push('/')
|
||||||
|
}, [creator, router])
|
||||||
|
|
||||||
|
if (!router.isReady || !creator) return <div />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
@ -58,7 +65,11 @@ export default function Create() {
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<Spacer h={6} />
|
<Spacer h={6} />
|
||||||
<NewContract question={question} groupId={groupId} />
|
<NewContract
|
||||||
|
question={question}
|
||||||
|
groupId={groupId}
|
||||||
|
creator={creator}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
@ -66,14 +77,12 @@ export default function Create() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow user to create a new contract
|
// Allow user to create a new contract
|
||||||
export function NewContract(props: { question: string; groupId?: string }) {
|
export function NewContract(props: {
|
||||||
const { question, groupId } = props
|
creator: User
|
||||||
const creator = useUser()
|
question: string
|
||||||
|
groupId?: string
|
||||||
useEffect(() => {
|
}) {
|
||||||
if (creator === null) router.push('/')
|
const { creator, question, groupId } = props
|
||||||
}, [creator])
|
|
||||||
|
|
||||||
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
|
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
|
||||||
const [initialProb] = useState(50)
|
const [initialProb] = useState(50)
|
||||||
const [minString, setMinString] = useState('')
|
const [minString, setMinString] = useState('')
|
||||||
|
|
|
@ -15,6 +15,8 @@ import { getUser, User } from 'web/lib/firebase/users'
|
||||||
import { Tabs } from 'web/components/layout/tabs'
|
import { Tabs } from 'web/components/layout/tabs'
|
||||||
import { GroupMembersList } from 'web/pages/group/[...slugs]'
|
import { GroupMembersList } from 'web/pages/group/[...slugs]'
|
||||||
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
|
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
|
||||||
|
import { SiteLink } from 'web/components/site-link'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
const groups = await listAllGroups().catch((_) => [])
|
const groups = await listAllGroups().catch((_) => [])
|
||||||
|
@ -105,7 +107,7 @@ export default function Groups(props: {
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
...(user
|
...(user && memberGroupIds.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: 'My Groups',
|
title: 'My Groups',
|
||||||
|
@ -202,3 +204,16 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) {
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GroupLink(props: { group: Group; className?: string }) {
|
||||||
|
const { group, className } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteLink
|
||||||
|
href={groupPath(group.slug)}
|
||||||
|
className={clsx('z-10 truncate', className)}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</SiteLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"composite": true,
|
|
||||||
"baseUrl": "../",
|
"baseUrl": "../",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
@ -17,15 +16,9 @@
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true
|
"incremental": true
|
||||||
},
|
},
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "../common"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
"watchOptions": {
|
"watchOptions": {
|
||||||
"excludeDirectories": [".next"]
|
"excludeDirectories": [".next"]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "../common/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user