initial commit

This commit is contained in:
thislight 2024-07-14 20:28:44 +08:00
commit 4a80c8552b
No known key found for this signature in database
GPG key ID: A50F9451AC56A63E
46 changed files with 8309 additions and 0 deletions

View file

@ -0,0 +1,57 @@
import type { mastodon } from "masto";
import { type Component } from "solid-js";
import tootStyle from "./toot.module.css";
import { formatRelative } from "date-fns";
import Img from "../material/Img";
import { Body2 } from "../material/typography";
import { css } from "solid-styled";
import { appliedCustomEmoji } from "../masto/toot";
import cardStyle from "../material/cards.module.css";
type CompactTootProps = {
status: mastodon.v1.Status;
now: Date;
class?: string;
};
const CompactToot: Component<CompactTootProps> = (props) => {
const toot = () => props.status;
return (
<section
class={[tootStyle.compact, props.class || ""].join(" ")}
lang={toot().language || undefined}
>
<Img
src={toot().account.avatar}
class={[
tootStyle.tootAvatar,
].join(" ")}
/>
<div class={[tootStyle.compactAuthorGroup].join(' ')}>
<Body2
ref={(e: { innerHTML: string }) => {
appliedCustomEmoji(
e,
toot().account.displayName,
toot().account.emojis,
);
}}
></Body2>
<span class={tootStyle.compactAuthorUsername}>
@{toot().account.username}@{new URL(toot().account.url).hostname}
</span>
<time datetime={toot().createdAt}>
{formatRelative(toot().createdAt, props.now)}
</time>
</div>
<div
ref={(e: { innerHTML: string }) => {
appliedCustomEmoji(e, toot().content, toot().emojis);
}}
class={[tootStyle.compactTootContent].join(' ')}
></div>
</section>
);
};
export default CompactToot;

336
src/timelines/Home.tsx Normal file
View file

