Porównaj commity

...

15 Commity

Autor SHA1 Wiadomość Data
Lim Chee Aun 7100937e79 Higher gif picker sheet 2024-04-02 19:44:22 +08:00
Lim Chee Aun c18efef7b6 GIF picker 2024-04-02 17:51:48 +08:00
Lim Chee Aun ff336628f8 Fix media description not recognized if programmatically entered 2024-04-02 17:45:14 +08:00
Lim Chee Aun 28882d98d9 Add different UI state than default for start 2024-04-02 17:42:51 +08:00
Lim Chee Aun f6ad22e58f Fix bug: media attachments not updated when edited 2024-04-02 13:12:52 +08:00
Lim Chee Aun aa664e15f6 Convert all the punycodes
Surprising that this is still not built into browsers
2024-04-02 09:03:13 +08:00
Chee Aun f2f203c9d8
Merge pull request #478 from snail-coupe/doc/fix_build_example
Update README.md
2024-04-02 07:49:11 +08:00
snail-coupe ae0e4a0792
Update README.md
Build examples: PHANPY_APP_TITLE -> PHANPY_CLIENT_NAME
2024-04-01 23:26:15 +01:00
Lim Chee Aun 4def6eef5a Refactor this out for no particular reason 2024-03-31 20:53:08 +08:00
Lim Chee Aun 1004a5f176 Revert back to 88px 2024-03-31 20:47:43 +08:00
Lim Chee Aun 2b6beee875 More logic to prevent recursive/wrong quote posts 2024-03-31 20:35:24 +08:00
Lim Chee Aun e35e02593a If beyond 12 hours, allow last catch up's end timing 2024-03-31 20:34:01 +08:00
Lim Chee Aun 5e56ba9fb9 Bring back auto-updating relative time
This time, more optimized re-render
2024-03-30 17:21:31 +08:00
Lim Chee Aun a7cc0785f9 Test another fix for submenus not opening 2024-03-30 14:44:48 +08:00
Lim Chee Aun bb5d34c94c Still need to request relationship for moved accounts
Instead hide specific elements if moved.
2024-03-29 21:27:46 +08:00
23 zmienionych plików z 685 dodań i 103 usunięć

Wyświetl plik

@ -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: Customization can be done by passing environment variables to the build command. Examples:
```bash ```bash
PHANPY_APP_TITLE="Phanpy Dev" \ PHANPY_CLIENT_NAME="Phanpy Dev" \
PHANPY_WEBSITE="https://dev.phanpy.social" \ PHANPY_WEBSITE="https://dev.phanpy.social" \
npm run build 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) - 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 fallback instances hard-coded in `/.env`
- [↗️ List of lingva-translate instances](https://github.com/thedaviddelta/lingva-translate?tab=readme-ov-file#instances) - [↗️ 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 ### Static site hosting

9
package-lock.json wygenerowano
Wyświetl plik

@ -30,6 +30,7 @@
"p-retry": "~6.2.0", "p-retry": "~6.2.0",
"p-throttle": "~6.1.0", "p-throttle": "~6.1.0",
"preact": "~10.20.1", "preact": "~10.20.1",
"punycode": "~2.3.1",
"react-hotkeys-hook": "~4.5.0", "react-hotkeys-hook": "~4.5.0",
"react-intersection-observer": "~9.8.1", "react-intersection-observer": "~9.8.1",
"react-quick-pinch-zoom": "~5.1.0", "react-quick-pinch-zoom": "~5.1.0",
@ -7154,11 +7155,9 @@
"integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==" "integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="
}, },
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.0", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }

Wyświetl plik

@ -32,6 +32,7 @@
"p-retry": "~6.2.0", "p-retry": "~6.2.0",
"p-throttle": "~6.1.0", "p-throttle": "~6.1.0",
"preact": "~10.20.1", "preact": "~10.20.1",
"punycode": "~2.3.1",
"react-hotkeys-hook": "~4.5.0", "react-hotkeys-hook": "~4.5.0",
"react-intersection-observer": "~9.8.1", "react-intersection-observer": "~9.8.1",
"react-quick-pinch-zoom": "~5.1.0", "react-quick-pinch-zoom": "~5.1.0",

Wyświetl plik

@ -295,7 +295,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
video, video,
img, img,
audio { audio {
min-height: var(--pointer-min-dimension); /* for extreme dimensions */ min-height: var(--min-dimension); /* for extreme dimensions */
} }
} }
} }

