Compare commits

..

No commits in common. "96f1a9d9f24e0a8d16754258c697858ef1fbbad4" and "9d813802a9bffeb08ba92172ebb9eddb216319b6" have entirely different histories.

65 changed files with 2189 additions and 2762 deletions

View file

@ -12,5 +12,5 @@ jobs:
- uses: actions/checkout@v4
with:
ref: production
- run: git tag "`date +%Y.%m.%d`.`git rev-parse --short HEAD`" $(git rev-parse HEAD)
- run: git push --tags
- run: git tag -a "'{date +%Y.%m.%d}.{git rev-parse --short HEAD}'" $(git rev-parse HEAD)
- run: git push

View file

@ -1,25 +0,0 @@
name: Create Release on every tag push in `production`
on:
push:
branches:
- production
tags:
- '*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: production
- uses: actions/setup-node@v3
with:
node-version: 18
- run: npm ci && npm run build
- run: cd dist && zip -r ../phanpy-dist.zip . && cd ..
- uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
files: phanpy-dist.zip

1
.gitignore vendored
View file

@ -26,7 +26,6 @@ dist-ssr
# Custom
.env.dev
src/data/instances-full.json
phanpy-dist.zip
# Nix
.direnv

View file

@ -8,7 +8,6 @@
"index.css$",
".css$",
"<THIRD_PARTY_MODULES>",
"/assets/",
"^../",
"^[./]"
],

View file

