import type { mastodon } from "masto"; import { splitProps, type Component, type JSX, Show, createRenderEffect, createSignal, createEffect, } from "solid-js"; import tootStyle from "./toot.module.css"; import { formatRelative } from "date-fns"; import Img from "../material/Img.js"; import { Body1, Body2, Caption, Subheading, 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, IconButton } 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"; type TootContentViewProps = { source?: string; emojis?: mastodon.v1.CustomEmoji[]; } & JSX.HTMLAttributes<HTMLDivElement>; const TootContentView: Component<TootContentViewProps> = (props) => { const [managed, rest] = splitProps(props, ["source", "emojis"]); return ( <div ref={(ref) => { createRenderEffect(() => { ref.innerHTML = managed.source ? managed.emojis ? resolveCustomEmoji(managed.source, managed.emojis) : managed.source : ""; }); }} {...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) => void; }; type TootCardProps = { status: mastodon.v1.Status; actionable?: boolean; evaluated?: boolean; } & TootActionGroupProps<mastodon.v1.Status> & JSX.HTMLElementTags["article"]; function isolatedCallback(e: MouseEvent) { e.stopPropagation(); } function TootActionGroup<T extends mastodon.v1.Status>( props: TootActionGroupProps<T> & { value: T }, ) { const toot = () => props.value; return ( <div class={tootStyle.tootBottomActionGrp} onClick={isolatedCallback}> <Button class={tootStyle.tootActionWithCount} onClick={() => props.onReply?.(toot())} > <ReplyAll /> <span>{toot().repliesCount}</span> </Button> <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 }) { const toot = () => props.status; const dateFnLocale = useDateFnLocale(); return ( <div class={tootStyle.tootAuthorGrp}> <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, props.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} loading="lazy" /> </Show> <Title component="h1">{props.src.title}</Title> <Body1 component="p">{props.src.description}</Body1> </a> ); } const RegularToot: Component<TootCardProps> = (props) => { let rootRef: HTMLElement; const [managed, managedActionGroup, rest] = splitProps( props, ["status", "lang", "class", "actionable", "evaluated"], ["onRetoot", "onFavourite", "onBookmark", "onReply"], ); const now = useTimeSource(); const status = () => managed.status; const toot = () => status().reblog ?? status(); css` .reply-sep { margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 8px); margin-block: 8px; } `; return ( <> <section classList={{ [tootStyle.toot]: true, [tootStyle.expanded]: managed.evaluated, [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()} /> <TootContentView source={toot().content} emojis={toot().emojis} 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;