@ -0,0 +1,336 @@
import {
Component,
For,
onCleanup,
createSignal,
createEffect,
Show,
untrack,
onMount,
} from "solid-js";
import { $accounts } from "../accounts/stores";
import { useDocumentTitle } from "../utils";
import { useStore } from "@nanostores/solid";
import { useMastoClientFor } from "../masto/clients";
import { type mastodon } from "masto";
import Scaffold from "../material/Scaffold";
import {
AppBar,
Button,
Fab,
LinearProgress,
ListItemSecondaryAction,
ListItemText,
MenuItem,
Switch,
Toolbar,
Typography,
} from "@suid/material";
import { css } from "solid-styled";
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
import TootThread from "./TootThread.js";
import { useAcctProfile } from "../masto/acct";
import ProfileMenuButton from "./ProfileMenuButton";
import Tabs from "../material/Tabs";
import Tab from "../material/Tab";
import { Create as CreateTootIcon } from "@suid/icons-material";
import { useTimeline } from "../masto/timelines";
import { makeEventListener } from "@solid-primitives/event-listener";
const TimelinePanel: Component<{
client: mastodon.rest.Client;
name: "home" | "public" | "trends";
prefetch?: boolean;
}> = (props) => {
const [timeline, { refetch: refetchTimeline, mutate: mutateTimeline }] =
useTimeline(() =>
props.name !== "trends"
? props.client.v1.timelines[props.name]
: props.client.v1.trends.statuses,
);
const tlEndObserver = new IntersectionObserver(() => {
if (untrack(() => props.prefetch) && !timeline.loading)
refetchTimeline({ direction: "old" });
});
onCleanup(() => tlEndObserver.disconnect());
const onBookmark = async (
index: number,
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
const result = await (status.bookmarked
? client.v1.statuses.$select(status.id).unbookmark()
: client.v1.statuses.$select(status.id).bookmark());
mutateTimeline((o) => {
o[index] = result;
return o;
});
};
const onBoost = async (
index: number,
client: mastodon.rest.Client,
status: mastodon.v1.Status,
) => {
const reblogged = false;
mutateTimeline((o) => {
Object.assign(o[index].reblog ?? o[index], {
reblogged: !reblogged,
});
return o;
});
const result = reblogged
? await client.v1.statuses.$select(status.id).unreblog()
: (await client.v1.statuses.$select(status.id).reblog()).reblog!;
mutateTimeline((o) => {
Object.assign(o[index].reblog ?? o[index], {
reblogged: result.reblogged,
reblogsCount: result.reblogsCount,
});
return o;
});
};
return (
<>
<div>
<For each={timeline()}>
{(item, index) => {
return (
<TootThread
status={item}
onBoost={(...args) => onBoost(index(), ...args)}
onBookmark={(...args) => onBookmark(index(), ...args)}
client={props.client}
/>
);
}}
</For>
</div>
<div ref={(e) => tlEndObserver.observe(e)}></div>
<Show when={timeline.loading}>
<div class="loading-line" style={{ width: "100%" }}>
<LinearProgress />
</div>
</Show>
<Show when={timeline.error}>
<div
style={{
display: "flex",
padding: "20px 0",
"align-items": "center",
"justify-content": "center",
}}
>
<Button variant="contained" onClick={[refetchTimeline, "old"]}>
Retry
</Button>
</div>
</Show>
<Show when={!props.prefetch && !timeline.loading}>
<div
style={{
display: "flex",
padding: "20px 0",
"align-items": "center",
"justify-content": "center",
}}
>
<Button variant="contained" onClick={[refetchTimeline, "old"]}>
Load More
</Button>
</div>
</Show>
</>
);
};
const Home: Component = () => {
let panelList: HTMLDivElement;
useDocumentTitle("Timelines");
const accounts = useStore($accounts);
const now = createTimeSource();
const client = useMastoClientFor(() => accounts()[0]);
const [profile] = useAcctProfile(() => accounts()[0]);
const [panelOffset, setPanelOffset] = createSignal(0);
const [prefetching, setPrefetching] = createSignal(true);
const [currentFocusOn, setCurrentFocusOn] = createSignal<HTMLElement[]>([]);
const [focusRange, setFocusRange] = createSignal([0, 0] as readonly [
number,
number,
]);
let scrollEventLockReleased = true;
const recalculateTabIndicator = () => {
scrollEventLockReleased = false;
try {
const { x: panelX, width: panelWidth } =
panelList.getBoundingClientRect();
let minIdx = +Infinity,
maxIdx = -Infinity;
const items = panelList.querySelectorAll(".tab-panel");
const ranges = Array.from(items).map((x) => {
const rect = x.getBoundingClientRect();
const inlineStart = rect.x - panelX;
const inlineEnd = rect.width + inlineStart;
return [inlineStart, inlineEnd] as const;
});
for (let i = 0; i < items.length; i++) {
const e = items.item(i);
const [inlineStart, inlineEnd] = ranges[i];
if (inlineStart >= 0 && inlineEnd <= panelWidth) {
minIdx = Math.min(minIdx, i);
maxIdx = Math.max(maxIdx, i);
e.classList.add("active");
} else {
e.classList.remove("active");
}
}
if (isFinite(minIdx) && isFinite(maxIdx)) {
setFocusRange([minIdx, maxIdx]);
}
} finally {
scrollEventLockReleased = true;
}
};
onMount(() => {
makeEventListener(panelList, "scroll", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
makeEventListener(window, "resize", () => {
if (scrollEventLockReleased) {
requestAnimationFrame(recalculateTabIndicator);
}
});
requestAnimationFrame(recalculateTabIndicator);
});
const isTabFocus = (idx: number) => {
const [start, end] = focusRange();
if (!isFinite(start) || !isFinite(end)) return false;
return idx >= start && idx <= end;
};
const onTabClick = (idx: number) => {
const items = panelList.querySelectorAll(".tab-panel");
if (items.length > idx) {
items.item(idx).scrollIntoView({ behavior: "smooth" });
}
};
css`
.tab-panel {
overflow: visible auto;
max-width: 560px;
height: 100%;
padding: 40px 16px;
max-height: calc(100vh - var(--scaffold-topbar-height, 0px));
max-height: calc(100dvh - var(--scaffold-topbar-height, 0px));
scroll-snap-align: center;
&:not(.active) {
overflow: hidden;
}
@media (max-width: 600px) {
padding: 0;
}
}
.panel-list {
display: grid;
grid-auto-columns: 560px;
grid-auto-flow: column;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-snap-stop: always;
height: calc(100vh - var(--scaffold-topbar-height, 0px));
height: calc(100dvh - var(--scaffold-topbar-height, 0px));
@media (max-width: 600px) {
grid-auto-columns: 100%;
}
}
`;
return (
<Scaffold
topbar={
<AppBar position="static">
<Toolbar variant="dense" class="responsive">
<Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}>
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
Home
</Tab>
<Tab focus={isTabFocus(1)} onClick={[onTabClick, 1]}>
Trending
</Tab>
<Tab focus={isTabFocus(2)} onClick={[onTabClick, 2]}>
Public
</Tab>
</Tabs>
<ProfileMenuButton profile={profile()}>
<MenuItem onClick={(e) => setPrefetching((x) => !x)}>
<ListItemText>Prefetch Toots</ListItemText>
<ListItemSecondaryAction>
<Switch checked={prefetching()}></Switch>
</ListItemSecondaryAction>
</MenuItem>
</ProfileMenuButton>
</Toolbar>
</AppBar>
}
fab={
<Fab color="secondary">
<CreateTootIcon />
</Fab>
}
>
<TimeSourceProvider value={now}>
<div class="panel-list" ref={panelList!}>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="home"
prefetch={prefetching()}
/>
</div>
</div>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="trends"
prefetch={prefetching()}
/>
</div>
</div>
<div class="tab-panel">
<div>
<TimelinePanel
client={client()}
name="public"
prefetch={prefetching()}
/>
</div>
</div>
<div></div>
</div>
</TimeSourceProvider>
</Scaffold>
);
};
export default Home;

