phanpy/src/app.jsx

520 lines
15 KiB
React
Raw Normal View History

2022-12-10 12:14:48 +03:00
import './app.css';
2022-12-26 09:02:05 +03:00
import 'toastify-js/src/toastify.css';
2022-12-10 12:14:48 +03:00
2023-01-01 07:01:54 +03:00
import debounce from 'just-debounce-it';
import { createClient } from 'masto';
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
2022-12-26 09:02:05 +03:00
import Toastify from 'toastify-js';
2022-12-10 12:14:48 +03:00
import { useSnapshot } from 'valtio';
import Account from './components/account';
import Compose from './components/compose';
import Drafts from './components/drafts';
import Icon from './components/icon';
import Link from './components/link';
2022-12-10 12:14:48 +03:00
import Loader from './components/loader';
import Modal from './components/modal';
import NotFound from './pages/404';
import Bookmarks from './pages/bookmarks';
import Favourites from './pages/favourites';
import Hashtags from './pages/hashtags';
2022-12-10 12:14:48 +03:00
import Home from './pages/home';
import Lists from './pages/lists';
2022-12-10 12:14:48 +03:00
import Login from './pages/login';
import Notifications from './pages/notifications';
import Public from './pages/public';
2022-12-10 12:14:48 +03:00
import Settings from './pages/settings';
import Status from './pages/status';
import Welcome from './pages/welcome';
import { getAccessToken } from './utils/auth';
2023-01-09 14:11:34 +03:00
import states, { saveStatus } from './utils/states';
2022-12-10 12:14:48 +03:00
import store from './utils/store';
window.__STATES__ = states;
2022-12-10 12:14:48 +03:00
2022-12-31 20:46:08 +03:00
function App() {
2022-12-10 12:14:48 +03:00
const snapStates = useSnapshot(states);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('loading');
const navigate = useNavigate();
2022-12-10 12:14:48 +03:00
useLayoutEffect(() => {
const theme = store.local.get('theme');
if (theme) {
document.documentElement.classList.add(`is-${theme}`);
document
.querySelector('meta[name="color-scheme"]')
.setAttribute('content', theme);
}
}, []);
useEffect(() => {
const instanceURL = store.local.get('instanceURL');
const accounts = store.local.getJSON('accounts') || [];
const code = (window.location.search.match(/code=([^&]+)/) || [])[1];
if (code) {
console.log({ code });
// Clear the code from the URL
window.history.replaceState({}, document.title, '/');
const clientID = store.session.get('clientID');
const clientSecret = store.session.get('clientSecret');
(async () => {
setUIState('loading');
const tokenJSON = await getAccessToken({
instanceURL,
client_id: clientID,
client_secret: clientSecret,
code,
});
const { access_token: accessToken } = tokenJSON;
store.session.set('accessToken', accessToken);
initMasto({
2022-12-10 12:14:48 +03:00
url: `https://${instanceURL}`,
accessToken,
});
const mastoAccount = await masto.v1.accounts.verifyCredentials();
2022-12-10 12:14:48 +03:00
2022-12-27 04:05:54 +03:00
// console.log({ tokenJSON, mastoAccount });
2022-12-10 12:14:48 +03:00
let account = accounts.find((a) => a.info.id === mastoAccount.id);
if (account) {
account.info = mastoAccount;
2023-01-01 15:59:55 +03:00
account.instanceURL = instanceURL.toLowerCase();
2022-12-10 12:14:48 +03:00
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);
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);
2022-12-10 12:14:48 +03:00
initMasto({
url: `https://${instanceURL}`,
accessToken,
});
} else {
setUIState('default');
2022-12-10 12:14:48 +03:00
}
}, []);
let location = useLocation();
states.currentLocation = location.pathname;
const locationDeckMap = {
'/': 'home-page',
'/notifications': 'notifications-page',
};
2022-12-30 15:37:57 +03:00
const focusDeck = () => {
let timer = setTimeout(() => {
const page = document.getElementById(locationDeckMap[location.pathname]);
console.debug('FOCUS', location.pathname, page);
2022-12-30 15:37:57 +03:00
if (page) {
page.focus();
}
}, 100);
return () => clearTimeout(timer);
};
useEffect(focusDeck, [location]);
2022-12-30 15:37:57 +03:00
useEffect(() => {
if (
!snapStates.showCompose &&
!snapStates.showSettings &&
!snapStates.showAccount
) {
focusDeck();
}
}, [snapStates.showCompose, snapStates.showSettings, snapStates.showAccount]);
2022-12-10 12:14:48 +03:00
useEffect(() => {
// HACK: prevent this from running again due to HMR
if (states.init) return;
if (isLoggedIn) {
requestAnimationFrame(startVisibility);
2022-12-10 12:14:48 +03:00
states.init = true;
}
}, [isLoggedIn]);
const { prevLocation } = snapStates;
const backgroundLocation = useRef(prevLocation || null);
const isModalPage = /^\/s\//i.test(location.pathname);
if (isModalPage) {
if (!backgroundLocation.current) backgroundLocation.current = prevLocation;
} else {
backgroundLocation.current = null;
}
console.debug({
backgroundLocation: backgroundLocation.current,
location,
});
const nonRootLocation = useMemo(() => {
const { pathname } = location;
return !/^\/(login|welcome|p)/.test(pathname);
}, [location]);
2022-12-10 12:14:48 +03:00
return (
<>
<Routes location={nonRootLocation || location}>
<Route
path="/"
element={
isLoggedIn ? (
<Home />
) : uiState === 'loading' ? (
<Loader />
) : (
<Welcome />
)
2022-12-10 12:14:48 +03:00
}
/>
<Route path="/login" element={<Login />} />
<Route path="/welcome" element={<Welcome />} />
</Routes>
<Routes location={backgroundLocation.current || location}>
{isLoggedIn && (
<Route path="/notifications" element={<Notifications />} />
)}
{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 />} />}
<Route path="/p/l?/:instance" element={<Public />} />
{/* <Route path="/:anything" element={<NotFound />} /> */}
</Routes>
<Routes>
{isLoggedIn && <Route path="/s/:id" element={<Status />} />}
</Routes>
<nav id="tab-bar" hidden>
<li>
<Link to="/">
<Icon icon="home" alt="Home" size="xl" />
</Link>
</li>
<li>
<Link to="/notifications">
<Icon icon="notification" alt="Notifications" size="xl" />
</Link>
</li>
<li>
<Link to="/bookmarks">
<Icon icon="bookmark" alt="Bookmarks" size="xl" />
</Link>
</li>
</nav>
2022-12-10 12:14:48 +03:00
{!!snapStates.showCompose && (
<Modal>
<Compose
replyToStatus={
typeof snapStates.showCompose !== 'boolean'
? snapStates.showCompose.replyToStatus
: window.__COMPOSE__?.replyToStatus || null
}
editStatus={
states.showCompose?.editStatus ||
window.__COMPOSE__?.editStatus ||
null
}
draftStatus={
states.showCompose?.draftStatus ||
window.__COMPOSE__?.draftStatus ||
null
2022-12-10 12:14:48 +03:00
}
onClose={(results) => {
const { newStatus } = results || {};
2022-12-10 12:14:48 +03:00
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
2022-12-10 12:14:48 +03:00
states.reloadStatusPage++;
2022-12-28 13:05:22 +03:00
setTimeout(() => {
const toast = Toastify({
text: 'Status posted. Check it out.',
duration: 10_000, // 10 seconds
gravity: 'bottom',
position: 'center',
// destination: `/#/s/${newStatus.id}`,
onClick: () => {
toast.hideToast();
states.prevLocation = location;
navigate(`/s/${newStatus.id}`);
2022-12-28 13:05:22 +03:00
},
});
toast.showToast();
}, 1000);
2022-12-10 12:14:48 +03:00
}
}}
/>
</Modal>
)}
{!!snapStates.showSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showSettings = false;
}
}}
>
<Settings
onClose={() => {
states.showSettings = false;
}}
/>
</Modal>
)}
{!!snapStates.showAccount && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showAccount = false;
}
}}
>
<Account account={snapStates.showAccount} />
</Modal>
)}
{!!snapStates.showDrafts && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showDrafts = false;
}
}}
>
<Drafts />
</Modal>
)}
2022-12-10 12:14:48 +03:00
</>
);
}
2022-12-31 20:46:08 +03:00
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).toLowerCase()] = info;
store.local.setJSON('instances', instances);
}
if (streamingApi || streaming) {
window.masto = createClient({
...clientParams,
streamingApiUrl: streaming || streamingApi,
});
}
})();
}
let ws;
2022-12-31 20:46:08 +03:00
async function startStream() {
if (
ws &&
(ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
) {
return;
}
2022-12-31 20:46:08 +03:00
const stream = await masto.v1.stream.streamUser();
console.log('STREAM START', { stream });
ws = stream.ws;
2023-01-01 07:01:54 +03:00
const handleNewStatus = debounce((status) => {
2022-12-31 20:46:08 +03:00
console.log('UPDATE', status);
const inHomeNew = states.homeNew.find((s) => s.id === status.id);
const inHome = status.id === states.homeLast?.id;
2022-12-31 20:46:08 +03:00
if (!inHomeNew && !inHome) {
if (states.settings.boostsCarousel && status.reblog) {
// do nothing
} else {
states.homeNew.unshift({
id: status.id,
reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId,
});
console.log('homeNew 1', [...states.homeNew]);
}
2022-12-31 20:46:08 +03:00
}
2023-01-09 14:11:34 +03:00
saveStatus(status);
2023-01-01 07:01:54 +03:00
}, 5000);
stream.on('update', handleNewStatus);
2022-12-31 20:46:08 +03:00
stream.on('status.update', (status) => {
console.log('STATUS.UPDATE', status);
2023-01-09 14:11:34 +03:00
saveStatus(status);
2022-12-31 20:46:08 +03:00
});
stream.on('delete', (statusID) => {
console.log('DELETE', statusID);
// delete states.statuses[statusID];
const s = states.statuses[statusID];
2022-12-31 20:46:08 +03:00
if (s) s._deleted = true;
});
stream.on('notification', (notification) => {
console.log('NOTIFICATION', notification);
const inNotificationsNew = states.notificationsNew.find(
(n) => n.id === notification.id,
);
const inNotifications = notification.id === states.notificationLast?.id;
2022-12-31 20:46:08 +03:00
if (!inNotificationsNew && !inNotifications) {
states.notificationsNew.unshift(notification);
}
2023-01-09 14:11:34 +03:00
saveStatus(notification.status, { override: false });
2022-12-31 20:46:08 +03:00
});
stream.ws.onclose = () => {
console.log('STREAM CLOSED!');
if (document.visibilityState !== 'hidden') {
2022-12-31 20:46:08 +03:00
startStream();
}
2022-12-31 20:46:08 +03:00
};
return {
stream,
stopStream: () => {
stream.ws.close();
},
};
}
let lastHidden;
2022-12-31 20:46:08 +03:00
function startVisibility() {
const handleVisible = (visible) => {
if (!visible) {
2022-12-31 20:46:08 +03:00
const timestamp = Date.now();
lastHidden = timestamp;
2022-12-31 20:46:08 +03:00
} else {
const timestamp = Date.now();
const diff = timestamp - lastHidden;
const diffMins = Math.round(diff / 1000 / 60);
console.log(`visible: ${visible}`, { lastHidden, diffMins });
if (!lastHidden || diffMins > 1) {
(async () => {
try {
2023-01-27 09:36:47 +03:00
const firstStatusID = states.homeLast?.id;
const firstNotificationID = states.notificationsLast?.id;
const fetchHome = masto.v1.timelines.listHome({
2023-01-27 09:36:47 +03:00
limit: 5,
...(firstStatusID && { sinceId: firstStatusID }),
});
const fetchNotifications = masto.v1.notifications.list({
limit: 1,
...(firstNotificationID && { sinceId: firstNotificationID }),
});
const newStatuses = await fetchHome;
2023-01-27 09:36:47 +03:00
const hasOneAndReblog =
newStatuses.length === 1 && newStatuses?.[0]?.reblog;
2023-01-27 10:17:56 +03:00
if (newStatuses.length) {
2023-01-27 09:36:47 +03:00
if (states.settings.boostsCarousel && hasOneAndReblog) {
// do nothing
} else {
2022-12-31 20:46:08 +03:00
states.homeNew = newStatuses.map((status) => {
2023-01-09 14:11:34 +03:00
saveStatus(status);
2022-12-31 20:46:08 +03:00
return {
id: status.id,
reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId,
};
});
console.log('homeNew 2', [...states.homeNew]);
2022-12-31 20:46:08 +03:00
}
}
2022-12-31 20:46:08 +03:00
const newNotifications = await fetchNotifications;
if (newNotifications.length) {
const notification = newNotifications[0];
const inNotificationsNew = states.notificationsNew.find(
(n) => n.id === notification.id,
);
const inNotifications =
notification.id === states.notificationLast?.id;
if (!inNotificationsNew && !inNotifications) {
states.notificationsNew.unshift(notification);
2022-12-31 20:46:08 +03:00
}
saveStatus(notification.status, { override: false });
2022-12-31 20:46:08 +03:00
}
} catch (e) {
// Silently fail
console.error(e);
} finally {
startStream();
}
})();
2022-12-31 20:46:08 +03:00
}
}
};
const handleVisibilityChange = () => {
const hidden = document.visibilityState === 'hidden';
handleVisible(!hidden);
console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
};
2022-12-31 20:46:08 +03:00
document.addEventListener('visibilitychange', handleVisibilityChange);
requestAnimationFrame(handleVisibilityChange);
2022-12-31 20:46:08 +03:00
return {
stop: () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
},
};
}
export { App };