This commit is contained in:
		
							parent
							
								
									e9c39492ec
								
							
						
					
					
						commit
						bea1d6abfa
					
				
					 7 changed files with 474 additions and 80 deletions
				
			
		| 
						 | 
					@ -24,9 +24,7 @@ import {
 | 
				
			||||||
  ResultDispatcher,
 | 
					  ResultDispatcher,
 | 
				
			||||||
  type JSONRPC,
 | 
					  type JSONRPC,
 | 
				
			||||||
} from "./serviceworker/workerrpc.js";
 | 
					} from "./serviceworker/workerrpc.js";
 | 
				
			||||||
import {
 | 
					import { Service } from "./serviceworker/services.js";
 | 
				
			||||||
  Service
 | 
					 | 
				
			||||||
} from "./serviceworker/services.js"
 | 
					 | 
				
			||||||
import { makeEventListener } from "@solid-primitives/event-listener";
 | 
					import { makeEventListener } from "@solid-primitives/event-listener";
 | 
				
			||||||
import { ServiceWorkerProvider } from "./platform/host.js";
 | 
					import { ServiceWorkerProvider } from "./platform/host.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -41,6 +39,7 @@ const MotionSettings = lazy(() => import("./settings/Motions.js"));
 | 
				
			||||||
const LanguageSettings = lazy(() => import("./settings/Language.js"));
 | 
					const LanguageSettings = lazy(() => import("./settings/Language.js"));
 | 
				
			||||||
const RegionSettings = lazy(() => import("./settings/Region.jsx"));
 | 
					const RegionSettings = lazy(() => import("./settings/Region.jsx"));
 | 
				
			||||||
