Breaking: refactor all masto API calls

Everything need to be instance-aware!
This commit is contained in:
Lim Chee Aun 2023-02-06 00:17:19 +08:00
parent b47c043699
commit a130743d4c
25 changed files with 481 additions and 253 deletions

View file

@ -2,7 +2,6 @@ import './app.css';
import 'toastify-js/src/toastify.css';
import debounce from 'just-debounce-it';
import { createClient } from 'masto';
import {
useEffect,
useLayoutEffect,
@ -36,9 +35,11 @@ import Public from './pages/public';
import Settings from './pages/settings';
import Status from './pages/status';
import Welcome from './pages/welcome';
import { api, initAccount, initClient, initInstance } from './utils/api';
import { getAccessToken } from './utils/auth';
import states, { saveStatus } from './utils/states';
import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils';
window.__STATES__ = states;
@ -54,13 +55,12 @@ function App() {
document.documentElement.classList.add(`is-${theme}`);
document
.querySelector('meta[name="color-scheme"]')
.setAttribute('content', theme);
.setAttribute('content', theme === 'auto' ? 'dark light' : theme);
}
}, []);
useEffect(() => {
const instanceURL = store.local.get('instanceURL');
const accounts = store.local.getJSON('accounts') || [];
const code = (window.location.search.match(/code=([^&]+)/) || [])[1];
if (code) {
@ -73,58 +73,31 @@ function App() {
(async () => {
setUIState('loading');
const tokenJSON = await getAccessToken({
const { access_token: accessToken } = await getAccessToken({
instanceURL,
client_id: clientID,
client_secret: clientSecret,
code,
});
const { access_token: accessToken } = tokenJSON;
store.session.set('accessToken', accessToken);
initMasto({
url: `https://${instanceURL}`,
accessToken,
});
const mastoAccount = await masto.v1.accounts.verifyCredentials();
// console.log({ tokenJSON, mastoAccount });
let account = accounts.find((a) => a.info.id === mastoAccount.id);
if (account) {
account.info = mastoAccount;
account.instanceURL = instanceURL.toLowerCase();
account.accessToken = accessToken;
} else {
account = {
info: mastoAccount,
instanceURL,
accessToken,
};
accounts.push(account);
}
store.local.setJSON('accounts', accounts);
store.session.set('currentAccount', account.info.id);
const masto = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([
initInstance(masto),
initAccount(masto, instanceURL, accessToken),
]);
setIsLoggedIn(true);
setUIState('default');
})();
} else if (accounts.length) {
const currentAccount = store.session.get('currentAccount');
const account =
accounts.find((a) => a.info.id === currentAccount) || accounts[0];
const instanceURL = account.instanceURL;
const accessToken = account.accessToken;
store.session.set('currentAccount', account.info.id);
if (accessToken) setIsLoggedIn(true);
initMasto({
url: `https://${instanceURL}`,
accessToken,
});
} else {
const account = getCurrentAccount();
if (account) {
store.session.set('currentAccount', account.info.id);
const { masto } = api({ account });
initInstance(masto);
setIsLoggedIn(true);
}
setUIState('default');
}
}, []);
@ -181,9 +154,11 @@ function App() {
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome|p)/.test(pathname);
return !/^\/(login|welcome)/.test(pathname);
}, [location]);
console.log('nonRootLocation', nonRootLocation, 'location', location);
return (
<>
<Routes location={nonRootLocation || location}>
@ -210,13 +185,17 @@ function App() {
{isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
{isLoggedIn && <Route path="/l/:id" element={<Lists />} />}
{isLoggedIn && <Route path="/t/:hashtag" element={<Hashtags />} />}
{isLoggedIn && <Route path="/a/:id" element={<AccountStatuses />} />}
{isLoggedIn && (
<Route path="/t/:instance?/:hashtag" element={<Hashtags />} />
)}
{isLoggedIn && (
<Route path="/a/:instance?/:id" element={<AccountStatuses />} />
)}
<Route path="/p/l?/:instance" element={<Public />} />
{/* <Route path="/:anything" element={<NotFound />} /> */}
</Routes>
<Routes>
<Route path="/s/:id" element={<Status />} />
<Route path="/s/:instance?/:id" element={<Status />} />
</Routes>
<nav id="tab-bar" hidden>
<li>
@ -304,7 +283,8 @@ function App() {
}}
>
<Account
account={snapStates.showAccount}
account={snapStates.showAccount?.account || snapStates.showAccount}
instance={snapStates.showAccount?.instance}
onClose={() => {
states.showAccount = false;
}}
@ -335,6 +315,7 @@ function App() {
>
<MediaModal
mediaAttachments={snapStates.showMediaModal.mediaAttachments}
instance={snapStates.showMediaModal.instance}
index={snapStates.showMediaModal.index}
statusID={snapStates.showMediaModal.statusID}
onClose={() => {
@ -347,57 +328,9 @@ function App() {
);
}
function initMasto(params) {
const clientParams = {
url: params.url || 'https://mastodon.social',
accessToken: params.accessToken || null,
disableVersionCheck: true,
timeout: 30_000,
};
window.masto = createClient(clientParams);
(async () => {
// Request v2, fallback to v1 if fail
let info;
try {
info = await masto.v2.instance.fetch();
} catch (e) {}
if (!info) {
try {
info = await masto.v1.instances.fetch();
} catch (e) {}
}
if (!info) return;
console.log(info);
const {
// v1
uri,
urls: { streamingApi } = {},
// v2
domain,
configuration: { urls: { streaming } = {} } = {},
} = info;
if (uri || domain) {
const instances = store.local.getJSON('instances') || {};
instances[
(domain || uri)
.replace(/^https?:\/\//, '')
.replace(/\/+$/, '')
.toLowerCase()
] = info;
store.local.setJSON('instances', instances);
}
if (streamingApi || streaming) {
window.masto = createClient({
...clientParams,
streamingApiUrl: streaming || streamingApi,
});
}
})();
}
let ws;
async function startStream() {
const { masto } = api();
if (
ws &&
(ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
@ -472,6 +405,7 @@ async function startStream() {
let lastHidden;
function startVisibility() {
const { masto } = api();
const handleVisible = (visible) => {
if (!visible) {
const timestamp = Date.now();

View file

@ -2,6 +2,7 @@ import './account.css';
import { useEffect, useState } from 'preact/hooks';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import enhanceContent from '../utils/enhance-content';
import handleContentLinks from '../utils/handle-content-links';
@ -14,7 +15,8 @@ import Icon from './icon';
import Link from './link';
import NameText from './name-text';
function Account({ account, onClose }) {
function Account({ account, instance, onClose }) {
const { masto, authenticated } = api({ instance });
const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account);
@ -82,7 +84,7 @@ function Account({ account, onClose }) {
const [relationship, setRelationship] = useState(null);
const [familiarFollowers, setFamiliarFollowers] = useState([]);
useEffect(() => {
if (info) {
if (info && authenticated) {
const currentAccount = store.session.get('currentAccount');
if (currentAccount === id) {
// It's myself!
@ -120,7 +122,7 @@ function Account({ account, onClose }) {
}
})();
}
}, [info]);
}, [info, authenticated]);
const {
following,
@ -174,7 +176,7 @@ function Account({ account, onClose }) {
<>
<header>
<Avatar url={avatar} size="xxxl" />
<NameText account={info} showAcct external />
<NameText account={info} instance={instance} showAcct external />
</header>
<main tabIndex="-1">
{bot && (
@ -186,7 +188,9 @@ function Account({ account, onClose }) {
)}
<div
class="note"
onClick={handleContentLinks()}
onClick={handleContentLinks({
instance,
})}
dangerouslySetInnerHTML={{
__html: enhanceContent(note, { emojis }),
}}
@ -270,7 +274,10 @@ function Account({ account, onClose }) {
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = follower;
states.showAccount = {
account: follower,
instance,
};
}}
>
<Avatar

View file

@ -12,6 +12,7 @@ import { useSnapshot } from 'valtio';
import supportedLanguages from '../data/status-supported-languages';
import urlRegex from '../data/url-regex';
import { api } from '../utils/api';
import db from '../utils/db';
import emojifyText from '../utils/emojify-text';
import openCompose from '../utils/open-compose';
@ -99,6 +100,7 @@ function Compose({
hasOpener,
}) {
console.warn('RENDER COMPOSER');
const { masto } = api();
const [uiState, setUIState] = useState('default');
const UID = useRef(draftStatus?.uid || uid());
console.log('Compose UID', UID.current);
@ -868,6 +870,9 @@ function Compose({
updateCharCount();
}}
maxCharacters={maxCharacters}
performSearch={(params) => {
return masto.v2.search(params);
}}
/>
{mediaAttachments.length > 0 && (
<div class="media-attachments">
@ -1031,7 +1036,7 @@ function Compose({
const Textarea = forwardRef((props, ref) => {
const [text, setText] = useState(ref.current?.value || '');
const { maxCharacters, ...textareaProps } = props;
const { maxCharacters, performSearch = () => {}, ...textareaProps } = props;
const snapStates = useSnapshot(states);
const charCount = snapStates.composerCharacterCount;
@ -1087,7 +1092,7 @@ const Textarea = forwardRef((props, ref) => {
}[key];
provide(
new Promise((resolve) => {
const searchResults = masto.v2.search({
const searchResults = performSearch({
type,
q: text,
limit: 5,

View file

@ -2,6 +2,7 @@ import './drafts.css';
import { useEffect, useMemo, useReducer, useState } from 'react';
import { api } from '../utils/api';
import db from '../utils/db';
import states from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils';
@ -10,6 +11,7 @@ import Icon from './icon';
import Loader from './loader';
function Drafts() {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [drafts, setDrafts] = useState([]);
const [reloadCount, reload] = useReducer((c) => c + 1, 0);

View file

@ -11,11 +11,12 @@ import Modal from './modal';
function MediaModal({
mediaAttachments,
statusID,
instance,
index = 0,
onClose = () => {},
}) {
const carouselRef = useRef(null);
const isStatusLocation = useMatch('/s/:id');
const isStatusLocation = useMatch('/s/:instance?/:id');
const [currentIndex, setCurrentIndex] = useState(index);
const carouselFocusItem = useRef(null);
@ -167,7 +168,7 @@ function MediaModal({
<span>
{!isStatusLocation && (
<Link
to={`/s/${statusID}`}
to={instance ? `/s/${instance}/${statusID}` : `/s/${statusID}`}
class="button carousel-button media-post-link plain3"
onClick={() => {
// if small screen (not media query min-width 40em + 350px), run onClose

View file

@ -5,7 +5,15 @@ import states from '../utils/states';
import Avatar from './avatar';
function NameText({ account, showAvatar, showAcct, short, external, onClick }) {
function NameText({
account,
instance,
showAvatar,
showAcct,
short,
external,
onClick,
}) {
const { acct, avatar, avatarStatic, id, url, displayName, emojis } = account;
let { username } = account;
@ -34,7 +42,10 @@ function NameText({ account, showAvatar, showAcct, short, external, onClick }) {
if (external) return;
e.preventDefault();
if (onClick) return onClick(e);
states.showAccount = account;
states.showAccount = {
account,
instance,
};
}}
>
{showAvatar && (

View file

@ -1,17 +1,9 @@
import './status.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash';
import mem from 'mem';
import { memo } from 'preact/compat';
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import 'swiped-events';
import useResizeObserver from 'use-resize-observer';
import { useSnapshot } from 'valtio';
@ -19,6 +11,7 @@ import { useSnapshot } from 'valtio';
import Loader from '../components/loader';
import Modal from '../components/modal';
import NameText from '../components/name-text';
import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import handleContentLinks from '../utils/handle-content-links';
import htmlContentLength from '../utils/html-content-length';
@ -33,7 +26,7 @@ import Link from './link';
import Media from './media';
import RelativeTime from './relative-time';
function fetchAccount(id) {
function fetchAccount(id, masto) {
try {
return masto.v1.accounts.fetch(id);
} catch (e) {
@ -45,6 +38,7 @@ const memFetchAccount = mem(fetchAccount);
function Status({
statusID,
status,
instance,
withinContext,
size = 'm',
skeleton,
@ -65,6 +59,7 @@ function Status({
</div>
);
}
const { masto, authenticated } = api({ instance });
const snapStates = useSnapshot(states);
if (!status) {
@ -135,7 +130,7 @@ function Status({
if (account) {
setInReplyToAccount(account);
} else {
memFetchAccount(inReplyToAccountId)
memFetchAccount(inReplyToAccountId, masto)
.then((account) => {
setInReplyToAccount(account);
states.accounts[account.id] = account;
@ -157,9 +152,10 @@ function Status({
<div class="status-reblog" onMouseEnter={debugHover}>
<div class="status-pre-meta">
<Icon icon="rocket" size="l" />{' '}
<NameText account={status.account} showAvatar /> boosted
<NameText account={status.account} instance={instance} showAvatar />{' '}
boosted
</div>
<Status status={reblog} size={size} />
<Status status={reblog} instance={instance} size={size} />
</div>
);
}
@ -198,6 +194,8 @@ function Status({
const statusRef = useRef(null);
const unauthInteractionErrorMessage = `Sorry, your current logged-in instance can't interact with this status from another instance.`;
return (
<article
ref={statusRef}
@ -229,7 +227,10 @@ function Status({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showAccount = status.account;
states.showAccount = {
account: status.account,
instance,
};
}}
>
<Avatar url={avatarStatic} size="xxl" />
@ -240,6 +241,7 @@ function Status({
{/* <span> */}
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={size === 'l'}
/>
@ -248,14 +250,23 @@ function Status({
{' '}
<span class="ib">
<Icon icon="arrow-right" class="arrow" />{' '}
<NameText account={inReplyToAccount} short />
<NameText account={inReplyToAccount} instance={instance} short />
</span>
</>
)} */}
{/* </span> */}{' '}
{size !== 'l' &&
(uri ? (
<Link to={`/s/${id}`} class="time">
<Link
to={
instance
? `
/s/${instance}/${id}
`
: `/s/${id}`
}
class="time"
>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
@ -294,7 +305,11 @@ function Status({
})) && (
<div class="status-reply-badge">
<Icon icon="reply" />{' '}
<NameText account={inReplyToAccount} short />
<NameText
account={inReplyToAccount}
instance={instance}
short
/>
</div>
)
)}
@ -346,7 +361,7 @@ function Status({
lang={language}
ref={contentRef}
data-read-more={readMoreText}
onClick={handleContentLinks({ mentions })}
onClick={handleContentLinks({ mentions, instance })}
dangerouslySetInnerHTML={{
__html: enhanceContent(content, {
emojis,
@ -367,10 +382,28 @@ function Status({
<Poll
lang={language}
poll={poll}
readOnly={readOnly}
readOnly={readOnly || !authenticated}
onUpdate={(newPoll) => {
states.statuses[id].poll = newPoll;
}}
refresh={() => {
return masto.v1.polls
.fetch(poll.id)
.then((pollResponse) => {
states.statuses[id].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
votePoll={(choices) => {
return masto.v1.polls
.vote(poll.id, {
choices,
})
.then((pollResponse) => {
states.statuses[id].poll = pollResponse;
})
.catch((e) => {}); // Silently fail
}}
/>
)}
{!spoilerText && sensitive && !!mediaAttachments.length && (
@ -410,6 +443,7 @@ function Status({
states.showMediaModal = {
mediaAttachments,
index: i,
instance,
statusID: readOnly ? null : id,
};
}}
@ -477,6 +511,9 @@ function Status({
icon="comment"
count={repliesCount}
onClick={() => {
if (!authenticated) {
return alert(unauthInteractionErrorMessage);
}
states.showCompose = {
replyToStatus: status,
};
@ -494,6 +531,9 @@ function Status({
icon="rocket"
count={reblogsCount}
onClick={async () => {
if (!authenticated) {
return alert(unauthInteractionErrorMessage);
}
try {
if (!reblogged) {
const yes = confirm(
@ -536,6 +576,9 @@ function Status({
icon="heart"
count={favouritesCount}
onClick={async () => {
if (!authenticated) {
return alert(unauthInteractionErrorMessage);
}
try {
// Optimistic
states.statuses[statusID] = {
@ -569,6 +612,9 @@ function Status({
class="bookmark-button"
icon="bookmark"
onClick={async () => {
if (!authenticated) {
return alert(unauthInteractionErrorMessage);
}
try {
// Optimistic
states.statuses[statusID] = {
@ -635,6 +681,10 @@ function Status({
>
<EditedAtModal
statusID={showEdited}
instance={instance}
fetchStatusHistory={() => {
return masto.v1.statuses.listHistory(showEdited);
}}
onClose={() => {
setShowEdited(false);
statusRef.current?.focus();
@ -742,7 +792,13 @@ function Card({ card }) {
}
}
function Poll({ poll, lang, readOnly, onUpdate = () => {} }) {
function Poll({
poll,
lang,
readOnly,
refresh = () => {},
votePoll = () => {},
}) {
const [uiState, setUIState] = useState('default');
const {
@ -768,12 +824,7 @@ function Poll({ poll, lang, readOnly, onUpdate = () => {} }) {
timeout = setTimeout(() => {
setUIState('loading');
(async () => {
try {
const pollResponse = await masto.v1.polls.fetch(id);
onUpdate(pollResponse);
} catch (e) {
// Silent fail
}
await refresh();
setUIState('default');
})();
}, ms);
@ -847,19 +898,15 @@ function Poll({ poll, lang, readOnly, onUpdate = () => {} }) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const votes = [];
const choices = [];
formData.forEach((value, key) => {
if (key === 'poll') {
votes.push(value);
choices.push(value);
}
});
console.log(votes);
setUIState('loading');
const pollResponse = await masto.v1.polls.vote(id, {
choices: votes,
});
console.log(pollResponse);
onUpdate(pollResponse);
await votePoll(choices);
setUIState('default');
}}
>
@ -903,12 +950,7 @@ function Poll({ poll, lang, readOnly, onUpdate = () => {} }) {
e.preventDefault();
setUIState('loading');
(async () => {
try {
const pollResponse = await masto.v1.polls.fetch(id);
onUpdate(pollResponse);
} catch (e) {
// Silent fail
}
await refresh();
setUIState('default');
})();
}}
@ -937,7 +979,12 @@ function Poll({ poll, lang, readOnly, onUpdate = () => {} }) {
);
}
function EditedAtModal({ statusID, onClose = () => {} }) {
function EditedAtModal({
statusID,
instance,
fetchStatusHistory = () => {},
onClose = () => {},
}) {
const [uiState, setUIState] = useState('default');
const [editHistory, setEditHistory] = useState([]);
@ -945,7 +992,7 @@ function EditedAtModal({ statusID, onClose = () => {} }) {
setUIState('loading');
(async () => {
try {
const editHistory = await masto.v1.statuses.listHistory(statusID);
const editHistory = await fetchStatusHistory();
console.log(editHistory);
setEditHistory(editHistory);
setUIState('default');
@ -997,7 +1044,13 @@ function EditedAtModal({ statusID, onClose = () => {} }) {
}).format(createdAtDate)}
</time>
</h3>
<Status status={status} size="s" withinContext readOnly />
<Status
status={status}
instance={instance}
size="s"
withinContext
readOnly
/>
</li>
);
})}

View file

@ -12,6 +12,7 @@ function Timeline({
title,
titleComponent,
id,
instance,
emptyText,
errorText,
boostsCarousel,
@ -112,17 +113,20 @@ function Timeline({
{items.map((status) => {
const { id: statusID, reblog, boosts } = status;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/s/${instance}/${actualStatusID}`
: `/s/${actualStatusID}`;
if (boosts) {
return (
<li key={`timeline-${statusID}`}>
<BoostsCarousel boosts={boosts} />
<BoostsCarousel boosts={boosts} instance={instance} />
</li>
);
}
return (
<li key={`timeline-${statusID}`}>
<Link class="status-link" to={`/s/${actualStatusID}`}>
<Status status={status} />
<Link class="status-link" to={url}>
<Status status={status} instance={instance} />
</Link>
</li>
);
@ -213,7 +217,7 @@ function groupBoosts(values) {
}
}
function BoostsCarousel({ boosts }) {
function BoostsCarousel({ boosts, instance }) {
const carouselRef = useRef();
const { reachStart, reachEnd, init } = useScroll({
scrollableElement: carouselRef.current,
@ -260,10 +264,13 @@ function BoostsCarousel({ boosts }) {
{boosts.map((boost) => {
const { id: statusID, reblog } = boost;
const actualStatusID = reblog?.id || statusID;
const url = instance
? `/s/${instance}/${actualStatusID}`
: `/s/${actualStatusID}`;
return (
<li key={statusID}>
<Link class="status-boost-link" to={`/s/${actualStatusID}`}>
<Status status={boost} size="s" />
<Link class="status-boost-link" to={url}>
<Status status={boost} instance={instance} size="s" />
</Link>
</li>
);

View file

@ -2,36 +2,16 @@ import './index.css';
import './app.css';
import { createClient } from 'masto';
import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import Compose from './components/compose';
import { getCurrentAccount } from './utils/store-utils';
import useTitle from './utils/useTitle';
if (window.opener) {
console = window.opener.console;
}
(() => {
if (window.masto) return;
console.warn('window.masto not found. Trying to log in...');
try {
const { instanceURL, accessToken } = getCurrentAccount();
window.masto = createClient({
url: `https://${instanceURL}`,
accessToken,
disableVersionCheck: true,
timeout: 30_000,
});
console.info('Logged in successfully.');
} catch (e) {
console.error(e);
alert('Failed to log in. Please try again.');
}
})();
function App() {
const [uiState, setUIState] = useState('default');

View file

@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import states from '../utils/states';
import useTitle from '../utils/useTitle';
@ -11,7 +12,8 @@ const LIMIT = 20;
function AccountStatuses() {
const snapStates = useSnapshot(states);
const { id } = useParams();
const { id, instance } = useParams();
const { masto } = api({ instance });
const accountStatusesIterator = useRef();
async function fetchAccountStatuses(firstLoad) {
if (firstLoad || !accountStatusesIterator.current) {
@ -46,7 +48,10 @@ function AccountStatuses() {
<h1
class="header-account"
onClick={() => {
states.showAccount = account;
states.showAccount = {
account,
instance,
};
}}
>
<b

View file

@ -1,12 +1,14 @@
import { useRef } from 'preact/hooks';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Bookmarks() {
useTitle('Bookmarks', '/b');
const { masto } = api();
const bookmarksIterator = useRef();
async function fetchBookmarks(firstLoad) {
if (firstLoad || !bookmarksIterator.current) {

View file

@ -1,12 +1,14 @@
import { useRef } from 'preact/hooks';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Favourites() {
useTitle('Favourites', '/f');
const { masto } = api();
const favouritesIterator = useRef();
async function fetchFavourites(firstLoad) {
if (firstLoad || !favouritesIterator.current) {

View file

@ -2,12 +2,14 @@ import { useRef } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Following() {
useTitle('Following', '/l/f');
const { masto } = api();
const snapStates = useSnapshot(states);
const homeIterator = useRef();
async function fetchHome(firstLoad) {

View file

@ -2,13 +2,15 @@ import { useRef } from 'preact/hooks';
import { useParams } from 'react-router-dom';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Hashtags() {
const { hashtag } = useParams();
const { hashtag, instance } = useParams();
useTitle(`#${hashtag}`, `/t/${hashtag}`);
const { masto } = api({ instance });
const hashtagsIterator = useRef();
async function fetchHashtags(firstLoad) {
if (firstLoad || !hashtagsIterator.current) {
@ -22,7 +24,15 @@ function Hashtags() {
return (
<Timeline
key={hashtag}
title={`#${hashtag}`}
title={instance ? `#${hashtag} on ${instance}` : `#${hashtag}`}
titleComponent={
!!instance && (
<h1 class="header-account">
<b>#{hashtag}</b>
<div>{instance}</div>
</h1>
)
}
id="hashtags"
emptyText="No one has posted anything with this tag yet."
errorText="Unable to load posts with this tag"

View file

@ -8,6 +8,7 @@ import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Status from '../components/status';
import { api } from '../utils/api';
import db from '../utils/db';
import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils';
@ -18,6 +19,7 @@ const LIMIT = 20;
function Home({ hidden }) {
useTitle('Home', '/');
const { masto } = api();
const snapStates = useSnapshot(states);
const isHomeLocation = snapStates.currentLocation === '/';
const [uiState, setUIState] = useState('default');

View file

@ -2,11 +2,13 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import { useParams } from 'react-router-dom';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
function Lists() {
const { masto } = api();
const { id } = useParams();
const listsIterator = useRef();
async function fetchLists(firstLoad) {

View file

@ -11,6 +11,7 @@ import Loader from '../components/loader';
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
import Status from '../components/status';
import { api } from '../utils/api';
import states, { saveStatus } from '../utils/states';
import store from '../utils/store';
import useScroll from '../utils/useScroll';
@ -48,6 +49,7 @@ const LIMIT = 30; // 30 is the maximum limit :(
function Notifications() {
useTitle('Notifications', '/notifications');
const { masto } = api();
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);

View file

@ -1,47 +1,43 @@
// EXPERIMENTAL: This is a work in progress and may not work as expected.
import { useRef } from 'preact/hooks';
import { useMatch, useParams } from 'react-router-dom';
import Timeline from '../components/timeline';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
let nextUrl = null;
function Public() {
const isLocal = !!useMatch('/p/l/:instance');
const params = useParams();
const { instance = '' } = params;
const { instance } = useParams();
const { masto } = api({ instance });
const title = `${instance} (${isLocal ? 'local' : 'federated'})`;
useTitle(title, `/p/${instance}`);
const publicIterator = useRef();
async function fetchPublic(firstLoad) {
const url = firstLoad
? `https://${instance}/api/v1/timelines/public?limit=${LIMIT}&local=${isLocal}`
: nextUrl;
if (!url) return { values: [], done: true };
const response = await fetch(url);
let value = await response.json();
if (value) {
value = camelCaseKeys(value);
if (firstLoad || !publicIterator.current) {
publicIterator.current = masto.v1.timelines.listPublic({
limit: LIMIT,
local: isLocal,
});
}
const done = !response.headers.has('link');
nextUrl = done
? null
: response.headers.get('link').match(/<(.+?)>; rel="next"/)?.[1];
console.debug({
url,
value,
done,
nextUrl,
});
return { value, done };
return await publicIterator.current.next();
}
return (
<Timeline
key={instance + isLocal}
title={title}
titleComponent={
<h1 class="header-account">
<b>{instance}</b>
<div>{isLocal ? 'local' : 'federated'}</div>
</h1>
}
id="public"
instance={instance}
emptyText="No one has posted anything yet."
errorText="Unable to load posts"
fetchItems={fetchPublic}
@ -49,31 +45,4 @@ function Public() {
);
}
function camelCaseKeys(obj) {
if (Array.isArray(obj)) {
return obj.map((item) => camelCaseKeys(item));
}
return new Proxy(obj, {
get(target, prop) {
let value = undefined;
if (prop in target) {
value = target[prop];
}
if (!value) {
const snakeCaseProp = prop.replace(
/([A-Z])/g,
(g) => `_${g.toLowerCase()}`,
);
if (snakeCaseProp in target) {
value = target[snakeCaseProp];
}
}
if (value && typeof value === 'object') {
return camelCaseKeys(value);
}
return value;
},
});
}
export default Public;

View file

@ -10,6 +10,7 @@ import Icon from '../components/icon';
import Link from '../components/link';
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
import { api } from '../utils/api';
import states from '../utils/states';
import store from '../utils/store';
@ -20,6 +21,7 @@ import store from '../utils/store';
*/
function Settings({ onClose }) {
const { masto } = api();
const snapStates = useSnapshot(states);
// Accounts
const accounts = store.local.getJSON('accounts');
@ -178,7 +180,10 @@ function Settings({ onClose }) {
}
document
.querySelector('meta[name="color-scheme"]')
.setAttribute('content', theme);
.setAttribute(
'content',
theme === 'auto' ? 'dark light' : theme,
);
if (theme === 'auto') {
store.local.del('theme');

View file

@ -6,7 +6,7 @@ import pRetry from 'p-retry';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
@ -17,6 +17,7 @@ import Loader from '../components/loader';
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
import Status from '../components/status';
import { api } from '../utils/api';
import htmlContentLength from '../utils/html-content-length';
import shortenNumber from '../utils/shorten-number';
import states, { saveStatus, threadifyStatus } from '../utils/states';
@ -34,8 +35,8 @@ function resetScrollPosition(id) {
}
function StatusPage() {
const { id } = useParams();
const location = useLocation();
const { id, instance } = useParams();
const { masto } = api({ instance });
const navigate = useNavigate();
const snapStates = useSnapshot(states);
const [statuses, setStatuses] = useState([]);
@ -92,6 +93,7 @@ function StatusPage() {
}
(async () => {
console.log('MASTO V1 fetch', masto);
const heroFetch = () =>
pRetry(() => masto.v1.statuses.fetch(id), {
retries: 4,
@ -211,7 +213,7 @@ function StatusPage() {
};
};
useEffect(initContext, [id]);
useEffect(initContext, [id, masto]);
useEffect(() => {
if (!statuses.length) return;
console.debug('STATUSES', statuses);
@ -462,7 +464,12 @@ function StatusPage() {
{!heroInView && heroStatus && uiState !== 'loading' ? (
<>
<span class="hero-heading">
<NameText showAvatar account={heroStatus.account} short />{' '}
<NameText
account={heroStatus.account}
instance={instance}
showAvatar
short
/>{' '}
<span class="insignificant">
&bull;{' '}
<RelativeTime
@ -583,18 +590,28 @@ function StatusPage() {
class="status-focus"
tabIndex={0}
>
<Status statusID={statusID} withinContext size="l" />
<Status
statusID={statusID}
instance={instance}
withinContext
size="l"
/>
</InView>
) : (
<Link
class="status-link"
to={`/s/${statusID}`}
to={
instance
? `/s/${instance}/${statusID}`
: `/s/${statusID}`
}
onClick={() => {
resetScrollPosition(statusID);
}}
>
<Status
statusID={statusID}
instance={instance}
withinContext
size={thread || ancestor ? 'm' : 's'}
/>
@ -610,6 +627,7 @@ function StatusPage() {
)}
{descendant && replies?.length > 0 && (
<SubComments
instance={instance}
hasManyStatuses={hasManyStatuses}
replies={replies}
/>
@ -691,7 +709,7 @@ function StatusPage() {
);
}
function SubComments({ hasManyStatuses, replies }) {
function SubComments({ hasManyStatuses, replies, instance }) {
// Set isBrief = true:
// - if less than or 2 replies
// - if replies have no sub-replies
@ -764,12 +782,17 @@ function SubComments({ hasManyStatuses, replies }) {
<li key={r.id}>
<Link
class="status-link"
to={`/s/${r.id}`}
to={instance ? `/s/${instance}/${r.id}` : `/s/${r.id}`}
onClick={() => {
resetScrollPosition(r.id);
}}
>
<Status statusID={r.id} withinContext size="s" />
<Status
statusID={r.id}
instance={instance}
withinContext
size="s"
/>
{!r.replies?.length && r.repliesCount > 0 && (
<div class="replies-link">
<Icon icon="comment" />{' '}
@ -781,6 +804,7 @@ function SubComments({ hasManyStatuses, replies }) {
</Link>
{r.replies?.length && (
<SubComments
instance={instance}
hasManyStatuses={hasManyStatuses}
replies={r.replies}
/>

176
src/utils/api.js Normal file
View file

@ -0,0 +1,176 @@
import { createClient } from 'masto';
import store from './store';
import { getAccount, getCurrentAccount, saveAccount } from './store-utils';
// Default *fallback* instance
const DEFAULT_INSTANCE = 'mastodon.social';
// Per-instance masto instance
// Useful when only one account is logged in
// I'm not sure if I'll ever allow multiple logged-in accounts but oh well...
// E.g. apis['mastodon.social']
const apis = {};
// Per-account masto instance
// Note: There can be many accounts per instance
// Useful when multiple accounts are logged in or when certain actions require a specific account
// Just in case if I need this one day.
// E.g. accountApis['mastodon.social']['ACCESS_TOKEN']
const accountApis = {};
// Current account masto instance
let currentAccountApi;
export function initClient({ instance, accessToken }) {
if (/^https?:\/\//.test(instance)) {
instance = instance
.replace(/^https?:\/\//, '')
.replace(/\/+$/, '')
.toLowerCase();
}
const url = instance ? `https://${instance}` : `https://${DEFAULT_INSTANCE}`;
const client = createClient({
url,
accessToken, // Can be null
disableVersionCheck: true, // Allow non-Mastodon instances
timeout: 30_000, // Unfortunatly this is global instead of per-request
});
client.__instance__ = instance;
apis[instance] = client;
if (!accountApis[instance]) accountApis[instance] = {};
if (accessToken) accountApis[instance][accessToken] = client;
return client;
}
// Get the instance information
// The config is needed for composing
export async function initInstance(client) {
const masto = client;
// Request v2, fallback to v1 if fail
let info;
try {
info = await masto.v2.instance.fetch();
} catch (e) {}
if (!info) {
try {
info = await masto.v1.instances.fetch();
} catch (e) {}
}
if (!info) return;
console.log(info);
const {
// v1
uri,
urls: { streamingApi } = {},
// v2
domain,
configuration: { urls: { streaming } = {} } = {},
} = info;
if (uri || domain) {
const instances = store.local.getJSON('instances') || {};
instances[
(domain || uri)
.replace(/^https?:\/\//, '')
.replace(/\/+$/, '')
.toLowerCase()
] = info;
store.local.setJSON('instances', instances);
}
// This is a weird place to put this but here's updating the masto instance with the streaming API URL set in the configuration
// Reason: Streaming WebSocket URL may change, unlike the standard API REST URLs
if (streamingApi || streaming) {
masto.config.props.streamingApiUrl = streaming || streamingApi;
}
}
// Get the account information and store it
export async function initAccount(client, instance, accessToken) {
const masto = client;
const mastoAccount = await masto.v1.accounts.verifyCredentials();
saveAccount({
info: mastoAccount,
instanceURL: instance.toLowerCase(),
accessToken,
});
}
// Get the masto instance
// If accountID is provided, get the masto instance for that account
export function api({ instance, accessToken, accountID, account } = {}) {
// If instance and accessToken are provided, get the masto instance for that account
if (instance && accessToken) {
return {
masto:
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken }),
authenticated: true,
instance,
};
}
// If account is provided, get the masto instance for that account
if (account || accountID) {
account = account || getAccount(accountID);
if (account) {
const accessToken = account.accessToken;
const instance = account.instanceURL;
return {
masto:
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken }),
authenticated: true,
instance,
};
} else {
throw new Error(`Account ${accountID} not found`);
}
}
// If only instance is provided, get the masto instance for that instance
if (instance) {
const masto = apis[instance] || initClient({ instance });
return {
masto,
authenticated: !!masto.config.props.accessToken,
instance,
};
}
// If no instance is provided, get the masto instance for the current account
if (currentAccountApi)
return {
masto: currentAccountApi,
authenticated: true,
instance: currentAccountApi.__instance__,
};
const currentAccount = getCurrentAccount();
if (currentAccount) {
const { accessToken, instanceURL: instance } = currentAccount;
currentAccountApi =
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken });
return {
masto: currentAccountApi,
authenticated: true,
instance,
};
}
// If no instance is provided and no account is logged in, get the masto instance for DEFAULT_INSTANCE
return {
masto: apis[DEFAULT_INSTANCE] || initClient({ instance: DEFAULT_INSTANCE }),
authenticated: false,
instance: DEFAULT_INSTANCE,
};
}
window.__API__ = {
currentAccountApi,
apis,
accountApis,
};

View file

@ -1,7 +1,7 @@
import states from './states';
function handleContentLinks(opts) {
const { mentions = [] } = opts || {};
const { mentions = [], instance } = opts || {};
return (e) => {
let { target } = e;
if (target.parentNode.tagName.toLowerCase() === 'a') {
@ -25,13 +25,19 @@ function handleContentLinks(opts) {
if (mention) {
e.preventDefault();
e.stopPropagation();
states.showAccount = mention.acct;
states.showAccount = {
account: mention.acct,
instance,
};
} else if (!/^http/i.test(targetText)) {
console.log('mention not found', targetText);
e.preventDefault();
e.stopPropagation();
const href = target.getAttribute('href');
states.showAccount = href;
states.showAccount = {
account: href,
instance,
};
}
} else if (
target.tagName.toLowerCase() === 'a' &&
@ -40,7 +46,9 @@ function handleContentLinks(opts) {
e.preventDefault();
e.stopPropagation();
const tag = target.innerText.replace(/^#/, '').trim();
location.hash = `#/t/${tag}`;
const hashURL = instance ? `#/t/${instance}/${tag}` : `#/t/${tag}`;
console.log({ hashURL });
location.hash = hashURL;
}
};
}

View file

@ -13,9 +13,9 @@ export default function openCompose(opts) {
);
if (newWin) {
if (masto) {
newWin.masto = masto;
}
// if (masto) {
// newWin.masto = masto;
// }
newWin.__COMPOSE__ = opts;
}

View file

@ -1,6 +1,7 @@
import { proxy } from 'valtio';
import { subscribeKey } from 'valtio/utils';
import { api } from './api';
import store from './store';
const states = proxy({
@ -76,6 +77,7 @@ export function saveStatus(status, opts) {
}
export function threadifyStatus(status) {
const { masto } = api();
// Return all statuses in the thread, via inReplyToId, if inReplyToAccountId === account.id
let fetchIndex = 0;
async function traverse(status, index = 0) {

View file

@ -1,10 +1,13 @@
import store from './store';
export function getCurrentAccount() {
export function getAccount(id) {
const accounts = store.local.getJSON('accounts') || [];
return accounts.find((a) => a.info.id === id);
}
export function getCurrentAccount() {
const currentAccount = store.session.get('currentAccount');
const account =
accounts.find((a) => a.info.id === currentAccount) || accounts[0];
const account = getAccount(currentAccount);
return account;
}
@ -16,3 +19,17 @@ export function getCurrentAccountNS() {
} = account;
return `${id}@${instanceURL}`;
}
export function saveAccount(account) {
const accounts = store.local.getJSON('accounts') || [];
const acc = accounts.find((a) => a.info.id === account.info.id);
if (acc) {
acc.info = account.info;
acc.instanceURL = account.instanceURL;
acc.accessToken = account.accessToken;
} else {
accounts.push(account);
}
store.local.setJSON('accounts', accounts);
store.session.set('currentAccount', account.info.id);
}