Compare commits

..

5 commits

Author SHA1 Message Date
thislight
4075c41942
vite-plugin-pwa: enable production sw for staging
All checks were successful
/ depoly (push) Successful in 1m21s
2024-10-24 23:50:57 +08:00
thislight
b22ac67c3a
Profile: filter recent toots
- material: new Menu component
- material/theme: animation curves
- TootFilterButton
2024-10-24 23:49:37 +08:00
thislight
dff6abd379
material: rename mui to theme 2024-10-24 22:21:37 +08:00
thislight
3bc25786ca
masto/timelines: now the params is general typed 2024-10-24 22:20:38 +08:00
thislight
8d9e4b1c48
vite-plugin-pwa: enable dev sw for not production 2024-10-24 16:00:19 +08:00
9 changed files with 315 additions and 45 deletions

View file

@ -10,7 +10,7 @@ import {
lazy,
onCleanup,
} from "solid-js";
import { useRootTheme } from "./material/mui.js";
import { useRootTheme } from "./material/theme.js";
import {
Provider as ClientProvider,
createMastoClientFor,
@ -149,7 +149,7 @@ const App: Component = () => {
return <UnexpectedError error={err} />;
}}
>
<ThemeProvider theme={theme()}>
<ThemeProvider theme={theme}>
<DateFnScope>
<ClientProvider value={clients}>
<ServiceWorkerProvider

View file

