From f50ed8d907c53fed4f7341269556d5c3365a2b93 Mon Sep 17 00:00:00 2001 From: thislight Date: Sat, 23 Nov 2024 20:19:43 +0800 Subject: [PATCH 1/4] BottomSheet: stop clicks propagation --- src/material/BottomSheet.tsx | 103 ++++++++++++++++------------------- 1 file changed, 46 insertions(+), 57 deletions(-) diff --git a/src/material/BottomSheet.tsx b/src/material/BottomSheet.tsx index b9a9f38..73fd734 100644 --- a/src/material/BottomSheet.tsx +++ b/src/material/BottomSheet.tsx @@ -1,19 +1,14 @@ import { children, createEffect, - createSignal, onCleanup, useTransition, type JSX, type ParentComponent, - type ResolvedChildren, } from "solid-js"; import "./BottomSheet.css"; import material from "./material.module.css"; -import { - ANIM_CURVE_ACELERATION, - ANIM_CURVE_DECELERATION, -} from "./theme"; +import { ANIM_CURVE_ACELERATION, ANIM_CURVE_DECELERATION } from "./theme"; import { animateSlideInFromRight, animateSlideOutToRight, @@ -28,11 +23,36 @@ export type BottomSheetProps = { const MOVE_SPEED = 1600; +function animateSlideInFromBottom(element: HTMLElement, reverse?: boolean) { + const rect = element.getBoundingClientRect(); + const easing = "cubic-bezier(0.4, 0, 0.2, 1)"; + element.classList.add("animated"); + const oldOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const distance = Math.abs(rect.top - window.innerHeight); + const duration = (distance / MOVE_SPEED) * 1000; + + const animation = element.animate( + { + top: reverse + ? [`${rect.top}px`, `${window.innerHeight}px`] + : [`${window.innerHeight}px`, `${rect.top}px`], + }, + { easing, duration }, + ); + const onAnimationEnd = () => { + element.classList.remove("animated"); + document.body.style.overflow = oldOverflow; + }; + animation.addEventListener("cancel", onAnimationEnd); + animation.addEventListener("finish", onAnimationEnd); + return animation; +} + const BottomSheet: ParentComponent = (props) => { let element: HTMLDialogElement; let animation: Animation | undefined; - const [cache, setCache] = createSignal(); - const ochildren = children(() => props.children); + const child = children(() => props.children); const [pending] = useTransition(); @@ -40,12 +60,10 @@ const BottomSheet: ParentComponent = (props) => { if (props.open) { if (!element.open && !pending()) { requestAnimationFrame(animatedOpen); - setCache(ochildren()); } } else { if (element.open) { animatedClose(); - setCache(undefined); } } }); @@ -55,22 +73,21 @@ const BottomSheet: ParentComponent = (props) => { }; const animatedClose = () => { - - if (window.innerWidth > 560 && !props.bottomUp) { - onClose(); - return; - } - const onAnimationEnd = () => { - element.classList.remove("animated"); - onClose(); - }; - element.classList.add("animated"); - animation = props.bottomUp - ? animateSlideInFromBottom(element, true) - : animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION }); - animation.addEventListener("finish", onAnimationEnd); - animation.addEventListener("cancel", onAnimationEnd); - + if (window.innerWidth > 560 && !props.bottomUp) { + onClose(); + return; + } + const onAnimationEnd = () => { + element.classList.remove("animated"); + animation = undefined; + onClose(); + }; + element.classList.add("animated"); + animation = props.bottomUp + ? animateSlideInFromBottom(element, true) + : animateSlideOutToRight(element, { easing: ANIM_CURVE_ACELERATION }); + animation.addEventListener("finish", onAnimationEnd); + animation.addEventListener("cancel", onAnimationEnd); }; const animatedOpen = () => { @@ -81,6 +98,7 @@ const BottomSheet: ParentComponent = (props) => { element.classList.add("animated"); const onAnimationEnd = () => { element.classList.remove("animated"); + animation = undefined; }; animation = animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION, @@ -90,36 +108,6 @@ const BottomSheet: ParentComponent = (props) => { } }; - const animateSlideInFromBottom = ( - element: HTMLElement, - reserve?: boolean, - ) => { - const rect = element.getBoundingClientRect(); - const easing = "cubic-bezier(0.4, 0, 0.2, 1)"; - element.classList.add("animated"); - const oldOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - const distance = Math.abs(rect.top - window.innerHeight); - const duration = (distance / MOVE_SPEED) * 1000; - - animation = element.animate( - { - top: reserve - ? [`${rect.top}px`, `${window.innerHeight}px`] - : [`${window.innerHeight}px`, `${rect.top}px`], - }, - { easing, duration }, - ); - const onAnimationEnd = () => { - element.classList.remove("animated"); - document.body.style.overflow = oldOverflow; - animation = undefined; - }; - animation.addEventListener("cancel", onAnimationEnd); - animation.addEventListener("finish", onAnimationEnd); - return animation; - }; - onCleanup(() => { if (animation) { animation.cancel(); @@ -129,6 +117,7 @@ const BottomSheet: ParentComponent = (props) => { const onDialogClick = ( event: MouseEvent & { currentTarget: HTMLDialogElement }, ) => { + event.stopPropagation(); if (event.target !== event.currentTarget) return; const rect = event.currentTarget.getBoundingClientRect(); const isNotInDialog = @@ -159,7 +148,7 @@ const BottomSheet: ParentComponent = (props) => { tabIndex={-1} role="presentation" > - {ochildren() ?? cache()} + {child()} ); }; From 9bf957188c60cd68e6a26700abae2dbc5fd9d207 Mon Sep 17 00:00:00 2001 From: thislight Date: Sat, 23 Nov 2024 20:20:27 +0800 Subject: [PATCH 2/4] theme: add error color --- src/material/theme.css | 2 ++ src/material/theme.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/material/theme.css b/src/material/theme.css index 5048f98..fc8d718 100644 --- a/src/material/theme.css +++ b/src/material/theme.css @@ -153,6 +153,8 @@ --tutu-transition-shadow: box-shadow 175ms var(--tutu-anim-curve-std); --tutu-zidx-nav: 1100; + + accent-color: var(--tutu-color-primary); } * { diff --git a/src/material/theme.ts b/src/material/theme.ts index a7c2c65..0eb4eaf 100644 --- a/src/material/theme.ts +++ b/src/material/theme.ts @@ -1,5 +1,5 @@ import { Theme, createTheme } from "@suid/material/styles"; -import { deepPurple, amber } from "@suid/material/colors"; +import { deepPurple, amber, red } from "@suid/material/colors"; import { Accessor } from "solid-js"; /** @@ -12,6 +12,9 @@ export function useRootTheme(): Accessor { primary: { main: deepPurple[500], }, + error: { + main: red[900], + }, secondary: { main: amber.A200, }, From c85cffc03ef85a0c1c6d427bd539494893c6c92e Mon Sep 17 00:00:00 2001 From: thislight Date: Sat, 23 Nov 2024 20:55:37 +0800 Subject: [PATCH 3/4] 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; From 25ceb469118598c0b481fd60f757ae36a68625c1 Mon Sep 17 00:00:00 2001 From: thislight Date: Sat, 23 Nov 2024 20:56:00 +0800 Subject: [PATCH 4/4] Profile: minor changes --- src/profiles/Profile.css | 1 + src/profiles/Profile.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/profiles/Profile.css b/src/profiles/Profile.css index a8ce070..021afdd 100644 --- a/src/profiles/Profile.css +++ b/src/profiles/Profile.css @@ -8,6 +8,7 @@ display: flex; flex-flow: column nowrap; gap: 16px; + contain: layout style; } .banner { diff --git a/src/profiles/Profile.tsx b/src/profiles/Profile.tsx index f795c7c..56f6c8e 100644 --- a/src/profiles/Profile.tsx +++ b/src/profiles/Profile.tsx @@ -237,7 +237,7 @@ const Profile: Component = () => { } class="Profile" > -
+
-
+