commit
65d51b077f
|
@ -138,7 +138,7 @@ Download or `git clone` this repository. Use `production` branch for *stable* re
|
|||
Customization can be done by passing environment variables to the build command. Examples:
|
||||
|
||||
```bash
|
||||
PHANPY_APP_TITLE="Phanpy Dev" \
|
||||
PHANPY_CLIENT_NAME="Phanpy Dev" \
|
||||
PHANPY_WEBSITE="https://dev.phanpy.social" \
|
||||
npm run build
|
||||
```
|
||||
|
@ -179,6 +179,10 @@ Available variables:
|
|||
- May specify a self-hosted Lingva instance, powered by either [lingva-translate](https://github.com/thedaviddelta/lingva-translate) or [lingva-api](https://github.com/cheeaun/lingva-api)
|
||||
- List of fallback instances hard-coded in `/.env`
|
||||
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances)
|
||||
- `PHANPY_GIPHY_API_KEY` (optional, no defaults):
|
||||
- API key for [GIPHY](https://developers.giphy.com/). See [API docs](https://developers.giphy.com/docs/api/).
|
||||
- If provided, a setting will appear for users to enable the GIF picker in the composer. Disabled by default.
|
||||
- This is not self-hosted.
|
||||
|
||||
### Static site hosting
|
||||
|
||||
|
|
9
package-lock.json
generated
9
package-lock.json
generated
|
@ -30,6 +30,7 @@
|
|||
"p-retry": "~6.2.0",
|
||||
"p-throttle": "~6.1.0",
|
||||
"preact": "~10.20.1",
|
||||
"punycode": "~2.3.1",
|
||||
"react-hotkeys-hook": "~4.5.0",
|
||||
"react-intersection-observer": "~9.8.1",
|
||||
"react-quick-pinch-zoom": "~5.1.0",
|
||||
|
@ -7154,11 +7155,9 @@
|
|||
"integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
|
||||
"integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
"p-retry": "~6.2.0",
|
||||
"p-throttle": "~6.1.0",
|
||||
"preact": "~10.20.1",
|
||||
"punycode": "~2.3.1",
|
||||
"react-hotkeys-hook": "~4.5.0",
|
||||
"react-intersection-observer": "~9.8.1",
|
||||
"react-quick-pinch-zoom": "~5.1.0",
|
||||
|
|
|
@ -295,7 +295,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
video,
|
||||
img,
|
||||
audio {
|
||||
min-height: var(--pointer-min-dimension); /* for extreme dimensions */
|
||||
min-height: var(--min-dimension); /* for extreme dimensions */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
21
src/app.jsx
21
src/app.jsx
|
@ -1,7 +1,6 @@
|
|||
import './app.css';
|
||||
|
||||
import debounce from 'just-debounce-it';
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
|
@ -18,14 +17,14 @@ import ComposeButton from './components/compose-button';
|
|||
import { ICONS } from './components/ICONS';
|
||||
import KeyboardShortcutsHelp from './components/keyboard-shortcuts-help';
|
||||
import Loader from './components/loader';
|
||||
// import Modals from './components/modals';
|
||||
import Modals from './components/modals';
|
||||
import NotificationService from './components/notification-service';
|
||||
import SearchCommand from './components/search-command';
|
||||
import Shortcuts from './components/shortcuts';
|
||||
import NotFound from './pages/404';
|
||||
import AccountStatuses from './pages/account-statuses';
|
||||
import Bookmarks from './pages/bookmarks';
|
||||
// import Catchup from './pages/catchup';
|
||||
import Catchup from './pages/catchup';
|
||||
import Favourites from './pages/favourites';
|
||||
import Filters from './pages/filters';
|
||||
import FollowedHashtags from './pages/followed-hashtags';
|
||||
|
@ -57,9 +56,6 @@ import store from './utils/store';
|
|||
import { getCurrentAccount } from './utils/store-utils';
|
||||
import './utils/toast-alert';
|
||||
|
||||
const Catchup = lazy(() => import('./pages/catchup'));
|
||||
const Modals = lazy(() => import('./components/modals'));
|
||||
|
||||
window.__STATES__ = states;
|
||||
window.__STATES_STATS__ = () => {
|
||||
const keys = [
|
||||
|
@ -387,9 +383,7 @@ function App() {
|
|||
)}
|
||||
{isLoggedIn && <ComposeButton />}
|
||||
{isLoggedIn && <Shortcuts />}
|
||||
<Suspense>
|
||||
<Modals />
|
||||
</Suspense>
|
||||
<Modals />
|
||||
{isLoggedIn && <NotificationService />}
|
||||
<BackgroundService isLoggedIn={isLoggedIn} />
|
||||
{uiState !== 'loading' && <SearchCommand onClose={focusDeck} />}
|
||||
|
@ -466,14 +460,7 @@ function SecondaryRoutes({ isLoggedIn }) {
|
|||
</Route>
|
||||
<Route path="/fh" element={<FollowedHashtags />} />
|
||||
<Route path="/ft" element={<Filters />} />
|
||||
<Route
|
||||
path="/catchup"
|
||||
element={
|
||||
<Suspense>
|
||||
<Catchup />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/catchup" element={<Catchup />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/:instance?/t/:hashtag" element={<Hashtag />} />
|
||||
|
|
3
src/assets/powered-by-giphy.svg
Normal file
3
src/assets/powered-by-giphy.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 641 223">
|
||||
<path fill="#aaa" d="M86 214c-9-1-17-4-24-8l-6-3-5-5-5-4-4-6-4-6-3-8-2-8v-27l2-9 3-9 4-6 4-6 5-5 5-5 7-3 6-4 7-2 7-2 12-1h12l7 1 8 2 7 4 7 3 5 5 5 4-10 10-10 9-4-3-10-5-5-1H88l-5 2-6 3-3 4-4 4-2 5-2 6v6l-1 7 1 7 2 7 3 5 2 4 4 3 4 3 5 2 6 2h9l10-1 5-2 6-3v-16H91v-27h59v54l-1 3-2 3-5 4-4 4-5 3-5 2-8 2-8 2-10 1H92l-6-1zm266-62V91h34v46h44V91h34v121h-34v-46h-44v46h-34v-61zm-182-1V90h34v121h-34v-60zm59-1V90h35l36 1 5 2c3 0 8 2 10 4l5 2 4 5 5 4 3 7 3 7 1 13v13l-4 6-3 7-4 4-5 5-5 2-5 3-6 2-5 1-18 1h-18v32h-34v-61zm67-2 3-2 2-4 2-5v-5l-2-4-2-4-3-2-3-3h-30v31h30l3-2zm226 39v-24l-8-12-18-28a1751 1751 0 0 0-20-31v-2h39l7 12 12 21 6 9 13-21 13-21h38v2l-41 61-7 10v48h-34v-24zM109 66l-4-1-5-5-5-4-1-5-3-9v-5l1-5c2-7 3-10 8-15l4-4 7-2 7-2h7l6 1 5 2 5 2 3 4 4 3 2 6 2 5v13l-2 5-2 6-4 4-3 3-5 2-4 2-9 1h-9l-5-2zm22-11 4-2 3-4 2-5V34l-2-4-2-4-3-2-4-3-5-1h-6l-4 2-5 2-2 4-3 5-1 3v4l1 5 2 5 2 2 5 3 4 2h10l4-2zM37 39V11h33l3 1 3 2 4 3 3 3 1 5 1 4v5l-1 4-3 4-3 5-4 1-3 2-11 1H49v16H37V39zm31 0 3-2 1-2 1-2v-4l-1-3-3-2-2-2H49v18h15l4-1zm107 25a512 512 0 0 0-19-53h14l4 14 6 19 1 4 1-1 7-19 5-17h9l6 19 7 18v-1l2-6 5-17 4-13h14v1l-4 12-16 41v2h-5l-5-1-6-15-6-15-1 1-3 7-6 15-2 8h-11l-1-3zm74-25V11h42v11h-29v2l-1 5v4h29v11h-28v11h2l15 1h13v11h-43V39zm55 0V11h33l5 3 5 2 2 4 2 5v10l-2 3-1 4-5 3-5 3 5 5 8 10 3 4h-14l-7-9-8-10h-9v19h-12V39zm33-3 2-3v-6l-3-3-2-3h-18v16h1v1h17l2-2zm26 3V11h42v11h-29l-1 6v5h29v11h-28v5l-1 5 1 1v1h30v11h-43V39zm54 0V11h17l18 1 4 2 5 3 2 4 3 4 2 6 1 6v5c-1 6-3 12-6 15l-3 4-5 3-5 2-17 1h-16V39zm33 14 5-5 2-3v-6l-1-6-1-3-1-3-4-3-3-2h-5l-6-1-3 1h-3v34h9l8-1 3-2zm50-14V11h34l5 2 4 2 2 3 2 3v9l-2 2-3 4-1 1 3 3 3 4 1 3 1 4-1 4-1 4-3 3-3 3-5 1-5 1h-31V39zm34 15 2-1v-6l-2-2-2-2h-20v13h20l2-2zm-3-22 4-2v-6l-2-1-2-2h-19v12h16l4-1zm42 24V45l-6-9-11-17-5-8h15l4 8 7 11 2 3 7-11 7-11h14l-11 16-11 17v23h-12V56z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
|
@ -107,4 +107,5 @@ export const ICONS = {
|
|||
quote: () => import('@iconify-icons/mingcute/quote-left-line'),
|
||||
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
|
||||
'heart-break': () => import('@iconify-icons/mingcute/heart-crack-line'),
|
||||
'user-x': () => import('@iconify-icons/mingcute/user-x-line'),
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import './account-info.css';
|
||||
|
||||
import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu';
|
||||
import { MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
@ -9,6 +9,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
|
||||
import { api } from '../utils/api';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
|
@ -32,7 +33,9 @@ import ListAddEdit from './list-add-edit';
|
|||
import Loader from './loader';
|
||||
import Menu2 from './menu2';
|
||||
import MenuConfirm from './menu-confirm';
|
||||
import MenuLink from './menu-link';
|
||||
import Modal from './modal';
|
||||
import SubMenu2 from './submenu2';
|
||||
import TranslationBlock from './translation-block';
|
||||
|
||||
const MUTE_DURATIONS = [
|
||||
|
@ -228,7 +231,7 @@ function AccountInfo({
|
|||
|
||||
const accountInstance = useMemo(() => {
|
||||
if (!url) return null;
|
||||
const domain = new URL(url).hostname;
|
||||
const domain = punycode.toUnicode(new URL(url).hostname);
|
||||
return domain;
|
||||
}, [url]);
|
||||
|
||||
|
@ -581,6 +584,15 @@ function AccountInfo({
|
|||
<Icon icon="external" />
|
||||
<span>Go to original profile page</span>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuLink href={info.avatar} target="_blank">
|
||||
<Icon icon="user" />
|
||||
<span>View profile image</span>
|
||||
</MenuLink>
|
||||
<MenuLink href={info.header} target="_blank">
|
||||
<Icon icon="media" />
|
||||
<span>View profile header</span>
|
||||
</MenuLink>
|
||||
</Menu2>
|
||||
) : (
|
||||
<AccountBlock
|
||||
|
@ -659,6 +671,7 @@ function AccountInfo({
|
|||
// states.showAccount = false;
|
||||
setTimeout(() => {
|
||||
states.showGenericAccounts = {
|
||||
id: 'followers',
|
||||
heading: 'Followers',
|
||||
fetchAccounts: fetchFollowers,
|
||||
instance,
|
||||
|
@ -809,38 +822,40 @@ function AccountInfo({
|
|||
</div>
|
||||
</LinkOrDiv>
|
||||
)}
|
||||
<div class="account-metadata-box">
|
||||
<div
|
||||
class="shazam-container no-animation"
|
||||
hidden={!!postingStats}
|
||||
>
|
||||
<div class="shazam-container-inner">
|
||||
<button
|
||||
type="button"
|
||||
class="posting-stats-button"
|
||||
disabled={postingStatsUIState === 'loading'}
|
||||
onClick={() => {
|
||||
renderPostingStats();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class={`posting-stats-bar posting-stats-icon ${
|
||||
postingStatsUIState === 'loading' ? 'loading' : ''
|
||||
}`}
|
||||
style={{
|
||||
'--originals-percentage': '33%',
|
||||
'--replies-percentage': '66%',
|
||||
{!moved && (
|
||||
<div class="account-metadata-box">
|
||||
<div
|
||||
class="shazam-container no-animation"
|
||||
hidden={!!postingStats}
|
||||
>
|
||||
<div class="shazam-container-inner">
|
||||
<button
|
||||
type="button"
|
||||
class="posting-stats-button"
|
||||
disabled={postingStatsUIState === 'loading'}
|
||||
onClick={() => {
|
||||
renderPostingStats();
|
||||
}}
|
||||
/>
|
||||
View post stats{' '}
|
||||
{/* <Loader
|
||||
>
|
||||
<div
|
||||
class={`posting-stats-bar posting-stats-icon ${
|
||||
postingStatsUIState === 'loading' ? 'loading' : ''
|
||||
}`}
|
||||
style={{
|
||||
'--originals-percentage': '33%',
|
||||
'--replies-percentage': '66%',
|
||||
}}
|
||||
/>
|
||||
View post stats{' '}
|
||||
{/* <Loader
|
||||
abrupt
|
||||
hidden={postingStatsUIState !== 'loading'}
|
||||
/> */}
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<footer>
|
||||
<RelatedActions
|
||||
|
@ -939,7 +954,7 @@ function RelatedActions({
|
|||
|
||||
accountID.current = currentID;
|
||||
|
||||
if (moved) return;
|
||||
// if (moved) return;
|
||||
|
||||
setRelationshipUIState('loading');
|
||||
|
||||
|
@ -1270,7 +1285,7 @@ function RelatedActions({
|
|||
<span>Unmute @{username}</span>
|
||||
</MenuItem>
|
||||
) : (
|
||||
<SubMenu
|
||||
<SubMenu2
|
||||
menuClassName="menu-blur"
|
||||
openTrigger="clickOnly"
|
||||
direction="bottom"
|
||||
|
@ -1324,7 +1339,44 @@ function RelatedActions({
|
|||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
</SubMenu>
|
||||
</SubMenu2>
|
||||
)}
|
||||
{followedBy && (
|
||||
<MenuConfirm
|
||||
subMenu
|
||||
menuItemClassName="danger"
|
||||
confirmLabel={
|
||||
<>
|
||||
<Icon icon="user-x" />
|
||||
<span>Remove @{username} from followers?</span>
|
||||
</>
|
||||
}
|
||||
onClick={() => {
|
||||
setRelationshipUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const newRelationship = await currentMasto.v1.accounts
|
||||
.$select(currentInfo?.id || id)
|
||||
.removeFromFollowers();
|
||||
console.log(
|
||||
'removing from followers',
|
||||
newRelationship,
|
||||
);
|
||||
setRelationship(newRelationship);
|
||||
setRelationshipUIState('default');
|
||||
showToast(`@${username} removed from followers`);
|
||||
states.reloadGenericAccounts.id = 'followers';
|
||||
states.reloadGenericAccounts.counter++;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setRelationshipUIState('error');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Icon icon="user-x" />
|
||||
<span>Remove follower…</span>
|
||||
</MenuConfirm>
|
||||
)}
|
||||
<MenuConfirm
|
||||
subMenu
|
||||
|
@ -1437,7 +1489,7 @@ function RelatedActions({
|
|||
{!relationship && relationshipUIState === 'loading' && (
|
||||
<Loader abrupt />
|
||||
)}
|
||||
{!!relationship && (
|
||||
{!!relationship && !moved && (
|
||||
<MenuConfirm
|
||||
confirm={following || requested}
|
||||
confirmLabel={
|
||||
|
@ -1596,7 +1648,7 @@ function niceAccountURL(url) {
|
|||
const path = pathname.replace(/\/$/, '').replace(/^\//, '');
|
||||
return (
|
||||
<>
|
||||
<span class="more-insignificant">{host}/</span>
|
||||
<span class="more-insignificant">{punycode.toUnicode(host)}/</span>
|
||||
<wbr />
|
||||
<span>{path}</span>
|
||||
</>
|
||||
|
|
|
@ -727,3 +727,165 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gif-shake {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.gif-picker-button {
|
||||
span {
|
||||
font-weight: bold;
|
||||
font-size: 11.5px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus) {
|
||||
span {
|
||||
animation: gif-shake 0.3s 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#gif-picker-sheet {
|
||||
height: 50vh;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
input[type='search'] {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 2px,
|
||||
black 16px,
|
||||
black calc(100% - 16px),
|
||||
transparent calc(100% - 2px)
|
||||
);
|
||||
|
||||
@media (min-height: 480px) {
|
||||
overflow-y: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.ui-state {
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
ul {
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
padding: 8px 2px;
|
||||
margin: 0;
|
||||
|
||||
@media (min-height: 480px) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
|
||||
button {
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-faded-color);
|
||||
|
||||
@media (min-height: 480px) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:is(:hover, :focus) {
|
||||
background-color: var(--link-bg-color);
|
||||
box-shadow: 0 0 0 2px var(--link-light-color);
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: var(--figure-width);
|
||||
max-width: 100%;
|
||||
|
||||
@media (min-height: 480px) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
font-size: 0.8em;
|
||||
padding: 2px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
background-color: var(--img-bg-color);
|
||||
border-radius: 4px;
|
||||
vertical-align: top;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
@media (min-height: 480px) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,8 @@ import { uid } from 'uid/single';
|
|||
import { useDebouncedCallback, useThrottledCallback } from 'use-debounce';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import poweredByGiphyURL from '../assets/powered-by-giphy.svg';
|
||||
|
||||
import Menu2 from '../components/menu2';
|
||||
import supportedLanguages from '../data/status-supported-languages';
|
||||
import urlRegex from '../data/url-regex';
|
||||
|
@ -41,7 +43,10 @@ import Loader from './loader';
|
|||
import Modal from './modal';
|
||||
import Status from './status';
|
||||
|
||||
const { PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL } = import.meta.env;
|
||||
const {
|
||||
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
||||
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
|
||||
} = import.meta.env;
|
||||
|
||||
const supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
|
||||
const [code, common, native] = l;
|
||||
|
@ -610,6 +615,7 @@ function Compose({
|
|||
}, [mediaAttachments]);
|
||||
|
||||
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
|
||||
const [showGIFPicker, setShowGIFPicker] = useState(false);
|
||||
|
||||
const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => {
|
||||
const topLanguages = [];
|
||||
|
@ -1235,6 +1241,18 @@ function Compose({
|
|||
>
|
||||
<Icon icon="emoji2" />
|
||||
</button>
|
||||
{!!states.settings.composerGIFPicker && (
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-button gif-picker-button"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
setShowGIFPicker(true);
|
||||
}}
|
||||
>
|
||||
<span>GIF</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<div class="spacer" />
|
||||
{uiState === 'loading' ? (
|
||||
|
@ -1319,6 +1337,64 @@ function Compose({
|
|||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{showGIFPicker && (
|
||||
<Modal
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowGIFPicker(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GIFPickerModal
|
||||
onClose={() => setShowGIFPicker(false)}
|
||||
onSelect={({ url, type, alt_text }) => {
|
||||
console.log('GIF URL', url);
|
||||
if (mediaAttachments.length >= maxMediaAttachments) {
|
||||
alert(
|
||||
`You can only attach up to ${maxMediaAttachments} files.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Download the GIF and insert it as media attachment
|
||||
(async () => {
|
||||
let theToast;
|
||||
try {
|
||||
theToast = showToast({
|
||||
text: 'Downloading GIF…',
|
||||
duration: -1,
|
||||
});
|
||||
const blob = await fetch(url, {
|
||||
referrerPolicy: 'no-referrer',
|
||||
}).then((res) => res.blob());
|
||||
const file = new File(
|
||||
[blob],
|
||||
type === 'video/mp4' ? 'video.mp4' : 'image.gif',
|
||||
{
|
||||
type,
|
||||
},
|
||||
);
|
||||
const newMediaAttachments = [
|
||||
...mediaAttachments,
|
||||
{
|
||||
file,
|
||||
type,
|
||||
size: file.size,
|
||||
id: null,
|
||||
description: alt_text || '',
|
||||
},
|
||||
];
|
||||
setMediaAttachments(newMediaAttachments);
|
||||
theToast?.hideToast?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
theToast?.hideToast?.();
|
||||
showToast('Failed to download GIF');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1711,6 +1787,9 @@ function MediaAttachment({
|
|||
onDescriptionChange,
|
||||
250,
|
||||
);
|
||||
useEffect(() => {
|
||||
debouncedOnDescriptionChange(description);
|
||||
}, [description, debouncedOnDescriptionChange]);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
|
@ -1759,7 +1838,7 @@ function MediaAttachment({
|
|||
onInput={(e) => {
|
||||
const { value } = e.target;
|
||||
setDescription(value);
|
||||
debouncedOnDescriptionChange(value);
|
||||
// debouncedOnDescriptionChange(value);
|
||||
}}
|
||||
></textarea>
|
||||
)}
|
||||
|
@ -2243,4 +2322,225 @@ function CustomEmojisModal({
|
|||
);
|
||||
}
|
||||
|
||||
const GIFS_PER_PAGE = 20;
|
||||
function GIFPickerModal({ onClose = () => {}, onSelect = () => {} }) {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [results, setResults] = useState([]);
|
||||
const formRef = useRef(null);
|
||||
const qRef = useRef(null);
|
||||
const currentOffset = useRef(0);
|
||||
const scrollableRef = useRef(null);
|
||||
|
||||
function fetchGIFs({ offset }) {
|
||||
console.log('fetchGIFs', { offset });
|
||||
if (!qRef.current?.value) return;
|
||||
setUIState('loading');
|
||||
scrollableRef.current?.scrollTo?.({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
const query = {
|
||||
api_key: GIPHY_API_KEY,
|
||||
q: qRef.current.value,
|
||||
rating: 'g',
|
||||
limit: GIFS_PER_PAGE,
|
||||
bundle: 'messaging_non_clips',
|
||||
offset,
|
||||
};
|
||||
const response = await fetch(
|
||||
'https://api.giphy.com/v1/gifs/search?' + new URLSearchParams(query),
|
||||
{
|
||||
referrerPolicy: 'no-referrer',
|
||||
},
|
||||
).then((r) => r.json());
|
||||
currentOffset.current = response.pagination?.offset || 0;
|
||||
setResults(response);
|
||||
setUIState('results');
|
||||
} catch (e) {
|
||||
setUIState('error');
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
qRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div id="gif-picker-sheet" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
fetchGIFs({ offset: 0 });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={qRef}
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder="Search GIFs"
|
||||
required
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellCheck="false"
|
||||
dir="auto"
|
||||
/>
|
||||
<input
|
||||
type="image"
|
||||
class="powered-button"
|
||||
src={poweredByGiphyURL}
|
||||
width="86"
|
||||
height="30"
|
||||
/>
|
||||
</form>
|
||||
</header>
|
||||
<main ref={scrollableRef} class={uiState === 'loading' ? 'loading' : ''}>
|
||||
{uiState === 'default' && (
|
||||
<div class="ui-state">
|
||||
<p class="insignificant">Type to search GIFs</p>
|
||||
</div>
|
||||
)}
|
||||
{uiState === 'loading' && !results?.data?.length && (
|
||||
<div class="ui-state">
|
||||
<Loader abrupt />
|
||||
</div>
|
||||
)}
|
||||
{results?.data?.length > 0 ? (
|
||||
<>
|
||||
<ul>
|
||||
{results.data.map((gif) => {
|
||||
const { id, images, title, alt_text } = gif;
|
||||
const {
|
||||
fixed_height_small,
|
||||
fixed_height_downsampled,
|
||||
fixed_height,
|
||||
original,
|
||||
} = images;
|
||||
const theImage = fixed_height_small?.url
|
||||
? fixed_height_small
|
||||
: fixed_height_downsampled?.url
|
||||
? fixed_height_downsampled
|
||||
: fixed_height;
|
||||
let { url, webp, width, height } = theImage;
|
||||
if (+height > 100) {
|
||||
width = (width / height) * 100;
|
||||
height = 100;
|
||||
}
|
||||
const urlObj = new URL(url);
|
||||
const strippedURL = urlObj.origin + urlObj.pathname;
|
||||
let strippedWebP;
|
||||
if (webp) {
|
||||
const webpObj = new URL(webp);
|
||||
strippedWebP = webpObj.origin + webpObj.pathname;
|
||||
}
|
||||
return (
|
||||
<li key={id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const { mp4, url } = original;
|
||||
const theURL = mp4 || url;
|
||||
const urlObj = new URL(theURL);
|
||||
const strippedURL = urlObj.origin + urlObj.pathname;
|
||||
onClose();
|
||||
onSelect({
|
||||
url: strippedURL,
|
||||
type: mp4 ? 'video/mp4' : 'image/gif',
|
||||
alt_text: alt_text || title,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<figure
|
||||
style={{
|
||||
'--figure-width': width + 'px',
|
||||
// width: width + 'px'
|
||||
}}
|
||||
>
|
||||
<picture>
|
||||
{strippedWebP && (
|
||||
<source srcset={strippedWebP} type="image/webp" />
|
||||
)}
|
||||
<img
|
||||
src={strippedURL}
|
||||
width={width}
|
||||
height={height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt={alt_text}
|
||||
referrerpolicy="no-referrer"
|
||||
onLoad={(e) => {
|
||||
e.target.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
/>
|
||||
</picture>
|
||||
<figcaption>{alt_text || title}</figcaption>
|
||||
</figure>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p class="pagination">
|
||||
{results.pagination?.offset > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
class="light small"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
fetchGIFs({
|
||||
offset: results.pagination?.offset - GIFS_PER_PAGE,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="chevron-left" />
|
||||
<span>Previous</span>
|
||||
</button>
|
||||
)}
|
||||
<span />
|
||||
{results.pagination?.offset + results.pagination?.count <
|
||||
results.pagination?.total_count && (
|
||||
<button
|
||||
type="button"
|
||||
class="light small"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
fetchGIFs({
|
||||
offset: results.pagination?.offset + GIFS_PER_PAGE,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Next</span> <Icon icon="chevron-right" />
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
uiState === 'results' && (
|
||||
<div class="ui-state">
|
||||
<p>No results</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{uiState === 'error' && (
|
||||
<div class="ui-state">
|
||||
<p>Error loading GIFs</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Compose;
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
export default function CustomEmoji({ staticUrl, alt, url }) {
|
||||
return (
|
||||
<picture>
|
||||
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
||||
{staticUrl && (
|
||||
<source srcset={staticUrl} media="(prefers-reduced-motion: reduce)" />
|
||||
)}
|
||||
<img
|
||||
key={alt}
|
||||
key={alt || url}
|
||||
src={url}
|
||||
alt={alt}
|
||||
class="shortcode-emoji emoji"
|
||||
|
|
|
@ -6,6 +6,15 @@ import Loader from './loader';
|
|||
|
||||
const supportsIntlSegmenter = !shouldPolyfill();
|
||||
|
||||
// Preload IntlSegmenter
|
||||
setTimeout(() => {
|
||||
queueMicrotask(() => {
|
||||
if (!supportsIntlSegmenter) {
|
||||
import('@formatjs/intl-segmenter/polyfill-force').catch(() => {});
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
export default function IntlSegmenterSuspense({ children }) {
|
||||
if (supportsIntlSegmenter) {
|
||||
return <Suspense fallback={<Loader />}>{children}</Suspense>;
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
/*
|
||||
Rendered but hidden. Only show when visible
|
||||
*/
|
||||
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
// The sticky header, usually at the top
|
||||
const TOP = 48;
|
||||
|
||||
export default function LazyShazam({ children }) {
|
||||
const containerRef = useRef();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
@ -11,6 +14,7 @@ export default function LazyShazam({ children }) {
|
|||
|
||||
const { ref } = useInView({
|
||||
root: null,
|
||||
rootMargin: `-${TOP}px 0px 0px 0px`,
|
||||
trackVisibility: true,
|
||||
delay: 1000,
|
||||
onChange: (inView) => {
|
||||
|
@ -22,11 +26,15 @@ export default function LazyShazam({ children }) {
|
|||
skip: visibleStart || visible,
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.bottom > 0) {
|
||||
setVisibleStart(true);
|
||||
if (rect.bottom > TOP) {
|
||||
if (rect.top < window.innerHeight) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisibleStart(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
@ -9,12 +9,12 @@ import {
|
|||
} from 'preact/hooks';
|
||||
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
|
||||
|
||||
import formatDuration from '../utils/format-duration';
|
||||
import mem from '../utils/mem';
|
||||
import states from '../utils/states';
|
||||
|
||||
import Icon from './icon';
|
||||
import Link from './link';
|
||||
import { formatDuration } from './status';
|
||||
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { MenuItem, SubMenu } from '@szhsin/react-menu';
|
||||
import { MenuItem } from '@szhsin/react-menu';
|
||||
import { cloneElement } from 'preact';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
import Menu2 from './menu2';
|
||||
import SubMenu2 from './submenu2';
|
||||
|
||||
function MenuConfirm({
|
||||
subMenu = false,
|
||||
|
@ -23,11 +23,9 @@ function MenuConfirm({
|
|||
}
|
||||
return children;
|
||||
}
|
||||
const Parent = subMenu ? SubMenu : Menu2;
|
||||
const menuRef = useRef();
|
||||
const Parent = subMenu ? SubMenu2 : Menu2;
|
||||
return (
|
||||
<Parent
|
||||
instanceRef={menuRef}
|
||||
openTrigger="clickOnly"
|
||||
direction="bottom"
|
||||
overflow="auto"
|
||||
|
@ -37,19 +35,6 @@ function MenuConfirm({
|
|||
{...restProps}
|
||||
menuButton={subMenu ? undefined : children}
|
||||
label={subMenu ? children : undefined}
|
||||
// Test fix for bug; submenus not opening on Android
|
||||
itemProps={{
|
||||
onPointerMove: (e) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
menuRef.current?.openMenu?.();
|
||||
}
|
||||
},
|
||||
onPointerLeave: (e) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
menuRef.current?.openMenu?.();
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem className={menuItemClassName} onClick={onClick}>
|
||||
{confirmLabel}
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
import './nav-menu.css';
|
||||
|
||||
import {
|
||||
ControlledMenu,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
SubMenu,
|
||||
} from '@szhsin/react-menu';
|
||||
import { ControlledMenu, MenuDivider, MenuItem } from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
|
@ -20,6 +15,7 @@ import store from '../utils/store';
|
|||
import Avatar from './avatar';
|
||||
import Icon from './icon';
|
||||
import MenuLink from './menu-link';
|
||||
import SubMenu2 from './submenu2';
|
||||
|
||||
function NavMenu(props) {
|
||||
const snapStates = useSnapshot(states);
|
||||
|
@ -148,7 +144,7 @@ function NavMenu(props) {
|
|||
}}
|
||||
{...props}
|
||||
overflow="auto"
|
||||
// viewScroll="close"
|
||||
viewScroll="close"
|
||||
position="anchor"
|
||||
align="center"
|
||||
boundingBoxPadding={boundingBoxPadding}
|
||||
|
@ -209,7 +205,7 @@ function NavMenu(props) {
|
|||
</MenuLink>
|
||||
)}
|
||||
{lists?.length > 0 ? (
|
||||
<SubMenu
|
||||
<SubMenu2
|
||||
menuClassName="nav-submenu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
|
@ -234,7 +230,7 @@ function NavMenu(props) {
|
|||
))}
|
||||
</>
|
||||
)}
|
||||
</SubMenu>
|
||||
</SubMenu2>
|
||||
) : (
|
||||
<MenuLink to="/l">
|
||||
<Icon icon="list" size="l" />
|
||||
|
@ -244,7 +240,7 @@ function NavMenu(props) {
|
|||
<MenuLink to="/b">
|
||||
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
|
||||
</MenuLink>
|
||||
<SubMenu
|
||||
<SubMenu2
|
||||
menuClassName="nav-submenu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
|
@ -293,7 +289,7 @@ function NavMenu(props) {
|
|||
<Icon icon="block" size="l" />
|
||||
Blocked users…
|
||||
</MenuItem>{' '}
|
||||
</SubMenu>
|
||||
</SubMenu2>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
|
|
|
@ -8,7 +8,7 @@ import dayjs from 'dayjs';
|
|||
import dayjsTwitter from 'dayjs-twitter';
|
||||
import localizedFormat from 'dayjs/plugin/localizedFormat';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import { useEffect, useMemo, useReducer } from 'preact/hooks';
|
||||
|
||||
dayjs.extend(dayjsTwitter);
|
||||
dayjs.extend(localizedFormat);
|
||||
|
@ -18,22 +18,49 @@ const dtf = new Intl.DateTimeFormat();
|
|||
|
||||
export default function RelativeTime({ datetime, format }) {
|
||||
if (!datetime) return null;
|
||||
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
|
||||
const date = useMemo(() => dayjs(datetime), [datetime]);
|
||||
const dateStr = useMemo(() => {
|
||||
const [dateStr, dt, title] = useMemo(() => {
|
||||
let str;
|
||||
if (format === 'micro') {
|
||||
// If date <= 1 day ago or day is within this year
|
||||
const now = dayjs();
|
||||
const dayDiff = now.diff(date, 'day');
|
||||
if (dayDiff <= 1 || now.year() === date.year()) {
|
||||
return date.twitter();
|
||||
str = date.twitter();
|
||||
} else {
|
||||
return dtf.format(date.toDate());
|
||||
str = dtf.format(date.toDate());
|
||||
}
|
||||
}
|
||||
return date.fromNow();
|
||||
}, [date, format]);
|
||||
const dt = useMemo(() => date.toISOString(), [date]);
|
||||
const title = useMemo(() => date.format('LLLL'), [date]);
|
||||
if (!str) str = date.fromNow();
|
||||
return [str, date.toISOString(), date.format('LLLL')];
|
||||
}, [date, format, renderCount]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout;
|
||||
let raf;
|
||||
function rafRerender() {
|
||||
raf = requestAnimationFrame(() => {
|
||||
rerender();
|
||||
scheduleRerender();
|
||||
});
|
||||
}
|
||||
function scheduleRerender() {
|
||||
// If less than 1 minute, rerender every 10s
|
||||
// If less than 1 hour rerender every 1m
|
||||
// Else, don't need to rerender
|
||||
if (date.diff(dayjs(), 'minute', true) < 1) {
|
||||
timeout = setTimeout(rafRerender, 10_000);
|
||||
} else if (date.diff(dayjs(), 'hour', true) < 1) {
|
||||
timeout = setTimeout(rafRerender, 60_000);
|
||||
}
|
||||
}
|
||||
scheduleRerender();
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<time datetime={dt} title={title}>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import './shortcuts.css';
|
||||
|
||||
import { MenuDivider, SubMenu } from '@szhsin/react-menu';
|
||||
import { MenuDivider } from '@szhsin/react-menu';
|
||||
import { memo } from 'preact/compat';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
@ -17,6 +17,7 @@ import Icon from './icon';
|
|||
import Link from './link';
|
||||
import Menu2 from './menu2';
|
||||
import MenuLink from './menu-link';
|
||||
import SubMenu2 from './submenu2';
|
||||
|
||||
function Shortcuts() {
|
||||
const { instance } = api();
|
||||
|
@ -182,7 +183,7 @@ function Shortcuts() {
|
|||
{formattedShortcuts.map(({ id, path, title, subtitle, icon }, i) => {
|
||||
if (id === 'lists') {
|
||||
return (
|
||||
<SubMenu
|
||||
<SubMenu2
|
||||
menuClassName="glass-menu"
|
||||
overflow="auto"
|
||||
gap={-8}
|
||||
|
@ -205,7 +206,7 @@ function Shortcuts() {
|
|||
<span>{list.title}</span>
|
||||
</MenuLink>
|
||||
))}
|
||||
</SubMenu>
|
||||
</SubMenu2>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -908,7 +908,7 @@
|
|||
grid-auto-rows: 1fr;
|
||||
gap: 2px;
|
||||
/* height: 160px; */
|
||||
min-height: var(--pointer-min-dimension);
|
||||
min-height: var(--min-dimension);
|
||||
height: auto;
|
||||
max-height: max(160px, 33vh);
|
||||
}
|
||||
|
@ -1037,9 +1037,9 @@
|
|||
.status .media-container.media-eq1 .media {
|
||||
display: inline-block;
|
||||
max-width: 100% !important;
|
||||
min-width: var(--pointer-min-dimension);
|
||||
min-width: var(--min-dimension);
|
||||
/* width: auto; */
|
||||
min-height: var(--pointer-min-dimension);
|
||||
min-height: var(--min-dimension);
|
||||
/* --maxAspectHeight: max(160px, 33vh);
|
||||
--aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */
|
||||
width: min(var(--aspectWidth), var(--width), 100%);
|
||||
|
@ -1300,7 +1300,7 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
:is(.status, .media-post) .media-audio {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: var(--pointer-min-dimension);
|
||||
min-height: var(--min-dimension);
|
||||
background-image: radial-gradient(
|
||||
circle at center center,
|
||||
transparent,
|
||||
|
@ -1695,13 +1695,14 @@ a.card:is(:hover, :focus):visited {
|
|||
}
|
||||
.poll-label input:is([type='radio'], [type='checkbox']) {
|
||||
flex-shrink: 0;
|
||||
margin: 3px;
|
||||
min-height: 1em;
|
||||
margin: 0 3px;
|
||||
min-height: 0.9em;
|
||||
}
|
||||
.poll-option-votes {
|
||||
flex-shrink: 0;
|
||||
font-size: 90%;
|
||||
opacity: 0.75;
|
||||
line-height: 1;
|
||||
}
|
||||
.poll-option-leading .poll-option-votes {
|
||||
font-weight: bold;
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -1942,7 +1943,24 @@ function Status({
|
|||
{!!emojiReactions?.length && (
|
||||
<div class="emoji-reactions">
|
||||
{emojiReactions.map((emojiReaction) => {
|
||||
const { name, count, me } = emojiReaction;
|
||||
const { name, count, me, url, staticUrl } = emojiReaction;
|
||||
if (url) {
|
||||
// Some servers return url and staticUrl
|
||||
return (
|
||||
<span
|
||||
class={`emoji-reaction tag ${
|
||||
me ? '' : 'insignificant'
|
||||
}`}
|
||||
>
|
||||
<CustomEmoji
|
||||
alt={name}
|
||||
url={url}
|
||||
staticUrl={staticUrl}
|
||||
/>{' '}
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const isShortCode = /^:.+?:$/.test(name);
|
||||
if (isShortCode) {
|
||||
const emoji = emojis.find(
|
||||
|
@ -1961,7 +1979,7 @@ function Status({
|
|||
alt={name}
|
||||
url={emoji.url}
|
||||
staticUrl={emoji.staticUrl}
|
||||
/>
|
||||
/>{' '}
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
|
@ -2231,9 +2249,9 @@ function Card({ card, selfReferential, instance }) {
|
|||
);
|
||||
|
||||
if (hasText && (image || (type === 'photo' && blurhash))) {
|
||||
const domain = new URL(url).hostname
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
const domain = punycode.toUnicode(
|
||||
new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
|
||||
);
|
||||
let blurhashImage;
|
||||
const rgbAverageColor =
|
||||
image && blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||
|
@ -2349,7 +2367,9 @@ function Card({ card, selfReferential, instance }) {
|
|||
// );
|
||||
}
|
||||
if (hasText && !image) {
|
||||
const domain = new URL(url).hostname.replace(/^www\./, '');
|
||||
const domain = punycode.toUnicode(
|
||||
new URL(url).hostname.replace(/^www\./, ''),
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={cardStatusURL || url}
|
||||
|
@ -2872,21 +2892,6 @@ function StatusButton({
|
|||
);
|
||||
}
|
||||
|
||||
export function formatDuration(time) {
|
||||
if (!time) return;
|
||||
let hours = Math.floor(time / 3600);
|
||||
let minutes = Math.floor((time % 3600) / 60);
|
||||
let seconds = Math.round(time % 60);
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
function nicePostURL(url) {
|
||||
if (!url) return;
|
||||
const urlObj = new URL(url);
|
||||
|
@ -2896,7 +2901,7 @@ function nicePostURL(url) {
|
|||
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
|
||||
return (
|
||||
<>
|
||||
{host}
|
||||
{punycode.toUnicode(host)}
|
||||
{username ? (
|
||||
<>
|
||||
/{username}
|
||||
|
|
25
src/components/submenu2.jsx
Normal file
25
src/components/submenu2.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { SubMenu } from '@szhsin/react-menu';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
export default function SubMenu2(props) {
|
||||
const menuRef = useRef();
|
||||
return (
|
||||
<SubMenu
|
||||
{...props}
|
||||
instanceRef={menuRef}
|
||||
// Test fix for bug; submenus not opening on Android
|
||||
itemProps={{
|
||||
onPointerMove: (e) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
menuRef.current?.openMenu?.();
|
||||
}
|
||||
},
|
||||
onPointerLeave: (e) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
menuRef.current?.openMenu?.();
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -51,7 +51,7 @@ function Timeline({
|
|||
}) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const [items, setItems] = useState([]);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [uiState, setUIState] = useState('start');
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [visible, setVisible] = useState(true);
|
||||
|
@ -496,7 +496,8 @@ function Timeline({
|
|||
)}
|
||||
</ul>
|
||||
) : (
|
||||
uiState !== 'error' && <p class="ui-state">{emptyText}</p>
|
||||
uiState !== 'error' &&
|
||||
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
|
||||
)}
|
||||
{uiState === 'error' && (
|
||||
<p class="ui-state">
|
||||
|
|
|
@ -10,6 +10,7 @@ import localeCode2Text from '../utils/localeCode2Text';
|
|||
import pmem from '../utils/pmem';
|
||||
|
||||
import Icon from './icon';
|
||||
import LazyShazam from './lazy-shazam';
|
||||
import Loader from './loader';
|
||||
|
||||
const { PHANPY_LINGVA_INSTANCES } = import.meta.env;
|
||||
|
@ -142,23 +143,21 @@ function TranslationBlock({
|
|||
detectedLang !== targetLangText
|
||||
) {
|
||||
return (
|
||||
<div class="shazam-container">
|
||||
<div class="shazam-container-inner">
|
||||
<div class="status-translation-block-mini">
|
||||
<Icon
|
||||
icon="translate"
|
||||
alt={`Auto-translated from ${sourceLangText}`}
|
||||
/>
|
||||
<output
|
||||
lang={targetLang}
|
||||
dir="auto"
|
||||
title={pronunciationContent || ''}
|
||||
>
|
||||
{translatedContent}
|
||||
</output>
|
||||
</div>
|
||||
<LazyShazam>
|
||||
<div class="status-translation-block-mini">
|
||||
<Icon
|
||||
icon="translate"
|
||||
alt={`Auto-translated from ${sourceLangText}`}
|
||||
/>
|
||||
<output
|
||||
lang={targetLang}
|
||||
dir="auto"
|
||||
title={pronunciationContent || ''}
|
||||
>
|
||||
{translatedContent}
|
||||
</output>
|
||||
</div>
|
||||
</div>
|
||||
</LazyShazam>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -109,13 +109,7 @@
|
|||
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
|
||||
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
--pointer-min-dimension: 88px;
|
||||
}
|
||||
|
||||
@media (pointer: fine) {
|
||||
:root {
|
||||
--pointer-min-dimension: 44px;
|
||||
}
|
||||
--min-dimension: 88px;
|
||||
}
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
|
@ -353,6 +347,7 @@ button[hidden] {
|
|||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='search'],
|
||||
textarea,
|
||||
select {
|
||||
color: var(--text-color);
|
||||
|
@ -362,6 +357,7 @@ select {
|
|||
border-radius: 4px;
|
||||
}
|
||||
input[type='text']:focus,
|
||||
input[type='search']:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--outline-color);
|
||||
|
@ -377,7 +373,7 @@ textarea:disabled {
|
|||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
|
||||
:is(input[type='text'], textarea, select).block {
|
||||
:is(input[type='text'], input[type='search'], textarea, select).block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
|
@ -516,7 +517,13 @@ function AccountStatuses() {
|
|||
>
|
||||
<Icon icon="transfer" />{' '}
|
||||
<small class="menu-double-lines">
|
||||
Switch to account's instance (<b>{accountInstance}</b>)
|
||||
Switch to account's instance{' '}
|
||||
{accountInstance ? (
|
||||
<>
|
||||
{' '}
|
||||
(<b>{punycode.toUnicode(accountInstance)}</b>)
|
||||
</>
|
||||
) : null}
|
||||
</small>
|
||||
</MenuItem>
|
||||
{!sameCurrentInstance && (
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { uid } from 'uid/single';
|
||||
|
@ -191,6 +192,7 @@ function Catchup() {
|
|||
|
||||
const [posts, setPosts] = useState([]);
|
||||
const catchupRangeRef = useRef();
|
||||
const catchupLastRef = useRef();
|
||||
const NS = useMemo(() => getCurrentAccountNS(), []);
|
||||
const handleCatchupClick = useCallback(async ({ duration } = {}) => {
|
||||
const now = Date.now();
|
||||
|
@ -925,7 +927,15 @@ function Catchup() {
|
|||
type="button"
|
||||
onClick={() => {
|
||||
if (range < RANGES[RANGES.length - 1].value) {
|
||||
const duration = range * 60 * 60 * 1000;
|
||||
let duration;
|
||||
if (
|
||||
range === RANGES[RANGES.length - 1].value &&
|
||||
catchupLastRef.current?.checked
|
||||
) {
|
||||
duration = Date.now() - lastCatchupEndAt;
|
||||
} else {
|
||||
duration = range * 60 * 60 * 1000;
|
||||
}
|
||||
handleCatchupClick({ duration });
|
||||
} else {
|
||||
handleCatchupClick();
|
||||
|
@ -935,11 +945,25 @@ function Catchup() {
|
|||
Catch up
|
||||
</button>
|
||||
</div>
|
||||
{lastCatchupRange && range > lastCatchupRange && (
|
||||
{lastCatchupRange && range > lastCatchupRange ? (
|
||||
<p class="catchup-info">
|
||||
<Icon icon="info" /> Overlaps with your last catch-up
|
||||
</p>
|
||||
)}
|
||||
) : range === RANGES[RANGES.length - 1].value &&
|
||||
lastCatchupEndAt ? (
|
||||
<p class="catchup-info">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
switch
|
||||
checked
|
||||
ref={catchupLastRef}
|
||||
/>{' '}
|
||||
Until the last catch-up (
|
||||
{dtf.format(new Date(lastCatchupEndAt))})
|
||||
</label>
|
||||
</p>
|
||||
) : null}
|
||||
<p class="insignificant">
|
||||
<small>
|
||||
Note: your instance might only show a maximum of 800 posts in
|
||||
|
@ -1076,9 +1100,11 @@ function Catchup() {
|
|||
height,
|
||||
publishedAt,
|
||||
} = card;
|
||||
const domain = new URL(url).hostname
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
const domain = punycode.toUnicode(
|
||||
new URL(url).hostname
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, ''),
|
||||
);
|
||||
let accentColor;
|
||||
if (blurhash) {
|
||||
const averageColor = getBlurHashAverageColor(blurhash);
|
||||
|
|
|
@ -177,6 +177,7 @@ function Search({ columnMode, ...props }) {
|
|||
['/', 'Slash'],
|
||||
(e) => {
|
||||
searchFormRef.current?.focus?.();
|
||||
searchFormRef.current?.select?.();
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
|
|
|
@ -28,6 +28,7 @@ const {
|
|||
PHANPY_WEBSITE: WEBSITE,
|
||||
PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,
|
||||
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
|
||||
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
|
||||
} = import.meta.env;
|
||||
|
||||
function Settings({ onClose }) {
|
||||
|
@ -433,6 +434,37 @@ function Settings({ onClose }) {
|
|||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{!!GIPHY_API_KEY && authenticated && (
|
||||
<li>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={snapStates.settings.composerGIFPicker}
|
||||
onChange={(e) => {
|
||||
states.settings.composerGIFPicker = e.target.checked;
|
||||
}}
|
||||
/>{' '}
|
||||
GIF Picker for composer
|
||||
</label>
|
||||
<div class="sub-section insignificant">
|
||||
<small>
|
||||
Note: This feature uses external GIF search service, powered
|
||||
by{' '}
|
||||
<a
|
||||
href="https://developers.giphy.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GIPHY
|
||||
</a>
|
||||
. G-rated (suitable for viewing by all ages), tracking
|
||||
parameters are stripped, referrer information is omitted
|
||||
from requests, but search queries and IP address information
|
||||
will still reach their servers.
|
||||
</small>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{!!IMG_ALT_API_URL && authenticated && (
|
||||
<li>
|
||||
<label>
|
||||
|
|
|
@ -12,10 +12,10 @@ import {
|
|||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { matchPath, useSearchParams } from 'react-router-dom';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import Avatar from '../components/avatar';
|
||||
|
@ -122,7 +122,7 @@ function StatusPage(params) {
|
|||
}, [showMedia]);
|
||||
|
||||
const mediaAttachments = mediaStatusID
|
||||
? mediaStatus?.mediaAttachments
|
||||
? snapStates.statuses[statusKey(mediaStatusID, instance)]?.mediaAttachments
|
||||
: heroStatus?.mediaAttachments;
|
||||
|
||||
const handleMediaClose = useCallback(() => {
|
||||
|
@ -1208,7 +1208,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
|||
{postInstance ? (
|
||||
<>
|
||||
{' '}
|
||||
(<b>{postInstance}</b>)
|
||||
(<b>{punycode.toUnicode(postInstance)}</b>)
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
|
|
|
@ -3,6 +3,7 @@ import '../components/links-bar.css';
|
|||
import { MenuItem } from '@szhsin/react-menu';
|
||||
import { getBlurHashAverageColor } from 'fast-blurhash';
|
||||
import { useMemo, useRef, useState } from 'preact/hooks';
|
||||
import punycode from 'punycode';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
|
@ -161,9 +162,9 @@ function Trending({ columnMode, ...props }) {
|
|||
url,
|
||||
width,
|
||||
} = link;
|
||||
const domain = new URL(url).hostname
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
const domain = punycode.toUnicode(
|
||||
new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
|
||||
);
|
||||
let accentColor;
|
||||
if (blurhash) {
|
||||
const averageColor = getBlurHashAverageColor(blurhash);
|
||||
|
|
14
src/utils/format-duration.jsx
Normal file
14
src/utils/format-duration.jsx
Normal file
|
@ -0,0 +1,14 @@
|
|||
export default function formatDuration(time) {
|
||||
if (!time) return;
|
||||
let hours = Math.floor(time / 3600);
|
||||
let minutes = Math.floor((time % 3600) / 60);
|
||||
let seconds = Math.round(time % 60);
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
}
|
|
@ -1,10 +1,16 @@
|
|||
export default function localeCode2Text(code) {
|
||||
import mem from './mem';
|
||||
|
||||
const IntlDN = new Intl.DisplayNames(navigator.languages, {
|
||||
type: 'language',
|
||||
});
|
||||
|
||||
function _localeCode2Text(code) {
|
||||
try {
|
||||
return new Intl.DisplayNames(navigator.languages, {
|
||||
type: 'language',
|
||||
}).of(code);
|
||||
return IntlDN.of(code);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default mem(_localeCode2Text);
|
||||
|
|
|
@ -67,6 +67,7 @@ const states = proxy({
|
|||
contentTranslationAutoInline: false,
|
||||
shortcutSettingsCloudImportExport: false,
|
||||
mediaAltGenerator: false,
|
||||
composerGIFPicker: false,
|
||||
cloakMode: false,
|
||||
},
|
||||
});
|
||||
|
@ -99,6 +100,8 @@ export function initStates() {
|
|||
store.account.get('settings-shortcutSettingsCloudImportExport') ?? false;
|
||||
states.settings.mediaAltGenerator =
|
||||
store.account.get('settings-mediaAltGenerator') ?? false;
|
||||
states.settings.composerGIFPicker =
|
||||
store.account.get('settings-composerGIFPicker') ?? false;
|
||||
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
|
||||
}
|
||||
|
||||
|
@ -140,6 +143,9 @@ subscribe(states, (changes) => {
|
|||
if (path.join('.') === 'settings.mediaAltGenerator') {
|
||||
store.account.set('settings-mediaAltGenerator', !!value);
|
||||
}
|
||||
if (path.join('.') === 'settings.composerGIFPicker') {
|
||||
store.account.set('settings-composerGIFPicker', !!value);
|
||||
}
|
||||
if (path?.[0] === 'shortcuts') {
|
||||
store.account.set('shortcuts', states.shortcuts);
|
||||
}
|
||||
|
|
|
@ -83,15 +83,23 @@ function _unfurlMastodonLink(instance, url) {
|
|||
limit: 1,
|
||||
})
|
||||
.then((results) => {
|
||||
if (results.statuses.length > 0) {
|
||||
const status = results.statuses[0];
|
||||
return {
|
||||
status,
|
||||
instance,
|
||||
};
|
||||
} else {
|
||||
throw new Error('No results');
|
||||
const { statuses } = results;
|
||||
if (statuses.length > 0) {
|
||||
// Filter out statuses that has content that contains the URL, in-case-sensitive
|
||||
const theStatuses = statuses.filter(
|
||||
(status) =>
|
||||
!status.content?.toLowerCase().includes(theURL.toLowerCase()),
|
||||
);
|
||||
|
||||
if (theStatuses.length === 1) {
|
||||
return {
|
||||
status: theStatuses[0],
|
||||
instance,
|
||||
};
|
||||
}
|
||||
// If there are multiple statuses, give up, something is wrong
|
||||
}
|
||||
throw new Error('No results');
|
||||
});
|
||||
|
||||
function handleFulfill(result) {
|
||||
|
|
Loading…
Reference in a new issue