@ -121,7 +121,6 @@ Try search for "how to self-host static sites" as there are many ways to do it.
- [React Router](https://reactrouter.com/) - Routing
- [masto.js](https://github.com/neet/masto.js/) - Mastodon API client
- [Iconify](https://iconify.design/) - Icon library
- [MingCute icons](https://www.mingcute.com/)
- Vanilla CSS - *Yes, I'm old school.*
Some of these may change in the future. The front-end world is ever-changing.

View file

@ -5,11 +5,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
@ -20,16 +20,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1697059129,
"narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
"lastModified": 1689413807,
"narHash": "sha256-exuzOvOhGAEKWQKwDuZAL4N8a1I837hH5eocaTcIbLc=",
"owner": "nixOS",
"repo": "nixpkgs",
"rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
"rev": "46ed466081b9cad1125b11f11a2af5cc40b942c7",
"type": "github"
},
"original": {
"owner": "nixOS",
"ref": "nixos-unstable",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}

View file

@ -1,5 +1,5 @@
{
inputs.nixpkgs.url = github:nixOS/nixpkgs/nixos-unstable;
inputs.nixpkgs.url = github:nixOS/nixpkgs/nixpkgs-unstable;
inputs.flake-utils.url = github:numtide/flake-utils;
outputs = { self, nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system:
@ -7,7 +7,7 @@
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
in
rec {
{
packages.default = pkgs.buildNpmPackage {
pname = "dtth-phanpy";
version = "0.1.0";
@ -16,7 +16,7 @@
src = lib.cleanSource ./.;
npmDepsHash = "sha256-LpvZfIzIdgxXg4upcDKm7jbK7CjrRvg//HULO4GDTdU=";
npmDepsHash = "sha256-tqR3YQ++nJmwDNKIm7uFLhJ5HlAqfeEmJVyynHx3Hzw=";
# npmDepsHash = lib.fakeHash;
# DTTH-specific env variables
@ -33,7 +33,6 @@
};
devShells.default = pkgs.mkShell {
inputsFrom = [ packages.default ];
buildInputs = with pkgs; [ nodejs ];
};
});

2326
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,25 +12,25 @@
"dependencies": {
"@formatjs/intl-localematcher": "~0.4.2",
"@github/text-expander-element": "~2.5.0",
"@iconify-icons/mingcute": "~1.2.8",
"@iconify-icons/mingcute": "~1.2.7",
"@justinribeiro/lite-youtube": "~1.5.0",
"@szhsin/react-menu": "~4.1.0",
"@uidotdev/usehooks": "~2.4.0",
"dayjs": "~1.11.10",
"@szhsin/react-menu": "~4.0.3",
"@uidotdev/usehooks": "~2.2.0",
"dayjs": "~1.11.9",
"dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3",
"idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0",
"masto": "~6.3.1",
"moize": "~6.1.6",
"p-retry": "~6.1.0",
"masto": "~5.11.4",
"mem": "~9.0.2",
"p-retry": "~6.0.0",
"p-throttle": "~5.1.0",
"preact": "~10.18.1",
"preact": "~10.17.1",
"react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.2",
"react-quick-pinch-zoom": "~5.0.0",
"react-quick-pinch-zoom": "~4.9.0",
"react-router-dom": "6.6.2",
"string-length": "5.0.1",
"swiped-events": "~1.1.7",
@ -42,13 +42,13 @@
"valtio": "1.9.0"
},
"devDependencies": {
"@preact/preset-vite": "~2.6.0",
"@preact/preset-vite": "~2.5.0",
"@trivago/prettier-plugin-sort-imports": "~4.2.0",
"postcss": "~8.4.31",
"postcss": "~8.4.29",
"postcss-dark-theme-class": "~1.0.0",
"postcss-preset-env": "~9.2.0",
"postcss-preset-env": "~9.1.3",
"twitter-text": "~3.1.0",
"vite": "~4.4.11",
"vite": "~4.4.9",
"vite-plugin-generate-file": "~0.0.4",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.16.5",

View file

@ -161,40 +161,52 @@ self.addEventListener('notificationclick', (event) => {
console.log('NOTIFICATION CLICK payload', payload);
const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload;
const { access_token, notification_type } = data;
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
event.notification.close();
event.waitUntil(
(async () => {
const clients = await self.clients.matchAll({
const actions = new Promise((resolve) => {
event.notification.close();
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
self.clients
.matchAll({
type: 'window',
includeUncontrolled: true,
});
console.log('NOTIFICATION CLICK clients 1', clients);
if (clients.length && 'navigate' in clients[0]) {
console.log('NOTIFICATION CLICK clients 2', clients);
const bestClient =
clients.find(
(client) => client.focused || client.visibilityState === 'visible',
) || clients[0];
console.log('NOTIFICATION CLICK navigate', url);
if (bestClient) {
console.log('NOTIFICATION CLICK postMessage', bestClient);
bestClient.postMessage?.({
type: 'notification',
id: tag,
accessToken: access_token,
});
bestClient.focus();
})
.then((clients) => {
console.log('NOTIFICATION CLICK clients 1', clients);
if (clients.length && 'navigate' in clients[0]) {
console.log('NOTIFICATION CLICK clients 2', clients);
const bestClient =
clients.find(
(client) =>
client.focused || client.visibilityState === 'visible',
) || clients[0];
console.log('NOTIFICATION CLICK navigate', url);
// Check if URL is root / or /notifications
// const clientURL = new URL(bestClient.url);
// if (
// /^#\/?$/.test(clientURL.hash) ||
// /^#\/notifications/i.test(clientURL.hash)
// ) {
// bestClient.navigate(url).then((client) => client?.focus());
// } else {
// User might be on a different page (e.g. composing a post), so don't navigate anywhere else
if (bestClient) {
console.log('NOTIFICATION CLICK postMessage', bestClient);
bestClient.postMessage?.({
type: 'notification',
id: tag,
accessToken: access_token,
});
bestClient.focus();
} else {
console.log('NOTIFICATION CLICK openWindow', url);
self.clients.openWindow(url);
}
// }
} else {
console.log('NOTIFICATION CLICK openWindow', url);
await self.clients.openWindow(url);
self.clients.openWindow(url);
}
// }
} else {
console.log('NOTIFICATION CLICK openWindow', url);
await self.clients.openWindow(url);
}
})(),
);
resolve();
});
});
event.waitUntil(actions);
});

View file

@ -1087,7 +1087,6 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
top: env(safe-area-inset-top, 0);
}
:is(.carousel-top-controls, .carousel-controls) {
/* mix-blend-mode: luminosity; */
position: absolute;
left: 0;
left: env(safe-area-inset-left, 0);
@ -1120,10 +1119,11 @@ button.carousel-dot {
button.carousel-dot {
background-color: transparent;
}
:is(.button, button).carousel-button {
.carousel-controls :is(.button, button).carousel-button {
background-color: var(--bg-blur-color);
}
:is(.button, button).carousel-button:is(:hover, :focus):not(:active) {
.carousel-controls
:is(.button, button).carousel-button:is(:hover, :focus):not(:active) {
background-color: var(--bg-color);
}
.carousel-top-controls .szh-menu-container {
@ -1140,19 +1140,15 @@ button.carousel-dot {
border: 1px solid var(--outline-color);
box-shadow: 0 4px 32px var(--drop-shadow-color);
/* backdrop-filter: blur(12px) invert(0.25); */
transition: background-color 0.2s ease-out;
&:hover {
background-color: var(--bg-color);
}
}
button.carousel-dot {
backdrop-filter: none !important;
border: none;
box-shadow: none;
}
/* button.carousel-dot[disabled] {
button.carousel-dot[disabled] {
pointer-events: none;
} */
}
button.carousel-dot .icon {
transition: all 0.2s;
transform: scale(0.5);
@ -1335,16 +1331,12 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
position: relative;
}
.sheet-max {
width: 90vw;
width: 90dvw;
max-width: none;
height: 90vh;
height: 90dvh;
}
@media (min-width: 40em) {
.sheet {
width: 90vw;
width: 90dvw;
}
}
.sheet .sheet-close {
position: absolute;
border-radius: 0;
@ -1431,10 +1423,6 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
display: inline-block;
margin: 4px;
align-self: center;
&.clickable {
cursor: pointer;
}
}
.tag .icon {
vertical-align: middle;
@ -1754,7 +1742,7 @@ meter.donut[hidden] {
font-weight: 500;
text-shadow: 0 1px var(--bg-color);
background-color: var(--bg-color);
border: 2px solid var(--link-faded-color);
border: 1px solid var(--outline-color);
box-shadow: 0 3px 16px var(--drop-shadow-color),
0 6px 16px -3px var(--drop-shadow-color);
}
@ -1762,7 +1750,8 @@ meter.donut[hidden] {
color: var(--text-color);
border-color: var(--link-color);
filter: none !important;
box-shadow: 0 3px 16px var(--drop-shadow-color),
box-shadow: 0 0 0 1px var(--link-text-color),
0 3px 16px var(--drop-shadow-color),
0 6px 16px -3px var(--drop-shadow-color),
0 6px 16px var(--drop-shadow-color);
}

View file

@ -114,31 +114,28 @@ function App() {
code,
});
const client = initClient({ instance: instanceURL, accessToken });
const masto = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([
initInstance(client, instanceURL),
initAccount(client, instanceURL, accessToken, vapidKey),
initInstance(masto, instanceURL),
initAccount(masto, instanceURL, accessToken, vapidKey),
]);
initStates();
initPreferences(client);
initPreferences(masto);
setIsLoggedIn(true);
setUIState('default');
})();
} else {
window.__IGNORE_GET_ACCOUNT_ERROR__ = true;
const account = getCurrentAccount();
if (account) {
store.session.set('currentAccount', account.info.id);
const { client } = api({ account });
const { instance } = client;
// console.log('masto', masto);
initStates();
initPreferences(client);
const { masto, instance } = api({ account });
console.log('masto', masto);
initPreferences(masto);
setUIState('loading');
(async () => {
try {
await initInstance(client, instance);
await initInstance(masto, instance);
} catch (e) {
} finally {
setIsLoggedIn(true);
@ -254,9 +251,9 @@ function App() {
<Shortcuts />
)}
<Modals />
{isLoggedIn && <NotificationService />}
<NotificationService />
<BackgroundService isLoggedIn={isLoggedIn} />
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
<SearchCommand onClose={focusDeck} />
<KeyboardShortcutsHelp />
</>
);

View file

@ -3,6 +3,7 @@ import './account-block.css';
// import { useNavigate } from 'react-router-dom';
import enhanceContent from '../utils/enhance-content';
import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number';
import states from '../utils/states';
import Avatar from './avatar';

View file

@ -220,7 +220,6 @@
}
.account-container .actions {
margin-block: 8px;
display: flex;
gap: 8px;
justify-content: space-between;
@ -343,82 +342,23 @@
opacity: 1;
}
}
.account-container .posting-stats-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
color: inherit;
background-color: var(--bg-faded-color);
padding: 8px 12px;
.account-container .posting-stats {
font-size: 90%;
color: var(--text-insignificant-color);
line-height: 1;
vertical-align: text-top;
border-radius: 4px;
&:is(:hover, :focus-within) {
color: var(--text-color);
background-color: var(--link-bg-hover-color);
filter: none !important;
}
.loader-container {
margin: 0;
opacity: 0.5;
transform: scale(0.75);
}
}
@keyframes wobble {
0% {
transform: rotate(-4deg);
}
100% {
transform: rotate(4deg);
}
}
@keyframes loading-spin {
0% {
transform: rotate(0deg) scale(0.75);
}
100% {
transform: rotate(360deg) scale(0.75);
}
}
.posting-stats-icon {
display: inline-block;
width: 24px;
height: 8px;
filter: opacity(0.75);
animation: wobble 2s linear both infinite alternate !important;
&.loading {
animation: loading-spin 0.35s linear both infinite !important;
}
}
.account-container {
--posting-stats-size: 8px;
background-color: var(--bg-faded-color);
padding: 8px 12px;
--size: 8px;
--original-color: var(--link-color);
.posting-stats {
font-size: 90%;
color: var(--text-insignificant-color);
background-color: var(--bg-faded-color);
padding: 8px 12px;
&:is(:hover, :focus-within) {
background-color: var(--link-bg-hover-color);
}
&:is(:hover, :focus-within) {
background-color: var(--link-bg-hover-color);
}
.posting-stats-bar {
--gap: 0.5px;
--gap-color: var(--outline-color);
height: var(--posting-stats-size);
border-radius: var(--posting-stats-size);
height: var(--size);
border-radius: var(--size);
overflow: hidden;
margin: 8px 0;
box-shadow: inset 0 0 0 1px var(--outline-color),
@ -448,9 +388,9 @@
.posting-stats-legend-item {
display: inline-block;
width: var(--posting-stats-size);
height: var(--posting-stats-size);
border-radius: var(--posting-stats-size);
width: var(--size);
height: var(--size);
border-radius: var(--size);
background-color: var(--text-insignificant-color);
vertical-align: middle;
margin: 0 4px 2px;

View file

@ -1,21 +1,14 @@
import './account-info.css';
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'preact/hooks';
import { useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
import { proxy, useSnapshot } from 'valtio';
import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { hideAllModals } from '../utils/states';
@ -56,64 +49,8 @@ const MUTE_DURATIONS_LABELS = {
const LIMIT = 80;
const ACCOUNT_INFO_MAX_AGE = 1000 * 60 * 10; // 10 mins
function fetchFamiliarFollowers(currentID, masto) {
return masto.v1.accounts.familiarFollowers.fetch({
id: [currentID],
});
}
const memFetchFamiliarFollowers = pmem(fetchFamiliarFollowers, {
maxAge: ACCOUNT_INFO_MAX_AGE,
});
async function fetchPostingStats(accountID, masto) {
const fetchStatuses = masto.v1.accounts
.$select(accountID)
.statuses.list({
limit: 20,
})
.next();
const { value: statuses } = await fetchStatuses;
console.log('fetched statuses', statuses);
const stats = {
total: statuses.length,
originals: 0,
replies: 0,
boosts: 0,
};
// Categories statuses by type
// - Original posts (not replies to others)
// - Threads (self-replies + 1st original post)
// - Boosts (reblogs)
// - Replies (not-self replies)
statuses.forEach((status) => {
if (status.reblog) {
stats.boosts++;
} else if (
!!status.inReplyToId &&
status.inReplyToAccountId !== status.account.id // Not self-reply
) {
stats.replies++;
} else {
stats.originals++;
}
});
// Count days since last post
if (statuses.length) {
stats.daysSinceLastPost = Math.ceil(
(Date.now() - new Date(statuses[statuses.length - 1].createdAt)) /
86400000,
);
}
console.log('posting stats', stats);
return stats;
}
const memFetchPostingStats = pmem(fetchPostingStats, {
maxAge: ACCOUNT_INFO_MAX_AGE,
const accountInfoStates = proxy({
familiarFollowers: [],
});
function AccountInfo({
@ -126,10 +63,10 @@ function AccountInfo({
const { masto } = api({
instance,
});
const { masto: currentMasto } = api();
const [uiState, setUIState] = useState('default');
const isString = typeof account === 'string';
const [info, setInfo] = useState(isString ? null : account);
const snapAccountInfoStates = useSnapshot(accountInfoStates);
const isSelf = useMemo(
() => account.id === store.session.get('currentAccount'),
@ -184,7 +121,6 @@ function AccountInfo({
username,
memorial,
moved,
roles,
} = info || {};
let headerIsAvatar = false;
let { header, headerStatic } = info || {};
@ -198,19 +134,13 @@ function AccountInfo({
}
}
const accountInstance = useMemo(() => {
if (!url) return null;
const domain = new URL(url).hostname;
return domain;
}, [url]);
const [headerCornerColors, setHeaderCornerColors] = useState([]);
const followersIterator = useRef();
const familiarFollowersCache = useRef([]);
async function fetchFollowers(firstLoad) {
if (firstLoad || !followersIterator.current) {
followersIterator.current = masto.v1.accounts.$select(id).followers.list({
followersIterator.current = masto.v1.accounts.listFollowers(id, {
limit: LIMIT,
});
}
@ -223,9 +153,9 @@ function AccountInfo({
// On first load, fetch familiar followers, merge to top of results' `value`
// Remove dups on every fetch
if (firstLoad) {
const familiarFollowers = await masto.v1.accounts
.familiarFollowers(id)
.fetch();
const familiarFollowers = await masto.v1.accounts.fetchFamiliarFollowers(
id,
);
familiarFollowersCache.current = familiarFollowers[0].accounts;
newValue = [
...familiarFollowersCache.current,
@ -254,7 +184,7 @@ function AccountInfo({
const followingIterator = useRef();
async function fetchFollowing(firstLoad) {
if (firstLoad || !followingIterator.current) {
followingIterator.current = masto.v1.accounts.$select(id).following.list({
followingIterator.current = masto.v1.accounts.listFollowing(id, {
limit: LIMIT,
});
}
@ -265,51 +195,6 @@ function AccountInfo({
const LinkOrDiv = standalone ? 'div' : Link;
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
const [familiarFollowers, setFamiliarFollowers] = useState([]);
const [postingStats, setPostingStats] = useState();
const [postingStatsUIState, setPostingStatsUIState] = useState('default');
const hasPostingStats = !!postingStats?.total;
const renderFamiliarFollowers = async (currentID) => {
try {
const followers = await memFetchFamiliarFollowers(
currentID,
currentMasto,
);
console.log('fetched familiar followers', followers);
setFamiliarFollowers(
followers[0].accounts.slice(0, FAMILIAR_FOLLOWERS_LIMIT),
);
} catch (e) {
console.error(e);
}
};
const renderPostingStats = async () => {
if (!id) return;
setPostingStatsUIState('loading');
try {
const stats = await memFetchPostingStats(id, masto);
setPostingStats(stats);
setPostingStatsUIState('default');
} catch (e) {
console.error(e);
setPostingStatsUIState('error');
}
};
const onRelationshipChange = useCallback(
({ relationship, currentID }) => {
if (!relationship.following) {
renderFamiliarFollowers(currentID);
if (!standalone) {
renderPostingStats();
}
}
},
[standalone, id],
);
return (
<div
class={`account-container ${uiState === 'loading' ? 'skeleton' : ''}`}
@ -344,7 +229,7 @@ function AccountInfo({
<p> </p>
<p> </p>
</div>
<div class="stats">
<p class="stats">
<div>
<span></span> Followers
</div>
@ -355,7 +240,7 @@ function AccountInfo({
<span></span> Posts
</div>
<div>Joined </div>
</div>
</p>
</main>
</>
) : (
@ -496,20 +381,8 @@ function AccountInfo({
<Icon icon="group" /> Group
</span>
)}
{roles?.map((role) => (
<span class="tag">
{role.name}
{!!accountInstance && (
<>
{' '}
<span class="more-insignificant">{accountInstance}</span>
</>
)}
</span>
))}
<div
class="note"
dir="auto"
onClick={handleContentLinks({
instance,
})}
@ -526,7 +399,6 @@ function AccountInfo({
verifiedAt ? 'profile-verified' : ''
}`}
key={name + i}
dir="auto"
>
<b>
<EmojiText text={name} emojis={emojis} />{' '}
@ -555,17 +427,19 @@ function AccountInfo({
};
}}
>
{!!familiarFollowers.length && (
{!!snapAccountInfoStates.familiarFollowers.length && (
<span class="shazam-container-horizontal">
<span class="shazam-container-inner stats-avatars-bunch">
{familiarFollowers.map((follower) => (
<Avatar
url={follower.avatarStatic}
size="s"
alt={`${follower.displayName} @${follower.acct}`}
squircle={follower?.bot}
/>
))}
{(snapAccountInfoStates.familiarFollowers || []).map(
(follower) => (
<Avatar
url={follower.avatarStatic}
size="s"
alt={`${follower.displayName} @${follower.acct}`}
squircle={follower?.bot}
/>
),
)}
</span>
</span>
)}
@ -620,112 +494,11 @@ function AccountInfo({
)}
</div>
</div>
{!!postingStats && (
<LinkOrDiv
to={accountLink}
class="account-metadata-box"
onClick={() => {
states.showAccount = false;
}}
>
<div class="shazam-container">
<div class="shazam-container-inner">
{hasPostingStats ? (
<div
class="posting-stats"
title={`${Math.round(
(postingStats.originals / postingStats.total) * 100,
)}% original posts, ${Math.round(
(postingStats.replies / postingStats.total) * 100,
)}% replies, ${Math.round(
(postingStats.boosts / postingStats.total) * 100,
)}% boosts`}
>
<div>
{postingStats.daysSinceLastPost < 365
? `Last ${postingStats.total} posts in the past
${postingStats.daysSinceLastPost} day${
postingStats.daysSinceLastPost > 1 ? 's' : ''
}`
: `
Last ${postingStats.total} posts in the past year(s)
`}
</div>
<div
class="posting-stats-bar"
style={{
// [originals | replies | boosts]
'--originals-percentage': `${
(postingStats.originals / postingStats.total) *
100
}%`,
'--replies-percentage': `${
((postingStats.originals +
postingStats.replies) /
postingStats.total) *
100
}%`,
}}
/>
<div class="posting-stats-legends">
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-originals" />{' '}
Original
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-replies" />{' '}
Replies
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-boosts" />{' '}
Boosts
</span>
</div>
</div>
) : (
<div class="posting-stats">Post stats unavailable.</div>
)}
</div>
</div>
</LinkOrDiv>
)}
<div class="account-metadata-box">
<div
class="shazam-container no-animation"
hidden={!!postingStats}
>
<div class="shazam-container-inner">
<button
type="button"
class="posting-stats-button"
disabled={postingStatsUIState === 'loading'}
onClick={() => {
renderPostingStats();
}}
>
<div
class={`posting-stats-bar posting-stats-icon ${
postingStatsUIState === 'loading' ? 'loading' : ''
}`}
style={{
'--originals-percentage': '33%',
'--replies-percentage': '66%',
}}
/>
View post stats{' '}
{/* <Loader
abrupt
hidden={postingStatsUIState !== 'loading'}
/> */}
</button>
</div>
</div>
</div>
<RelatedActions
info={info}
instance={instance}
authenticated={authenticated}
onRelationshipChange={onRelationshipChange}
standalone={standalone}
/>
</main>
</>
@ -737,12 +510,7 @@ function AccountInfo({
const FAMILIAR_FOLLOWERS_LIMIT = 3;
function RelatedActions({
info,
instance,
authenticated,
onRelationshipChange = () => {},
}) {
function RelatedActions({ info, instance, authenticated, standalone }) {
if (!info) return null;
const {
masto: currentMasto,
@ -753,6 +521,7 @@ function RelatedActions({
const [relationshipUIState, setRelationshipUIState] = useState('default');
const [relationship, setRelationship] = useState(null);
const [postingStats, setPostingStats] = useState();
const { id, acct, url, username, locked, lastStatusAt, note, fields, moved } =
info;
@ -786,7 +555,7 @@ function RelatedActions({
// Grab this account from my logged-in instance
const acctHasInstance = info.acct.includes('@');
try {
const results = await currentMasto.v2.search.fetch({
const results = await currentMasto.v2.search({
q: acctHasInstance ? info.acct : `${info.username}@${instance}`,
type: 'accounts',
limit: 1,
@ -815,12 +584,12 @@ function RelatedActions({
if (moved) return;
setRelationshipUIState('loading');
accountInfoStates.familiarFollowers = [];
setPostingStats(null);
const fetchRelationships = currentMasto.v1.accounts.relationships.fetch(
{
id: [currentID],
},
);
const fetchRelationships = currentMasto.v1.accounts.fetchRelationships([
currentID,
]);
try {
const relationships = await fetchRelationships;
@ -830,7 +599,63 @@ function RelatedActions({
if (relationships.length) {
const relationship = relationships[0];
setRelationship(relationship);
onRelationshipChange({ relationship, currentID });
if (!relationship.following) {
try {
const fetchFamiliarFollowers =
currentMasto.v1.accounts.fetchFamiliarFollowers(currentID);
const fetchStatuses = currentMasto.v1.accounts
.listStatuses(currentID, {
limit: 20,
})
.next();
const followers = await fetchFamiliarFollowers;
console.log('fetched familiar followers', followers);
accountInfoStates.familiarFollowers =
followers[0].accounts.slice(0, FAMILIAR_FOLLOWERS_LIMIT);
if (!standalone) {
const { value: statuses } = await fetchStatuses;
console.log('fetched statuses', statuses);
const stats = {
total: statuses.length,
originals: 0,
replies: 0,
boosts: 0,
};
// Categories statuses by type
// - Original posts (not replies to others)
// - Threads (self-replies + 1st original post)
// - Boosts (reblogs)
// - Replies (not-self replies)
statuses.forEach((status) => {
if (status.reblog) {
stats.boosts++;
} else if (
status.inReplyToAccountId !== currentID &&
!!status.inReplyToId
) {
stats.replies++;
} else {
stats.originals++;
}
});
// Count days since last post
stats.daysSinceLastPost = Math.ceil(
(Date.now() -
new Date(statuses[statuses.length - 1].createdAt)) /
86400000,
);
console.log('posting stats', stats);
setPostingStats(stats);
}
} catch (e) {
console.error(e);
}
}
}
} catch (e) {
console.error(e);
@ -852,9 +677,75 @@ function RelatedActions({
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
const hasPostingStats = postingStats?.total >= 3;
const accountLink = instance ? `/${instance}/a/${id}` : `/a/${id}`;
return (
<>
<div class="actions">
{hasPostingStats && (
<Link
to={accountLink}
class="account-metadata-box"
onClick={() => {
states.showAccount = false;
}}
>
<div class="shazam-container">
<div class="shazam-container-inner">
<div
class="posting-stats"
title={`${Math.round(
(postingStats.originals / postingStats.total) * 100,
)}% original posts, ${Math.round(
(postingStats.replies / postingStats.total) * 100,
)}% replies, ${Math.round(
(postingStats.boosts / postingStats.total) * 100,
)}% boosts`}
>
<div>
{postingStats.daysSinceLastPost < 365
? `Last ${postingStats.total} posts in the past
${postingStats.daysSinceLastPost} day${
postingStats.daysSinceLastPost > 1 ? 's' : ''
}`
: `
Last ${postingStats.total} posts in the past year(s)
`}
</div>
<div
class="posting-stats-bar"
style={{
// [originals | replies | boosts]
'--originals-percentage': `${
(postingStats.originals / postingStats.total) * 100
}%`,
'--replies-percentage': `${
((postingStats.originals + postingStats.replies) /
postingStats.total) *
100
}%`,
}}
/>
<div class="posting-stats-legends">
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-originals" />{' '}
Original
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-replies" />{' '}
Replies
</span>{' '}
<span class="ib">
<span class="posting-stats-legend-item posting-stats-legend-item-boosts" />{' '}
Boosts
</span>
</div>
</div>
</div>
</div>
</Link>
)}
<p class="actions">
<span>
{followedBy ? (
<span class="tag">Following you</span>
@ -989,15 +880,14 @@ function RelatedActions({
setRelationshipUIState('loading');
(async () => {
try {
const newRelationship = await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.unmute();
const newRelationship =
await currentMasto.v1.accounts.unmute(
currentInfo?.id || id,
);
console.log('unmuting', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Unmuted @${username}`);
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -1037,19 +927,18 @@ function RelatedActions({
(async () => {
try {
const newRelationship =
await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.mute({
await currentMasto.v1.accounts.mute(
currentInfo?.id || id,
{
duration,
});
},
);
console.log('muting', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(
`Muted @${username} for ${MUTE_DURATIONS_LABELS[duration]}`,
);
states.reloadGenericAccounts.id = 'mute';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -1082,24 +971,24 @@ function RelatedActions({
(async () => {
try {
if (blocking) {
const newRelationship = await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.unblock();
const newRelationship =
await currentMasto.v1.accounts.unblock(
currentInfo?.id || id,
);
console.log('unblocking', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Unblocked @${username}`);
} else {
const newRelationship = await currentMasto.v1.accounts
.$select(currentInfo?.id || id)
.block();
const newRelationship =
await currentMasto.v1.accounts.block(
currentInfo?.id || id,
);
console.log('blocking', newRelationship);
setRelationship(newRelationship);
setRelationshipUIState('default');
showToast(`Blocked @${username}`);
}
states.reloadGenericAccounts.id = 'block';
states.reloadGenericAccounts.counter++;
} catch (e) {
console.error(e);
setRelationshipUIState('error');
@ -1161,14 +1050,14 @@ function RelatedActions({
// );
// if (yes) {
newRelationship = await currentMasto.v1.accounts
.$select(accountID.current)
.unfollow();
newRelationship = await currentMasto.v1.accounts.unfollow(
accountID.current,
);
// }
} else {
newRelationship = await currentMasto.v1.accounts
.$select(accountID.current)
.follow();
newRelationship = await currentMasto.v1.accounts.follow(
accountID.current,
);
}
if (newRelationship) setRelationship(newRelationship);
@ -1207,7 +1096,7 @@ function RelatedActions({
</MenuConfirm>
)}
</span>
</div>
</p>
{!!showTranslatedBio && (
<Modal
class="light"
@ -1313,9 +1202,9 @@ function AddRemoveListsSheet({ accountID, onClose }) {
(async () => {
try {
const lists = await masto.v1.lists.list();
const listsContainingAccount = await masto.v1.accounts
.$select(accountID)
.lists.list();
const listsContainingAccount = await masto.v1.accounts.listLists(
accountID,
);
console.log({ lists, listsContainingAccount });
setLists(lists);
setListsContainingAccount(listsContainingAccount);
@ -1357,17 +1246,13 @@ function AddRemoveListsSheet({ accountID, onClose }) {
(async () => {
try {
if (inList) {
await masto.v1.lists
.$select(list.id)
.accounts.remove({
accountIds: [accountID],
});
await masto.v1.lists.removeAccount(list.id, {
accountIds: [accountID],
});
} else {
await masto.v1.lists
.$select(list.id)
.accounts.create({
accountIds: [accountID],
});
await masto.v1.lists.addAccount(list.id, {
accountIds: [accountID],
});
}
// setUIState('default');
reload();

View file

@ -46,7 +46,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
});
return info;
} catch (e) {
const result = await masto.v2.search.fetch({
const result = await masto.v2.search({
q: account,
type: 'accounts',
limit: 1,
@ -57,7 +57,7 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
} else if (/https?:\/\/[^/]+\/@/.test(account)) {
const accountURL = new URL(account);
const acct = accountURL.pathname.replace(/^\//, '');
const result = await masto.v2.search.fetch({
const result = await masto.v2.search({
q: acct,
type: 'accounts',
limit: 1,

View file

@ -11,10 +11,10 @@ export default memo(function BackgroundService({ isLoggedIn }) {
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
const notificationStream = useRef();
useEffect(() => {
let sub;
if (isLoggedIn && visible) {
const { masto, streaming, instance } = api();
const { masto, instance } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
@ -42,26 +42,34 @@ export default memo(function BackgroundService({ isLoggedIn }) {
}
// 2. Start streaming
if (streaming) {
sub = streaming.user.notification.subscribe();
console.log('🎏 Streaming notification', sub);
for await (const entry of sub) {
if (!sub) break;
console.log('🔔🔔 Notification entry', entry);
if (entry.event === 'notification') {
console.log('🔔🔔 Notification', entry);
saveStatus(entry.payload, instance, {
skipThreading: true,
});
}
states.notificationsShowNew = true;
notificationStream.current = await masto.ws.stream(
'/api/v1/streaming',
{
stream: 'user:notification',
},
);
console.log('🎏 Streaming notification', notificationStream.current);
notificationStream.current.on('notification', (notification) => {
console.log('🔔🔔 Notification', notification);
if (notification.status) {
saveStatus(notification.status, instance, {
skipThreading: true,
});
}
}
states.notificationsShowNew = true;
});
notificationStream.current.ws.onclose = () => {
console.log('🔔🔔 Notification stream closed');
};
})();
}
return () => {
sub?.unsubscribe?.();
sub = null;
if (notificationStream.current) {
notificationStream.current.ws.close();
notificationStream.current = null;
}
};
}, [visible, isLoggedIn]);

View file

@ -487,28 +487,7 @@
padding-inline: 24px;
}
}
#media-sheet {
.media-form {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 50vh;
textarea {
flex-grow: 1;
resize: none;
width: 100%;
/* height: 10em; */
}
footer {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}
#media-sheet main {
padding-top: 8px;
display: flex;
@ -516,6 +495,10 @@
flex: 1;
gap: 8px;
}
#media-sheet textarea {
width: 100%;
height: 10em;
}
#media-sheet .media-preview {
border: 2px solid var(--outline-color);
border-radius: 8px;
@ -532,7 +515,6 @@
linear-gradient(-45deg, transparent 75%, var(--img-bg-color) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
flex: 0.8;
}
#media-sheet .media-preview > * {
width: 100%;
@ -552,11 +534,11 @@
#media-sheet .media-preview > * {
max-height: none;
}
/* #media-sheet textarea {
#media-sheet textarea {
flex: 1;
min-height: 100%;
height: auto;
} */
}
}
#custom-emojis-sheet {

View file

@ -185,7 +185,7 @@ function Compose({
: visibility,
);
setLanguage(language || prefs.postingDefaultLanguage || DEFAULT_LANG);
setSensitive(sensitive && !!spoilerText);
setSensitive(sensitive);
} else if (editStatus) {
const { visibility, language, sensitive, poll, mediaAttachments } =
editStatus;
@ -197,9 +197,9 @@ function Compose({
setUIState('loading');
(async () => {
try {
const statusSource = await masto.v1.statuses
.$select(editStatus.id)
.source.fetch();
const statusSource = await masto.v1.statuses.fetchSource(
editStatus.id,
);
console.log({ statusSource });
const { text, spoilerText } = statusSource;
textareaRef.current.value = text;
@ -749,12 +749,14 @@ function Compose({
file,
description,
});
return masto.v2.media.create(params).then((res) => {
if (res.id) {
attachment.id = res.id;
}
return res;
});
return masto.v2.mediaAttachments
.create(params)
.then((res) => {
if (res.id) {
attachment.id = res.id;
}
return res;
});
}
});
const results = await Promise.allSettled(mediaPromises);
@ -782,8 +784,6 @@ function Compose({
/* NOTE:
Using snakecase here because masto.js's `isObject` returns false for `params`, ONLY happens when opening in pop-out window. This is maybe due to `window.masto` variable being passed from the parent window. The check that failed is `x.constructor === Object`, so maybe the `Object` in new window is different than parent window's?
Code: https://github.com/neet/masto.js/blob/dd0d649067b6a2b6e60fbb0a96597c373a255b00/src/serializers/is-object.ts#L2
// TODO: Note above is no longer true in Masto.js v6. Revisit this.
*/
let params = {
status,
@ -818,9 +818,10 @@ function Compose({
let newStatus;
if (editStatus) {
newStatus = await masto.v1.statuses
.$select(editStatus.id)
.update(params);
newStatus = await masto.v1.statuses.update(
editStatus.id,
params,
);
saveStatus(newStatus, instance, {
skipThreading: true,
});
@ -838,8 +839,6 @@ function Compose({
// Close
onClose({
// type: post, reply, edit
type: editStatus ? 'edit' : replyToStatus ? 'reply' : 'post',
newStatus,
instance,
});
@ -934,13 +933,13 @@ function Compose({
performSearch={(params) => {
const { type, q, limit } = params;
if (type === 'accounts') {
return masto.v1.accounts.search.list({
return masto.v1.accounts.search({
q,
limit,
resolve: false,
});
}
return masto.v2.search.fetch(params);
return masto.v2.search(params);
}}
/>
{mediaAttachments?.length > 0 && (
@ -1476,11 +1475,7 @@ function MediaAttachment({
onRemove = () => {},
}) {
const supportsEdit = supports('@mastodon/edit-media-attributes');
const { type, id, file } = attachment;
const url = useMemo(
() => (file ? URL.createObjectURL(file) : attachment.url),
[file, attachment.url],
);
const { url, type, id } = attachment;
console.log({ attachment });
const [description, setDescription] = useState(attachment.description);
const suffixType = type.split('/')[0];
@ -1547,7 +1542,6 @@ function MediaAttachment({
<div class="media-attachment">
<div
class="media-preview"
tabIndex="0"
onClick={() => {
setShowModal(true);
}}
@ -1574,7 +1568,6 @@ function MediaAttachment({
</div>
{showModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowModal(false);
@ -1612,20 +1605,7 @@ function MediaAttachment({
<audio src={url} controls />
) : null}
</div>
<div class="media-form">
{descTextarea}
<footer>
<button
type="button"
class="light block"
onClick={() => {
setShowModal(false);
}}
>
Done
</button>
</footer>
</div>
{descTextarea}
</main>
</div>
</Modal>

View file

@ -128,9 +128,9 @@ function Drafts({ onClose }) {
if (replyTo) {
setUIState('loading');
try {
replyToStatus = await masto.v1.statuses
.$select(replyTo.id)
.fetch();
replyToStatus = await masto.v1.statuses.fetch(
replyTo.id,
);
} catch (e) {
console.error(e);
alert('Error fetching reply-to status!');

View file

@ -17,15 +17,13 @@ function FollowRequestButtons({ accountID, onChange }) {
<p class="follow-request-buttons">
<button
type="button"
disabled={uiState === 'loading' || hasRelationship}
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
setRequestState('accept');
(async () => {
try {
const rel = await masto.v1.followRequests
.$select(accountID)
.authorize();
const rel = await masto.v1.followRequests.authorize(accountID);
if (!rel?.followedBy) {
throw new Error('Follow request not accepted');
}
@ -42,16 +40,14 @@ function FollowRequestButtons({ accountID, onChange }) {
</button>{' '}
<button
type="button"
disabled={uiState === 'loading' || hasRelationship}
disabled={uiState === 'loading'}
class="light danger"
onClick={() => {
setUIState('loading');
setRequestState('reject');
(async () => {
try {
const rel = await masto.v1.followRequests
.$select(accountID)
.reject();
const rel = await masto.v1.followRequests.reject(accountID);
if (rel?.followedBy) {
throw new Error('Follow request not rejected');
}

View file

@ -21,7 +21,6 @@ export default function GenericAccounts({ onClose = () => {} }) {
}
const {
id,
heading,
fetchAccounts,
accounts: staticAccounts,
@ -61,14 +60,6 @@ export default function GenericAccounts({ onClose = () => {} }) {
}
}, [staticAccounts, fetchAccounts]);
useEffect(() => {
// reloadGenericAccounts contains value like {id: 'mute', counter: 1}
// We only need to reload if the id matches
if (snapStates.reloadGenericAccounts?.id === id) {
loadAccounts(true);
}
}, [snapStates.reloadGenericAccounts.counter]);
return (
<div id="generic-accounts-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>

View file

@ -100,7 +100,6 @@ export const ICONS = {
'account-edit': () => import('@iconify-icons/mingcute/user-edit-line'),
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
};
function Icon({
@ -127,7 +126,7 @@ function Icon({
}, [iconBlock]);
return (
<span
<div
class={`icon ${className}`}
title={title || alt}
style={{
@ -152,7 +151,7 @@ function Icon({
}}
/>
)}
</span>
</div>
);
}

View file

@ -56,7 +56,7 @@ function ListAddEdit({ list, onClose }) {
let listResult;
if (editMode) {
listResult = await masto.v1.lists.$select(list.id).update({
listResult = await masto.v1.lists.update(list.id, {
title,
replies_policy: repliesPolicy,
exclusive,
@ -141,7 +141,7 @@ function ListAddEdit({ list, onClose }) {
(async () => {
try {
await masto.v1.lists.$select(list.id).remove();
await masto.v1.lists.remove(list.id);
setUIState('default');
onClose?.({
state: 'deleted',

View file

@ -1,74 +0,0 @@
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeMatch from '../utils/locale-match';
import states from '../utils/states';
import Icon from './icon';
import TranslationBlock from './translation-block';
export default function MediaAltModal({ alt, lang, onClose }) {
const snapStates = useSnapshot(states);
const [forceTranslate, setForceTranslate] = useState(false);
const targetLanguage = getTranslateTargetLanguage(true);
const contentTranslationHideLanguages =
snapStates.settings.contentTranslationHideLanguages || [];
const differentLanguage =
!!lang &&
lang !== targetLanguage &&
!localeMatch([lang], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
(l) => lang === l || localeMatch([lang], [l]),
);
return (
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid">
<h2>Media description</h2>
<div class="header-side">
<Menu
align="end"
menuButton={
<button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" />
</button>
}
>
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
</Menu>
</div>
</header>
<main lang={lang} dir="auto">
<p
style={{
whiteSpace: 'pre-wrap',
}}
>
{alt}
</p>
{(differentLanguage || forceTranslate) && (
<TranslationBlock
forceTranslate={forceTranslate}
sourceLanguage={lang}
text={alt}
/>
)}
</main>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { Menu } from '@szhsin/react-menu';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash';
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
@ -6,15 +6,14 @@ import { useHotkeys } from 'react-hotkeys-hook';
import Icon from './icon';
import Link from './link';
import Media from './media';
import MediaAltModal from './media-alt-modal';
import MenuLink from './menu-link';
import Modal from './modal';
import TranslationBlock from './translation-block';
function MediaModal({
mediaAttachments,
statusID,
instance,
lang,
index = 0,
onClose = () => {},
}) {
@ -139,19 +138,14 @@ function MediaModal({
class="media-alt"
hidden={!showControls}
onClick={() => {
setShowMediaAlt({
alt: media.description,
lang,
});
setShowMediaAlt(media.description);
}}
>
<span class="alt-badge">ALT</span>
<span class="media-alt-desc" lang={lang} dir="auto">
{media.description}
</span>
<Icon icon="info" />
<span class="media-alt-desc">{media.description}</span>
</button>
)}
<Media media={media} showOriginal lang={lang} />
<Media media={media} showOriginal />
</div>
);
})}
@ -285,8 +279,7 @@ function MediaModal({
}}
>
<MediaAltModal
alt={showMediaAlt.alt || showMediaAlt}
lang={showMediaAlt?.lang}
alt={showMediaAlt}
onClose={() => setShowMediaAlt(false)}
/>
</Modal>
@ -295,4 +288,52 @@ function MediaModal({
);
}
function MediaAltModal({ alt, onClose }) {
const [forceTranslate, setForceTranslate] = useState(false);
return (
<div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid">
<h2>Media description</h2>
<div class="header-side">
<Menu
align="end"
menuButton={
<button type="button" class="plain4">
<Icon icon="more" alt="More" size="xl" />
</button>
}
>
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
</Menu>
</div>
</header>
<main>
<p
style={{
whiteSpace: 'pre-wrap',
}}
>
{alt}
</p>
{forceTranslate && (
<TranslationBlock forceTranslate={forceTranslate} text={alt} />
)}
</main>
</div>
);
}
export default MediaModal;

View file

@ -9,9 +9,6 @@ import {
} from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import mem from '../utils/mem';
import states from '../utils/states';
import Icon from './icon';
import Link from './link';
import { formatDuration } from './status';
@ -28,49 +25,7 @@ video = Video clip
audio = Audio track
*/
const dataAltLabel = 'ALT';
const AltBadge = (props) => {
const { alt, lang, index, ...rest } = props;
if (!alt || !alt.trim()) return null;
return (
<button
type="button"
class="alt-badge clickable"
{...rest}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
states.showMediaAlt = {
alt,
lang,
};
}}
title="Media description"
>
{dataAltLabel}
{!!index && <sup>{index}</sup>}
</button>
);
};
const MEDIA_CAPTION_LIMIT = 140;
export const isMediaCaptionLong = mem((caption) =>
caption?.length
? caption.length > MEDIA_CAPTION_LIMIT ||
/[\n\r].*[\n\r]/.test(caption.trim())
: false,
);
function Media({
media,
to,
lang,
showOriginal,
autoAnimate,
showCaption,
altIndex,
onClick = () => {},
}) {
function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
const {
blurhash,
description,
@ -179,35 +134,6 @@ function Media({
aspectRatio: `${width} / ${height}`,
};
const longDesc = isMediaCaptionLong(description);
const showInlineDesc =
!!showCaption && !showOriginal && !!description && !longDesc;
const Figure = !showInlineDesc
? Fragment
: (props) => {
const { children, ...restProps } = props;
return (
<figure {...restProps}>
{children}
<figcaption
class="media-caption"
lang={lang}
dir="auto"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showMediaAlt = {
alt: description,
lang,
};
}}
>
{description}
</figcaption>
</figure>
);
};
if (isImage) {
// Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit';
@ -226,87 +152,79 @@ function Media({
}, [mediaURL]);
return (
<Figure>
<Parent
ref={parentRef}
class={`media media-image`}
onClick={onClick}
data-orientation={orientation}
data-has-alt={!showInlineDesc}
style={
showOriginal
? {
backgroundImage: `url(${previewUrl})`,
backgroundSize: imageSmallerThanParent
? `${width}px ${height}px`
: undefined,
<Parent
ref={parentRef}
class={`media media-image`}
onClick={onClick}
data-orientation={orientation}
style={
showOriginal
? {
backgroundImage: `url(${previewUrl})`,
backgroundSize: imageSmallerThanParent
? `${width}px ${height}px`
: undefined,
}
: mediaStyles
}
>
{showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps}>
<img
ref={mediaRef}
src={mediaURL}
alt={description}
width={width}
height={height}
data-orientation={orientation}
loading="eager"
decoding="sync"
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
e.target.closest('.media-zoom').style.display = '';
setPinchZoomEnabled(true);
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
: mediaStyles
}
>
{showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps}>
<img
ref={mediaRef}
src={mediaURL}
alt={description}
width={width}
height={height}
data-orientation={orientation}
loading="eager"
decoding="sync"
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
e.target.closest('.media-zoom').style.display = '';
setPinchZoomEnabled(true);
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
</QuickPinchZoom>
) : (
<>
<img
src={mediaURL}
alt={showInlineDesc ? '' : description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
style={{
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
backgroundPosition: focalBackgroundPosition || 'center',
// Duration based on width or height in pixels
// 100px per second (rough estimate)
// Clamp between 5s and 120s
'--anim-duration': `${Math.min(
Math.max(Math.max(width, height) / 100, 5),
120,
)}s`,
}}
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
e.target.dataset.loaded = true;
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
{!showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</>
)}
</Parent>
</Figure>
}}
/>
</QuickPinchZoom>
) : (
<img
src={mediaURL}
alt={description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
style={{
backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
backgroundPosition: focalBackgroundPosition || 'center',
// Duration based on width or height in pixels
// 100px per second (rough estimate)
// Clamp between 5s and 120s
'--anim-duration': `${Math.min(
Math.max(Math.max(width, height) / 100, 5),
120,
)}s`,
}}
onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = '';
e.target.dataset.loaded = true;
}}
onError={(e) => {
const { src } = e.target;
if (src === mediaURL) {
e.target.src = remoteMediaURL;
}
}}
/>
)}
</Parent>
);
} else if (type === 'gifv' || type === 'video' || isVideoMaybe) {
const shortDuration = original.duration < 31;
@ -334,8 +252,11 @@ function Media({
></video>
`;
const showInlineDesc = !showOriginal && !isGIF && !!description;
const Container = showInlineDesc ? 'figure' : Fragment;
return (
<Figure>
<Container>
<Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : ''
@ -343,7 +264,6 @@ function Media({
data-orientation={orientation}
data-formatted-duration={formattedDuration}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
data-has-alt={!showInlineDesc}
// style={{
// backgroundColor:
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
@ -371,20 +291,6 @@ function Media({
} catch (e) {}
}
}}
onFocus={() => {
if (hoverAnimate) {
try {
videoRef.current.play();
} catch (e) {}
}
}}
onBlur={() => {
if (hoverAnimate) {
try {
videoRef.current.pause();
} catch (e) {}
}
}}
>
{showOriginal || autoGIFAnimate ? (
isGIF && showOriginal ? (
@ -433,47 +339,45 @@ function Media({
</div>
</>
)}
{!showOriginal && !showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</Parent>
</Figure>
{showInlineDesc && (
<figcaption
onClick={() => {
location.hash = to;
}}
>
{description}
</figcaption>
)}
</Container>
);
} else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration);
return (
<Figure>
<Parent
class="media media-audio"
data-formatted-duration={formattedDuration}
data-has-alt={!showInlineDesc}
onClick={onClick}
style={!showOriginal && mediaStyles}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoplay />
) : previewUrl ? (
<img
src={previewUrl}
alt={showInlineDesc ? '' : description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
/>
) : null}
{!showOriginal && (
<>
<div class="media-play">
<Icon icon="play" size="xl" />
</div>
{!showInlineDesc && (
<AltBadge alt={description} lang={lang} index={altIndex} />
)}
</>
)}
</Parent>
</Figure>
<Parent
class="media media-audio"
data-formatted-duration={formattedDuration}
onClick={onClick}
style={!showOriginal && mediaStyles}
>
{showOriginal ? (
<audio src={remoteUrl || url} preload="none" controls autoplay />
) : previewUrl ? (
<img
src={previewUrl}
alt={description}
width={width}
height={height}
data-orientation={orientation}
loading="lazy"
/>
) : null}
{!showOriginal && (
<div class="media-play">
<Icon icon="play" size="xl" />
</div>
)}
</Parent>
);
}
}

View file

@ -11,7 +11,6 @@ import AccountSheet from './account-sheet';
import Compose from './compose';
import Drafts from './drafts';
import GenericAccounts from './generic-accounts';
import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal';
import Modal from './modal';
import ShortcutsSettings from './shortcuts-settings';
@ -51,17 +50,13 @@ export default function Modals() {
null
}
onClose={(results) => {
const { newStatus, instance, type } = results || {};
const { newStatus, instance } = results || {};
states.showCompose = false;
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
showToast({
text: {
post: 'Post published. Check it out.',
reply: 'Reply posted. Check it out.',
edit: 'Post updated. Check it out.',
}[type || 'post'],
text: 'Post published. Check it out.',
delay: 1000,
duration: 10_000, // 10 seconds
onClick: (toast) => {
@ -179,24 +174,6 @@ export default function Modals() {
/>
</Modal>
)}
{!!snapStates.showMediaAlt && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
states.showMediaAlt = false;
}
}}
>
<MediaAltModal
alt={snapStates.showMediaAlt.alt || snapStates.showMediaAlt}
lang={snapStates.showMediaAlt?.lang}
onClose={() => {
states.showMediaAlt = false;
}}
/>
</Modal>
)}
</>
);
}

View file

@ -25,20 +25,13 @@ function NameText({
const trimmedDisplayName = (displayName || '').toLowerCase().trim();
const shortenedDisplayName = trimmedDisplayName
.replace(/(\:(\w|\+|\-)+\:)(?=|[\!\.\?]|$)/g, '') // Remove shortcodes, regex from https://regex101.com/r/iE9uV0/1
.replace(/\s+/g, ''); // E.g. "My name" === "myname"
const shortenedAlphaNumericDisplayName = shortenedDisplayName.replace(
/[^a-z0-9]/gi,
'',
); // Remove non-alphanumeric characters
.replace(/\s+/g, '') // E.g. "My name" === "myname"
.replace(/[^a-z0-9]/gi, ''); // Remove non-alphanumeric characters
if (
!short &&
(trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName ||
trimmedUsername === shortenedAlphaNumericDisplayName ||
trimmedUsername.localeCompare?.(shortenedDisplayName, 'en', {
sensitivity: 'base',
}) === 0)
trimmedUsername === shortenedDisplayName)
) {
username = null;
}

View file

@ -17,7 +17,7 @@ import { accountsIsDtth, gtsDtthSettings } from '../utils/dtth';
function NavMenu(props) {
const snapStates = useSnapshot(states);
const { masto, instance, authenticated } = api();
const { instance, authenticated } = api();
const [currentAccount, setCurrentAccount] = useState();
const [moreThanOneAccount, setMoreThanOneAccount] = useState(false);
@ -61,28 +61,6 @@ function NavMenu(props) {
0,
]);
const mutesIterator = useRef();
async function fetchMutes(firstLoad) {
if (firstLoad || !mutesIterator.current) {
mutesIterator.current = masto.v1.mutes.list({
limit: 80,
});
}
const results = await mutesIterator.current.next();
return results;
}
const blocksIterator = useRef();
async function fetchBlocks(firstLoad) {
if (firstLoad || !blocksIterator.current) {
blocksIterator.current = masto.v1.blocks.list({
limit: 80,
});
}
const results = await blocksIterator.current.next();
return results;
}
return (
<>
<button
@ -231,29 +209,6 @@ function NavMenu(props) {
>
<Icon icon="group" size="l" /> <span>Accounts&hellip;</span>
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'mute',
heading: 'Muted users',
fetchAccounts: fetchMutes,
};
}}
>
<Icon icon="mute" size="l" /> Muted users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
id: 'block',
heading: 'Blocked users',
fetchAccounts: fetchBlocks,
};
}}
>
<Icon icon="block" size="l" />
Blocked users&hellip;
</MenuItem>
<MenuItem
onClick={() => {
states.showKeyboardShortcutsHelp = true;

View file

@ -38,7 +38,7 @@ export default memo(function NotificationService() {
? getAccountByAccessToken(accessToken)
: getCurrentAccount();
(async () => {
const notification = await masto.v1.notifications.$select(id).fetch();
const notification = await masto.v1.notifications.fetch(id);
if (notification && account) {
console.log('🛎️ Notification', { id, notification, account });
const accountInstance = account.instanceURL;

View file

@ -58,14 +58,14 @@ const contentText = {
'favourite+reblog+account': (count) =>
`boosted & favourited ${count} of your posts.`,
'favourite+reblog_reply': 'boosted & favourited your reply.',
'admin.sign_up': 'signed up.',
'admin.report': (targetAccount) => <>reported {targetAccount}</>,
'admin.signup': 'signed up.',
'admin.report': 'reported a post.',
};
const AVATARS_LIMIT = 50;
function Notification({ notification, instance, reload, isStatic }) {
const { id, status, account, report, _accounts, _statuses } = notification;
const { id, status, account, _accounts, _statuses } = notification;
let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
@ -119,15 +119,7 @@ function Notification({ notification, instance, reload, isStatic }) {
}
if (typeof text === 'function') {
const count = _statuses?.length || _accounts?.length;
if (count) {
text = text(count);
} else if (type === 'admin.report') {
const targetAccount = report?.targetAccount;
if (targetAccount) {
text = text(<NameText account={targetAccount} showAvatar />);
}
}
text = text(_statuses?.length || _accounts?.length);
}
if (type === 'mention' && !status) {

View file

@ -199,7 +199,6 @@ export default function Poll({
setUIState('default');
})();
}}
title="Refresh"
>
<Icon icon="refresh" alt="Refresh" />
</button>
@ -213,7 +212,6 @@ export default function Poll({
e.preventDefault();
setShowResults(!showResults);
}}
title={showResults ? 'Hide results' : 'Show results'}
>
<Icon
icon={showResults ? 'eye-open' : 'eye-close'}

View file

@ -4,15 +4,14 @@ import {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
} from 'lz-string';
import mem from 'mem';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import floatingButtonUrl from '../assets/floating-button.svg';
import multiColumnUrl from '../assets/multi-column.svg';
import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
import { api } from '../utils/api';
import pmem from '../utils/pmem';
import showToast from '../utils/show-toast';
import states from '../utils/states';
@ -133,10 +132,15 @@ export const SHORTCUTS_META = {
},
list: {
id: 'list',
title: pmem(async ({ id }) => {
const list = await api().masto.v1.lists.$select(id).fetch();
return list.title;
}),
title: mem(
async ({ id }) => {
const list = await api().masto.v1.lists.fetch(id);
return list.title;
},
{
cacheKey: ([{ id }]) => id,
},
),
path: ({ id }) => `/l/${id}`,
icon: 'list',
},
@ -162,10 +166,15 @@ export const SHORTCUTS_META = {
},
'account-statuses': {
id: 'account-statuses',
title: pmem(async ({ id }) => {
const account = await api().masto.v1.accounts.$select(id).fetch();
return account.username || account.acct || account.displayName;
}),
title: mem(
async ({ id }) => {
const account = await api().masto.v1.accounts.fetch(id);
return account.username || account.acct || account.displayName;
},
{
cacheKey: ([{ id }]) => id,
},
),
path: ({ id }) => `/a/${id}`,
icon: 'user',
},

View file

@ -82,8 +82,6 @@
list-style: none;
display: flex;
justify-content: center;
min-width: 20vw;
flex-basis: 20vw;
}
#shortcuts .tab-bar li a {
-webkit-tap-highlight-color: transparent;
@ -97,13 +95,7 @@
padding: 8px;
text-decoration: none;
text-shadow: 0 var(--hairline-width) var(--bg-color);
width: 100%;
@media (hover: hover) {
&:is(:hover, :focus) {
color: var(--text-color);
}
}
width: 20vw;
}
#shortcuts .tab-bar li a:active {
transform: scale(0.95);
@ -179,8 +171,6 @@ shortcuts .tab-bar[hidden] {
}
#shortcuts .tab-bar li {
flex-grow: 0;
min-width: auto;
flex-basis: auto;
}
#shortcuts .tab-bar li a {
padding: 0 16px;

View file

@ -166,11 +166,8 @@
.status.large .status-card :is(.content, .poll, .media-container) {
max-height: 80vh !important;
}
.status-card :is(.content, .poll, .media-container) {
font-size: inherit !important;
}
.status-card :is(.content.truncated, .poll, .media-container.truncated) {
/* font-size: inherit !important; */
font-size: inherit !important;
mask-image: linear-gradient(to bottom, #000 80px, transparent);
}
.status.small
@ -302,7 +299,7 @@
overflow: hidden;
/* text-overflow: ellipsis; */
}
.status > .container > .meta .meta-name {
.status > .container > .meta .name-text {
mask-image: linear-gradient(to left, transparent, black 16px);
flex-grow: 1;
}
@ -337,7 +334,7 @@
.status > .container > .meta a.time:after {
content: '';
position: absolute;
inset: -16px -16px -8px;
inset: -16px;
}
.status > .container > .meta .reply-to {
opacity: 0.5;
@ -460,7 +457,7 @@
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ *:not(.media-container, .card, .media-figure-multiple),
~ *:not(.media-container, .card),
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
@ -469,7 +466,7 @@
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ :is(.media-container, .media-figure-multiple)
~ .media-container
figcaption {
filter: blur(5px) invert(0.5);
image-rendering: crisp-edges;
@ -483,7 +480,7 @@
.status
.content-container.has-spoiler:not(.show-spoiler)
.spoiler
~ :is(.media-container, .media-figure-multiple)
~ .media-container
.media
> *,
.status
@ -547,7 +544,7 @@
max-height: 40vh;
max-height: 40dvh;
}
.timeline-deck .status:not(.truncated .status) .content.truncated {
.timeline-deck .status .content.truncated {
mask-image: linear-gradient(
to top,
transparent,
@ -555,7 +552,7 @@
black 1.5em
);
}
.timeline-deck .status:not(.truncated .status) .content.truncated:after {
.timeline-deck .status .content.truncated:after {
content: attr(data-read-more);
line-height: 1;
display: inline-block;
@ -711,21 +708,21 @@
figure {
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
/* align-items: flex-end; */
column-gap: 4px;
figcaption {
align-self: flex-end;
padding: 4px;
margin: -2px 0 0;
padding: 0 4px;
font-size: 90%;
color: var(--text-insignificant-color);
overflow: hidden;
white-space: normal;
display: -webkit-box;
display: box;
-webkit-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.2;
cursor: pointer;
white-space: pre-line;
flex-basis: 15em;
flex-grow: 1;
}
}
@ -836,7 +833,7 @@
.status .media:is(:hover, :focus) {
border-color: var(--outline-hover-color);
}
.status .media:active:not(:has(button:active)) {
.status .media:active {
filter: brightness(0.8);
transform: scale(0.99);
}
@ -848,22 +845,6 @@
}
.status .media {
cursor: pointer;
&[data-has-alt] {
position: relative;
.alt-badge {
position: absolute;
bottom: 8px;
left: 8px;
&:before {
content: '';
position: absolute;
inset: -12px;
}
}
}
}
.status .media img:is(:hover, :focus),
a:focus-visible .status .media img {
@ -893,9 +874,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
color: var(--media-fg-color);
background-color: var(--media-bg-color);
box-shadow: inset 0 0 0 2px var(--media-outline-color);
color: var(--video-fg-color);
background-color: var(--video-bg-color);
box-shadow: inset 0 0 0 2px var(--video-outline-color);
display: flex;
place-content: center;
place-items: center;
@ -912,9 +893,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: absolute;
bottom: 8px;
right: 8px;
color: var(--media-fg-color);
background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color);
color: var(--video-fg-color);
background-color: var(--video-bg-color);
border: var(--hairline-width) solid var(--video-outline-color);
border-radius: 4px;
padding: 0 4px;
}
@ -929,9 +910,9 @@ body:has(#modal-container .carousel) .status .media img:hover {
position: absolute;
bottom: 8px;
right: 8px;
color: var(--media-fg-color);
background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color);
color: var(--bg-faded-color);
background-color: var(--text-insignificant-color);
backdrop-filter: blur(6px) saturate(3) invert(0.2);
border-radius: 4px;
padding: 0 4px;
}
@ -998,62 +979,6 @@ body:has(#modal-container .carousel) .status .media img:hover {
white-space: normal;
}
.media-figure-multiple {
margin: 0;
padding: 0;
figcaption {
padding: 4px;
font-size: 90%;
color: var(--text-insignificant-color);
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
& > * {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-flex;
gap: 4px;
&:hover {
color: var(--text-color);
cursor: pointer;
}
&:only-child {
white-space: pre-line;
overflow: auto;
text-overflow: unset;
}
}
sup {
opacity: 0.75;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
}
/* Only 4, for now. Would be better if this is a for loop */
&:has(.media[data-has-alt]:nth-child(1):is(:hover, :focus))
figcaption
> div[data-caption-index~='1'],
&:has(.media[data-has-alt]:nth-child(2):is(:hover, :focus))
figcaption
> div[data-caption-index~='2'],
&:has(.media[data-has-alt]:nth-child(3):is(:hover, :focus))
figcaption
> div[data-caption-index~='3'],
&:has(.media[data-has-alt]:nth-child(4):is(:hover, :focus))
figcaption
> div[data-caption-index~='4'] {
color: var(--text-color);
}
}
.carousel-item {
position: relative;
}
@ -1078,12 +1003,6 @@ body:has(#modal-container .carousel) .status .media img:hover {
font-size: 90%;
z-index: 1;
text-shadow: 0 var(--hairline-width) var(--bg-color);
mix-blend-mode: luminosity;
white-space: pre-line;
&:is(:hover, :focus) {
mix-blend-mode: normal;
}
}
.carousel-item button.media-alt .media-alt-desc {
overflow: hidden;
@ -1719,37 +1638,3 @@ a.card:is(:hover, :focus):visited {
#reactions-container .reactions-block .reblog-icon {
color: var(--reblog-color);
}
/* ALT BADGE */
.alt-badge {
font-size: 12px;
font-weight: bold;
color: var(--media-fg-color);
background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color);
mix-blend-mode: luminosity;
border-radius: 4px;
padding: 4px;
opacity: 0.65;
sup {
vertical-align: super;
font-weight: normal;
line-height: 0;
padding-left: 2px;
}
&.clickable {
opacity: 0.75;
border-width: 2px;
&:is(:hover, :focus):not(:active) {
transition: 0.15s ease-out;
transition-property: transform, opacity, mix-blend-mode;
transform: scale(1.15);
opacity: 0.9;
mix-blend-mode: normal;
}
}
}

View file

@ -9,6 +9,7 @@ import {
MenuItem,
} from '@szhsin/react-menu';
import { decodeBlurHash } from 'fast-blurhash';
import mem from 'mem';
import pThrottle from 'p-throttle';
import { memo } from 'preact/compat';
import {
@ -21,6 +22,7 @@ import {
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useLongPress } from 'use-long-press';
import useResizeObserver from 'use-resize-observer';
import { useSnapshot } from 'valtio';
import { snapshot } from 'valtio/vanilla';
@ -41,7 +43,6 @@ import htmlContentLength from '../utils/html-content-length';
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
import localeMatch from '../utils/locale-match';
import niceDateTime from '../utils/nice-date-time';
import pmem from '../utils/pmem';
import safeBoundingBoxPadding from '../utils/safe-bounding-box-padding';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
@ -55,7 +56,6 @@ import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import Media from './media';
import { isMediaCaptionLong } from './media';
import MenuLink from './menu-link';
import RelativeTime from './relative-time';
import TranslationBlock from './translation-block';
@ -67,9 +67,13 @@ const throttle = pThrottle({
});
function fetchAccount(id, masto) {
return masto.v1.accounts.$select(id).fetch();
try {
return masto.v1.accounts.fetch(id);
} catch (e) {
return Promise.reject(e);
}
}
const memFetchAccount = pmem(fetchAccount);
const memFetchAccount = mem(fetchAccount);
const visibilityText = {
public: 'Public',
@ -386,11 +390,11 @@ function Status({
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
};
if (reblogged) {
const newStatus = await masto.v1.statuses.$select(id).unreblog();
const newStatus = await masto.v1.statuses.unreblog(id);
saveStatus(newStatus, instance);
return true;
} else {
const newStatus = await masto.v1.statuses.$select(id).reblog();
const newStatus = await masto.v1.statuses.reblog(id);
saveStatus(newStatus, instance);
return true;
}
@ -414,11 +418,11 @@ function Status({
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
};
if (reblogged) {
const newStatus = await masto.v1.statuses.$select(id).unreblog();
const newStatus = await masto.v1.statuses.unreblog(id);
saveStatus(newStatus, instance);
return true;
} else {
const newStatus = await masto.v1.statuses.$select(id).reblog();
const newStatus = await masto.v1.statuses.reblog(id);
saveStatus(newStatus, instance);
return true;
}
@ -442,10 +446,10 @@ function Status({
favouritesCount: favouritesCount + (favourited ? -1 : 1),
};
if (favourited) {
const newStatus = await masto.v1.statuses.$select(id).unfavourite();
const newStatus = await masto.v1.statuses.unfavourite(id);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.$select(id).favourite();
const newStatus = await masto.v1.statuses.favourite(id);
saveStatus(newStatus, instance);
}
} catch (e) {
@ -466,10 +470,10 @@ function Status({
bookmarked: !bookmarked,
};
if (bookmarked) {
const newStatus = await masto.v1.statuses.$select(id).unbookmark();
const newStatus = await masto.v1.statuses.unbookmark(id);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.$select(id).bookmark();
const newStatus = await masto.v1.statuses.bookmark(id);
saveStatus(newStatus, instance);
}
} catch (e) {
@ -480,7 +484,7 @@ function Status({
};
const differentLanguage =
!!language &&
language &&
language !== targetLanguage &&
!localeMatch([language], [targetLanguage]) &&
!contentTranslationHideLanguages.find(
@ -704,9 +708,9 @@ function Status({
<MenuItem
onClick={async () => {
try {
const newStatus = await masto.v1.statuses
.$select(id)
[muted ? 'unmute' : 'mute']();
const newStatus = await masto.v1.statuses[
muted ? 'unmute' : 'mute'
](id);
saveStatus(newStatus, instance);
showToast(muted ? 'Conversation unmuted' : 'Conversation muted');
} catch (e) {
@ -759,7 +763,7 @@ function Status({
// if (yes) {
(async () => {
try {
await masto.v1.statuses.$select(id).remove();
await masto.v1.statuses.remove(id);
const cachedStatus = getStatus(id, instance);
cachedStatus._deleted = true;
showToast('Deleted');
@ -786,34 +790,24 @@ function Status({
x: 0,
y: 0,
});
const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
// Only iOS/iPadOS browsers don't support contextmenu
// Some comments report iPadOS might support contextmenu if a mouse is connected
const bindLongPressContext = useLongPress(
isIOS
? (e) => {
if (e.pointerType === 'mouse') return;
// There's 'pen' too, but not sure if contextmenu event would trigger from a pen
const { clientX, clientY } = e.touches?.[0] || e;
// link detection copied from onContextMenu because here it works
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
e.preventDefault();
setContextMenuAnchorPoint({
x: clientX,
y: clientY,
});
setIsContextMenuOpen(true);
}
: null,
(e) => {
const { clientX, clientY } = e.touches?.[0] || e;
// link detection copied from onContextMenu because here it works
const link = e.target.closest('a');
if (link && /^https?:\/\//.test(link.getAttribute('href'))) return;
e.preventDefault();
setContextMenuAnchorPoint({
x: clientX,
y: clientY,
});
setIsContextMenuOpen(true);
},
{
threshold: 600,
captureEvent: true,
detect: 'touch',
cancelOnMovement: 2, // true allows movement of up to 25 pixels
cancelOnMovement: 4, // true allows movement of up to 25 pixels
},
);
@ -868,72 +862,6 @@ function Status({
},
);
const displayedMediaAttachments = mediaAttachments.slice(
0,
isSizeLarge ? undefined : 4,
);
const showMultipleMediaCaptions =
mediaAttachments.length > 1 &&
displayedMediaAttachments.some(
(media) => !!media.description && !isMediaCaptionLong(media.description),
);
const captionChildren = useMemo(() => {
if (!showMultipleMediaCaptions) return null;
const attachments = [];
displayedMediaAttachments.forEach((media, i) => {
if (!media.description) return;
const index = attachments.findIndex(
(attachment) => attachment.media.description === media.description,
);
if (index === -1) {
attachments.push({
media,
indices: [i],
});
} else {
attachments[index].indices.push(i);
}
});
return attachments.map(({ media, indices }) => (
<div
key={media.id}
data-caption-index={indices.map((i) => i + 1).join(' ')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
states.showMediaAlt = {
alt: media.description,
lang: language,
};
}}
title={media.description}
>
<sup>{indices.map((i) => i + 1).join(' ')}</sup> {media.description}
</div>
));
// return displayedMediaAttachments.map(
// (media, i) =>
// !!media.description && (
// <div
// key={media.id}
// data-caption-index={i + 1}
// onClick={(e) => {
// e.preventDefault();
// e.stopPropagation();
// states.showMediaAlt = {
// alt: media.description,
// lang: language,
// };
// }}
// title={media.description}
// >
// <sup>{i + 1}</sup> {media.description}
// </div>
// ),
// );
}, [showMultipleMediaCaptions, displayedMediaAttachments, language]);
return (
<article
ref={(node) => {
@ -1040,14 +968,13 @@ function Status({
)}
<div class="container">
<div class="meta">
<span class="meta-name">
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={isSizeLarge}
/>
</span>
{/* <span> */}
<NameText
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={isSizeLarge}
/>
{/* {inReplyToAccount && !withinContext && size !== 's' && (
<>
{' '}
@ -1264,8 +1191,7 @@ function Status({
}}
refresh={() => {
return masto.v1.polls
.$select(poll.id)
.fetch()
.fetch(poll.id)
.then((pollResponse) => {
states.statuses[sKey].poll = pollResponse;
})
@ -1273,8 +1199,7 @@ function Status({
}}
votePoll={(choices) => {
return masto.v1.polls
.$select(poll.id)
.votes.create({
.vote(poll.id, {
choices,
})
.then((pollResponse) => {
@ -1330,27 +1255,19 @@ function Status({
</button>
)}
{!!mediaAttachments.length && (
<MultipleMediaFigure
lang={language}
enabled={showMultipleMediaCaptions}
captionChildren={captionChildren}
<div
ref={mediaContainerRef}
class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
<div
ref={mediaContainerRef}
class={`media-container media-eq${mediaAttachments.length} ${
mediaAttachments.length > 2 ? 'media-gt2' : ''
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
{displayedMediaAttachments.map((media, i) => (
{mediaAttachments
.slice(0, isSizeLarge ? undefined : 4)
.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={isSizeLarge}
showCaption={mediaAttachments.length === 1}
lang={language}
altIndex={
showMultipleMediaCaptions && !!media.description && i + 1
}
to={`/${instance}/s/${id}?${
withinContext ? 'media' : 'media-only'
}=${i + 1}`}
@ -1363,8 +1280,7 @@ function Status({
}
/>
))}
</div>
</MultipleMediaFigure>
</div>
)}
{!!card &&
card?.url !== status.url &&
@ -1532,7 +1448,7 @@ function Status({
statusID={showEdited}
instance={instance}
fetchStatusHistory={() => {
return masto.v1.statuses.$select(showEdited).history.list();
return masto.v1.statuses.listHistory(showEdited);
}}
onClose={() => {
setShowEdited(false);
@ -1561,19 +1477,6 @@ function Status({
);
}
function MultipleMediaFigure(props) {
const { enabled, children, lang, captionChildren } = props;
if (!enabled || !captionChildren) return children;
return (
<figure class="media-figure-multiple">
{children}
<figcaption lang={lang} dir="auto">
{captionChildren}
</figcaption>
</figure>
);
}
function Card({ card, instance }) {
const snapStates = useSnapshot(states);
const {
@ -1582,18 +1485,14 @@ function Card({ card, instance }) {
description,
html,
providerName,
providerUrl,
authorName,
authorUrl,
width,
height,
image,
imageDescription,
url,
type,
embedUrl,
language,
publishedAt,
} = card;
/* type
@ -1619,7 +1518,7 @@ function Card({ card, instance }) {
// NOTE: This is for quote post
// (async () => {
// const { masto } = api({ instance });
// const status = await masto.v1.statuses.$select(id).fetch();
// const status = await masto.v1.statuses.fetch(id);
// saveStatus(status, instance);
// setCardStatusID(id);
// })();
@ -1666,7 +1565,7 @@ function Card({ card, instance }) {
width={width}
height={height}
loading="lazy"
alt={imageDescription || ''}
alt=""
onError={(e) => {
try {
e.target.style.display = 'none';
@ -1839,16 +1738,15 @@ function ReactionsModal({ statusID, instance, onClose }) {
(async () => {
try {
if (firstLoad) {
reblogIterator.current = masto.v1.statuses
.$select(statusID)
.rebloggedBy.list({
reblogIterator.current = masto.v1.statuses.listRebloggedBy(statusID, {
limit: REACTIONS_LIMIT,
});
favouriteIterator.current = masto.v1.statuses.listFavouritedBy(
statusID,
{
limit: REACTIONS_LIMIT,
});
favouriteIterator.current = masto.v1.statuses
.$select(statusID)
.favouritedBy.list({
limit: REACTIONS_LIMIT,
});
},
);
}
const [{ value: reblogResults }, { value: favouriteResults }] =
await Promise.allSettled([
@ -2078,24 +1976,21 @@ function _unfurlMastodonLink(instance, url) {
if (statusMatch) {
const id = statusMatch[3];
const { masto } = api({ instance: domain });
remoteInstanceFetch = masto.v1.statuses
.$select(id)
.fetch()
.then((status) => {
if (status?.id) {
return {
status,
instance: domain,
};
} else {
throw new Error('No results');
}
});
remoteInstanceFetch = masto.v1.statuses.fetch(id).then((status) => {
if (status?.id) {
return {
status,
instance: domain,
};
} else {
throw new Error('No results');
}
});
}
const { masto } = api({ instance });
const mastoSearchFetch = masto.v2.search
.fetch({
const mastoSearchFetch = masto.v2
.search({
q: url,
type: 'statuses',
resolve: true,
@ -2165,7 +2060,11 @@ function nicePostURL(url) {
);
}
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
const unfurlMastodonLink = throttle(
mem(_unfurlMastodonLink, {
cacheKey: (instance, url) => `${instance}:${url}`,
}),
);
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
const {
@ -2188,7 +2087,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
threshold: 600,
captureEvent: true,
detect: 'touch',
cancelOnMovement: 2, // true allows movement of up to 25 pixels
cancelOnMovement: 4, // true allows movement of up to 25 pixels
},
);

View file

@ -14,15 +14,10 @@ import useScroll from '../utils/useScroll';
import Icon from './icon';
import Link from './link';
import Loader from './loader';
import NavMenu from './nav-menu';
import Status from './status';
const scrollIntoViewOptions = {
block: 'nearest',
inline: 'center',
behavior: 'smooth',
};
function Timeline({
title,
titleComponent,
@ -117,7 +112,7 @@ function Timeline({
}
if (nextItem) {
nextItem.focus();
nextItem.scrollIntoView(scrollIntoViewOptions);
nextItem.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
@ -127,7 +122,7 @@ function Timeline({
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView(scrollIntoViewOptions);
topmostItem.scrollIntoViewIfNeeded?.();
}
}
});
@ -156,7 +151,7 @@ function Timeline({
}
if (prevItem) {
prevItem.focus();
prevItem.scrollIntoView(scrollIntoViewOptions);
prevItem.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
@ -166,7 +161,7 @@ function Timeline({
});
if (topmostItem) {
topmostItem.focus();
topmostItem.scrollIntoView(scrollIntoViewOptions);
topmostItem.scrollIntoViewIfNeeded?.();
}
}
});
@ -418,7 +413,7 @@ function Timeline({
const isMiddle = i > 0 && i < items.length - 1;
const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact =
(!_differentAuthor && isSpoiler && i > 0) ||
(isSpoiler && i > 0) ||
(manyItems &&
isMiddle &&
(type === 'thread' ||

View file

@ -7,7 +7,6 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import sourceLanguages from '../data/lingva-source-languages';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeCode2Text from '../utils/localeCode2Text';
import pmem from '../utils/pmem';
import Icon from './icon';
import Loader from './loader';
@ -26,7 +25,7 @@ const LINGVA_INSTANCES = [
];
let currentLingvaInstance = 0;
function _lingvaTranslate(text, source, target) {
function lingvaTranslate(text, source, target) {
console.log('TRANSLATE', text, source, target);
const fetchCall = () => {
let instance = LINGVA_INSTANCES[currentLingvaInstance];
@ -56,18 +55,11 @@ function _lingvaTranslate(text, source, target) {
);
},
});
// return masto.v1.statuses.$select(id).translate({
// return masto.v1.statuses.translate(id, {
// lang: DEFAULT_LANG,
// });
}
const TRANSLATED_MAX_AGE = 1000 * 60 * 60; // 1 hour
const lingvaTranslate = pmem(_lingvaTranslate, {
maxAge: TRANSLATED_MAX_AGE,
});
const throttledLingvaTranslate = pmem(throttle(lingvaTranslate), {
// I know, this is double-layered memoization
maxAge: TRANSLATED_MAX_AGE,
});
const throttledLingvaTranslate = throttle(lingvaTranslate);
function TranslationBlock({
forceTranslate,

View file

@ -47,7 +47,7 @@
--reply-to-color: var(--orange-color);
--reply-to-text-color: #b36200;
--favourite-color: var(--red-color);
--reply-to-faded-color: #ffa60020;
--reply-to-faded-color: #ffa60030;
--outline-color: rgba(128, 128, 128, 0.2);
--outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1);
@ -64,9 +64,9 @@
--close-button-hover-color: rgba(0, 0, 0, 1);
/* Video colors won't change based on color scheme */
--media-fg-color: #f0f2f5;
--media-bg-color: #242526;
--media-outline-color: color-mix(in lch, var(--media-fg-color), transparent);
--video-fg-color: #f0f2f5;
--video-bg-color: #242526;
--video-outline-color: color-mix(in lch, var(--video-fg-color), transparent);
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
}
@ -92,14 +92,9 @@
--link-light-color: #6494ed99;
--link-faded-color: #6494ed88;
--link-bg-hover-color: #34353799;
--link-visited-color: color-mix(
in lch,
mediumslateblue 70%,
var(--text-color) 30%
);
--reblog-faded-color: #b190f141;
--reply-to-text-color: var(--reply-to-color);
--reply-to-faded-color: #ffa60017;
--reply-to-faded-color: #ffa60027;
--divider-color: rgba(255, 255, 255, 0.1);
--bg-blur-color: #24252699;
--backdrop-color: rgba(0, 0, 0, 0.5);

View file

@ -1,4 +1,4 @@
import { MenuItem } from '@szhsin/react-menu';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useParams, useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -31,8 +31,7 @@ function AccountStatuses() {
const results = [];
if (firstLoad) {
const { value: pinnedStatuses } = await masto.v1.accounts
.$select(id)
.statuses.list({
.listStatuses(id, {
pinned: true,
})
.next();
@ -54,15 +53,13 @@ function AccountStatuses() {
}
}
if (firstLoad || !accountStatusesIterator.current) {
accountStatusesIterator.current = masto.v1.accounts
.$select(id)
.statuses.list({
limit: LIMIT,
exclude_replies: excludeReplies,
exclude_reblogs: excludeBoosts,
only_media: media,
tagged,
});
accountStatusesIterator.current = masto.v1.accounts.listStatuses(id, {
limit: LIMIT,
exclude_replies: excludeReplies,
exclude_reblogs: excludeBoosts,
only_media: media,
tagged,
});
}
const { value, done } = await accountStatusesIterator.current.next();
if (value?.length) {
@ -89,16 +86,14 @@ function AccountStatuses() {
useEffect(() => {
(async () => {
try {
const acc = await masto.v1.accounts.$select(id).fetch();
const acc = await masto.v1.accounts.fetch(id);
console.log(acc);
setAccount(acc);
} catch (e) {
console.error(e);
}
try {
const featuredTags = await masto.v1.accounts
.$select(id)
.featuredTags.list(id);
const featuredTags = await masto.v1.accounts.listFeaturedTags(id);
console.log({ featuredTags });
setFeaturedTags(featuredTags);
} catch (e) {
@ -118,7 +113,7 @@ function AccountStatuses() {
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={() => masto.v1.accounts.$select(id).fetch()}
fetchAccount={() => masto.v1.accounts.fetch(id)}
authenticated={authenticated}
standalone
/>

View file

@ -52,9 +52,9 @@ function Accounts({ onClose }) {
onDblClick={async () => {
if (isCurrent) {
try {
const info = await masto.v1.accounts
.$select(account.info.id)
.fetch();
const info = await masto.v1.accounts.fetch(
account.info.id,
);
console.log('fetched account info', info);
account.info = info;
store.local.setJSON('accounts', accounts);

View file

@ -13,7 +13,7 @@ const LIMIT = 20;
function Following({ title, path, id, ...props }) {
useTitle(title || 'Following', path || '/following');
const { masto, streaming, instance } = api();
const { masto, instance } = api();
const snapStates = useSnapshot(states);
const homeIterator = useRef();
const latestItem = useRef();
@ -22,7 +22,7 @@ function Following({ title, path, id, ...props }) {
async function fetchHome(firstLoad) {
if (firstLoad || !homeIterator.current) {
homeIterator.current = masto.v1.timelines.home.list({ limit: LIMIT });
homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT });
}
const results = await homeIterator.current.next();
let { value } = results;
@ -53,8 +53,8 @@ function Following({ title, path, id, ...props }) {
async function checkForUpdates() {
try {
const results = await masto.v1.timelines.home
.list({
const results = await masto.v1.timelines
.listHome({
limit: 5,
since_id: latestItem.current,
})
@ -75,33 +75,52 @@ function Following({ title, path, id, ...props }) {
}
}
const ws = useRef();
const streamUser = async () => {
console.log('🎏 Start streaming user', ws.current);
if (
ws.current &&
(ws.current.readyState === WebSocket.CONNECTING ||
ws.current.readyState === WebSocket.OPEN)
) {
console.log('🎏 Streaming user already open');
return;
}
const stream = await masto.v1.stream.streamUser();
ws.current = stream.ws;
ws.current.__id = Math.random();
console.log('🎏 Streaming user', ws.current);
stream.on('status.update', (status) => {
console.log(`🔄 Status ${status.id} updated`);
saveStatus(status, instance);
});
stream.on('delete', (statusID) => {
console.log(`❌ Status ${statusID} deleted`);
// delete states.statuses[statusID];
const s = getStatus(statusID, instance);
if (s) s._deleted = true;
});
stream.ws.onclose = () => {
console.log('🎏 Streaming user closed');
};
return stream;
};
useEffect(() => {
let sub;
let stream;
(async () => {
if (streaming) {
sub = streaming.user.subscribe();
console.log('🎏 Streaming user', sub);
for await (const entry of sub) {
if (!sub) break;
if (entry.event === 'status.update') {
const status = entry.payload;
console.log(`🔄 Status ${status.id} updated`);
saveStatus(status, instance);
} else if (entry.event === 'delete') {
const statusID = entry.payload;
console.log(`❌ Status ${statusID} deleted`);
// delete states.statuses[statusID];
const s = getStatus(statusID, instance);
if (s) s._deleted = true;
}
}
}
stream = await streamUser();
})();
return () => {
sub?.unsubscribe?.();
sub = null;
if (stream) {
stream.ws.close();
ws.current = null;
}
};
}, [streaming]);
}, []);
return (
<Timeline

View file

@ -1,5 +1,6 @@
import {
FocusableItem,
Menu,
MenuDivider,
MenuGroup,
MenuItem,
@ -46,7 +47,7 @@ function Hashtags({ columnMode, ...props }) {
const maxID = useRef(undefined);
async function fetchHashtags(firstLoad) {
// if (firstLoad || !hashtagsIterator.current) {
// hashtagsIterator.current = masto.v1.timelines.tag.$select(hashtag).list({
// hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, {
// limit: LIMIT,
// any: hashtags.slice(1),
// });
@ -54,9 +55,8 @@ function Hashtags({ columnMode, ...props }) {
// const results = await hashtagsIterator.current.next();
// NOTE: Temporary fix for listHashtag not persisting `any` in subsequent calls.
const results = await masto.v1.timelines.tag
.$select(hashtag)
.list({
const results = await masto.v1.timelines
.listHashtag(hashtag, {
limit: LIMIT,
any: hashtags.slice(1),
maxId: firstLoad ? undefined : maxID.current,
@ -82,9 +82,8 @@ function Hashtags({ columnMode, ...props }) {
async function checkForUpdates() {
try {
const results = await masto.v1.timelines.tag
.$select(hashtag)
.list({
const results = await masto.v1.timelines
.listHashtag(hashtag, {
limit: 1,
any: hashtags.slice(1),
since_id: latestItem.current,
@ -106,7 +105,7 @@ function Hashtags({ columnMode, ...props }) {
useEffect(() => {
(async () => {
try {
const info = await masto.v1.tags.$select(hashtag).fetch();
const info = await masto.v1.tags.fetch(hashtag);
console.log(info);
setInfo(info);
} catch (e) {
@ -165,8 +164,7 @@ function Hashtags({ columnMode, ...props }) {
// return;
// }
masto.v1.tags
.$select(hashtag)
.unfollow()
.unfollow(hashtag)
.then(() => {
setInfo({ ...info, following: false });
showToast(`Unfollowed #${hashtag}`);
@ -180,8 +178,7 @@ function Hashtags({ columnMode, ...props }) {
});
} else {
masto.v1.tags
.$select(hashtag)
.follow()
.follow(hashtag)
.then(() => {
setInfo({ ...info, following: true });
showToast(`Followed #${hashtag}`);
@ -261,14 +258,11 @@ function Hashtags({ columnMode, ...props }) {
onClick={(e) => {
hashtags.splice(i, 1);
hashtags.sort();
// navigate(
// instance
// ? `/${instance}/t/${hashtags.join('+')}`
// : `/t/${hashtags.join('+')}`,
// );
location.hash = instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`;
navigate(
instance
? `/${instance}/t/${hashtags.join('+')}`
: `/t/${hashtags.join('+')}`,
);
}}
>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
@ -323,8 +317,7 @@ function Hashtags({ columnMode, ...props }) {
}
if (newInstance) {
newInstance = newInstance.toLowerCase().trim();
// navigate(`/${newInstance}/t/${hashtags.join('+')}`);
location.hash = `/${newInstance}/t/${hashtags.join('+')}`;
navigate(`/${newInstance}/t/${hashtags.join('+')}`);
}
}}
>

View file

@ -13,6 +13,7 @@ import Notification from '../components/notification';
import { api } from '../utils/api';
import db from '../utils/db';
import groupNotifications from '../utils/group-notifications';
import openCompose from '../utils/open-compose';
import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils';
@ -48,6 +49,24 @@ function Home() {
headerEnd={<NotificationsLink />}
/>
)}
{/* <button
// hidden={scrollDirection === 'end' && !nearReachStart}
type="button"
id="compose-button"
onClick={(e) => {
if (e.shiftKey) {
const newWin = openCompose();
if (!newWin) {
alert('Looks like your browser is blocking popups.');
states.showCompose = true;
}
} else {
states.showCompose = true;
}
}}
>
<Icon icon="quill" size="xl" alt="Compose" />
</button> */}
</>
);
}

View file

@ -32,7 +32,7 @@ function List(props) {
const listIterator = useRef();
async function fetchList(firstLoad) {
if (firstLoad || !listIterator.current) {
listIterator.current = masto.v1.timelines.list.$select(id).list({
listIterator.current = masto.v1.timelines.listList(id, {
limit: LIMIT,
});
}
@ -56,7 +56,7 @@ function List(props) {
async function checkForUpdates() {
try {
const results = await masto.v1.timelines.list.$select(id).list({
const results = await masto.v1.timelines.listList(id, {
limit: 1,
since_id: latestItem.current,
});
@ -77,7 +77,7 @@ function List(props) {
useEffect(() => {
(async () => {
try {
const list = await masto.v1.lists.$select(id).fetch();
const list = await masto.v1.lists.fetch(id);
setList(list);
// setTitle(list.title);
} catch (e) {
@ -200,11 +200,9 @@ function ListManageMembers({ listID, onClose }) {
(async () => {
try {
if (firstLoad || !membersIterator.current) {
membersIterator.current = masto.v1.lists
.$select(listID)
.accounts.list({
limit: MEMBERS_LIMIT,
});
membersIterator.current = masto.v1.lists.listAccounts(listID, {
limit: MEMBERS_LIMIT,
});
}
const results = await membersIterator.current.next();
let { done, value } = results;
@ -276,7 +274,7 @@ function RemoveAddButton({ account, listID }) {
setUIState('loading');
(async () => {
try {
await masto.v1.lists.$select(listID).accounts.create({
await masto.v1.lists.addAccount(listID, {
accountIds: [account.id],
});
setUIState('default');
@ -292,7 +290,7 @@ function RemoveAddButton({ account, listID }) {
(async () => {
try {
await masto.v1.lists.$select(listID).accounts.remove({
await masto.v1.lists.removeAccount(listID, {
accountIds: [account.id],
});
setUIState('default');

View file

@ -92,16 +92,11 @@ function Notifications({ columnMode }) {
return allNotifications;
}
async function fetchFollowRequests() {
function fetchFollowRequests() {
// Note: no pagination here yet because this better be on a separate page. Should be rare use-case???
try {
return await masto.v1.followRequests.list({
limit: 80,
});
} catch (e) {
// Silently fail
return [];
}
return masto.v1.followRequests.list({
limit: 80,
});
}
const loadFollowRequests = () => {
@ -117,13 +112,8 @@ function Notifications({ columnMode }) {
})();
};
async function fetchAnnouncements() {
try {
return await masto.v1.announcements.list();
} catch (e) {
// Silently fail
return [];
}
function fetchAnnouncements() {
return masto.v1.announcements.list();
}
const loadNotifications = (firstLoad) => {
@ -389,43 +379,39 @@ function Notifications({ columnMode }) {
)}
{snapStates.notifications.length ? (
<>
{snapStates.notifications
// This is leaked from Notifications popover
.filter((n) => n.type !== 'follow_request')
.map((notification) => {
if (onlyMentions && notification.type !== 'mention') {
return null;
}
const notificationDay = new Date(notification.createdAt);
const differentDay =
notificationDay.toDateString() !== currentDay.toDateString();
if (differentDay) {
currentDay = notificationDay;
}
// if notificationDay is yesterday, show "Yesterday"
// if notificationDay is before yesterday, show date
const heading =
notificationDay.toDateString() ===
yesterdayDate.toDateString()
? 'Yesterday'
: niceDateTime(currentDay, {
hideTime: true,
});
return (
<>
{differentDay && <h2 class="timeline-header">{heading}</h2>}
<Notification
instance={instance}
notification={notification}
key={notification.id}
reload={() => {
loadNotifications(true);
loadFollowRequests();
}}
/>
</>
);
})}
{snapStates.notifications.map((notification) => {
if (onlyMentions && notification.type !== 'mention') {
return null;
}
const notificationDay = new Date(notification.createdAt);
const differentDay =
notificationDay.toDateString() !== currentDay.toDateString();
if (differentDay) {
currentDay = notificationDay;
}
// if notificationDay is yesterday, show "Yesterday"
// if notificationDay is before yesterday, show date
const heading =
notificationDay.toDateString() === yesterdayDate.toDateString()
? 'Yesterday'
: niceDateTime(currentDay, {
hideTime: true,
});
return (
<>
{differentDay && <h2 class="timeline-header">{heading}</h2>}
<Notification
instance={instance}
notification={notification}
key={notification.id}
reload={() => {
loadNotifications(true);
loadFollowRequests();
}}
/>
</>
);
})}
</>
) : (
<>

View file

@ -29,7 +29,7 @@ function Public({ local, columnMode, ...props }) {
const publicIterator = useRef();
async function fetchPublic(firstLoad) {
if (firstLoad || !publicIterator.current) {
publicIterator.current = masto.v1.timelines.public.list({
publicIterator.current = masto.v1.timelines.listPublic({
limit: LIMIT,
local: isLocal,
});
@ -54,8 +54,8 @@ function Public({ local, columnMode, ...props }) {
async function checkForUpdates() {
try {
const results = await masto.v1.timelines.public
.list({
const results = await masto.v1.timelines
.listPublic({
limit: 1,
local: isLocal,
since_id: latestItem.current,

View file

@ -90,7 +90,7 @@ function Search(props) {
if (authenticated) params.offset = offsetRef.current;
}
try {
const results = await masto.v2.search.fetch(params);
const results = await masto.v2.search(params);
console.log(results);
if (type) {
if (firstLoad) {

View file

@ -135,8 +135,3 @@
padding-inline: 16px;
color: var(--text-insignificant-color);
}
#settings-container .synced-icon {
color: var(--link-color);
vertical-align: middle;
}

View file

@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import logo from '../assets/logo.svg';
import Icon from '../components/icon';
import Link from '../components/link';
import RelativeTime from '../components/relative-time';
@ -35,7 +34,7 @@ function Settings({ onClose }) {
const currentTextSize = store.local.get('textSize') || DEFAULT_TEXT_SIZE;
const [prefs, setPrefs] = useState(store.account.get('preferences') || {});
const { masto, authenticated, instance } = api();
const { masto, authenticated } = api();
// Get preferences every time Settings is opened
// NOTE: Disabled for now because I don't expect this to change often. Also for some reason, the /api/v1/preferences endpoint is cached for a while and return old prefs if refresh immediately after changing them.
// useEffect(() => {
@ -179,8 +178,7 @@ function Settings({ onClose }) {
<li>
<div>
<label for="posting-privacy-field">
Default visibility{' '}
<Icon icon="cloud" alt="Synced" class="synced-icon" />
Default visibility
</label>
</div>
<div>
@ -219,19 +217,6 @@ function Settings({ onClose }) {
</li>
</ul>
</section>
<p class="section-postnote">
<Icon icon="cloud" alt="Synced" class="synced-icon" />{' '}
<small>
Synced to your instance server's settings.{' '}
<a
href={`https://${instance}/`}
target="_blank"
rel="noopener noreferrer"
>
Go to your instance ({instance}) for more settings.
</a>
</small>
</p>
</>
)}
<h3>Experiments</h3>
@ -354,7 +339,6 @@ function Settings({ onClose }) {
<a
href="https://github.com/thedaviddelta/lingva-translate"
target="_blank"
rel="noopener noreferrer"
>
Lingva Translate
</a>
@ -451,7 +435,6 @@ function Settings({ onClose }) {
<a
href="https://hachyderm.io/@phanpy"
// target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'phanpy@hachyderm.io';
@ -475,7 +458,6 @@ function Settings({ onClose }) {
<a
href="https://mastodon.social/@cheeaun"
// target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
states.showAccount = 'cheeaun@mastodon.social';
@ -486,26 +468,9 @@ function Settings({ onClose }) {
</div>
</div>
<p>
<a
href="https://github.com/sponsors/cheeaun"
target="_blank"
rel="noopener noreferrer"
>
Sponsor
</a>{' '}
&middot;{' '}
<a
href="https://www.buymeacoffee.com/cheeaun"
target="_blank"
rel="noopener noreferrer"
>
Donate
</a>{' '}
&middot;{' '}
<a
href="https://github.com/cheeaun/phanpy/blob/main/PRIVACY.MD"
target="_blank"
rel="noopener noreferrer"
>
Privacy Policy
</a>

View file

@ -14,7 +14,7 @@ import {
} from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { matchPath, useSearchParams } from 'react-router-dom';
import { matchPath, useParams, useSearchParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
@ -54,12 +54,6 @@ function resetScrollPosition(id) {
delete scrollPositions[id];
}
const scrollIntoViewOptions = {
block: 'nearest',
inline: 'center',
behavior: 'smooth',
};
function StatusPage(params) {
const { id } = params;
const { masto, instance } = api({ instance: params.instance });
@ -100,7 +94,7 @@ function StatusPage(params) {
if (!heroStatus && showMedia) {
(async () => {
try {
const status = await masto.v1.statuses.$select(id).fetch();
const status = await masto.v1.statuses.fetch(id);
saveStatus(status, instance);
setHeroStatus(status);
} catch (err) {
@ -141,7 +135,6 @@ function StatusPage(params) {
mediaAttachments={mediaAttachments}
statusID={mediaStatusID || id}
instance={instance}
lang={heroStatus?.language}
index={mediaIndex - 1}
onClose={handleMediaClose}
/>
@ -235,15 +228,12 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
(async () => {
const heroFetch = () =>
pRetry(() => masto.v1.statuses.$select(id).fetch(), {
pRetry(() => masto.v1.statuses.fetch(id), {
retries: 4,
});
const contextFetch = pRetry(
() => masto.v1.statuses.$select(id).context.fetch(),
{
retries: 8,
},
);
const contextFetch = pRetry(() => masto.v1.statuses.fetchContext(id), {
retries: 8,
});
const hasStatus = !!snapStates.statuses[sKey];
let heroStatus = snapStates.statuses[sKey];
@ -564,7 +554,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
let nextStatus = allStatusLinks[activeStatusIndex + 1];
if (nextStatus) {
nextStatus.focus();
nextStatus.scrollIntoView(scrollIntoViewOptions);
nextStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
@ -574,7 +564,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoView(scrollIntoViewOptions);
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
@ -598,7 +588,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
let prevStatus = allStatusLinks[activeStatusIndex - 1];
if (prevStatus) {
prevStatus.focus();
prevStatus.scrollIntoView(scrollIntoViewOptions);
prevStatus.scrollIntoViewIfNeeded?.();
}
} else {
// If active status is not in viewport, get the topmost status-link in viewport
@ -608,7 +598,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
});
if (topmostStatusLink) {
topmostStatusLink.focus();
topmostStatusLink.scrollIntoView(scrollIntoViewOptions);
topmostStatusLink.scrollIntoViewIfNeeded?.();
}
}
});
@ -949,13 +939,12 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
setUIState('loading');
(async () => {
try {
const results =
await currentMasto.v2.search.fetch({
q: heroStatus.url,
type: 'statuses',
resolve: true,
limit: 1,
});
const results = await currentMasto.v2.search({
q: heroStatus.url,
type: 'statuses',
resolve: true,
limit: 1,
});
if (results.statuses.length) {
const status = results.statuses[0];
location.hash = currentInstance

View file

@ -1,4 +1,4 @@
import { MenuItem } from '@szhsin/react-menu';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useMemo, useRef, useState } from 'preact/hooks';
import { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
@ -30,13 +30,13 @@ function Trending({ columnMode, ...props }) {
const trendIterator = useRef();
async function fetchTrend(firstLoad) {
if (firstLoad || !trendIterator.current) {
trendIterator.current = masto.v1.trends.statuses.list({
trendIterator.current = masto.v1.trends.listStatuses({
limit: LIMIT,
});
// Get hashtags
try {
const iterator = masto.v1.trends.tags.list();
const iterator = masto.v1.trends.listTags();
const { value: tags } = await iterator.next();
console.log(tags);
setHashtags(tags);
@ -64,8 +64,8 @@ function Trending({ columnMode, ...props }) {
async function checkForUpdates() {
try {
const results = await masto.v1.trends.statuses
.list({
const results = await masto.v1.trends
.listStatuses({
limit: 1,
// NOT SUPPORTED
// since_id: latestItem.current,

View file

@ -7,7 +7,6 @@ import multiHashtagTimelineUrl from '../assets/features/multi-hashtag-timeline.j
import nestedCommentsThreadUrl from '../assets/features/nested-comments-thread.jpg';
import logoText from '../assets/logo-text.svg';
import logo from '../assets/logo.svg';
import Link from '../components/link';
import states from '../utils/states';
import useTitle from '../utils/useTitle';

View file

@ -1,4 +1,4 @@
import { createRestAPIClient, createStreamingAPIClient } from 'masto';
import { createClient } from 'masto';
import store from './store';
import {
@ -37,17 +37,14 @@ export function initClient({ instance, accessToken }) {
}
const url = instance ? `https://${instance}` : `https://${DEFAULT_INSTANCE}`;
const masto = createRestAPIClient({
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;
const client = {
masto,
instance,
accessToken,
};
apis[instance] = client;
if (!accountApis[instance]) accountApis[instance] = {};
if (accessToken) accountApis[instance][accessToken] = client;
@ -58,8 +55,7 @@ export function initClient({ instance, accessToken }) {
// Get the instance information
// The config is needed for composing
export async function initInstance(client, instance) {
console.log('INIT INSTANCE', client, instance);
const { masto, accessToken } = client;
const masto = client;
// Request v2, fallback to v1 if fail
let info;
try {
@ -67,7 +63,7 @@ export async function initInstance(client, instance) {
} catch (e) {}
if (!info) {
try {
info = await masto.v1.instance.fetch();
info = await masto.v1.instances.fetch();
} catch (e) {}
}
if (!info) return;
@ -95,28 +91,17 @@ export async function initInstance(client, instance) {
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
const supportsWebSocket = 'WebSocket' in window;
if (supportsWebSocket && (streamingApi || streaming)) {
if (streamingApi || streaming) {
console.log('🎏 Streaming API URL:', streaming || streamingApi);
// masto.config.props.streamingApiUrl = streaming || streamingApi;
// Legacy masto.ws
const streamClient = createStreamingAPIClient({
streamingApiUrl: streaming || streamingApi,
accessToken,
implementation: WebSocket,
});
client.streaming = streamClient;
// masto.ws = streamClient;
console.log('🎏 Streaming API client:', client);
masto.config.props.streamingApiUrl = streaming || streamingApi;
}
}
// Get the account information and store it
export async function initAccount(client, instance, accessToken, vapidKey) {
const { masto } = client;
const masto = client;
const mastoAccount = await masto.v1.accounts.verifyCredentials();
console.log('CURRENTACCOUNT SET', mastoAccount.id);
store.session.set('currentAccount', mastoAccount.id);
saveAccount({
@ -130,7 +115,7 @@ export async function initAccount(client, instance, accessToken, vapidKey) {
// Get preferences
export async function initPreferences(client) {
try {
const { masto } = client;
const masto = client;
const preferences = await masto.v1.preferences.fetch();
store.account.set('preferences', preferences);
} catch (e) {
@ -149,14 +134,10 @@ export function api({ instance, accessToken, accountID, account } = {}) {
// If instance and accessToken are provided, get the masto instance for that account
if (instance && accessToken) {
const client =
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken });
const { masto, streaming } = client;
return {
masto,
streaming,
client,
masto:
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken }),
authenticated: true,
instance,
};
@ -168,12 +149,8 @@ export function api({ instance, accessToken, accountID, account } = {}) {
for (const instance in accountApis) {
if (accountApis[instance][accessToken]) {
console.log('X 2', accountApis, instance, accessToken);
const client = accountApis[instance][accessToken];
const { masto, streaming } = client;
return {
masto,
streaming,
client,
masto: accountApis[instance][accessToken],
authenticated: true,
instance,
};
@ -183,17 +160,13 @@ export function api({ instance, accessToken, accountID, account } = {}) {
if (account) {
const accessToken = account.accessToken;
const instance = account.instanceURL.toLowerCase().trim();
const client = initClient({ instance, accessToken });
const { masto, streaming } = client;
return {
masto,
streaming,
client,
masto: initClient({ instance, accessToken }),
authenticated: true,
instance,
};
} else {
throw new Error(`Access token not found`);
throw new Error(`Access token ${accessToken} not found`);
}
}
}
@ -205,14 +178,10 @@ export function api({ instance, accessToken, accountID, account } = {}) {
if (account) {
const accessToken = account.accessToken;
const instance = account.instanceURL.toLowerCase().trim();
const client =
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken });
const { masto, streaming } = client;
return {
masto,
streaming,
client,
masto:
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken }),
authenticated: true,
instance,
};
@ -223,13 +192,10 @@ export function api({ instance, accessToken, accountID, account } = {}) {
// If only instance is provided, get the masto instance for that instance
if (instance) {
const client = apis[instance] || initClient({ instance });
const { masto, streaming, accessToken } = client;
const masto = apis[instance] || initClient({ instance });
return {
masto,
streaming,
client,
authenticated: !!accessToken,
authenticated: !!masto.config.props.accessToken,
instance,
};
}
@ -237,11 +203,9 @@ export function api({ instance, accessToken, accountID, account } = {}) {
// If no instance is provided, get the masto instance for the current account
if (currentAccountApi) {
return {
masto: currentAccountApi.masto,
streaming: currentAccountApi.streaming,
client: currentAccountApi,
masto: currentAccountApi,
authenticated: true,
instance: currentAccountApi.instance,
instance: currentAccountApi.__instance__,
};
}
const currentAccount = getCurrentAccount();
@ -251,22 +215,15 @@ export function api({ instance, accessToken, accountID, account } = {}) {
accountApis[instance]?.[accessToken] ||
initClient({ instance, accessToken });
return {
masto: currentAccountApi.masto,
streaming: currentAccountApi.streaming,
client: currentAccountApi,
masto: currentAccountApi,
authenticated: true,
instance,
};
}
// If no instance is provided and no account is logged in, get the masto instance for DEFAULT_INSTANCE
const client =
apis[DEFAULT_INSTANCE] || initClient({ instance: DEFAULT_INSTANCE });
const { masto, streaming } = client;
return {
masto,
streaming,
client,
masto: apis[DEFAULT_INSTANCE] || initClient({ instance: DEFAULT_INSTANCE }),
authenticated: false,
instance: DEFAULT_INSTANCE,
};

View file

@ -1,10 +1,9 @@
import emojifyText from './emojify-text';
import mem from './mem';
const fauxDiv = document.createElement('div');
const whitelistLinkClasses = ['u-url', 'mention', 'hashtag'];
function _enhanceContent(content, opts = {}) {
function enhanceContent(content, opts = {}) {
const { emojis, postEnhanceDOM = () => {} } = opts;
let enhancedContent = content;
const dom = document.createElement('div');
@ -251,7 +250,6 @@ function _enhanceContent(content, opts = {}) {
return enhancedContent;
}
const enhanceContent = mem(_enhanceContent);
const defaultRejectFilter = [
// Document metadata

View file

@ -3,11 +3,6 @@ import translationTargetLanguages from '../data/lingva-target-languages';
import localeMatch from './locale-match';
import states from './states';
const locales = [
new Intl.DateTimeFormat().resolvedOptions().locale,
...navigator.languages,
];
function getTranslateTargetLanguage(fromSettings = false) {
if (fromSettings) {
const { contentTranslationTargetLanguage } = states.settings;
@ -16,7 +11,10 @@ function getTranslateTargetLanguage(fromSettings = false) {
}
}
return localeMatch(
locales,
[
new Intl.DateTimeFormat().resolvedOptions().locale,
...navigator.languages,
],
translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match`
'en',
);

View file

@ -2,7 +2,7 @@ export default function isMastodonLinkMaybe(url) {
const { pathname } = new URL(url);
return (
/^\/.*\/\d+$/i.test(pathname) ||
/^\/@[^/]+\/(statuses|posts)\/\w+\/?$/i.test(pathname) || // GoToSocial, Takahe
/^\/@[^/]+\/statuses\/\w+$/i.test(pathname) || // GoToSocial
/^\/notes\/[a-z0-9]+$/i.test(pathname) || // Misskey, Calckey
/^\/(notice|objects)\/[a-z0-9-]+$/i.test(pathname) // Pleroma
);

View file

@ -1,6 +1,5 @@
import { match } from '@formatjs/intl-localematcher';
import mem from './mem';
import mem from 'mem';
function _localeMatch(...args) {
// Wrap in try/catch because localeMatcher throws on invalid locales
@ -11,6 +10,8 @@ function _localeMatch(...args) {
return defaultLocale || false;
}
}
const localeMatch = mem(_localeMatch);
const localeMatch = mem(_localeMatch, {
cacheKey: (args) => args.join(),
});
export default localeMatch;

View file

@ -1,5 +0,0 @@
import moize from 'moize';
export default function mem(fn, opts = {}) {
return moize(fn, { ...opts, maxSize: 100 });
}

View file

@ -1,5 +0,0 @@
import mem from './mem';
export default function pmem(fn, opts = {}) {
return mem(fn, { isPromise: true, ...opts });
}

View file

@ -34,22 +34,22 @@ import { getCurrentAccount } from './store-utils';
function createBackendPushSubscription(subscription) {
const { masto } = api();
return masto.v1.push.subscription.create(subscription);
return masto.v1.webPushSubscriptions.create(subscription);
}
function fetchBackendPushSubscription() {
const { masto } = api();
return masto.v1.push.subscription.fetch();
return masto.v1.webPushSubscriptions.fetch();
}
function updateBackendPushSubscription(subscription) {
const { masto } = api();
return masto.v1.push.subscription.update(subscription);
return masto.v1.webPushSubscriptions.update(subscription);
}
function removeBackendPushSubscription() {
const { masto } = api();
return masto.v1.push.subscription.remove();
return masto.v1.webPushSubscriptions.remove();
}
// Front-end

View file

@ -1,8 +1,8 @@
import mem from 'mem';
import { proxy, subscribe } from 'valtio';
import { subscribeKey } from 'valtio/utils';
import { api } from './api';
import pmem from './pmem';
import store from './store';
const states = proxy({
@ -18,16 +18,12 @@ const states = proxy({
homeLast: null, // Last item in 'home' list
homeLastFetchTime: null,
notifications: [],
notificationsLast: null, // Last read notification
notificationsLast: store.account.get('notificationsLast') || null, // Last read notification
notificationsNew: [],
notificationsShowNew: false,
notificationsLastFetchTime: null,
accounts: {},
reloadStatusPage: 0,
reloadGenericAccounts: {
id: null,
counter: 0,
},
spoilers: {},
scrollPositions: {},
unfurledLinks: {},
@ -43,21 +39,24 @@ const states = proxy({
showMediaModal: false,
showShortcutsSettings: false,
showKeyboardShortcutsHelp: false,
showGenericAccounts: false,
showMediaAlt: false,
// Shortcuts
shortcuts: [],
shortcuts: store.account.get('shortcuts') ?? [],
// Settings
settings: {
autoRefresh: false,
shortcutsViewMode: null,
shortcutsColumnsMode: false,
boostsCarousel: true,
contentTranslation: true,
contentTranslationTargetLanguage: null,
contentTranslationHideLanguages: [],
contentTranslationAutoInline: false,
cloakMode: false,
autoRefresh: store.account.get('settings-autoRefresh') ?? false,
shortcutsViewMode: store.account.get('settings-shortcutsViewMode') ?? null,
shortcutsColumnsMode:
store.account.get('settings-shortcutsColumnsMode') ?? false,
boostsCarousel: store.account.get('settings-boostsCarousel') ?? true,
contentTranslation:
store.account.get('settings-contentTranslation') ?? true,
contentTranslationTargetLanguage:
store.account.get('settings-contentTranslationTargetLanguage') || null,
contentTranslationHideLanguages:
store.account.get('settings-contentTranslationHideLanguages') || [],
contentTranslationAutoInline:
store.account.get('settings-contentTranslationAutoInline') ?? false,
cloakMode: store.account.get('settings-cloakMode') ?? false,
},
});
@ -141,7 +140,6 @@ export function hideAllModals() {
states.showShortcutsSettings = false;
states.showKeyboardShortcutsHelp = false;
states.showGenericAccounts = false;
states.showMediaAlt = false;
}
export function statusKey(id, instance) {
@ -207,7 +205,7 @@ export function threadifyStatus(status, propInstance) {
if (!prevStatus) {
if (fetchIndex++ > 3) throw 'Too many fetches for thread'; // Some people revive old threads
await new Promise((r) => setTimeout(r, 500 * fetchIndex)); // Be nice to rate limits
// prevStatus = await masto.v1.statuses.$.select(inReplyToId).fetch();
// prevStatus = await masto.v1.statuses.fetch(inReplyToId);
prevStatus = await fetchStatus(inReplyToId, masto);
saveStatus(prevStatus, instance, { skipThreading: true });
}
@ -229,6 +227,6 @@ export function threadifyStatus(status, propInstance) {
});
}
const fetchStatus = pmem((statusID, masto) => {
return masto.v1.statuses.$select(statusID).fetch();
const fetchStatus = mem((statusID, masto) => {
return masto.v1.statuses.fetch(statusID);
});

View file

@ -11,11 +11,6 @@ export function getAccountByAccessToken(accessToken) {
}
export function getCurrentAccount() {
if (!window.__IGNORE_GET_ACCOUNT_ERROR__) {
// Track down getCurrentAccount() calls before account-based states are initialized
console.error('getCurrentAccount() called before states are initialized');
if (import.meta.env.DEV) console.trace();
}
const currentAccount = store.session.get('currentAccount');
const account = getAccount(currentAccount);
return account;

View file

@ -1,25 +1,17 @@
import { useRef } from 'preact/hooks';
import { useThrottledCallback } from 'use-debounce';
import useResizeObserver from 'use-resize-observer';
export default function useTruncated({ className = 'truncated' } = {}) {
const ref = useRef();
const onResize = useThrottledCallback(({ height }) => {
if (ref.current) {
const { scrollHeight } = ref.current;
let truncated = scrollHeight > height;
if (truncated) {
const { height: _height, maxHeight } = getComputedStyle(ref.current);
const computedHeight = parseInt(maxHeight || _height, 10);
truncated = scrollHeight > computedHeight;
}
ref.current.classList.toggle(className, truncated);
}
}, 300);
useResizeObserver({
ref,
box: 'border-box',
onResize,
onResize: ({ height }) => {
if (ref.current) {
const { scrollHeight } = ref.current;
ref.current.classList.toggle(className, scrollHeight > height);
}
},
});
return ref;
}