diff --git a/src/timelines/MediaAttachmentGrid.css b/src/timelines/MediaAttachmentGrid.css index 55bb30d..a26085c 100644 --- a/src/timelines/MediaAttachmentGrid.css +++ b/src/timelines/MediaAttachmentGrid.css @@ -6,23 +6,34 @@ gap: 4px; contain: layout style; - > :where(img, video) { + >.cell { max-height: 35vh; min-height: 40px; min-width: 40px; - object-fit: contain; max-width: 100%; + contain: strict; + content-visibility: auto; background-color: var(--media-color-accent, var(--tutu-color-surface-d)); border-radius: 2px; border: 1px solid var(--tutu-color-surface-d); transition: outline-width 60ms var(--tutu-anim-curve-std), border-color 60ms var(--tutu-anim-curve-std); - contain: strict; - content-visibility: auto; &:hover, &:focus-visible { outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d)); border-color: var(--media-color-accent, var(--tutu-color-surface-d)); } + + & > :where(img, video, .sensitive-placeholder) { + object-fit: contain; + width: 100%; + height: 100%; + } + + & > .sensitive-placeholder { + display: flex; + align-items: center; + justify-content: center; + } } } \ No newline at end of file diff --git a/src/timelines/MediaAttachmentGrid.tsx b/src/timelines/MediaAttachmentGrid.tsx index 2b9db57..948bb7b 100644 --- a/src/timelines/MediaAttachmentGrid.tsx +++ b/src/timelines/MediaAttachmentGrid.tsx @@ -1,7 +1,6 @@ import type { mastodon } from "masto"; import { type Component, - For, Index, Match, Switch, @@ -9,6 +8,7 @@ import { createRenderEffect, createSignal, onCleanup, + untrack, } from "solid-js"; import MediaViewer from "./MediaViewer"; import { render } from "solid-js/web"; @@ -21,6 +21,8 @@ import { $settings } from "../settings/stores"; import { averageColorHex } from "../platform/blurhash"; import "./MediaAttachmentGrid.css"; import cardStyle from "../material/cards.module.css"; +import { Preview } from "@suid/icons-material"; +import { IconButton } from "@suid/material"; type ElementSize = { width: number; height: number }; @@ -44,23 +46,30 @@ function constraintedSize( }; } +function isolateCallback(event: Event) { + if (event.target !== event.currentTarget) { + event.stopPropagation(); + } +} + const MediaAttachmentGrid: Component<{ attachments: mastodon.v1.MediaAttachment[]; + sensitive?: boolean; }> = (props) => { const [rootRef, setRootRef] = createSignal(); const [viewerIndex, setViewerIndex] = createSignal(); const viewerOpened = () => typeof viewerIndex() !== "undefined"; const settings = useStore($settings); const windowSize = useWindowSize(); + const [reveal, setReveal] = createSignal([] as number[]); - createRenderEffect((lastDispose?: () => void) => { - lastDispose?.(); + createRenderEffect(() => { const vidx = viewerIndex(); if (typeof vidx === "undefined") return; const container = document.createElement("div"); container.setAttribute("role", "presentation"); document.body.appendChild(container); - return render(() => { + const dispose = render(() => { onCleanup(() => { document.body.removeChild(container); }); @@ -75,6 +84,8 @@ const MediaAttachmentGrid: Component<{ /> ); }, container); + + onCleanup(dispose); }); const openViewerFor = (index: number) => { @@ -127,73 +138,88 @@ const MediaAttachmentGrid: Component<{ accentColor ? { "--media-color-accent": accentColor } : {}, ); }; + + const isReveal = (idx: number) => { + return reveal().includes(idx); + }; + + const addReveal = (idx: number) => { + if (!untrack(() => isReveal(idx))) { + setReveal((x) => [...x, idx]); + } + }; return (
{ - if (e.target !== e.currentTarget) { - e.stopImmediatePropagation(); - } + classList={{ + sensitive: props.sensitive, }} + style={{ "column-count": columnCount() }} + onClick={isolateCallback} > {(item, index) => { const itemType = () => item().type; return ( - - - {item().description - - - - - - - - - + ); }} diff --git a/src/timelines/RegularToot.tsx b/src/timelines/RegularToot.tsx index 5a4faa0..07541ad 100644 --- a/src/timelines/RegularToot.tsx +++ b/src/timelines/RegularToot.tsx @@ -5,6 +5,8 @@ import { type JSX, Show, createRenderEffect, + createSignal, + type Setter, } from "solid-js"; import tootStyle from "./toot.module.css"; import { formatRelative } from "date-fns"; @@ -31,9 +33,9 @@ import MediaAttachmentGrid from "./MediaAttachmentGrid.js"; import { useDateFnLocale } from "../platform/i18n"; import { canShare, share } from "../platform/share"; import { makeAcctText, useDefaultSession } from "../masto/clients"; -import TootContent from "./toot-components/TootContent"; -import BoostIcon from "./toot-components/BoostIcon"; -import PreviewCard from "./toot-components/PreviewCard"; +import TootContent from "./toots/TootContent"; +import BoostIcon from "./toots/BoostIcon"; +import PreviewCard from "./toots/PreviewCard"; type TootActionGroupProps = { onRetoot?: (value: T) => void; @@ -45,7 +47,7 @@ type TootActionGroupProps = { ) => void; }; -type TootCardProps = { +type RegularTootProps = { status: mastodon.v1.Status; actionable?: boolean; evaluated?: boolean; @@ -200,6 +202,11 @@ export function findElementActionable( return current; } +function onToggleReveal(setValue: Setter, event: Event) { + event.stopPropagation(); + setValue((x) => !x); +} + /** * Component for a toot. * @@ -228,7 +235,7 @@ export function findElementActionable( * You can extract the intent from the attributes of the "actionable" element. * The action type is the dataset's `action`. */ -const RegularToot: Component = (props) => { +const RegularToot: Component = (props) => { let rootRef: HTMLElement; const [managed, managedActionGroup, rest] = splitProps( props, @@ -239,6 +246,7 @@ const RegularToot: Component = (props) => { const status = () => managed.status; const toot = () => status().reblog ?? status(); const session = useDefaultSession(); + const [reveal, setReveal] = createSignal(false); css` .reply-sep { @@ -285,7 +293,7 @@ const RegularToot: Component = (props) => { return ( <> -
= (props) => { emojis={toot().emojis} mentions={toot().mentions} class={cardStyle.cardNoPad} + sensitive={toot().sensitive} + spoilerText={toot().spoilerText} + reveal={reveal()} + onToggleReveal={[onToggleReveal, setReveal]} /> - + 0}> - + = (props) => { /> -
+ ); }; diff --git a/src/timelines/toot-components/TootContent.tsx b/src/timelines/toot-components/TootContent.tsx deleted file mode 100644 index fd0dab6..0000000 --- a/src/timelines/toot-components/TootContent.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { mastodon } from "masto"; -import { - splitProps, - type Component, - type JSX, - createRenderEffect, - createMemo, -} from "solid-js"; -import { resolveCustomEmoji } from "../../masto/toot.js"; -import { makeAcctText, useDefaultSession } from "../../masto/clients"; -import "./TootContent.css"; - -function preventDefault(event: Event) { - event.preventDefault(); -} - -export type TootContentProps = { - source?: string; - emojis?: mastodon.v1.CustomEmoji[]; - mentions: mastodon.v1.StatusMention[]; -} & JSX.HTMLAttributes; - -const TootContent: Component = (oprops) => { - const session = useDefaultSession(); - const [props, rest] = splitProps(oprops, [ - "source", - "emojis", - "mentions", - "class", - ]); - - const clientFinder = createMemo(() => - session() ? makeAcctText(session()!) : undefined, - ); - - return ( -
{ - createRenderEffect(() => { - ref.innerHTML = props.source - ? props.emojis - ? resolveCustomEmoji(props.source, props.emojis) - : props.source - : ""; - }); - - createRenderEffect(() => { - const finder = clientFinder(); - for (const mention of props.mentions) { - const elements = ref.querySelectorAll( - `a[href='${mention.url}']`, - ); - for (const e of elements) { - e.onclick = preventDefault; - e.dataset.action = "acct"; - e.dataset.client = finder; - e.dataset.acctId = mention.id.toString(); - } - } - }); - }} - class={`TootContent ${props.class || ""}`} - {...rest} - >
- ); -}; - -export default TootContent; diff --git a/src/timelines/toot-components/BoostIcon.css b/src/timelines/toots/BoostIcon.css similarity index 100% rename from src/timelines/toot-components/BoostIcon.css rename to src/timelines/toots/BoostIcon.css diff --git a/src/timelines/toot-components/BoostIcon.tsx b/src/timelines/toots/BoostIcon.tsx similarity index 100% rename from src/timelines/toot-components/BoostIcon.tsx rename to src/timelines/toots/BoostIcon.tsx diff --git a/src/timelines/toot-components/PreviewCard.css b/src/timelines/toots/PreviewCard.css similarity index 100% rename from src/timelines/toot-components/PreviewCard.css rename to src/timelines/toots/PreviewCard.css diff --git a/src/timelines/toot-components/PreviewCard.tsx b/src/timelines/toots/PreviewCard.tsx similarity index 100% rename from src/timelines/toot-components/PreviewCard.tsx rename to src/timelines/toots/PreviewCard.tsx diff --git a/src/timelines/toot-components/TootContent.css b/src/timelines/toots/TootContent.css similarity index 92% rename from src/timelines/toot-components/TootContent.css rename to src/timelines/toots/TootContent.css index b5e56e6..79c4ffc 100644 --- a/src/timelines/toot-components/TootContent.css +++ b/src/timelines/toots/TootContent.css @@ -4,6 +4,10 @@ margin-right: var(--card-pad, 0); line-height: 1.5; + > .content { + display: contents; + } + & * { user-select: text; } diff --git a/src/timelines/toots/TootContent.tsx b/src/timelines/toots/TootContent.tsx new file mode 100644 index 0000000..7a2286d --- /dev/null +++ b/src/timelines/toots/TootContent.tsx @@ -0,0 +1,115 @@ +import type { mastodon } from "masto"; +import { + splitProps, + type Component, + type JSX, + createRenderEffect, + createMemo, + Show, +} from "solid-js"; +import { resolveCustomEmoji } from "../../masto/toot.js"; +import { makeAcctText, useDefaultSession } from "../../masto/clients.js"; +import "./TootContent.css"; +import { Button } from "@suid/material"; +import { createTranslator } from "../../platform/i18n.jsx"; + +function preventDefault(event: Event) { + event.preventDefault(); +} + +export type TootContentProps = JSX.HTMLAttributes & { + source?: string; + emojis?: mastodon.v1.CustomEmoji[]; + mentions: mastodon.v1.StatusMention[]; + sensitive?: boolean; + spoilerText?: string; + reveal?: boolean; + onToggleReveal?: JSX.EventHandlerUnion; +}; + +const TootContent: Component = (oprops) => { + const [t] = createTranslator( + (code) => + import(`./i18n/${code}.json`) as Promise<{ + default: { + cw: string; + }; + }>, + ); + + const session = useDefaultSession(); + const [props, rest] = splitProps(oprops, [ + "source", + "emojis", + "mentions", + "class", + "sensitive", + "spoilerText", + "reveal", + "onToggleReveal", + ]); + + const clientFinder = createMemo(() => + session() ? makeAcctText(session()!) : undefined, + ); + + const shouldRevealContent = () => { + return !props.sensitive || (props.sensitive && props.reveal); + }; + + return ( +
{ + createRenderEffect(() => { + const finder = clientFinder(); + for (const mention of props.mentions) { + const elements = ref.querySelectorAll( + `a[href='${mention.url}']`, + ); + for (const e of elements) { + e.onclick = preventDefault; + e.dataset.action = "acct"; + e.dataset.client = finder; + e.dataset.acctId = mention.id.toString(); + } + } + }); + }} + class={`TootContent ${props.class || ""}`} + {...rest} + > + +
+ { + createRenderEffect(() => { + ref.innerHTML = props.spoilerText + ? props.emojis + ? resolveCustomEmoji(props.spoilerText, props.emojis) + : props.spoilerText + : ""; + }); + }} + > + +
+
+ +
+ createRenderEffect(() => { + ref.innerHTML = props.source + ? props.emojis + ? resolveCustomEmoji(props.source, props.emojis) + : props.source + : ""; + }) + } + >
+
+
+ ); +}; + +export default TootContent; diff --git a/src/timelines/toots/i18n/en.json b/src/timelines/toots/i18n/en.json new file mode 100644 index 0000000..3d75ddd --- /dev/null +++ b/src/timelines/toots/i18n/en.json @@ -0,0 +1,3 @@ +{ + "cw": "\"Content Warning\"" +} \ No newline at end of file diff --git a/src/timelines/toots/i18n/zh-Hans.json b/src/timelines/toots/i18n/zh-Hans.json new file mode 100644 index 0000000..0e64a62 --- /dev/null +++ b/src/timelines/toots/i18n/zh-Hans.json @@ -0,0 +1,3 @@ +{ + "cw": "“内容警告”" +} \ No newline at end of file