mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-02-13 03:26:21 +01:00
commit
1e21f519f3
42 changed files with 1080 additions and 427 deletions
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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.
|
32
README.md
32
README.md
|
@ -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
33
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 */
|
||||
|
||||
|
|
11
src/app.jsx
11
src/app.jsx
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -177,6 +177,7 @@
|
|||
}
|
||||
|
||||
.account-container .account-block .account-block-acct {
|
||||
display: block;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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 />
|
||||
);
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -176,6 +176,10 @@ export default function Modals() {
|
|||
}}
|
||||
>
|
||||
<GenericAccounts
|
||||
instance={snapStates.showGenericAccounts.instance}
|
||||
excludeRelationshipAttrs={
|
||||
snapStates.showGenericAccounts.excludeRelationshipAttrs
|
||||
}
|
||||
onClose={() => (states.showGenericAccounts = false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
};
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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…
|
||||
</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" />
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -179,6 +179,7 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
|
|||
key={notification.id}
|
||||
instance={instance}
|
||||
notification={notification}
|
||||
disableContextMenu
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"> </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>
|
||||
))}
|
||||
|
|
62
src/utils/followed-tags.js
Normal file
62
src/utils/followed-tags.js
Normal 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(/^[^#]*#+/, ''),
|
||||
);
|
||||
};
|
|
@ -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
26
src/utils/ratelimit.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
38
src/utils/relationships.js
Normal file
38
src/utils/relationships.js
Normal 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
15
src/utils/speech.js
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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 = {};
|
||||
}
|
||||
|
|
14
src/utils/useCloseWatcher.js
Normal file
14
src/utils/useCloseWatcher.js
Normal 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 : () => {};
|
Loading…
Reference in a new issue