Skip to content

Commit

Permalink
Merge pull request #1585 from ecency/feature/polls
Browse files Browse the repository at this point in the history
Feature/polls
  • Loading branch information
feruzm authored Apr 27, 2024
2 parents 9c0c487 + cfda23e commit b542b86
Show file tree
Hide file tree
Showing 39 changed files with 1,060 additions and 21 deletions.
2 changes: 2 additions & 0 deletions src/common/api/private-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -217,6 +218,7 @@ export interface DraftMetadata extends MetaData {
beneficiaries: BeneficiaryRoute[];
rewardType: RewardType;
videos?: Record<string, ThreeSpeakVideo>;
poll?: PollSnapshot;
}

export interface Draft {
Expand Down
3 changes: 2 additions & 1 deletion src/common/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -117,7 +118,7 @@ const App = (props: any) => {
<UIManager>
<EntriesCacheManager>
{/*Excluded from production*/}
{/*<ReactQueryDevtools initialIsOpen={false} />*/}
<ReactQueryDevtools initialIsOpen={false} />
<Tracker />
<UserActivityRecorder />
<ChatContextProvider
Expand Down
35 changes: 34 additions & 1 deletion src/common/components/editor-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,20 @@ import Fragments from "../fragments";
import AddImage from "../add-image";
import AddLink from "../add-link";
import "./_index.scss";
import { PollsCreation, PollSnapshot } from "../../features/polls";
import { UilPanelAdd } from "@iconscout/react-unicons";
import { classNameObject } from "../../helper/class-name-object";

interface Props {
sm?: boolean;
setVideoEncoderBeneficiary?: (video: any) => void;
toggleNsfwC?: () => void;
comment: boolean;
setVideoMetadata?: (v: ThreeSpeakVideo) => void;
onAddPoll?: (poll: PollSnapshot) => void;
existingPoll?: PollSnapshot;
onDeletePoll?: () => void;
readonlyPoll?: boolean;
}

export const detectEvent = (eventType: string) => {
Expand All @@ -62,7 +69,11 @@ export function EditorToolbar({
comment,
setVideoMetadata,
setVideoEncoderBeneficiary,
toggleNsfwC
toggleNsfwC,
onAddPoll,
existingPoll,
onDeletePoll,
readonlyPoll
}: Props) {
const { global, activeUser, users } = useMappedStore();

Expand All @@ -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()], []);
Expand Down Expand Up @@ -481,6 +493,19 @@ export function EditorToolbar({
{linkSvg}
</div>
</Tooltip>
{!comment && (
<Tooltip content={_t("editor-toolbar.polls")}>
<div
className={classNameObject({
"editor-tool": true,
"bg-green bg-opacity-25": !!existingPoll
})}
onClick={() => setShowPollsCreation(!showPollsCreation)}
>
<UilPanelAdd />
</div>
</Tooltip>
)}
</div>
<input
onChange={fileInputChanged}
Expand Down Expand Up @@ -548,6 +573,14 @@ export function EditorToolbar({
}}
/>
)}
<PollsCreation
readonly={readonlyPoll}
existingPoll={existingPoll}
show={showPollsCreation}
setShow={(v) => setShowPollsCreation(v)}
onAdd={(snap) => onAddPoll?.(snap)}
onDeletePoll={() => onDeletePoll?.()}
/>
</>
);
}
9 changes: 8 additions & 1 deletion src/common/components/entry-list-item/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);

Expand Down Expand Up @@ -201,6 +202,12 @@ export function EntryListItem({
<span className="date" title={dateFormatted}>
{dateRelative}
</span>

{(entry.json_metadata as any).content_type === "poll" && (
<StyledTooltip className="flex" content={_t("polls.poll")}>
<UilPanelAdd className="text-gray-600 dark:text-gray-400" size={16} />
</StyledTooltip>
)}
</div>
<div className="item-header-features">
{((community && !!entry.stats?.is_pinned) || entry.permlink === pinned) && (
Expand Down
2 changes: 1 addition & 1 deletion src/common/components/profile-link/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions src/common/components/tooltip/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<any>();
const [popperElement, setPopperElement] = useState<any>();
const [show, setShow] = useState(false);
Expand All @@ -30,7 +32,10 @@ export function StyledTooltip({ children, content }: StyledProps) {
return isMounted() ? (
<div
ref={setRef}
className="styled-tooltip"
className={classNameObject({
"styled-tooltip": true,
[className ?? ""]: true
})}
onMouseEnter={() => {
setShow(true);
popper.update?.();
Expand Down
4 changes: 3 additions & 1 deletion src/common/core/react-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
45 changes: 45 additions & 0 deletions src/common/features/polls/api/get-poll-details-query.ts
Original file line number Diff line number Diff line change
@@ -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<GetPollDetailsQueryResponse>(
[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
}
);
}
2 changes: 2 additions & 0 deletions src/common/features/polls/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./get-poll-details-query";
export * from "./sign-poll-vote";
82 changes: 82 additions & 0 deletions src/common/features/polls/api/sign-poll-vote.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useGetPollDetailsQuery>["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<ReturnType<typeof useGetPollDetailsQuery>["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<typeof useGetPollDetailsQuery>["data"];
}
)
});
}
2 changes: 2 additions & 0 deletions src/common/features/polls/components/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./polls-creation";
export * from "./poll-widget";
53 changes: 53 additions & 0 deletions src/common/features/polls/components/poll-option-with-results.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={classNameObject({
"min-h-[52px] relative overflow-hidden flex items-center gap-4 duration-300 cursor-pointer text-sm px-4 py-3 rounded-2xl":
true,
"bg-gray-200 dark:bg-dark-200": true
})}
>
<div
className={classNameObject({
"bg-blue-dark-sky bg-opacity-50 min-h-[52px] absolute top-0 left-0 bottom-0": true
})}
style={{
width: `${((votesCount * 100) / totalVotes).toFixed(2)}%`
}}
/>
{activeChoice === choice && <PollCheck checked={activeChoice === choice} />}
<div className="flex w-full gap-2 justify-between">
<span>{choice}</span>
<span className="text-xs whitespace-nowrap">
{((votesCount * 100) / totalVotes).toFixed(2)}% ({votesCount} {_t("polls.votes")})
</span>
</div>
</div>
);
}
Loading

0 comments on commit b542b86

Please sign in to comment.