);
}
}
-function Card({ card, size }) {
+function Card({ card }) {
const {
blurhash,
title,
@@ -876,7 +891,7 @@ function Card({ card, size }) {
const hasText = title || providerName || authorName;
const isLandscape = width / height >= 1.2;
- size = size === 'l' && isLandscape ? 'large' : '';
+ const size = isLandscape ? 'large' : '';
if (hasText && image) {
const domain = new URL(url).hostname.replace(/^www\./, '');
@@ -887,19 +902,20 @@ function Card({ card, size }) {
rel="nofollow noopener noreferrer"
class={`card link ${size}`}
>
-
{
- try {
- e.target.style.display = 'none';
- } catch (e) {}
- }}
- />
+
{domain}
{} }) {
•{' '}
>
)}
- {shortenNumber(votersCount)} {' '}
- {votersCount === 1 ? 'voter' : 'voters'}
- {votersCount !== votesCount && (
+ {shortenNumber(votesCount)} vote
+ {votesCount === 1 ? '' : 's'}
+ {!!votersCount && votersCount !== votesCount && (
<>
{' '}
- •
- {shortenNumber(votesCount)}
- {' '}
- vote
- {votesCount === 1 ? '' : 's'}
+ •{' '}
+ {shortenNumber(votersCount)} {' '}
+ voter
+ {votersCount === 1 ? '' : 's'}
>
)}{' '}
• {expired ? 'Ended' : 'Ending'}{' '}
@@ -1329,8 +1344,10 @@ function Carousel({ mediaAttachments, index = 0, onClose = () => {} }) {
a.info.id === currentAccount) || accounts[0];
- const instanceURL = account.instanceURL;
- const accessToken = account.accessToken;
+ const { instanceURL, accessToken } = getCurrentAccount();
window.masto = await login({
url: `https://${instanceURL}`,
accessToken,
@@ -79,6 +74,8 @@ function App() {
);
}
+ console.debug('OPEN COMPOSE');
+
return (
, document.getElementById('app'));
+
+// Clean up iconify localStorage
+// TODO: Remove this after few weeks?
+setTimeout(() => {
+ try {
+ Object.keys(localStorage).forEach((key) => {
+ if (key.startsWith('iconify')) {
+ localStorage.removeItem(key);
+ }
+ });
+ Object.keys(sessionStorage).forEach((key) => {
+ if (key.startsWith('iconify')) {
+ sessionStorage.removeItem(key);
+ }
+ });
+ } catch (e) {}
+}, 5000);
diff --git a/src/pages/home.jsx b/src/pages/home.jsx
index d3abe301..e4c333e8 100644
--- a/src/pages/home.jsx
+++ b/src/pages/home.jsx
@@ -1,13 +1,15 @@
import { Link } from 'preact-router/match';
+import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
-import { InView } from 'react-intersection-observer';
import { useSnapshot } from 'valtio';
import Icon from '../components/icon';
import Loader from '../components/loader';
import Status from '../components/status';
-import states from '../utils/states';
+import db from '../utils/db';
+import states, { saveStatus } from '../utils/states';
+import { getCurrentAccountNS } from '../utils/store-utils';
import useDebouncedCallback from '../utils/useDebouncedCallback';
import useScroll from '../utils/useScroll';
@@ -18,6 +20,8 @@ function Home({ hidden }) {
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
+ console.debug('RENDER Home');
+
const homeIterator = useRef(
masto.v1.timelines.listHome({
limit: LIMIT,
@@ -36,23 +40,78 @@ function Home({ hidden }) {
return { done: true };
}
const homeValues = allStatuses.value.map((status) => {
- states.statuses.set(status.id, status);
- if (status.reblog) {
- states.statuses.set(status.reblog.id, status.reblog);
- }
+ saveStatus(status);
return {
id: status.id,
reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId,
};
});
- if (firstLoad) {
- states.home = homeValues;
+
+ // BOOSTS CAROUSEL
+ if (snapStates.settings.boostsCarousel) {
+ let specialHome = [];
+ let boostStash = [];
+ let serialBoosts = 0;
+ for (let i = 0; i < homeValues.length; i++) {
+ const status = homeValues[i];
+ if (status.reblog) {
+ boostStash.push(status);
+ serialBoosts++;
+ } else {
+ specialHome.push(status);
+ if (serialBoosts < 3) {
+ serialBoosts = 0;
+ }
+ }
+ }
+ // if boostStash is more than quarter of homeValues
+ // or if there are 3 or more boosts in a row
+ if (boostStash.length > homeValues.length / 4 || serialBoosts >= 3) {
+ // if boostStash is more than 3 quarter of homeValues
+ const boostStashID = boostStash.map((status) => status.id);
+ if (boostStash.length > (homeValues.length * 3) / 4) {
+ // insert boost array at the end of specialHome list
+ specialHome = [
+ ...specialHome,
+ { id: boostStashID, boosts: boostStash },
+ ];
+ } else {
+ // insert boosts array in the middle of specialHome list
+ const half = Math.floor(specialHome.length / 2);
+ specialHome = [
+ ...specialHome.slice(0, half),
+ {
+ id: boostStashID,
+ boosts: boostStash,
+ },
+ ...specialHome.slice(half),
+ ];
+ }
+ } else {
+ // Untouched, this is fine
+ specialHome = homeValues;
+ }
+ console.log({
+ specialHome,
+ });
+ if (firstLoad) {
+ states.home = specialHome;
+ } else {
+ states.home.push(...specialHome);
+ }
} else {
- states.home.push(...homeValues);
+ if (firstLoad) {
+ states.home = homeValues;
+ } else {
+ states.home.push(...homeValues);
+ }
}
+
states.homeLastFetchTime = Date.now();
- return allStatuses;
+ return {
+ done: false,
+ };
}
const loadingStatuses = useRef(false);
@@ -80,106 +139,141 @@ function Home({ hidden }) {
const scrollableRef = useRef();
- useHotkeys('j', () => {
+ useHotkeys('j, shift+j', (_, handler) => {
// focus on next status after active status
// Traverses .timeline li .status-link, focus on .status-link
- const activeStatus = document.activeElement.closest('.status-link');
+ const activeStatus = document.activeElement.closest(
+ '.status-link, .status-boost-link',
+ );
const activeStatusRect = activeStatus?.getBoundingClientRect();
+ const allStatusLinks = Array.from(
+ scrollableRef.current.querySelectorAll(
+ '.status-link, .status-boost-link',
+ ),
+ );
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
- const nextStatus = activeStatus.parentElement.nextElementSibling;
+ const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
+ let nextStatus = allStatusLinks[activeStatusIndex + 1];
+ if (handler.shift) {
+ // get next status that's not .status-boost-link
+ nextStatus = allStatusLinks.find(
+ (statusLink, index) =>
+ index > activeStatusIndex &&
+ !statusLink.classList.contains('status-boost-link'),
+ );
+ }
if (nextStatus) {
- const statusLink = nextStatus.querySelector('.status-link');
- if (statusLink) {
- statusLink.focus();
- }
+ nextStatus.focus();
+ nextStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
- const statusLinks = document.querySelectorAll(
- '.timeline li .status-link',
- );
- let topmostStatusLink;
- for (const statusLink of statusLinks) {
+ const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
- if (statusLinkRect.top >= 44) {
- // 44 is the magic number for header height, not real
- topmostStatusLink = statusLink;
- break;
- }
- }
+ return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
+ });
if (topmostStatusLink) {
topmostStatusLink.focus();
+ topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
- useHotkeys('k', () => {
+ useHotkeys('k. shift+k', () => {
// focus on previous status after active status
// Traverses .timeline li .status-link, focus on .status-link
- const activeStatus = document.activeElement.closest('.status-link');
+ const activeStatus = document.activeElement.closest(
+ '.status-link, .status-boost-link',
+ );
const activeStatusRect = activeStatus?.getBoundingClientRect();
+ const allStatusLinks = Array.from(
+ scrollableRef.current.querySelectorAll(
+ '.status-link, .status-boost-link',
+ ),
+ );
if (
activeStatus &&
activeStatusRect.top < scrollableRef.current.clientHeight &&
activeStatusRect.bottom > 0
) {
- const prevStatus = activeStatus.parentElement.previousElementSibling;
+ const activeStatusIndex = allStatusLinks.indexOf(activeStatus);
+ let prevStatus = allStatusLinks[activeStatusIndex - 1];
+ if (handler.shift) {
+ // get prev status that's not .status-boost-link
+ prevStatus = allStatusLinks.find(
+ (statusLink, index) =>
+ index < activeStatusIndex &&
+ !statusLink.classList.contains('status-boost-link'),
+ );
+ }
if (prevStatus) {
- const statusLink = prevStatus.querySelector('.status-link');
- if (statusLink) {
- statusLink.focus();
- }
+ prevStatus.focus();
+ prevStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
- const statusLinks = document.querySelectorAll(
- '.timeline li .status-link',
- );
- let topmostStatusLink;
- for (const statusLink of statusLinks) {
+ const topmostStatusLink = allStatusLinks.find((statusLink) => {
const statusLinkRect = statusLink.getBoundingClientRect();
- if (statusLinkRect.top >= 44) {
- // 44 is the magic number for header height, not real
- topmostStatusLink = statusLink;
- break;
- }
- }
+ return statusLinkRect.top >= 44 && statusLinkRect.left >= 0; // 44 is the magic number for header height, not real
+ });
if (topmostStatusLink) {
topmostStatusLink.focus();
+ topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
useHotkeys(['enter', 'o'], () => {
// open active status
- const activeStatus = document.activeElement.closest('.status-link');
+ const activeStatus = document.activeElement.closest(
+ '.status-link, .status-boost-link',
+ );
if (activeStatus) {
activeStatus.click();
}
});
- const { scrollDirection, reachTop, nearReachTop, nearReachBottom } =
- useScroll({
- scrollableElement: scrollableRef.current,
- distanceFromTop: 0.1,
- distanceFromBottom: 0.15,
- });
+ const {
+ scrollDirection,
+ reachStart,
+ nearReachStart,
+ nearReachEnd,
+ reachEnd,
+ } = useScroll({
+ scrollableElement: scrollableRef.current,
+ distanceFromStart: 1,
+ distanceFromEnd: 3,
+ scrollThresholdStart: 44,
+ });
useEffect(() => {
- if (nearReachBottom && showMore) {
+ if (nearReachEnd || (reachEnd && showMore)) {
loadStatuses();
}
- }, [nearReachBottom]);
+ }, [nearReachEnd, reachEnd]);
useEffect(() => {
- if (reachTop) {
+ if (reachStart) {
loadStatuses(true);
}
- }, [reachTop]);
+ }, [reachStart]);
+
+ useEffect(() => {
+ (async () => {
+ const keys = await db.drafts.keys();
+ if (keys.length) {
+ const ns = getCurrentAccountNS();
+ const ownKeys = keys.filter((key) => key.startsWith(ns));
+ if (ownKeys.length) {
+ states.showDrafts = true;
+ }
+ }
+ })();
+ }, []);
return (
{
@@ -209,7 +303,7 @@ function Home({ hidden }) {
{
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
@@ -247,17 +341,19 @@ function Home({ hidden }) {
{snapStates.homeNew.length > 0 &&
- scrollDirection === 'up' &&
- !nearReachTop &&
- !nearReachBottom && (
+ scrollDirection === 'start' &&
+ !nearReachStart &&
+ !nearReachEnd && (
{
- const uniqueHomeNew = snapStates.homeNew.filter(
- (status) => !states.home.some((s) => s.id === status.id),
- );
- states.home.unshift(...uniqueHomeNew);
+ if (!snapStates.settings.boostsCarousel) {
+ const uniqueHomeNew = snapStates.homeNew.filter(
+ (status) => !states.home.some((s) => s.id === status.id),
+ );
+ states.home.unshift(...uniqueHomeNew);
+ }
loadStatuses(true);
states.homeNew = [];
@@ -273,8 +369,15 @@ function Home({ hidden }) {
{snapStates.home.length ? (
<>
- {snapStates.home.map(({ id: statusID, reblog }) => {
+ {snapStates.home.map(({ id: statusID, reblog, boosts }) => {
const actualStatusID = reblog || statusID;
+ if (boosts) {
+ return (
+
+
+
+ );
+ }
return (
{
+ init?.();
+ }, []);
+
+ return (
+
+
+ {boosts.length} Boosts
+
+ {
+ carouselRef.current?.scrollBy({
+ left: -Math.min(320, carouselRef.current?.offsetWidth),
+ behavior: 'smooth',
+ });
+ }}
+ >
+
+ {' '}
+ {
+ carouselRef.current?.scrollBy({
+ left: Math.min(320, carouselRef.current?.offsetWidth),
+ behavior: 'smooth',
+ });
+ }}
+ >
+
+
+
+
+
+ {boosts.map((boost) => {
+ const { id: statusID, reblog } = boost;
+ const actualStatusID = reblog || statusID;
+ return (
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+export default memo(Home);
diff --git a/src/pages/notifications.jsx b/src/pages/notifications.jsx
index cf425c83..738f6971 100644
--- a/src/pages/notifications.jsx
+++ b/src/pages/notifications.jsx
@@ -1,6 +1,7 @@
import './notifications.css';
import { Link } from 'preact-router/match';
+import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
@@ -84,32 +85,42 @@ function Notification({ notification }) {
{type !== 'mention' && (
-
- {!/poll|update/i.test(type) && (
- <>
- {_accounts?.length > 1 ? (
- <>
- {_accounts.length} people {' '}
- >
- ) : (
- <>
- {' '}
- >
- )}
- >
+ <>
+
+ {!/poll|update/i.test(type) && (
+ <>
+ {_accounts?.length > 1 ? (
+ <>
+ {_accounts.length} people {' '}
+ >
+ ) : (
+ <>
+ {' '}
+ >
+ )}
+ >
+ )}
+ {text}
+ {type === 'mention' && (
+
+ {' '}
+ •{' '}
+
+
+ )}
+
+ {type === 'follow_request' && (
+
{
+ loadNotifications(true);
+ }}
+ />
)}
- {text}
- {type === 'mention' && (
-
- {' '}
- •{' '}
-
-
- )}
-
+ >
)}
{_accounts?.length > 1 && (
@@ -205,6 +216,8 @@ function Notifications() {
const [showMore, setShowMore] = useState(false);
const [onlyMentions, setOnlyMentions] = useState(false);
+ console.debug('RENDER Notifications');
+
const notificationsIterator = useRef(
masto.v1.notifications.list({
limit: LIMIT,
@@ -224,7 +237,7 @@ function Notifications() {
}
const notificationsValues = allNotifications.value.map((notification) => {
if (notification.status) {
- states.statuses.set(notification.status.id, notification.status);
+ states.statuses[notification.status.id] = notification.status;
}
return notification;
});
@@ -411,4 +424,50 @@ function Notifications() {
);
}
-export default Notifications;
+function FollowRequestButtons({ accountID, onChange }) {
+ const [uiState, setUIState] = useState('default');
+ return (
+
+ {
+ setUIState('loading');
+ (async () => {
+ try {
+ await masto.v1.followRequests.authorize(accountID);
+ onChange();
+ } catch (e) {
+ console.error(e);
+ setUIState('default');
+ }
+ })();
+ }}
+ >
+ Accept
+ {' '}
+ {
+ setUIState('loading');
+ (async () => {
+ try {
+ await masto.v1.followRequests.reject(accountID);
+ onChange();
+ } catch (e) {
+ console.error(e);
+ setUIState('default');
+ }
+ })();
+ }}
+ >
+ Reject
+
+
+
+ );
+}
+
+export default memo(Notifications);
diff --git a/src/pages/settings.css b/src/pages/settings.css
index 6805b463..0f786504 100644
--- a/src/pages/settings.css
+++ b/src/pages/settings.css
@@ -1,12 +1,28 @@
+#settings-container {
+ background-color: var(--bg-faded-color);
+}
+
#settings-container h2 {
- font-size: 0.9em;
+ font-size: 85%;
text-transform: uppercase;
color: var(--text-insignificant-color);
+ font-weight: normal;
}
#settings-container h2 ~ h2 {
margin-top: 2em;
}
+#settings-container :is(section, .section) {
+ background-color: var(--bg-color);
+ margin: 0 -16px;
+ padding: 8px 16px;
+ border-top: var(--hairline-width) solid var(--outline-color);
+ border-bottom: var(--hairline-width) solid var(--outline-color);
+}
+#settings-container :is(section, .section) > li + li {
+ border-top: var(--hairline-width) solid var(--outline-color);
+}
+
#settings-container ul {
margin: 0;
padding: 0;
@@ -82,3 +98,10 @@
#settings-container .radio-group label:has(input:checked) input:checked + span {
color: inherit;
}
+
+@media (min-width: 40em) {
+ #settings-container :is(section, .section) {
+ margin-inline: 0;
+ border-radius: 8px;
+ }
+}
diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx
index fbc6ef37..60b55d38 100644
--- a/src/pages/settings.jsx
+++ b/src/pages/settings.jsx
@@ -1,6 +1,7 @@
import './settings.css';
import { useRef, useState } from 'preact/hooks';
+import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar';
import Icon from '../components/icon';
@@ -16,6 +17,7 @@ import store from '../utils/store';
*/
function Settings({ onClose }) {
+ const snapStates = useSnapshot(states);
// Accounts
const accounts = store.local.getJSON('accounts');
const currentAccount = store.session.get('currentAccount');
@@ -31,187 +33,234 @@ function Settings({ onClose }) {
*/}
Accounts
-
- {accounts.map((account, i) => {
- const isCurrent = account.info.id === currentAccount;
- const isDefault = i === (currentDefault || 0);
- return (
-
-
- {moreThanOneAccount && (
-
-
-
- )}
-
-
{
- states.showAccount = `${account.info.username}@${account.instanceURL}`;
- }}
- />
-
-
- {isDefault && moreThanOneAccount && (
- <>
-
Default {' '}
- >
- )}
- {!isCurrent && (
-
{
- store.session.set('currentAccount', account.info.id);
- location.reload();
- }}
- >
- Switch
-
- )}
+
+
+ {accounts.map((account, i) => {
+ const isCurrent = account.info.id === currentAccount;
+ const isDefault = i === (currentDefault || 0);
+ return (
+
- {!isDefault && moreThanOneAccount && (
+ {moreThanOneAccount && (
+
+
+
+ )}
+
+
{
+ states.showAccount = `${account.info.username}@${account.instanceURL}`;
+ }}
+ />
+
+
+ {isDefault && moreThanOneAccount && (
+ <>
+
Default {' '}
+ >
+ )}
+ {!isCurrent && (
{
- // Move account to the top of the list
- accounts.splice(i, 1);
- accounts.unshift(account);
- store.local.setJSON('accounts', accounts);
- setCurrentDefault(i);
+ store.session.set('currentAccount', account.info.id);
+ location.reload();
}}
>
- Set as default
+ Switch
)}
- {isCurrent && (
- <>
- {' '}
+
+ {!isDefault && moreThanOneAccount && (
{
- const yes = confirm(
- 'Are you sure you want to log out?',
- );
- if (!yes) return;
+ // Move account to the top of the list
accounts.splice(i, 1);
+ accounts.unshift(account);
store.local.setJSON('accounts', accounts);
- location.reload();
+ setCurrentDefault(i);
}}
>
- Log out
+ Set as default
- >
- )}
+ )}
+ {isCurrent && (
+ <>
+ {' '}
+ {
+ const yes = confirm(
+ 'Are you sure you want to log out?',
+ );
+ if (!yes) return;
+ accounts.splice(i, 1);
+ store.local.setJSON('accounts', accounts);
+ location.reload();
+ }}
+ >
+ Log out
+
+ >
+ )}
+
+
+ );
+ })}
+
+ {moreThanOneAccount && (
+
+
+ Note: Default account will always be used for first load.
+ Switched accounts will persist during the session.
+
+
+ )}
+
+
+ Add new account
+
+
+
+
Settings
+
- {moreThanOneAccount && (
-
-
- Note: Default account will always be used for first load.
- Switched accounts will persist during the session.
-
-
- )}
-
-
- Add new account
-
-
-
Theme
-