Wyświetl plik

@ -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>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.9 KiB

Wyświetl plik

@ -9,6 +9,7 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { api } from '../utils/api'; import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
@ -228,7 +229,7 @@ function AccountInfo({
const accountInstance = useMemo(() => { const accountInstance = useMemo(() => {
if (!url) return null; if (!url) return null;
const domain = new URL(url).hostname; const domain = punycode.toUnicode(new URL(url).hostname);
return domain; return domain;
}, [url]); }, [url]);
@ -809,38 +810,40 @@ function AccountInfo({
</div> </div>
</LinkOrDiv> </LinkOrDiv>
)} )}
<div class="account-metadata-box"> {!moved && (
<div <div class="account-metadata-box">
class="shazam-container no-animation" <div
hidden={!!postingStats} class="shazam-container no-animation"
> hidden={!!postingStats}
<div class="shazam-container-inner"> >
<button <div class="shazam-container-inner">
type="button" <button
class="posting-stats-button" type="button"
disabled={postingStatsUIState === 'loading'} class="posting-stats-button"
onClick={() => { disabled={postingStatsUIState === 'loading'}
renderPostingStats(); onClick={() => {
}} renderPostingStats();
>
<div
class={`posting-stats-bar posting-stats-icon ${
postingStatsUIState === 'loading' ? 'loading' : ''
}`}
style={{
'--originals-percentage': '33%',
'--replies-percentage': '66%',
}} }}
/> >
View post stats{' '} <div
{/* <Loader class={`posting-stats-bar posting-stats-icon ${
postingStatsUIState === 'loading' ? 'loading' : ''
}`}
style={{
'--originals-percentage': '33%',
'--replies-percentage': '66%',
}}
/>
View post stats{' '}
{/* <Loader
abrupt abrupt
hidden={postingStatsUIState !== 'loading'} hidden={postingStatsUIState !== 'loading'}
/> */} /> */}
</button> </button>
</div>
</div> </div>
</div> </div>
</div> )}
</main> </main>
<footer> <footer>
<RelatedActions <RelatedActions
@ -939,7 +942,7 @@ function RelatedActions({
accountID.current = currentID; accountID.current = currentID;
if (moved) return; // if (moved) return;
setRelationshipUIState('loading'); setRelationshipUIState('loading');
@ -1437,7 +1440,7 @@ function RelatedActions({
{!relationship && relationshipUIState === 'loading' && ( {!relationship && relationshipUIState === 'loading' && (
<Loader abrupt /> <Loader abrupt />
)} )}
{!!relationship && ( {!!relationship && !moved && (
<MenuConfirm <MenuConfirm
confirm={following || requested} confirm={following || requested}
confirmLabel={ confirmLabel={
@ -1596,7 +1599,7 @@ function niceAccountURL(url) {
const path = pathname.replace(/\/$/, '').replace(/^\//, ''); const path = pathname.replace(/\/$/, '').replace(/^\//, '');
return ( return (
<> <>
<span class="more-insignificant">{host}/</span> <span class="more-insignificant">{punycode.toUnicode(host)}/</span>
<wbr /> <wbr />
<span>{path}</span> <span>{path}</span>
</> </>

Wyświetl plik

@ -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;
}
}
}
}

Wyświetl plik

@ -11,6 +11,8 @@ import { uid } from 'uid/single';
import { useDebouncedCallback, useThrottledCallback } from 'use-debounce'; import { useDebouncedCallback, useThrottledCallback } from 'use-debounce';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import poweredByGiphyURL from '../assets/powered-by-giphy.svg';
import Menu2 from '../components/menu2'; import Menu2 from '../components/menu2';
import supportedLanguages from '../data/status-supported-languages'; import supportedLanguages from '../data/status-supported-languages';
import urlRegex from '../data/url-regex'; import urlRegex from '../data/url-regex';
@ -41,7 +43,10 @@ import Loader from './loader';
import Modal from './modal'; import Modal from './modal';
import Status from './status'; 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 supportedLanguagesMap = supportedLanguages.reduce((acc, l) => {
const [code, common, native] = l; const [code, common, native] = l;
@ -610,6 +615,7 @@ function Compose({
}, [mediaAttachments]); }, [mediaAttachments]);
const [showEmoji2Picker, setShowEmoji2Picker] = useState(false); const [showEmoji2Picker, setShowEmoji2Picker] = useState(false);
const [showGIFPicker, setShowGIFPicker] = useState(false);
const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => { const [topSupportedLanguages, restSupportedLanguages] = useMemo(() => {
const topLanguages = []; const topLanguages = [];
@ -1235,6 +1241,18 @@ function Compose({
> >
<Icon icon="emoji2" /> <Icon icon="emoji2" />
</button> </button>
{!!states.settings.composerGIFPicker && (
<button
type="button"
class="toolbar-button gif-picker-button"
disabled={uiState === 'loading'}
onClick={() => {
setShowGIFPicker(true);
}}
>
<span>GIF</span>
</button>
)}
</span> </span>
<div class="spacer" /> <div class="spacer" />
{uiState === 'loading' ? ( {uiState === 'loading' ? (
@ -1319,6 +1337,64 @@ function Compose({
/> />
</Modal> </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> </div>
); );
} }
@ -1711,6 +1787,9 @@ function MediaAttachment({
onDescriptionChange, onDescriptionChange,
250, 250,
); );
useEffect(() => {
debouncedOnDescriptionChange(description);
}, [description, debouncedOnDescriptionChange]);
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const textareaRef = useRef(null); const textareaRef = useRef(null);
@ -1759,7 +1838,7 @@ function MediaAttachment({
onInput={(e) => { onInput={(e) => {
const { value } = e.target; const { value } = e.target;
setDescription(value); setDescription(value);
debouncedOnDescriptionChange(value); // debouncedOnDescriptionChange(value);
}} }}
></textarea> ></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; export default Compose;

Wyświetl plik

@ -9,12 +9,12 @@ import {
} from 'preact/hooks'; } from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom'; import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import formatDuration from '../utils/format-duration';
import mem from '../utils/mem'; import mem from '../utils/mem';
import states from '../utils/states'; import states from '../utils/states';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
import { formatDuration } from './status';
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755 const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755

Wyświetl plik

@ -21,6 +21,8 @@ import Avatar from './avatar';
import Icon from './icon'; import Icon from './icon';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
const supportsTouch = 'ontouchstart' in window;
function NavMenu(props) { function NavMenu(props) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { masto, instance, authenticated } = api(); const { masto, instance, authenticated } = api();
@ -210,6 +212,7 @@ function NavMenu(props) {
)} )}
{lists?.length > 0 ? ( {lists?.length > 0 ? (
<SubMenu <SubMenu
openTrigger={supportsTouch ? 'clickOnly' : undefined}
menuClassName="nav-submenu" menuClassName="nav-submenu"
overflow="auto" overflow="auto"
gap={-8} gap={-8}
@ -245,6 +248,7 @@ function NavMenu(props) {
<Icon icon="bookmark" size="l" /> <span>Bookmarks</span> <Icon icon="bookmark" size="l" /> <span>Bookmarks</span>
</MenuLink> </MenuLink>
<SubMenu <SubMenu
openTrigger={supportsTouch ? 'clickOnly' : undefined}
menuClassName="nav-submenu" menuClassName="nav-submenu"
overflow="auto" overflow="auto"
gap={-8} gap={-8}

Wyświetl plik

@ -8,7 +8,7 @@ import dayjs from 'dayjs';
import dayjsTwitter from 'dayjs-twitter'; import dayjsTwitter from 'dayjs-twitter';
import localizedFormat from 'dayjs/plugin/localizedFormat'; import localizedFormat from 'dayjs/plugin/localizedFormat';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import { useMemo } from 'preact/hooks'; import { useEffect, useMemo, useReducer } from 'preact/hooks';
dayjs.extend(dayjsTwitter); dayjs.extend(dayjsTwitter);
dayjs.extend(localizedFormat); dayjs.extend(localizedFormat);
@ -18,22 +18,49 @@ const dtf = new Intl.DateTimeFormat();
export default function RelativeTime({ datetime, format }) { export default function RelativeTime({ datetime, format }) {
if (!datetime) return null; if (!datetime) return null;
const [renderCount, rerender] = useReducer((x) => x + 1, 0);
const date = useMemo(() => dayjs(datetime), [datetime]); const date = useMemo(() => dayjs(datetime), [datetime]);
const dateStr = useMemo(() => { const [dateStr, dt, title] = useMemo(() => {
let str;
if (format === 'micro') { if (format === 'micro') {
// If date <= 1 day ago or day is within this year // If date <= 1 day ago or day is within this year
const now = dayjs(); const now = dayjs();
const dayDiff = now.diff(date, 'day'); const dayDiff = now.diff(date, 'day');
if (dayDiff <= 1 || now.year() === date.year()) { if (dayDiff <= 1 || now.year() === date.year()) {
return date.twitter(); str = date.twitter();
} else { } else {
return dtf.format(date.toDate()); str = dtf.format(date.toDate());
} }
} }
return date.fromNow(); if (!str) str = date.fromNow();
}, [date, format]); return [str, date.toISOString(), date.format('LLLL')];
const dt = useMemo(() => date.toISOString(), [date]); }, [date, format, renderCount]);
const title = useMemo(() => date.format('LLLL'), [date]);
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 ( return (
<time datetime={dt} title={title}> <time datetime={dt} title={title}>

Wyświetl plik

@ -908,7 +908,7 @@
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
gap: 2px; gap: 2px;
/* height: 160px; */ /* height: 160px; */
min-height: var(--pointer-min-dimension); min-height: var(--min-dimension);
height: auto; height: auto;
max-height: max(160px, 33vh); max-height: max(160px, 33vh);
} }
@ -1037,9 +1037,9 @@
.status .media-container.media-eq1 .media { .status .media-container.media-eq1 .media {
display: inline-block; display: inline-block;
max-width: 100% !important; max-width: 100% !important;
min-width: var(--pointer-min-dimension); min-width: var(--min-dimension);
/* width: auto; */ /* width: auto; */
min-height: var(--pointer-min-dimension); min-height: var(--min-dimension);
/* --maxAspectHeight: max(160px, 33vh); /* --maxAspectHeight: max(160px, 33vh);
--aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */ --aspectWidth: calc(--width / --height * var(--maxAspectHeight)); */
width: min(var(--aspectWidth), var(--width), 100%); 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 { :is(.status, .media-post) .media-audio {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: var(--pointer-min-dimension); min-height: var(--min-dimension);
background-image: radial-gradient( background-image: radial-gradient(
circle at center center, circle at center center,
transparent, transparent,

Wyświetl plik

@ -20,6 +20,7 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -2231,9 +2232,9 @@ function Card({ card, selfReferential, instance }) {
); );
if (hasText && (image || (type === 'photo' && blurhash))) { if (hasText && (image || (type === 'photo' && blurhash))) {
const domain = new URL(url).hostname const domain = punycode.toUnicode(
.replace(/^www\./, '') new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
.replace(/\/$/, ''); );
let blurhashImage; let blurhashImage;
const rgbAverageColor = const rgbAverageColor =
image && blurhash ? getBlurHashAverageColor(blurhash) : null; image && blurhash ? getBlurHashAverageColor(blurhash) : null;
@ -2349,7 +2350,9 @@ function Card({ card, selfReferential, instance }) {
// ); // );
} }
if (hasText && !image) { if (hasText && !image) {
const domain = new URL(url).hostname.replace(/^www\./, ''); const domain = punycode.toUnicode(
new URL(url).hostname.replace(/^www\./, ''),
);
return ( return (
<a <a
href={cardStatusURL || url} href={cardStatusURL || url}
@ -2872,21 +2875,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) { function nicePostURL(url) {
if (!url) return; if (!url) return;
const urlObj = new URL(url); const urlObj = new URL(url);
@ -2896,7 +2884,7 @@ function nicePostURL(url) {
const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || []; const [_, username, restPath] = path.match(/\/(@[^\/]+)\/(.*)/) || [];
return ( return (
<> <>
{host} {punycode.toUnicode(host)}
{username ? ( {username ? (
<> <>
/{username} /{username}

Wyświetl plik

@ -51,7 +51,7 @@ function Timeline({
}) { }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('start');
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
const [showNew, setShowNew] = useState(false); const [showNew, setShowNew] = useState(false);
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
@ -496,7 +496,8 @@ function Timeline({
)} )}
</ul> </ul>
) : ( ) : (
uiState !== 'error' && <p class="ui-state">{emptyText}</p> uiState !== 'error' &&
uiState !== 'start' && <p class="ui-state">{emptyText}</p>
)} )}
{uiState === 'error' && ( {uiState === 'error' && (
<p class="ui-state"> <p class="ui-state">

Wyświetl plik

@ -109,13 +109,7 @@
--timing-function: cubic-bezier(0.3, 0.5, 0, 1); --timing-function: cubic-bezier(0.3, 0.5, 0, 1);
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275); --spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
--pointer-min-dimension: 88px; --min-dimension: 88px;
}
@media (pointer: fine) {
:root {
--pointer-min-dimension: 44px;
}
} }
@media (min-resolution: 2dppx) { @media (min-resolution: 2dppx) {
@ -353,6 +347,7 @@ button[hidden] {
} }
input[type='text'], input[type='text'],
input[type='search'],
textarea, textarea,
select { select {
color: var(--text-color); color: var(--text-color);
@ -362,6 +357,7 @@ select {
border-radius: 4px; border-radius: 4px;
} }
input[type='text']:focus, input[type='text']:focus,
input[type='search']:focus,
textarea:focus, textarea:focus,
select:focus { select:focus {
border-color: var(--outline-color); border-color: var(--outline-color);
@ -377,7 +373,7 @@ textarea:disabled {
background-color: var(--bg-faded-color); 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; display: block;
width: 100%; width: 100%;
} }

Wyświetl plik

@ -6,6 +6,7 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -516,7 +517,13 @@ function AccountStatuses() {
> >
<Icon icon="transfer" />{' '} <Icon icon="transfer" />{' '}
<small class="menu-double-lines"> <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> </small>
</MenuItem> </MenuItem>
{!sameCurrentInstance && ( {!sameCurrentInstance && (

Wyświetl plik

@ -13,6 +13,7 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { uid } from 'uid/single'; import { uid } from 'uid/single';
@ -191,6 +192,7 @@ function Catchup() {
const [posts, setPosts] = useState([]); const [posts, setPosts] = useState([]);
const catchupRangeRef = useRef(); const catchupRangeRef = useRef();
const catchupLastRef = useRef();
const NS = useMemo(() => getCurrentAccountNS(), []); const NS = useMemo(() => getCurrentAccountNS(), []);
const handleCatchupClick = useCallback(async ({ duration } = {}) => { const handleCatchupClick = useCallback(async ({ duration } = {}) => {
const now = Date.now(); const now = Date.now();
@ -925,7 +927,15 @@ function Catchup() {
type="button" type="button"
onClick={() => { onClick={() => {
if (range < RANGES[RANGES.length - 1].value) { 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 }); handleCatchupClick({ duration });
} else { } else {
handleCatchupClick(); handleCatchupClick();
@ -935,11 +945,25 @@ function Catchup() {
Catch up Catch up
</button> </button>
</div> </div>
{lastCatchupRange && range > lastCatchupRange && ( {lastCatchupRange && range > lastCatchupRange ? (
<p class="catchup-info"> <p class="catchup-info">
<Icon icon="info" /> Overlaps with your last catch-up <Icon icon="info" /> Overlaps with your last catch-up
</p> </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"> <p class="insignificant">
<small> <small>
Note: your instance might only show a maximum of 800 posts in Note: your instance might only show a maximum of 800 posts in
@ -1076,9 +1100,11 @@ function Catchup() {
height, height,
publishedAt, publishedAt,
} = card; } = card;
const domain = new URL(url).hostname const domain = punycode.toUnicode(
.replace(/^www\./, '') new URL(url).hostname
.replace(/\/$/, ''); .replace(/^www\./, '')
.replace(/\/$/, ''),
);
let accentColor; let accentColor;
if (blurhash) { if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash); const averageColor = getBlurHashAverageColor(blurhash);

Wyświetl plik

@ -28,6 +28,7 @@ const {
PHANPY_WEBSITE: WEBSITE, PHANPY_WEBSITE: WEBSITE,
PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL, PHANPY_PRIVACY_POLICY_URL: PRIVACY_POLICY_URL,
PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL, PHANPY_IMG_ALT_API_URL: IMG_ALT_API_URL,
PHANPY_GIPHY_API_KEY: GIPHY_API_KEY,
} = import.meta.env; } = import.meta.env;
function Settings({ onClose }) { function Settings({ onClose }) {
@ -433,6 +434,37 @@ function Settings({ onClose }) {
</div> </div>
</div> </div>
</li> </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 && ( {!!IMG_ALT_API_URL && authenticated && (
<li> <li>
<label> <label>

Wyświetl plik

@ -12,10 +12,10 @@ import {
useRef, useRef,
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import punycode from 'punycode';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { matchPath, useSearchParams } from 'react-router-dom'; import { matchPath, useSearchParams } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar'; import Avatar from '../components/avatar';
@ -122,7 +122,7 @@ function StatusPage(params) {
}, [showMedia]); }, [showMedia]);
const mediaAttachments = mediaStatusID const mediaAttachments = mediaStatusID
? mediaStatus?.mediaAttachments ? snapStates.statuses[statusKey(mediaStatusID, instance)]?.mediaAttachments
: heroStatus?.mediaAttachments; : heroStatus?.mediaAttachments;
const handleMediaClose = useCallback(() => { const handleMediaClose = useCallback(() => {
@ -1208,7 +1208,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
{postInstance ? ( {postInstance ? (
<> <>
{' '} {' '}
(<b>{postInstance}</b>) (<b>{punycode.toUnicode(postInstance)}</b>)
</> </>
) : ( ) : (
'' ''

Wyświetl plik

@ -3,6 +3,7 @@ import '../components/links-bar.css';
import { MenuItem } from '@szhsin/react-menu'; import { MenuItem } from '@szhsin/react-menu';
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { useMemo, useRef, useState } from 'preact/hooks'; import { useMemo, useRef, useState } from 'preact/hooks';
import punycode from 'punycode';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -161,9 +162,9 @@ function Trending({ columnMode, ...props }) {
url, url,
width, width,
} = link; } = link;
const domain = new URL(url).hostname const domain = punycode.toUnicode(
.replace(/^www\./, '') new URL(url).hostname.replace(/^www\./, '').replace(/\/$/, ''),
.replace(/\/$/, ''); );
let accentColor; let accentColor;
if (blurhash) { if (blurhash) {
const averageColor = getBlurHashAverageColor(blurhash); const averageColor = getBlurHashAverageColor(blurhash);

Wyświetl plik

@ -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')}`;
}
}

Wyświetl plik

@ -67,6 +67,7 @@ const states = proxy({
contentTranslationAutoInline: false, contentTranslationAutoInline: false,
shortcutSettingsCloudImportExport: false, shortcutSettingsCloudImportExport: false,
mediaAltGenerator: false, mediaAltGenerator: false,
composerGIFPicker: false,
cloakMode: false, cloakMode: false,
}, },
}); });
@ -99,6 +100,8 @@ export function initStates() {
store.account.get('settings-shortcutSettingsCloudImportExport') ?? false; store.account.get('settings-shortcutSettingsCloudImportExport') ?? false;
states.settings.mediaAltGenerator = states.settings.mediaAltGenerator =
store.account.get('settings-mediaAltGenerator') ?? false; store.account.get('settings-mediaAltGenerator') ?? false;
states.settings.composerGIFPicker =
store.account.get('settings-composerGIFPicker') ?? false;
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false; states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
} }
@ -140,6 +143,9 @@ subscribe(states, (changes) => {
if (path.join('.') === 'settings.mediaAltGenerator') { if (path.join('.') === 'settings.mediaAltGenerator') {
store.account.set('settings-mediaAltGenerator', !!value); store.account.set('settings-mediaAltGenerator', !!value);
} }
if (path.join('.') === 'settings.composerGIFPicker') {
store.account.set('settings-composerGIFPicker', !!value);
}
if (path?.[0] === 'shortcuts') { if (path?.[0] === 'shortcuts') {
store.account.set('shortcuts', states.shortcuts); store.account.set('shortcuts', states.shortcuts);
} }

Wyświetl plik

@ -83,15 +83,23 @@ function _unfurlMastodonLink(instance, url) {
limit: 1, limit: 1,
}) })
.then((results) => { .then((results) => {
if (results.statuses.length > 0) { const { statuses } = results;
const status = results.statuses[0]; if (statuses.length > 0) {
return { // Filter out statuses that has content that contains the URL, in-case-sensitive
status, const theStatuses = statuses.filter(
instance, (status) =>
}; !status.content?.toLowerCase().includes(theURL.toLowerCase()),
} else { );
throw new Error('No results');
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) { function handleFulfill(result) {