View file

@ -0,0 +1,80 @@
import type { mastodon } from "masto";
import { type Component, For, createSignal } from "solid-js";
import { css } from "solid-styled";
import tootStyle from "./toot.module.css";
import { Portal } from "solid-js/web";
import MediaViewer, { MEDIA_VIEWER_HEROSRC } from "./MediaViewer";
import { HeroSourceProvider } from "../platform/anim";
const MediaAttachmentGrid: Component<{
attachments: mastodon.v1.MediaAttachment[];
}> = (props) => {
let rootRef: HTMLElement;
const [viewerIndex, setViewerIndex] = createSignal<number>();
const viewerOpened = () => typeof viewerIndex() !== "undefined"
const gridTemplateColumns = () => {
const l = props.attachments.length;
if (l < 2) {
return "1fr";
}
if (l < 4) {
return "repeat(2, 1fr)";
}
return "repeat(3, 1fr)";
};
const openViewerFor = (index: number) => {
setViewerIndex(index);
};
css`
.attachments {
grid-template-columns: ${gridTemplateColumns()};
}
`;
return (
<section
ref={rootRef!}
class={[tootStyle.tootAttachmentGrp, "attachments"].join(" ")}
onClick={(e) => e.stopImmediatePropagation()}
>
<For each={props.attachments}>
{(item, index) => {
switch (item.type) {
case "image":
return (
<img
src={item.previewUrl}
alt={item.description || undefined}
onClick={[openViewerFor, index()]}
loading="lazy"
></img>
);
case "video":
case "gifv":
case "audio":
case "unknown":
return <div></div>;
}
}}
</For>
<HeroSourceProvider
value={() => ({
[MEDIA_VIEWER_HEROSRC]: rootRef.children.item(
viewerIndex() || 0,
) as HTMLElement,
})}
>
<MediaViewer
show={viewerOpened()}
index={viewerIndex() || 0}
onIndexUpdated={setViewerIndex}
media={props.attachments}
onClose={() => setViewerIndex(undefined)}
/>
</HeroSourceProvider>
</section>
);
};
export default MediaAttachmentGrid;

View file

