From c85cffc03ef85a0c1c6d427bd539494893c6c92e Mon Sep 17 00:00:00 2001 From: thislight Date: Sat, 23 Nov 2024 20:55:37 +0800 Subject: [PATCH] RegularToot: supports polls --- src/timelines/RegularToot.tsx | 27 +++- src/timelines/TootList.tsx | 35 +++++ src/timelines/toots/TootPoll.css | 22 +++ src/timelines/toots/TootPoll.tsx | 193 +++++++++++++++++++++++++ src/timelines/toots/TootPollDialog.css | 12 ++ src/timelines/toots/TootPollDialog.tsx | 121 ++++++++++++++++ 6 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 src/timelines/toots/TootPoll.css create mode 100644 src/timelines/toots/TootPoll.tsx create mode 100644 src/timelines/toots/TootPollDialog.css create mode 100644 src/timelines/toots/TootPollDialog.tsx diff --git a/src/timelines/RegularToot.tsx b/src/timelines/RegularToot.tsx index 3cfb40a..68c0f72 100644 --- a/src/timelines/RegularToot.tsx +++ b/src/timelines/RegularToot.tsx @@ -9,7 +9,7 @@ import { type Setter, } from "solid-js"; import tootStyle from "./toot.module.css"; -import { formatRelative } from "date-fns"; +import { formatRelative, parseISO } from "date-fns"; import Img from "~material/Img.js"; import { Body2 } from "~material/typography.js"; import { css } from "solid-styled"; @@ -36,6 +36,7 @@ import { makeAcctText, useDefaultSession } from "../masto/clients"; import TootContent from "./toots/TootContent"; import BoostIcon from "./toots/BoostIcon"; import PreviewCard from "./toots/PreviewCard"; +import TootPoll from "./toots/TootPoll"; type TootActionGroupProps = { onRetoot?: (value: T) => void; @@ -52,6 +53,11 @@ type RegularTootProps = { actionable?: boolean; evaluated?: boolean; thread?: "top" | "bottom" | "middle"; + + onVote?: (value: { + status: mastodon.v1.Status; + votes: readonly number[]; + }) => void | Promise; } & TootActionGroupProps & JSX.HTMLElementTags["article"]; @@ -237,10 +243,11 @@ function onToggleReveal(setValue: Setter, event: Event) { */ const RegularToot: Component = (props) => { let rootRef: HTMLElement; - const [managed, managedActionGroup, rest] = splitProps( + const [managed, managedActionGroup, pollProps, rest] = splitProps( props, ["status", "lang", "class", "actionable", "evaluated", "thread"], ["onRetoot", "onFavourite", "onBookmark", "onReply"], + ["onVote"], ); const now = useTimeSource(); const status = () => managed.status; @@ -352,6 +359,22 @@ const RegularToot: Component = (props) => { sensitive={toot().sensitive} /> + + pollProps.onVote?.({ status: status(), votes })} + /> + { + const client = session()?.client; + if (!client) return; + + const toot = status.reblog ?? status; + if (!toot.poll) return; + + const npoll = await client.v1.polls.$select(toot.poll.id).votes.create({ + choices: votes, + }); + + if (status.reblog) { + props.onChangeToot(status.id, { + ...status, + reblog: { + ...status.reblog, + poll: npoll, + }, + }); + } else { + props.onChangeToot(status.id, { + ...status, + poll: npoll, + }); + } + + }; + return ( { @@ -272,6 +306,7 @@ const TootList: Component<{ onRetoot={toggleBoost} onFavourite={toggleFavourite} onReply={reply} + onVote={vote} onClick={[onItemClick, status()]} /> ); diff --git a/src/timelines/toots/TootPoll.css b/src/timelines/toots/TootPoll.css new file mode 100644 index 0000000..41b6a82 --- /dev/null +++ b/src/timelines/toots/TootPoll.css @@ -0,0 +1,22 @@ +.TootPoll { + margin-top: 12px; + border: 1px solid var(--tutu-color-surface-d); + background-color: var(--tutu-color-surface); + max-width: 560px; + contain: layout style paint; + padding-top: 8px; + padding-bottom: 8px; + + >.hints, + >.trailers { + padding-right: 8px; + color: var(--tutu-color-secondary-text-on-surface); + display: flex; + justify-content: space-between; + align-items: center; + } + + >.hints { + padding-left: 8px; + } +} diff --git a/src/timelines/toots/TootPoll.tsx b/src/timelines/toots/TootPoll.tsx new file mode 100644 index 0000000..ce972b0 --- /dev/null +++ b/src/timelines/toots/TootPoll.tsx @@ -0,0 +1,193 @@ +import { + batch, + createRenderEffect, + createSelector, + createSignal, + Index, + Show, + untrack, + type Component, +} from "solid-js"; +import "./TootPoll.css"; +import type { mastodon } from "masto"; +import { resolveCustomEmoji } from "../../masto/toot"; +import { + Button, + Checkbox, + Divider, + List, + ListItemButton, + ListItemText, + Radio, +} from "@suid/material"; +import { + formatDistance, + isBefore, +} from "date-fns"; +import { useTimeSource } from "~platform/timesrc"; +import { useDateFnLocale } from "~platform/i18n"; +import TootPollDialog from "./TootPollDialog"; +import { ANIM_CURVE_STD } from "~material/theme"; + +type TootPollProps = { + options: Readonly; + multiple?: boolean; + votesCount: number; + expired?: boolean; + expiredAt?: Date; + voted?: boolean; + ownVotes?: readonly number[]; + + onVote(votes: readonly number[]): void | Promise; +}; + +const TootPoll: Component = (props) => { + let list: HTMLUListElement; + const now = useTimeSource(); + const dateFnLocale = useDateFnLocale(); + const [mustShowResult, setMustShowResult] = createSignal(); + const [showVoteDialog, setShowVoteDialog] = createSignal(false); + + const [initialVote, setInitialVote] = createSignal(0); + + const isShowResult = () => { + const n = mustShowResult(); + if (typeof n !== "undefined") { + return n; + } + + return props.expired || props.voted; + }; + + const isOwnVote = createSelector( + () => props.ownVotes, + (idx: number, votes) => votes?.includes(idx) || false, + ); + + const openVote = (i: number, event: Event) => { + event.stopPropagation(); + + if (props.expired || props.voted) { + return; + } + + batch(() => { + setInitialVote(i); + setShowVoteDialog(true); + }); + }; + + const animateAndSetMustShow = (event: Event) => { + event.stopPropagation(); + list.animate( + { + opacity: [0.5, 0, 0.5], + }, + { + duration: 220, + easing: ANIM_CURVE_STD, + }, + ); + setMustShowResult((x) => { + if (typeof x === "undefined") { + return !untrack(isShowResult); + } else { + return undefined; + } + }); + }; + + return ( +
+
+ {props.votesCount} votes in total + + Poll is ended + +
+ + + {(option, index) => { + return ( + <> + + + + + + + createRenderEffect(() => { + e.innerHTML = resolveCustomEmoji( + option().title, + option().emojis, + ); + }) + } + > + + + + + + {option().votesCount} votes + + + + + + } + > + + + + + + ); + }} + + +
+ + + + + {isBefore(now(), props.expiredAt!) ? "Expire in" : "Expired"} + + + + + +
+ + console.debug(votes)} + onClose={() => setShowVoteDialog(false)} + initialVotes={[initialVote()]} + /> +
+ ); +}; + +export default TootPoll; diff --git a/src/timelines/toots/TootPollDialog.css b/src/timelines/toots/TootPollDialog.css new file mode 100644 index 0000000..02787b4 --- /dev/null +++ b/src/timelines/toots/TootPollDialog.css @@ -0,0 +1,12 @@ +.TootPollDialog { + >.bottom-dock>.actions { + border-top: 1px solid #ddd; + background: var(--tutu-color-surface); + padding: + 8px 16px calc(8px + var(--safe-area-inset-bottom, 0px)); + width: 100%; + display: flex; + flex-flow: row wrap; + justify-content: space-between; + } +} \ No newline at end of file diff --git a/src/timelines/toots/TootPollDialog.tsx b/src/timelines/toots/TootPollDialog.tsx new file mode 100644 index 0000000..3d6ef4d --- /dev/null +++ b/src/timelines/toots/TootPollDialog.tsx @@ -0,0 +1,121 @@ +import { + Button, + Checkbox, + List, + ListItemButton, + ListItemText, + Radio, +} from "@suid/material"; +import type { mastodon } from "masto"; +import { + createEffect, + createRenderEffect, + createSignal, + Index, + Show, + type Component, +} from "solid-js"; +import BottomSheet, { type BottomSheetProps } from "~material/BottomSheet"; +import Scaffold from "~material/Scaffold"; +import { resolveCustomEmoji } from "../../masto/toot"; +import "./TootPollDialog.css"; + +export type TootPollDialogPoll = { + open?: boolean; + options: Readonly; + initialVotes?: readonly number[]; + multiple?: boolean; + + onVote(votes: readonly number[]): void | Promise; + onClose?: BottomSheetProps["onClose"] & + ((reason: "cancel" | "success") => void); +}; + +const TootPollDialog: Component = (props) => { + const [votes, setVotes] = createSignal([] as readonly number[]); + const [inProgress, setInProgress] = createSignal(false); + + createEffect(() => { + setVotes(props.initialVotes || []); + }); + + const toggleVote = (i: number) => { + if (props.multiple) { + setVotes((o) => [...o.filter((x) => x === i), i]); + } else { + setVotes([i]); + } + }; + + const sendVote = async () => { + setInProgress(true); + try { + await props.onVote(votes()); + } catch (reason) { + console.error(reason); + props.onClose?.("cancel"); + return; + } finally { + setInProgress(false); + } + props.onClose?.("success"); + }; + + return ( + + + + + + } + > + + + {(option, index) => { + return ( + + + + createRenderEffect( + () => + (e.innerHTML = resolveCustomEmoji( + option().title, + option().emojis, + )), + ) + } + > + + + } + > + + + + ); + }} + + + + + ); +}; + +export default TootPollDialog;