Compare commits

..

6 commits

Author SHA1 Message Date
thislight
8d8d2a8fb1
RegularToot: only show preview if reveal
All checks were successful
/ depoly (push) Successful in 1m20s
2024-11-20 21:17:22 +08:00
thislight
8cd95b9e90
MediaAttachmentGrid: support click to reveal 2024-11-20 21:09:14 +08:00
thislight
1047a3b10d
MediaAttachmentGrid: add a box to each item 2024-11-20 20:45:24 +08:00
thislight
b1f6033cc8
TootContent: localized 2024-11-20 16:33:30 +08:00
thislight
6313827b1e
rename toot-components to toots 2024-11-20 16:26:05 +08:00
thislight
737d63f88a
RegularToot: support content warning 2024-11-20 16:24:57 +08:00
12 changed files with 255 additions and 142 deletions

View file

@ -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;
}
}
}

View file

@ -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<HTMLElement>();
const [viewerIndex, setViewerIndex] = createSignal<number>();
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 (
<section
ref={setRootRef}
class={`MediaAttachmentGrid ${cardStyle.cardNoPad}`}
style={{ "column-count": columnCount() }}
onClick={(e) => {
if (e.target !== e.currentTarget) {
e.stopImmediatePropagation();
}
classList={{
sensitive: props.sensitive,
}}
style={{ "column-count": columnCount() }}
onClick={isolateCallback}
>
<Index each={props.attachments}>
{(item, index) => {
const itemType = () => item().type;
return (
<Switch>
<Match when={itemType() === "image"}>
<img
data-sort={index}
data-media-type={item().type}
src={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
alt={item().description || undefined}
onClick={[openViewerFor, index]}
loading="lazy"
style={itemStyle(item())}
></img>
</Match>
<Match when={itemType() === "video"}>
<video
data-sort={index}
data-media-type={item().type}
src={item().url || undefined}
autoplay={settings().autoPlayVideos}
playsinline={settings().autoPlayVideos ? true : undefined}
controls
poster={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
style={itemStyle(item())}
/>
</Match>
<Match when={itemType() === "gifv"}>
<video
data-sort={index}
data-media-type={item().type}
src={item().url || undefined}
autoplay={settings().autoPlayGIFs}
controls
playsinline /* or safari on iOS will play in full-screen */
loop
poster={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
style={itemStyle(item())}
/>
</Match>
<Match when={itemType() === "audio"}>
<audio
data-sort={index}
data-media-type={item().type}
src={item().url || undefined}
controls
></audio>
</Match>
</Switch>
<div
class="cell"
role="presentation"
style={itemStyle(item())}
data-sort={index}
data-media-type={item().type}
>
<Switch>
<Match when={props.sensitive && !isReveal(index)}>
<div class="sensitive-placeholder">
<IconButton
color="inherit"
size="large"
onClick={[addReveal, index]}
aria-label="Reveal this media"
>
<Preview />
</IconButton>
</div>
</Match>
<Match when={itemType() === "image"}>
<img
src={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
alt={item().description || undefined}
onClick={[openViewerFor, index]}
loading="lazy"
></img>
</Match>
<Match when={itemType() === "video"}>
<video
src={item().url || undefined}
autoplay={!props.sensitive && settings().autoPlayVideos}
playsinline={settings().autoPlayVideos ? true : undefined}
controls
poster={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
/>
</Match>
<Match when={itemType() === "gifv"}>
<video
src={item().url || undefined}
autoplay={!props.sensitive && settings().autoPlayGIFs}
controls
playsinline /* or safari on iOS will play in full-screen */
loop
poster={item().previewUrl}
width={item().meta?.small?.width}
height={item().meta?.small?.height}
/>
</Match>
<Match when={itemType() === "audio"}>
<audio src={item().url || undefined} controls></audio>
</Match>
</Switch>
</div>
);
}}
</Index>

View file

@ -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<T extends mastodon.v1.Status> = {
onRetoot?: (value: T) => void;
@ -45,7 +47,7 @@ type TootActionGroupProps<T extends mastodon.v1.Status> = {
) => 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<boolean>, 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<TootCardProps> = (props) => {
const RegularToot: Component<RegularTootProps> = (props) => {
let rootRef: HTMLElement;
const [managed, managedActionGroup, rest] = splitProps(
props,
@ -239,6 +246,7 @@ const RegularToot: Component<TootCardProps> = (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<TootCardProps> = (props) => {
return (
<>
<section
<article
classList={{
[tootStyle.toot]: true,
[tootStyle.expanded]: managed.evaluated,
@ -326,12 +334,23 @@ const RegularToot: Component<TootCardProps> = (props) => {
emojis={toot().emojis}
mentions={toot().mentions}
class={cardStyle.cardNoPad}
sensitive={toot().sensitive}
spoilerText={toot().spoilerText}
reveal={reveal()}
onToggleReveal={[onToggleReveal, setReveal]}
/>
<Show when={toot().card}>
<Show
when={
toot().card && (!toot().sensitive || (toot().sensitive && reveal()))
}
>
<PreviewCard src={toot().card!} />
</Show>
<Show when={toot().mediaAttachments.length > 0}>
<MediaAttachmentGrid attachments={toot().mediaAttachments} />
<MediaAttachmentGrid
attachments={toot().mediaAttachments}
sensitive={toot().sensitive}
/>
</Show>
<Show when={managed.actionable}>
<Divider
@ -340,7 +359,7 @@ const RegularToot: Component<TootCardProps> = (props) => {
/>
<TootActionGroup value={toot()} {...managedActionGroup} />
</Show>
</section>
</article>
</>
);
};

View file

@ -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<HTMLDivElement>;
const TootContent: Component<TootContentProps> = (oprops) => {
const session = useDefaultSession();
const [props, rest] = splitProps(oprops, [
"source",
"emojis",
"mentions",
"class",
]);
const clientFinder = createMemo(() =>
session() ? makeAcctText(session()!) : undefined,
);
return (
<div
ref={(ref) => {
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<HTMLAnchorElement>(
`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}
></div>
);
};
export default TootContent;

View file

@ -4,6 +4,10 @@
margin-right: var(--card-pad, 0);
line-height: 1.5;
> .content {
display: contents;
}
& * {
user-select: text;
}

View file

@ -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<HTMLDivElement> & {
source?: string;
emojis?: mastodon.v1.CustomEmoji[];
mentions: mastodon.v1.StatusMention[];
sensitive?: boolean;
spoilerText?: string;
reveal?: boolean;
onToggleReveal?: JSX.EventHandlerUnion<HTMLElement, Event>;
};
const TootContent: Component<TootContentProps> = (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 (
<div
ref={(ref) => {
createRenderEffect(() => {
const finder = clientFinder();
for (const mention of props.mentions) {
const elements = ref.querySelectorAll<HTMLAnchorElement>(
`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}
>
<Show when={props.sensitive}>
<div>
<span
ref={(ref) => {
createRenderEffect(() => {
ref.innerHTML = props.spoilerText
? props.emojis
? resolveCustomEmoji(props.spoilerText, props.emojis)
: props.spoilerText
: "";
});
}}
></span>
<Button onClick={props.onToggleReveal}>{t("cw")}</Button>
</div>
</Show>
<Show when={shouldRevealContent()}>
<div
class="content"
ref={(ref) =>
createRenderEffect(() => {
ref.innerHTML = props.source
? props.emojis
? resolveCustomEmoji(props.source, props.emojis)
: props.source
: "";
})
}
></div>
</Show>
</div>
);
};
export default TootContent;

View file

@ -0,0 +1,3 @@
{
"cw": "\"Content Warning\""
}

View file

@ -0,0 +1,3 @@
{
"cw": "“内容警告”"
}