@ -0,0 +1,378 @@
import type { mastodon } from "masto";
import {
For,
type Component,
type ParentComponent,
Switch,
Match,
createEffect,
createSignal,
type JSX,
onMount,
Index,
mergeProps,
requestCallback,
untrack,
} from "solid-js";
import { css } from "solid-styled";
import { useHeroSource } from "../platform/anim";
import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store";
import { IconButton, Toolbar } from "@suid/material";
import { ArrowLeft, ArrowRight, Close } from "@suid/icons-material";
type MediaViewerProps = {
show: boolean;
index: number;
media: mastodon.v1.MediaAttachment[];
onIndexUpdated?: (newIndex: number) => void;
onClose?: () => void;
};
export const MEDIA_VIEWER_HEROSRC = Symbol("mediaViewerHeroSrc");
function within(n: number, target: number, range: number) {
return n >= target - range || n <= target + range;
}
function clamp(input: number, min: number, max: number) {
return Math.min(Math.max(input, min), max)
}
const MediaViewer: ParentComponent<MediaViewerProps> = (props) => {
let rootRef: HTMLDialogElement;
const heroSource = useHeroSource();
const heroSourceEl = () => heroSource()[MEDIA_VIEWER_HEROSRC];
type State = {
ref?: HTMLElement;
media: mastodon.v1.MediaAttachment;
top: number;
left: number;
scale: number;
osize: [number, number]; // width, height
};
const [state, setState] = createStore<State[]>(
props.media.map(
(media) =>
({
top: 0,
left: 0,
ref: undefined,
media,
scale: 1,
osize: [0, 9],
}) as State,
),
);
const [showControls, setShowControls] = createSignal(true);
const [dragging, setDragging] = createSignal(false);
const hasPrev = () => state.length > 1 && props.index !== 0;
const hasNext = () => state.length > 1 && props.index < state.length - 1;
css`
.media-viewer--root {
background: none;
border: none;
overflow: hidden;
margin: 0;
padding: 0;
outline: none;
max-width: 100%;
max-height: 100%;
height: 100%;
width: 100%;
&[open] {
display: block;
}
}
.media-viewer {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
width: 100%;
height: 100%;
overflow: auto;
background-color: ${showControls()
? "var(--tutu-color-surface)"
: "var(--tutu-color-on-surface)"};
transition: background-color 0.2s var(--tutu-anim-curve-std);
scroll-behavior: smooth;
> .media {
height: 100%;
}
}
.media {
overflow: hidden;
position: relative;
> img {
position: absolute;
object-fit: contain;
top: 0;
left: 0;
transform-origin: center;
}
}
.media-ctrls {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 1;
cursor: ${dragging() ? "grabbing" : "grab"};
}
.left-dock {
position: absolute;
left: 24px;
top: 50%;
transform: translateY(-50%)
${showControls() && hasPrev()
? ""
: "translateX(-100%) translateX(-24px)"};
display: inline-block;
transition: transform 0.2s var(--tutu-anim-curve-std);
}
.right-dock {
position: absolute;
right: 24px;
top: 50%;
transform: translateY(-50%)
${showControls() && hasNext()
? ""
: "translateX(100%) translateX(24px)"};
display: inline-block;
transition: transform 0.2s var(--tutu-anim-curve-std);
}
`;
createEffect(() => {
if (props.show) {
rootRef.showModal();
untrack(() => {
for (let i = 0; i < state.length; i++) {
centre(state[i], i);
}
});
} else {
rootRef.close();
}
});
createEffect(() => {
const viewer = rootRef.children.item(0)!;
const targetPageE = viewer.children.item(props.index + 1);
if (!targetPageE) return;
targetPageE.scrollIntoView();
});
const minScaleOf = (state: State) => {
const {
ref,
osize: [width, height],
} = state;
const { width: parentWidth, height: parentHeight } =
ref!.parentElement!.getBoundingClientRect();
if (height <= parentHeight && width <= parentWidth) {
return 1;
}
return Math.min(parentHeight / height, parentWidth / width);
};
// Position medias to the centre.
// This function is only available when the elements are layout.
const centre = ({ ref, osize: [width, height] }: State, idx: number) => {
const { width: parentWidth, height: parentHeight } =
ref!.parentElement!.getBoundingClientRect();
const scale =
height <= parentHeight && width <= parentWidth
? 1
: Math.min(parentHeight / height, parentWidth / width);
const top = parentHeight / 2 - height / 2;
const left = parentWidth / 2 - width / 2;
setState(idx, { top, left, scale });
};
const scale = (
center: readonly [number, number], // left, top
move: number,
idx: number,
) => {
const { ref, top: otop, left: oleft, scale: oscale, osize: [owidth, oheight] } = state[idx];
const [cx, cy] = center;
const iy = clamp(cy - otop, 0, oheight),
ix = clamp(cx - oleft, 0, owidth); // in image coordinate system
const scale = move + oscale;
const oix = ix / oscale,
oiy = iy / oscale;
const nix = oix * scale,
niy = oiy * scale;
// Now we can calculate the center's move
const { width: vw, height: vh } =
ref!.parentElement!.getBoundingClientRect();
const top = vh / 2 - niy;
const left = vw / 2 - nix;
setState(idx, { top, left, scale });
};
const movePrev = () => {
props.onIndexUpdated?.(Math.max(props.index - 1, 0));
};
const moveNext = () => {
props.onIndexUpdated?.(Math.min(props.index + 1, state.length - 1));
};
const ctrlWheel = (event: WheelEvent) => {
if (event.ctrlKey && event.deltaY !== 0) {
event.preventDefault();
const center = [event.clientX, event.clientY] as const;
scale(center, -event.deltaY / event.clientY, props.index);
} else {
if (event.deltaX !== 0) {
event.preventDefault();
if (event.deltaX > 0) {
moveNext();
} else {
movePrev();
}
}
}
};
let lastMousedown: [number, number, number] | null = null; // time, left, top
const ctrlMouseDown = (event: MouseEvent) => {
if (event.buttons !== 1) return;
event.preventDefault();
lastMousedown = [Date.now(), event.clientX, event.clientY];
setDragging(true);
};
const ctrlMouseMove = (event: MouseEvent) => {
if (!lastMousedown) return;
event.preventDefault();
const { movementX: mleft, movementY: mtop } = event;
setState(props.index, (o) => ({ left: o.left + mleft, top: o.top + mtop }));
};
const ctrlMouseUp = (event: MouseEvent) => {
if (lastMousedown !== null) {
event.preventDefault();
const [time, left, top] = lastMousedown;
const { clientX: nleft, clientY: ntop } = event;
const now = Date.now();
const target = event.target;
checkControls: {
if (
target instanceof Element &&
!target.classList.contains("media-ctrls")
) {
// It's dispatched from sub controls, exits
break checkControls;
}
if (
now - time < 250 &&
within(left, nleft, 4) &&
within(top, ntop, 4)
) {
setShowControls((x) => !x);
}
}
lastMousedown = null;
setDragging(false);
}
};
return (
<dialog ref={rootRef!} class="media-viewer--root">
<div class="media-viewer">
<div
class="media-ctrls"
onWheel={ctrlWheel}
onMouseDown={ctrlMouseDown}
onMouseUp={ctrlMouseUp}
onMouseMove={ctrlMouseMove}
>
<Toolbar
variant="dense"
sx={{
backgroundColor: "var(--tutu-color-surface)",
transform: !showControls() ? "translateY(-100%)" : undefined,
transition:
"transform 0.2s var(--tutu-anim-curve-std), box-shadow 0.2s var(--tutu-anim-curve-std)",
boxShadow: showControls() ? "var(--tutu-shadow-e6)" : undefined,
}}
>
<IconButton onClick={props.onClose}>
<Close />
</IconButton>
</Toolbar>
<div class="left-dock">
<IconButton
size="large"
onClick={(e) => (movePrev(), e.stopPropagation())}
>
<ArrowLeft />
</IconButton>
</div>
<div class="right-dock">
<IconButton
size="large"
onClick={(e) => (moveNext(), e.stopPropagation())}
>
<ArrowRight />
</IconButton>
</div>
</div>
<Index each={state}>
{(item, index) => {
return (
<div class="media" data-index={index}>
<Switch
fallback={
<pre>{JSON.stringify(item().media, undefined, 2)}</pre>
}
>
<Match when={item().media.type === "image"}>
<img
ref={(r) => {
setState(index, { ref: r });
}}
onLoad={(e) => {
const { naturalWidth: width, naturalHeight: height } =
e.currentTarget;
setState(index, {
osize: [width, height],
});
}}
src={item().media.url || undefined}
style={{
left: `${item().left}px`,
top: `${item().top}px`,
transform: `scale(${item().scale})`,
}}
alt={item().media.description || undefined}
></img>
</Match>
</Switch>
</div>
);
}}
</Index>
</div>
</dialog>
);
};
export default MediaViewer;