const UnexpectedError = lazy(() => import("./UnexpectedError.js"));
 | 
					const UnexpectedError = lazy(() => import("./UnexpectedError.js"));
 | 
				
			||||||
 | 
					const Profile = lazy(() => import("./profiles/Profile.js"));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const Routing: Component = () => {
 | 
					const Routing: Component = () => {
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
| 
						 | 
					@ -54,6 +53,7 @@ const Routing: Component = () => {
 | 
				
			||||||
          <Route path="/motions" component={MotionSettings}></Route>
 | 
					          <Route path="/motions" component={MotionSettings}></Route>
 | 
				
			||||||
        </Route>
 | 
					        </Route>
 | 
				
			||||||
        <Route path="/:acct/toot/:id" component={TootBottomSheet}></Route>
 | 
					        <Route path="/:acct/toot/:id" component={TootBottomSheet}></Route>
 | 
				
			||||||
 | 
					        <Route path="/:acct/profile/:id" component={Profile}></Route>
 | 
				
			||||||
      </Route>
 | 
					      </Route>
 | 
				
			||||||
      <Route path={"/accounts"}>
 | 
					      <Route path={"/accounts"}>
 | 
				
			||||||
        <Route path={"/sign-in"} component={AccountSignIn} />
 | 
					        <Route path={"/sign-in"} component={AccountSignIn} />
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,7 +4,6 @@ import {
 | 
				
			||||||
  createMemo,
 | 
					  createMemo,
 | 
				
			||||||
  createRenderEffect,
 | 
					  createRenderEffect,
 | 
				
			||||||
  createResource,
 | 
					  createResource,
 | 
				
			||||||
  Signal,
 | 
					 | 
				
			||||||
  useContext,
 | 
					  useContext,
 | 
				
			||||||
} from "solid-js";
 | 
					} from "solid-js";
 | 
				
			||||||
import { Account } from "../accounts/stores";
 | 
					import { Account } from "../accounts/stores";
 | 
				
			||||||
| 
						 | 
					@ -78,6 +77,26 @@ function useSessionsRaw() {
 | 
				
			||||||
  return store;
 | 
					  return store;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const DefaultSessionContext = /* @__PURE__ */ createContext<Accessor<number>>(() => 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const DefaultSessionProvider = DefaultSessionContext.Provider;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Return the default session (the first session).
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * This function may return `undefined`, but it will try to redirect the user to the sign in.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export function useDefaultSession() {
 | 
				
			||||||
 | 
					  const sessions = useSessions()
 | 
				
			||||||
 | 
					  const sessionIndex = useContext(DefaultSessionContext)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return () => {
 | 
				
			||||||
 | 
					    if (sessions().length > 0) {
 | 
				
			||||||
 | 
					      return sessions()[sessionIndex()]
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Get a session for the specific acct string.
 | 
					 * Get a session for the specific acct string.
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
| 
						 | 
					@ -98,7 +117,6 @@ export function useSessionForAcctStr(acct: Accessor<string>) {
 | 
				
			||||||
  const allSessions = useSessions()
 | 
					  const allSessions = useSessions()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return createMemo(() => {
 | 
					  return createMemo(() => {
 | 
				
			||||||
    const parts = acct().split("@", 2)
 | 
					 | 
				
			||||||
    const [inputUsername, inputSite] = acct().split("@", 2);
 | 
					    const [inputUsername, inputSite] = acct().split("@", 2);
 | 
				
			||||||
    const authedSession = allSessions().find(
 | 
					    const authedSession = allSessions().find(
 | 
				
			||||||
      (x) =>
 | 
					      (x) =>
 | 
				
			||||||
| 
						 | 
					@ -113,3 +131,5 @@ export function useSessionForAcctStr(acct: Accessor<string>) {
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -23,9 +23,20 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  box-shadow: var(--tutu-shadow-e16);
 | 
					  box-shadow: var(--tutu-shadow-e16);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  :global(.MuiToolbar-root) > :global(.MuiButtonBase-root):first-child {
 | 
					  :global(.MuiToolbar-root) {
 | 
				
			||||||
    margin-left: -0.5em;
 | 
					    > :global(.MuiButtonBase-root) {
 | 
				
			||||||
    margin-right: 24px;
 | 
					
 | 
				
			||||||
 | 
					      &:first-child {
 | 
				
			||||||
 | 
					        margin-left: -0.5em;
 | 
				
			||||||
 | 
					        margin-right: 24px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:last-child {
 | 
				
			||||||
 | 
					        margin-right: -0.5em;
 | 
				
			||||||
 | 
					        margin-left: 24px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @media (max-width: 560px) {
 | 
					  @media (max-width: 560px) {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										252
									
								
								src/profiles/Profile.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										252
									
								
								src/profiles/Profile.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,252 @@
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  createRenderEffect,
 | 
				
			||||||
 | 
					  createResource,
 | 
				
			||||||
 | 
					  createSignal,
 | 
				
			||||||
 | 
					  For,
 | 
				
			||||||
 | 
					  onCleanup,
 | 
				
			||||||
 | 
					  Show,
 | 
				
			||||||
 | 
					  type Component,
 | 
				
			||||||
 | 
					} from "solid-js";
 | 
				
			||||||
 | 
					import Scaffold from "../material/Scaffold";
 | 
				
			||||||
 | 
					import { AppBar, Avatar, Button, IconButton, Toolbar } from "@suid/material";
 | 
				
			||||||
 | 
					import { Close, MoreVert, Verified } from "@suid/icons-material";
 | 
				
			||||||
 | 
					import { Title } from "../material/typography";
 | 
				
			||||||
 | 
					import { useNavigate, useParams } from "@solidjs/router";
 | 
				
			||||||
 | 
					import { useSessionForAcctStr } from "../masto/clients";
 | 
				
			||||||
 | 
					import { resolveCustomEmoji } from "../masto/toot";
 | 
				
			||||||
 | 
					import { FastAverageColor } from "fast-average-color";
 | 
				
			||||||
 | 
					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";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const Profile: Component = () => {
 | 
				
			||||||
 | 
					  const navigate = useNavigate();
 | 
				
			||||||
 | 
					  const params = useParams<{ acct: string; id: string }>();
 | 
				
			||||||
 | 
					  const acctText = () => decodeURIComponent(params.acct);
 | 
				
			||||||
 | 
					  const session = useSessionForAcctStr(acctText);
 | 
				
			||||||
 | 
					  const [bannerSampledColors, setBannerSampledColors] = createSignal<{
 | 
				
			||||||
 | 
					    average: string;
 | 
				
			||||||
 | 
					    text: string;
 | 
				
			||||||
 | 
					  }>();
 | 
				
			||||||
 | 
					  const windowSize = useWindowSize();
 | 
				
			||||||
 | 
					  const time = createTimeSource();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [scrolledPastBanner, setScrolledPastBanner] = createSignal(false);
 | 
				
			||||||
 | 
					  const obx = new IntersectionObserver(
 | 
				
			||||||
 | 
					    (entries) => {
 | 
				
			||||||
 | 
					      const ent = entries[0];
 | 
				
			||||||
 | 
					      if (ent.intersectionRatio < 0.1) {
 | 
				
			||||||
 | 
					        setScrolledPastBanner(true);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        setScrolledPastBanner(false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      threshold: 0.1,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onCleanup(() => obx.disconnect());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [profile] = createResource(
 | 
				
			||||||
 | 
					    () => [session().client, params.id] as const,
 | 
				
			||||||
 | 
					    async ([client, id]) => {
 | 
				
			||||||
 | 
					      return await client.v1.accounts.$select(id).fetch();
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const [recentToots] = createTimeline(
 | 
				
			||||||
 | 
					    () => session().client.v1.accounts.$select(params.id).statuses,
 | 
				
			||||||
 | 
					    () => 20,
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const bannerImg = () => profile()?.header;
 | 
				
			||||||
 | 
					  const avatarImg = () => profile()?.avatar;
 | 
				
			||||||
 | 
					  const displayName = () =>
 | 
				
			||||||
 | 
					    resolveCustomEmoji(profile()?.displayName || "", profile()?.emojis ?? []);
 | 
				
			||||||
 | 
					  const fullUsername = () => `@${profile()?.acct ?? "..."}`; // TODO: full user name
 | 
				
			||||||
 | 
					  const description = () => profile()?.note;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  css`
 | 
				
			||||||
 | 
					    .intro {
 | 
				
			||||||
 | 
					      background-color: var(--tutu-color-surface-d);
 | 
				
			||||||
 | 
					      color: var(--tutu-color-on-surface);
 | 
				
			||||||
 | 
					      padding: 16px 12px;
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      flex-flow: column nowrap;
 | 
				
			||||||
 | 
					      gap: 16px;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .acct-grp {
 | 
				
			||||||
 | 
					      display: grid;
 | 
				
			||||||
 | 
					      grid-template-columns: auto 1fr auto;
 | 
				
			||||||
 | 
					      gap: 16px;
 | 
				
			||||||
 | 
					      align-items: center;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .name-grp {
 | 
				
			||||||
 | 
					      display: flex;
 | 
				
			||||||
 | 
					      flex-flow: column nowrap;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    table.acct-fields {
 | 
				
			||||||
 | 
					      & td > :global(a) {
 | 
				
			||||||
 | 
					        display: inline-flex;
 | 
				
			||||||
 | 
					        min-height: 44px;
 | 
				
			||||||
 | 
					        align-items: center;
 | 
				
			||||||
 | 
					        color: inherit;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      & :global(a > .invisible) {
 | 
				
			||||||
 | 
					        display: none;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      & :global(svg) {
 | 
				
			||||||
 | 
					        vertical-align: middle;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .page-title {
 | 
				
			||||||
 | 
					      flex-grow: 1;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Scaffold
 | 
				
			||||||
 | 
					      topbar={
 | 
				
			||||||
 | 
					        <AppBar
 | 
				
			||||||
 | 
					          position="static"
 | 
				
			||||||
 | 
					          color={scrolledPastBanner() ? "primary" : "transparent"}
 | 
				
			||||||
 | 
					          elevation={scrolledPastBanner() ? undefined : 0}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <Toolbar
 | 
				
			||||||
 | 
					            variant="dense"
 | 
				
			||||||
 | 
					            sx={{
 | 
				
			||||||
 | 
					              display: "flex",
 | 
				
			||||||
 | 
					              color: bannerSampledColors()?.text,
 | 
				
			||||||
 | 
					              paddingTop: "var(--safe-area-inset-top)",
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <IconButton color="inherit" onClick={[navigate, -1]}>
 | 
				
			||||||
 | 
					              <Close />
 | 
				
			||||||
 | 
					            </IconButton>
 | 
				
			||||||
 | 
					            <Title
 | 
				
			||||||
 | 
					              use:solid-styled
 | 
				
			||||||
 | 
					              class="page-title"
 | 
				
			||||||
 | 
					              style={{
 | 
				
			||||||
 | 
					                visibility: scrolledPastBanner() ? undefined : "hidden",
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
 | 
					              ref={(e: HTMLElement) =>
 | 
				
			||||||
 | 
					                createRenderEffect(() => (e.innerHTML = displayName()))
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            ></Title>
 | 
				
			||||||
 | 
					            <IconButton color="inherit">
 | 
				
			||||||
 | 
					              <MoreVert />
 | 
				
			||||||
 | 
					            </IconButton>
 | 
				
			||||||
 | 
					          </Toolbar>
 | 
				
			||||||
 | 
					        </AppBar>
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        style={{
 | 
				
			||||||
 | 
					          width: "100%",
 | 
				
			||||||
 | 
					          height: `${268 * (Math.min(560, windowSize.width) / 560)}px`,
 | 
				
			||||||
 | 
					          "margin-top":
 | 
				
			||||||
 | 
					            "calc(-1 * (var(--scaffold-topbar-height) + var(--safe-area-inset-top)))",
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <img
 | 
				
			||||||
 | 
					          ref={(e) => obx.observe(e)}
 | 
				
			||||||
 | 
					          src={bannerImg()}
 | 
				
			||||||
 | 
					          style={{
 | 
				
			||||||
 | 
					            "object-fit": "contain",
 | 
				
			||||||
 | 
					            width: "100%",
 | 
				
			||||||
 | 
					            height: "100%",
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					          crossOrigin="anonymous"
 | 
				
			||||||
 | 
					          onLoad={async (event) => {
 | 
				
			||||||
 | 
					            const ins = new FastAverageColor();
 | 
				
			||||||
 | 
					            const colors = ins.getColor(event.currentTarget);
 | 
				
			||||||
 | 
					            setBannerSampledColors({
 | 
				
			||||||
 | 
					              average: colors.hex,
 | 
				
			||||||
 | 
					              text: colors.isDark ? "white" : "black",
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        ></img>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        class="intro"
 | 
				
			||||||
 | 
					        style={{
 | 
				
			||||||
 | 
					          "background-color": bannerSampledColors()?.average,
 | 
				
			||||||
 | 
					          color: bannerSampledColors()?.text,
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <div class="acct-grp">
 | 
				
			||||||
 | 
					          <Avatar
 | 
				
			||||||
 | 
					            src={avatarImg()}
 | 
				
			||||||
 | 
					            sx={{
 | 
				
			||||||
 | 
					              marginTop: "calc(-16px - 72px / 2)",
 | 
				
			||||||
 | 
					              width: "72px",
 | 
				
			||||||
 | 
					              height: "72px",
 | 
				
			||||||
 | 
					            }}
 | 
				
			||||||
 | 
					          ></Avatar>
 | 
				
			||||||
 | 
					          <div class="name-grp">
 | 
				
			||||||
 | 
					            <span
 | 
				
			||||||
 | 
					              ref={(e) =>
 | 
				
			||||||
 | 
					                createRenderEffect(() => (e.innerHTML = displayName()))
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            ></span>
 | 
				
			||||||
 | 
					            <span>{fullUsername()}</span>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div>
 | 
				
			||||||
 | 
					            <Button variant="contained" color="secondary">
 | 
				
			||||||
 | 
					              Subscribe
 | 
				
			||||||
 | 
					            </Button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          ref={(e) =>
 | 
				
			||||||
 | 
					            createRenderEffect(() => (e.innerHTML = description() || ""))
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        ></div>
 | 
				
			||||||
 | 
					        <table class="acct-fields">
 | 
				
			||||||
 | 
					          <tbody>
 | 
				
			||||||
 | 
					            <For each={profile()?.fields ?? []}>
 | 
				
			||||||
 | 
					              {(item, index) => {
 | 
				
			||||||
 | 
					                return (
 | 
				
			||||||
 | 
					                  <tr data-field-index={index()}>
 | 
				
			||||||
 | 
					                    <td>{item.name}</td>
 | 
				
			||||||
 | 
					                    <td>
 | 
				
			||||||
 | 
					                      <Show when={item.verifiedAt}>
 | 
				
			||||||
 | 
					                        <Verified />
 | 
				
			||||||
 | 
					                      </Show>
 | 
				
			||||||
 | 
					                    </td>
 | 
				
			||||||
 | 
					                    <td
 | 
				
			||||||
 | 
					                      ref={(e) => {
 | 
				
			||||||
 | 
					                        createRenderEffect(() => (e.innerHTML = item.value));
 | 
				
			||||||
 | 
					                      }}
 | 
				
			||||||
 | 
					                    ></td>
 | 
				
			||||||
 | 
					                  </tr>
 | 
				
			||||||
 | 
					                );
 | 
				
			||||||
 | 
					              }}
 | 
				
			||||||
 | 
					            </For>
 | 
				
			||||||
 | 
					          </tbody>
 | 
				
			||||||
 | 
					        </table>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <TimeSourceProvider value={time}>
 | 
				
			||||||
 | 
					        <TootList
 | 
				
			||||||
 | 
					          threads={recentToots.list}
 | 
				
			||||||
 | 
					          onUnknownThread={recentToots.getPath}
 | 
				
			||||||
 | 
					          onChangeToot={recentToots.set}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </TimeSourceProvider>
 | 
				
			||||||
 | 
					    </Scaffold>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default Profile;
 | 
				
			||||||
| 
						 | 
					@ -213,7 +213,7 @@ const Home: ParentComponent = (props) => {
 | 
				
			||||||
                  Public
 | 
					                  Public
 | 
				
			||||||
                </Tab>
 | 
					                </Tab>
 | 
				
			||||||
              </Tabs>
 | 
					              </Tabs>
 | 
				
			||||||
              <ProfileMenuButton profile={profile()}>
 | 
					              <ProfileMenuButton profile={profiles()[0]}>
 | 
				
			||||||
                <MenuItem
 | 
					                <MenuItem
 | 
				
			||||||
                  onClick={(e) =>
 | 
					                  onClick={(e) =>
 | 
				
			||||||
                    $settings.setKey(
 | 
					                    $settings.setKey(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -24,7 +24,10 @@ import {
 | 
				
			||||||
import { A } from "@solidjs/router";
 | 
					import { A } from "@solidjs/router";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const ProfileMenuButton: ParentComponent<{
 | 
					const ProfileMenuButton: ParentComponent<{
 | 
				
			||||||
  profile?: { displayName: string; avatar: string; username: string };
 | 
					  profile?: {
 | 
				
			||||||
 | 
					    account: { site: string };
 | 
				
			||||||
 | 
					    inf?: { displayName: string; avatar: string; username: string; id: string };
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
  onClick?: () => void;
 | 
					  onClick?: () => void;
 | 
				
			||||||
  onClose?: () => void;
 | 
					  onClose?: () => void;
 | 
				
			||||||
}> = (props) => {
 | 
					}> = (props) => {
 | 
				
			||||||
| 
						 | 
					@ -48,79 +51,83 @@ const ProfileMenuButton: ParentComponent<{
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <>
 | 
					    <>
 | 
				
			||||||
        <ButtonBase
 | 
					      <ButtonBase
 | 
				
			||||||
          aria-haspopup="true"
 | 
					        aria-haspopup="true"
 | 
				
			||||||
          sx={{ borderRadius: "50%" }}
 | 
					        sx={{ borderRadius: "50%" }}
 | 
				
			||||||
          id={buttonId}
 | 
					        id={buttonId}
 | 
				
			||||||
          onClick={onClick}
 | 
					        onClick={onClick}
 | 
				
			||||||
          aria-controls={open() ? menuId : undefined}
 | 
					        aria-controls={open() ? menuId : undefined}
 | 
				
			||||||
          aria-expanded={open() ? "true" : undefined}
 | 
					        aria-expanded={open() ? "true" : undefined}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <Avatar
 | 
				
			||||||
 | 
					          alt={`${props.profile?.inf?.displayName}'s avatar`}
 | 
				
			||||||
 | 
					          src={props.profile?.inf?.avatar}
 | 
				
			||||||
 | 
					        ></Avatar>
 | 
				
			||||||
 | 
					      </ButtonBase>
 | 
				
			||||||
 | 
					      <Menu
 | 
				
			||||||
 | 
					        id={menuId}
 | 
				
			||||||
 | 
					        anchorEl={anchor()}
 | 
				
			||||||
 | 
					        open={open()}
 | 
				
			||||||
 | 
					        onClose={onClose}
 | 
				
			||||||
 | 
					        MenuListProps={{
 | 
				
			||||||
 | 
					          "aria-labelledby": buttonId,
 | 
				
			||||||
 | 
					          sx: {
 | 
				
			||||||
 | 
					            minWidth: "220px",
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					        anchorOrigin={{
 | 
				
			||||||
 | 
					          vertical: "top",
 | 
				
			||||||
 | 
					          horizontal: "right",
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					        transformOrigin={{
 | 
				
			||||||
 | 
					          vertical: "top",
 | 
				
			||||||
 | 
					          horizontal: "right",
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <MenuItem
 | 
				
			||||||
 | 
					          component={A}
 | 
				
			||||||
 | 
					          href={`/${encodeURIComponent(`${props.profile?.inf?.username}@${props.profile?.account.site}`)}/profile/${props.profile?.inf?.id}`}
 | 
				
			||||||
 | 
					          disabled={!props.profile}
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <Avatar
 | 
					          <ListItemAvatar>
 | 
				
			||||||
            alt={`${props.profile?.displayName}'s avatar`}
 | 
					            <Avatar src={props.profile?.inf?.avatar}></Avatar>
 | 
				
			||||||
            src={props.profile?.avatar}
 | 
					          </ListItemAvatar>
 | 
				
			||||||
          ></Avatar>
 | 
					          <ListItemText
 | 
				
			||||||
        </ButtonBase>
 | 
					            primary={props.profile?.inf?.displayName}
 | 
				
			||||||
        <Menu
 | 
					            secondary={`@${props.profile?.inf?.username}`}
 | 
				
			||||||
          id={menuId}
 | 
					          ></ListItemText>
 | 
				
			||||||
          anchorEl={anchor()}
 | 
					        </MenuItem>
 | 
				
			||||||
          open={open()}
 | 
					 | 
				
			||||||
          onClose={onClose}
 | 
					 | 
				
			||||||
          MenuListProps={{
 | 
					 | 
				
			||||||
            "aria-labelledby": buttonId,
 | 
					 | 
				
			||||||
            sx: {
 | 
					 | 
				
			||||||
              minWidth: "220px",
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
          anchorOrigin={{
 | 
					 | 
				
			||||||
            vertical: "top",
 | 
					 | 
				
			||||||
            horizontal: "right",
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
          transformOrigin={{
 | 
					 | 
				
			||||||
            vertical: "top",
 | 
					 | 
				
			||||||
            horizontal: "right",
 | 
					 | 
				
			||||||
          }}
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <MenuItem>
 | 
					 | 
				
			||||||
            <ListItemAvatar>
 | 
					 | 
				
			||||||
              <Avatar src={props.profile?.avatar}></Avatar>
 | 
					 | 
				
			||||||
            </ListItemAvatar>
 | 
					 | 
				
			||||||
            <ListItemText
 | 
					 | 
				
			||||||
              primary={props.profile?.displayName}
 | 
					 | 
				
			||||||
              secondary={`@${props.profile?.username}`}
 | 
					 | 
				
			||||||
            ></ListItemText>
 | 
					 | 
				
			||||||
          </MenuItem>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <MenuItem>
 | 
					        <MenuItem>
 | 
				
			||||||
            <ListItemIcon>
 | 
					          <ListItemIcon>
 | 
				
			||||||
              <BookmarkIcon />
 | 
					            <BookmarkIcon />
 | 
				
			||||||
            </ListItemIcon>
 | 
					          </ListItemIcon>
 | 
				
			||||||
            <ListItemText>Bookmarks</ListItemText>
 | 
					          <ListItemText>Bookmarks</ListItemText>
 | 
				
			||||||
          </MenuItem>
 | 
					        </MenuItem>
 | 
				
			||||||
          <MenuItem>
 | 
					        <MenuItem>
 | 
				
			||||||
            <ListItemIcon>
 | 
					          <ListItemIcon>
 | 
				
			||||||
              <LikeIcon />
 | 
					            <LikeIcon />
 | 
				
			||||||
            </ListItemIcon>
 | 
					          </ListItemIcon>
 | 
				
			||||||
            <ListItemText>Likes</ListItemText>
 | 
					          <ListItemText>Likes</ListItemText>
 | 
				
			||||||
          </MenuItem>
 | 
					        </MenuItem>
 | 
				
			||||||
          <MenuItem>
 | 
					        <MenuItem>
 | 
				
			||||||
            <ListItemIcon>
 | 
					          <ListItemIcon>
 | 
				
			||||||
              <ListIcon />
 | 
					            <ListIcon />
 | 
				
			||||||
            </ListItemIcon>
 | 
					          </ListItemIcon>
 | 
				
			||||||
            <ListItemText>Lists</ListItemText>
 | 
					          <ListItemText>Lists</ListItemText>
 | 
				
			||||||
          </MenuItem>
 | 
					        </MenuItem>
 | 
				
			||||||
 | 
					        <Divider />
 | 
				
			||||||
 | 
					        <Show when={props.children}>
 | 
				
			||||||
 | 
					          {props.children}
 | 
				
			||||||
          <Divider />
 | 
					          <Divider />
 | 
				
			||||||
          <Show when={props.children}>
 | 
					        </Show>
 | 
				
			||||||
            {props.children}
 | 
					        <MenuItem component={A} href="/settings" onClick={onClose}>
 | 
				
			||||||
            <Divider />
 | 
					          <ListItemIcon>
 | 
				
			||||||
          </Show>
 | 
					            <SettingsIcon />
 | 
				
			||||||
          <MenuItem component={A} href="/settings" onClick={onClose}>
 | 
					          </ListItemIcon>
 | 
				
			||||||
            <ListItemIcon>
 | 
					          <ListItemText>Settings</ListItemText>
 | 
				
			||||||
              <SettingsIcon />
 | 
					        </MenuItem>
 | 
				
			||||||
            </ListItemIcon>
 | 
					      </Menu>
 | 
				
			||||||
            <ListItemText>Settings</ListItemText>
 | 
					 | 
				
			||||||
          </MenuItem>
 | 
					 | 
				
			||||||
        </Menu>
 | 
					 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										104
									
								
								src/timelines/TootList.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								src/timelines/TootList.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,104 @@
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  Component,
 | 
				
			||||||
 | 
					  For,
 | 
				
			||||||
 | 
					  onCleanup,
 | 
				
			||||||
 | 
					  createSignal,
 | 
				
			||||||
 | 
					  Show,
 | 
				
			||||||
 | 
					  untrack,
 | 
				
			||||||
 | 
					  Match,
 | 
				
			||||||
 | 
					  Switch as JsSwitch,
 | 
				
			||||||
 | 
					  ErrorBoundary,
 | 
				
			||||||
 | 
					  type Ref,
 | 
				
			||||||
 | 
					} 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";
 | 
				
			||||||
 | 
					import { useDefaultSession } from "../masto/clients";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const TootList: Component<{
 | 
				
			||||||
 | 
					  ref?: Ref<HTMLDivElement>;
 | 
				
			||||||
 | 
					  threads: string[];
 | 
				
			||||||
 | 
					  onUnknownThread: (id: string) => { value: mastodon.v1.Status }[] | undefined;
 | 
				
			||||||
 | 
					  onChangeToot: (id: string, value: mastodon.v1.Status) => void;
 | 
				
			||||||
 | 
					}> = (props) => {
 | 
				
			||||||
 | 
					  const session = useDefaultSession();
 | 
				
			||||||
 | 
					  const [expandedThreadId, setExpandedThreadId] = createSignal<string>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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());
 | 
				
			||||||
 | 
					    props.onChangeToot(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 };
 | 
				
			||||||
 | 
					      props.onChangeToot(status.id, status);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      props.onChangeToot(
 | 
				
			||||||
 | 
					        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!;
 | 
				
			||||||
 | 
					    props.onChangeToot(
 | 
				
			||||||
 | 
					      status.id,
 | 
				
			||||||
 | 
					      Object.assign(status.reblog ?? status, result.reblog),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <ErrorBoundary
 | 
				
			||||||
 | 
					      fallback={(err, reset) => {
 | 
				
			||||||
 | 
					        return <p>Oops: {String(err)}</p>;
 | 
				
			||||||
 | 
					      }}
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <div ref={props.ref}>
 | 
				
			||||||
 | 
					        <For each={props.threads}>
 | 
				
			||||||
 | 
					          {(itemId, index) => {
 | 
				
			||||||
 | 
					            const path = props.onUnknownThread(itemId)!;
 | 
				
			||||||
 | 
					            const toots = path.reverse().map((x) => x.value);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            return (
 | 
				
			||||||
 | 
					              <Thread
 | 
				
			||||||
 | 
					                toots={toots}
 | 
				
			||||||
 | 
					                onBoost={onBoost}
 | 
				
			||||||
 | 
					                onBookmark={onBookmark}
 | 
				
			||||||
 | 
					                onReply={({ status }, element) => {}}
 | 
				
			||||||
 | 
					                client={session()?.client!}
 | 
				
			||||||
 | 
					                isExpended={(status) => status.id === expandedThreadId()}
 | 
				
			||||||
 | 
					                onItemClick={(status, event) => {
 | 
				
			||||||
 | 
					                  if (status.id !== expandedThreadId()) {
 | 
				
			||||||
 | 
					                    setExpandedThreadId((x) => (x ? undefined : status.id));
 | 
				
			||||||
 | 
					                  } else {
 | 
				
			||||||
 | 
					                    // TODO: open full-screen toot
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					              />
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					          }}
 | 
				
			||||||
 | 
					        </For>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </ErrorBoundary>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default TootList;
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue