Compare commits

...

2 commits

Author SHA1 Message Date
thislight
85ac9a236b
ci: build staging bundle for preview
All checks were successful
/ depoly (push) Successful in 1m18s
2024-07-22 21:59:37 +08:00
thislight
71b9a60b35
added settings 2024-07-22 21:57:04 +08:00
16 changed files with 364 additions and 91 deletions

View file

@ -28,8 +28,13 @@ jobs:
- name: Install Dependencies - name: Install Dependencies
run: pnpm i run: pnpm i
- name: Build Dist (Staging)
run: pnpm dist -m staging
if: env.GITHUB_REF_NAME == 'master'
- name: Build Dist - name: Build Dist
run: pnpm dist run: pnpm dist
if: env.GITHUB_REF_NAME != 'master'
- name: Depoly to Preview - name: Depoly to Preview
uses: https://github.com/cloudflare/wrangler-action@v3 uses: https://github.com/cloudflare/wrangler-action@v3

View file

@ -20,6 +20,7 @@
"prettier": "^3.3.2", "prettier": "^3.3.2",
"typescript": "^5.5.2", "typescript": "^5.5.2",
"vite": "^5.3.2", "vite": "^5.3.2",
"vite-plugin-package-version": "^1.1.0",
"vite-plugin-pwa": "^0.20.0", "vite-plugin-pwa": "^0.20.0",
"vite-plugin-solid": "^2.10.2", "vite-plugin-solid": "^2.10.2",
"vite-plugin-solid-styled": "^0.11.1", "vite-plugin-solid-styled": "^0.11.1",

View file

@ -69,6 +69,9 @@ importers:
vite: vite:
specifier: ^5.3.2 specifier: ^5.3.2
version: 5.3.2(@types/node@20.14.10)(lightningcss@1.25.1)(terser@5.31.2) version: 5.3.2(@types/node@20.14.10)(lightningcss@1.25.1)(terser@5.31.2)
vite-plugin-package-version:
specifier: ^1.1.0
version: 1.1.0(vite@5.3.2(@types/node@20.14.10)(lightningcss@1.25.1)(terser@5.31.2))
vite-plugin-pwa: vite-plugin-pwa:
specifier: ^0.20.0 specifier: ^0.20.0
version: 0.20.0(vite@5.3.2(@types/node@20.14.10)(lightningcss@1.25.1)(terser@5.31.2))(workbox-build@7.1.1(@types/babel__core@7.20.5))(workbox-window@7.1.0) version: 0.20.0(vite@5.3.2(@types/node@20.14.10)(lightningcss@1.25.1)(terser@5.31.2))(workbox-build@7.1.1(@types/babel__core@7.20.5))(workbox-window@7.1.0)
@ -2489,6 +2492,11 @@ packages:
validate-html-nesting@1.2.2: validate-html-nesting@1.2.2:
resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==} resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==}
vite-plugin-package-version@1.1.0:
resolution: {integrity: sha512-TPoFZXNanzcaKCIrC3e2L/TVRkkRLB6l4RPN/S7KbG7rWfyLcCEGsnXvxn6qR7fyZwXalnnSN/I9d6pSFjHpEA==}
peerDependencies:
vite: '>=2.0.0-beta.69'
vite-plugin-pwa@0.20.0: vite-plugin-pwa@0.20.0:
resolution: {integrity: sha512-/kDZyqF8KqoXRpMUQtR5Atri/7BWayW8Gp7Kz/4bfstsV6zSFTxjREbXZYL7zSuRL40HGA+o2hvUAFRmC+bL7g==} resolution: {integrity: sha512-/kDZyqF8KqoXRpMUQtR5Atri/7BWayW8Gp7Kz/4bfstsV6zSFTxjREbXZYL7zSuRL40HGA+o2hvUAFRmC+bL7g==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@ -5280,6 +5288,10 @@ snapshots:
validate-html-nesting@1.2.2: {} validate-html-nesting@1.2.2: {}
vite-plugin-package-version@1.1.0(vite@5.3.2(@types/node@20.14.10)(lightningcss@1.25.1)(terser@5.31.2)):
dependencies:
vite: 5.3.2(@types/node@20.14.10)(lightningcss@1.25.1)(terser@5.31.2)
vite-plugin-pwa@0.20.0(vite@5.3.2(@types/node@20.14.10)(lightningcss@1.25.1)(terser@5.31.2))(workbox-build@7.1.1(@types/babel__core@7.20.5))(workbox-window@7.1.0): vite-plugin-pwa@0.20.0(vite@5.3.2(@types/node@20.14.10)(lightningcss@1.25.1)(terser@5.31.2))(workbox-build@7.1.1(@types/babel__core@7.20.5))(workbox-window@7.1.0):
dependencies: dependencies:
debug: 4.3.5 debug: 4.3.5

View file

@ -1,14 +1,4 @@
html, :root {
body {
overflow: hidden;
height: 100vh;
height: 100dvh;
}
#root {
overflow: hidden hidden;
height: 100vh;
height: 100dvh;
background-color: var(--tutu-color-surface, transparent); background-color: var(--tutu-color-surface, transparent);
} }

