This commit is contained in:
		
						commit
						21afb718f7
					
				
					 12 changed files with 852 additions and 355 deletions
				
			
		
							
								
								
									
										
											BIN
										
									
								
								bun.lockb
									
										
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								bun.lockb
									
										
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| 
						 | 
					@ -33,6 +33,7 @@
 | 
				
			||||||
    "@solid-primitives/event-listener": "^2.3.3",
 | 
					    "@solid-primitives/event-listener": "^2.3.3",
 | 
				
			||||||
    "@solid-primitives/i18n": "^2.1.1",
 | 
					    "@solid-primitives/i18n": "^2.1.1",
 | 
				
			||||||
    "@solid-primitives/intersection-observer": "^2.1.6",
 | 
					    "@solid-primitives/intersection-observer": "^2.1.6",
 | 
				
			||||||
 | 
					    "@solid-primitives/map": "^0.4.13",
 | 
				
			||||||
    "@solid-primitives/resize-observer": "^2.0.26",
 | 
					    "@solid-primitives/resize-observer": "^2.0.26",
 | 
				
			||||||
    "@solidjs/router": "^0.14.5",
 | 
					    "@solidjs/router": "^0.14.5",
 | 
				
			||||||
    "@suid/icons-material": "^0.8.0",
 | 
					    "@suid/icons-material": "^0.8.0",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,40 +1,59 @@
 | 
				
			||||||
import { Button } from '@suid/material';
 | 
					import { Button } from "@suid/material";
 | 
				
			||||||
import {Component, createResource} from 'solid-js'
 | 
					import { Component, createResource } from "solid-js";
 | 
				
			||||||
