diff --git a/src/common/api/private-api.ts b/src/common/api/private-api.ts index 0642c90715b..c9c4c8fb883 100644 --- a/src/common/api/private-api.ts +++ b/src/common/api/private-api.ts @@ -15,6 +15,7 @@ import { AppWindow } from "../../client/window"; import { NotifyTypes } from "../enums"; import { BeneficiaryRoute, MetaData, RewardType } from "./operations"; import { ThreeSpeakVideo } from "./threespeak"; +import { PollSnapshot } from "../features/polls"; declare var window: AppWindow; @@ -217,6 +218,7 @@ export interface DraftMetadata extends MetaData { beneficiaries: BeneficiaryRoute[]; rewardType: RewardType; videos?: Record; + poll?: PollSnapshot; } export interface Draft { diff --git a/src/common/app.tsx b/src/common/app.tsx index 51ad4560c3e..7fb8b38dbf0 100644 --- a/src/common/app.tsx +++ b/src/common/app.tsx @@ -35,6 +35,7 @@ import { useGetAccountFullQuery } from "./api/queries"; import { UIManager } from "@ui/core"; import defaults from "./constants/defaults.json"; import { getAccessToken } from "./helper/user-token"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; // Define lazy pages const ProfileContainer = loadable(() => import("./pages/profile-functional")); @@ -117,7 +118,7 @@ const App = (props: any) => { {/*Excluded from production*/} - {/**/} + void; comment: boolean; setVideoMetadata?: (v: ThreeSpeakVideo) => void; + onAddPoll?: (poll: PollSnapshot) => void; + existingPoll?: PollSnapshot; + onDeletePoll?: () => void; + readonlyPoll?: boolean; } export const detectEvent = (eventType: string) => { @@ -62,7 +69,11 @@ export function EditorToolbar({ comment, setVideoMetadata, setVideoEncoderBeneficiary, - toggleNsfwC + toggleNsfwC, + onAddPoll, + existingPoll, + onDeletePoll, + readonlyPoll }: Props) { const { global, activeUser, users } = useMappedStore(); @@ -77,6 +88,7 @@ export function EditorToolbar({ const [gif, setGif] = useState(false); const [showVideoUpload, setShowVideoUpload] = useState(false); const [showVideoGallery, setShowVideoGallery] = useState(false); + const [showPollsCreation, setShowPollsCreation] = useState(false); const toolbarId = useMemo(() => v4(), []); const headers = useMemo(() => [...Array(3).keys()], []); @@ -481,6 +493,19 @@ export function EditorToolbar({ {linkSvg} + {!comment && ( + +
setShowPollsCreation(!showPollsCreation)} + > + +
+
+ )} )} + setShowPollsCreation(v)} + onAdd={(snap) => onAddPoll?.(snap)} + onDeletePoll={() => onDeletePoll?.()} + /> ); } diff --git a/src/common/components/entry-list-item/index.tsx b/src/common/components/entry-list-item/index.tsx index 606bdeb0958..d2f898fab30 100644 --- a/src/common/components/entry-list-item/index.tsx +++ b/src/common/components/entry-list-item/index.tsx @@ -11,7 +11,7 @@ import EntryVoteBtn from "../entry-vote-btn/index"; import EntryReblogBtn from "../entry-reblog-btn/index"; import EntryPayout from "../entry-payout/index"; import EntryVotes from "../entry-votes"; -import Tooltip from "../tooltip"; +import Tooltip, { StyledTooltip } from "../tooltip"; import EntryMenu from "../entry-menu"; import { dateToFormatted, dateToRelative } from "../../helper/parse-date"; import { _t } from "../../i18n"; @@ -29,6 +29,7 @@ import useMount from "react-use/lib/useMount"; import { useUnmount } from "react-use"; import { Community } from "../../store/communities"; import { EntryListItemThumbnail } from "./entry-list-item-thumbnail"; +import { UilPanelAdd } from "@iconscout/react-unicons"; setProxyBase(defaults.imageServer); @@ -201,6 +202,12 @@ export function EntryListItem({ {dateRelative} + + {(entry.json_metadata as any).content_type === "poll" && ( + + + + )}
{((community && !!entry.stats?.is_pinned) || entry.permlink === pinned) && ( diff --git a/src/common/components/profile-link/index.tsx b/src/common/components/profile-link/index.tsx index 43f0803a820..b28b61558cc 100644 --- a/src/common/components/profile-link/index.tsx +++ b/src/common/components/profile-link/index.tsx @@ -7,7 +7,7 @@ export const makePath = (username: string) => `/@${username}`; interface Props { history: History; - children: JSX.Element; + children: JSX.Element | JSX.Element[]; username: string; addAccount: (data: Account) => void; afterClick?: () => void; diff --git a/src/common/components/tooltip/index.tsx b/src/common/components/tooltip/index.tsx index f4be9d807b4..e1695f02e13 100644 --- a/src/common/components/tooltip/index.tsx +++ b/src/common/components/tooltip/index.tsx @@ -2,6 +2,7 @@ import React, { ReactNode, useState } from "react"; import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { useMountedState } from "react-use"; +import { classNameObject } from "../../helper/class-name-object"; interface Props { content: string | JSX.Element; @@ -16,9 +17,10 @@ export default function ({ content, children }: Props) { interface StyledProps { children: ReactNode; content: ReactNode; + className?: string; } -export function StyledTooltip({ children, content }: StyledProps) { +export function StyledTooltip({ children, content, className }: StyledProps) { const [ref, setRef] = useState(); const [popperElement, setPopperElement] = useState(); const [show, setShow] = useState(false); @@ -30,7 +32,10 @@ export function StyledTooltip({ children, content }: StyledProps) { return isMounted() ? (
{ setShow(true); popper.update?.(); diff --git a/src/common/core/react-query.ts b/src/common/core/react-query.ts index 2469ba6f4aa..3f73e9eb681 100644 --- a/src/common/core/react-query.ts +++ b/src/common/core/react-query.ts @@ -33,5 +33,7 @@ export enum QueryIdentifiers { GET_POSTS = "get-posts", GET_BOTS = "get-bots", GET_BOOST_PLUS_PRICES = "get-boost-plus-prices", - GET_BOOST_PLUS_ACCOUNTS = "get-boost-plus-accounts" + GET_BOOST_PLUS_ACCOUNTS = "get-boost-plus-accounts", + + POLL_DETAILS = "poll-details" } diff --git a/src/common/features/polls/api/get-poll-details-query.ts b/src/common/features/polls/api/get-poll-details-query.ts new file mode 100644 index 00000000000..f81fe8fcd02 --- /dev/null +++ b/src/common/features/polls/api/get-poll-details-query.ts @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; +import { QueryIdentifiers } from "../../../core"; +import { Entry } from "../../../store/entries/types"; +import axios from "axios"; + +interface GetPollDetailsQueryResponse { + author: string; + created: string; + end_time: string; + filter_account_age_days: number; + image: string; + parent_permlink: string; + permlink: string; + platform: null; + poll_choices: { choice_num: number; choice_text: string; votes?: { total_votes: number } }[]; + poll_stats: { total_voting_accounts_num: number }; + poll_trx_id: string; + poll_voters?: { name: string; choice_num: number }[]; + post_body: string; + post_title: string; + preferred_interpretation: string; + protocol_version: number; + question: string; + status: string; + tags: string[]; + token: null; +} + +export function useGetPollDetailsQuery(entry?: Entry) { + return useQuery( + [QueryIdentifiers.POLL_DETAILS, entry?.author, entry?.permlink], + { + queryFn: () => + axios + .get( + `https://polls.ecency.com/rpc/poll?author=eq.${entry!!.author}&permlink=eq.${ + entry!!.permlink + }` + ) + .then((resp) => resp.data[0]), + enabled: !!entry, + refetchOnMount: false + } + ); +} diff --git a/src/common/features/polls/api/index.ts b/src/common/features/polls/api/index.ts new file mode 100644 index 00000000000..d6350fc501f --- /dev/null +++ b/src/common/features/polls/api/index.ts @@ -0,0 +1,2 @@ +export * from "./get-poll-details-query"; +export * from "./sign-poll-vote"; diff --git a/src/common/features/polls/api/sign-poll-vote.ts b/src/common/features/polls/api/sign-poll-vote.ts new file mode 100644 index 00000000000..dde4fea5002 --- /dev/null +++ b/src/common/features/polls/api/sign-poll-vote.ts @@ -0,0 +1,82 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useGetPollDetailsQuery } from "./get-poll-details-query"; +import { error } from "../../../components/feedback"; +import { _t } from "../../../i18n"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import { broadcastPostingJSON } from "../../../api/operations"; +import { QueryIdentifiers } from "../../../core"; + +export function useSignPollVoteByKey(poll: ReturnType["data"]) { + const { activeUser } = useMappedStore(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ["sign-poll-vote", poll?.author, poll?.permlink], + mutationFn: async ({ choice }: { choice: string }) => { + if (!poll || !activeUser) { + error(_t("polls.not-found")); + return; + } + + const choiceNum = poll.poll_choices?.find((pc) => pc.choice_text === choice)?.choice_num; + if (typeof choiceNum !== "number") { + error(_t("polls.not-found")); + return; + } + + await broadcastPostingJSON(activeUser.username, "polls", { + poll: poll.poll_trx_id, + action: "vote", + choice: choiceNum + }); + + return { choiceNum }; + }, + onSuccess: (resp) => + queryClient.setQueryData["data"]>( + [QueryIdentifiers.POLL_DETAILS, poll?.author, poll?.permlink], + (data) => { + if (!data || !resp) { + return data; + } + + const existingVote = data.poll_voters?.find((pv) => pv.name === activeUser!!.username); + const previousUserChoice = data.poll_choices?.find( + (pc) => existingVote?.choice_num === pc.choice_num + ); + const choice = data.poll_choices?.find((pc) => pc.choice_num === resp.choiceNum)!!; + + const notTouchedChoices = data.poll_choices?.filter( + (pc) => ![previousUserChoice?.choice_num, choice?.choice_num].includes(pc.choice_num) + ); + const otherVoters = + data.poll_voters?.filter((pv) => pv.name !== activeUser!!.username) ?? []; + + return { + ...data, + poll_choices: [ + ...notTouchedChoices, + previousUserChoice + ? { + ...previousUserChoice, + votes: { + total_votes: (previousUserChoice?.votes?.total_votes ?? 0) - 1 + } + } + : undefined, + { + ...choice, + votes: { + total_votes: (choice?.votes?.total_votes ?? 0) + 1 + } + } + ].filter((el) => !!el), + poll_voters: [ + ...otherVoters, + { name: activeUser?.username, choice_num: resp.choiceNum } + ] + } as ReturnType["data"]; + } + ) + }); +} diff --git a/src/common/features/polls/components/index.ts b/src/common/features/polls/components/index.ts new file mode 100644 index 00000000000..704dcc1ac51 --- /dev/null +++ b/src/common/features/polls/components/index.ts @@ -0,0 +1,2 @@ +export * from "./polls-creation"; +export * from "./poll-widget"; diff --git a/src/common/features/polls/components/poll-option-with-results.tsx b/src/common/features/polls/components/poll-option-with-results.tsx new file mode 100644 index 00000000000..48b3fa93a12 --- /dev/null +++ b/src/common/features/polls/components/poll-option-with-results.tsx @@ -0,0 +1,53 @@ +import { classNameObject } from "../../../helper/class-name-object"; +import React, { useMemo } from "react"; +import { PollCheck } from "./poll-option"; +import { useGetPollDetailsQuery } from "../api"; +import { Entry } from "../../../store/entries/types"; +import { _t } from "../../../i18n"; + +export interface Props { + activeChoice?: string; + choice: string; + entry?: Entry; +} + +export function PollOptionWithResults({ choice, activeChoice, entry }: Props) { + const pollDetails = useGetPollDetailsQuery(entry); + + const votesCount = useMemo( + () => + pollDetails.data?.poll_choices.find((pc) => pc.choice_text === choice)?.votes?.total_votes ?? + 0, + [choice, pollDetails.data?.poll_choices] + ); + const totalVotes = useMemo( + () => Math.max(pollDetails.data?.poll_stats.total_voting_accounts_num ?? 0, 1), + [pollDetails.data?.poll_stats.total_voting_accounts_num] + ); + + return ( +
+
+ {activeChoice === choice && } +
+ {choice} + + {((votesCount * 100) / totalVotes).toFixed(2)}% ({votesCount} {_t("polls.votes")}) + +
+
+ ); +} diff --git a/src/common/features/polls/components/poll-option.tsx b/src/common/features/polls/components/poll-option.tsx new file mode 100644 index 00000000000..03f1d13e4d1 --- /dev/null +++ b/src/common/features/polls/components/poll-option.tsx @@ -0,0 +1,37 @@ +import { classNameObject } from "../../../helper/class-name-object"; +import React from "react"; +import { UilCheck } from "@iconscout/react-unicons"; + +export function PollCheck({ checked }: { checked: boolean }) { + return ( +
+ {checked && } +
+ ); +} + +export interface Props { + activeChoice?: string; + choice: string; + setActiveChoice: (choice?: string) => void; +} + +export function PollOption({ activeChoice, choice, setActiveChoice }: Props) { + return ( +
+ activeChoice === choice ? setActiveChoice(undefined) : setActiveChoice(choice) + } + > + + {choice} +
+ ); +} diff --git a/src/common/features/polls/components/poll-votes-list-dialog.tsx b/src/common/features/polls/components/poll-votes-list-dialog.tsx new file mode 100644 index 00000000000..5271b9e1cfb --- /dev/null +++ b/src/common/features/polls/components/poll-votes-list-dialog.tsx @@ -0,0 +1,81 @@ +import { Modal, ModalBody, ModalHeader } from "@ui/modal"; +import React, { useMemo, useState } from "react"; +import { _t } from "../../../i18n"; +import { Entry } from "../../../store/entries/types"; +import { useGetPollDetailsQuery } from "../api"; +import { List, ListItem } from "@ui/list"; +import { Button } from "@ui/button"; +import UserAvatar from "../../../components/user-avatar"; +import { Link } from "react-router-dom"; +import { Badge } from "@ui/badge"; + +interface Props { + entry?: Entry; +} + +export function PollVotesListDialog({ entry }: Props) { + const { data: poll } = useGetPollDetailsQuery(entry); + + const [show, setShow] = useState(false); + const [chosenChoice, setChosenChoice] = useState(); + + const pollChoices = useMemo( + () => (poll?.poll_choices ?? []).filter((ch) => !!ch), + [poll?.poll_choices] + ); + const pollVotes = useMemo( + () => + (poll?.poll_voters ?? []).filter((vote) => + chosenChoice + ? chosenChoice === + pollChoices.find((pc) => pc.choice_num === vote.choice_num)?.choice_text + : true + ), + [poll?.poll_voters, chosenChoice, pollChoices] + ); + + return ( + <> + + setShow(false)}> + {_t("polls.votes-list")} + +
+ {pollChoices.map((choice) => ( + + setChosenChoice( + choice.choice_text === chosenChoice ? undefined : choice.choice_text + ) + } + > + {choice.votes?.total_votes ?? 0} + {choice.choice_text} + + ))} +
+ + {pollVotes.map((vote) => ( + + + +
+ {vote.name} + + {pollChoices.find((pc) => pc.choice_num === vote.choice_num)?.choice_text} + +
+ +
+ ))} +
+
+
+ + ); +} diff --git a/src/common/features/polls/components/poll-widget.tsx b/src/common/features/polls/components/poll-widget.tsx new file mode 100644 index 00000000000..e6bd2f1b849 --- /dev/null +++ b/src/common/features/polls/components/poll-widget.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { PollSnapshot } from "./polls-creation"; +import { _t } from "../../../i18n"; +import { Button } from "@ui/button"; +import { Entry } from "../../../store/entries/types"; +import { useGetPollDetailsQuery, useSignPollVoteByKey } from "../api"; +import { PollOption } from "./poll-option"; +import { PollOptionWithResults } from "./poll-option-with-results"; +import { PollVotesListDialog } from "./poll-votes-list-dialog"; +import { UilClock, UilPanelAdd } from "@iconscout/react-unicons"; +import { useMappedStore } from "../../../store/use-mapped-store"; +import { format, isBefore } from "date-fns"; +import useLocalStorage from "react-use/lib/useLocalStorage"; +import { PREFIX } from "../../../util/local-storage"; + +interface Props { + poll: PollSnapshot; + isReadOnly: boolean; + entry?: Entry; +} + +export function PollWidget({ poll, isReadOnly, entry }: Props) { + const { activeUser } = useMappedStore(); + + const pollDetails = useGetPollDetailsQuery(entry); + const activeUserVote = useMemo( + () => (pollDetails.data?.poll_voters ?? []).find((pv) => pv.name === activeUser?.username), + [pollDetails.data?.poll_voters, activeUser?.username] + ); + + const { mutateAsync: vote, isLoading: isVoting } = useSignPollVoteByKey(pollDetails.data); + + const [activeChoice, setActiveChoice] = useState(); + const [resultsMode, setResultsMode] = useState(false); + const [isVotedAlready, setIsVotedAlready] = useState(false); + const [showEndDate, setShowEndDate] = useLocalStorage(PREFIX + "_plls_set", false); + + const endTimeFullDate = useMemo(() => format(poll.endTime, "dd.MM.yyyy HH:mm"), [poll.endTime]); + const isFinished = useMemo(() => isBefore(poll.endTime, new Date()), [poll.endTime]); + const showViewVotes = useMemo( + () => poll.hideVotes && !resultsMode, + [poll.hideVotes, resultsMode] + ); + const showChangeVote = useMemo( + () => poll.voteChange && resultsMode && pollDetails.data?.status === "Active", + [resultsMode, poll.voteChange, pollDetails.data?.status] + ); + const showVote = useMemo( + () => pollDetails.data?.status === "Active" && !resultsMode, + [pollDetails.data?.status, resultsMode] + ); + + useEffect(() => { + if (activeUserVote) { + const choice = pollDetails.data?.poll_choices.find( + (pc) => pc.choice_num === activeUserVote.choice_num + ); + setActiveChoice(choice?.choice_text); + } + }, [activeUserVote, pollDetails.data]); + + useEffect(() => { + setResultsMode(isVotedAlready || isFinished); + }, [isVotedAlready, isFinished]); + + useEffect(() => { + setIsVotedAlready(!!activeUserVote); + }, [activeUserVote]); + + return ( +
+
+ {isReadOnly && ( +
+ {_t("polls.preview-mode")} +
+ )} +
+
{poll.title}
+
+ {showEndDate && + (isFinished ? ( + _t("polls.finished") + ) : ( +
+ {_t("polls.end-time")} + {endTimeFullDate} +
+ ))} +
+
+ {poll.filters.accountAge > 0 && ( +
+ {_t("polls.account-age-hint", { n: poll.filters.accountAge })} +
+ )} +
+ {poll.choices.map((choice) => + resultsMode ? ( + + ) : ( + + ) + )} +
+ {showVote && ( + + )} + {showChangeVote && ( + + )} + {showViewVotes && ( + + )} + {resultsMode && } +
+
+ ); +} diff --git a/src/common/features/polls/components/polls-creation.tsx b/src/common/features/polls/components/polls-creation.tsx new file mode 100644 index 00000000000..4cd1ab46d78 --- /dev/null +++ b/src/common/features/polls/components/polls-creation.tsx @@ -0,0 +1,242 @@ +import React, { useMemo } from "react"; +import { Modal, ModalBody, ModalFooter, ModalHeader, ModalTitle } from "@ui/modal"; +import { _t } from "../../../i18n"; +import { usePollsCreationManagement } from "../hooks"; +import { FormControl, InputGroup } from "@ui/input"; +import { + UilCalender, + UilPanelAdd, + UilPlus, + UilQuestionCircle, + UilSave, + UilTrash +} from "@iconscout/react-unicons"; +import { Button } from "@ui/button"; +import { format } from "date-fns"; + +export interface PollSnapshot { + title: string; + choices: string[]; + voteChange: boolean; + hideVotes: boolean; + filters: { + accountAge: number; + }; + endTime: Date; + interpretation: "number_of_votes" | "tokens"; +} + +interface Props { + show: boolean; + setShow: (v: boolean) => void; + onAdd: (poll: PollSnapshot) => void; + existingPoll?: PollSnapshot; + onDeletePoll: () => void; + readonly?: boolean; +} + +export function PollsCreation({ + show, + setShow, + onAdd, + existingPoll, + onDeletePoll, + readonly +}: Props) { + const { + title, + setTitle, + choices, + pushChoice, + deleteChoiceByIndex, + updateChoiceByIndex, + hasEmptyOrDuplicatedChoices, + accountAge, + setAccountAge, + endDate, + setEndDate, + interpretation, + setInterpretation, + hideVotes, + setHideVotes, + voteChange, + setVoteChange, + isExpiredEndDate + } = usePollsCreationManagement(existingPoll); + + const formatDate = useMemo(() => format(endDate ?? new Date(), "yyyy-MM-dd"), [endDate]); + + return ( + setShow(false)} + className="polls-creation-modal" + > + + {_t(existingPoll ? "polls.edit-title" : "polls.title")} + + +
+ }> + setTitle(e.target.value)} + /> + + }> + setEndDate(new Date(e.target.value))} + /> + + + {isExpiredEndDate && !readonly && ( +
+ {_t("polls.expired-date")} +
+ )} + +
+
{_t("polls.choices")}
+ {choices?.map((choice, key) => ( +
+ + updateChoiceByIndex(e.target.value, key)} + /> + +
+ ))} +
+ {hasEmptyOrDuplicatedChoices && !readonly && ( +
{_t("polls.polls-form-hint")}
+ )} +
+
+
{_t("polls.options")}
+
{_t("polls.account-age")}
+ { + const value = +e.target.value; + if (value >= 0 && value <= 200) { + setAccountAge(+e.target.value); + } else if (value < 0) { + setAccountAge(0); + } else { + setAccountAge(200); + } + }} + /> + ) => + setInterpretation(e.target.value as PollSnapshot["interpretation"]) + } + > + + + + setVoteChange(e)} + /> + setHideVotes(e)} + /> +
+
+ +
+ +
+ {existingPoll && ( + + )} + +
+
+
+
+ ); +} diff --git a/src/common/features/polls/hooks/index.ts b/src/common/features/polls/hooks/index.ts new file mode 100644 index 00000000000..7abeb8ad6b6 --- /dev/null +++ b/src/common/features/polls/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-polls-creation-management"; diff --git a/src/common/features/polls/hooks/use-polls-creation-management.ts b/src/common/features/polls/hooks/use-polls-creation-management.ts new file mode 100644 index 00000000000..324fd0b842a --- /dev/null +++ b/src/common/features/polls/hooks/use-polls-creation-management.ts @@ -0,0 +1,84 @@ +import useLocalStorage from "react-use/lib/useLocalStorage"; +import { PREFIX } from "../../../util/local-storage"; +import { useEffect, useMemo, useState } from "react"; +import { addDays, isBefore } from "date-fns"; +import { PollSnapshot } from "../components"; + +export function usePollsCreationManagement(poll?: PollSnapshot) { + const [title, setTitle, clearTitle] = useLocalStorage(PREFIX + "_plls_t", ""); + const [endDate, setEndDate, clearEndDate] = useLocalStorage( + PREFIX + "_plls_ed", + addDays(new Date(), 7), + { + raw: false, + deserializer: (v: string) => new Date(v), + serializer: (v: Date) => v.toISOString() + } + ); + const [accountAge, setAccountAge, clearAccountAge] = useLocalStorage(PREFIX + "_plls_ag", 100); + const [choices, setChoices, clearChoices] = useLocalStorage(PREFIX + "_plls_ch", []); + const [interpretation, setInterpretation] = + useState("number_of_votes"); + const [voteChange, setVoteChange] = useLocalStorage(PREFIX + "_plls_vc", true); + const [hideVotes, setHideVotes] = useLocalStorage(PREFIX + "_plls_cs", false); + + const hasEmptyOrDuplicatedChoices = useMemo(() => { + if (!choices || choices.length <= 1) { + return true; + } + + const hasDuplicates = new Set(choices).size !== choices.length; + return choices.some((c) => !c) || hasDuplicates; + }, [choices]); + const isExpiredEndDate = useMemo( + () => (endDate ? isBefore(endDate, new Date()) : false), + [endDate] + ); + + useEffect(() => { + if (poll) { + setTitle(poll.title); + setChoices(poll.choices); + setAccountAge(poll.filters.accountAge); + setEndDate(poll.endTime); + setInterpretation(poll.interpretation); + setVoteChange(poll.voteChange); + setHideVotes(poll.hideVotes); + } + }, [poll]); + + const pushChoice = (choice: string) => setChoices([...(choices ?? []), choice]); + + const deleteChoiceByIndex = (index: number) => { + const next = [...(choices ?? [])]; + next.splice(index, 1); + return setChoices(next); + }; + + const updateChoiceByIndex = (choice: string, index: number) => { + const next = [...(choices ?? [])]; + next.splice(index, 1, choice); + return setChoices(next); + }; + + return { + title, + setTitle, + choices, + pushChoice, + deleteChoiceByIndex, + updateChoiceByIndex, + hasEmptyOrDuplicatedChoices, + accountAge, + setAccountAge, + endDate, + setEndDate, + interpretation, + setInterpretation, + hideVotes, + setHideVotes, + voteChange, + setVoteChange, + isExpiredEndDate + }; +} diff --git a/src/common/features/polls/index.ts b/src/common/features/polls/index.ts new file mode 100644 index 00000000000..40b494c5f87 --- /dev/null +++ b/src/common/features/polls/index.ts @@ -0,0 +1 @@ +export * from "./components"; diff --git a/src/common/features/polls/utils/build-poll-json-metadata.ts b/src/common/features/polls/utils/build-poll-json-metadata.ts new file mode 100644 index 00000000000..ba0840847cb --- /dev/null +++ b/src/common/features/polls/utils/build-poll-json-metadata.ts @@ -0,0 +1,19 @@ +import { PollSnapshot } from "../components"; +import { MetaData } from "../../../api/operations"; + +export function buildPollJsonMetadata(poll: PollSnapshot) { + return { + content_type: "poll", + version: 0.6, + question: poll.title, + choices: poll.choices, + preferred_interpretation: poll.interpretation, + token: null, + hide_votes: poll.hideVotes, + vote_change: poll.voteChange, + filters: { + account_age: poll.filters.accountAge + }, + end_time: poll.endTime.getTime() / 1000 + } as MetaData; +} diff --git a/src/common/features/polls/utils/index.ts b/src/common/features/polls/utils/index.ts new file mode 100644 index 00000000000..a0f97d81962 --- /dev/null +++ b/src/common/features/polls/utils/index.ts @@ -0,0 +1 @@ +export * from "./build-poll-json-metadata"; diff --git a/src/common/features/ui/badge/badge-styles.ts b/src/common/features/ui/badge/badge-styles.ts index d0f5a6563d1..7ebbbbc55d2 100644 --- a/src/common/features/ui/badge/badge-styles.ts +++ b/src/common/features/ui/badge/badge-styles.ts @@ -2,6 +2,7 @@ export type BadgeAppearance = "primary" | "secondary"; export const BADGE_STYLES: Record = { primary: - "bg-blue-dark-sky-040 border border-blue-dark-sky-030 text-blue-dark-sky dark:bg-blue-metallic-10 dark:border-blue-metallic-20 dark:text-gray-200", - secondary: "bg-gray-400 text-gray-600 dark:bg-gray-600 dark:text-gray-400 text-xs font-bold" + "bg-blue-dark-sky-040 border border-blue-dark-sky-030 text-blue-dark-sky dark:bg-blue-metallic-10 text-xs font-bold dark:border-blue-metallic-20 dark:text-gray-200", + secondary: + "bg-gray-400 text-gray-600 dark:bg-gray-600 dark:text-gray-400 text-xs font-bold border border-gray-400 dark:border-gray-600" }; diff --git a/src/common/features/ui/input/form-controls/checkbox.tsx b/src/common/features/ui/input/form-controls/checkbox.tsx index 1031eaf6607..2b6c6192786 100644 --- a/src/common/features/ui/input/form-controls/checkbox.tsx +++ b/src/common/features/ui/input/form-controls/checkbox.tsx @@ -8,13 +8,14 @@ export interface CheckboxProps extends Omit, "onChange"> checked: boolean; onChange: (e: boolean) => void; label?: string; + disabled?: boolean; } export function Checkbox({ checked, onChange, label, disabled }: CheckboxProps) { return (
onChange(!checked)} + onClick={() => !disabled && onChange(!checked)} >
((props, ref) => { switch (props.type) { diff --git a/src/common/features/ui/input/form-controls/input.tsx b/src/common/features/ui/input/form-controls/input.tsx index 2b4ab6a9946..a24942145fc 100644 --- a/src/common/features/ui/input/form-controls/input.tsx +++ b/src/common/features/ui/input/form-controls/input.tsx @@ -4,7 +4,7 @@ import { INPUT_DARK_STYLES, INPUT_STYLES, INVALID_INPUT_STYLES } from "./input-s import { useFilteredProps } from "../../../../util/props-filter"; export interface InputProps extends HTMLProps { - type: "text" | "password" | "number" | "email" | "range"; + type: "text" | "password" | "number" | "email" | "range" | "date"; noStyles?: boolean; // TODO: styles for that plaintext?: boolean; diff --git a/src/common/features/ui/modal/modal-footer.tsx b/src/common/features/ui/modal/modal-footer.tsx index 57967295a0c..e9bc7021c43 100644 --- a/src/common/features/ui/modal/modal-footer.tsx +++ b/src/common/features/ui/modal/modal-footer.tsx @@ -1,12 +1,13 @@ import { classNameObject } from "../../../helper/class-name-object"; import React, { HTMLProps } from "react"; -export function ModalFooter(props: HTMLProps) { +export function ModalFooter(props: HTMLProps & { sticky?: boolean }) { return (
diff --git a/src/common/i18n/locales/en-US.json b/src/common/i18n/locales/en-US.json index 3cfb68011f8..f2f6d04d680 100644 --- a/src/common/i18n/locales/en-US.json +++ b/src/common/i18n/locales/en-US.json @@ -1428,7 +1428,8 @@ "image-error-size": "Image size is too large, try smaller size image again.", "image-error-conflict-name": "You have image with this filename already. Select it from gallery instead.", "fragments": "Snippets", - "link-image": "Link" + "link-image": "Link", + "polls": "Polls" }, "emoji-picker": { "filter-placeholder": "Filter", @@ -2403,5 +2404,40 @@ "toolbar-hint": "Toolbar allow to insert a lot of different components, apply formatting, insert images, videos and links", "advanced-hint": "Configure advanced settings such as scheduling, beneficiaries", "help-hint": "Have any questions? Check out help center" + }, + "polls": { + "poll": "Poll", + "title": "Create a poll", + "edit-title": "Edit a poll", + "title-placeholder": "Poll question", + "choices": "Choices", + "post-poll": "Post's poll", + "choice-placeholder": "Choice {{n}}", + "add-choice": "Add choice", + "attach": "Attach to post", + "update": "Update", + "polls-form-hint": "To attach poll to post, need to fill all choices without duplicates", + "options": "Options", + "account-age": "Account age for voting(in days)", + "end-time": "End time", + "vote": "Vote", + "account-age-hint": "Only accounts older than {{n}} days allowed", + "preview-mode": "Preview mode", + "videos-collision-error": "Polls and 3speak videos cannot be published in same post since there are different kind of posts. Please choose only one of them.", + "number_of_votes": "By number of votes", + "tokens": "By tokens", + "view-votes": "View votes", + "back-to-vote": "Back to vote", + "votes": "Votes", + "votes-list": "Votes list", + "show-voters": "Show voters", + "vote-confirmation-and-sign": "Sign and confirm your vote", + "your-choice": "Your choice", + "not-found": "Poll not found", + "voting": "Voting...", + "vote-change": "Changing a vote", + "current-standing": "Hide votes", + "finished": "Finished", + "expired-date": "End date should be in present or future" } } diff --git a/src/common/pages/entry/index.tsx b/src/common/pages/entry/index.tsx index 50e64383d77..32f654b5637 100644 --- a/src/common/pages/entry/index.tsx +++ b/src/common/pages/entry/index.tsx @@ -68,6 +68,8 @@ import { useDistanceDetector } from "./distance-detector"; import usePrevious from "react-use/lib/usePrevious"; import { Button } from "@ui/button"; import { useCreateReply, useUpdateReply } from "../../api/mutations"; +import { useEntryPollExtractor } from "./utils"; +import { PollWidget } from "../../features/polls"; const EntryComponent = (props: Props) => { const [loading, setLoading] = useState(false); @@ -137,6 +139,7 @@ const EntryComponent = (props: Props) => { reload(); } ); + const postPoll = useEntryPollExtractor(entry); useDistanceDetector( entryControlsRef, @@ -721,6 +724,15 @@ const EntryComponent = (props: Props) => { className="entry-body markdown-view user-selectable" dangerouslySetInnerHTML={renderedBody} /> + {postPoll && ( +
+ +
+ )} ) : ( @@ -753,7 +765,6 @@ const EntryComponent = (props: Props) => { {showProfileBox && }
)} -
{tags && diff --git a/src/common/pages/entry/utils/index.ts b/src/common/pages/entry/utils/index.ts new file mode 100644 index 00000000000..f2dcab15420 --- /dev/null +++ b/src/common/pages/entry/utils/index.ts @@ -0,0 +1 @@ +export * from "./use-entry-poll-extractor"; diff --git a/src/common/pages/entry/utils/use-entry-poll-extractor.ts b/src/common/pages/entry/utils/use-entry-poll-extractor.ts new file mode 100644 index 00000000000..431bda9043f --- /dev/null +++ b/src/common/pages/entry/utils/use-entry-poll-extractor.ts @@ -0,0 +1,26 @@ +import { useMemo } from "react"; +import { Entry, JsonPollMetadata } from "../../../store/entries/types"; +import { PollSnapshot } from "../../../features/polls"; + +export function useEntryPollExtractor(entry?: Entry | null) { + return useMemo(() => { + if ( + entry && + "content_type" in entry.json_metadata && + (entry.json_metadata as JsonPollMetadata).content_type === "poll" + ) { + return { + title: (entry.json_metadata as JsonPollMetadata)?.question, + choices: (entry.json_metadata as JsonPollMetadata)?.choices, + endTime: new Date((entry.json_metadata as JsonPollMetadata)?.end_time * 1000), + interpretation: (entry.json_metadata as JsonPollMetadata)?.preferred_interpretation, + voteChange: (entry.json_metadata as JsonPollMetadata)?.vote_change ?? true, + hideVotes: (entry.json_metadata as JsonPollMetadata)?.hide_votes ?? false, + filters: { + accountAge: (entry.json_metadata as JsonPollMetadata)?.filters.account_age + } + } as PollSnapshot; + } + return undefined; + }, [entry]); +} diff --git a/src/common/pages/submit/api/publish.ts b/src/common/pages/submit/api/publish.ts index 79df412ad2d..73c3164e00d 100644 --- a/src/common/pages/submit/api/publish.ts +++ b/src/common/pages/submit/api/publish.ts @@ -23,9 +23,12 @@ import { buildMetadata, getDimensionsFromDataUrl } from "../functions"; import { useContext } from "react"; import { EntriesCacheContext } from "../../../core"; import { version } from "../../../../../package.json"; +import { PollsContext } from "../hooks/polls-manager"; +import { buildPollJsonMetadata } from "../../../features/polls/utils"; export function usePublishApi(history: History, onClear: () => void) { const { activeUser } = useMappedStore(); + const { activePoll, clearActivePoll } = useContext(PollsContext); const { videos, isNsfw, buildBody } = useThreeSpeakManager(); const { updateCache } = useContext(EntriesCacheContext); @@ -98,6 +101,10 @@ export function usePublishApi(history: History, onClear: () => void) { ); } + if (activePoll) { + jsonMeta = { ...jsonMeta, ...buildPollJsonMetadata(activePoll) }; + } + // If post have one unpublished video need to modify // json metadata which matches to 3Speak if (unpublished3SpeakVideo) { @@ -117,6 +124,10 @@ export function usePublishApi(history: History, onClear: () => void) { jsonMeta.type = "video"; } + if (jsonMeta.type === "video" && activePoll) { + throw new Error(_t("polls.videos-collision-error")); + } + const options = makeCommentOptions(author, permlink, reward, beneficiaries); try { @@ -151,6 +162,7 @@ export function usePublishApi(history: History, onClear: () => void) { success(_t("submit.published")); onClear(); + clearActivePoll(); const newLoc = makePathEntry(parentPermlink, author, permlink); history.push(newLoc); diff --git a/src/common/pages/submit/api/save-draft.ts b/src/common/pages/submit/api/save-draft.ts index da2e1e1d849..b19357eab1b 100644 --- a/src/common/pages/submit/api/save-draft.ts +++ b/src/common/pages/submit/api/save-draft.ts @@ -9,10 +9,13 @@ import { buildMetadata } from "../functions"; import { ThreeSpeakVideo } from "../../../api/threespeak"; import { useThreeSpeakManager } from "../hooks"; import { QueryIdentifiers } from "../../../core"; +import { useContext } from "react"; +import { PollsContext } from "../hooks/polls-manager"; export function useSaveDraftApi(history: History) { const { activeUser } = useMappedStore(); const { videos } = useThreeSpeakManager(); + const { activePoll, clearActivePoll } = useContext(PollsContext); const queryClient = useQueryClient(); @@ -56,7 +59,8 @@ export function useSaveDraftApi(history: History) { ...meta, beneficiaries, rewardType: reward, - videos + videos, + poll: activePoll }; try { @@ -74,6 +78,8 @@ export function useSaveDraftApi(history: History) { history.push(`/draft/${draft._id}`); } + + clearActivePoll(); } catch (e) { error(_t("g.server-error")); } diff --git a/src/common/pages/submit/api/schedule.ts b/src/common/pages/submit/api/schedule.ts index 3fc47fcf26f..ef07544d113 100644 --- a/src/common/pages/submit/api/schedule.ts +++ b/src/common/pages/submit/api/schedule.ts @@ -13,10 +13,14 @@ import { _t } from "../../../i18n"; import { useMappedStore } from "../../../store/use-mapped-store"; import { version } from "../../../../../package.json"; import { useThreeSpeakManager } from "../hooks"; +import { buildPollJsonMetadata } from "../../../features/polls/utils"; +import { useContext } from "react"; +import { PollsContext } from "../hooks/polls-manager"; export function useScheduleApi(onClear: () => void) { const { activeUser } = useMappedStore(); const { buildBody } = useThreeSpeakManager(); + const { activePoll, clearActivePoll } = useContext(PollsContext); return useMutation( ["schedule"], @@ -51,7 +55,10 @@ export function useScheduleApi(onClear: () => void) { } const meta = extractMetaData(body); - const jsonMeta = makeJsonMetaData(meta, tags, description, version); + let jsonMeta = makeJsonMetaData(meta, tags, description, version); + if (activePoll) { + jsonMeta = { ...jsonMeta, ...buildPollJsonMetadata(activePoll) }; + } const options = makeCommentOptions(author, permlink, reward, beneficiaries); const reblog = isCommunity(tags[0]) && reblogSwitch; @@ -68,6 +75,7 @@ export function useScheduleApi(onClear: () => void) { reblog ); onClear(); + clearActivePoll(); } catch (e) { if (e.response?.data?.message) { error(e.response?.data?.message); diff --git a/src/common/pages/submit/hooks/api-draft-detector.ts b/src/common/pages/submit/hooks/api-draft-detector.ts index bf8b5f39e2a..4f65af11a5a 100644 --- a/src/common/pages/submit/hooks/api-draft-detector.ts +++ b/src/common/pages/submit/hooks/api-draft-detector.ts @@ -3,9 +3,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { QueryIdentifiers } from "../../../core"; import { Draft, getDrafts } from "../../../api/private-api"; import { useMappedStore } from "../../../store/use-mapped-store"; -import { useEffect } from "react"; +import { useContext, useEffect } from "react"; import { Location } from "history"; import usePrevious from "react-use/lib/usePrevious"; +import { PollsContext } from "./polls-manager"; export function useApiDraftDetector( match: MatchType, @@ -13,6 +14,7 @@ export function useApiDraftDetector( onDraftLoaded: (draft: Draft) => void, onInvalidDraft: () => void ) { + const { setActivePoll } = useContext(PollsContext); const { activeUser } = useMappedStore(); const queryClient = useQueryClient(); @@ -56,6 +58,7 @@ export function useApiDraftDetector( useEffect(() => { if (draftQuery.data) { onDraftLoaded(draftQuery.data); + setActivePoll(draftQuery.data.meta?.poll); } }, [draftQuery.data]); diff --git a/src/common/pages/submit/hooks/polls-manager.tsx b/src/common/pages/submit/hooks/polls-manager.tsx new file mode 100644 index 00000000000..eb2892e443d --- /dev/null +++ b/src/common/pages/submit/hooks/polls-manager.tsx @@ -0,0 +1,39 @@ +import React, { createContext, PropsWithChildren } from "react"; +import { PollSnapshot } from "../../../features/polls"; +import useLocalStorage from "react-use/lib/useLocalStorage"; +import { PREFIX } from "../../../util/local-storage"; + +export const PollsContext = createContext<{ + activePoll?: PollSnapshot; + setActivePoll: (v: PollSnapshot | undefined) => void; + clearActivePoll: () => void; +}>({ + activePoll: undefined, + setActivePoll: () => {}, + clearActivePoll: () => {} +}); + +export function PollsManager(props: PropsWithChildren) { + const [activePoll, setActivePoll, clearActivePoll] = useLocalStorage( + PREFIX + "_sa_pll", + undefined, + { + raw: false, + deserializer: (value) => { + if (value) { + const parsedInstance = JSON.parse(value) as PollSnapshot; + parsedInstance.endTime = new Date(parsedInstance.endTime); + return parsedInstance; + } + return undefined; + }, + serializer: (value) => (value ? JSON.stringify(value) : "") + } + ); + + return ( + + {props.children} + + ); +} diff --git a/src/common/pages/submit/index.tsx b/src/common/pages/submit/index.tsx index 7341e7aa1f2..2a7e4720522 100644 --- a/src/common/pages/submit/index.tsx +++ b/src/common/pages/submit/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import { Entry } from "../../store/entries/types"; import { Draft } from "../../api/private-api"; import { MatchType, PostBase, VideoProps } from "./types"; @@ -68,6 +68,8 @@ import { FormControl } from "@ui/input"; import { IntroTour } from "@ui/intro-tour"; import { IntroStep } from "@ui/core"; import { dotsMenuIconSvg } from "../../features/decks/icons"; +import { PollsContext, PollsManager } from "./hooks/polls-manager"; +import { useEntryPollExtractor } from "../entry/utils"; interface MatchProps { match: MatchType; @@ -76,6 +78,7 @@ interface MatchProps { export function Submit(props: PageProps & MatchProps) { const postBodyRef = useRef(null); const threeSpeakManager = useThreeSpeakManager(); + const { setActivePoll, activePoll, clearActivePoll } = useContext(PollsContext); const { body, setBody } = useBodyVersioningManager(); const { activeUser } = useMappedStore(); @@ -106,6 +109,8 @@ export function Submit(props: PageProps & MatchProps) { const [editingDraft, setEditingDraft] = useState(null); const [isTourFinished] = useLocalStorage(PREFIX + `_itf_submit`, false); + const postPoll = useEntryPollExtractor(editingEntry); + const tourEnabled = useMemo(() => !activeUser, [activeUser]); const introSteps = useMemo( () => [ @@ -255,6 +260,12 @@ export function Submit(props: PageProps & MatchProps) { } }); + useEffect(() => { + if (postPoll) { + setActivePoll(postPoll); + } + }, [postPoll]); + useEffect(() => { if (postBodyRef.current) { postBodyRef.current.addEventListener("paste", (event) => @@ -478,11 +489,15 @@ export function Submit(props: PageProps & MatchProps) { threeSpeakManager.setIsNsfw(true); }} comment={false} + existingPoll={activePoll} setVideoMetadata={(v) => { threeSpeakManager.attach(v); // Attach videos as special token in a body and render it in a preview setBody(`${body}\n[3speak](${v._id})`); }} + onAddPoll={(v) => setActivePoll(v)} + onDeletePoll={() => clearActivePoll()} + readonlyPoll={!!editingEntry} />
{ return ( - + + + ); diff --git a/src/common/pages/submit/submit-poll-preview.tsx b/src/common/pages/submit/submit-poll-preview.tsx new file mode 100644 index 00000000000..cc07ede0eed --- /dev/null +++ b/src/common/pages/submit/submit-poll-preview.tsx @@ -0,0 +1,9 @@ +import React, { useContext } from "react"; +import { PollsContext } from "./hooks/polls-manager"; +import { PollWidget } from "../../features/polls"; + +export function SubmitPollPreview() { + const { activePoll } = useContext(PollsContext); + + return activePoll ? : <>; +} diff --git a/src/common/pages/submit/submit-preview-content.tsx b/src/common/pages/submit/submit-preview-content.tsx index 5dca01c34bb..b8d5de1ab9b 100644 --- a/src/common/pages/submit/submit-preview-content.tsx +++ b/src/common/pages/submit/submit-preview-content.tsx @@ -4,6 +4,7 @@ import React, { useMemo } from "react"; import { useMappedStore } from "../../store/use-mapped-store"; import { History } from "history"; import { useThreeSpeakManager } from "./hooks"; +import { SubmitPollPreview } from "./submit-poll-preview"; interface Props { title: string; @@ -34,7 +35,10 @@ export function SubmitPreviewContent({ title, tags, body, history }: Props) { })}
- +
+ + +
); } diff --git a/src/common/store/entries/types.ts b/src/common/store/entries/types.ts index 54b3d5abbc9..51e363aa7fb 100644 --- a/src/common/store/entries/types.ts +++ b/src/common/store/entries/types.ts @@ -19,6 +19,19 @@ export interface EntryStat { is_pinned?: boolean; } +export interface JsonPollMetadata { + content_type: "poll"; + version: number; + question: string; + choices: string[]; + preferred_interpretation: string; + token: string; + vote_change: boolean; + hide_votes: boolean; + filters: { account_age: number }; + end_time: number; +} + export interface JsonMetadata { tags?: string[]; description?: string | null;