View file

@ -0,0 +1,121 @@
import {
Avatar,
ButtonBase,
Divider,
ListItemAvatar,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
} from "@suid/material";
import { Show, createSignal, createUniqueId, type ParentComponent } from "solid-js";
import {
Settings as SettingsIcon,
Bookmark as BookmarkIcon,
Star as LikeIcon,
FeaturedPlayList as ListIcon,
} from "@suid/icons-material";
const ProfileMenuButton: ParentComponent<{
profile?: { displayName: string; avatar: string; username: string };
onClick?: () => void;
onClose?: () => void;
}> = (props) => {
const menuId = createUniqueId();
const buttonId = createUniqueId();
let [anchor, setAnchor] = createSignal<HTMLButtonElement | null>(null);
const open = () => !!anchor();
const onClick = (
event: MouseEvent & { currentTarget: HTMLButtonElement },
) => {
setAnchor(event.currentTarget);
props.onClick?.();
};
const onClose = () => {
props.onClick?.();
setAnchor(null);
};
return (
<>
<ButtonBase
aria-haspopup="true"
sx={{ borderRadius: "50%" }}
id={buttonId}
onClick={onClick}
aria-controls={open() ? menuId : undefined}
aria-expanded={open() ? "true" : undefined}
>
<Avatar
alt={`${props.profile?.displayName}'s avatar`}
src={props.profile?.avatar}
></Avatar>
</ButtonBase>
<Menu
id={menuId}
anchorEl={anchor()}
open={open()}
onClose={onClose}
MenuListProps={{
"aria-labelledby": buttonId,
sx: {
minWidth: "220px",
}
}}
anchorOrigin={{
vertical: "top",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem>
<ListItemAvatar>
<Avatar src={props.profile?.avatar}></Avatar>
</ListItemAvatar>
<ListItemText
primary={props.profile?.displayName}
secondary={`@${props.profile?.username}`}
></ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<BookmarkIcon />
</ListItemIcon>
<ListItemText>Bookmarks</ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<LikeIcon />
</ListItemIcon>
<ListItemText>Likes</ListItemText>
</MenuItem>
<MenuItem>
<ListItemIcon>
<ListIcon />
</ListItemIcon>
<ListItemText>Lists</ListItemText>
</MenuItem>
<Divider />
<Show when={props.children}>
{props.children}
<Divider />
</Show>
<MenuItem>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
</Menu>
</>
);
};
export default ProfileMenuButton;

View file

@ -0,0 +1,259 @@
import type { mastodon } from "masto";
import {
splitProps,
type Component,
type JSX,
Show,
createRenderEffect,
} from "solid-js";
import tootStyle from "./toot.module.css";
import { formatRelative } from "date-fns";
import Img from "../material/Img.js";
import { Body2 } from "../material/typography.js";
import { css } from "solid-styled";
import {
BookmarkAddOutlined,
Repeat,
ReplyAll,
Star,
StarOutline,
Bookmark,
Reply,
} 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";
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>
</div>
);
}
function TootAuthorGroup(props: { status: mastodon.v1.Status; now: Date }) {
const toot = () => props.status;
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)}
</time>
<span>
@{toot().account.username}@{new URL(toot().account.url).hostname}
</span>
</div>
</div>
);
}
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().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;

