initial commit
This commit is contained in:
commit
4a80c8552b
46 changed files with 8309 additions and 0 deletions
57
src/timelines/CompactToot.tsx
Normal file
57
src/timelines/CompactToot.tsx
Normal 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
336
src/timelines/Home.tsx
Normal 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;
|
80
src/timelines/MediaAttachmentGrid.tsx
Normal file
80
src/timelines/MediaAttachmentGrid.tsx
Normal 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;
|
378
src/timelines/MediaViewer.tsx
Normal file
378
src/timelines/MediaViewer.tsx
Normal 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;
|
121
src/timelines/ProfileMenuButton.tsx
Normal file
121
src/timelines/ProfileMenuButton.tsx
Normal 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;
|
259
src/timelines/RegularToot.tsx
Normal file
259
src/timelines/RegularToot.tsx
Normal 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;
|
8
src/timelines/TootBottomSheet.tsx
Normal file
8
src/timelines/TootBottomSheet.tsx
Normal file
|
@ -0,0 +1,8 @@
|
|||
import type { Component } from "solid-js";
|
||||
|
||||
|
||||
const TootBottomSheet: Component = (props) => {
|
||||
return <></>
|
||||
}
|
||||
|
||||
export default TootBottomSheet
|
86
src/timelines/TootThread.tsx
Normal file
86
src/timelines/TootThread.tsx
Normal 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;
|
209
src/timelines/toot.module.css
Normal file
209
src/timelines/toot.module.css
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue