import type { mastodon } from "masto"; import { splitProps, type Component, type JSX, Show, createRenderEffect, createEffect, createMemo, } from "solid-js"; import tootStyle from "./toot.module.css"; import { formatRelative } from "date-fns"; import Img from "../material/Img.js"; import { Body1, Body2, Title } from "../material/typography.js"; import { css } from "solid-styled"; import { BookmarkAddOutlined, Repeat, ReplyAll, Star, StarOutline, Bookmark, Reply, Share, } from "@suid/icons-material"; import { useTimeSource } from "../platform/timesrc.js"; import { resolveCustomEmoji } from "../masto/toot.js"; import { Divider } from "@suid/material"; import cardStyle from "../material/cards.module.css"; import Button from "../material/Button.js"; import MediaAttachmentGrid from "./MediaAttachmentGrid.js"; import { FastAverageColor } from "fast-average-color"; import Color from "colorjs.io"; import { useDateFnLocale } from "../platform/i18n"; import { canShare, share } from "../platform/share"; import { makeAcctText, useDefaultSession } from "../masto/clients"; import { useNavigate } from "@solidjs/router"; function preventDefault(event: Event) { event.preventDefault(); } type TootContentViewProps = { source?: string; emojis?: mastodon.v1.CustomEmoji[]; mentions: mastodon.v1.StatusMention[]; } & JSX.HTMLAttributes<HTMLDivElement>; const TootContentView: Component<TootContentViewProps> = (props) => { const session = useDefaultSession(); const [managed, rest] = splitProps(props, ["source", "emojis", "mentions"]); const clientFinder = createMemo(() => session() ? makeAcctText(session()!) : undefined, ); return ( <div ref={(ref) => { createRenderEffect(() => { ref.innerHTML = managed.source ? managed.emojis ? resolveCustomEmoji(managed.source, managed.emojis) : managed.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.rel = "acct"; e.dataset.client = finder; e.dataset.acctId = mention.id.toString(); } } }); }} {...rest} ></div> ); }; const RetootIcon: Component<JSX.HTMLElementTags["i"]> = (props) => { const [managed, rest] = splitProps(props, ["class"]); css` .retoot-icon { padding: 0; display: inline-block; border-radius: 2px; > :global(svg) { color: green; font-size: 1rem; vertical-align: middle; } } `; return ( <i class={["retoot-icon", managed.class].join(" ")} {...rest}> <Repeat /> </i> ); }; const ReplyIcon: Component<JSX.HTMLElementTags["i"]> = (props) => { const [managed, rest] = splitProps(props, ["class"]); css` .retoot-icon { padding: 0; display: inline-block; border-radius: 2px; > :global(svg) { color: var(--tutu-color-primary); font-size: 1rem; vertical-align: middle; } } `; return ( <i class={["retoot-icon", managed.class].join(" ")} {...rest}> <Reply /> </i> ); }; type TootActionGroupProps<T extends mastodon.v1.Status> = { onRetoot?: (value: T) => void; onFavourite?: (value: T) => void; onBookmark?: (value: T) => void; onReply?: ( value: T, event: MouseEvent & { currentTarget: HTMLButtonElement }, ) => void; }; type TootCardProps = { status: mastodon.v1.Status; actionable?: boolean; evaluated?: boolean; thread?: "top" | "bottom" | "middle"; } & TootActionGroupProps<mastodon.v1.Status> & JSX.HTMLElementTags["article"]; function isolatedCallback(e: MouseEvent) { e.stopPropagation(); } export function findRootToot(element: HTMLElement) { let current: HTMLElement | null = element; while (current && !current.classList.contains(tootStyle.toot)) { current = current.parentElement; } if (!current) { throw Error( `the element must be placed under a element with ${tootStyle.toot}`, ); } return current; } function TootActionGroup<T extends mastodon.v1.Status>( props: TootActionGroupProps<T> & { value: T }, ) { let actGrpElement: HTMLDivElement; const toot = () => props.value; return ( <div ref={actGrpElement!} class={tootStyle.tootBottomActionGrp} onClick={isolatedCallback} > <Show when={props.onReply}> <Button class={tootStyle.tootActionWithCount} onClick={[props.onReply!, props.value]} > <ReplyAll /> <span>{toot().repliesCount}</span> </Button> </Show> <Button class={tootStyle.tootActionWithCount} style={{ color: toot().reblogged ? "var(--tutu-color-primary)" : undefined, }} onClick={() => props.onRetoot?.(toot())} > <Repeat /> <span>{toot().reblogsCount}</span> </Button> <Button class={tootStyle.tootActionWithCount} style={{ color: toot().favourited ? "var(--tutu-color-primary)" : undefined, }} onClick={() => props.onFavourite?.(toot())} > {toot().favourited ? <Star /> : <StarOutline />} <span>{toot().favouritesCount}</span> </Button> <Button class={tootStyle.tootAction} style={{ color: toot().bookmarked ? "var(--tutu-color-primary)" : undefined, }} onClick={() => props.onBookmark?.(toot())} > {toot().bookmarked ? <Bookmark /> : <BookmarkAddOutlined />} </Button> <Show when={canShare({ url: toot().url ?? undefined })}> <Button class={tootStyle.tootAction} aria-label="Share" onClick={async () => { await share({ url: toot().url ?? undefined, }); }} > <Share /> </Button> </Show> </div> ); } function TootAuthorGroup( props: { status: mastodon.v1.Status; now: Date; } & JSX.HTMLElementTags["div"], ) { const [managed, rest] = splitProps(props, ["status", "now"]); const toot = () => managed.status; const dateFnLocale = useDateFnLocale(); return ( <div class={tootStyle.tootAuthorGrp} {...rest}> <Img src={toot().account.avatar} class={tootStyle.tootAvatar} /> <div class={tootStyle.tootAuthorNameGrp}> <Body2 class={tootStyle.tootAuthorNamePrimary} ref={(e: { innerHTML: string }) => { createRenderEffect(() => { e.innerHTML = resolveCustomEmoji( toot().account.displayName, toot().account.emojis, ); }); }} /> <time datetime={toot().createdAt}> {formatRelative(toot().createdAt, managed.now, { locale: dateFnLocale(), })} </time> <span> @{toot().account.username}@{new URL(toot().account.url).hostname} </span> </div> </div> ); } export function TootPreviewCard(props: { src: mastodon.v1.PreviewCard; alwaysCompact?: boolean; }) { let root: HTMLAnchorElement; createEffect(() => { if (props.alwaysCompact) { root.classList.add(tootStyle.compact); return; } if (!props.src.width) return; const width = root.getBoundingClientRect().width; if (width > props.src.width) { root.classList.add(tootStyle.compact); } else { root.classList.remove(tootStyle.compact); } }); const onImgLoad = (event: Event & { currentTarget: HTMLImageElement }) => { // TODO: better extraction algorithm // I'd like to use a pattern panel and match one in the panel from the extracted color const fac = new FastAverageColor(); const result = fac.getColor(event.currentTarget); if (result.error) { console.error(result.error); fac.destroy(); return; } root.style.setProperty("--tutu-color-surface", result.hex); const focusSurface = result.isDark ? new Color(result.hex).darken(0.2) : new Color(result.hex).lighten(0.2); root.style.setProperty("--tutu-color-surface-d", focusSurface.toString()); const textColor = result.isDark ? "white" : "black"; const secondaryTextColor = new Color(textColor); secondaryTextColor.alpha = 0.75; root.style.setProperty("--tutu-color-on-surface", textColor); root.style.setProperty( "--tutu-color-secondary-text-on-surface", secondaryTextColor.toString(), ); fac.destroy(); }; return ( <a ref={root!} class={tootStyle.previewCard} href={props.src.url} target="_blank" referrerPolicy="unsafe-url" > <Show when={props.src.image}> <img crossOrigin="anonymous" src={props.src.image!} onLoad={onImgLoad} width={props.src.width || undefined} height={props.src.height || undefined} loading="lazy" /> </Show> <Title component="h1">{props.src.title}</Title> <Body1 component="p">{props.src.description}</Body1> </a> ); } /** * Component for a toot. * * If the session involved is not the first session, you must wrap * this component under a `<DefaultSessionProvier />` with correct * session. */ const RegularToot: Component<TootCardProps> = (props) => { let rootRef: HTMLElement; const [managed, managedActionGroup, rest] = splitProps( props, ["status", "lang", "class", "actionable", "evaluated", "thread"], ["onRetoot", "onFavourite", "onBookmark", "onReply"], ); const now = useTimeSource(); const status = () => managed.status; const toot = () => status().reblog ?? status(); const session = useDefaultSession(); const navigate = useNavigate(); const openProfile = (event: MouseEvent) => { if (!managed.evaluated) return; event.stopPropagation(); const s = session(); if (!s) { console.warn("No session is provided"); return; } const acct = makeAcctText(s); navigate( `/${encodeURIComponent(acct)}/profile/${managed.status.account.id}`, ); }; css` .reply-sep { margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 8px); margin-block: 8px; } .thread-top, .thread-mid, .thread-btm { position: relative; &::before { content: ""; position: absolute; left: 36px; background-color: var(--tutu-color-secondary); width: 2px; display: block; } } .thread-mid { &::before { top: 0; bottom: 0; } } .thread-top { &::before { top: 16px; bottom: 0; } } .thread-btm { &::before { top: 0; height: 16px; } } `; return ( <> <section classList={{ [tootStyle.toot]: true, [tootStyle.expanded]: managed.evaluated, "thread-top": managed.thread === "top", "thread-mid": managed.thread === "middle", "thread-btm": managed.thread === "bottom", [managed.class || ""]: true, }} ref={rootRef!} lang={toot().language || managed.lang} {...rest} > <Show when={!!status().reblog}> <div class={tootStyle.tootRetootGrp}> <RetootIcon /> <span> <Body2 ref={(e: { innerHTML: string }) => { createRenderEffect(() => { e.innerHTML = resolveCustomEmoji( status().account.displayName, toot().emojis, ); }); }} ></Body2>{" "} boosted </span> </div> </Show> <TootAuthorGroup status={toot()} now={now()} data-rel="acct" data-client={session() ? makeAcctText(session()!) : undefined} data-acct-id={toot().account.id} /> <TootContentView source={toot().content} emojis={toot().emojis} mentions={toot().mentions} class={tootStyle.tootContent} /> <Show when={toot().card}> <TootPreviewCard src={toot().card!} /> </Show> <Show when={toot().mediaAttachments.length > 0}> <MediaAttachmentGrid attachments={toot().mediaAttachments} /> </Show> <Show when={managed.actionable}> <Divider class={cardStyle.cardNoPad} style={{ "margin-top": "8px" }} /> <TootActionGroup value={toot()} {...managedActionGroup} /> </Show> </section> </> ); }; export default RegularToot;