View file

@ -22,11 +22,15 @@ const AccountMastodonOAuth2Callback = lazy(
() => import("./accounts/MastodonOAuth2Callback.js"), () => import("./accounts/MastodonOAuth2Callback.js"),
); );
const TimelineHome = lazy(() => import("./timelines/Home.js")); const TimelineHome = lazy(() => import("./timelines/Home.js"));
const Settings = lazy(() => import("./settings/Settings.js"));
const Routing: Component = () => { const Routing: Component = () => {
return ( return (
<Router> <Router>
<Route path="/" component={TimelineHome}></Route> <Route path="/" component={TimelineHome}>
<Route path=""></Route>
<Route path="/settings" component={Settings}></Route>
</Route>
<Route path={"/accounts"}> <Route path={"/accounts"}>
<Route path={"/sign-in"} component={AccountSignIn} /> <Route path={"/sign-in"} component={AccountSignIn} />
<Route <Route

View file

@ -1,6 +1,11 @@
import { persistentAtom } from "@nanostores/persistent"; import { persistentAtom } from "@nanostores/persistent";
import { createOAuthAPIClient, createRestAPIClient } from "masto"; import {
createOAuthAPIClient,
createRestAPIClient,
type mastodon,
} from "masto";
import { action } from "nanostores"; import { action } from "nanostores";
import { createMastoClientFor } from "../masto/clients";
export type Account = { export type Account = {
site: string; site: string;
@ -9,6 +14,8 @@ export type Account = {
tokenType: string; tokenType: string;
scope: string; scope: string;
createdAt: number; createdAt: number;
inf?: mastodon.v1.AccountCredentials;
}; };
export const $accounts = persistentAtom<Account[]>("accounts", [], { export const $accounts = persistentAtom<Account[]>("accounts", [], {
@ -75,6 +82,23 @@ export const acceptAccountViaAuthCode = action(
}, },
); );
export const updateAcctInf = action(
$accounts,
"updateAcctInf",
async ($store, idx: number) => {
const o = $store.get();
const client = createMastoClientFor(o[idx]);
const inf = await client.v1.accounts.verifyCredentials();
o[idx].inf = inf;
$store.set(o);
return inf;
},
);
export const signOut = action($accounts, "signOut", ($store, predicate: (acct: Account) => boolean) => {
$store.set($store.get().filter(a => !predicate(a)));
});
export type RegisteredApp = { export type RegisteredApp = {
site: string; site: string;
clientId: string; clientId: string;

View file

@ -1,10 +1,35 @@
import { Accessor, createResource } from "solid-js"; import { Accessor, createResource } from "solid-js";
import type { mastodon } from "masto"; import type { mastodon } from "masto";
import { useSessions } from "./clients";
import { updateAcctInf } from "../accounts/stores";
export function useAcctProfile(client: Accessor<mastodon.rest.Client>) { export function useAcctProfile(client: Accessor<mastodon.rest.Client>) {
return createResource(client, (client) => { return createResource(
return client.v1.accounts.verifyCredentials() client,
}, { (client) => {
name: "MastodonAccountProfile" return client.v1.accounts.verifyCredentials();
}) },
{
name: "MastodonAccountProfile",
},
);
}
export function useSignedInProfiles() {
const sessions = useSessions();
const [accessor, tools] = createResource(sessions, async (all) => {
return Promise.all(
all.map(async (x, i) => ({ ...x, inf: await updateAcctInf(i) })),
);
});
return [
() => {
if (accessor.loading) {
accessor();
return sessions().map((x) => ({ ...x, inf: x.account.inf }));
}
return accessor();
},
tools,
] as const;
} }

View file

@ -69,7 +69,7 @@ export function useSessions() {
return sessions; return sessions;
} }
export function useSessionsRw() { function useSessionsRw() {
const store = useContext(Context); const store = useContext(Context);
if (!store) { if (!store) {
throw new TypeError("sessions are not provided"); throw new TypeError("sessions are not provided");

View file

@ -0,0 +1,32 @@
.bottomSheet {
composes: surface from 'material.module.css';
border: none;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
padding: 0;
width: 100%;
max-width: 560px;
border-radius: 2px;
overscroll-behavior: contain;
box-shadow: var(--tutu-shadow-e16);
:global(.MuiToolbar-root) > :global(.MuiButtonBase-root):first-child {
color: white;
margin-left: -0.5em;
margin-right: 24px;
}
@media (max-width: 560px) {
& {
left: 0;
top: 0;
transform: none;
bottom: 0;
height: 100vh;
height: 100dvh;
}
}
}

View file

@ -0,0 +1,26 @@
import { createEffect, type ParentComponent } from "solid-js";
import styles from './BottomSheet.module.css'
export type BottomSheetProps = {
open?: boolean;
};
const BottomSheet: ParentComponent<BottomSheetProps> = (props) => {
let element: HTMLDialogElement;
createEffect(() => {
if (props.open) {
if (!element.open) {
element.showModal();
}
} else {
if (element.open) {
element.close();
}
}
});
return <dialog class={styles.bottomSheet} ref={element!}>{props.children}</dialog>;
};
export default BottomSheet;

View file

@ -22,9 +22,6 @@ const Scaffold: ParentComponent<ScaffoldProps> = (props) => {
css` css`
.scaffold-content { .scaffold-content {
--scaffold-topbar-height: ${(topbarSize.height?.toString() ?? 0) + "px"}; --scaffold-topbar-height: ${(topbarSize.height?.toString() ?? 0) + "px"};
height: 100%;
width: 100%;
} }
.topbar { .topbar {

10
src/overrides.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly BUILT_AT: string;
readonly PACKAGE_VERSION: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

137
src/settings/Settings.tsx Normal file
View file

@ -0,0 +1,137 @@
import { createResource, For, type ParentComponent } from "solid-js";
import Scaffold from "../material/Scaffold.js";
import {
AppBar,
Divider,
IconButton,
List,
ListItem,
ListItemButton,
ListItemSecondaryAction,
ListItemText,
ListSubheader,
NativeSelect,
Select,
Switch,
Toolbar,
} from "@suid/material";
import { Close as CloseIcon } from "@suid/icons-material";
import { useNavigate } from "@solidjs/router";
import { Title } from "../material/typography.jsx";
import { useSessions } from "../masto/clients.js";
import { css } from "solid-styled";
import { useSignedInProfiles } from "../masto/acct.js";
import { signOut, type Account } from "../accounts/stores.js";
import { intlFormat } from "date-fns";
const Settings: ParentComponent = () => {
const navigate = useNavigate();
const [profiles] = useSignedInProfiles();
const doSignOut = (acct: Account) => {
signOut((a) => a.site === acct.site && a.accessToken === acct.accessToken);
};
css`
ul {
padding: 0;
}
.setting-list {
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
}
`;
return (
<Scaffold
topbar={
<AppBar position="static">
<Toolbar variant="dense">
<IconButton onClick={[navigate, -1]}>
<CloseIcon />
</IconButton>
<Title>Settings</Title>
</Toolbar>
</AppBar>
}
>
<List class="setting-list" use:solid-styled>
<li>
<ul>
<ListSubheader>Accounts</ListSubheader>
<ListItem>
<ListItemText>All Notifications</ListItemText>
<ListItemSecondaryAction>
<Switch />
</ListItemSecondaryAction>
</ListItem>
<ListItem>
<ListItemText>Sign in...</ListItemText>
</ListItem>
</ul>
<For each={profiles()}>
{({ account: acct, inf }) => (
<ul data-site={acct.site} data-username={inf?.username}>
<ListSubheader>{`@${inf?.username ?? "..."}@${new URL(acct.site).host}`}</ListSubheader>
<ListItem>
<ListItemText>Notifications</ListItemText>
<ListItemSecondaryAction>
<Switch />
</ListItemSecondaryAction>
</ListItem>
<ListItemButton onClick={[doSignOut, acct]}>
<ListItemText>Sign out</ListItemText>
</ListItemButton>
</ul>
)}
</For>
</li>
<li>
<ListSubheader>Reading</ListSubheader>
<ListItem>
<ListItemText secondary="Regular">Fonts</ListItemText>
</ListItem>
<ListItem>
<ListItemText secondary="Tutu will download toots before you scroll to the position.">
Prefetch Toots
</ListItemText>
<ListItemSecondaryAction>
<Switch />
</ListItemSecondaryAction>
</ListItem>
</li>
<li>
<ListSubheader>Controls</ListSubheader>
<ListItem>
<ListItemText>Optimized UI</ListItemText>
<ListItemSecondaryAction>
<NativeSelect value="auto">
<option value="auto">Tutu Decides (Mouse)</option>
<option value="mouse">Mouse</option>
<option value="touch">Touch</option>
<option value="controlpad">Control Pad</option>
</NativeSelect>
</ListItemSecondaryAction>
</ListItem>
</li>
<li>
<ListSubheader>This Application</ListSubheader>
<ListItem>
<ListItemText secondary="Comformtable tooting experience.">
About Tutu
</ListItemText>
</ListItem>
<ListItem>
<ListItemText
secondary={`Using v${import.meta.env.PACKAGE_VERSION} (built on ${intlFormat(import.meta.env.BUILT_AT)}, ${import.meta.env.MODE})`}
>
No updates
</ListItemText>
</ListItem>
</li>
</List>
</Scaffold>
);
};
export default Settings;

View file

@ -6,9 +6,11 @@ import {
Show, Show,
untrack, untrack,
onMount, onMount,
type ParentComponent,
children,
} from "solid-js"; } from "solid-js";
import { useDocumentTitle } from "../utils"; import { useDocumentTitle } from "../utils";
import { useSessions } from "../masto/clients"; import { useSessions } from "../masto/clients";
import { type mastodon } from "masto"; import { type mastodon } from "masto";
import Scaffold from "../material/Scaffold"; import Scaffold from "../material/Scaffold";
import { import {
@ -32,6 +34,7 @@ import Tab from "../material/Tab";
import { Create as CreateTootIcon } from "@suid/icons-material"; import { Create as CreateTootIcon } from "@suid/icons-material";
import { useTimeline } from "../masto/timelines"; import { useTimeline } from "../masto/timelines";
import { makeEventListener } from "@solid-primitives/event-listener"; import { makeEventListener } from "@solid-primitives/event-listener";
import BottomSheet from "../material/BottomSheet";
const TimelinePanel: Component<{ const TimelinePanel: Component<{
client: mastodon.rest.Client; client: mastodon.rest.Client;
@ -145,7 +148,7 @@ const TimelinePanel: Component<{
); );
}; };
const Home: Component = () => { const Home: ParentComponent = (props) => {
let panelList: HTMLDivElement; let panelList: HTMLDivElement;
useDocumentTitle("Timelines"); useDocumentTitle("Timelines");
const now = createTimeSource(); const now = createTimeSource();
@ -162,6 +165,8 @@ const Home: Component = () => {
number, number,
]); ]);
const child = children(() => props.children)
let scrollEventLockReleased = true; let scrollEventLockReleased = true;
const recalculateTabIndicator = () => { const recalculateTabIndicator = () => {
@ -221,7 +226,7 @@ const Home: Component = () => {
const onTabClick = (idx: number) => { const onTabClick = (idx: number) => {
const items = panelList.querySelectorAll(".tab-panel"); const items = panelList.querySelectorAll(".tab-panel");
if (items.length > idx) { if (items.length > idx) {
items.item(idx).scrollIntoView({ behavior: "smooth" }); items.item(idx).scrollIntoView({ block: "nearest", behavior: "smooth" });
} }
}; };
@ -235,10 +240,6 @@ const Home: Component = () => {
max-height: calc(100dvh - var(--scaffold-topbar-height, 0px)); max-height: calc(100dvh - var(--scaffold-topbar-height, 0px));
scroll-snap-align: center; scroll-snap-align: center;
&:not(.active) {
overflow: hidden;
}
@media (max-width: 600px) { @media (max-width: 600px) {
padding: 0; padding: 0;
} }
@ -261,71 +262,74 @@ const Home: Component = () => {
`; `;
return ( return (
<Scaffold <>
topbar={ <Scaffold
<AppBar position="static"> topbar={
<Toolbar variant="dense" class="responsive"> <AppBar position="static">
<Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}> <Toolbar variant="dense" class="responsive">
<Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}> <Tabs onFocusChanged={setCurrentFocusOn} offset={panelOffset()}>
Home <Tab focus={isTabFocus(0)} onClick={[onTabClick, 0]}>
</Tab> Home
<Tab focus={isTabFocus(1)} onClick={[onTabClick, 1]}> </Tab>
Trending <Tab focus={isTabFocus(1)} onClick={[onTabClick, 1]}>
</Tab> Trending
<Tab focus={isTabFocus(2)} onClick={[onTabClick, 2]}> </Tab>
Public <Tab focus={isTabFocus(2)} onClick={[onTabClick, 2]}>
</Tab> Public
</Tabs> </Tab>
<ProfileMenuButton profile={profile()}> </Tabs>
<MenuItem onClick={(e) => setPrefetching((x) => !x)}> <ProfileMenuButton profile={profile()}>
<ListItemText>Prefetch Toots</ListItemText> <MenuItem onClick={(e) => setPrefetching((x) => !x)}>
<ListItemSecondaryAction> <ListItemText>Prefetch Toots</ListItemText>
<Switch checked={prefetching()}></Switch> <ListItemSecondaryAction>
</ListItemSecondaryAction> <Switch checked={prefetching()}></Switch>
</MenuItem> </ListItemSecondaryAction>
</ProfileMenuButton> </MenuItem>
</Toolbar> </ProfileMenuButton>
</AppBar> </Toolbar>
} </AppBar>
fab={ }
<Fab color="secondary"> fab={
<CreateTootIcon /> <Fab color="secondary">
</Fab> <CreateTootIcon />
} </Fab>
> }
<TimeSourceProvider value={now}> >
<div class="panel-list" ref={panelList!}> <TimeSourceProvider value={now}>
<div class="tab-panel"> <div class="panel-list" ref={panelList!}>
<div> <div class="tab-panel">
<TimelinePanel <div>
client={client()} <TimelinePanel
name="home" client={client()}
prefetch={prefetching()} name="home"
/> prefetch={prefetching()}
/>
</div>
</div> </div>
</div> <div class="tab-panel">
<div class="tab-panel"> <div>
<div> <TimelinePanel
<TimelinePanel client={client()}
client={client()} name="trends"
name="trends" prefetch={prefetching()}
prefetch={prefetching()} />
/> </div>
</div> </div>
</div> <div class="tab-panel">
<div class="tab-panel"> <div>
<div> <TimelinePanel
<TimelinePanel client={client()}
client={client()} name="public"
name="public" prefetch={prefetching()}
prefetch={prefetching()} />
/> </div>
</div> </div>
<div></div>
</div> </div>
<div></div> </TimeSourceProvider>
</div> <BottomSheet open={!!child()}>{child()}</BottomSheet>
</TimeSourceProvider> </Scaffold>
</Scaffold> </>
); );
}; };

View file

@ -15,6 +15,7 @@ import {
Star as LikeIcon, Star as LikeIcon,
FeaturedPlayList as ListIcon, FeaturedPlayList as ListIcon,
} from "@suid/icons-material"; } from "@suid/icons-material";
import { A } from "@solidjs/router";
const ProfileMenuButton: ParentComponent<{ const ProfileMenuButton: ParentComponent<{
profile?: { displayName: string; avatar: string; username: string }; profile?: { displayName: string; avatar: string; username: string };
@ -107,7 +108,7 @@ const ProfileMenuButton: ParentComponent<{
{props.children} {props.children}
<Divider /> <Divider />
</Show> </Show>
<MenuItem> <MenuItem component={A} href="/settings" onClick={onClose}>
<ListItemIcon> <ListItemIcon>
<SettingsIcon /> <SettingsIcon />
</ListItemIcon> </ListItemIcon>

View file

@ -3,6 +3,7 @@ import solid from "vite-plugin-solid";
import solidStyled from "vite-plugin-solid-styled"; import solidStyled from "vite-plugin-solid-styled";
import suid from "@suid/vite-plugin"; import suid from "@suid/vite-plugin";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import version from "vite-plugin-package-version";
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
plugins: [ plugins: [
@ -17,7 +18,11 @@ export default defineConfig(({ mode }) => ({
VitePWA({ VitePWA({
registerType: "autoUpdate", registerType: "autoUpdate",
}), }),
version(),
], ],
define: {
"import.meta.env.BUILT_AT": `"${new Date().toISOString()}"`,
},
css: { css: {
devSourcemap: true, devSourcemap: true,
}, },