this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #360 from cheeaun/main

Update from main

authored by

Chee Aun and committed by
GitHub
1e21f519 19da64a7

+1080 -427
+20
.github/ISSUE_TEMPLATE/feature_request.md
··· 1 + --- 2 + name: Feature request 3 + about: Suggest an idea for this project 4 + title: '' 5 + labels: 'enhancement' 6 + assignees: '' 7 + 8 + --- 9 + 10 + **Is your feature request related to a problem? Please describe.** 11 + A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 + 13 + **Describe the solution you'd like** 14 + A clear and concise description of what you want to happen. 15 + 16 + **Describe alternatives you've considered** 17 + A clear and concise description of any alternative solutions or features you've considered. 18 + 19 + **Additional context** 20 + Add any other context or screenshots about the feature request here.
+25 -7
README.md
··· 107 107 - requires `.env.dev` file with `INSTANCES_SOCIAL_SECRET_TOKEN` variable set 108 108 - `npm run sourcemap` - Run `source-map-explorer` on the production build 109 109 110 - ## Self-hosting 111 - 112 - 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. 113 - 114 - Try search for "how to self-host static sites" as there are many ways to do it. 115 - 116 110 ## Tech stack 117 111 118 112 - [Vite](https://vitejs.dev/) - Build tool ··· 122 116 - [masto.js](https://github.com/neet/masto.js/) - Mastodon API client 123 117 - [Iconify](https://iconify.design/) - Icon library 124 118 - [MingCute icons](https://www.mingcute.com/) 125 - - Vanilla CSS - *Yes, I'm old school.* 119 + - Vanilla CSS - _Yes, I'm old school._ 126 120 127 121 Some of these may change in the future. The front-end world is ever-changing. 122 + 123 + ## Self-hosting 124 + 125 + This is a **pure static web app**. You can host it anywhere you want. 126 + 127 + Two ways (choose one): 128 + 129 + 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. 130 + 2. Download or `git clone` this repository. Build it by running `npm run build` (after `npm install`). Serve the `dist` folder. 131 + 132 + Try search for "how to self-host static sites" as there are many ways to do it. 133 + 134 + ## Community deployments 135 + 136 + These are self-hosted by other wonderful folks. 137 + 138 + - [ferengi.one](https://ferengi.one/) by [@david@collantes.social](https://collantes.social/@david) 139 + - [phanpy.blaede.family](https://phanpy.blaede.family/) by [@cassidy@blaede.family](https://mastodon.blaede.family/@cassidy) 140 + - [phanpy.mstdn.mx](https://phanpy.mstdn.mx/) by [@maop@mstdn.mx](https://mstdn.mx/@maop) 141 + - [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan) 142 + - [phanpy.gotosocial.social](https://phanpy.gotosocial.social/) by [@admin@gotosocial.social](https://gotosocial.social/@admin) 143 + - [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3) 144 + 145 + > Note: Add yours by creating a pull request. 128 146 129 147 ## Costs 130 148
+16 -17
package-lock.json
··· 26 26 "masto": "~6.5.1", 27 27 "moize": "~6.1.6", 28 28 "p-retry": "~6.1.0", 29 - "p-throttle": "~6.0.0", 30 - "preact": "~10.19.2", 29 + "p-throttle": "~6.1.0", 30 + "preact": "~10.19.3", 31 31 "react-hotkeys-hook": "~4.4.1", 32 32 "react-intersection-observer": "~9.5.3", 33 33 "react-quick-pinch-zoom": "~5.1.0", ··· 49 49 "postcss-dark-theme-class": "~1.1.0", 50 50 "postcss-preset-env": "~9.3.0", 51 51 "twitter-text": "~3.1.0", 52 - "vite": "~5.0.5", 52 + "vite": "~5.0.10", 53 53 "vite-plugin-generate-file": "~0.1.1", 54 54 "vite-plugin-html-config": "~1.0.11", 55 - "vite-plugin-pwa": "~0.17.3", 55 + "vite-plugin-pwa": "~0.17.4", 56 56 "vite-plugin-remove-console": "~2.2.0", 57 57 "workbox-cacheable-response": "~7.0.0", 58 58 "workbox-expiration": "~7.0.0", ··· 5698 5698 } 5699 5699 }, 5700 5700 "node_modules/p-throttle": { 5701 - "version": "6.0.0", 5702 - "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-6.0.0.tgz", 5703 - "integrity": "sha512-08yhRj7LFw5O0pV4Bkk/9sQlKTFhSMdvG5Akeo9lvaLhBvyKDgTt/bcSMd9b5UHjz+2P1EQPjzcnIXAKnKSiaA==", 5701 + "version": "6.1.0", 5702 + "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-6.1.0.tgz", 5703 + "integrity": "sha512-eQMdGTxk2+047La67wefUtt0tEHh7D+C8Jl7QXoFCuIiNYeQ9zWs2AZiJdIAs72rSXZ06t11me2bgalRNdy3SQ==", 5704 5704 "engines": { 5705 5705 "node": ">=18" 5706 5706 }, ··· 6515 6515 "license": "MIT" 6516 6516 }, 6517 6517 "node_modules/preact": { 6518 - "version": "10.19.2", 6519 - "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.2.tgz", 6520 - "integrity": "sha512-UA9DX/OJwv6YwP9Vn7Ti/vF80XL+YA5H2l7BpCtUr3ya8LWHFzpiO5R+N7dN16ujpIxhekRFuOOF82bXX7K/lg==", 6521 - "license": "MIT", 6518 + "version": "10.19.3", 6519 + "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.3.tgz", 6520 + "integrity": "sha512-nHHTeFVBTHRGxJXKkKu5hT8C/YWBkPso4/Gad6xuj5dbptt9iF9NZr9pHbPhBrnT2klheu7mHTxTZ/LjwJiEiQ==", 6522 6521 "funding": { 6523 6522 "type": "opencollective", 6524 6523 "url": "https://opencollective.com/preact" ··· 7618 7617 } 7619 7618 }, 7620 7619 "node_modules/vite": { 7621 - "version": "5.0.5", 7622 - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.5.tgz", 7623 - "integrity": "sha512-OekeWqR9Ls56f3zd4CaxzbbS11gqYkEiBtnWFFgYR2WV8oPJRRKq0mpskYy/XaoCL3L7VINDhqqOMNDiYdGvGg==", 7620 + "version": "5.0.10", 7621 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.10.tgz", 7622 + "integrity": "sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==", 7624 7623 "dev": true, 7625 7624 "dependencies": { 7626 7625 "esbuild": "^0.19.3", ··· 7699 7698 } 7700 7699 }, 7701 7700 "node_modules/vite-plugin-pwa": { 7702 - "version": "0.17.3", 7703 - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.17.3.tgz", 7704 - "integrity": "sha512-ilOs0mGxIxKQN3FZYX8pys5DmY/wI9A6oojlY5rrd7mAxCVcSbtjDVAhm62C+3Ww6KQrNr/jmiRUCplC8AsaBw==", 7701 + "version": "0.17.4", 7702 + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.17.4.tgz", 7703 + "integrity": "sha512-j9iiyinFOYyof4Zk3Q+DtmYyDVBDAi6PuMGNGq6uGI0pw7E+LNm9e+nQ2ep9obMP/kjdWwzilqUrlfVRj9OobA==", 7705 7704 "dev": true, 7706 7705 "dependencies": { 7707 7706 "debug": "^4.3.4",
+4 -4
package.json
··· 28 28 "masto": "~6.5.1", 29 29 "moize": "~6.1.6", 30 30 "p-retry": "~6.1.0", 31 - "p-throttle": "~6.0.0", 32 - "preact": "~10.19.2", 31 + "p-throttle": "~6.1.0", 32 + "preact": "~10.19.3", 33 33 "react-hotkeys-hook": "~4.4.1", 34 34 "react-intersection-observer": "~9.5.3", 35 35 "react-quick-pinch-zoom": "~5.1.0", ··· 51 51 "postcss-dark-theme-class": "~1.1.0", 52 52 "postcss-preset-env": "~9.3.0", 53 53 "twitter-text": "~3.1.0", 54 - "vite": "~5.0.5", 54 + "vite": "~5.0.10", 55 55 "vite-plugin-generate-file": "~0.1.1", 56 56 "vite-plugin-html-config": "~1.0.11", 57 - "vite-plugin-pwa": "~0.17.3", 57 + "vite-plugin-pwa": "~0.17.4", 58 58 "vite-plugin-remove-console": "~2.2.0", 59 59 "workbox-cacheable-response": "~7.0.0", 60 60 "workbox-expiration": "~7.0.0",
+7
src/app.css
··· 1578 1578 .tag.danger { 1579 1579 background-color: var(--red-color); 1580 1580 } 1581 + .tag.minimal { 1582 + margin: 0; 1583 + color: var(--text-insignificant-color); 1584 + background-color: var(--bg-faded-color); 1585 + text-shadow: 0 1px var(--bg-color); 1586 + line-height: 1; 1587 + } 1581 1588 1582 1589 /* MENU POPUP */ 1583 1590
+10 -1
src/app.jsx
··· 204 204 ); 205 205 const color = $meta?.getAttribute('content'); 206 206 if (color) { 207 - $meta.content = ''; 207 + let tempColor; 208 + if (/^#/.test(color)) { 209 + // Assume either #RBG or #RRGGBB 210 + if (color.length === 4) { 211 + tempColor = color + 'f'; 212 + } else if (color.length === 7) { 213 + tempColor = color + 'ff'; 214 + } 215 + } 216 + $meta.content = tempColor || ''; 208 217 setTimeout(() => { 209 218 $meta.content = color; 210 219 }, 10);
+45 -31
src/components/account-block.css
··· 4 4 gap: 8px; 5 5 color: var(--text-color); 6 6 text-decoration: none; 7 + 8 + .account-block-acct { 9 + display: inline-block; 10 + } 7 11 } 8 12 .account-block:hover b { 9 13 text-decoration: underline; ··· 13 17 color: var(--bg-faded-color); 14 18 } 15 19 16 - .account-block .short-desc { 17 - max-height: 1.2em; /* just in case clamping ain't working */ 18 - } 19 - .account-block .short-desc, 20 - .account-block .short-desc > * { 21 - display: -webkit-box; 22 - -webkit-line-clamp: 1; 23 - -webkit-box-orient: vertical; 24 - overflow: hidden; 25 - } 26 - .account-block .short-desc > * + * { 27 - display: none; 28 - } 29 - .account-block .short-desc * { 30 - margin: 0; 31 - padding: 0; 32 - color: inherit; 33 - pointer-events: none; 34 - } 35 - 36 20 .account-block .verified-field { 37 - color: var(--green-color); 38 21 display: inline-flex; 39 - align-items: center; 22 + align-items: baseline; 40 23 gap: 2px; 41 - } 42 - .account-block .verified-field .icon { 43 - } 44 - .account-block .verified-field .invisible { 45 - display: none; 24 + 25 + * { 26 + -webkit-box-orient: vertical; 27 + display: -webkit-box; 28 + -webkit-line-clamp: 1; 29 + line-clamp: 1; 30 + text-overflow: ellipsis; 31 + overflow: hidden; 32 + } 33 + 34 + a { 35 + pointer-events: none; 36 + color: color-mix( 37 + in lch, 38 + var(--green-color) 20%, 39 + var(--text-insignificant-color) 80% 40 + ) !important; 41 + } 42 + 43 + .icon { 44 + color: var(--green-color); 45 + transform: translateY(1px); 46 + } 47 + 48 + .invisible { 49 + display: none; 50 + } 51 + .ellipsis:after { 52 + content: '…'; 53 + } 46 54 } 47 55 48 56 .account-block .account-block-stats { 57 + line-height: 1.25; 49 58 margin-top: 2px; 50 59 font-size: 0.9em; 51 60 color: var(--text-insignificant-color); 52 - } 53 - .account-block .account-block-stats a { 54 - color: inherit; 55 - text-decoration: none; 61 + display: flex; 62 + flex-wrap: wrap; 63 + align-items: center; 64 + column-gap: 4px; 65 + 66 + a { 67 + color: inherit; 68 + text-decoration: none; 69 + } 56 70 }
+45 -11
src/components/account-block.jsx
··· 3 3 // import { useNavigate } from 'react-router-dom'; 4 4 import enhanceContent from '../utils/enhance-content'; 5 5 import niceDateTime from '../utils/nice-date-time'; 6 + import shortenNumber from '../utils/shorten-number'; 6 7 import states from '../utils/states'; 7 8 8 9 import Avatar from './avatar'; ··· 22 23 showStats = false, 23 24 accountInstance, 24 25 hideDisplayName = false, 26 + relationship = {}, 27 + excludeRelationshipAttrs = [], 25 28 }) { 26 29 if (skeleton) { 27 30 return ( ··· 34 37 </span> 35 38 </div> 36 39 ); 40 + } 41 + 42 + if (!account) { 43 + return null; 37 44 } 38 45 39 46 // const navigate = useNavigate(); ··· 53 60 fields, 54 61 note, 55 62 group, 63 + followersCount, 56 64 } = account; 57 65 let [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct]; 58 66 if (accountInstance) { ··· 61 69 62 70 const verifiedField = fields?.find((f) => !!f.verifiedAt && !!f.value); 63 71 72 + const excludedRelationship = {}; 73 + for (const r in relationship) { 74 + if (!excludeRelationshipAttrs.includes(r)) { 75 + excludedRelationship[r] = relationship[r]; 76 + } 77 + } 78 + const hasRelationship = 79 + excludedRelationship.following || 80 + excludedRelationship.followedBy || 81 + excludedRelationship.requested; 82 + 64 83 return ( 65 84 <a 66 85 class="account-block" ··· 97 116 ) : ( 98 117 <b>{username}</b> 99 118 )} 100 - <br /> 101 119 </> 102 - )} 120 + )}{' '} 103 121 <span class="account-block-acct"> 104 122 @{acct1} 105 123 <wbr /> ··· 124 142 )} 125 143 {showStats && ( 126 144 <div class="account-block-stats"> 127 - <div 128 - class="short-desc" 129 - dangerouslySetInnerHTML={{ 130 - __html: enhanceContent(note, { emojis }), 131 - }} 132 - /> 133 145 {bot && ( 134 146 <> 135 - <span class="tag"> 147 + <span class="tag collapsed"> 136 148 <Icon icon="bot" /> Automated 137 149 </span> 138 150 </> 139 151 )} 140 152 {!!group && ( 141 153 <> 142 - <span class="tag"> 154 + <span class="tag collapsed"> 143 155 <Icon icon="group" /> Group 144 156 </span> 145 157 </> 146 158 )} 159 + {hasRelationship && ( 160 + <div key={relationship.id} class="shazam-container-horizontal"> 161 + <div class="shazam-container-inner"> 162 + {excludedRelationship.following && 163 + excludedRelationship.followedBy ? ( 164 + <span class="tag minimal">Mutual</span> 165 + ) : excludedRelationship.requested ? ( 166 + <span class="tag minimal">Requested</span> 167 + ) : excludedRelationship.following ? ( 168 + <span class="tag minimal">Following</span> 169 + ) : excludedRelationship.followedBy ? ( 170 + <span class="tag minimal">Follows you</span> 171 + ) : null} 172 + </div> 173 + </div> 174 + )} 175 + {!!followersCount && ( 176 + <span class="ib"> 177 + {shortenNumber(followersCount)}{' '} 178 + {followersCount === 1 ? 'follower' : 'followers'} 179 + </span> 180 + )} 147 181 {!!verifiedField && ( 148 - <span class="verified-field ib"> 182 + <span class="verified-field"> 149 183 <Icon icon="check-circle" size="s" />{' '} 150 184 <span 151 185 dangerouslySetInnerHTML={{
+1
src/components/account-info.css
··· 177 177 } 178 178 179 179 .account-container .account-block .account-block-acct { 180 + display: block; 180 181 opacity: 0.7; 181 182 } 182 183
+20 -14
src/components/account-info.jsx
··· 35 35 import TranslationBlock from './translation-block'; 36 36 37 37 const MUTE_DURATIONS = [ 38 - 1000 * 60 * 5, // 5 minutes 39 - 1000 * 60 * 30, // 30 minutes 40 - 1000 * 60 * 60, // 1 hour 41 - 1000 * 60 * 60 * 6, // 6 hours 42 - 1000 * 60 * 60 * 24, // 1 day 43 - 1000 * 60 * 60 * 24 * 3, // 3 days 44 - 1000 * 60 * 60 * 24 * 7, // 1 week 38 + 60 * 5, // 5 minutes 39 + 60 * 30, // 30 minutes 40 + 60 * 60, // 1 hour 41 + 60 * 60 * 6, // 6 hours 42 + 60 * 60 * 24, // 1 day 43 + 60 * 60 * 24 * 3, // 3 days 44 + 60 * 60 * 24 * 7, // 1 week 45 45 0, // forever 46 46 ]; 47 47 const MUTE_DURATIONS_LABELS = { 48 48 0: 'Forever', 49 - 300_000: '5 minutes', 50 - 1_800_000: '30 minutes', 51 - 3_600_000: '1 hour', 52 - 21_600_000: '6 hours', 53 - 86_400_000: '1 day', 54 - 259_200_000: '3 days', 55 - 604_800_000: '1 week', 49 + 300: '5 minutes', 50 + 1_800: '30 minutes', 51 + 3_600: '1 hour', 52 + 21_600: '6 hours', 53 + 86_400: '1 day', 54 + 259_200: '3 days', 55 + 604_800: '1 week', 56 56 }; 57 57 58 58 const LIMIT = 80; ··· 604 604 states.showGenericAccounts = { 605 605 heading: 'Followers', 606 606 fetchAccounts: fetchFollowers, 607 + instance, 608 + excludeRelationshipAttrs: isSelf 609 + ? ['followedBy'] 610 + : [], 607 611 }; 608 612 }, 0); 609 613 }} ··· 637 641 states.showGenericAccounts = { 638 642 heading: 'Following', 639 643 fetchAccounts: fetchFollowing, 644 + instance, 645 + excludeRelationshipAttrs: isSelf ? ['following'] : [], 640 646 }; 641 647 }, 0); 642 648 }}
+4
src/components/columns.jsx
··· 9 9 import Mentions from '../pages/mentions'; 10 10 import Notifications from '../pages/notifications'; 11 11 import Public from '../pages/public'; 12 + import Search from '../pages/search'; 12 13 import Trending from '../pages/trending'; 13 14 import states from '../utils/states'; 14 15 import useTitle from '../utils/useTitle'; ··· 33 34 hashtag: Hashtag, 34 35 mentions: Mentions, 35 36 trending: Trending, 37 + search: Search, 36 38 }[type]; 37 39 if (!Component) return null; 40 + // Don't show Search column with no query, for now 41 + if (type === 'search' && !params.query) return null; 38 42 return ( 39 43 <Component key={type + JSON.stringify(params)} {...params} columnMode /> 40 44 );
+10 -1
src/components/compose.jsx
··· 28 28 getCurrentInstanceConfiguration, 29 29 } from '../utils/store-utils'; 30 30 import supports from '../utils/supports'; 31 + import useCloseWatcher from '../utils/useCloseWatcher'; 31 32 import useInterval from '../utils/useInterval'; 32 33 import visibilityIconsMap from '../utils/visibility-icons-map'; 33 34 ··· 108 109 // https://github.com/mastodon/mastodon/blob/c03bd2a238741a012aa4b98dc4902d6cf948ab63/app/models/account.rb#L69 109 110 const USERNAME_RE = /[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?/i; 110 111 const MENTION_RE = new RegExp( 111 - `(^|[^=\\/\\w.])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`, 112 + `(^|[^=\\/\\w])(@${USERNAME_RE.source}(?:@[\\p{L}\\w.-]+[\\w]+)?)`, 112 113 'uig', 113 114 ); 114 115 ··· 416 417 }; 417 418 useEffect(updateCharCount, []); 418 419 420 + const supportsCloseWatcher = window.CloseWatcher; 419 421 const escDownRef = useRef(false); 420 422 useHotkeys( 421 423 'esc', ··· 424 426 // This won't be true if this event is already handled and not propagated 🤞 425 427 }, 426 428 { 429 + enabled: !supportsCloseWatcher, 427 430 enableOnFormTags: true, 428 431 }, 429 432 ); ··· 436 439 escDownRef.current = false; 437 440 }, 438 441 { 442 + enabled: !supportsCloseWatcher, 439 443 enableOnFormTags: true, 440 444 // Use keyup because Esc keydown will close the confirm dialog on Safari 441 445 keyup: true, ··· 448 452 }, 449 453 }, 450 454 ); 455 + useCloseWatcher(() => { 456 + if (!standalone && confirmClose()) { 457 + onClose(); 458 + } 459 + }, [standalone, confirmClose, onClose]); 451 460 452 461 const prevBackgroundDraft = useRef({}); 453 462 const draftKey = () => {
+39 -5
src/components/generic-accounts.css
··· 1 1 #generic-accounts-container { 2 2 .accounts-list { 3 + --list-gap: 16px; 3 4 list-style: none; 4 5 margin: 0; 5 6 padding: 8px 0; ··· 7 8 flex-wrap: wrap; 8 9 flex-direction: row; 9 10 column-gap: 1.5em; 10 - row-gap: 16px; 11 + row-gap: var(--list-gap); 11 12 12 13 li { 13 14 display: flex; 14 15 flex-grow: 1; 15 16 flex-basis: 16em; 16 - align-items: center; 17 + /* align-items: center; */ 17 18 margin: 0; 18 19 padding: 0; 19 20 gap: 8px; 21 + 22 + position: relative; 23 + 24 + &:before { 25 + content: ''; 26 + display: block; 27 + border-top: var(--hairline-width) solid var(--divider-color); 28 + position: absolute; 29 + bottom: calc(-1 * var(--list-gap) / 2); 30 + left: 40px; 31 + right: 0; 32 + } 33 + 34 + &:has(.reactions-block):before { 35 + /* avatar + reactions + gap */ 36 + left: calc(40px + 16px + 8px); 37 + } 20 38 } 21 39 22 40 .account-block-acct { 23 - font-size: 80%; 41 + font-size: 0.9em; 24 42 color: var(--text-insignificant-color); 25 - display: block; 43 + /* display: block; */ 26 44 } 27 45 } 28 46 29 47 .reactions-block { 30 48 display: flex; 31 49 flex-direction: column; 32 - align-self: center; 50 + /* align-self: center; */ 33 51 34 52 .favourite-icon { 35 53 color: var(--favourite-color); ··· 38 56 .reblog-icon { 39 57 color: var(--reblog-color); 40 58 } 59 + 60 + > .icon:only-child { 61 + margin-top: 8px; /* half of icon dimension */ 62 + } 63 + } 64 + 65 + .account-relationships { 66 + flex-grow: 1; 67 + 68 + .tag { 69 + animation: appear 0.3s ease-out; 70 + } 71 + } 72 + 73 + .account-block { 74 + align-items: flex-start; 41 75 } 42 76 }
+87 -22
src/components/generic-accounts.jsx
··· 4 4 import { InView } from 'react-intersection-observer'; 5 5 import { useSnapshot } from 'valtio'; 6 6 7 + import { api } from '../utils/api'; 8 + import { fetchRelationships } from '../utils/relationships'; 7 9 import states from '../utils/states'; 8 10 import useLocationChange from '../utils/useLocationChange'; 9 11 ··· 11 13 import Icon from './icon'; 12 14 import Loader from './loader'; 13 15 14 - export default function GenericAccounts({ onClose = () => {} }) { 16 + export default function GenericAccounts({ 17 + instance, 18 + excludeRelationshipAttrs = [], 19 + onClose = () => {}, 20 + }) { 21 + const { masto, instance: currentInstance } = api(); 22 + const isCurrentInstance = instance ? instance === currentInstance : true; 15 23 const snapStates = useSnapshot(states); 24 + ``; 16 25 const [uiState, setUIState] = useState('default'); 17 26 const [accounts, setAccounts] = useState([]); 18 27 const [showMore, setShowMore] = useState(false); ··· 31 40 showReactions, 32 41 } = snapStates.showGenericAccounts; 33 42 43 + const [relationshipsMap, setRelationshipsMap] = useState({}); 44 + 45 + const loadRelationships = async (accounts) => { 46 + if (!accounts?.length) return; 47 + if (!isCurrentInstance) return; 48 + const relationships = await fetchRelationships(accounts, relationshipsMap); 49 + if (relationships) { 50 + setRelationshipsMap({ 51 + ...relationshipsMap, 52 + ...relationships, 53 + }); 54 + } 55 + }; 56 + 34 57 const loadAccounts = (firstLoad) => { 35 58 if (!fetchAccounts) return; 36 59 if (firstLoad) setAccounts([]); ··· 40 63 const { done, value } = await fetchAccounts(firstLoad); 41 64 if (Array.isArray(value)) { 42 65 if (firstLoad) { 43 - setAccounts(value); 66 + const accounts = []; 67 + for (let i = 0; i < value.length; i++) { 68 + const account = value[i]; 69 + const theAccount = accounts.find( 70 + (a, j) => a.id === account.id && i !== j, 71 + ); 72 + if (!theAccount) { 73 + accounts.push({ 74 + _types: [], 75 + ...account, 76 + }); 77 + } else { 78 + theAccount._types.push(...account._types); 79 + } 80 + } 81 + setAccounts(accounts); 44 82 } else { 45 - setAccounts((prev) => [...prev, ...value]); 83 + // setAccounts((prev) => [...prev, ...value]); 84 + // Merge accounts by id and _types 85 + setAccounts((prev) => { 86 + const newAccounts = prev; 87 + for (const account of value) { 88 + const theAccount = newAccounts.find((a) => a.id === account.id); 89 + if (!theAccount) { 90 + newAccounts.push(account); 91 + } else { 92 + theAccount._types.push(...account._types); 93 + } 94 + } 95 + return newAccounts; 96 + }); 46 97 } 47 98 setShowMore(!done); 99 + 100 + loadRelationships(value); 48 101 } else { 49 102 setShowMore(false); 50 103 } ··· 60 113 useEffect(() => { 61 114 if (staticAccounts?.length > 0) { 62 115 setAccounts(staticAccounts); 116 + loadRelationships(staticAccounts); 63 117 } else { 64 118 loadAccounts(true); 65 119 firstLoad.current = false; ··· 87 141 {accounts.length > 0 ? ( 88 142 <> 89 143 <ul class="accounts-list"> 90 - {accounts.map((account) => ( 91 - <li key={account.id + (account._types || '')}> 92 - {showReactions && account._types?.length > 0 && ( 93 - <div class="reactions-block"> 94 - {account._types.map((type) => ( 95 - <Icon 96 - icon={ 97 - { 98 - reblog: 'rocket', 99 - favourite: 'heart', 100 - }[type] 101 - } 102 - class={`${type}-icon`} 103 - /> 104 - ))} 144 + {accounts.map((account) => { 145 + const relationship = relationshipsMap[account.id]; 146 + const key = `${account.id}-${account._types?.length || ''}`; 147 + return ( 148 + <li key={key}> 149 + {showReactions && account._types?.length > 0 && ( 150 + <div class="reactions-block"> 151 + {account._types.map((type) => ( 152 + <Icon 153 + icon={ 154 + { 155 + reblog: 'rocket', 156 + favourite: 'heart', 157 + }[type] 158 + } 159 + class={`${type}-icon`} 160 + /> 161 + ))} 162 + </div> 163 + )} 164 + <div class="account-relationships"> 165 + <AccountBlock 166 + account={account} 167 + showStats 168 + relationship={relationship} 169 + excludeRelationshipAttrs={excludeRelationshipAttrs} 170 + /> 105 171 </div> 106 - )} 107 - <AccountBlock account={account} /> 108 - </li> 109 - ))} 172 + </li> 173 + ); 174 + })} 110 175 </ul> 111 176 {uiState === 'default' ? ( 112 177 showMore ? (
+1
src/components/icon.jsx
··· 104 104 cloud: () => import('@iconify-icons/mingcute/cloud-line'), 105 105 month: () => import('@iconify-icons/mingcute/calendar-month-line'), 106 106 media: () => import('@iconify-icons/mingcute/photo-album-line'), 107 + speak: () => import('@iconify-icons/mingcute/radar-line'), 107 108 }; 108 109 109 110 function Icon({
+9 -17
src/components/keyboard-shortcuts-help.jsx
··· 30 30 }, 31 31 ); 32 32 33 - const escRef = useHotkeys('esc', onClose, []); 34 - 35 33 return ( 36 34 !!snapStates.showKeyboardShortcutsHelp && ( 37 - <Modal 38 - class="light" 39 - onClick={(e) => { 40 - if (e.target === e.currentTarget) { 41 - onClose(); 42 - } 43 - }} 44 - > 45 - <div 46 - id="keyboard-shortcuts-help-container" 47 - class="sheet" 48 - tabindex="-1" 49 - ref={escRef} 50 - > 35 + <Modal class="light" onClose={onClose}> 36 + <div id="keyboard-shortcuts-help-container" class="sheet" tabindex="-1"> 51 37 <button type="button" class="sheet-close" onClick={onClose}> 52 38 <Icon icon="x" /> 53 39 </button> ··· 94 80 ), 95 81 }, 96 82 { 97 - action: 'Toggle expanded/collapsed thread', 83 + action: ( 84 + <> 85 + Expand content warning or 86 + <br /> 87 + toggle expanded/collapsed thread 88 + </> 89 + ), 98 90 keys: <kbd>x</kbd>, 99 91 }, 100 92 {
+11
src/components/media-alt-modal.jsx
··· 4 4 5 5 import getTranslateTargetLanguage from '../utils/get-translate-target-language'; 6 6 import localeMatch from '../utils/locale-match'; 7 + import { speak, supportsTTS } from '../utils/speech'; 7 8 import states from '../utils/states'; 8 9 9 10 import Icon from './icon'; ··· 51 52 <Icon icon="translate" /> 52 53 <span>Translate</span> 53 54 </MenuItem> 55 + {supportsTTS && ( 56 + <MenuItem 57 + onClick={() => { 58 + speak(alt, lang); 59 + }} 60 + > 61 + <Icon icon="speak" /> 62 + <span>Speak</span> 63 + </MenuItem> 64 + )} 54 65 </Menu2> 55 66 </div> 56 67 </header>
+5 -1
src/components/modal.jsx
··· 4 4 import { useEffect, useRef } from 'preact/hooks'; 5 5 import { useHotkeys } from 'react-hotkeys-hook'; 6 6 7 + import useCloseWatcher from '../utils/useCloseWatcher'; 8 + 7 9 const $modalContainer = document.getElementById('modal-container'); 8 10 9 11 function Modal({ children, onClose, onClick, class: className }) { ··· 20 22 return () => clearTimeout(timer); 21 23 }, []); 22 24 25 + const supportsCloseWatcher = window.CloseWatcher; 23 26 const escRef = useHotkeys( 24 27 'esc', 25 28 () => { ··· 28 31 }, 0); 29 32 }, 30 33 { 31 - enabled: !!onClose, 34 + enabled: !supportsCloseWatcher && !!onClose, 32 35 // Using keyup and setTimeout above 33 36 // This will run "later" to prevent clash with esc handlers from other components 34 37 keydown: false, ··· 36 39 }, 37 40 [onClose], 38 41 ); 42 + useCloseWatcher(onClose, [onClose]); 39 43 40 44 const Modal = ( 41 45 <div
+4
src/components/modals.jsx
··· 176 176 }} 177 177 > 178 178 <GenericAccounts 179 + instance={snapStates.showGenericAccounts.instance} 180 + excludeRelationshipAttrs={ 181 + snapStates.showGenericAccounts.excludeRelationshipAttrs 182 + } 179 183 onClose={() => (states.showGenericAccounts = false)} 180 184 /> 181 185 </Modal>
+5 -3
src/components/name-text.jsx
··· 7 7 import Avatar from './avatar'; 8 8 import EmojiText from './emoji-text'; 9 9 10 + const nameCollator = new Intl.Collator('en', { 11 + sensitivity: 'base', 12 + }); 13 + 10 14 function NameText({ 11 15 account, 12 16 instance, ··· 36 40 (trimmedUsername === trimmedDisplayName || 37 41 trimmedUsername === shortenedDisplayName || 38 42 trimmedUsername === shortenedAlphaNumericDisplayName || 39 - trimmedUsername.localeCompare?.(shortenedDisplayName, 'en', { 40 - sensitivity: 'base', 41 - }) === 0) 43 + nameCollator.compare(trimmedUsername, shortenedDisplayName) === 0) 42 44 ) { 43 45 username = null; 44 46 }
+2
src/components/nav-menu.jsx
··· 233 233 id: 'mute', 234 234 heading: 'Muted users', 235 235 fetchAccounts: fetchMutes, 236 + excludeRelationshipAttrs: ['muting'], 236 237 }; 237 238 }} 238 239 > ··· 244 245 id: 'block', 245 246 heading: 'Blocked users', 246 247 fetchAccounts: fetchBlocks, 248 + excludeRelationshipAttrs: ['blocking'], 247 249 }; 248 250 }} 249 251 >
+25 -15
src/components/notification.jsx
··· 67 67 68 68 const AVATARS_LIMIT = 50; 69 69 70 - function Notification({ notification, instance, isStatic }) { 70 + function Notification({ 71 + notification, 72 + instance, 73 + isStatic, 74 + disableContextMenu, 75 + }) { 71 76 const { id, status, account, report, _accounts, _statuses } = notification; 72 77 let { type } = notification; 73 78 ··· 153 158 heading: genericAccountsHeading, 154 159 accounts: _accounts, 155 160 showReactions: type === 'favourite+reblog', 161 + excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [], 156 162 }; 157 163 }; 158 164 ··· 300 306 ? `/${instance}/s/${actualStatusID}` 301 307 : `/s/${actualStatusID}` 302 308 } 303 - onContextMenu={(e) => { 304 - const post = e.target.querySelector('.status'); 305 - if (post) { 306 - // Fire a custom event to open the context menu 307 - if (e.metaKey) return; 308 - e.preventDefault(); 309 - post.dispatchEvent( 310 - new MouseEvent('contextmenu', { 311 - clientX: e.clientX, 312 - clientY: e.clientY, 313 - }), 314 - ); 315 - } 316 - }} 309 + onContextMenu={ 310 + !disableContextMenu 311 + ? (e) => { 312 + const post = e.target.querySelector('.status'); 313 + if (post) { 314 + // Fire a custom event to open the context menu 315 + if (e.metaKey) return; 316 + e.preventDefault(); 317 + post.dispatchEvent( 318 + new MouseEvent('contextmenu', { 319 + clientX: e.clientX, 320 + clientY: e.clientY, 321 + }), 322 + ); 323 + } 324 + } 325 + : undefined 326 + } 317 327 > 318 328 {isStatic ? ( 319 329 <Status status={actualStatus} size="s" />
+5
src/components/shortcuts-settings.css
··· 123 123 min-width: 0; 124 124 max-width: 320px; 125 125 } 126 + #shortcut-settings-form .form-note { 127 + display: flex; 128 + gap: 6px; 129 + align-items: center; 130 + } 126 131 #shortcut-settings-form form footer { 127 132 display: flex; 128 133 gap: 16px;
+36 -13
src/components/shortcuts-settings.jsx
··· 13 13 import tabMenuBarUrl from '../assets/tab-menu-bar.svg'; 14 14 15 15 import { api } from '../utils/api'; 16 + import { fetchFollowedTags } from '../utils/followed-tags'; 16 17 import pmem from '../utils/pmem'; 17 18 import showToast from '../utils/show-toast'; 18 19 import states from '../utils/states'; ··· 31 32 'list', 32 33 'public', 33 34 'trending', 34 - // NOTE: Hide for now 35 - // 'search', // Search on Mastodon ain't great 36 - // 'account-statuses', // Need @acct search first 35 + 'search', 37 36 'hashtag', 38 37 'bookmarks', 39 38 'favourites', 39 + // NOTE: Hide for now 40 + // 'account-statuses', // Need @acct search first 40 41 ]; 41 42 const TYPE_TEXT = { 42 43 following: 'Home / Following', ··· 86 87 text: 'Search term', 87 88 name: 'query', 88 89 type: 'text', 90 + placeholder: 'Optional, unless for multi-column mode', 91 + notRequired: true, 89 92 }, 90 93 ], 91 94 'account-statuses': [ ··· 167 170 }, 168 171 search: { 169 172 id: 'search', 170 - title: ({ query }) => query, 171 - path: ({ query }) => `/search?q=${query}`, 173 + title: ({ query }) => (query ? `"${query}"` : 'Search'), 174 + path: ({ query }) => 175 + query ? `/search?q=${query}&type=statuses` : '/search', 172 176 icon: 'search', 177 + excludeViewMode: ({ query }) => (!query ? ['multi-column'] : []), 173 178 }, 174 179 'account-statuses': { 175 180 id: 'account-statuses', ··· 278 283 const key = Object.values(shortcut).join('-'); 279 284 const { type } = shortcut; 280 285 if (!SHORTCUTS_META[type]) return null; 281 - let { icon, title, subtitle } = SHORTCUTS_META[type]; 286 + let { icon, title, subtitle, excludeViewMode } = 287 + SHORTCUTS_META[type]; 282 288 if (typeof title === 'function') { 283 289 title = title(shortcut, i); 284 290 } ··· 288 294 if (typeof icon === 'function') { 289 295 icon = icon(shortcut, i); 290 296 } 297 + if (typeof excludeViewMode === 'function') { 298 + excludeViewMode = excludeViewMode(shortcut, i); 299 + } 300 + const excludedViewMode = excludeViewMode?.includes( 301 + snapStates.settings.shortcutsViewMode, 302 + ); 291 303 return ( 292 304 <li key={key}> 293 305 <Icon icon={icon} /> ··· 299 311 <small class="ib insignificant">{subtitle}</small> 300 312 </> 301 313 )} 314 + {excludedViewMode && ( 315 + <span class="tag"> 316 + Not available in current view mode 317 + </span> 318 + )} 302 319 </span> 303 320 <span class="shortcut-actions"> 304 321 <button ··· 467 484 }, 468 485 ); 469 486 487 + const FORM_NOTES = { 488 + search: `For multi-column mode, search term is required, else the column will not be shown.`, 489 + hashtag: 'Multiple hashtags are supported. Space-separated.', 490 + }; 491 + 470 492 function ShortcutForm({ 471 493 onSubmit, 472 494 disabled, ··· 500 522 (async () => { 501 523 if (currentType !== 'hashtag') return; 502 524 try { 503 - const iterator = masto.v1.followedTags.list(); 504 - const tags = []; 505 - do { 506 - const { value, done } = await iterator.next(); 507 - if (done || value?.length === 0) break; 508 - tags.push(...value); 509 - } while (true); 525 + const tags = await fetchFollowedTags(); 510 526 setFollowedHashtags(tags); 511 527 } catch (e) { 512 528 console.error(e); ··· 620 636 <span>{text}</span>{' '} 621 637 <input 622 638 type={type} 639 + switch={type === 'checkbox' || undefined} 623 640 name={name} 624 641 placeholder={placeholder} 625 642 required={type === 'text' && !notRequired} ··· 646 663 </p> 647 664 ); 648 665 }, 666 + )} 667 + {!!FORM_NOTES[currentType] && ( 668 + <p class="form-note insignificant"> 669 + <Icon icon="info" /> 670 + {FORM_NOTES[currentType]} 671 + </p> 649 672 )} 650 673 <footer> 651 674 <button
+52 -2
src/components/status.css
··· 14 14 transparent min(160px, 50%) 15 15 ); 16 16 } 17 + .status-followed-tags { 18 + background: linear-gradient( 19 + 160deg, 20 + var(--hashtag-faded-color), 21 + transparent min(160px, 50%) 22 + ); 23 + } 17 24 .status-reply-to { 18 25 background: linear-gradient( 19 26 160deg, ··· 21 28 transparent min(160px, 50%) 22 29 ); 23 30 } 24 - :is(.status-reblog, .status-group) .status-reply-to { 31 + :is(.status-reblog, .status-group, .status-followed-tags) .status-reply-to { 25 32 background: linear-gradient( 26 33 -20deg, 27 34 var(--reply-to-faded-color), ··· 62 69 color: var(--group-color); 63 70 margin-right: 4px; 64 71 vertical-align: text-bottom; 72 + } 73 + .status-followed-tags { 74 + .status-pre-meta { 75 + position: relative; 76 + z-index: 1; 77 + display: flex; 78 + flex-wrap: wrap; 79 + gap: 4px; 80 + align-items: center; 81 + 82 + .icon { 83 + color: var(--hashtag-color); 84 + margin-right: 4px; 85 + vertical-align: text-bottom; 86 + } 87 + a { 88 + color: var(--hashtag-text-color); 89 + font-weight: bold; 90 + font-size: 12px; 91 + text-decoration-color: var(--hashtag-faded-color); 92 + text-underline-offset: 2px; 93 + text-decoration-thickness: 2px; 94 + display: inline-block; 95 + padding: 2px; 96 + vertical-align: top; 97 + text-transform: uppercase; 98 + text-shadow: 0 1px var(--bg-color); 99 + 100 + &:hover { 101 + color: var(--text-color); 102 + text-decoration-color: var(--hashtag-color); 103 + } 104 + } 105 + } 106 + 107 + .status-followed-tag-item { 108 + color: var(--hashtag-text-color); 109 + padding: 2px; 110 + font-weight: bold; 111 + font-size: 12px; 112 + text-transform: uppercase; 113 + margin-inline-end: 0.5em; 114 + } 65 115 } 66 116 67 117 /* STATUS */ ··· 544 594 .timeline-deck .status .content { 545 595 max-height: 50vh; 546 596 max-height: 50dvh; 547 - overflow: hidden; 597 + overflow: clip; 548 598 position: relative; 549 599 } 550 600 .timeline-deck
+223 -216
src/components/status.jsx
··· 63 63 import { isMediaCaptionLong } from './media'; 64 64 import MenuLink from './menu-link'; 65 65 import RelativeTime from './relative-time'; 66 + import { speak, supportsTTS } from '../utils/speech'; 66 67 import TranslationBlock from './translation-block'; 67 68 68 69 const SHOW_COMMENT_COUNT_LIMIT = 280; ··· 88 89 window.ontouchstart !== undefined && 89 90 /iPad|iPhone|iPod/.test(navigator.userAgent); 90 91 92 + const REACTIONS_LIMIT = 80; 93 + 94 + function getPollText(poll) { 95 + if (!poll?.options?.length) return ''; 96 + return `📊:\n${poll.options 97 + .map( 98 + (option) => 99 + `- ${option.title}${ 100 + option.votesCount >= 0 ? ` (${option.votesCount})` : '' 101 + }`, 102 + ) 103 + .join('\n')}`; 104 + } 105 + function getPostText(status) { 106 + const { spoilerText, content, poll } = status; 107 + return ( 108 + (spoilerText ? `${spoilerText}\n\n` : '') + 109 + getHTMLText(content) + 110 + getPollText(poll) 111 + ); 112 + } 113 + 91 114 function Status({ 92 115 statusID, 93 116 status, 94 117 instance: propInstance, 118 + size = 'm', 119 + contentTextWeight, 120 + readOnly, 121 + enableCommentHint, 95 122 withinContext, 96 - size = 'm', 97 123 skeleton, 98 - readOnly, 99 - contentTextWeight, 100 124 enableTranslate, 101 125 forceTranslate: _forceTranslate, 102 126 previewMode, ··· 104 128 onMediaClick, 105 129 quoted, 106 130 onStatusLinkClick = () => {}, 107 - enableCommentHint, 131 + showFollowedTags, 108 132 }) { 109 133 if (skeleton) { 110 134 return ( ··· 174 198 uri, 175 199 url, 176 200 emojis, 201 + tags, 177 202 // Non-API props 178 203 _deleted, 179 204 _pinned, ··· 214 239 containerProps={{ 215 240 onMouseEnter: debugHover, 216 241 }} 242 + showFollowedTags 217 243 /> 218 244 ); 219 245 } ··· 302 328 ); 303 329 } 304 330 331 + // Check followedTags 332 + if (showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length) { 333 + return ( 334 + <div 335 + data-state-post-id={sKey} 336 + class="status-followed-tags" 337 + onMouseEnter={debugHover} 338 + > 339 + <div class="status-pre-meta"> 340 + <Icon icon="hashtag" size="l" />{' '} 341 + {snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => ( 342 + <Link 343 + key={tag} 344 + to={instance ? `/${instance}/t/${tag}` : `/t/${tag}`} 345 + class="status-followed-tag-item" 346 + > 347 + {tag} 348 + </Link> 349 + ))} 350 + </div> 351 + <Status 352 + status={statusID ? null : status} 353 + statusID={statusID ? status.id : null} 354 + instance={instance} 355 + size={size} 356 + contentTextWeight={contentTextWeight} 357 + readOnly={readOnly} 358 + enableCommentHint 359 + /> 360 + </div> 361 + ); 362 + } 363 + 305 364 const isSizeLarge = size === 'l'; 306 365 307 366 const [forceTranslate, setForceTranslate] = useState(_forceTranslate); ··· 344 403 ]); 345 404 346 405 const [showEdited, setShowEdited] = useState(false); 347 - const [showReactions, setShowReactions] = useState(false); 348 406 349 407 const spoilerContentRef = useTruncated(); 350 408 const contentRef = useTruncated(); ··· 524 582 (l) => language === l || localeMatch([language], [l]), 525 583 ); 526 584 585 + const reblogIterator = useRef(); 586 + const favouriteIterator = useRef(); 587 + async function fetchBoostedLikedByAccounts(firstLoad) { 588 + if (firstLoad) { 589 + reblogIterator.current = masto.v1.statuses 590 + .$select(statusID) 591 + .rebloggedBy.list({ 592 + limit: REACTIONS_LIMIT, 593 + }); 594 + favouriteIterator.current = masto.v1.statuses 595 + .$select(statusID) 596 + .favouritedBy.list({ 597 + limit: REACTIONS_LIMIT, 598 + }); 599 + } 600 + const [{ value: reblogResults }, { value: favouriteResults }] = 601 + await Promise.allSettled([ 602 + reblogIterator.current.next(), 603 + favouriteIterator.current.next(), 604 + ]); 605 + if (reblogResults.value?.length || favouriteResults.value?.length) { 606 + const accounts = []; 607 + if (reblogResults.value?.length) { 608 + accounts.push( 609 + ...reblogResults.value.map((a) => { 610 + a._types = ['reblog']; 611 + return a; 612 + }), 613 + ); 614 + } 615 + if (favouriteResults.value?.length) { 616 + accounts.push( 617 + ...favouriteResults.value.map((a) => { 618 + a._types = ['favourite']; 619 + return a; 620 + }), 621 + ); 622 + } 623 + return { 624 + value: accounts, 625 + done: reblogResults.done && favouriteResults.done, 626 + }; 627 + } 628 + return { 629 + value: [], 630 + done: true, 631 + }; 632 + } 633 + 527 634 const menuInstanceRef = useRef(); 528 635 const StatusMenuItems = ( 529 636 <> ··· 584 691 )} 585 692 {(!isSizeLarge || !!editedAt) && <MenuDivider />} 586 693 {isSizeLarge && ( 587 - <MenuItem onClick={() => setShowReactions(true)}> 694 + <MenuItem 695 + onClick={() => { 696 + states.showGenericAccounts = { 697 + heading: 'Boosted/Liked by…', 698 + fetchAccounts: fetchBoostedLikedByAccounts, 699 + instance, 700 + showReactions: true, 701 + }; 702 + }} 703 + > 588 704 <Icon icon="react" /> 589 705 <span> 590 706 Boosted/Liked by<span class="more-insignificant">…</span> ··· 687 803 </> 688 804 )} 689 805 {enableTranslate ? ( 690 - <MenuItem 691 - disabled={forceTranslate} 692 - onClick={() => { 693 - setForceTranslate(true); 694 - }} 695 - > 696 - <Icon icon="translate" /> 697 - <span>Translate</span> 698 - </MenuItem> 699 - ) : ( 700 - (!language || differentLanguage) && ( 701 - <MenuLink 702 - to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`} 806 + <div class={supportsTTS ? 'menu-horizontal' : ''}> 807 + <MenuItem 808 + disabled={forceTranslate} 809 + onClick={() => { 810 + setForceTranslate(true); 811 + }} 703 812 > 704 813 <Icon icon="translate" /> 705 814 <span>Translate</span> 706 - </MenuLink> 815 + </MenuItem> 816 + {supportsTTS && ( 817 + <MenuItem 818 + onClick={() => { 819 + const postText = getPostText(status); 820 + if (postText) { 821 + speak(postText, language); 822 + } 823 + }} 824 + > 825 + <Icon icon="speak" /> 826 + <span>Speak</span> 827 + </MenuItem> 828 + )} 829 + </div> 830 + ) : ( 831 + (!language || differentLanguage) && ( 832 + <div class={supportsTTS ? 'menu-horizontal' : ''}> 833 + <MenuLink 834 + to={`${instance ? `/${instance}` : ''}/s/${id}?translate=1`} 835 + > 836 + <Icon icon="translate" /> 837 + <span>Translate</span> 838 + </MenuLink> 839 + {supportsTTS && ( 840 + <MenuItem 841 + onClick={() => { 842 + const postText = getPostText(status); 843 + if (postText) { 844 + speak(postText, language); 845 + } 846 + }} 847 + > 848 + <Icon icon="speak" /> 849 + <span>Speak</span> 850 + </MenuItem> 851 + )} 852 + </div> 707 853 ) 708 854 )} 709 855 {((!isSizeLarge && sameInstance) || enableTranslate) && <MenuDivider />} ··· 926 1072 enabled: hotkeysEnabled && canBoost, 927 1073 }, 928 1074 ); 1075 + const xRef = useHotkeys('x', (e) => { 1076 + const activeStatus = document.activeElement.closest( 1077 + '.status-link, .status-focus', 1078 + ); 1079 + if (activeStatus) { 1080 + const spoilerButton = activeStatus.querySelector( 1081 + 'button.spoiler:not(.spoiling)', 1082 + ); 1083 + if (spoilerButton) { 1084 + e.stopPropagation(); 1085 + spoilerButton.click(); 1086 + } 1087 + } 1088 + }); 929 1089 930 1090 const displayedMediaAttachments = mediaAttachments.slice( 931 1091 0, ··· 1074 1234 fRef.current = nodeRef; 1075 1235 dRef.current = nodeRef; 1076 1236 bRef.current = nodeRef; 1237 + xRef.current = nodeRef; 1077 1238 }} 1078 1239 tabindex="-1" 1079 1240 class={`status ${ ··· 1468 1629 forceTranslate={forceTranslate || inlineTranslate} 1469 1630 mini={!isSizeLarge && !withinContext} 1470 1631 sourceLanguage={language} 1471 - text={ 1472 - (spoilerText ? `${spoilerText}\n\n` : '') + 1473 - getHTMLText(content) + 1474 - (poll?.options?.length 1475 - ? `\n\nPoll:\n${poll.options 1476 - .map( 1477 - (option) => 1478 - `- ${option.title}${ 1479 - option.votesCount >= 0 1480 - ? ` (${option.votesCount})` 1481 - : '' 1482 - }`, 1483 - ) 1484 - .join('\n')}` 1485 - : '') 1486 - } 1632 + text={getPostText(status)} 1487 1633 /> 1488 1634 )} 1489 1635 {!spoilerText && sensitive && !!mediaAttachments.length && ( ··· 1542 1688 </MultipleMediaFigure> 1543 1689 )} 1544 1690 {!!card && 1545 - card?.url !== status.url && 1546 - card?.url !== status.uri && 1547 1691 /^https/i.test(card?.url) && 1548 1692 !sensitive && 1549 1693 !spoilerText && 1550 1694 !poll && 1551 1695 !mediaAttachments.length && 1552 1696 !snapStates.statusQuotes[sKey] && ( 1553 - <Card card={card} instance={currentInstance} /> 1697 + <Card 1698 + card={card} 1699 + selfReferential={ 1700 + card?.url === status.url || card?.url === status.uri 1701 + } 1702 + instance={currentInstance} 1703 + /> 1554 1704 )} 1555 1705 </div> 1556 1706 {!isSizeLarge && showCommentCount && ( ··· 1573 1723 <time 1574 1724 class="created" 1575 1725 datetime={createdAtDate.toISOString()} 1726 + title={createdAtDate.toLocaleString()} 1576 1727 > 1577 1728 {createdDateText} 1578 1729 </time> ··· 1722 1873 /> 1723 1874 </Modal> 1724 1875 )} 1725 - {showReactions && ( 1726 - <Modal 1727 - class="light" 1728 - onClick={(e) => { 1729 - if (e.target === e.currentTarget) { 1730 - setShowReactions(false); 1731 - } 1732 - }} 1733 - > 1734 - <ReactionsModal 1735 - statusID={id} 1736 - instance={instance} 1737 - onClose={() => setShowReactions(false)} 1738 - /> 1739 - </Modal> 1740 - )} 1741 1876 </article> 1742 1877 ); 1743 1878 } ··· 1755 1890 ); 1756 1891 } 1757 1892 1758 - function Card({ card, instance }) { 1893 + function Card({ card, selfReferential, instance }) { 1759 1894 const snapStates = useSnapshot(states); 1760 1895 const { 1761 1896 blurhash, ··· 1791 1926 const [cardStatusURL, setCardStatusURL] = useState(null); 1792 1927 // const [cardStatusID, setCardStatusID] = useState(null); 1793 1928 useEffect(() => { 1794 - if (hasText && image && isMastodonLinkMaybe(url)) { 1929 + if (hasText && image && !selfReferential && isMastodonLinkMaybe(url)) { 1795 1930 unfurlMastodonLink(instance, url).then((result) => { 1796 1931 if (!result) return; 1797 1932 const { id, url } = result; ··· 1806 1941 // })(); 1807 1942 }); 1808 1943 } 1809 - }, [hasText, image]); 1944 + }, [hasText, image, selfReferential]); 1810 1945 1811 1946 // if (cardStatusID) { 1812 1947 // return ( ··· 2009 2144 ); 2010 2145 } 2011 2146 2012 - const REACTIONS_LIMIT = 80; 2013 - function ReactionsModal({ statusID, instance, onClose }) { 2014 - const { masto } = api({ instance }); 2015 - const [uiState, setUIState] = useState('default'); 2016 - const [accounts, setAccounts] = useState([]); 2017 - const [showMore, setShowMore] = useState(false); 2018 - 2019 - const reblogIterator = useRef(); 2020 - const favouriteIterator = useRef(); 2021 - 2022 - async function fetchAccounts(firstLoad) { 2023 - setShowMore(false); 2024 - setUIState('loading'); 2025 - (async () => { 2026 - try { 2027 - if (firstLoad) { 2028 - reblogIterator.current = masto.v1.statuses 2029 - .$select(statusID) 2030 - .rebloggedBy.list({ 2031 - limit: REACTIONS_LIMIT, 2032 - }); 2033 - favouriteIterator.current = masto.v1.statuses 2034 - .$select(statusID) 2035 - .favouritedBy.list({ 2036 - limit: REACTIONS_LIMIT, 2037 - }); 2038 - } 2039 - const [{ value: reblogResults }, { value: favouriteResults }] = 2040 - await Promise.allSettled([ 2041 - reblogIterator.current.next(), 2042 - favouriteIterator.current.next(), 2043 - ]); 2044 - if (reblogResults.value?.length || favouriteResults.value?.length) { 2045 - if (reblogResults.value?.length) { 2046 - for (const account of reblogResults.value) { 2047 - const theAccount = accounts.find((a) => a.id === account.id); 2048 - if (!theAccount) { 2049 - accounts.push({ 2050 - ...account, 2051 - _types: ['reblog'], 2052 - }); 2053 - } else { 2054 - theAccount._types.push('reblog'); 2055 - } 2056 - } 2057 - } 2058 - if (favouriteResults.value?.length) { 2059 - for (const account of favouriteResults.value) { 2060 - const theAccount = accounts.find((a) => a.id === account.id); 2061 - if (!theAccount) { 2062 - accounts.push({ 2063 - ...account, 2064 - _types: ['favourite'], 2065 - }); 2066 - } else { 2067 - theAccount._types.push('favourite'); 2068 - } 2069 - } 2070 - } 2071 - setAccounts(accounts); 2072 - setShowMore(!reblogResults.done || !favouriteResults.done); 2073 - } else { 2074 - setShowMore(false); 2075 - } 2076 - setUIState('default'); 2077 - } catch (e) { 2078 - console.error(e); 2079 - setUIState('error'); 2080 - } 2081 - })(); 2082 - } 2083 - 2084 - useEffect(() => { 2085 - fetchAccounts(true); 2086 - }, []); 2087 - 2088 - return ( 2089 - <div id="reactions-container" class="sheet"> 2090 - {!!onClose && ( 2091 - <button type="button" class="sheet-close" onClick={onClose}> 2092 - <Icon icon="x" /> 2093 - </button> 2094 - )} 2095 - <header> 2096 - <h2>Boosted/Liked by…</h2> 2097 - </header> 2098 - <main> 2099 - {accounts.length > 0 ? ( 2100 - <> 2101 - <ul class="reactions-list"> 2102 - {accounts.map((account) => { 2103 - const { _types } = account; 2104 - return ( 2105 - <li key={account.id + _types}> 2106 - <div class="reactions-block"> 2107 - {_types.map((type) => ( 2108 - <Icon 2109 - icon={ 2110 - { 2111 - reblog: 'rocket', 2112 - favourite: 'heart', 2113 - }[type] 2114 - } 2115 - class={`${type}-icon`} 2116 - /> 2117 - ))} 2118 - </div> 2119 - <AccountBlock account={account} instance={instance} /> 2120 - </li> 2121 - ); 2122 - })} 2123 - </ul> 2124 - {uiState === 'default' ? ( 2125 - showMore ? ( 2126 - <InView 2127 - onChange={(inView) => { 2128 - if (inView) { 2129 - fetchAccounts(); 2130 - } 2131 - }} 2132 - > 2133 - <button 2134 - type="button" 2135 - class="plain block" 2136 - onClick={() => fetchAccounts()} 2137 - > 2138 - Show more&hellip; 2139 - </button> 2140 - </InView> 2141 - ) : ( 2142 - <p class="ui-state insignificant">The end.</p> 2143 - ) 2144 - ) : ( 2145 - uiState === 'loading' && ( 2146 - <p class="ui-state"> 2147 - <Loader abrupt /> 2148 - </p> 2149 - ) 2150 - )} 2151 - </> 2152 - ) : uiState === 'loading' ? ( 2153 - <p class="ui-state"> 2154 - <Loader abrupt /> 2155 - </p> 2156 - ) : uiState === 'error' ? ( 2157 - <p class="ui-state">Unable to load accounts</p> 2158 - ) : ( 2159 - <p class="ui-state insignificant">No one yet.</p> 2160 - )} 2161 - </main> 2162 - </div> 2163 - ); 2164 - } 2165 - 2166 2147 function StatusButton({ 2167 2148 checked, 2168 2149 count, ··· 2372 2353 2373 2354 const unfurlMastodonLink = throttle(_unfurlMastodonLink); 2374 2355 2375 - function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { 2356 + function FilteredStatus({ 2357 + status, 2358 + filterInfo, 2359 + instance, 2360 + containerProps = {}, 2361 + showFollowedTags, 2362 + }) { 2363 + const snapStates = useSnapshot(states); 2376 2364 const { 2377 2365 id: statusID, 2378 2366 account: { avatar, avatarStatic, bot, group }, ··· 2399 2387 ); 2400 2388 2401 2389 const statusPeekRef = useTruncated(); 2402 - const sKey = 2390 + const sKey = statusKey(status.id, instance); 2391 + const ssKey = 2403 2392 statusKey(status.id, instance) + 2404 2393 ' ' + 2405 2394 (statusKey(reblog?.id, instance) || ''); ··· 2408 2397 const url = instance 2409 2398 ? `/${instance}/s/${actualStatusID}` 2410 2399 : `/s/${actualStatusID}`; 2400 + const isFollowedTags = 2401 + showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length; 2411 2402 2412 2403 return ( 2413 2404 <div 2414 - class={isReblog ? (group ? 'status-group' : 'status-reblog') : ''} 2405 + class={ 2406 + isReblog 2407 + ? group 2408 + ? 'status-group' 2409 + : 'status-reblog' 2410 + : isFollowedTags 2411 + ? 'status-followed-tags' 2412 + : '' 2413 + } 2415 2414 {...containerProps} 2416 2415 title={statusPeekText} 2417 2416 onContextMenu={(e) => { ··· 2420 2419 }} 2421 2420 {...bindLongPressPeek()} 2422 2421 > 2423 - <article data-state-post-id={sKey} class="status filtered" tabindex="-1"> 2422 + <article data-state-post-id={ssKey} class="status filtered" tabindex="-1"> 2424 2423 <b 2425 2424 class="status-filtered-badge clickable badge-meta" 2426 2425 title={filterTitleStr} ··· 2443 2442 />{' '} 2444 2443 {isReblog ? ( 2445 2444 'boosted' 2445 + ) : isFollowedTags ? ( 2446 + <span> 2447 + {snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => ( 2448 + <span key={tag} class="status-followed-tag-item"> 2449 + #{tag} 2450 + </span> 2451 + ))} 2452 + </span> 2446 2453 ) : ( 2447 2454 <RelativeTime datetime={createdAtDate} format="micro" /> 2448 2455 )}
+19 -10
src/components/timeline.jsx
··· 5 5 import { useSnapshot } from 'valtio'; 6 6 7 7 import FilterContext from '../utils/filter-context'; 8 - import { isFiltered } from '../utils/filters'; 8 + import { filteredItems, isFiltered } from '../utils/filters'; 9 9 import states, { statusKey } from '../utils/states'; 10 10 import statusPeek from '../utils/status-peek'; 11 11 import { groupBoosts, groupContext } from '../utils/timeline-utils'; ··· 44 44 refresh, 45 45 view, 46 46 filterContext, 47 + showFollowedTags, 47 48 }) { 48 49 const snapStates = useSnapshot(states); 49 50 const [items, setItems] = useState([]); ··· 391 392 filterContext={filterContext} 392 393 key={status.id + status?._pinned + view} 393 394 view={view} 395 + showFollowedTags={showFollowedTags} 394 396 /> 395 397 ))} 396 398 {showMore && ··· 478 480 // allowFilters, 479 481 filterContext, 480 482 view, 483 + showFollowedTags, 481 484 }) { 482 485 const { id: statusID, reblog, items, type, _pinned } = status; 483 486 if (_pinned) useItemID = false; ··· 493 496 } 494 497 const isCarousel = type === 'boosts' || type === 'pinned'; 495 498 if (items) { 499 + const fItems = filteredItems(items, filterContext); 496 500 if (isCarousel) { 497 501 // Here, we don't hide filtered posts, but we sort them last 498 - items.sort((a, b) => { 502 + fItems.sort((a, b) => { 499 503 // if (a._filtered && !b._filtered) { 500 504 // return 1; 501 505 // } ··· 515 519 return ( 516 520 <li key={`timeline-${statusID}`} class="timeline-item-carousel"> 517 521 <StatusCarousel title={title} class={`${type}-carousel`}> 518 - {items.map((item) => { 522 + {fItems.map((item) => { 519 523 const { id: statusID, reblog, _pinned } = item; 520 524 const actualStatusID = reblog?.id || statusID; 521 525 const url = instance ··· 552 556 </li> 553 557 ); 554 558 } 555 - const manyItems = items.length > 3; 556 - return items.map((item, i) => { 559 + const manyItems = fItems.length > 3; 560 + return fItems.map((item, i) => { 557 561 const { id: statusID, _differentAuthor } = item; 558 562 const url = instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`; 559 - const isMiddle = i > 0 && i < items.length - 1; 563 + const isMiddle = i > 0 && i < fItems.length - 1; 560 564 const isSpoiler = item.sensitive && !!item.spoilerText; 561 565 const showCompact = 562 566 (!_differentAuthor && isSpoiler && i > 0) || ··· 565 569 (type === 'thread' || 566 570 (type === 'conversation' && 567 571 !_differentAuthor && 568 - !items[i - 1]._differentAuthor && 569 - !items[i + 1]._differentAuthor))); 570 - const isEnd = i === items.length - 1; 572 + !fItems[i - 1]._differentAuthor && 573 + !fItems[i + 1]._differentAuthor))); 574 + const isStart = i === 0; 575 + const isEnd = i === fItems.length - 1; 571 576 return ( 572 577 <li 573 578 key={`timeline-${statusID}`} 574 579 class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${ 575 - i === 0 ? 'start' : isEnd ? 'end' : 'middle' 580 + isStart ? 'start' : isEnd ? 'end' : 'middle' 576 581 } ${_differentAuthor ? 'timeline-item-diff-author' : ''}`} 577 582 > 578 583 <Link class="status-link timeline-item" to={url}> ··· 583 588 statusID={statusID} 584 589 instance={instance} 585 590 enableCommentHint={isEnd} 591 + showFollowedTags={showFollowedTags} 586 592 // allowFilters={allowFilters} 587 593 /> 588 594 ) : ( ··· 590 596 status={item} 591 597 instance={instance} 592 598 enableCommentHint={isEnd} 599 + showFollowedTags={showFollowedTags} 593 600 // allowFilters={allowFilters} 594 601 /> 595 602 )} ··· 631 638 statusID={statusID} 632 639 instance={instance} 633 640 enableCommentHint 641 + showFollowedTags={showFollowedTags} 634 642 // allowFilters={allowFilters} 635 643 /> 636 644 ) : ( ··· 638 646 status={status} 639 647 instance={instance} 640 648 enableCommentHint 649 + showFollowedTags={showFollowedTags} 641 650 // allowFilters={allowFilters} 642 651 /> 643 652 )}
+11
src/index.css
··· 54 54 --reply-to-text-color: #b36200; 55 55 --favourite-color: var(--red-color); 56 56 --reply-to-faded-color: #ffa60020; 57 + --hashtag-color: LightSeaGreen; 58 + --hashtag-faded-color: color-mix( 59 + in srgb, 60 + var(--hashtag-color) 15%, 61 + transparent 62 + ); 63 + --hashtag-text-color: color-mix( 64 + in lch, 65 + var(--hashtag-color) 40%, 66 + var(--text-color) 60% 67 + ); 57 68 --outline-color: rgba(128, 128, 128, 0.2); 58 69 --outline-hover-color: rgba(128, 128, 128, 0.7); 59 70 --divider-color: rgba(0, 0, 0, 0.1);
+2 -13
src/pages/followed-hashtags.jsx
··· 5 5 import Loader from '../components/loader'; 6 6 import NavMenu from '../components/nav-menu'; 7 7 import { api } from '../utils/api'; 8 + import { fetchFollowedTags } from '../utils/followed-tags'; 8 9 import useTitle from '../utils/useTitle'; 9 - 10 - const LIMIT = 200; 11 10 12 11 function FollowedHashtags() { 13 12 const { masto, instance } = api(); ··· 19 18 setUIState('loading'); 20 19 (async () => { 21 20 try { 22 - const iterator = masto.v1.followedTags.list({ 23 - limit: LIMIT, 24 - }); 25 - const tags = []; 26 - do { 27 - const { value, done } = await iterator.next(); 28 - if (done || value?.length === 0) break; 29 - tags.push(...value); 30 - } while (true); 31 - tags.sort((a, b) => a.name.localeCompare(b.name)); 32 - console.log(tags); 21 + const tags = await fetchFollowedTags(); 33 22 setFollowedHashtags(tags); 34 23 setUIState('default'); 35 24 } catch (e) {
+12 -1
src/pages/following.jsx
··· 6 6 import { filteredItems } from '../utils/filters'; 7 7 import states from '../utils/states'; 8 8 import { getStatus, saveStatus } from '../utils/states'; 9 - import { dedupeBoosts } from '../utils/timeline-utils'; 9 + import { 10 + assignFollowedTags, 11 + clearFollowedTagsState, 12 + dedupeBoosts, 13 + } from '../utils/timeline-utils'; 10 14 import useTitle from '../utils/useTitle'; 11 15 12 16 const LIMIT = 20; ··· 27 31 const results = await homeIterator.current.next(); 28 32 let { value } = results; 29 33 if (value?.length) { 34 + let latestItemChanged = false; 30 35 if (firstLoad) { 36 + if (value[0].id !== latestItem.current) { 37 + latestItemChanged = true; 38 + } 31 39 latestItem.current = value[0].id; 32 40 console.log('First load', latestItem.current); 33 41 } ··· 37 45 saveStatus(item, instance); 38 46 }); 39 47 value = dedupeBoosts(value, instance); 48 + if (firstLoad && latestItemChanged) clearFollowedTagsState(); 49 + assignFollowedTags(value, instance); 40 50 41 51 // ENFORCE sort by datetime (Latest first) 42 52 value.sort((a, b) => { ··· 118 128 {...props} 119 129 // allowFilters 120 130 filterContext="home" 131 + showFollowedTags 121 132 /> 122 133 ); 123 134 }
+1
src/pages/home.jsx
··· 179 179 key={notification.id} 180 180 instance={instance} 181 181 notification={notification} 182 + disableContextMenu 182 183 /> 183 184 ))} 184 185 </>
+2 -3
src/pages/http-route.jsx
··· 21 21 useLayoutEffect(() => { 22 22 setUIState('loading'); 23 23 (async () => { 24 - const { instance, id } = statusObject; 25 - const { masto } = api({ instance }); 26 - 27 24 // Check if status returns 200 28 25 try { 26 + const { instance, id } = statusObject; 27 + const { masto } = api({ instance }); 29 28 const status = await masto.v1.statuses.$select(id).fetch(); 30 29 if (status) { 31 30 window.location.hash = statusURL + '?view=full';
+45 -12
src/pages/search.css
··· 1 1 #search-page .deck > header .header-grid { 2 2 grid-template-columns: auto 1fr auto; 3 3 } 4 - #search-page header input { 5 - width: 100%; 6 - padding: 8px 16px; 7 - border: 0; 8 - border-radius: 999px; 9 - background-color: var(--bg-faded-color); 10 - border: 2px solid transparent; 4 + #search-page header { 5 + input { 6 + width: 100%; 7 + padding: 8px 16px; 8 + border: 0; 9 + border-radius: 999px; 10 + background-color: var(--bg-faded-color); 11 + border: 2px solid transparent; 12 + 13 + &:focus { 14 + outline: 0; 15 + background-color: var(--bg-color); 16 + border-color: var(--link-color); 17 + } 18 + 19 + #columns & { 20 + font-weight: bold; 21 + background-color: transparent; 22 + text-align: center; 23 + padding-inline: 8px; 24 + text-overflow: ellipsis; 25 + } 26 + } 11 27 } 12 - #search-page header input:focus { 13 - outline: 0; 14 - background-color: var(--bg-color); 15 - border-color: var(--link-color); 28 + 29 + #columns #search-page { 30 + .header-grid { 31 + .header-side { 32 + min-width: 40px; 33 + 34 + &:last-of-type { 35 + button { 36 + display: block; 37 + 38 + &:not(:hover, :focus) { 39 + color: var(--text-insignificant-color); 40 + } 41 + } 42 + } 43 + } 44 + } 16 45 } 17 46 18 47 #search-page ul.accounts-list { ··· 24 53 display: flex; 25 54 padding: 8px 16px; 26 55 gap: 8px; 27 - align-items: center; 56 + /* align-items: center; */ 28 57 flex-grow: 1; 58 + 59 + .account-block { 60 + align-items: flex-start; 61 + } 29 62 } 30 63 31 64 ul.link-list.hashtag-list {
+59 -7
src/pages/search.jsx
··· 14 14 import SearchForm from '../components/search-form'; 15 15 import Status from '../components/status'; 16 16 import { api } from '../utils/api'; 17 + import { fetchRelationships } from '../utils/relationships'; 17 18 import shortenNumber from '../utils/shorten-number'; 19 + import usePageVisibility from '../utils/usePageVisibility'; 20 + import useScroll from '../utils/useScroll'; 18 21 import useTitle from '../utils/useTitle'; 19 22 20 23 const SHORT_LIMIT = 5; 21 24 const LIMIT = 40; 25 + const emptySearchParams = new URLSearchParams(); 22 26 23 - function Search(props) { 24 - const params = useParams(); 27 + function Search({ columnMode, ...props }) { 28 + const params = columnMode ? {} : useParams(); 25 29 const { masto, instance, authenticated } = api({ 26 30 instance: params.instance, 27 31 }); 28 32 const [uiState, setUIState] = useState('default'); 29 - const [searchParams] = useSearchParams(); 33 + const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams(); 30 34 const searchFormRef = useRef(); 31 35 const q = props?.query || searchParams.get('q'); 32 - const type = props?.type || searchParams.get('type'); 36 + const type = columnMode 37 + ? 'statuses' 38 + : props?.type || searchParams.get('type'); 33 39 useTitle( 34 40 q 35 41 ? `Search: ${q}${ ··· 72 78 hashtags: setHashtagResults, 73 79 }; 74 80 81 + const [relationshipsMap, setRelationshipsMap] = useState({}); 82 + const loadRelationships = async (accounts) => { 83 + if (!accounts?.length) return; 84 + const relationships = await fetchRelationships(accounts, relationshipsMap); 85 + if (relationships) { 86 + setRelationshipsMap({ 87 + ...relationshipsMap, 88 + ...relationships, 89 + }); 90 + } 91 + }; 92 + 75 93 function loadResults(firstLoad) { 94 + if (firstLoad) { 95 + offsetRef.current = 0; 96 + } 97 + 76 98 if (!firstLoad && !authenticated) { 77 99 // Search results pagination is only available to authenticated users 78 100 return; ··· 119 141 offsetRef.current = 0; 120 142 setShowMore(false); 121 143 } 144 + loadRelationships(results.accounts); 145 + 122 146 setUIState('default'); 123 147 } catch (err) { 124 148 console.error(err); ··· 127 151 })(); 128 152 } 129 153 154 + const { reachStart } = useScroll({ 155 + scrollableRef, 156 + }); 157 + const lastHiddenTime = useRef(); 158 + usePageVisibility((visible) => { 159 + if (visible && reachStart) { 160 + const timeDiff = Date.now() - lastHiddenTime.current; 161 + if (!lastHiddenTime.current || timeDiff > 1000 * 3) { 162 + // 3 seconds 163 + loadResults(true); 164 + } else { 165 + lastHiddenTime.current = Date.now(); 166 + } 167 + } 168 + }); 169 + 130 170 useEffect(() => { 171 + searchFormRef.current?.setValue?.(q || ''); 131 172 if (q) { 132 - searchFormRef.current?.setValue?.(q); 133 173 loadResults(true); 134 174 } else { 135 175 searchFormRef.current?.focus?.(); ··· 157 197 <NavMenu /> 158 198 </div> 159 199 <SearchForm ref={searchFormRef} /> 160 - <div class="header-side">&nbsp;</div> 200 + <div class="header-side"> 201 + <button 202 + type="button" 203 + class="plain" 204 + onClick={() => { 205 + loadResults(true); 206 + }} 207 + disabled={uiState === 'loading'} 208 + > 209 + <Icon icon="search" size="l" /> 210 + </button> 211 + </div> 161 212 </div> 162 213 </header> 163 214 <main> 164 - {!!q && ( 215 + {!!q && !columnMode && ( 165 216 <div 166 217 ref={filterBarParent} 167 218 class={`filter-bar ${uiState === 'loading' ? 'loading' : ''}`} ··· 216 267 account={account} 217 268 instance={instance} 218 269 showStats 270 + relationship={relationshipsMap[account.id]} 219 271 /> 220 272 </li> 221 273 ))}
+62
src/utils/followed-tags.js
··· 1 + import { api } from '../utils/api'; 2 + import store from '../utils/store'; 3 + 4 + const LIMIT = 200; 5 + const MAX_FETCH = 10; 6 + 7 + export async function fetchFollowedTags() { 8 + const { masto } = api(); 9 + const iterator = masto.v1.followedTags.list({ 10 + limit: LIMIT, 11 + }); 12 + const tags = []; 13 + let fetchCount = 0; 14 + do { 15 + const { value, done } = await iterator.next(); 16 + if (done || value?.length === 0) break; 17 + tags.push(...value); 18 + fetchCount++; 19 + } while (fetchCount < MAX_FETCH); 20 + tags.sort((a, b) => a.name.localeCompare(b.name)); 21 + console.log(tags); 22 + 23 + if (tags.length) { 24 + setTimeout(() => { 25 + // Save to local storage, with saved timestamp 26 + store.account.set('followedTags', { 27 + tags, 28 + updatedAt: Date.now(), 29 + }); 30 + }, 1); 31 + } 32 + 33 + return tags; 34 + } 35 + 36 + const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day 37 + export async function getFollowedTags() { 38 + try { 39 + const { tags, updatedAt } = store.account.get('followedTags') || {}; 40 + if (!tags?.length) return await fetchFollowedTags(); 41 + if (Date.now() - updatedAt > MAX_AGE) { 42 + // Stale-while-revalidate 43 + fetchFollowedTags(); 44 + return tags; 45 + } 46 + return tags; 47 + } catch (e) { 48 + return []; 49 + } 50 + } 51 + 52 + const fauxDiv = document.createElement('div'); 53 + export const extractTagsFromStatus = (content) => { 54 + if (!content) return []; 55 + if (content.indexOf('#') === -1) return []; 56 + fauxDiv.innerHTML = content; 57 + const hashtagLinks = fauxDiv.querySelectorAll('a.hashtag'); 58 + if (!hashtagLinks.length) return []; 59 + return Array.from(hashtagLinks).map((a) => 60 + a.innerText.trim().replace(/^[^#]*#+/, ''), 61 + ); 62 + };
+5
src/utils/html-content-length.js
··· 2 2 export default function htmlContentLength(html) { 3 3 if (!html) return 0; 4 4 div.innerHTML = html; 5 + // .invisible spans for links 6 + // e.g. <span class="invisible">https://</span>mastodon.social 7 + div.querySelectorAll('.invisible').forEach((el) => { 8 + el.remove(); 9 + }); 5 10 return div.innerText.length; 6 11 }
+26
src/utils/ratelimit.js
··· 1 + // Rate limit repeated function calls and queue them to set interval 2 + export default function rateLimit(fn, interval) { 3 + let queue = []; 4 + let isRunning = false; 5 + 6 + function executeNext() { 7 + if (queue.length === 0) { 8 + isRunning = false; 9 + return; 10 + } 11 + 12 + const nextFn = queue.shift(); 13 + nextFn(); 14 + setTimeout(executeNext, interval); 15 + } 16 + 17 + return function (...args) { 18 + const callFn = () => fn.apply(this, args); 19 + queue.push(callFn); 20 + 21 + if (!isRunning) { 22 + isRunning = true; 23 + setTimeout(executeNext, interval); 24 + } 25 + }; 26 + }
+38
src/utils/relationships.js
··· 1 + import { api } from './api'; 2 + import store from './store'; 3 + 4 + export async function fetchRelationships(accounts, relationshipsMap = {}) { 5 + if (!accounts?.length) return; 6 + const { masto } = api(); 7 + 8 + const currentAccount = store.session.get('currentAccount'); 9 + const uniqueAccountIds = accounts.reduce((acc, a) => { 10 + // 1. Ignore duplicate accounts 11 + // 2. Ignore accounts that are already inside relationshipsMap 12 + // 3. Ignore currently logged in account 13 + if ( 14 + !acc.includes(a.id) && 15 + !relationshipsMap[a.id] && 16 + a.id !== currentAccount 17 + ) { 18 + acc.push(a.id); 19 + } 20 + return acc; 21 + }, []); 22 + if (!uniqueAccountIds.length) return null; 23 + 24 + try { 25 + const relationships = await masto.v1.accounts.relationships.fetch({ 26 + id: uniqueAccountIds, 27 + }); 28 + const newRelationshipsMap = relationships.reduce((acc, r) => { 29 + acc[r.id] = r; 30 + return acc; 31 + }, {}); 32 + return newRelationshipsMap; 33 + } catch (e) { 34 + console.error(e); 35 + // It's okay to fail 36 + return null; 37 + } 38 + }
+15
src/utils/speech.js
··· 1 + export const supportsTTS = 'speechSynthesis' in window; 2 + 3 + export function speak(text, lang) { 4 + if (!supportsTTS) return; 5 + try { 6 + if (speechSynthesis.speaking) { 7 + speechSynthesis.cancel(); 8 + } 9 + const utterance = new SpeechSynthesisUtterance(text); 10 + if (lang) utterance.lang = lang; 11 + speechSynthesis.speak(utterance); 12 + } catch (e) { 13 + alert(e); 14 + } 15 + }
+4 -1
src/utils/states.js
··· 3 3 4 4 import { api } from './api'; 5 5 import pmem from './pmem'; 6 + import rateLimit from './ratelimit'; 6 7 import store from './store'; 7 8 8 9 const states = proxy({ ··· 31 32 scrollPositions: {}, 32 33 unfurledLinks: {}, 33 34 statusQuotes: {}, 35 + statusFollowedTags: {}, 34 36 accounts: {}, 35 37 routeNotification: null, 36 38 // Modals ··· 186 188 } 187 189 } 188 190 189 - export function threadifyStatus(status, propInstance) { 191 + function _threadifyStatus(status, propInstance) { 190 192 const { masto, instance } = api({ instance: propInstance }); 191 193 // Return all statuses in the thread, via inReplyToId, if inReplyToAccountId === account.id 192 194 let fetchIndex = 0; ··· 225 227 console.error(e, status); 226 228 }); 227 229 } 230 + export const threadifyStatus = rateLimit(_threadifyStatus, 100); 228 231 229 232 const fetchStatus = pmem((statusID, masto) => { 230 233 return masto.v1.statuses.$select(statusID).fetch();
+54
src/utils/timeline-utils.jsx
··· 1 + import { extractTagsFromStatus, getFollowedTags } from './followed-tags'; 2 + import { fetchRelationships } from './relationships'; 3 + import states, { statusKey } from './states'; 1 4 import store from './store'; 2 5 3 6 export function groupBoosts(values) { ··· 175 178 176 179 return newItems; 177 180 } 181 + 182 + export async function assignFollowedTags(items, instance) { 183 + const followedTags = await getFollowedTags(); // [{name: 'tag'}, {...}] 184 + if (!followedTags.length) return; 185 + const { statusFollowedTags } = states; 186 + console.log('statusFollowedTags', statusFollowedTags); 187 + const statusWithFollowedTags = []; 188 + items.forEach((item) => { 189 + if (item.reblog) return; 190 + const { id, content, tags = [] } = item; 191 + const sKey = statusKey(id, instance); 192 + if (statusFollowedTags[sKey]?.length) return; 193 + const extractedTags = extractTagsFromStatus(content); 194 + if (!extractedTags.length && !tags.length) return; 195 + const itemFollowedTags = followedTags.reduce((acc, tag) => { 196 + if ( 197 + extractedTags.some((t) => t.toLowerCase() === tag.name.toLowerCase()) || 198 + tags.some((t) => t.name.toLowerCase() === tag.name.toLowerCase()) 199 + ) { 200 + acc.push(tag.name); 201 + } 202 + return acc; 203 + }, []); 204 + if (itemFollowedTags.length) { 205 + // statusFollowedTags[sKey] = itemFollowedTags; 206 + statusWithFollowedTags.push({ 207 + item, 208 + sKey, 209 + followedTags: itemFollowedTags, 210 + }); 211 + } 212 + }); 213 + 214 + if (statusWithFollowedTags.length) { 215 + const accounts = statusWithFollowedTags.map((s) => s.item.account); 216 + const relationships = await fetchRelationships(accounts); 217 + if (!relationships) return; 218 + 219 + statusWithFollowedTags.forEach((s) => { 220 + const { item, sKey, followedTags } = s; 221 + const r = relationships[item.account.id]; 222 + if (!r.following) { 223 + statusFollowedTags[sKey] = followedTags; 224 + } 225 + }); 226 + } 227 + } 228 + 229 + export function clearFollowedTagsState() { 230 + states.statusFollowedTags = {}; 231 + }
+14
src/utils/useCloseWatcher.js
··· 1 + import { useEffect } from 'preact/hooks'; 2 + 3 + function useCloseWatcher(fn, deps = []) { 4 + if (!fn || typeof fn !== 'function') return; 5 + useEffect(() => { 6 + const watcher = new CloseWatcher(); 7 + watcher.addEventListener('close', fn); 8 + return () => { 9 + watcher.destroy(); 10 + }; 11 + }, deps); 12 + } 13 + 14 + export default window.CloseWatcher ? useCloseWatcher : () => {};