Merge pull request #360 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-12-22 23:34:15 +08:00 committed by GitHub
commit 1e21f519f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1080 additions and 427 deletions

View file

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: 'enhancement'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -107,12 +107,6 @@ Prerequisites: Node.js 18+
- requires `.env.dev` file with `INSTANCES_SOCIAL_SECRET_TOKEN` variable set
- `npm run sourcemap` - Run `source-map-explorer` on the production build
## Self-hosting
This is a **pure static web app**. You can host it anywhere you want. Build it by running `npm run build` (after `npm install`) and serve the `dist` folder.
Try search for "how to self-host static sites" as there are many ways to do it.
## Tech stack
- [Vite](https://vitejs.dev/) - Build tool
@ -122,10 +116,34 @@ Try search for "how to self-host static sites" as there are many ways to do it.
- [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.*
- Vanilla CSS - _Yes, I'm old school._
Some of these may change in the future. The front-end world is ever-changing.
## Self-hosting
This is a **pure static web app**. You can host it anywhere you want.
Two ways (choose one):
1. (Recommended) Go to [Releases](https://github.com/cheeaun/phanpy/releases) and download the latest `phanpy-dist.zip`. It's pre-built so don't need to run any install/build commands. Extract it. Serve the folder of extracted files.
2. Download or `git clone` this repository. Build it by running `npm run build` (after `npm install`). Serve the `dist` folder.
Try search for "how to self-host static sites" as there are many ways to do it.
## Community deployments
These are self-hosted by other wonderful folks.
- [ferengi.one](https://ferengi.one/) by [@david@collantes.social](https://collantes.social/@david)
- [phanpy.blaede.family](https://phanpy.blaede.family/) by [@cassidy@blaede.family](https://mastodon.blaede.family/@cassidy)
- [phanpy.mstdn.mx](https://phanpy.mstdn.mx/) by [@maop@mstdn.mx](https://mstdn.mx/@maop)
- [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan)
- [phanpy.gotosocial.social](https://phanpy.gotosocial.social/) by [@admin@gotosocial.social](https://gotosocial.social/@admin)
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
> Note: Add yours by creating a pull request.
## Costs
Costs involved in running and developing this web app:

33
package-lock.json generated
View file

@ -26,8 +26,8 @@
"masto": "~6.5.1",
"moize": "~6.1.6",
"p-retry": "~6.1.0",
"p-throttle": "~6.0.0",
"preact": "~10.19.2",
"p-throttle": "~6.1.0",
"preact": "~10.19.3",
"react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.3",
"react-quick-pinch-zoom": "~5.1.0",
@ -49,10 +49,10 @@
"postcss-dark-theme-class": "~1.1.0",
"postcss-preset-env": "~9.3.0",
"twitter-text": "~3.1.0",
"vite": "~5.0.5",
"vite": "~5.0.10",
"vite-plugin-generate-file": "~0.1.1",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.17.3",
"vite-plugin-pwa": "~0.17.4",
"vite-plugin-remove-console": "~2.2.0",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",
@ -5698,9 +5698,9 @@
}
},
"node_modules/p-throttle": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-6.0.0.tgz",
"integrity": "sha512-08yhRj7LFw5O0pV4Bkk/9sQlKTFhSMdvG5Akeo9lvaLhBvyKDgTt/bcSMd9b5UHjz+2P1EQPjzcnIXAKnKSiaA==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-6.1.0.tgz",
"integrity": "sha512-eQMdGTxk2+047La67wefUtt0tEHh7D+C8Jl7QXoFCuIiNYeQ9zWs2AZiJdIAs72rSXZ06t11me2bgalRNdy3SQ==",
"engines": {
"node": ">=18"
},
@ -6515,10 +6515,9 @@
"license": "MIT"
},
"node_modules/preact": {
"version": "10.19.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.19.2.tgz",
"integrity": "sha512-UA9DX/OJwv6YwP9Vn7Ti/vF80XL+YA5H2l7BpCtUr3ya8LWHFzpiO5R+N7dN16ujpIxhekRFuOOF82bXX7K/lg==",
"license": "MIT",
"version": "10.19.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz",
"integrity": "sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -7618,9 +7617,9 @@
}
},
"node_modules/vite": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.5.tgz",
"integrity": "sha512-OekeWqR9Ls56f3zd4CaxzbbS11gqYkEiBtnWFFgYR2WV8oPJRRKq0mpskYy/XaoCL3L7VINDhqqOMNDiYdGvGg==",
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.10.tgz",
"integrity": "sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
@ -7699,9 +7698,9 @@
}
},
"node_modules/vite-plugin-pwa": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.17.3.tgz",
"integrity": "sha512-ilOs0mGxIxKQN3FZYX8pys5DmY/wI9A6oojlY5rrd7mAxCVcSbtjDVAhm62C+3Ww6KQrNr/jmiRUCplC8AsaBw==",
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.17.4.tgz",
"integrity": "sha512-j9iiyinFOYyof4Zk3Q+DtmYyDVBDAi6PuMGNGq6uGI0pw7E+LNm9e+nQ2ep9obMP/kjdWwzilqUrlfVRj9OobA==",
"dev": true,
"dependencies": {
"debug": "^4.3.4",

View file

@ -28,8 +28,8 @@
"masto": "~6.5.1",
"moize": "~6.1.6",
"p-retry": "~6.1.0",
"p-throttle": "~6.0.0",
"preact": "~10.19.2",
"p-throttle": "~6.1.0",
"preact": "~10.19.3",
"react-hotkeys-hook": "~4.4.1",
"react-intersection-observer": "~9.5.3",
"react-quick-pinch-zoom": "~5.1.0",
@ -51,10 +51,10 @@
"postcss-dark-theme-class": "~1.1.0",
"postcss-preset-env": "~9.3.0",
"twitter-text": "~3.1.0",
"vite": "~5.0.5",
"vite": "~5.0.10",
"vite-plugin-generate-file": "~0.1.1",
"vite-plugin-html-config": "~1.0.11",
"vite-plugin-pwa": "~0.17.3",
"vite-plugin-pwa": "~0.17.4",
"vite-plugin-remove-console": "~2.2.0",
"workbox-cacheable-response": "~7.0.0",
"workbox-expiration": "~7.0.0",

View file

@ -1578,6 +1578,13 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
.tag.danger {
background-color: var(--red-color);
}
.tag.minimal {
margin: 0;
color: var(--text-insignificant-color);
background-color: var(--bg-faded-color);
text-shadow: 0 1px var(--bg-color);
line-height: 1;
}
/* MENU POPUP */

View file

@ -204,7 +204,16 @@ if (isIOS) {
);
const color = $meta?.getAttribute('content');
if (color) {
$meta.content = '';
let tempColor;
if (/^#/.test(color)) {
// Assume either #RBG or #RRGGBB
if (color.length === 4) {
tempColor = color + 'f';
} else if (color.length === 7) {
tempColor = color + 'ff';
}
}
$meta.content = tempColor || '';
setTimeout(() => {
$meta.content = color;
}, 10);

View file

@ -4,6 +4,10 @@
gap: 8px;
color: var(--text-color);
text-decoration: none;
.account-block-acct {
display: inline-block;
}
}
.account-block:hover b {
text-decoration: underline;
@ -13,44 +17,54 @@
color: var(--bg-faded-color);
}
.account-block .short-desc {
max-height: 1.2em; /* just in case clamping ain't working */
}
.account-block .short-desc,
.account-block .short-desc > * {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.account-block .short-desc > * + * {
display: none;
}
.account-block .short-desc * {
margin: 0;
padding: 0;
color: inherit;
pointer-events: none;
}
.account-block .verified-field {
color: var(--green-color);
display: inline-flex;
align-items: center;
align-items: baseline;
gap: 2px;
}
.account-block .verified-field .icon {
}
.account-block .verified-field .invisible {
display: none;
* {
-webkit-box-orient: vertical;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
text-overflow: ellipsis;
overflow: hidden;
}
a {
pointer-events: none;
color: color-mix(
in lch,
var(--green-color) 20%,
var(--text-insignificant-color) 80%
) !important;
}
.icon {
color: var(--green-color);
transform: translateY(1px);
}
.invisible {
display: none;
}
.ellipsis:after {
content: '…';
}
}
.account-block .account-block-stats {
line-height: 1.25;
margin-top: 2px;
font-size: 0.9em;
color: var(--text-insignificant-color);
}
.account-block .account-block-stats a {
color: inherit;
text-decoration: none;
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 4px;
a {
color: inherit;
text-decoration: none;
}
}

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';
@ -22,6 +23,8 @@ function AccountBlock({
showStats = false,
accountInstance,
hideDisplayName = false,
relationship = {},
excludeRelationshipAttrs = [],
}) {
if (skeleton) {
return (
@ -36,6 +39,10 @@ function AccountBlock({
);
}
if (!account) {
return null;
}
// const navigate = useNavigate();
const {
@ -53,6 +60,7 @@ function AccountBlock({
fields,
note,
group,
followersCount,
} = account;
let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
if (accountInstance) {
@ -61,6 +69,17 @@ function AccountBlock({
const verifiedField = fields?.find((f) => !!f.verifiedAt && !!f.value);
const excludedRelationship = {};
for (const r in relationship) {
if (!excludeRelationshipAttrs.includes(r)) {
excludedRelationship[r] = relationship[r];
}
}
const hasRelationship =
excludedRelationship.following ||
excludedRelationship.followedBy ||
excludedRelationship.requested;
return (
<a
class="account-block"
@ -97,9 +116,8 @@ function AccountBlock({
) : (
<b>{username}</b>
)}
<br />
</>
)}
)}{' '}
<span class="account-block-acct">
@{acct1}
<wbr />
@ -124,28 +142,44 @@ function AccountBlock({
)}
{showStats && (
<div class="account-block-stats">
<div
class="short-desc"
dangerouslySetInnerHTML={{
__html: enhanceContent(note, { emojis }),
}}
/>
{bot && (
<>
<span class="tag">
<span class="tag collapsed">
<Icon icon="bot" /> Automated
</span>
</>
)}
{!!group && (
<>
<span class="tag">
<span class="tag collapsed">
<Icon icon="group" /> Group
</span>
</>
)}
{hasRelationship && (
<div key={relationship.id} class="shazam-container-horizontal">
<div class="shazam-container-inner">
{excludedRelationship.following &&
excludedRelationship.followedBy ? (
<span class="tag minimal">Mutual</span>
) : excludedRelationship.requested ? (
<span class="tag minimal">Requested</span>
) : excludedRelationship.following ? (
<span class="tag minimal">Following</span>
) : excludedRelationship.followedBy ? (
<span class="tag minimal">Follows you</span>
) : null}
</div>
</div>
)}
{!!followersCount && (
<span class="ib">
{shortenNumber(followersCount)}{' '}
{followersCount === 1 ? 'follower' : 'followers'}
</span>
)}
{!!verifiedField && (
<span class="verified-field ib">
<span class="verified-field">
<Icon icon="check-circle" size="s" />{' '}
<span
dangerouslySetInnerHTML={{

View file

@ -177,6 +177,7 @@
}
.account-container .account-block .account-block-acct {
display: block;
opacity: 0.7;
}

View file

@ -35,24 +35,24 @@ import Modal from './modal';
import TranslationBlock from './translation-block';
const MUTE_DURATIONS = [
1000 * 60 * 5, // 5 minutes
1000 * 60 * 30, // 30 minutes
1000 * 60 * 60, // 1 hour
1000 * 60 * 60 * 6, // 6 hours
1000 * 60 * 60 * 24, // 1 day
1000 * 60 * 60 * 24 * 3, // 3 days
1000 * 60 * 60 * 24 * 7, // 1 week
60 * 5, // 5 minutes
60 * 30, // 30 minutes
60 * 60, // 1 hour
60 * 60 * 6, // 6 hours
60 * 60 * 24, // 1 day
60 * 60 * 24 * 3, // 3 days
60 * 60 * 24 * 7, // 1 week
0, // forever
];
const MUTE_DURATIONS_LABELS = {
0: 'Forever',
300_000: '5 minutes',
1_800_000: '30 minutes',
3_600_000: '1 hour',
21_600_000: '6 hours',
86_400_000: '1 day',
259_200_000: '3 days',
604_800_000: '1 week',
300: '5 minutes',
1_800: '30 minutes',
3_600: '1 hour',
21_600: '6 hours',
86_400: '1 day',
259_200: '3 days',
604_800: '1 week',
};
const LIMIT = 80;
@ -604,6 +604,10 @@ function AccountInfo({
states.showGenericAccounts = {
heading: 'Followers',
fetchAccounts: fetchFollowers,
instance,
excludeRelationshipAttrs: isSelf
? ['followedBy']
: [],
};
}, 0);
}}
@ -637,6 +641,8 @@ function AccountInfo({
states.showGenericAccounts = {
heading: 'Following',
fetchAccounts: fetchFollowing,
instance,
excludeRelationshipAttrs: isSelf ? ['following'] : [],
};
}, 0);
}}

View file

@ -9,6 +9,7 @@ import List from '../pages/list';
import Mentions from '../pages/mentions';
import Notifications from '../pages/notifications';
import Public from '../pages/public';
import Search from '../pages/search';
import Trending from '../pages/trending';
import states from '../utils/states';
import useTitle from '../utils/useTitle';
@ -33,8 +34,11 @@ function Columns() {
hashtag: Hashtag,
mentions: Mentions,
trending: Trending,
search: Search,
}[type];
if (!Component) return null;
// Don't show Search column with no query, for now
if (type === 'search' && !params.query) return null;
return (
<Component key={type + JSON.stringify(params)} {...params} columnMode />
);

View file

@ -28,6 +28,7 @@ import {
getCurrentInstanceConfiguration,
} from '../utils/store-utils';
import supports from '../utils/supports';
import useCloseWatcher from '../utils/useCloseWatcher';
import useInterval from '../utils/useInterval';
import visibilityIconsMap from '../utils/visibility-icons-map';
@ -108,7 +109,7 @@ function countableText(inputText) {
// https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69
const USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i;
const MENTION_RE = new RegExp(
`(^|[^=\\/\\w.])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`,
`(^|[^=\\/\\w])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`,
'uig',
);
@ -416,6 +417,7 @@ function Compose({
};
useEffect(updateCharCount, []);
const supportsCloseWatcher = window.CloseWatcher;
const escDownRef = useRef(false);
useHotkeys(
'esc',
@ -424,6 +426,7 @@ function Compose({
// This won't be true if this event is already handled and not propagated 🤞
},
{
enabled: !supportsCloseWatcher,
enableOnFormTags: true,
},
);
@ -436,6 +439,7 @@ function Compose({
escDownRef.current = false;
},
{
enabled: !supportsCloseWatcher,
enableOnFormTags: true,
// Use keyup because Esc keydown will close the confirm dialog on Safari
keyup: true,
@ -448,6 +452,11 @@ function Compose({
},
},
);
useCloseWatcher(() => {
if (!standalone && confirmClose()) {
onClose();
}
}, [standalone, confirmClose, onClose]);
const prevBackgroundDraft = useRef({});
const draftKey = () => {

View file

@ -1,5 +1,6 @@
#generic-accounts-container {
.accounts-list {
--list-gap: 16px;
list-style: none;
margin: 0;
padding: 8px 0;
@ -7,29 +8,46 @@
flex-wrap: wrap;
flex-direction: row;
column-gap: 1.5em;
row-gap: 16px;
row-gap: var(--list-gap);
li {
display: flex;
flex-grow: 1;
flex-basis: 16em;
align-items: center;
/* align-items: center; */
margin: 0;
padding: 0;
gap: 8px;
position: relative;
&:before {
content: '';
display: block;
border-top: var(--hairline-width) solid var(--divider-color);
position: absolute;
bottom: calc(-1 * var(--list-gap) / 2);
left: 40px;
right: 0;
}
&:has(.reactions-block):before {
/* avatar + reactions + gap */
left: calc(40px + 16px + 8px);
}
}
.account-block-acct {
font-size: 80%;
font-size: 0.9em;
color: var(--text-insignificant-color);
display: block;
/* display: block; */
}
}
.reactions-block {
display: flex;
flex-direction: column;
align-self: center;
/* align-self: center; */
.favourite-icon {
color: var(--favourite-color);
@ -38,5 +56,21 @@
.reblog-icon {
color: var(--reblog-color);
}
> .icon:only-child {
margin-top: 8px; /* half of icon dimension */
}
}
.account-relationships {
flex-grow: 1;
.tag {
animation: appear 0.3s ease-out;
}
}
.account-block {
align-items: flex-start;
}
}

View file

@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api';
import { fetchRelationships } from '../utils/relationships';
import states from '../utils/states';
import useLocationChange from '../utils/useLocationChange';
@ -11,8 +13,15 @@ import AccountBlock from './account-block';
import Icon from './icon';
import Loader from './loader';
export default function GenericAccounts({ onClose = () => {} }) {
export default function GenericAccounts({
instance,
excludeRelationshipAttrs = [],
onClose = () => {},
}) {
const { masto, instance: currentInstance } = api();
const isCurrentInstance = instance ? instance === currentInstance : true;
const snapStates = useSnapshot(states);
``;
const [uiState, setUIState] = useState('default');
const [accounts, setAccounts] = useState([]);
const [showMore, setShowMore] = useState(false);
@ -31,6 +40,20 @@ export default function GenericAccounts({ onClose = () => {} }) {
showReactions,
} = snapStates.showGenericAccounts;
const [relationshipsMap, setRelationshipsMap] = useState({});
const loadRelationships = async (accounts) => {
if (!accounts?.length) return;
if (!isCurrentInstance) return;
const relationships = await fetchRelationships(accounts, relationshipsMap);
if (relationships) {
setRelationshipsMap({
...relationshipsMap,
...relationships,
});
}
};
const loadAccounts = (firstLoad) => {
if (!fetchAccounts) return;
if (firstLoad) setAccounts([]);
@ -40,11 +63,41 @@ export default function GenericAccounts({ onClose = () => {} }) {
const { done, value } = await fetchAccounts(firstLoad);
if (Array.isArray(value)) {
if (firstLoad) {
setAccounts(value);
const accounts = [];
for (let i = 0; i < value.length; i++) {
const account = value[i];
const theAccount = accounts.find(
(a, j) => a.id === account.id && i !== j,
);
if (!theAccount) {
accounts.push({
_types: [],
...account,
});
} else {
theAccount._types.push(...account._types);
}
}
setAccounts(accounts);
} else {
setAccounts((prev) => [...prev, ...value]);
// setAccounts((prev) => [...prev, ...value]);
// Merge accounts by id and _types
setAccounts((prev) => {
const newAccounts = prev;
for (const account of value) {
const theAccount = newAccounts.find((a) => a.id === account.id);
if (!theAccount) {
newAccounts.push(account);
} else {
theAccount._types.push(...account._types);
}
}
return newAccounts;
});
}
setShowMore(!done);
loadRelationships(value);
} else {
setShowMore(false);
}
@ -60,6 +113,7 @@ export default function GenericAccounts({ onClose = () => {} }) {
useEffect(() => {
if (staticAccounts?.length > 0) {
setAccounts(staticAccounts);
loadRelationships(staticAccounts);
} else {
loadAccounts(true);
firstLoad.current = false;
@ -87,26 +141,37 @@ export default function GenericAccounts({ onClose = () => {} }) {
{accounts.length > 0 ? (
<>
<ul class="accounts-list">
{accounts.map((account) => (
<li key={account.id + (account._types || '')}>
{showReactions && account._types?.length > 0 && (
<div class="reactions-block">
{account._types.map((type) => (
<Icon
icon={
{
reblog: 'rocket',
favourite: 'heart',
}[type]
}
class={`${type}-icon`}
/>
))}
{accounts.map((account) => {
const relationship = relationshipsMap[account.id];
const key = `${account.id}-${account._types?.length || ''}`;
return (
<li key={key}>
{showReactions && account._types?.length > 0 && (
<div class="reactions-block">
{account._types.map((type) => (
<Icon
icon={
{
reblog: 'rocket',
favourite: 'heart',
}[type]
}
class={`${type}-icon`}
/>
))}
</div>
)}
<div class="account-relationships">
<AccountBlock
account={account}
showStats
relationship={relationship}
excludeRelationshipAttrs={excludeRelationshipAttrs}
/>
</div>
)}
<AccountBlock account={account} />
</li>
))}
</li>
);
})}
</ul>
{uiState === 'default' ? (
showMore ? (

View file

@ -104,6 +104,7 @@ export const ICONS = {
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
month: () => import('@iconify-icons/mingcute/calendar-month-line'),
media: () => import('@iconify-icons/mingcute/photo-album-line'),
speak: () => import('@iconify-icons/mingcute/radar-line'),
};
function Icon({

View file

@ -30,24 +30,10 @@ export default memo(function KeyboardShortcutsHelp() {
},
);
const escRef = useHotkeys('esc', onClose, []);
return (
!!snapStates.showKeyboardShortcutsHelp && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
id="keyboard-shortcuts-help-container"
class="sheet"
tabindex="-1"
ref={escRef}
>
<Modal class="light" onClose={onClose}>
<div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
@ -94,7 +80,13 @@ export default memo(function KeyboardShortcutsHelp() {
),
},
{
action: 'Toggle expanded/collapsed thread',
action: (
<>
Expand content warning or
<br />
toggle expanded/collapsed thread
</>
),
keys: <kbd>x</kbd>,
},
{

View file

@ -4,6 +4,7 @@ import { useSnapshot } from 'valtio';
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeMatch from '../utils/locale-match';
import { speak, supportsTTS } from '../utils/speech';
import states from '../utils/states';
import Icon from './icon';
@ -51,6 +52,16 @@ export default function MediaAltModal({ alt, lang, onClose }) {
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
{supportsTTS && (
<MenuItem
onClick={() => {
speak(alt, lang);
}}
>
<Icon icon="speak" />
<span>Speak</span>
</MenuItem>
)}
</Menu2>
</div>
</header>

View file

@ -4,6 +4,8 @@ import { createPortal } from 'preact/compat';
import { useEffect, useRef } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook';
import useCloseWatcher from '../utils/useCloseWatcher';
const $modalContainer = document.getElementById('modal-container');
function Modal({ children, onClose, onClick, class: className }) {
@ -20,6 +22,7 @@ function Modal({ children, onClose, onClick, class: className }) {
return () => clearTimeout(timer);
}, []);
const supportsCloseWatcher = window.CloseWatcher;
const escRef = useHotkeys(
'esc',
() => {
@ -28,7 +31,7 @@ function Modal({ children, onClose, onClick, class: className }) {
}, 0);
},
{
enabled: !!onClose,
enabled: !supportsCloseWatcher && !!onClose,
// Using keyup and setTimeout above
// This will run "later" to prevent clash with esc handlers from other components
keydown: false,
@ -36,6 +39,7 @@ function Modal({ children, onClose, onClick, class: className }) {
},
[onClose],
);
useCloseWatcher(onClose, [onClose]);
const Modal = (
<div

View file

@ -176,6 +176,10 @@ export default function Modals() {
}}
>
<GenericAccounts
instance={snapStates.showGenericAccounts.instance}
excludeRelationshipAttrs={
snapStates.showGenericAccounts.excludeRelationshipAttrs
}
onClose={() => (states.showGenericAccounts = false)}
/>
</Modal>

View file

@ -7,6 +7,10 @@ import states from '../utils/states';
import Avatar from './avatar';
import EmojiText from './emoji-text';
const nameCollator = new Intl.Collator('en', {
sensitivity: 'base',
});
function NameText({
account,
instance,
@ -36,9 +40,7 @@ function NameText({
(trimmedUsername === trimmedDisplayName ||
trimmedUsername === shortenedDisplayName ||
trimmedUsername === shortenedAlphaNumericDisplayName ||
trimmedUsername.localeCompare?.(shortenedDisplayName, 'en', {
sensitivity: 'base',
}) === 0)
nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0)
) {
username = null;
}

View file

@ -233,6 +233,7 @@ function NavMenu(props) {
id: 'mute',
heading: 'Muted users',
fetchAccounts: fetchMutes,
excludeRelationshipAttrs: ['muting'],
};
}}
>
@ -244,6 +245,7 @@ function NavMenu(props) {
id: 'block',
heading: 'Blocked users',
fetchAccounts: fetchBlocks,
excludeRelationshipAttrs: ['blocking'],
};
}}
>

View file

@ -67,7 +67,12 @@ const contentText = {
const AVATARS_LIMIT = 50;
function Notification({ notification, instance, isStatic }) {
function Notification({
notification,
instance,
isStatic,
disableContextMenu,
}) {
const { id, status, account, report, _accounts, _statuses } = notification;
let { type } = notification;
@ -153,6 +158,7 @@ function Notification({ notification, instance, isStatic }) {
heading: genericAccountsHeading,
accounts: _accounts,
showReactions: type === 'favourite+reblog',
excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [],
};
};
@ -300,20 +306,24 @@ function Notification({ notification, instance, isStatic }) {
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`
}
onContextMenu={(e) => {
const post = e.target.querySelector('.status');
if (post) {
// Fire a custom event to open the context menu
if (e.metaKey) return;
e.preventDefault();
post.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: e.clientX,
clientY: e.clientY,
}),
);
}
}}
onContextMenu={
!disableContextMenu
? (e) => {
const post = e.target.querySelector('.status');
if (post) {
// Fire a custom event to open the context menu
if (e.metaKey) return;
e.preventDefault();
post.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: e.clientX,
clientY: e.clientY,
}),
);
}
}
: undefined
}
>
{isStatic ? (
<Status status={actualStatus} size="s" />

View file

@ -123,6 +123,11 @@
min-width: 0;
max-width: 320px;
}
#shortcut-settings-form .form-note {
display: flex;
gap: 6px;
align-items: center;
}
#shortcut-settings-form form footer {
display: flex;
gap: 16px;

View file

@ -13,6 +13,7 @@ import multiColumnUrl from '../assets/multi-column.svg';
import tabMenuBarUrl from '../assets/tab-menu-bar.svg';
import { api } from '../utils/api';
import { fetchFollowedTags } from '../utils/followed-tags';
import pmem from '../utils/pmem';
import showToast from '../utils/show-toast';
import states from '../utils/states';
@ -31,12 +32,12 @@ const TYPES = [
'list',
'public',
'trending',
// NOTE: Hide for now
// 'search', // Search on Mastodon ain't great
// 'account-statuses', // Need @acct search first
'search',
'hashtag',
'bookmarks',
'favourites',
// NOTE: Hide for now
// 'account-statuses', // Need @acct search first
];
const TYPE_TEXT = {
following: 'Home / Following',
@ -86,6 +87,8 @@ const TYPE_PARAMS = {
text: 'Search term',
name: 'query',
type: 'text',
placeholder: 'Optional, unless for multi-column mode',
notRequired: true,
},
],
'account-statuses': [
@ -167,9 +170,11 @@ export const SHORTCUTS_META = {
},
search: {
id: 'search',
title: ({ query }) => query,
path: ({ query }) => `/search?q=${query}`,
title: ({ query }) => (query ? `"${query}"` : 'Search'),
path: ({ query }) =>
query ? `/search?q=${query}&type=statuses` : '/search',
icon: 'search',
excludeViewMode: ({ query }) => (!query ? ['multi-column'] : []),
},
'account-statuses': {
id: 'account-statuses',
@ -278,7 +283,8 @@ function ShortcutsSettings({ onClose }) {
const key = Object.values(shortcut).join('-');
const { type } = shortcut;
if (!SHORTCUTS_META[type]) return null;
let { icon, title, subtitle } = SHORTCUTS_META[type];
let { icon, title, subtitle, excludeViewMode } =
SHORTCUTS_META[type];
if (typeof title === 'function') {
title = title(shortcut, i);
}
@ -288,6 +294,12 @@ function ShortcutsSettings({ onClose }) {
if (typeof icon === 'function') {
icon = icon(shortcut, i);
}
if (typeof excludeViewMode === 'function') {
excludeViewMode = excludeViewMode(shortcut, i);
}
const excludedViewMode = excludeViewMode?.includes(
snapStates.settings.shortcutsViewMode,
);
return (
<li key={key}>
<Icon icon={icon} />
@ -299,6 +311,11 @@ function ShortcutsSettings({ onClose }) {
<small class="ib insignificant">{subtitle}</small>
</>
)}
{excludedViewMode && (
<span class="tag">
Not available in current view mode
</span>
)}
</span>
<span class="shortcut-actions">
<button
@ -467,6 +484,11 @@ const fetchLists = pmem(
},
);
const FORM_NOTES = {
search: `For multi-column mode, search term is required, else the column will not be shown.`,
hashtag: 'Multiple hashtags are supported. Space-separated.',
};
function ShortcutForm({
onSubmit,
disabled,
@ -500,13 +522,7 @@ function ShortcutForm({
(async () => {
if (currentType !== 'hashtag') return;
try {
const iterator = masto.v1.followedTags.list();
const tags = [];
do {
const { value, done } = await iterator.next();
if (done || value?.length === 0) break;
tags.push(...value);
} while (true);
const tags = await fetchFollowedTags();
setFollowedHashtags(tags);
} catch (e) {
console.error(e);
@ -620,6 +636,7 @@ function ShortcutForm({
<span>{text}</span>{' '}
<input
type={type}
switch={type === 'checkbox' || undefined}
name={name}
placeholder={placeholder}
required={type === 'text' && !notRequired}
@ -647,6 +664,12 @@ function ShortcutForm({
);
},
)}
{!!FORM_NOTES[currentType] && (
<p class="form-note insignificant">
<Icon icon="info" />
{FORM_NOTES[currentType]}
</p>
)}
<footer>
<button
type="submit"

View file

@ -14,6 +14,13 @@
transparent min(160px, 50%)
);
}
.status-followed-tags {
background: linear-gradient(
160deg,
var(--hashtag-faded-color),
transparent min(160px, 50%)
);
}
.status-reply-to {
background: linear-gradient(
160deg,
@ -21,7 +28,7 @@
transparent min(160px, 50%)
);
}
:is(.status-reblog, .status-group) .status-reply-to {
:is(.status-reblog, .status-group, .status-followed-tags) .status-reply-to {
background: linear-gradient(
-20deg,
var(--reply-to-faded-color),
@ -63,6 +70,49 @@
margin-right: 4px;
vertical-align: text-bottom;
}
.status-followed-tags {
.status-pre-meta {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
.icon {
color: var(--hashtag-color);
margin-right: 4px;
vertical-align: text-bottom;
}
a {
color: var(--hashtag-text-color);
font-weight: bold;
font-size: 12px;
text-decoration-color: var(--hashtag-faded-color);
text-underline-offset: 2px;
text-decoration-thickness: 2px;
display: inline-block;
padding: 2px;
vertical-align: top;
text-transform: uppercase;
text-shadow: 0 1px var(--bg-color);
&:hover {
color: var(--text-color);
text-decoration-color: var(--hashtag-color);
}
}
}
.status-followed-tag-item {
color: var(--hashtag-text-color);
padding: 2px;
font-weight: bold;
font-size: 12px;
text-transform: uppercase;
margin-inline-end: 0.5em;
}
}
/* STATUS */
@ -544,7 +594,7 @@
.timeline-deck .status .content {
max-height: 50vh;
max-height: 50dvh;
overflow: hidden;
overflow: clip;
position: relative;
}
.timeline-deck

View file

@ -63,6 +63,7 @@ import Media from './media';
import { isMediaCaptionLong } from './media';
import MenuLink from './menu-link';
import RelativeTime from './relative-time';
import { speak, supportsTTS } from '../utils/speech';
import TranslationBlock from './translation-block';
const SHOW_COMMENT_COUNT_LIMIT = 280;
@ -88,15 +89,38 @@ const isIOS =
window.ontouchstart !== undefined &&
/iPad|iPhone|iPod/.test(navigator.userAgent);
const REACTIONS_LIMIT = 80;
function getPollText(poll) {
if (!poll?.options?.length) return '';
return `📊:\n${poll.options
.map(
(option) =>
`- ${option.title}${
option.votesCount >= 0 ? ` (${option.votesCount})` : ''
}`,
)
.join('\n')}`;
}
function getPostText(status) {
const { spoilerText, content, poll } = status;
return (
(spoilerText ? `${spoilerText}\n\n` : '') +
getHTMLText(content) +
getPollText(poll)
);
}
function Status({
statusID,
status,
instance: propInstance,
withinContext,
size = 'm',
skeleton,
readOnly,
contentTextWeight,
readOnly,
enableCommentHint,
withinContext,
skeleton,
enableTranslate,
forceTranslate: _forceTranslate,
previewMode,
@ -104,7 +128,7 @@ function Status({
onMediaClick,
quoted,
onStatusLinkClick = () => {},
enableCommentHint,
showFollowedTags,
}) {
if (skeleton) {
return (
@ -174,6 +198,7 @@ function Status({
uri,
url,
emojis,
tags,
// Non-API props
_deleted,
_pinned,
@ -214,6 +239,7 @@ function Status({
containerProps={{
onMouseEnter: debugHover,
}}
showFollowedTags
/>
);
}
@ -302,6 +328,39 @@ function Status({
);
}
// Check followedTags
if (showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length) {
return (
<div
data-state-post-id={sKey}
class="status-followed-tags"
onMouseEnter={debugHover}
>
<div class="status-pre-meta">
<Icon icon="hashtag" size="l" />{' '}
{snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
<Link
key={tag}
to={instance ? `/${instance}/t/${tag}` : `/t/${tag}`}
class="status-followed-tag-item"
>
{tag}
</Link>
))}
</div>
<Status
status={statusID ? null : status}
statusID={statusID ? status.id : null}
instance={instance}
size={size}
contentTextWeight={contentTextWeight}
readOnly={readOnly}
enableCommentHint
/>
</div>
);
}
const isSizeLarge = size === 'l';
const [forceTranslate, setForceTranslate] = useState(_forceTranslate);
@ -344,7 +403,6 @@ function Status({
]);
const [showEdited, setShowEdited] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const spoilerContentRef = useTruncated();
const contentRef = useTruncated();
@ -524,6 +582,55 @@ function Status({
(l) => language === l || localeMatch([language], [l]),
);
const reblogIterator = useRef();
const favouriteIterator = useRef();
async function fetchBoostedLikedByAccounts(firstLoad) {
if (firstLoad) {
reblogIterator.current = masto.v1.statuses
.$select(statusID)
.rebloggedBy.list({
limit: REACTIONS_LIMIT,
});
favouriteIterator.current = masto.v1.statuses
.$select(statusID)
.favouritedBy.list({
limit: REACTIONS_LIMIT,
});
}
const [{ value: reblogResults }, { value: favouriteResults }] =
await Promise.allSettled([
reblogIterator.current.next(),
favouriteIterator.current.next(),
]);
if (reblogResults.value?.length || favouriteResults.value?.length) {
const accounts = [];
if (reblogResults.value?.length) {
accounts.push(
...reblogResults.value.map((a) => {
a._types = ['reblog'];
return a;
}),
);
}
if (favouriteResults.value?.length) {
accounts.push(
...favouriteResults.value.map((a) => {
a._types = ['favourite'];
return a;
}),
);
}
return {
value: accounts,
done: reblogResults.done && favouriteResults.done,
};
}
return {
value: [],
done: true,
};
}
const menuInstanceRef = useRef();
const StatusMenuItems = (
<>
@ -584,7 +691,16 @@ function Status({
)}
{(!isSizeLarge || !!editedAt) && <MenuDivider />}
{isSizeLarge && (
<MenuItem onClick={() => setShowReactions(true)}>
<MenuItem
onClick={() => {
states.showGenericAccounts = {
heading: 'Boosted/Liked by…',
fetchAccounts: fetchBoostedLikedByAccounts,
instance,
showReactions: true,
};
}}
>
<Icon icon="react" />
<span>
Boosted/Liked by<span class="more-insignificant"></span>
@ -687,23 +803,53 @@ function Status({
</>
)}
{enableTranslate ? (
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuItem>
) : (
(!language || differentLanguage) && (
<MenuLink
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
<div class={supportsTTS ? 'menu-horizontal' : ''}>
<MenuItem
disabled={forceTranslate}
onClick={() => {
setForceTranslate(true);
}}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuLink>
</MenuItem>
{supportsTTS && (
<MenuItem
onClick={() => {
const postText = getPostText(status);
if (postText) {
speak(postText, language);
}
}}
>
<Icon icon="speak" />
<span>Speak</span>
</MenuItem>
)}
</div>
) : (
(!language || differentLanguage) && (
<div class={supportsTTS ? 'menu-horizontal' : ''}>
<MenuLink
to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`}
>
<Icon icon="translate" />
<span>Translate</span>
</MenuLink>
{supportsTTS && (
<MenuItem
onClick={() => {
const postText = getPostText(status);
if (postText) {
speak(postText, language);
}
}}
>
<Icon icon="speak" />
<span>Speak</span>
</MenuItem>
)}
</div>
)
)}
{((!isSizeLarge && sameInstance) || enableTranslate) && <MenuDivider />}
@ -926,6 +1072,20 @@ function Status({
enabled: hotkeysEnabled && canBoost,
},
);
const xRef = useHotkeys('x', (e) => {
const activeStatus = document.activeElement.closest(
'.status-link, .status-focus',
);
if (activeStatus) {
const spoilerButton = activeStatus.querySelector(
'button.spoiler:not(.spoiling)',
);
if (spoilerButton) {
e.stopPropagation();
spoilerButton.click();
}
}
});
const displayedMediaAttachments = mediaAttachments.slice(
0,
@ -1074,6 +1234,7 @@ function Status({
fRef.current = nodeRef;
dRef.current = nodeRef;
bRef.current = nodeRef;
xRef.current = nodeRef;
}}
tabindex="-1"
class={`status ${
@ -1468,22 +1629,7 @@ function Status({
forceTranslate={forceTranslate || inlineTranslate}
mini={!isSizeLarge && !withinContext}
sourceLanguage={language}
text={
(spoilerText ? `${spoilerText}\n\n` : '') +
getHTMLText(content) +
(poll?.options?.length
? `\n\nPoll:\n${poll.options
.map(
(option) =>
`- ${option.title}${
option.votesCount >= 0
? ` (${option.votesCount})`
: ''
}`,
)
.join('\n')}`
: '')
}
text={getPostText(status)}
/>
)}
{!spoilerText && sensitive && !!mediaAttachments.length && (
@ -1542,15 +1688,19 @@ function Status({
</MultipleMediaFigure>
)}
{!!card &&
card?.url !== status.url &&
card?.url !== status.uri &&
/^https/i.test(card?.url) &&
!sensitive &&
!spoilerText &&
!poll &&
!mediaAttachments.length &&
!snapStates.statusQuotes[sKey] && (
<Card card={card} instance={currentInstance} />
<Card
card={card}
selfReferential={
card?.url === status.url || card?.url === status.uri
}
instance={currentInstance}
/>
)}
</div>
{!isSizeLarge && showCommentCount && (
@ -1573,6 +1723,7 @@ function Status({
<time
class="created"
datetime={createdAtDate.toISOString()}
title={createdAtDate.toLocaleString()}
>
{createdDateText}
</time>
@ -1722,22 +1873,6 @@ function Status({
/>
</Modal>
)}
{showReactions && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowReactions(false);
}
}}
>
<ReactionsModal
statusID={id}
instance={instance}
onClose={() => setShowReactions(false)}
/>
</Modal>
)}
</article>
);
}
@ -1755,7 +1890,7 @@ function MultipleMediaFigure(props) {
);
}
function Card({ card, instance }) {
function Card({ card, selfReferential, instance }) {
const snapStates = useSnapshot(states);
const {
blurhash,
@ -1791,7 +1926,7 @@ function Card({ card, instance }) {
const [cardStatusURL, setCardStatusURL] = useState(null);
// const [cardStatusID, setCardStatusID] = useState(null);
useEffect(() => {
if (hasText && image && isMastodonLinkMaybe(url)) {
if (hasText && image && !selfReferential && isMastodonLinkMaybe(url)) {
unfurlMastodonLink(instance, url).then((result) => {
if (!result) return;
const { id, url } = result;
@ -1806,7 +1941,7 @@ function Card({ card, instance }) {
// })();
});
}
}, [hasText, image]);
}, [hasText, image, selfReferential]);
// if (cardStatusID) {
// return (
@ -2009,160 +2144,6 @@ function EditedAtModal({
);
}
const REACTIONS_LIMIT = 80;
function ReactionsModal({ statusID, instance, onClose }) {
const { masto } = api({ instance });
const [uiState, setUIState] = useState('default');
const [accounts, setAccounts] = useState([]);
const [showMore, setShowMore] = useState(false);
const reblogIterator = useRef();
const favouriteIterator = useRef();
async function fetchAccounts(firstLoad) {
setShowMore(false);
setUIState('loading');
(async () => {
try {
if (firstLoad) {
reblogIterator.current = masto.v1.statuses
.$select(statusID)
.rebloggedBy.list({
limit: REACTIONS_LIMIT,
});
favouriteIterator.current = masto.v1.statuses
.$select(statusID)
.favouritedBy.list({
limit: REACTIONS_LIMIT,
});
}
const [{ value: reblogResults }, { value: favouriteResults }] =
await Promise.allSettled([
reblogIterator.current.next(),
favouriteIterator.current.next(),
]);
if (reblogResults.value?.length || favouriteResults.value?.length) {
if (reblogResults.value?.length) {
for (const account of reblogResults.value) {
const theAccount = accounts.find((a) => a.id === account.id);
if (!theAccount) {
accounts.push({
...account,
_types: ['reblog'],
});
} else {
theAccount._types.push('reblog');
}
}
}
if (favouriteResults.value?.length) {
for (const account of favouriteResults.value) {
const theAccount = accounts.find((a) => a.id === account.id);
if (!theAccount) {
accounts.push({
...account,
_types: ['favourite'],
});
} else {
theAccount._types.push('favourite');
}
}
}
setAccounts(accounts);
setShowMore(!reblogResults.done || !favouriteResults.done);
} else {
setShowMore(false);
}
setUIState('default');
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}
useEffect(() => {
fetchAccounts(true);
}, []);
return (
<div id="reactions-container" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header>
<h2>Boosted/Liked by</h2>
</header>
<main>
{accounts.length > 0 ? (
<>
<ul class="reactions-list">
{accounts.map((account) => {
const { _types } = account;
return (
<li key={account.id + _types}>
<div class="reactions-block">
{_types.map((type) => (
<Icon
icon={
{
reblog: 'rocket',
favourite: 'heart',
}[type]
}
class={`${type}-icon`}
/>
))}
</div>
<AccountBlock account={account} instance={instance} />
</li>
);
})}
</ul>
{uiState === 'default' ? (
showMore ? (
<InView
onChange={(inView) => {
if (inView) {
fetchAccounts();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => fetchAccounts()}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
)
) : (
uiState === 'loading' && (
<p class="ui-state">
<Loader abrupt />
</p>
)
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load accounts</p>
) : (
<p class="ui-state insignificant">No one yet.</p>
)}
</main>
</div>
);
}
function StatusButton({
checked,
count,
@ -2372,7 +2353,14 @@ function nicePostURL(url) {
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
function FilteredStatus({
status,
filterInfo,
instance,
containerProps = {},
showFollowedTags,
}) {
const snapStates = useSnapshot(states);
const {
id: statusID,
account: { avatar, avatarStatic, bot, group },
@ -2399,7 +2387,8 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
);
const statusPeekRef = useTruncated();
const sKey =
const sKey = statusKey(status.id, instance);
const ssKey =
statusKey(status.id, instance) +
' ' +
(statusKey(reblog?.id, instance) || '');
@ -2408,10 +2397,20 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
const url = instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`;
const isFollowedTags =
showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length;
return (
<div
class={isReblog ? (group ? 'status-group' : 'status-reblog') : ''}
class={
isReblog
? group
? 'status-group'
: 'status-reblog'
: isFollowedTags
? 'status-followed-tags'
: ''
}
{...containerProps}
title={statusPeekText}
onContextMenu={(e) => {
@ -2420,7 +2419,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
}}
{...bindLongPressPeek()}
>
<article data-state-post-id={sKey} class="status filtered" tabindex="-1">
<article data-state-post-id={ssKey} class="status filtered" tabindex="-1">
<b
class="status-filtered-badge clickable badge-meta"
title={filterTitleStr}
@ -2443,6 +2442,14 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
/>{' '}
{isReblog ? (
'boosted'
) : isFollowedTags ? (
<span>
{snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
<span key={tag} class="status-followed-tag-item">
#{tag}
</span>
))}
</span>
) : (
<RelativeTime datetime={createdAtDate} format="micro" />
)}

View file

@ -5,7 +5,7 @@ import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio';
import FilterContext from '../utils/filter-context';
import { isFiltered } from '../utils/filters';
import { filteredItems, isFiltered } from '../utils/filters';
import states, { statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek';
import { groupBoosts, groupContext } from '../utils/timeline-utils';
@ -44,6 +44,7 @@ function Timeline({
refresh,
view,
filterContext,
showFollowedTags,
}) {
const snapStates = useSnapshot(states);
const [items, setItems] = useState([]);
@ -391,6 +392,7 @@ function Timeline({
filterContext={filterContext}
key={status.id + status?._pinned + view}
view={view}
showFollowedTags={showFollowedTags}
/>
))}
{showMore &&
@ -478,6 +480,7 @@ function TimelineItem({
// allowFilters,
filterContext,
view,
showFollowedTags,
}) {
const { id: statusID, reblog, items, type, _pinned } = status;
if (_pinned) useItemID = false;
@ -493,9 +496,10 @@ function TimelineItem({
}
const isCarousel = type === 'boosts' || type === 'pinned';
if (items) {
const fItems = filteredItems(items, filterContext);
if (isCarousel) {
// Here, we don't hide filtered posts, but we sort them last
items.sort((a, b) => {
fItems.sort((a, b) => {
// if (a._filtered && !b._filtered) {
// return 1;
// }
@ -515,7 +519,7 @@ function TimelineItem({
return (
<li key={`timeline-${statusID}`} class="timeline-item-carousel">
<StatusCarousel title={title} class={`${type}-carousel`}>
{items.map((item) => {
{fItems.map((item) => {
const { id: statusID, reblog, _pinned } = item;
const actualStatusID = reblog?.id || statusID;
const url = instance
@ -552,11 +556,11 @@ function TimelineItem({
</li>
);
}
const manyItems = items.length > 3;
return items.map((item, i) => {
const manyItems = fItems.length > 3;
return fItems.map((item, i) => {
const { id: statusID, _differentAuthor } = item;
const url = instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`;
const isMiddle = i > 0 && i < items.length - 1;
const isMiddle = i > 0 && i < fItems.length - 1;
const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact =
(!_differentAuthor && isSpoiler && i > 0) ||
@ -565,14 +569,15 @@ function TimelineItem({
(type === 'thread' ||
(type === 'conversation' &&
!_differentAuthor &&
!items[i - 1]._differentAuthor &&
!items[i + 1]._differentAuthor)));
const isEnd = i === items.length - 1;
!fItems[i - 1]._differentAuthor &&
!fItems[i + 1]._differentAuthor)));
const isStart = i === 0;
const isEnd = i === fItems.length - 1;
return (
<li
key={`timeline-${statusID}`}
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
i === 0 ? 'start' : isEnd ? 'end' : 'middle'
isStart ? 'start' : isEnd ? 'end' : 'middle'
} ${_differentAuthor ? 'timeline-item-diff-author' : ''}`}
>
<Link class="status-link timeline-item" to={url}>
@ -583,6 +588,7 @@ function TimelineItem({
statusID={statusID}
instance={instance}
enableCommentHint={isEnd}
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
) : (
@ -590,6 +596,7 @@ function TimelineItem({
status={item}
instance={instance}
enableCommentHint={isEnd}
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
)}
@ -631,6 +638,7 @@ function TimelineItem({
statusID={statusID}
instance={instance}
enableCommentHint
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
) : (
@ -638,6 +646,7 @@ function TimelineItem({
status={status}
instance={instance}
enableCommentHint
showFollowedTags={showFollowedTags}
// allowFilters={allowFilters}
/>
)}

View file

@ -54,6 +54,17 @@
--reply-to-text-color: #b36200;
--favourite-color: var(--red-color);
--reply-to-faded-color: #ffa60020;
--hashtag-color: LightSeaGreen;
--hashtag-faded-color: color-mix(
in srgb,
var(--hashtag-color) 15%,
transparent
);
--hashtag-text-color: color-mix(
in lch,
var(--hashtag-color) 40%,
var(--text-color) 60%
);
--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);

View file

@ -5,10 +5,9 @@ import Link from '../components/link';
import Loader from '../components/loader';
import NavMenu from '../components/nav-menu';
import { api } from '../utils/api';
import { fetchFollowedTags } from '../utils/followed-tags';
import useTitle from '../utils/useTitle';
const LIMIT = 200;
function FollowedHashtags() {
const { masto, instance } = api();
useTitle(`Followed Hashtags`, `/ft`);
@ -19,17 +18,7 @@ function FollowedHashtags() {
setUIState('loading');
(async () => {
try {
const iterator = masto.v1.followedTags.list({
limit: LIMIT,
});
const tags = [];
do {
const { value, done } = await iterator.next();
if (done || value?.length === 0) break;
tags.push(...value);
} while (true);
tags.sort((a, b) => a.name.localeCompare(b.name));
console.log(tags);
const tags = await fetchFollowedTags();
setFollowedHashtags(tags);
setUIState('default');
} catch (e) {

View file

@ -6,7 +6,11 @@ import { api } from '../utils/api';
import { filteredItems } from '../utils/filters';
import states from '../utils/states';
import { getStatus, saveStatus } from '../utils/states';
import { dedupeBoosts } from '../utils/timeline-utils';
import {
assignFollowedTags,
clearFollowedTagsState,
dedupeBoosts,
} from '../utils/timeline-utils';
import useTitle from '../utils/useTitle';
const LIMIT = 20;
@ -27,7 +31,11 @@ function Following({ title, path, id, ...props }) {
const results = await homeIterator.current.next();
let { value } = results;
if (value?.length) {
let latestItemChanged = false;
if (firstLoad) {
if (value[0].id !== latestItem.current) {
latestItemChanged = true;
}
latestItem.current = value[0].id;
console.log('First load', latestItem.current);
}
@ -37,6 +45,8 @@ function Following({ title, path, id, ...props }) {
saveStatus(item, instance);
});
value = dedupeBoosts(value, instance);
if (firstLoad && latestItemChanged) clearFollowedTagsState();
assignFollowedTags(value, instance);
// ENFORCE sort by datetime (Latest first)
value.sort((a, b) => {
@ -118,6 +128,7 @@ function Following({ title, path, id, ...props }) {
{...props}
// allowFilters
filterContext="home"
showFollowedTags
/>
);
}

View file

@ -179,6 +179,7 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
key={notification.id}
instance={instance}
notification={notification}
disableContextMenu
/>
))}
</>

View file

@ -21,11 +21,10 @@ export default function HttpRoute() {
useLayoutEffect(() => {
setUIState('loading');
(async () => {
const { instance, id } = statusObject;
const { masto } = api({ instance });
// Check if status returns 200
try {
const { instance, id } = statusObject;
const { masto } = api({ instance });
const status = await masto.v1.statuses.$select(id).fetch();
if (status) {
window.location.hash = statusURL + '?view=full';

View file

@ -1,18 +1,47 @@
#search-page .deck > header .header-grid {
grid-template-columns: auto 1fr auto;
}
#search-page header input {
width: 100%;
padding: 8px 16px;
border: 0;
border-radius: 999px;
background-color: var(--bg-faded-color);
border: 2px solid transparent;
#search-page header {
input {
width: 100%;
padding: 8px 16px;
border: 0;
border-radius: 999px;
background-color: var(--bg-faded-color);
border: 2px solid transparent;
&:focus {
outline: 0;
background-color: var(--bg-color);
border-color: var(--link-color);
}
#columns & {
font-weight: bold;
background-color: transparent;
text-align: center;
padding-inline: 8px;
text-overflow: ellipsis;
}
}
}
#search-page header input:focus {
outline: 0;
background-color: var(--bg-color);
border-color: var(--link-color);
#columns #search-page {
.header-grid {
.header-side {
min-width: 40px;
&:last-of-type {
button {
display: block;
&:not(:hover, :focus) {
color: var(--text-insignificant-color);
}
}
}
}
}
}
#search-page ul.accounts-list {
@ -24,8 +53,12 @@
display: flex;
padding: 8px 16px;
gap: 8px;
align-items: center;
/* align-items: center; */
flex-grow: 1;
.account-block {
align-items: flex-start;
}
}
ul.link-list.hashtag-list {

View file

@ -14,22 +14,28 @@ import NavMenu from '../components/nav-menu';
import SearchForm from '../components/search-form';
import Status from '../components/status';
import { api } from '../utils/api';
import { fetchRelationships } from '../utils/relationships';
import shortenNumber from '../utils/shorten-number';
import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
const SHORT_LIMIT = 5;
const LIMIT = 40;
const emptySearchParams = new URLSearchParams();
function Search(props) {
const params = useParams();
function Search({ columnMode, ...props }) {
const params = columnMode ? {} : useParams();
const { masto, instance, authenticated } = api({
instance: params.instance,
});
const [uiState, setUIState] = useState('default');
const [searchParams] = useSearchParams();
const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams();
const searchFormRef = useRef();
const q = props?.query || searchParams.get('q');
const type = props?.type || searchParams.get('type');
const type = columnMode
? 'statuses'
: props?.type || searchParams.get('type');
useTitle(
q
? `Search: ${q}${
@ -72,7 +78,23 @@ function Search(props) {
hashtags: setHashtagResults,
};
const [relationshipsMap, setRelationshipsMap] = useState({});
const loadRelationships = async (accounts) => {
if (!accounts?.length) return;
const relationships = await fetchRelationships(accounts, relationshipsMap);
if (relationships) {
setRelationshipsMap({
...relationshipsMap,
...relationships,
});
}
};
function loadResults(firstLoad) {
if (firstLoad) {
offsetRef.current = 0;
}
if (!firstLoad && !authenticated) {
// Search results pagination is only available to authenticated users
return;
@ -119,6 +141,8 @@ function Search(props) {
offsetRef.current = 0;
setShowMore(false);
}
loadRelationships(results.accounts);
setUIState('default');
} catch (err) {
console.error(err);
@ -127,9 +151,25 @@ function Search(props) {
})();
}
const { reachStart } = useScroll({
scrollableRef,
});
const lastHiddenTime = useRef();
usePageVisibility((visible) => {
if (visible && reachStart) {
const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 3) {
// 3 seconds
loadResults(true);
} else {
lastHiddenTime.current = Date.now();
}
}
});
useEffect(() => {
searchFormRef.current?.setValue?.(q || '');
if (q) {
searchFormRef.current?.setValue?.(q);
loadResults(true);
} else {
searchFormRef.current?.focus?.();
@ -157,11 +197,22 @@ function Search(props) {
<NavMenu />
</div>
<SearchForm ref={searchFormRef} />
<div class="header-side">&nbsp;</div>
<div class="header-side">
<button
type="button"
class="plain"
onClick={() => {
loadResults(true);
}}
disabled={uiState === 'loading'}
>
<Icon icon="search" size="l" />
</button>
</div>
</div>
</header>
<main>
{!!q && (
{!!q && !columnMode && (
<div
ref={filterBarParent}
class={`filter-bar ${uiState === 'loading' ? 'loading' : ''}`}
@ -216,6 +267,7 @@ function Search(props) {
account={account}
instance={instance}
showStats
relationship={relationshipsMap[account.id]}
/>
</li>
))}

View file

@ -0,0 +1,62 @@
import { api } from '../utils/api';
import store from '../utils/store';
const LIMIT = 200;
const MAX_FETCH = 10;
export async function fetchFollowedTags() {
const { masto } = api();
const iterator = masto.v1.followedTags.list({
limit: LIMIT,
});
const tags = [];
let fetchCount = 0;
do {
const { value, done } = await iterator.next();
if (done || value?.length === 0) break;
tags.push(...value);
fetchCount++;
} while (fetchCount < MAX_FETCH);
tags.sort((a, b) => a.name.localeCompare(b.name));
console.log(tags);
if (tags.length) {
setTimeout(() => {
// Save to local storage, with saved timestamp
store.account.set('followedTags', {
tags,
updatedAt: Date.now(),
});
}, 1);
}
return tags;
}
const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day
export async function getFollowedTags() {
try {
const { tags, updatedAt } = store.account.get('followedTags') || {};
if (!tags?.length) return await fetchFollowedTags();
if (Date.now() - updatedAt > MAX_AGE) {
// Stale-while-revalidate
fetchFollowedTags();
return tags;
}
return tags;
} catch (e) {
return [];
}
}
const fauxDiv = document.createElement('div');
export const extractTagsFromStatus = (content) => {
if (!content) return [];
if (content.indexOf('#') === -1) return [];
fauxDiv.innerHTML = content;
const hashtagLinks = fauxDiv.querySelectorAll('a.hashtag');
if (!hashtagLinks.length) return [];
return Array.from(hashtagLinks).map((a) =>
a.innerText.trim().replace(/^[^#]*#+/, ''),
);
};

View file

@ -2,5 +2,10 @@ const div = document.createElement('div');
export default function htmlContentLength(html) {
if (!html) return 0;
div.innerHTML = html;
// .invisible spans for links
// e.g. <span class="invisible">https://</span>mastodon.social
div.querySelectorAll('.invisible').forEach((el) => {
el.remove();
});
return div.innerText.length;
}

26
src/utils/ratelimit.js Normal file
View file

@ -0,0 +1,26 @@
// Rate limit repeated function calls and queue them to set interval
export default function rateLimit(fn, interval) {
let queue = [];
let isRunning = false;
function executeNext() {
if (queue.length === 0) {
isRunning = false;
return;
}
const nextFn = queue.shift();
nextFn();
setTimeout(executeNext, interval);
}
return function (...args) {
const callFn = () => fn.apply(this, args);
queue.push(callFn);
if (!isRunning) {
isRunning = true;
setTimeout(executeNext, interval);
}
};
}

View file

@ -0,0 +1,38 @@
import { api } from './api';
import store from './store';
export async function fetchRelationships(accounts, relationshipsMap = {}) {
if (!accounts?.length) return;
const { masto } = api();
const currentAccount = store.session.get('currentAccount');
const uniqueAccountIds = accounts.reduce((acc, a) => {
// 1. Ignore duplicate accounts
// 2. Ignore accounts that are already inside relationshipsMap
// 3. Ignore currently logged in account
if (
!acc.includes(a.id) &&
!relationshipsMap[a.id] &&
a.id !== currentAccount
) {
acc.push(a.id);
}
return acc;
}, []);
if (!uniqueAccountIds.length) return null;
try {
const relationships = await masto.v1.accounts.relationships.fetch({
id: uniqueAccountIds,
});
const newRelationshipsMap = relationships.reduce((acc, r) => {
acc[r.id] = r;
return acc;
}, {});
return newRelationshipsMap;
} catch (e) {
console.error(e);
// It's okay to fail
return null;
}
}

15
src/utils/speech.js Normal file
View file

@ -0,0 +1,15 @@
export const supportsTTS = 'speechSynthesis' in window;
export function speak(text, lang) {
if (!supportsTTS) return;
try {
if (speechSynthesis.speaking) {
speechSynthesis.cancel();
}
const utterance = new SpeechSynthesisUtterance(text);
if (lang) utterance.lang = lang;
speechSynthesis.speak(utterance);
} catch (e) {
alert(e);
}
}

View file

@ -3,6 +3,7 @@ import { subscribeKey } from 'valtio/utils';
import { api } from './api';
import pmem from './pmem';
import rateLimit from './ratelimit';
import store from './store';
const states = proxy({
@ -31,6 +32,7 @@ const states = proxy({
scrollPositions: {},
unfurledLinks: {},
statusQuotes: {},
statusFollowedTags: {},
accounts: {},
routeNotification: null,
// Modals
@ -186,7 +188,7 @@ export function saveStatus(status, instance, opts) {
}
}
export function threadifyStatus(status, propInstance) {
function _threadifyStatus(status, propInstance) {
const { masto, instance } = api({ instance: propInstance });
// Return all statuses in the thread, via inReplyToId, if inReplyToAccountId === account.id
let fetchIndex = 0;
@ -225,6 +227,7 @@ export function threadifyStatus(status, propInstance) {
console.error(e, status);
});
}
export const threadifyStatus = rateLimit(_threadifyStatus, 100);
const fetchStatus = pmem((statusID, masto) => {
return masto.v1.statuses.$select(statusID).fetch();

View file

@ -1,3 +1,6 @@
import { extractTagsFromStatus, getFollowedTags } from './followed-tags';
import { fetchRelationships } from './relationships';
import states, { statusKey } from './states';
import store from './store';
export function groupBoosts(values) {
@ -175,3 +178,54 @@ export function groupContext(items) {
return newItems;
}
export async function assignFollowedTags(items, instance) {
const followedTags = await getFollowedTags(); // [{name: 'tag'}, {...}]
if (!followedTags.length) return;
const { statusFollowedTags } = states;
console.log('statusFollowedTags', statusFollowedTags);
const statusWithFollowedTags = [];
items.forEach((item) => {
if (item.reblog) return;
const { id, content, tags = [] } = item;
const sKey = statusKey(id, instance);
if (statusFollowedTags[sKey]?.length) return;
const extractedTags = extractTagsFromStatus(content);
if (!extractedTags.length && !tags.length) return;
const itemFollowedTags = followedTags.reduce((acc, tag) => {
if (
extractedTags.some((t) => t.toLowerCase() === tag.name.toLowerCase()) ||
tags.some((t) => t.name.toLowerCase() === tag.name.toLowerCase())
) {
acc.push(tag.name);
}
return acc;
}, []);
if (itemFollowedTags.length) {
// statusFollowedTags[sKey] = itemFollowedTags;
statusWithFollowedTags.push({
item,
sKey,
followedTags: itemFollowedTags,
});
}
});
if (statusWithFollowedTags.length) {
const accounts = statusWithFollowedTags.map((s) => s.item.account);
const relationships = await fetchRelationships(accounts);
if (!relationships) return;
statusWithFollowedTags.forEach((s) => {
const { item, sKey, followedTags } = s;
const r = relationships[item.account.id];
if (!r.following) {
statusFollowedTags[sKey] = followedTags;
}
});
}
}
export function clearFollowedTagsState() {
states.statusFollowedTags = {};
}

View file

@ -0,0 +1,14 @@
import { useEffect } from 'preact/hooks';
function useCloseWatcher(fn, deps = []) {
if (!fn || typeof fn !== 'function') return;
useEffect(() => {
const watcher = new CloseWatcher();
watcher.addEventListener('close', fn);
return () => {
watcher.destroy();
};
}, deps);
}
export default window.CloseWatcher ? useCloseWatcher : () => {};