@ -11,23 +11,15 @@ import {
} from "solid-js";
import { createStore } from "solid-js/store";
type Timeline = {
list(params: {
/** Return results older than this ID. */
readonly maxId?: string;
/** Return results newer than this ID. */
readonly sinceId?: string;
/** Get a list of items with ID greater than this value excluding this ID */
readonly minId?: string;
/** Maximum number of results to return per page. Defaults to 40. NOTE: Pagination is done with the Link header from the response. */
readonly limit?: number;
}): mastodon.Paginator<mastodon.v1.Status[], unknown>;
type Timeline<T extends mastodon.DefaultPaginationParams> = {
list(params?: T): mastodon.Paginator<mastodon.v1.Status[], unknown>;
};
export function createTimelineSnapshot(
timeline: Accessor<Timeline>,
limit: Accessor<number>,
) {
type TimelineParamsOf<T> = T extends Timeline<infer P> ? P : never;
export function createTimelineSnapshot<
T extends Timeline<mastodon.DefaultPaginationParams>,
>(timeline: Accessor<T>, limit: Accessor<number>) {
const [shot, { refetch }] = createResource(
() => [timeline(), limit()] as const,
async ([tl, limit]) => {
@ -80,13 +72,13 @@ export function createTimelineSnapshot(
export type TimelineFetchDirection = mastodon.Direction;
export type TimelineChunk = {
tl: Timeline;
export type TimelineChunk<T extends mastodon.DefaultPaginationParams> = {
tl: Timeline<T>;
rebuilt: boolean;
chunk: readonly mastodon.v1.Status[];
done?: boolean;
direction: TimelineFetchDirection;
limit: number;
params: T;
};
type TreeNode<T> = {
@ -108,21 +100,21 @@ function collectPath<T>(node: TreeNode<T>) {
return path;
}
function createTimelineChunk(
timeline: Accessor<Timeline>,
limit: Accessor<number>,
function createTimelineChunk<T extends Timeline<mastodon.DefaultPaginationParams>>(
timeline: Accessor<T>,
params: Accessor<TimelineParamsOf<T>>,
) {
let vpMaxId: string | undefined, vpMinId: string | undefined;
const fetchExtendingPage = async (
tl: Timeline,
tl: T,
direction: TimelineFetchDirection,
limit: number,
params: TimelineParamsOf<T>,
) => {
switch (direction) {
case "next": {
const page = await tl
.list({ limit, sinceId: vpMaxId })
.list({ ...params, sinceId: vpMaxId })
.setDirection(direction)
.next();
if ((page.value?.length ?? 0) > 0) {
@ -133,7 +125,7 @@ function createTimelineChunk(
case "prev": {
const page = await tl
.list({ limit, maxId: vpMinId })
.list({ ...params, maxId: vpMinId })
.setDirection(direction)
.next();
if ((page.value?.length ?? 0) > 0) {
@ -145,11 +137,11 @@ function createTimelineChunk(
};
return createResource(
() => [timeline(), limit()] as const,
() => [timeline(), params()] as const,
async (
[tl, limit],
[tl, params],
info: ResourceFetcherInfo<
Readonly<TimelineChunk>,
Readonly<TimelineChunk<TimelineParamsOf<T>>>,
TimelineFetchDirection
>,
) => {
@ -160,27 +152,26 @@ function createTimelineChunk(
vpMaxId = undefined;
vpMinId = undefined;
}
const posts = await fetchExtendingPage(tl, direction, limit);
const posts = await fetchExtendingPage(tl, direction, params);
return {
tl,
rebuilt: rebuildTimeline,
chunk: posts.value ?? [],
done: posts.done,
direction,
limit,
params,
};
},
);
}
export function createTimeline(
timeline: Accessor<Timeline>,
limit: Accessor<number>,
) {
export function createTimeline<
T extends Timeline<mastodon.DefaultPaginationParams>,
>(timeline: Accessor<T>, params: Accessor<TimelineParamsOf<T>>) {
const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>();
const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
const [chunk, { refetch }] = createTimelineChunk(timeline, limit);
const [chunk, { refetch }] = createTimelineChunk(timeline, params);
createEffect(() => {
const chk = catchError(chunk, (e) => console.error(e));

136
src/material/Menu.tsx Normal file
View file

@ -0,0 +1,136 @@
import { useWindowSize } from "@solid-primitives/resize-observer";
import { MenuList } from "@suid/material";
import {
createEffect,
createSignal,
type JSX,
type ParentComponent,
} from "solid-js";
import { ANIM_CURVE_STD } from "./theme";
type Props = {
open?: boolean;
onClose?: JSX.EventHandlerUnion<HTMLDialogElement, Event>;
anchor: () => DOMRect;
};
function adjustMenuPosition(
rect: DOMRect,
[left, top]: [number, number],
{ width, height }: { width: number; height: number },
) {
const ntop = rect.bottom > height ? top - (rect.bottom - height) : top;
const nleft = rect.right > width ? left - (rect.right - width) : left;
return [nleft, ntop] as [number, number];
}
const Menu: ParentComponent<Props> = (props) => {
let root: HTMLDialogElement;
const [pos, setPos] = createSignal<[number, number]>([0, 0]);
const windowSize = useWindowSize();
createEffect(() => {
if (props.open) {
const a = props.anchor();
if (!root.open) {
root.showModal();
const rend = root.getBoundingClientRect();
setPos(adjustMenuPosition(rend, [a.left, a.top], windowSize));
const overflow = root.style.overflow;
root.style.overflow = "hidden";
const duration = (rend.height / 1600) * 1000;
const easing = ANIM_CURVE_STD;
const animation = root.animate(
{
height: [`${rend.height / 2}px`, `${rend.height}px`],
width: [`${rend.width / 4 * 3}px`, `${rend.width}px`],
},
{
duration,
easing,
},
);
animation.addEventListener(
"finish",
() => (root.style.overflow = overflow),
);
} else {
setPos(
adjustMenuPosition(
root.getBoundingClientRect(),
[a.left, a.top],
windowSize,
),
);
}
} else {
animateClose();
}
});
const animateClose = () => {
const rend = root.getBoundingClientRect();
const overflow = root.style.overflow;
root.style.overflow = "hidden";
const animation = root.animate(
{
height: [`${rend.height}px`, `${rend.height / 2}px`],
width: [`${rend.width}px`, `${rend.width / 4 * 3}px`],
},
{
duration: (rend.height / 2 / 1600) * 1000,
easing: ANIM_CURVE_STD,
},
);
animation.addEventListener("finish", () => {
root.style.overflow = overflow;
root.close();
});
};
return (
<dialog
ref={root!}
onClose={props.onClose}
onClick={(e) => {
if (e.target === root) {
if (props.onClose) {
if (Array.isArray(props.onClose)) {
props.onClose[0](props.onClose[1], e);
} else {
(
props.onClose as (
event: Event & { currentTarget: HTMLDialogElement },
) => void
)(e);
}
}
e.stopPropagation();
}
}}
style={{
position: "absolute",
left: `${pos()[0]}px`,
top: `${pos()[1]}px`,
border: "none",
padding: 0,
"max-width": "560px",
width: "max-content",
/*"min-width": "20vw", */
"box-shadow": "var(--tutu-shadow-e8)",
}}
>
<div
style={{
background: "var(--tutu-color-surface)",
}}
>
<MenuList>{props.children}</MenuList>
</div>
</dialog>
);
};
export default Menu;

View file

@ -2,6 +2,9 @@ import { Theme, createTheme } from "@suid/material/styles";
import { deepPurple, amber } from "@suid/material/colors";
import { Accessor } from "solid-js";
/**
* The MUI theme.
*/
export function useRootTheme(): Accessor<Theme> {
return () =>
createTheme({
@ -15,3 +18,8 @@ export function useRootTheme(): Accessor<Theme> {
},
});
}
export const ANIM_CURVE_STD = "cubic-bezier(0.4, 0, 0.2, 1)";
export const ANIM_CURVE_DECELERATION = "cubic-bezier(0, 0, 0.2, 1)";
export const ANIM_CURVE_ACELERATION = "cubic-bezier(0.4, 0, 1, 1)";
export const ANIM_CURVE_SHARP = "cubic-bezier(0.4, 0, 0.6, 1)";

View file

@ -16,7 +16,7 @@ import {
import { Close as CloseIcon, ContentCopy } from "@suid/icons-material";
import { Title } from "../material/typography";
import { render } from "solid-js/web";
import { useRootTheme } from "../material/mui";
import { useRootTheme } from "../material/theme";
const ShareBottomSheet: Component<{
data?: ShareData;

View file

@ -19,8 +19,8 @@ import { useWindowSize } from "@solid-primitives/resize-observer";
import { css } from "solid-styled";
import { createTimeline } from "../masto/timelines";
import TootList from "../timelines/TootList";
import { createIntersectionObserver } from "@solid-primitives/intersection-observer";
import { createTimeSource, TimeSourceProvider } from "../platform/timesrc";
import TootFilterButton from "./TootFilterButton";
const Profile: Component = () => {
const navigate = useNavigate();
@ -48,7 +48,6 @@ const Profile: Component = () => {
threshold: 0.1,
},
);
onCleanup(() => obx.disconnect());
const [profile] = createResource(
@ -58,16 +57,25 @@ const Profile: Component = () => {
},
);
const [recentTootFilter, setRecentTootFilter] = createSignal({
boost: true,
reply: true,
original: true,
});
const [recentToots] = createTimeline(
() => session().client.v1.accounts.$select(params.id).statuses,
() => 20,
() => {
const { boost, reply } = recentTootFilter();
return { limit: 20, excludeReblogs: !boost, excludeReplies: !reply };
},
);
const bannerImg = () => profile()?.header;
const avatarImg = () => profile()?.avatar;
const displayName = () =>
resolveCustomEmoji(profile()?.displayName || "", profile()?.emojis ?? []);
const fullUsername = () => `@${profile()?.acct ?? "..."}`; // TODO: full user name
const fullUsername = () => `@${profile()?.acct ?? ""}`; // TODO: full user name
const description = () => profile()?.note;
css`
@ -126,7 +134,9 @@ const Profile: Component = () => {
variant="dense"
sx={{
display: "flex",
color: scrolledPastBanner() ? undefined : bannerSampledColors()?.text,
color: scrolledPastBanner()
? undefined
: bannerSampledColors()?.text,
paddingTop: "var(--safe-area-inset-top)",
}}
>
@ -238,6 +248,19 @@ const Profile: Component = () => {
</table>
</div>
<div>
<TootFilterButton
options={{
boost: "Boosteds",
reply: "Replies",
original: "Originals",
}}
applied={recentTootFilter()}
onApply={setRecentTootFilter}
disabledKeys={["original"]}
></TootFilterButton>
</div>
<TimeSourceProvider value={time}>
<TootList
threads={recentToots.list}

View file

@ -0,0 +1,112 @@
import {
Button,
MenuItem,
Checkbox,
ListItemText,
} from "@suid/material";
import {
createMemo,
createSignal,
createUniqueId,
For,
} from "solid-js";
import Menu from "../material/Menu";
import { FilterList, FilterListOff } from "@suid/icons-material";
type Props<Filters extends Record<string, string>> = {
options: Filters;
applied: Record<keyof Filters, boolean | undefined>;
disabledKeys?: (keyof Filters)[];
onApply(value: Record<keyof Filters, boolean | undefined>): void;
};
function TootFilterButton<F extends Record<string, string>>(props: Props<F>) {
const buttonId = createUniqueId();
const [open, setOpen] = createSignal(false);
const getTextForMultipleEntities = (texts: string[]) => {
switch (texts.length) {
case 0:
return "Nothing";
case 1:
return texts[0];
case 2:
return `${texts[0]} and ${texts[1]}`;
case 3:
return `${texts[0]}, ${texts[1]} and ${texts[2]}`;
default:
return `${texts[0]} and ${texts.length - 1} other${texts.length > 2 ? "s" : ""}`;
}
};
const optionKeys = () => Object.keys(props.options);
const appliedKeys = createMemo(() => {
const applied = props.applied;
return optionKeys().filter((k) => applied[k]);
});
const text = () => {
const keys = optionKeys();
const napplied = appliedKeys().length;
switch (napplied) {
case keys.length:
return "All";
default:
return getTextForMultipleEntities(
appliedKeys().map((k) => props.options[k]),
);
}
};
const toggleKey = (key: keyof F) => {
props.onApply(
Object.assign({}, props.applied, {
[key]: !props.applied[key],
}),
);
};
return (
<>
<Button size="large" onClick={[setOpen, true]} id={buttonId}>
{appliedKeys().length === optionKeys().length ? (
<FilterListOff />
) : (
<FilterList />
)}
<span style={{ "margin-left": "0.5em" }}>{text()}</span>
</Button>
<Menu
open={open()}
onClose={[setOpen, false]}
anchor={() =>
document.getElementById(buttonId)!.getBoundingClientRect()
}
>
<For each={Object.keys(props.options)}>
{(item, idx) => (
<>
<MenuItem
data-sort={idx()}
onClick={[toggleKey, item]}
disabled={props.disabledKeys?.includes(item)}
>
<ListItemText>{props.options[item]}</ListItemText>
<Checkbox
checked={props.applied[item]}
sx={{ marginRight: "-8px" }}
disabled={props.disabledKeys?.includes(item)}
></Checkbox>
</MenuItem>
</>
)}
</For>
</Menu>
</>
);
}
export default TootFilterButton;

View file

@ -32,7 +32,7 @@ const TimelinePanel: Component<{
const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline(
() => props.client.v1.timelines[props.name],
() => 20,
() => ({limit: 20}),
);
const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
const [typing, setTyping] = createSignal(false);

View file

@ -64,7 +64,7 @@ export default defineConfig(({ mode }) => ({
strategies: "injectManifest",
registerType: "autoUpdate",
devOptions: {
enabled: mode === "staging" || mode === "dev",
enabled: !["production", "staging"].includes(mode),
},
srcDir: "src/serviceworker",
filename: "main.ts",