Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ REACT_APP_CORS_PROXY_URL=http://localhost:8001
REACT_APP_DAO_DEPLOYER_API=http://localhost:3001
REACT_APP_ENV=DEV
REACT_APP_HASURA_URL=http://localhost:8080/v1/graphql
# Homebase Lite backend: off-chain polls, communities, and DAO email alerts
REACT_APP_LITE_API_URL=http://localhost:3005
REACT_APP_LAUNCH_DARKLY_SDK_DEV=your_launch_darkly_sdk_key_here

REACT_APP_MIXPANEL_DEBUG_ENABLED=false
Expand Down
20 changes: 20 additions & 0 deletions src/models/Polls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ export enum ProposalStatus {
ACTIVE = "active",
CLOSED = "closed"
}
export interface PollFundingRequest {
recipient: string
amount: string
}

export interface PollOnchainProposal {
daoAddress: string
proposalKey: string
network: string
}

export interface Poll {
_id?: string
daoID: string | undefined
Expand All @@ -27,6 +38,15 @@ export interface Poll {
isXTZ: boolean
id?: string
getStatus?: any
// Optional treasury funding attached to the poll. Sent to the lite backend
// inside the signed payload and returned by the poll read endpoints.
fundingRequest?: PollFundingRequest
// Set by the backend once the poll has been promoted to an on-chain proposal.
onchainProposal?: PollOnchainProposal
// Form-only fields for the optional "Funding request" section. They are
// folded into `fundingRequest` (or dropped) before the payload is signed.
fundingRecipient?: string
fundingAmount?: string
}

export interface Vote {
Expand Down
125 changes: 125 additions & 0 deletions src/modules/explorer/components/DAOEmailAlerts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import React, { useState } from "react"
import { Grid, styled, TextField, Typography } from "@mui/material"
import { SmallButton } from "modules/common/SmallButton"
import { useNotification } from "modules/common/hooks/useNotification"
import { useTezos } from "services/beacon/hooks/useTezos"
import { subscribeToDAOAlerts } from "services/services/lite/lite-services"
import { ContentContainer } from "./ContentContainer"

const AlertsContainer = styled(ContentContainer)(({ theme }) => ({
padding: "24px 38px",
[theme.breakpoints.down("lg")]: {
width: "inherit"
}
}))

const TitleText = styled(Typography)({
fontSize: 18,
fontWeight: 500
})

const HelperText = styled(Typography)(({ theme }) => ({
fontSize: 14,
fontWeight: 300,
color: theme.palette.primary.light
}))

const EmailInput = styled(TextField)({
"background": "#2f3438",
"borderRadius": 8,
"flex": "1 1 280px",
"maxWidth": 480,
"& .MuiInputBase-input": {
padding: "12px 16px",
fontSize: 16,
fontWeight: 300
}
})

// Deliberately permissive: the backend is the authority on deliverability, this
// only stops obviously malformed input from costing a round trip.
const looksLikeEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())

export const DAOEmailAlerts: React.FC<{ daoAddress: string; daoName?: string }> = ({ daoAddress, daoName }) => {
const { network } = useTezos()
const openNotification = useNotification()
const [email, setEmail] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [confirmationSent, setConfirmationSent] = useState(false)

const onSubscribe = async () => {
if (!looksLikeEmail(email)) {
openNotification({
message: "Please enter a valid email address",
autoHideDuration: 3000,
variant: "error"
})
return
}

try {
setIsSubmitting(true)
const resp = await subscribeToDAOAlerts(email.trim(), daoAddress, network, daoName)

if (!resp.ok) {
openNotification({
message: "Could not subscribe to email alerts",
autoHideDuration: 3000,
variant: "error"
})
return
}

setConfirmationSent(true)
setEmail("")
openNotification({
message: "Check your inbox to confirm",
autoHideDuration: 5000,
variant: "success"
})
} catch (error) {
console.log("error: ", error)
openNotification({
message: "Could not subscribe to email alerts",
autoHideDuration: 3000,
variant: "error"
})
} finally {
setIsSubmitting(false)
}
}

return (
<AlertsContainer item>
<Grid container direction="column" style={{ gap: 12 }}>
<Grid item>
<TitleText color="textPrimary">Get email alerts for this DAO</TitleText>
<HelperText>Be notified when proposals are created and when voting is about to close.</HelperText>
</Grid>
<Grid item container direction="row" alignItems="center" wrap="wrap" style={{ gap: 12 }}>
<EmailInput
type="email"
value={email}
placeholder="you@example.com"
variant="standard"
InputProps={{ disableUnderline: true }}
onChange={event => setEmail(event.target.value)}
onKeyDown={event => {
if (event.key === "Enter" && !isSubmitting) {
onSubscribe()
}
}}
/>
<SmallButton variant="contained" color="secondary" disabled={isSubmitting} onClick={onSubscribe}>
{isSubmitting ? "Subscribing..." : "Subscribe"}
</SmallButton>
</Grid>
{confirmationSent ? (
<Grid item>
<HelperText color="secondary">Check your inbox to confirm</HelperText>
</Grid>
) : null}
</Grid>
</AlertsContainer>
)
}
112 changes: 112 additions & 0 deletions src/modules/explorer/components/PendingVotesBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React, { useMemo } from "react"
import { Grid, styled, Typography } from "@mui/material"
import HowToVoteIcon from "@mui/icons-material/HowToVote"
import { useHistory } from "react-router-dom"
import { useDAO } from "services/services/dao/hooks/useDAO"
import { useProposals } from "services/services/dao/hooks/useProposals"
import { ProposalStatus } from "services/services/dao/mappers/proposal/types"
import { useTezos } from "services/beacon/hooks/useTezos"
import { useDAOID } from "../pages/DAO/router"
import { ContentContainer } from "./ContentContainer"

const BannerContainer = styled(ContentContainer)(({ theme }) => ({
"padding": "18px 38px",
"cursor": "pointer",
"border": `1px solid ${theme.palette.secondary.main}`,
"&:hover": {
opacity: 0.9
},
[theme.breakpoints.down("lg")]: {
width: "inherit"
}
}))

const BannerText = styled(Typography)({
fontSize: 16,
fontWeight: 500
})

const BannerHint = styled(Typography)(({ theme }) => ({
fontSize: 14,
fontWeight: 300,
color: theme.palette.primary.light
}))

/**
* Nudges a governance-token holder towards proposals that are open for voting
* and that they have not voted on yet.
*/
export const PendingVotesBanner: React.FC = () => {
const daoId = useDAOID()
const navigate = useHistory()
const { account } = useTezos()
const { data: dao, cycleInfo, ledger } = useDAO(daoId)
const { data: proposals } = useProposals(daoId)

// Only nudge people who actually have a stake in this DAO.
const isTokenHolder = useMemo(() => {
if (!account || !ledger) {
return false
}

return ledger.some(
entry => entry.holder.address.toLowerCase() === account.toLowerCase() && entry.total_balance.gt(0)
)
}, [account, ledger])

const pendingProposals = useMemo(() => {
if (!proposals || !cycleInfo || !account) {
return []
}

return proposals.filter(proposal => {
const status = proposal.getStatus(cycleInfo.currentLevel).status
if (status !== ProposalStatus.ACTIVE) {
return false
}

return !proposal.voters.some(
(voter: { address: string }) => voter.address.toLowerCase() === account.toLowerCase()
)
})
}, [proposals, cycleInfo, account])

// The DAO alternates proposing/voting periods of `period` blocks. Only show a
// countdown when we have both the blocks left and an average block time.
const closesIn = useMemo(() => {
if (!cycleInfo || cycleInfo.type !== "voting" || !cycleInfo.timeEstimateForNextBlock) {
return undefined
}

const secondsLeft = cycleInfo.blocksLeft * cycleInfo.timeEstimateForNextBlock
if (!Number.isFinite(secondsLeft) || secondsLeft <= 0) {
return undefined
}

const hoursLeft = Math.round(secondsLeft / 3600)
if (hoursLeft < 1) {
return `${Math.max(1, Math.round(secondsLeft / 60))} minutes`
}

return `${hoursLeft} ${hoursLeft === 1 ? "hour" : "hours"}`
}, [cycleInfo])

if (!dao || !isTokenHolder || pendingProposals.length === 0) {
return null
}

return (
<BannerContainer item onClick={() => navigate.push(`/explorer/dao/${daoId}/proposals`)}>
<Grid container direction="row" alignItems="center" style={{ gap: 14 }}>
<HowToVoteIcon color="secondary" />
<Grid item>
<BannerText color="textPrimary">
{pendingProposals.length} {pendingProposals.length === 1 ? "proposal is" : "proposals are"} waiting for your
vote
</BannerText>
{closesIn ? <BannerHint>Voting closes in {closesIn}</BannerHint> : null}
</Grid>
</Grid>
</BannerContainer>
)
}
14 changes: 12 additions & 2 deletions src/modules/explorer/components/ProposalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ interface Props {
handleClose: () => void
defaultValues?: ProposalFormDefaultValues
defaultTab: number
// Fired only when the form is actually submitted, so callers can tell a
// submit apart from a plain dismiss.
onSubmitted?: () => void
}

const enabledForms: Record<
Expand Down Expand Up @@ -92,7 +95,13 @@ const Content = styled(Grid)({
paddingBottom: 24
})

export const ProposalFormContainer: React.FC<Props> = ({ open, handleClose, defaultValues, defaultTab }) => {
export const ProposalFormContainer: React.FC<Props> = ({
open,
handleClose,
defaultValues,
defaultTab,
onSubmitted
}) => {
const daoId = useDAOID()
const { data: dao } = useDAO(daoId)
const { data: daoHoldings } = useDAOHoldings(daoId)
Expand Down Expand Up @@ -159,8 +168,9 @@ export const ProposalFormContainer: React.FC<Props> = ({ open, handleClose, defa

methods.reset()
handleClose()
onSubmitted?.()
},
[dao, handleClose, methods, registryMutate]
[dao, handleClose, methods, registryMutate, onSubmitted]
)

return (
Expand Down
42 changes: 42 additions & 0 deletions src/modules/explorer/hooks/useAlertsOutcomeToast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect } from "react"
import { useHistory, useLocation } from "react-router-dom"
import { useNotification } from "modules/common/hooks/useNotification"

const OUTCOMES: Record<string, { message: string; variant: "success" | "info" | "error" }> = {
confirmed: { message: "Email alerts confirmed", variant: "success" },
unsubscribed: { message: "Unsubscribed from email alerts", variant: "info" },
invalid: { message: "This email alerts link is no longer valid", variant: "error" }
}

/**
* The confirm/unsubscribe links in the alert emails land back in the app with
* ?alerts=confirmed|unsubscribed|invalid. Show the outcome once and drop the
* param, leaving any other query params untouched.
*/
export const useAlertsOutcomeToast = () => {
const location = useLocation()
const history = useHistory()
const openNotification = useNotification()
const alertsParam = new URLSearchParams(location.search).get("alerts")

useEffect(() => {
if (!alertsParam) {
return
}

const outcome = OUTCOMES[alertsParam]
if (outcome) {
openNotification({
message: outcome.message,
autoHideDuration: 5000,
variant: outcome.variant
})
}

const searchParams = new URLSearchParams(location.search)
searchParams.delete("alerts")
history.replace({ pathname: location.pathname, search: searchParams.toString() })
// openNotification is recreated on every render, so it is deliberately not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [alertsParam])
}
10 changes: 10 additions & 0 deletions src/modules/explorer/pages/DAO/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { useDAOID } from "./router"
import { ContentContainer } from "../../components/ContentContainer"
import { DAOStatsRow } from "../../components/DAOStatsRow"
import { UsersTable } from "../../components/UsersTable"
import { DAOEmailAlerts } from "../../components/DAOEmailAlerts"
import { PendingVotesBanner } from "../../components/PendingVotesBanner"
import { useAlertsOutcomeToast } from "modules/explorer/hooks/useAlertsOutcomeToast"

import { SmallButton } from "../../../common/SmallButton"
import { DaoSettingModal } from "./components/Settings"
Expand Down Expand Up @@ -95,6 +98,8 @@ export const DAOOverview: React.FC = () => {
const [openDialog, setOpenDialog] = useState(false)
const [openChangeDialog, setChangeOpenDialog] = useState(false)

useAlertsOutcomeToast()

const handleCloseModal = () => {
setOpenDialog(false)
}
Expand Down Expand Up @@ -125,6 +130,7 @@ export const DAOOverview: React.FC = () => {

return (
<Grid container direction="column" style={{ gap: isExtraSmall ? 25 : 32 }}>
<PendingVotesBanner />
<HeroContainer item>
<Grid container direction="column" style={{ gap: isExtraSmall ? 40 : 20 }}>
<Grid item>
Expand Down Expand Up @@ -227,6 +233,10 @@ export const DAOOverview: React.FC = () => {
</HeroContainer>
<DAOStatsRow />

{data?.data.address && !data?.data.network?.startsWith("etherlink") ? (
<DAOEmailAlerts daoAddress={data.data.address} daoName={data.data.name} />
) : null}

<Grid item style={{ width: "inherit" }}>
<UsersTable data={usersTableData} symbol={symbol || ""} />
</Grid>
Expand Down
Loading
Loading