View file

@ -0,0 +1,8 @@
import type { Component } from "solid-js";
const TootBottomSheet: Component = (props) => {
return <></>
}
export default TootBottomSheet

View file

@ -0,0 +1,86 @@
import type { mastodon } from "masto";
import { Show, createResource, createSignal, type Component } from "solid-js";
import CompactToot from "./CompactToot";
import { useTimeSource } from "../platform/timesrc";
import RegularToot from "./RegularToot";
import cardStyle from "../material/cards.module.css";
import { css } from "solid-styled";
type TootThreadProps = {
status: mastodon.v1.Status;
client: mastodon.rest.Client;
expanded?: 0 | 1 | 2;
onBoost?(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
onBookmark?(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
};
const TootThread: Component<TootThreadProps> = (props) => {
const status = () => props.status;
const now = useTimeSource();
const [expanded, setExpanded] = createSignal(false);
const [inReplyTo] = createResource(
() => [props.client, status().inReplyToId || null] as const,
async ([client, replyToId]) => {
if (!(client && replyToId)) return null;
return await client.v1.statuses.$select(replyToId).fetch();
},
);
const boost = (status: mastodon.v1.Status) => {
props.onBoost?.(props.client, status);
};
const bookmark = (status: mastodon.v1.Status) => {
props.onBookmark?.(props.client, status);
};
css`
article {
transition: margin 90ms var(--tutu-anim-curve-sharp), var(--tutu-transition-shadow);
user-select: none;
cursor: pointer;
}
.thread-line {
position: relative;
&::before {
content: "";
position: absolute;
left: 36px;
top: 16px;
bottom: 0;
background-color: var(--tutu-color-secondary);
width: 2px;
display: block;
}
}
.expanded {
margin-block: 20px;
box-shadow: var(--tutu-shadow-e9);
}
`;
return (
<article classList={{ "thread-line": !!inReplyTo(), "expanded": expanded() }} onClick={() => setExpanded((x) => !x)}>
<Show when={inReplyTo()}>
<CompactToot
status={inReplyTo()!}
now={now()}
class={[cardStyle.card, cardStyle.manualMargin].join(" ")}
/>
</Show>
<RegularToot
status={status()}
class={cardStyle.card}
actionable={expanded()}
onBookmark={(s) => bookmark(s)}
onRetoot={(s) => boost(s)}
/>
</article>
);
};
export default TootThread;

View file

@ -0,0 +1,209 @@
.toot {
--card-pad: 16px;
--card-gut: 16px;
--toot-avatar-size: 40px;
margin-block: 0;
&.toot {
/* fix composition ordering: I think the css module processor should aware the overriding and behaves, but no */
transition: margin-block 125ms var(--tutu-anim-curve-std),
height 225ms var(--tutu-anim-curve-std),
var(--tutu-transition-shadow);
border-radius: 0;
}
&>.toot {
box-shadow: none;
}
time {
color: var(--tutu-color-secondary-text-on-surface);
}
& :global(.custom-emoji) {
height: 1em;
object-fit: contain;
}
&.expanded {
margin-block: 20px;
box-shadow: var(--tutu-shadow-e9);
}
}
.tootAuthorGrp {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
> :not(:first-child) {
flex-grow: 1;
}
}
.tootAuthorNameGrp {
display: grid;
grid-template-columns: 1fr auto;
>* {
color: var(--tutu-color-secondary-text-on-surface);
}
>:last-child {
grid-column: 1 /3;
}
> time {
text-align: end;
}
&:hover {
.tootAuthorNamePrimary {
text-decoration: underline;
}
}
}
.tootAuthorNamePrimary {
color: revert;
}
.tootAvatar {
width: calc(var(--toot-avatar-size, 40px) - 1px);
aspect-ratio: 1/1;
object-fit: contain;
border-radius: 50% 50%;
overflow: hidden;
border: 1px solid var(--tutu-color-surface);
background-color: var(--tutu-color-surface-d);
}
.tootContent {
composes: cardNoPad from '../material/cards.module.css';
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
margin-right: var(--card-pad, 0);
line-height: 1.5;
& a {
color: var(--tutu-color-primary-d);
}
& :global(a[target="_blank"]) {
> :global(.invisible) {
display: none;
}
> :global(.ellipsis) {
&::after {
display: inline;
content: "...";
}
}
}
}
.compact {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
row-gap: 0;
padding-block: var(--card-gut, 16px);
padding-inline: var(--card-pad, 16px);
> :first-child {
grid-row: 1/3;
}
> :last-child {
grid-column: 2 /3;
}
}
.compactAuthorGroup {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 8px;
> .compactAuthorUsername {
color: var(--tutu-color-secondary-text-on-surface);
flex-grow: 1;
}
> time {
color: var(--tutu-color-secondary-text-on-surface);
}
}
.compactTootContent {
composes: tootContent;
margin-left: 0;
margin-right: 0;
}
.tootRetootGrp {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 8px;
margin-bottom: 8px;
}
.tootAttachmentGrp {
composes: cardNoPad from '../material/cards.module.css';
margin-top: 1em;
margin-left: calc(var(--card-pad, 0) + var(--toot-avatar-size, 0) + 8px);
margin-right: var(--card-pad, 0);
display: grid;
gap: 4px;
>:where(img) {
max-height: 35vh;
min-height: 40px;
object-fit: none;
width: 100%;
background-color: var(--tutu-color-surface-d);
border-radius: 2px;
}
}
.tootBottomActionGrp {
composes: cardGutSkip from '../material/cards.module.css';
padding-block: calc((var(--card-gut) - 10px) / 2);
animation: 225ms var(--tutu-anim-curve-std) tootBottomExpanding;
display: flex;
flex-flow: row wrap;
justify-content: space-evenly;
> button{
color: var(--tutu-color-on-surface);
padding: 10px 8px;
> svg {
font-size: 20px;
}
}
}
.tootActionWithCount {
display: flex;
align-items: center;
gap: 8px;
}
.tootAction {
display: flex;
align-items: center;
justify-content: center;
}
@keyframes tootBottomExpanding {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}