import { css } from 'solid-styled';
 | 
					import { css } from "solid-styled";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const UnexpectedError: Component<{error?: any}> = (props) => {
 | 
					const UnexpectedError: Component<{ error?: any }> = (props) => {
 | 
				
			||||||
 | 
					  const [errorMsg] = createResource(
 | 
				
			||||||
  const [errorMsg] = createResource(() => props.error, async (err) => {
 | 
					    () => props.error,
 | 
				
			||||||
 | 
					    async (err) => {
 | 
				
			||||||
      if (err instanceof Error) {
 | 
					      if (err instanceof Error) {
 | 
				
			||||||
      const mod = await import('stacktrace-js')
 | 
					        const mod = await import("stacktrace-js");
 | 
				
			||||||
      const stacktrace = await mod.fromError(err)
 | 
					        try {
 | 
				
			||||||
      const strackMsg = stacktrace.map(entry => `${entry.functionName ?? "<unknown>"}@${entry.fileName}:(${entry.lineNumber}:${entry.columnNumber})`).join('\n')
 | 
					          const stacktrace = await mod.fromError(err);
 | 
				
			||||||
      return `${err.name}: ${err.message}\n${strackMsg}`
 | 
					          const strackMsg = stacktrace
 | 
				
			||||||
 | 
					            .map(
 | 
				
			||||||
 | 
					              (entry) =>
 | 
				
			||||||
 | 
					                `${entry.functionName ?? "<unknown>"}@${entry.fileName}:(${entry.lineNumber}:${entry.columnNumber})`,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            .join("\n");
 | 
				
			||||||
 | 
					          return `${err.name}: ${err.message}\n${strackMsg}`;
 | 
				
			||||||
 | 
					        } catch (reason) {
 | 
				
			||||||
 | 
					          return `<failed to build the stacktrace of "${err}"...>\n${reason}`;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return err.toString()
 | 
					      return err.toString();
 | 
				
			||||||
  })
 | 
					    },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  css`
 | 
					  css`
 | 
				
			||||||
    main {
 | 
					    main {
 | 
				
			||||||
    padding: calc(var(--safe-area-inset-top) + 20px) calc(var(--safe-area-inset-right) + 20px) calc(var(--safe-area-inset-bottom) + 20px) calc(var(--safe-area-inset-left) + 20px);
 | 
					      padding: calc(var(--safe-area-inset-top) + 20px)
 | 
				
			||||||
 | 
					        calc(var(--safe-area-inset-right) + 20px)
 | 
				
			||||||
 | 
					        calc(var(--safe-area-inset-bottom) + 20px)
 | 
				
			||||||
 | 
					        calc(var(--safe-area-inset-left) + 20px);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  `
 | 
					  `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return <main>
 | 
					  return (
 | 
				
			||||||
 | 
					    <main>
 | 
				
			||||||
      <h1>Oh, it is our fault.</h1>
 | 
					      <h1>Oh, it is our fault.</h1>
 | 
				
			||||||
      <p>There is an unexpected error in our app, and it's not your fault.</p>
 | 
					      <p>There is an unexpected error in our app, and it's not your fault.</p>
 | 
				
			||||||
    <p>You can reload to see if this guy is gone. If you meet this guy repeatly, please report to us.</p>
 | 
					      <p>
 | 
				
			||||||
 | 
					        You can reload to see if this guy is gone. If you meet this guy
 | 
				
			||||||
 | 
					        repeatly, please report to us.
 | 
				
			||||||
 | 
					      </p>
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <Button onClick={() => window.location.reload()}>Reload</Button>
 | 
					        <Button onClick={() => window.location.reload()}>Reload</Button>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <details>
 | 
					      <details>
 | 
				
			||||||
      <summary>{errorMsg.loading ? 'Generating ' : " "}Technical Infomation (Bring to us if you report the problem)</summary>
 | 
					        <summary>
 | 
				
			||||||
      <pre>
 | 
					          {errorMsg.loading ? "Generating " : " "}Technical Infomation
 | 
				
			||||||
        {errorMsg()}
 | 
					        </summary>
 | 
				
			||||||
      </pre>
 | 
					        <pre>{errorMsg()}</pre>
 | 
				
			||||||
      </details>
 | 
					      </details>
 | 
				
			||||||
    </main>
 | 
					    </main>
 | 
				
			||||||
}
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default UnexpectedError;
 | 
					export default UnexpectedError;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,111 +1,258 @@
 | 
				
			||||||
 | 
					import { ReactiveMap } from "@solid-primitives/map";
 | 
				
			||||||
import { type mastodon } from "masto";
 | 
					import { type mastodon } from "masto";
 | 
				
			||||||
import { Accessor, createEffect, createResource } from "solid-js";
 | 
					import {
 | 
				
			||||||
 | 
					  Accessor,
 | 
				
			||||||
 | 
					  batch,
 | 
				
			||||||
 | 
					  catchError,
 | 
				
			||||||
 | 
					  createEffect,
 | 
				
			||||||
 | 
					  createResource,
 | 
				
			||||||
 | 
					  untrack,
 | 
				
			||||||
 | 
					  type ResourceFetcherInfo,
 | 
				
			||||||
 | 
					} from "solid-js";
 | 
				
			||||||
import { createStore } from "solid-js/store";
 | 
					import { createStore } from "solid-js/store";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type TimelineFetchTips = {
 | 
					 | 
				
			||||||
  direction?: "new" | "old";
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
type Timeline = {
 | 
					type Timeline = {
 | 
				
			||||||
  list(params: {
 | 
					  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;
 | 
					    readonly limit?: number;
 | 
				
			||||||
  }): mastodon.Paginator<mastodon.v1.Status[], unknown>;
 | 
					  }): mastodon.Paginator<mastodon.v1.Status[], unknown>;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function useTimeline(
 | 
					export function createTimelineSnapshot(
 | 
				
			||||||
  timeline: Accessor<Timeline>,
 | 
					  timeline: Accessor<Timeline>,
 | 
				
			||||||
  cfg?: {
 | 
					  limit: Accessor<number>,
 | 
				
			||||||
    /**
 | 
					 | 
				
			||||||
     * Use full refresh mode. This mode ignores paging, it will refetch the specified number
 | 
					 | 
				
			||||||
     * of toots at every refetch().
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    fullRefresh?: number;
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
) {
 | 
					) {
 | 
				
			||||||
  let otl: Timeline | undefined;
 | 
					  const [shot, { refetch }] = createResource(
 | 
				
			||||||
  let npager: mastodon.Paginator<mastodon.v1.Status[], unknown> | undefined;
 | 
					    () => [timeline(), limit()] as const,
 | 
				
			||||||
  let opager: mastodon.Paginator<mastodon.v1.Status[], unknown> | undefined;
 | 
					    async ([tl, limit]) => {
 | 
				
			||||||
  const [snapshot, { refetch }] = createResource<
 | 
					      const ls = await tl.list({ limit }).next();
 | 
				
			||||||
    {
 | 
					      return ls.value?.map((x) => [x]) ?? [];
 | 
				
			||||||
      records: mastodon.v1.Status[];
 | 
					 | 
				
			||||||
      direction: "new" | "old" | "items";
 | 
					 | 
				
			||||||
      tlChanged: boolean;
 | 
					 | 
				
			||||||
    },
 | 
					 | 
				
			||||||
    [Timeline],
 | 
					 | 
				
			||||||
    TimelineFetchTips | undefined
 | 
					 | 
				
			||||||
  >(
 | 
					 | 
				
			||||||
    () => [timeline()] as const,
 | 
					 | 
				
			||||||
    async ([tl], info) => {
 | 
					 | 
				
			||||||
      let tlChanged = false;
 | 
					 | 
				
			||||||
      if (otl !== tl) {
 | 
					 | 
				
			||||||
        npager = opager = undefined;
 | 
					 | 
				
			||||||
        otl = tl;
 | 
					 | 
				
			||||||
        tlChanged = true;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      const fullRefresh = cfg?.fullRefresh;
 | 
					 | 
				
			||||||
      if (typeof fullRefresh !== "undefined") {
 | 
					 | 
				
			||||||
        const records = await tl
 | 
					 | 
				
			||||||
          .list({
 | 
					 | 
				
			||||||
            limit: fullRefresh,
 | 
					 | 
				
			||||||
          })
 | 
					 | 
				
			||||||
          .next();
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
          direction: "items",
 | 
					 | 
				
			||||||
          records: records.value ?? [],
 | 
					 | 
				
			||||||
          end: records.done,
 | 
					 | 
				
			||||||
          tlChanged,
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      const direction =
 | 
					 | 
				
			||||||
        typeof info.refetching !== "boolean"
 | 
					 | 
				
			||||||
          ? (info.refetching?.direction ?? "old")
 | 
					 | 
				
			||||||
          : "old";
 | 
					 | 
				
			||||||
      if (direction === "old") {
 | 
					 | 
				
			||||||
        if (!opager) {
 | 
					 | 
				
			||||||
          opager = tl.list({}).setDirection("next");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        const next = await opager.next();
 | 
					 | 
				
			||||||
        return {
 | 
					 | 
				
			||||||
          direction,
 | 
					 | 
				
			||||||
          records: next.value ?? [],
 | 
					 | 
				
			||||||
          end: next.done,
 | 
					 | 
				
			||||||
          tlChanged,
 | 
					 | 
				
			||||||
        };
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        if (!npager) {
 | 
					 | 
				
			||||||
          npager = tl.list({}).setDirection("prev");
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        const next = await npager.next();
 | 
					 | 
				
			||||||
        const page = next.value ?? [];
 | 
					 | 
				
			||||||
        return { direction, records: page, end: next.done, tlChanged };
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [store, setStore] = createStore([] as mastodon.v1.Status[]);
 | 
					  const [snapshot, setSnapshot] = createStore([] as mastodon.v1.Status[][]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  createEffect(() => {
 | 
					  createEffect(() => {
 | 
				
			||||||
    const shot = snapshot();
 | 
					    const nls = catchError(shot, (e) => console.error(e));
 | 
				
			||||||
    if (!shot) return;
 | 
					    if (!nls) return;
 | 
				
			||||||
    const { direction, records, tlChanged } = shot;
 | 
					    const ols = Array.from(snapshot);
 | 
				
			||||||
    if (tlChanged) {
 | 
					    // The algorithm below assumes the snapshot is not changing
 | 
				
			||||||
      setStore(() => []);
 | 
					    for (let i = 0; i < nls.length; i++) {
 | 
				
			||||||
 | 
					      if (i >= ols.length) {
 | 
				
			||||||
 | 
					        setSnapshot(i, nls[i]);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        if (nls[i].length !== ols[i].length) {
 | 
				
			||||||
 | 
					          setSnapshot(i, nls[i]);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          const oth = ols[i],
 | 
				
			||||||
 | 
					            nth = nls[i];
 | 
				
			||||||
 | 
					          for (let j = 0; j < oth.length; j++) {
 | 
				
			||||||
 | 
					            const ost = oth[j],
 | 
				
			||||||
 | 
					              nst = nth[j];
 | 
				
			||||||
 | 
					            for (const key of Object.keys(
 | 
				
			||||||
 | 
					              nst,
 | 
				
			||||||
 | 
					            ) as unknown as (keyof mastodon.v1.Status)[]) {
 | 
				
			||||||
 | 
					              if (ost[key] !== nst[key]) {
 | 
				
			||||||
 | 
					                setSnapshot(i, j, key, nst[key]);
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    if (direction === "new") {
 | 
					 | 
				
			||||||
      setStore((x) => [...records, ...x]);
 | 
					 | 
				
			||||||
    } else if (direction === "old") {
 | 
					 | 
				
			||||||
      setStore((x) => [...x, ...records]);
 | 
					 | 
				
			||||||
    } else if (direction === "items") {
 | 
					 | 
				
			||||||
      setStore(() => records);
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return [
 | 
					  return [
 | 
				
			||||||
    store,
 | 
					 | 
				
			||||||
    snapshot,
 | 
					    snapshot,
 | 
				
			||||||
 | 
					    shot,
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      refetch,
 | 
					      refetch,
 | 
				
			||||||
      mutate: setStore,
 | 
					      mutate: setSnapshot,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  ] as const;
 | 
					  ] as const;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type TimelineFetchDirection = mastodon.Direction;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type TimelineChunk = {
 | 
				
			||||||
 | 
					  tl: Timeline;
 | 
				
			||||||
 | 
					  rebuilt: boolean;
 | 
				
			||||||
 | 
					  chunk: readonly mastodon.v1.Status[];
 | 
				
			||||||
 | 
					  done?: boolean;
 | 
				
			||||||
 | 
					  direction: TimelineFetchDirection;
 | 
				
			||||||
 | 
					  limit: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type TreeNode<T> = {
 | 
				
			||||||
 | 
					  parent?: TreeNode<T>;
 | 
				
			||||||
 | 
					  value: T;
 | 
				
			||||||
 | 
					  children?: TreeNode<T>[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/** Collect the path of a node for the root.
 | 
				
			||||||
 | 
					 * The first element is the node itself, the last element is the root.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					function collectPath<T>(node: TreeNode<T>) {
 | 
				
			||||||
 | 
					  const path = [node] as TreeNode<T>[];
 | 
				
			||||||
 | 
					  let current = node;
 | 
				
			||||||
 | 
					  while (current.parent) {
 | 
				
			||||||
 | 
					    path.push(current.parent);
 | 
				
			||||||
 | 
					    current = current.parent;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return path;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function createTimelineChunk(
 | 
				
			||||||
 | 
					  timeline: Accessor<Timeline>,
 | 
				
			||||||
 | 
					  limit: Accessor<number>,
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  let vpMaxId: string | undefined, vpMinId: string | undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fetchExtendingPage = async (
 | 
				
			||||||
 | 
					    tl: Timeline,
 | 
				
			||||||
 | 
					    direction: TimelineFetchDirection,
 | 
				
			||||||
 | 
					    limit: number,
 | 
				
			||||||
 | 
					  ) => {
 | 
				
			||||||
 | 
					    switch (direction) {
 | 
				
			||||||
 | 
					      case "next": {
 | 
				
			||||||
 | 
					        const page = await tl
 | 
				
			||||||
 | 
					          .list({ limit, sinceId: vpMaxId })
 | 
				
			||||||
 | 
					          .setDirection(direction)
 | 
				
			||||||
 | 
					          .next();
 | 
				
			||||||
 | 
					        if ((page.value?.length ?? 0) > 0) {
 | 
				
			||||||
 | 
					          vpMaxId = page.value![0].id;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return page;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case "prev": {
 | 
				
			||||||
 | 
					        const page = await tl
 | 
				
			||||||
 | 
					          .list({ limit, maxId: vpMinId })
 | 
				
			||||||
 | 
					          .setDirection(direction)
 | 
				
			||||||
 | 
					          .next();
 | 
				
			||||||
 | 
					        if ((page.value?.length ?? 0) > 0) {
 | 
				
			||||||
 | 
					          vpMinId = page.value![page.value!.length - 1].id;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return page;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return createResource(
 | 
				
			||||||
 | 
					    () => [timeline(), limit()] as const,
 | 
				
			||||||
 | 
					    async (
 | 
				
			||||||
 | 
					      [tl, limit],
 | 
				
			||||||
 | 
					      info: ResourceFetcherInfo<
 | 
				
			||||||
 | 
					        Readonly<TimelineChunk>,
 | 
				
			||||||
 | 
					        TimelineFetchDirection
 | 
				
			||||||
 | 
					      >,
 | 
				
			||||||
 | 
					    ) => {
 | 
				
			||||||
 | 
					      const direction =
 | 
				
			||||||
 | 
					        typeof info.refetching === "boolean" ? "prev" : info.refetching;
 | 
				
			||||||
 | 
					      const rebuildTimeline = tl !== info.value?.tl;
 | 
				
			||||||
 | 
					      if (rebuildTimeline) {
 | 
				
			||||||
 | 
					        vpMaxId = undefined;
 | 
				
			||||||
 | 
					        vpMinId = undefined;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const posts = await fetchExtendingPage(tl, direction, limit);
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        tl,
 | 
				
			||||||
 | 
					        rebuilt: rebuildTimeline,
 | 
				
			||||||
 | 
					        chunk: posts.value ?? [],
 | 
				
			||||||
 | 
					        done: posts.done,
 | 
				
			||||||
 | 
					        direction,
 | 
				
			||||||
 | 
					        limit,
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function createTimeline(
 | 
				
			||||||
 | 
					  timeline: Accessor<Timeline>,
 | 
				
			||||||
 | 
					  limit: Accessor<number>,
 | 
				
			||||||
 | 
					) {
 | 
				
			||||||
 | 
					  const lookup = new ReactiveMap<string, TreeNode<mastodon.v1.Status>>();
 | 
				
			||||||
 | 
					  const [threads, setThreads] = createStore([] as mastodon.v1.Status["id"][]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [chunk, { refetch }] = createTimelineChunk(timeline, limit);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  createEffect(() => {
 | 
				
			||||||
 | 
					    const chk = catchError(chunk, (e) => console.error(e));
 | 
				
			||||||
 | 
					    if (!chk) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (chk.rebuilt) {
 | 
				
			||||||
 | 
					      lookup.clear();
 | 
				
			||||||
 | 
					      setThreads([]);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const existence = [] as boolean[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const [idx, status] of chk.chunk.entries()) {
 | 
				
			||||||
 | 
					      existence[idx] = !!untrack(() => lookup.get(status.id));
 | 
				
			||||||
 | 
					      lookup.set(status.id, {
 | 
				
			||||||
 | 
					        value: status,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const status of chk.chunk) {
 | 
				
			||||||
 | 
					      const node = untrack(() => lookup.get(status.id))!;
 | 
				
			||||||
 | 
					      if (status.inReplyToId) {
 | 
				
			||||||
 | 
					        const parent = lookup.get(status.inReplyToId);
 | 
				
			||||||
 | 
					        if (parent) {
 | 
				
			||||||
 | 
					          const children = parent.children ?? [];
 | 
				
			||||||
 | 
					          if (!children.find((x) => x.value.id == status.id)) {
 | 
				
			||||||
 | 
					            children.push(node);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          parent.children = children;
 | 
				
			||||||
 | 
					          node.parent = parent;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const nThreadIds = chk.chunk
 | 
				
			||||||
 | 
					      .filter((x, i) => !existence[i])
 | 
				
			||||||
 | 
					      .map((x) => x.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    batch(() => {
 | 
				
			||||||
 | 
					      if (chk.direction === "prev") {
 | 
				
			||||||
 | 
					        setThreads((threads) => [...threads, ...nThreadIds]);
 | 
				
			||||||
 | 
					      } else if (chk.direction === "next") {
 | 
				
			||||||
 | 
					        setThreads((threads) => [...nThreadIds, ...threads]);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      setThreads((threads) =>
 | 
				
			||||||
 | 
					        threads.filter((id) => (lookup.get(id)!.children?.length ?? 0) === 0),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      list: threads,
 | 
				
			||||||
 | 
					      get(id: string) {
 | 
				
			||||||
 | 
					        return lookup.get(id);
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      getPath(id: string) {
 | 
				
			||||||
 | 
					        const node = lookup.get(id);
 | 
				
			||||||
 | 
					        if (!node) return;
 | 
				
			||||||
 | 
					        return collectPath(node);
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      set(id: string, value: mastodon.v1.Status) {
 | 
				
			||||||
 | 
					        const node = untrack(() => lookup.get(id));
 | 
				
			||||||
 | 
					        if (!node) return;
 | 
				
			||||||
 | 
					        node.value = value;
 | 
				
			||||||
 | 
					        lookup.set(id, node);
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    chunk,
 | 
				
			||||||
 | 
					    { refetch },
 | 
				
			||||||
 | 
					  ] as const;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,26 +1,16 @@
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  Component,
 | 
					 | 
				
			||||||
  For,
 | 
					 | 
				
			||||||
  onCleanup,
 | 
					 | 
				
			||||||
  createSignal,
 | 
					  createSignal,
 | 
				
			||||||
  Show,
 | 
					  Show,
 | 
				
			||||||
  untrack,
 | 
					 | 
				
			||||||
  onMount,
 | 
					  onMount,
 | 
				
			||||||
  type ParentComponent,
 | 
					  type ParentComponent,
 | 
				
			||||||
  children,
 | 
					  children,
 | 
				
			||||||
  Suspense,
 | 
					  Suspense,
 | 
				
			||||||
  Match,
 | 
					 | 
				
			||||||
  Switch as JsSwitch,
 | 
					 | 
				
			||||||
  ErrorBoundary,
 | 
					 | 
				
			||||||
} from "solid-js";
 | 
					} from "solid-js";
 | 
				
			||||||
import { useDocumentTitle } from "../utils";
 | 
					import { useDocumentTitle } from "../utils";
 | 
				
			||||||
import { type mastodon } from "masto";
 | 
					import { type mastodon } from "masto";
 | 
				
			||||||
import Scaffold from "../material/Scaffold";
 | 
					import Scaffold from "../material/Scaffold";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  AppBar,
 | 
					  AppBar,
 | 
				
			||||||
  Button,
 | 
					 | 
				
			||||||
  Fab,
 | 
					 | 
				
			||||||
  LinearProgress,
 | 
					 | 
				
			||||||
  ListItemSecondaryAction,
 | 
					  ListItemSecondaryAction,
 | 
				
			||||||
  ListItemText,
 | 
					  ListItemText,
 | 
				
			||||||
  MenuItem,
 | 
					  MenuItem,
 | 
				
			||||||
| 
						 | 
					@ -29,205 +19,21 @@ import {
 | 
				
			||||||
} from "@suid/material";
 | 
					} from "@suid/material";
 | 
				
			||||||
import { css } from "solid-styled";
 | 
					import { css } from "solid-styled";
 | 
				
			||||||
import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
 | 
					import { TimeSourceProvider, createTimeSource } from "../platform/timesrc";
 | 
				
			||||||
import TootThread from "./TootThread.js";
 | 
					 | 
				
			||||||
import ProfileMenuButton from "./ProfileMenuButton";
 | 
					import ProfileMenuButton from "./ProfileMenuButton";
 | 
				
			||||||
import Tabs from "../material/Tabs";
 | 
					import Tabs from "../material/Tabs";
 | 
				
			||||||
import Tab from "../material/Tab";
 | 
					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";
 | 
					import { makeEventListener } from "@solid-primitives/event-listener";
 | 
				
			||||||
import BottomSheet, {
 | 
					import BottomSheet, {
 | 
				
			||||||
  HERO as BOTTOM_SHEET_HERO,
 | 
					  HERO as BOTTOM_SHEET_HERO,
 | 
				
			||||||
} from "../material/BottomSheet";
 | 
					} from "../material/BottomSheet";
 | 
				
			||||||
import { $settings } from "../settings/stores";
 | 
					import { $settings } from "../settings/stores";
 | 
				
			||||||
import { useStore } from "@nanostores/solid";
 | 
					import { useStore } from "@nanostores/solid";
 | 
				
			||||||
import { vibrate } from "../platform/hardware";
 | 
					 | 
				
			||||||
import PullDownToRefresh from "./PullDownToRefresh";
 | 
					 | 
				
			||||||
import { HeroSourceProvider, type HeroSource } from "../platform/anim";
 | 
					import { HeroSourceProvider, type HeroSource } from "../platform/anim";
 | 
				
			||||||
import { useNavigate } from "@solidjs/router";
 | 
					import { useNavigate } from "@solidjs/router";
 | 
				
			||||||
import { useSignedInProfiles } from "../masto/acct";
 | 
					import { useSignedInProfiles } from "../masto/acct";
 | 
				
			||||||
import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
 | 
					import { setCache as setTootBottomSheetCache } from "./TootBottomSheet";
 | 
				
			||||||
import TootComposer from "./TootComposer";
 | 
					import TrendTimelinePanel from "./TrendTimelinePanel";
 | 
				
			||||||
 | 
					import TimelinePanel from "./TimelinePanel";
 | 
				
			||||||
const TimelinePanel: Component<{
 | 
					 | 
				
			||||||
  client: mastodon.rest.Client;
 | 
					 | 
				
			||||||
  name: "home" | "public" | "trends";
 | 
					 | 
				
			||||||
  prefetch?: boolean;
 | 
					 | 
				
			||||||
  fullRefetch?: number;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  openFullScreenToot: (
 | 
					 | 
				
			||||||
    toot: mastodon.v1.Status,
 | 
					 | 
				
			||||||
    srcElement?: HTMLElement,
 | 
					 | 
				
			||||||
    reply?: boolean,
 | 
					 | 
				
			||||||
  ) => void;
 | 
					 | 
				
			||||||
}> = (props) => {
 | 
					 | 
				
			||||||
  const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
 | 
					 | 
				
			||||||
  const [
 | 
					 | 
				
			||||||
    timeline,
 | 
					 | 
				
			||||||
    snapshot,
 | 
					 | 
				
			||||||
    { refetch: refetchTimeline, mutate: mutateTimeline },
 | 
					 | 
				
			||||||
  ] = useTimeline(
 | 
					 | 
				
			||||||
    () =>
 | 
					 | 
				
			||||||
      props.name !== "trends"
 | 
					 | 
				
			||||||
        ? props.client.v1.timelines[props.name]
 | 
					 | 
				
			||||||
        : props.client.v1.trends.statuses,
 | 
					 | 
				
			||||||
    { fullRefresh: props.fullRefetch },
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
  const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
 | 
					 | 
				
			||||||
  const [typing, setTyping] = createSignal(false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const tlEndObserver = new IntersectionObserver(() => {
 | 
					 | 
				
			||||||
    if (untrack(() => props.prefetch) && !snapshot.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 = status.reblog
 | 
					 | 
				
			||||||
      ? status.reblog.reblogged
 | 
					 | 
				
			||||||
      : status.reblogged;
 | 
					 | 
				
			||||||
    vibrate(50);
 | 
					 | 
				
			||||||
    mutateTimeline(index, (x) => {
 | 
					 | 
				
			||||||
      if (x.reblog) {
 | 
					 | 
				
			||||||
        x.reblog = { ...x.reblog, reblogged: !reblogged };
 | 
					 | 
				
			||||||
        return Object.assign({}, x);
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        return Object.assign({}, x, {
 | 
					 | 
				
			||||||
          reblogged: !reblogged,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    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 (
 | 
					 | 
				
			||||||
    <ErrorBoundary
 | 
					 | 
				
			||||||
      fallback={(err, reset) => {
 | 
					 | 
				
			||||||
        return <p>Oops: {String(err)}</p>;
 | 
					 | 
				
			||||||
      }}
 | 
					 | 
				
			||||||
    >
 | 
					 | 
				
			||||||
      <PullDownToRefresh
 | 
					 | 
				
			||||||
        linkedElement={scrollLinked()}
 | 
					 | 
				
			||||||
        loading={snapshot.loading}
 | 
					 | 
				
			||||||
        onRefresh={() => refetchTimeline({ direction: "new" })}
 | 
					 | 
				
			||||||
      />
 | 
					 | 
				
			||||||
      <div
 | 
					 | 
				
			||||||
        ref={(e) =>
 | 
					 | 
				
			||||||
          setTimeout(() => {
 | 
					 | 
				
			||||||
            setScrollLinked(e.parentElement!);
 | 
					 | 
				
			||||||
          }, 0)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <Show when={props.name === "home"}>
 | 
					 | 
				
			||||||
          <TootComposer
 | 
					 | 
				
			||||||
            style={{
 | 
					 | 
				
			||||||
              "--scaffold-topbar-height": "0px",
 | 
					 | 
				
			||||||
            }}
 | 
					 | 
				
			||||||
            isTyping={typing()}
 | 
					 | 
				
			||||||
            onTypingChange={setTyping}
 | 
					 | 
				
			||||||
            client={props.client}
 | 
					 | 
				
			||||||
            onSent={() => refetchTimeline({ direction: "new" })}
 | 
					 | 
				
			||||||
          />
 | 
					 | 
				
			||||||
        </Show>
 | 
					 | 
				
			||||||
        <For each={timeline}>
 | 
					 | 
				
			||||||
          {(item, index) => {
 | 
					 | 
				
			||||||
            let element: HTMLElement | undefined;
 | 
					 | 
				
			||||||
            return (
 | 
					 | 
				
			||||||
              <TootThread
 | 
					 | 
				
			||||||
                ref={element}
 | 
					 | 
				
			||||||
                status={item}
 | 
					 | 
				
			||||||
                onBoost={(...args) => onBoost(index(), ...args)}
 | 
					 | 
				
			||||||
                onBookmark={(...args) => onBookmark(index(), ...args)}
 | 
					 | 
				
			||||||
                onReply={(client, status) =>
 | 
					 | 
				
			||||||
                  props.openFullScreenToot(status, element, true)
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
                client={props.client}
 | 
					 | 
				
			||||||
                expanded={item.id === expandedThreadId() ? 1 : 0}
 | 
					 | 
				
			||||||
                onExpandChange={(x) => {
 | 
					 | 
				
			||||||
                  setTyping(false)
 | 
					 | 
				
			||||||
                  if (item.id !== expandedThreadId()) {
 | 
					 | 
				
			||||||
                    setExpandedThreadId((x) => (x ? undefined : item.id));
 | 
					 | 
				
			||||||
                  } else if (x === 2) {
 | 
					 | 
				
			||||||
                    props.openFullScreenToot(item, element);
 | 
					 | 
				
			||||||
                  }
 | 
					 | 
				
			||||||
                }}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
            );
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        </For>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      <div ref={(e) => tlEndObserver.observe(e)}></div>
 | 
					 | 
				
			||||||
      <Show when={snapshot.loading}>
 | 
					 | 
				
			||||||
        <div
 | 
					 | 
				
			||||||
          class="loading-line"
 | 
					 | 
				
			||||||
          style={{
 | 
					 | 
				
			||||||
            width: "100%",
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <LinearProgress />
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </Show>
 | 
					 | 
				
			||||||
      <div
 | 
					 | 
				
			||||||
        style={{
 | 
					 | 
				
			||||||
          display: "flex",
 | 
					 | 
				
			||||||
          padding: "20px 0 calc(20px + var(--safe-area-inset-bottom, 0px))",
 | 
					 | 
				
			||||||
          "align-items": "center",
 | 
					 | 
				
			||||||
          "justify-content": "center",
 | 
					 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <JsSwitch>
 | 
					 | 
				
			||||||
          <Match when={snapshot.error}>
 | 
					 | 
				
			||||||
            <Button
 | 
					 | 
				
			||||||
              variant="contained"
 | 
					 | 
				
			||||||
              onClick={[refetchTimeline, "old"]}
 | 
					 | 
				
			||||||
              disabled={snapshot.loading}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              Retry
 | 
					 | 
				
			||||||
            </Button>
 | 
					 | 
				
			||||||
          </Match>
 | 
					 | 
				
			||||||
          <Match when={typeof props.fullRefetch === "undefined"}>
 | 
					 | 
				
			||||||
            <Button
 | 
					 | 
				
			||||||
              variant="contained"
 | 
					 | 
				
			||||||
              onClick={[refetchTimeline, "old"]}
 | 
					 | 
				
			||||||
              disabled={snapshot.loading}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              Load More
 | 
					 | 
				
			||||||
            </Button>
 | 
					 | 
				
			||||||
          </Match>
 | 
					 | 
				
			||||||
        </JsSwitch>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </ErrorBoundary>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Home: ParentComponent = (props) => {
 | 
					const Home: ParentComponent = (props) => {
 | 
				
			||||||
  let panelList: HTMLDivElement;
 | 
					  let panelList: HTMLDivElement;
 | 
				
			||||||
| 
						 | 
					@ -340,7 +146,9 @@ const Home: ParentComponent = (props) => {
 | 
				
			||||||
      console.warn("no account info?");
 | 
					      console.warn("no account info?");
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    setHeroSrc((x) => Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }));
 | 
					    setHeroSrc((x) =>
 | 
				
			||||||
 | 
					      Object.assign({}, x, { [BOTTOM_SHEET_HERO]: srcElement }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
    const acct = `${inf.username}@${p.account.site}`;
 | 
					    const acct = `${inf.username}@${p.account.site}`;
 | 
				
			||||||
    setTootBottomSheetCache(acct, toot);
 | 
					    setTootBottomSheetCache(acct, toot);
 | 
				
			||||||
    navigate(`/${encodeURIComponent(acct)}/${toot.id}`, {
 | 
					    navigate(`/${encodeURIComponent(acct)}/${toot.id}`, {
 | 
				
			||||||
| 
						 | 
					@ -439,12 +247,10 @@ const Home: ParentComponent = (props) => {
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
              <div class="tab-panel">
 | 
					              <div class="tab-panel">
 | 
				
			||||||
                <div>
 | 
					                <div>
 | 
				
			||||||
                  <TimelinePanel
 | 
					                  <TrendTimelinePanel
 | 
				
			||||||
                    client={client()}
 | 
					                    client={client()}
 | 
				
			||||||
                    name="trends"
 | 
					 | 
				
			||||||
                    prefetch={prefetching()}
 | 
					                    prefetch={prefetching()}
 | 
				
			||||||
                    openFullScreenToot={openFullScreenToot}
 | 
					                    openFullScreenToot={openFullScreenToot}
 | 
				
			||||||
                    fullRefetch={120}
 | 
					 | 
				
			||||||
                  />
 | 
					                  />
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
| 
						 | 
					@ -464,7 +270,9 @@ const Home: ParentComponent = (props) => {
 | 
				
			||||||
        </TimeSourceProvider>
 | 
					        </TimeSourceProvider>
 | 
				
			||||||
        <Suspense>
 | 
					        <Suspense>
 | 
				
			||||||
          <HeroSourceProvider value={[heroSrc, setHeroSrc]}>
 | 
					          <HeroSourceProvider value={[heroSrc, setHeroSrc]}>
 | 
				
			||||||
            <BottomSheet open={!!child()}>{child()}</BottomSheet>
 | 
					            <BottomSheet open={!!child()} onClose={() => navigate(-1)}>
 | 
				
			||||||
 | 
					              {child()}
 | 
				
			||||||
 | 
					            </BottomSheet>
 | 
				
			||||||
          </HeroSourceProvider>
 | 
					          </HeroSourceProvider>
 | 
				
			||||||
        </Suspense>
 | 
					        </Suspense>
 | 
				
			||||||
      </Scaffold>
 | 
					      </Scaffold>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -66,7 +66,7 @@ const MediaAttachmentGrid: Component<{
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  css`
 | 
					  css`
 | 
				
			||||||
    .attachments {
 | 
					    .attachments {
 | 
				
			||||||
      column-count: ${columnCount.toString()};
 | 
					      column-count: ${columnCount().toString()};
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  `;
 | 
					  `;
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -52,7 +52,7 @@ const PullDownToRefresh: Component<{
 | 
				
			||||||
  let lts = -1;
 | 
					  let lts = -1;
 | 
				
			||||||
  let ds = 0;
 | 
					  let ds = 0;
 | 
				
			||||||
  let holding = false;
 | 
					  let holding = false;
 | 
				
			||||||
  const K = 10;
 | 
					  const K = 20;
 | 
				
			||||||
  const updatePullDown = (ts: number) => {
 | 
					  const updatePullDown = (ts: number) => {
 | 
				
			||||||
    released = false;
 | 
					    released = false;
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
| 
						 | 
					@ -60,8 +60,9 @@ const PullDownToRefresh: Component<{
 | 
				
			||||||
      const dt = lts !== -1 ? ts - lts : 1 / 60;
 | 
					      const dt = lts !== -1 ? ts - lts : 1 / 60;
 | 
				
			||||||
      const vspring = holding ? 0 : K * x * dt;
 | 
					      const vspring = holding ? 0 : K * x * dt;
 | 
				
			||||||
      v = ds / dt - vspring;
 | 
					      v = ds / dt - vspring;
 | 
				
			||||||
 | 
					      const final = Math.max(Math.min(x + v * dt, stopPos()), 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      setPullDown(Math.max(Math.min(x + v * dt, stopPos()), 0));
 | 
					      setPullDown(final);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (Math.abs(x) > 1 || Math.abs(v) > 1) {
 | 
					      if (Math.abs(x) > 1 || Math.abs(v) > 1) {
 | 
				
			||||||
        requestAnimationFrame(updatePullDown);
 | 
					        requestAnimationFrame(updatePullDown);
 | 
				
			||||||
| 
						 | 
					@ -69,15 +70,6 @@ const PullDownToRefresh: Component<{
 | 
				
			||||||
        v = 0;
 | 
					        v = 0;
 | 
				
			||||||
        lts = -1;
 | 
					        lts = -1;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (
 | 
					 | 
				
			||||||
        !holding &&
 | 
					 | 
				
			||||||
        untrack(pullDown) >= stopPos() &&
 | 
					 | 
				
			||||||
        !props.loading &&
 | 
					 | 
				
			||||||
        props.onRefresh
 | 
					 | 
				
			||||||
      ) {
 | 
					 | 
				
			||||||
        setTimeout(props.onRefresh, 0);
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      ds = 0;
 | 
					      ds = 0;
 | 
				
			||||||
      released = true;
 | 
					      released = true;
 | 
				
			||||||
| 
						 | 
					@ -89,6 +81,11 @@ const PullDownToRefresh: Component<{
 | 
				
			||||||
  const onWheelNotUpdated = () => {
 | 
					  const onWheelNotUpdated = () => {
 | 
				
			||||||
    wheelTimeout = undefined;
 | 
					    wheelTimeout = undefined;
 | 
				
			||||||
    holding = false;
 | 
					    holding = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (released) {
 | 
				
			||||||
 | 
					      released = false;
 | 
				
			||||||
 | 
					      requestAnimationFrame(updatePullDown);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleLinkedWheel = (event: WheelEvent) => {
 | 
					  const handleLinkedWheel = (event: WheelEvent) => {
 | 
				
			||||||
| 
						 | 
					@ -97,11 +94,18 @@ const PullDownToRefresh: Component<{
 | 
				
			||||||
      const d = untrack(pullDown);
 | 
					      const d = untrack(pullDown);
 | 
				
			||||||
      if (d > 1) event.preventDefault();
 | 
					      if (d > 1) event.preventDefault();
 | 
				
			||||||
      ds = -(event.deltaY / window.devicePixelRatio / 2);
 | 
					      ds = -(event.deltaY / window.devicePixelRatio / 2);
 | 
				
			||||||
      holding = d < stopPos();
 | 
					 | 
				
			||||||
      if (wheelTimeout) {
 | 
					      if (wheelTimeout) {
 | 
				
			||||||
        clearTimeout(wheelTimeout);
 | 
					        clearTimeout(wheelTimeout);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					      if (d >= stopPos() && !props.loading) {
 | 
				
			||||||
 | 
					        props.onRefresh?.();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        holding = false;
 | 
				
			||||||
 | 
					        wheelTimeout = undefined;
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        holding = true;
 | 
				
			||||||
        wheelTimeout = setTimeout(onWheelNotUpdated, 200);
 | 
					        wheelTimeout = setTimeout(onWheelNotUpdated, 200);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (released) {
 | 
					      if (released) {
 | 
				
			||||||
        released = false;
 | 
					        released = false;
 | 
				
			||||||
| 
						 | 
					@ -151,12 +155,8 @@ const PullDownToRefresh: Component<{
 | 
				
			||||||
    lastTouchId = undefined;
 | 
					    lastTouchId = undefined;
 | 
				
			||||||
    lastTouchScreenY = 0;
 | 
					    lastTouchScreenY = 0;
 | 
				
			||||||
    holding = false;
 | 
					    holding = false;
 | 
				
			||||||
    if (
 | 
					    if (untrack(pullDown) >= stopPos() && !props.loading) {
 | 
				
			||||||
      untrack(indicatorOfsY) >= stopPos() &&
 | 
					      props.onRefresh?.();
 | 
				
			||||||
      !props.loading &&
 | 
					 | 
				
			||||||
      props.onRefresh
 | 
					 | 
				
			||||||
    ) {
 | 
					 | 
				
			||||||
      setTimeout(props.onRefresh, 0);
 | 
					 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      if (released) {
 | 
					      if (released) {
 | 
				
			||||||
        released = false;
 | 
					        released = false;
 | 
				
			||||||
| 
						 | 
					@ -203,9 +203,7 @@ const PullDownToRefresh: Component<{
 | 
				
			||||||
      background-color: var(--tutu-color-surface);
 | 
					      background-color: var(--tutu-color-surface);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      > :global(.refresh-icon) {
 | 
					      > :global(.refresh-icon) {
 | 
				
			||||||
        transform: rotate(
 | 
					        transform: rotate(${`${(indicatorOfsY() / 160 / 2).toString()}turn`});
 | 
				
			||||||
          ${`${((indicatorOfsY() / 160) * 180).toString()}deg`}
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        will-change: transform;
 | 
					        will-change: transform;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -111,13 +111,17 @@ type TootActionGroupProps<T extends mastodon.v1.Status> = {
 | 
				
			||||||
  onRetoot?: (value: T) => void;
 | 
					  onRetoot?: (value: T) => void;
 | 
				
			||||||
  onFavourite?: (value: T) => void;
 | 
					  onFavourite?: (value: T) => void;
 | 
				
			||||||
  onBookmark?: (value: T) => void;
 | 
					  onBookmark?: (value: T) => void;
 | 
				
			||||||
  onReply?: (value: T) => void;
 | 
					  onReply?: (
 | 
				
			||||||
 | 
					    value: T,
 | 
				
			||||||
 | 
					    event: MouseEvent & { currentTarget: HTMLButtonElement },
 | 
				
			||||||
 | 
					  ) => void;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type TootCardProps = {
 | 
					type TootCardProps = {
 | 
				
			||||||
  status: mastodon.v1.Status;
 | 
					  status: mastodon.v1.Status;
 | 
				
			||||||
  actionable?: boolean;
 | 
					  actionable?: boolean;
 | 
				
			||||||
  evaluated?: boolean;
 | 
					  evaluated?: boolean;
 | 
				
			||||||
 | 
					  thread?: "top" | "bottom" | "middle";
 | 
				
			||||||
} & TootActionGroupProps<mastodon.v1.Status> &
 | 
					} & TootActionGroupProps<mastodon.v1.Status> &
 | 
				
			||||||
  JSX.HTMLElementTags["article"];
 | 
					  JSX.HTMLElementTags["article"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -125,19 +129,40 @@ function isolatedCallback(e: MouseEvent) {
 | 
				
			||||||
  e.stopPropagation();
 | 
					  e.stopPropagation();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function findRootToot(element: HTMLElement) {
 | 
				
			||||||
 | 
					  let current: HTMLElement | null = element;
 | 
				
			||||||
 | 
					  while (current && !current.classList.contains(tootStyle.toot)) {
 | 
				
			||||||
 | 
					    current = current.parentElement;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  if (!current) {
 | 
				
			||||||
 | 
					    throw Error(
 | 
				
			||||||
 | 
					      `the element must be placed under a element with ${tootStyle.toot}`,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return current;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function TootActionGroup<T extends mastodon.v1.Status>(
 | 
					function TootActionGroup<T extends mastodon.v1.Status>(
 | 
				
			||||||
  props: TootActionGroupProps<T> & { value: T },
 | 
					  props: TootActionGroupProps<T> & { value: T },
 | 
				
			||||||
) {
 | 
					) {
 | 
				
			||||||
 | 
					  let actGrpElement: HTMLDivElement;
 | 
				
			||||||
  const toot = () => props.value;
 | 
					  const toot = () => props.value;
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div class={tootStyle.tootBottomActionGrp} onClick={isolatedCallback}>
 | 
					    <div
 | 
				
			||||||
 | 
					      ref={actGrpElement!}
 | 
				
			||||||
 | 
					      class={tootStyle.tootBottomActionGrp}
 | 
				
			||||||
 | 
					      onClick={isolatedCallback}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <Show when={props.onReply}>
 | 
				
			||||||
        <Button
 | 
					        <Button
 | 
				
			||||||
          class={tootStyle.tootActionWithCount}
 | 
					          class={tootStyle.tootActionWithCount}
 | 
				
			||||||
        onClick={() => props.onReply?.(toot())}
 | 
					          onClick={[props.onReply!, props.value]}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <ReplyAll />
 | 
					          <ReplyAll />
 | 
				
			||||||
          <span>{toot().repliesCount}</span>
 | 
					          <span>{toot().repliesCount}</span>
 | 
				
			||||||
        </Button>
 | 
					        </Button>
 | 
				
			||||||
 | 
					      </Show>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <Button
 | 
					      <Button
 | 
				
			||||||
        class={tootStyle.tootActionWithCount}
 | 
					        class={tootStyle.tootActionWithCount}
 | 
				
			||||||
        style={{
 | 
					        style={{
 | 
				
			||||||
| 
						 | 
					@ -288,7 +313,7 @@ const RegularToot: Component<TootCardProps> = (props) => {
 | 
				
			||||||
  let rootRef: HTMLElement;
 | 
					  let rootRef: HTMLElement;
 | 
				
			||||||
  const [managed, managedActionGroup, rest] = splitProps(
 | 
					  const [managed, managedActionGroup, rest] = splitProps(
 | 
				
			||||||
    props,
 | 
					    props,
 | 
				
			||||||
    ["status", "lang", "class", "actionable", "evaluated"],
 | 
					    ["status", "lang", "class", "actionable", "evaluated", "thread"],
 | 
				
			||||||
    ["onRetoot", "onFavourite", "onBookmark", "onReply"],
 | 
					    ["onRetoot", "onFavourite", "onBookmark", "onReply"],
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
  const now = useTimeSource();
 | 
					  const now = useTimeSource();
 | 
				
			||||||
| 
						 | 
					@ -300,6 +325,42 @@ const RegularToot: Component<TootCardProps> = (props) => {
 | 
				
			||||||
      margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 8px);
 | 
					      margin-left: calc(var(--toot-avatar-size) + var(--card-pad) + 8px);
 | 
				
			||||||
      margin-block: 8px;
 | 
					      margin-block: 8px;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .thread-top,
 | 
				
			||||||
 | 
					    .thread-mid,
 | 
				
			||||||
 | 
					    .thread-btm {
 | 
				
			||||||
 | 
					      position: relative;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &::before {
 | 
				
			||||||
 | 
					        content: "";
 | 
				
			||||||
 | 
					        position: absolute;
 | 
				
			||||||
 | 
					        left: 36px;
 | 
				
			||||||
 | 
					        background-color: var(--tutu-color-secondary);
 | 
				
			||||||
 | 
					        width: 2px;
 | 
				
			||||||
 | 
					        display: block;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .thread-mid {
 | 
				
			||||||
 | 
					      &::before {
 | 
				
			||||||
 | 
					        top: 0;
 | 
				
			||||||
 | 
					        bottom: 0;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .thread-top {
 | 
				
			||||||
 | 
					      &::before {
 | 
				
			||||||
 | 
					        top: 16px;
 | 
				
			||||||
 | 
					        bottom: 0;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .thread-btm {
 | 
				
			||||||
 | 
					      &::before {
 | 
				
			||||||
 | 
					        top: 0;
 | 
				
			||||||
 | 
					        height: 16px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  `;
 | 
					  `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					@ -308,6 +369,9 @@ const RegularToot: Component<TootCardProps> = (props) => {
 | 
				
			||||||
        classList={{
 | 
					        classList={{
 | 
				
			||||||
          [tootStyle.toot]: true,
 | 
					          [tootStyle.toot]: true,
 | 
				
			||||||
          [tootStyle.expanded]: managed.evaluated,
 | 
					          [tootStyle.expanded]: managed.evaluated,
 | 
				
			||||||
 | 
					          "thread-top": managed.thread === "top",
 | 
				
			||||||
 | 
					          "thread-mid": managed.thread === "middle",
 | 
				
			||||||
 | 
					          "thread-btm": managed.thread === "bottom",
 | 
				
			||||||
          [managed.class || ""]: true,
 | 
					          [managed.class || ""]: true,
 | 
				
			||||||
        }}
 | 
					        }}
 | 
				
			||||||
        ref={rootRef!}
 | 
					        ref={rootRef!}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										92
									
								
								src/timelines/Thread.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/timelines/Thread.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,92 @@
 | 
				
			||||||
 | 
					import type { mastodon } from "masto";
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  For,
 | 
				
			||||||
 | 
					  Show,
 | 
				
			||||||
 | 
					  createResource,
 | 
				
			||||||
 | 
					  createSignal,
 | 
				
			||||||
 | 
					  type Component,
 | 
				
			||||||
 | 
					  type Ref,
 | 
				
			||||||
 | 
					} from "solid-js";
 | 
				
			||||||
 | 
					import CompactToot from "./CompactToot";
 | 
				
			||||||
 | 
					import { useTimeSource } from "../platform/timesrc";
 | 
				
			||||||
 | 
					import RegularToot, { findRootToot } from "./RegularToot";
 | 
				
			||||||
 | 
					import cardStyle from "../material/cards.module.css";
 | 
				
			||||||
 | 
					import { css } from "solid-styled";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type TootActionTarget = {
 | 
				
			||||||
 | 
					  client: mastodon.rest.Client;
 | 
				
			||||||
 | 
					  status: mastodon.v1.Status;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type TootActions = {
 | 
				
			||||||
 | 
					  onBoost(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
 | 
				
			||||||
 | 
					  onBookmark(client: mastodon.rest.Client, status: mastodon.v1.Status): void;
 | 
				
			||||||
 | 
					  onReply(target: TootActionTarget, element: HTMLElement): void;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type ThreadProps = {
 | 
				
			||||||
 | 
					  ref?: Ref<HTMLElement>;
 | 
				
			||||||
 | 
					  client: mastodon.rest.Client;
 | 
				
			||||||
 | 
					  toots: readonly mastodon.v1.Status[];
 | 
				
			||||||
 | 
					  isExpended: (status: mastodon.v1.Status) => boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onItemClick(status: mastodon.v1.Status, event: MouseEvent): void;
 | 
				
			||||||
 | 
					} & TootActions;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Thread: Component<ThreadProps> = (props) => {
 | 
				
			||||||
 | 
					  const boost = (status: mastodon.v1.Status) => {
 | 
				
			||||||
 | 
					    props.onBoost(props.client, status);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const bookmark = (status: mastodon.v1.Status) => {
 | 
				
			||||||
 | 
					    props.onBookmark(props.client, status);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const reply = (
 | 
				
			||||||
 | 
					    status: mastodon.v1.Status,
 | 
				
			||||||
 | 
					    event: MouseEvent & { currentTarget: HTMLElement },
 | 
				
			||||||
 | 
					  ) => {
 | 
				
			||||||
 | 
					    const element = findRootToot(event.currentTarget);
 | 
				
			||||||
 | 
					    props.onReply({ client: props.client, status }, element);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  css`
 | 
				
			||||||
 | 
					  .thread {
 | 
				
			||||||
 | 
					    user-select: none;
 | 
				
			||||||
 | 
					    cursor: pointer;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  `
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <article ref={props.ref} class="thread">
 | 
				
			||||||
 | 
					      <For each={props.toots}>
 | 
				
			||||||
 | 
					        {(status, index) => {
 | 
				
			||||||
 | 
					          const useThread = props.toots.length > 1;
 | 
				
			||||||
 | 
					          const threadPosition = useThread
 | 
				
			||||||
 | 
					            ? index() === 0
 | 
				
			||||||
 | 
					              ? "top"
 | 
				
			||||||
 | 
					              : index() === props.toots.length - 1
 | 
				
			||||||
 | 
					                ? "bottom"
 | 
				
			||||||
 | 
					                : "middle"
 | 
				
			||||||
 | 
					            : undefined;
 | 
				
			||||||
 | 
					          return (
 | 
				
			||||||
 | 
					            <RegularToot
 | 
				
			||||||
 | 
					              data-status-id={status.id}
 | 
				
			||||||
 | 
					              data-thread-sort={index()}
 | 
				
			||||||
 | 
					              status={status}
 | 
				
			||||||
 | 
					              thread={threadPosition}
 | 
				
			||||||
 | 
					              class={cardStyle.card}
 | 
				
			||||||
 | 
					              evaluated={props.isExpended(status)}
 | 
				
			||||||
 | 
					              actionable={props.isExpended(status)}
 | 
				
			||||||
 | 
					              onBookmark={(s) => bookmark(s)}
 | 
				
			||||||
 | 
					              onRetoot={(s) => boost(s)}
 | 
				
			||||||
 | 
					              onReply={reply}
 | 
				
			||||||
 | 
					              onClick={[props.onItemClick, status]}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          );
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      </For>
 | 
				
			||||||
 | 
					    </article>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default Thread;
 | 
				
			||||||
							
								
								
									
										189
									
								
								src/timelines/TimelinePanel.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								src/timelines/TimelinePanel.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,189 @@
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Component,
 | 
				
			||||||
 | 
					  For,
 | 
				
			||||||
 | 
					  onCleanup,
 | 
				
			||||||
 | 
					  createSignal,
 | 
				
			||||||
 | 
					  Show,
 | 
				
			||||||
 | 
					  untrack,
 | 
				
			||||||
 | 
					  Match,
 | 
				
			||||||
 | 
					  Switch as JsSwitch,
 | 
				
			||||||
 | 
					  ErrorBoundary,
 | 
				
			||||||
 | 
					} from "solid-js";
 | 
				
			||||||
 | 
					import { type mastodon } from "masto";
 | 
				
			||||||
 | 
					import { Button, LinearProgress } from "@suid/material";
 | 
				
			||||||
 | 
					import { createTimeline } from "../masto/timelines";
 | 
				
			||||||
 | 
					import { vibrate } from "../platform/hardware";
 | 
				
			||||||
 | 
					import PullDownToRefresh from "./PullDownToRefresh";
 | 
				
			||||||
 | 
					import TootComposer from "./TootComposer";
 | 
				
			||||||
 | 
					import Thread from "./Thread.jsx";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const TimelinePanel: Component<{
 | 
				
			||||||
 | 
					  client: mastodon.rest.Client;
 | 
				
			||||||
 | 
					  name: "home" | "public";
 | 
				
			||||||
 | 
					  prefetch?: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  openFullScreenToot: (
 | 
				
			||||||
 | 
					    toot: mastodon.v1.Status,
 | 
				
			||||||
 | 
					    srcElement?: HTMLElement,
 | 
				
			||||||
 | 
					    reply?: boolean,
 | 
				
			||||||
 | 
					  ) => void;
 | 
				
			||||||
 | 
					}> = (props) => {
 | 
				
			||||||
 | 
					  const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [timeline, snapshot, { refetch: refetchTimeline }] = createTimeline(
 | 
				
			||||||
 | 
					    () => props.client.v1.timelines[props.name],
 | 
				
			||||||
 | 
					    () => 20,
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
 | 
				
			||||||
 | 
					  const [typing, setTyping] = createSignal(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const tlEndObserver = new IntersectionObserver(() => {
 | 
				
			||||||
 | 
					    if (untrack(() => props.prefetch) && !snapshot.loading)
 | 
				
			||||||
 | 
					      refetchTimeline("next");
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onCleanup(() => tlEndObserver.disconnect());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onBookmark = async (
 | 
				
			||||||
 | 
					    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());
 | 
				
			||||||
 | 
					    timeline.set(result.id, result);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const onBoost = async (
 | 
				
			||||||
 | 
					    client: mastodon.rest.Client,
 | 
				
			||||||
 | 
					    status: mastodon.v1.Status,
 | 
				
			||||||
 | 
					  ) => {
 | 
				
			||||||
 | 
					    vibrate(50);
 | 
				
			||||||
 | 
					    const rootStatus = status.reblog ? status.reblog : status;
 | 
				
			||||||
 | 
					    const reblogged = rootStatus.reblogged;
 | 
				
			||||||
 | 
					    if (status.reblog) {
 | 
				
			||||||
 | 
					      status.reblog = { ...status.reblog, reblogged: !reblogged };
 | 
				
			||||||
 | 
					      timeline.set(status.id, status);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      timeline.set(
 | 
				
			||||||
 | 
					        status.id,
 | 
				
			||||||
 | 
					        Object.assign(status, {
 | 
				
			||||||
 | 
					          reblogged: !reblogged,
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const result = reblogged
 | 
				
			||||||
 | 
					      ? await client.v1.statuses.$select(status.id).unreblog()
 | 
				
			||||||
 | 
					      : (await client.v1.statuses.$select(status.id).reblog()).reblog!;
 | 
				
			||||||
 | 
					    timeline.set(
 | 
				
			||||||
 | 
					      status.id,
 | 
				
			||||||
 | 
					      Object.assign(status.reblog ?? status, result.reblog),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <ErrorBoundary
 | 
				
			||||||
 | 
					      fallback={(err, reset) => {
 | 
				
			||||||
 | 
					        return <p>Oops: {String(err)}</p>;
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <PullDownToRefresh
 | 
				
			||||||
 | 
					        linkedElement={scrollLinked()}
 | 
				
			||||||
 | 
					        loading={snapshot.loading}
 | 
				
			||||||
 | 
					        onRefresh={() => refetchTimeline("next")}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        ref={(e) =>
 | 
				
			||||||
 | 
					          setTimeout(() => {
 | 
				
			||||||
 | 
					            setScrollLinked(e.parentElement!);
 | 
				
			||||||
 | 
					          }, 0)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Show when={props.name === "home"}>
 | 
				
			||||||
 | 
					          <TootComposer
 | 
				
			||||||
 | 
					            style={{
 | 
				
			||||||
 | 
					              "--scaffold-topbar-height": "0px",
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					            isTyping={typing()}
 | 
				
			||||||
 | 
					            onTypingChange={setTyping}
 | 
				
			||||||
 | 
					            client={props.client}
 | 
				
			||||||
 | 
					            onSent={() => refetchTimeline("prev")}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </Show>
 | 
				
			||||||
 | 
					        <For each={timeline.list}>
 | 
				
			||||||
 | 
					          {(itemId, index) => {
 | 
				
			||||||
 | 
					            const path = timeline.getPath(itemId)!;
 | 
				
			||||||
 | 
					            const toots = path.reverse().map((x) => x.value);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return (
 | 
				
			||||||
 | 
					              <Thread
 | 
				
			||||||
 | 
					                toots={toots}
 | 
				
			||||||
 | 
					                onBoost={onBoost}
 | 
				
			||||||
 | 
					                onBookmark={onBookmark}
 | 
				
			||||||
 | 
					                onReply={({ status }, element) =>
 | 
				
			||||||
 | 
					                  props.openFullScreenToot(status, element, true)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                client={props.client}
 | 
				
			||||||
 | 
					                isExpended={(status) => status.id === expandedThreadId()}
 | 
				
			||||||
 | 
					                onItemClick={(status, event) => {
 | 
				
			||||||
 | 
					                  setTyping(false);
 | 
				
			||||||
 | 
					                  if (status.id !== expandedThreadId()) {
 | 
				
			||||||
 | 
					                    setExpandedThreadId((x) => (x ? undefined : status.id));
 | 
				
			||||||
 | 
					                  } else {
 | 
				
			||||||
 | 
					                    props.openFullScreenToot(
 | 
				
			||||||
 | 
					                      status,
 | 
				
			||||||
 | 
					                      event.currentTarget as HTMLElement,
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        </For>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div ref={(e) => tlEndObserver.observe(e)}></div>
 | 
				
			||||||
 | 
					      <Show when={snapshot.loading}>
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          class="loading-line"
 | 
				
			||||||
 | 
					          style={{
 | 
				
			||||||
 | 
					            width: "100%",
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <LinearProgress />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </Show>
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        style={{
 | 
				
			||||||
 | 
					          display: "flex",
 | 
				
			||||||
 | 
					          padding: "20px 0 calc(20px + var(--safe-area-inset-bottom, 0px))",
 | 
				
			||||||
 | 
					          "align-items": "center",
 | 
				
			||||||
 | 
					          "justify-content": "center",
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <JsSwitch>
 | 
				
			||||||
 | 
					          <Match when={snapshot.error}>
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              variant="contained"
 | 
				
			||||||
 | 
					              onClick={[refetchTimeline, "next"]}
 | 
				
			||||||
 | 
					              disabled={snapshot.loading}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              Retry
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					          </Match>
 | 
				
			||||||
 | 
					          <Match when={true}>
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              variant="contained"
 | 
				
			||||||
 | 
					              onClick={[refetchTimeline, "prev"]}
 | 
				
			||||||
 | 
					              disabled={snapshot.loading}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              Load More
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					          </Match>
 | 
				
			||||||
 | 
					        </JsSwitch>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </ErrorBoundary>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default TimelinePanel;
 | 
				
			||||||
| 
						 | 
					@ -62,9 +62,6 @@ const TootBottomSheet: Component = (props) => {
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
  const profile = () => {
 | 
					 | 
				
			||||||
    return session().account;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const pushedCount = () => {
 | 
					  const pushedCount = () => {
 | 
				
			||||||
    return location.state?.tootBottomSheetPushedCount || 0;
 | 
					    return location.state?.tootBottomSheetPushedCount || 0;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										182
									
								
								src/timelines/TrendTimelinePanel.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								src/timelines/TrendTimelinePanel.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,182 @@
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Component,
 | 
				
			||||||
 | 
					  For,
 | 
				
			||||||
 | 
					  onCleanup,
 | 
				
			||||||
 | 
					  createSignal,
 | 
				
			||||||
 | 
					  untrack,
 | 
				
			||||||
 | 
					  Match,
 | 
				
			||||||
 | 
					  Switch as JsSwitch,
 | 
				
			||||||
 | 
					  ErrorBoundary,
 | 
				
			||||||
 | 
					  createSelector,
 | 
				
			||||||
 | 
					} from "solid-js";
 | 
				
			||||||
 | 
					import { type mastodon } from "masto";
 | 
				
			||||||
 | 
					import { Button } from "@suid/material";
 | 
				
			||||||
 | 
					import { createTimelineSnapshot } from "../masto/timelines.js";
 | 
				
			||||||
 | 
					import { vibrate } from "../platform/hardware.js";
 | 
				
			||||||
 | 
					import PullDownToRefresh from "./PullDownToRefresh.jsx";
 | 
				
			||||||
 | 
					import Thread from "./Thread.jsx";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const TrendTimelinePanel: Component<{
 | 
				
			||||||
 | 
					  client: mastodon.rest.Client;
 | 
				
			||||||
 | 
					  prefetch?: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  openFullScreenToot: (
 | 
				
			||||||
 | 
					    toot: mastodon.v1.Status,
 | 
				
			||||||
 | 
					    srcElement?: HTMLElement,
 | 
				
			||||||
 | 
					    reply?: boolean,
 | 
				
			||||||
 | 
					  ) => void;
 | 
				
			||||||
 | 
					}> = (props) => {
 | 
				
			||||||
 | 
					  const [scrollLinked, setScrollLinked] = createSignal<HTMLElement>();
 | 
				
			||||||
 | 
					  const [
 | 
				
			||||||
 | 
					    timeline,
 | 
				
			||||||
 | 
					    snapshot,
 | 
				
			||||||
 | 
					    { refetch: refetchTimeline, mutate: mutateTimeline },
 | 
				
			||||||
 | 
					  ] = createTimelineSnapshot(
 | 
				
			||||||
 | 
					    () => props.client.v1.trends.statuses,
 | 
				
			||||||
 | 
					    () => 120,
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					  const [expandedId, setExpandedId] = createSignal<string>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const tlEndObserver = new IntersectionObserver(() => {
 | 
				
			||||||
 | 
					    if (untrack(() => props.prefetch) && !snapshot.loading)
 | 
				
			||||||
 | 
					      refetchTimeline();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onCleanup(() => tlEndObserver.disconnect());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const isExpandedId = createSelector(expandedId);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const isExpanded = (st: mastodon.v1.Status) => isExpandedId(st.id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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 = status.reblog
 | 
				
			||||||
 | 
					      ? status.reblog.reblogged
 | 
				
			||||||
 | 
					      : status.reblogged;
 | 
				
			||||||
 | 
					    vibrate(50);
 | 
				
			||||||
 | 
					    mutateTimeline(index, (th) => {
 | 
				
			||||||
 | 
					      const x = th[0];
 | 
				
			||||||
 | 
					      if (x.reblog) {
 | 
				
			||||||
 | 
					        x.reblog = { ...x.reblog, reblogged: !reblogged };
 | 
				
			||||||
 | 
					        return [Object.assign({}, x)];
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        return [
 | 
				
			||||||
 | 
					          Object.assign({}, x, {
 | 
				
			||||||
 | 
					            reblogged: !reblogged,
 | 
				
			||||||
 | 
					          }),
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    const result = reblogged
 | 
				
			||||||
 | 
					      ? await client.v1.statuses.$select(status.id).unreblog()
 | 
				
			||||||
 | 
					      : (await client.v1.statuses.$select(status.id).reblog()).reblog!;
 | 
				
			||||||
 | 
					    mutateTimeline(index, (th) => {
 | 
				
			||||||
 | 
					      Object.assign(th[0].reblog ?? th[0], {
 | 
				
			||||||
 | 
					        reblogged: result.reblogged,
 | 
				
			||||||
 | 
					        reblogsCount: result.reblogsCount,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      return th;
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <ErrorBoundary
 | 
				
			||||||
 | 
					      fallback={(err, reset) => {
 | 
				
			||||||
 | 
					        return <p>Oops: {String(err)}</p>;
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <PullDownToRefresh
 | 
				
			||||||
 | 
					        linkedElement={scrollLinked()}
 | 
				
			||||||
 | 
					        loading={snapshot.loading}
 | 
				
			||||||
 | 
					        onRefresh={() => refetchTimeline({ direction: "new" })}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        ref={(e) =>
 | 
				
			||||||
 | 
					          setTimeout(() => {
 | 
				
			||||||
 | 
					            setScrollLinked(e.parentElement!);
 | 
				
			||||||
 | 
					          }, 0)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <For each={timeline}>
 | 
				
			||||||
 | 
					          {(item, index) => {
 | 
				
			||||||
 | 
					            let element: HTMLElement | undefined;
 | 
				
			||||||
 | 
					            return (
 | 
				
			||||||
 | 
					              <Thread
 | 
				
			||||||
 | 
					                ref={element}
 | 
				
			||||||
 | 
					                toots={item}
 | 
				
			||||||
 | 
					                onBoost={(...args) => onBoost(index(), ...args)}
 | 
				
			||||||
 | 
					                onBookmark={(...args) => onBookmark(index(), ...args)}
 | 
				
			||||||
 | 
					                onReply={(client, status) =>
 | 
				
			||||||
 | 
					                  props.openFullScreenToot(status, element, true)
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                client={props.client}
 | 
				
			||||||
 | 
					                isExpended={isExpanded}
 | 
				
			||||||
 | 
					                onItemClick={(x) => {
 | 
				
			||||||
 | 
					                  if (x.id !== expandedId()) {
 | 
				
			||||||
 | 
					                    setExpandedId((o) => (o ? undefined : x.id));
 | 
				
			||||||
 | 
					                  } else {
 | 
				
			||||||
 | 
					                    props.openFullScreenToot(x, element);
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        </For>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div ref={(e) => tlEndObserver.observe(e)}></div>
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        style={{
 | 
				
			||||||
 | 
					          display: "flex",
 | 
				
			||||||
 | 
					          padding: "20px 0 calc(20px + var(--safe-area-inset-bottom, 0px))",
 | 
				
			||||||
 | 
					          "align-items": "center",
 | 
				
			||||||
 | 
					          "justify-content": "center",
 | 
				
			||||||
 | 
					          "flex-flow": "column",
 | 
				
			||||||
 | 
					          gap: "20px"
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <JsSwitch>
 | 
				
			||||||
 | 
					          <Match when={snapshot.error}>
 | 
				
			||||||
 | 
					            <p>{`Oops: ${snapshot.error}`}</p>
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              variant="contained"
 | 
				
			||||||
 | 
					              onClick={[refetchTimeline, undefined]}
 | 
				
			||||||
 | 
					              disabled={snapshot.loading}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              Retry
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          </Match>
 | 
				
			||||||
 | 
					          <Match when={true}>
 | 
				
			||||||
 | 
					            <Button
 | 
				
			||||||
 | 
					              variant="contained"
 | 
				
			||||||
 | 
					              onClick={[refetchTimeline, undefined]}
 | 
				
			||||||
 | 
					              disabled={snapshot.loading}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              Refresh
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					          </Match>
 | 
				
			||||||
 | 
					        </JsSwitch>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </ErrorBoundary>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default TrendTimelinePanel;
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue