diff --git a/src/App.tsx b/src/App.tsx index 399d257..028a234 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,9 +24,7 @@ import { ResultDispatcher, type JSONRPC, } from "./serviceworker/workerrpc.js"; -import { - Service -} from "./serviceworker/services.js" +import { Service } from "./serviceworker/services.js"; import { makeEventListener } from "@solid-primitives/event-listener"; 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 RegionSettings = lazy(() => import("./settings/Region.jsx")); const UnexpectedError = lazy(() => import("./UnexpectedError.js")); +const Profile = lazy(() => import("./profiles/Profile.js")); const Routing: Component = () => { return ( @@ -54,6 +53,7 @@ const Routing: Component = () => { + diff --git a/src/masto/clients.ts b/src/masto/clients.ts index ebe7deb..d9f255d 100644 --- a/src/masto/clients.ts +++ b/src/masto/clients.ts @@ -4,7 +4,6 @@ import { createMemo, createRenderEffect, createResource, - Signal, useContext, } from "solid-js"; import { Account } from "../accounts/stores"; @@ -78,6 +77,26 @@ function useSessionsRaw() { return store; } +const DefaultSessionContext = /* @__PURE__ */ createContext>(() => 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. * @@ -98,7 +117,6 @@ export function useSessionForAcctStr(acct: Accessor) { const allSessions = useSessions() return createMemo(() => { - const parts = acct().split("@", 2) const [inputUsername, inputSite] = acct().split("@", 2); const authedSession = allSessions().find( (x) => @@ -113,3 +131,5 @@ export function useSessionForAcctStr(acct: Accessor) { ); }); } + + diff --git a/src/material/BottomSheet.module.css b/src/material/BottomSheet.module.css index 154de26..fad6682 100644 --- a/src/material/BottomSheet.module.css +++ b/src/material/BottomSheet.module.css @@ -23,9 +23,20 @@ box-shadow: var(--tutu-shadow-e16); - :global(.MuiToolbar-root) > :global(.MuiButtonBase-root):first-child { - margin-left: -0.5em; - margin-right: 24px; + :global(.MuiToolbar-root) { + > :global(.MuiButtonBase-root) { + + &:first-child { + margin-left: -0.5em; + margin-right: 24px; + } + + &:last-child { + margin-right: -0.5em; + margin-left: 24px; + } + + } } @media (max-width: 560px) { diff --git a/src/profiles/Profile.tsx b/src/profiles/Profile.tsx new file mode 100644 index 0000000..fff795d --- /dev/null +++ b/src/profiles/Profile.tsx @@ -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 ( + + + + + + + createRenderEffect(() => (e.innerHTML = displayName())) + } + > + + + + + + } + > +
+ 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", + }); + }} + > +
+ +
+
+ +
+ + createRenderEffect(() => (e.innerHTML = displayName())) + } + > + {fullUsername()} +
+
+ +
+
+
+ createRenderEffect(() => (e.innerHTML = description() || "")) + } + >
+ + + + {(item, index) => { + return ( + + + + + + ); + }} + + +
{item.name} + + + + { + createRenderEffect(() => (e.innerHTML = item.value)); + }} + >
+
+ + + + +
+ ); +}; + +export default Profile; diff --git a/src/timelines/Home.tsx b/src/timelines/Home.tsx index 88ba5bf..d229da8 100644 --- a/src/timelines/Home.tsx +++ b/src/timelines/Home.tsx @@ -213,7 +213,7 @@ const Home: ParentComponent = (props) => { Public - + $settings.setKey( diff --git a/src/timelines/ProfileMenuButton.tsx b/src/timelines/ProfileMenuButton.tsx index 4fe8f04..6d407f8 100644 --- a/src/timelines/ProfileMenuButton.tsx +++ b/src/timelines/ProfileMenuButton.tsx @@ -24,7 +24,10 @@ import { import { A } from "@solidjs/router"; 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; onClose?: () => void; }> = (props) => { @@ -48,79 +51,83 @@ const ProfileMenuButton: ParentComponent<{ return ( <> - + + + + - - - - - - - - - + + + + + - - - - - Bookmarks - - - - - - Likes - - - - - - Lists - + + + + + Bookmarks + + + + + + Likes + + + + + + Lists + + + + {props.children} - - {props.children} - - - - - - - Settings - - + + + + + + Settings + + ); }; diff --git a/src/timelines/TootList.tsx b/src/timelines/TootList.tsx new file mode 100644 index 0000000..a72fb65 --- /dev/null +++ b/src/timelines/TootList.tsx @@ -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; + 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(); + + 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 ( + { + return

Oops: {String(err)}

; + }} + > +
+ + {(itemId, index) => { + const path = props.onUnknownThread(itemId)!; + const toots = path.reverse().map((x) => x.value); + + return ( + {}} + 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 + } + }} + /> + ); + }} + +
+
+ ); +}; + +export default TootList;