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

View file

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

View file

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

View file

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

View file

@ -11,11 +11,12 @@ import Modal from './modal';
function MediaModal({ function MediaModal({
mediaAttachments, mediaAttachments,
statusID, statusID,
instance,
index = 0, index = 0,
onClose = () => {}, onClose = () => {},
}) { }) {
const carouselRef = useRef(null); const carouselRef = useRef(null);
const isStatusLocation = useMatch('/s/:id'); const isStatusLocation = useMatch('/s/:instance?/:id');
const [currentIndex, setCurrentIndex] = useState(index); const [currentIndex, setCurrentIndex] = useState(index);
const carouselFocusItem = useRef(null); const carouselFocusItem = useRef(null);
@ -167,7 +168,7 @@ function MediaModal({
<span> <span>
{!isStatusLocation && ( {!isStatusLocation && (
<Link <Link
to={`/s/${statusID}`} to={instance ? `/s/${instance}/${statusID}` : `/s/${statusID}`}
class="button carousel-button media-post-link plain3" class="button carousel-button media-post-link plain3"
onClick={() => { onClick={() => {
// if small screen (not media query min-width 40em + 350px), run onClose // 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'; 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; const { acct, avatar, avatarStatic, id, url, displayName, emojis } = account;
let { username } = account; let { username } = account;
@ -34,7 +42,10 @@ function NameText({ account, showAvatar, showAcct, short, external, onClick }) {
if (external) return; if (external) return;
e.preventDefault(); e.preventDefault();
if (onClick) return onClick(e); if (onClick) return onClick(e);
states.showAccount = account; states.showAccount = {
account,
instance,
};
}} }}
> >
{showAvatar && ( {showAvatar && (

View file

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

View file

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

View file

@ -2,36 +2,16 @@ import './index.css';
import './app.css'; import './app.css';
import { createClient } from 'masto';
import { render } from 'preact'; import { render } from 'preact';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import Compose from './components/compose'; import Compose from './components/compose';
import { getCurrentAccount } from './utils/store-utils';
import useTitle from './utils/useTitle'; import useTitle from './utils/useTitle';
if (window.opener) { if (window.opener) {
console = window.opener.console; 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() { function App() {
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');

View file

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

View file

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

View file

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

View file

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

View file

@ -2,13 +2,15 @@ import { useRef } from 'preact/hooks';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import Timeline from '../components/timeline'; import Timeline from '../components/timeline';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
function Hashtags() { function Hashtags() {
const { hashtag } = useParams(); const { hashtag, instance } = useParams();
useTitle(`#${hashtag}`, `/t/${hashtag}`); useTitle(`#${hashtag}`, `/t/${hashtag}`);
const { masto } = api({ instance });
const hashtagsIterator = useRef(); const hashtagsIterator = useRef();
async function fetchHashtags(firstLoad) { async function fetchHashtags(firstLoad) {
if (firstLoad || !hashtagsIterator.current) { if (firstLoad || !hashtagsIterator.current) {
@ -22,7 +24,15 @@ function Hashtags() {
return ( return (
<Timeline <Timeline
key={hashtag} 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" id="hashtags"
emptyText="No one has posted anything with this tag yet." emptyText="No one has posted anything with this tag yet."
errorText="Unable to load posts with this tag" 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 Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
import Status from '../components/status'; import Status from '../components/status';
import { api } from '../utils/api';
import db from '../utils/db'; import db from '../utils/db';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils'; import { getCurrentAccountNS } from '../utils/store-utils';
@ -18,6 +19,7 @@ const LIMIT = 20;
function Home({ hidden }) { function Home({ hidden }) {
useTitle('Home', '/'); useTitle('Home', '/');
const { masto } = api();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const isHomeLocation = snapStates.currentLocation === '/'; const isHomeLocation = snapStates.currentLocation === '/';
const [uiState, setUIState] = useState('default'); 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 { useParams } from 'react-router-dom';
import Timeline from '../components/timeline'; import Timeline from '../components/timeline';
import { api } from '../utils/api';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
function Lists() { function Lists() {
const { masto } = api();
const { id } = useParams(); const { id } = useParams();
const listsIterator = useRef(); const listsIterator = useRef();
async function fetchLists(firstLoad) { async function fetchLists(firstLoad) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,13 @@
import store from './store'; import store from './store';
export function getCurrentAccount() { export function getAccount(id) {
const accounts = store.local.getJSON('accounts') || []; const accounts = store.local.getJSON('accounts') || [];
return accounts.find((a) => a.info.id === id);
}
export function getCurrentAccount() {
const currentAccount = store.session.get('currentAccount'); const currentAccount = store.session.get('currentAccount');
const account = const account = getAccount(currentAccount);
accounts.find((a) => a.info.id === currentAccount) || accounts[0];
return account; return account;
} }
@ -16,3 +19,17 @@ export function getCurrentAccountNS() {
} = account; } = account;
return `${id}@${instanceURL}`; 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);
}