Merge pull request #13 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2022-12-21 21:32:38 +08:00 committed by GitHub
commit 77ba42dba9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 504 additions and 259 deletions

View file

@ -2,7 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<title>Phanpy</title> <title>Phanpy</title>
<meta <meta
name="description" name="description"

11
package-lock.json generated
View file

@ -13,6 +13,7 @@
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"history": "~5.3.0", "history": "~5.3.0",
"iconify-icon": "~1.0.2", "iconify-icon": "~1.0.2",
"just-debounce-it": "^3.2.0",
"masto": "~4.10.1", "masto": "~4.10.1",
"mem": "~9.0.2", "mem": "~9.0.2",
"preact": "~10.11.3", "preact": "~10.11.3",
@ -4046,6 +4047,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/just-debounce-it": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-3.2.0.tgz",
"integrity": "sha512-WXzwLL0745uNuedrCsCs3rpmfD6DBaf7uuVwaq98/8dafURfgQaBsSpjiPp5+CW6Vjltwy9cOGI6qE71b3T8iQ=="
},
"node_modules/kolorist": { "node_modules/kolorist": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz",
@ -8562,6 +8568,11 @@
"integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
"dev": true "dev": true
}, },
"just-debounce-it": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-3.2.0.tgz",
"integrity": "sha512-WXzwLL0745uNuedrCsCs3rpmfD6DBaf7uuVwaq98/8dafURfgQaBsSpjiPp5+CW6Vjltwy9cOGI6qE71b3T8iQ=="
},
"kolorist": { "kolorist": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.6.0.tgz",

View file

@ -15,6 +15,7 @@
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"history": "~5.3.0", "history": "~5.3.0",
"iconify-icon": "~1.0.2", "iconify-icon": "~1.0.2",
"just-debounce-it": "^3.2.0",
"masto": "~4.10.1", "masto": "~4.10.1",
"mem": "~9.0.2", "mem": "~9.0.2",
"preact": "~10.11.3", "preact": "~10.11.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -5,12 +5,17 @@ import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
const imageRoute = new Route( const imageRoute = new Route(
({ request, sameOrigin }) => { ({ request, sameOrigin }) => {
return !sameOrigin && request.destination === 'image'; const isRemote = !sameOrigin;
const isImage = request.destination === 'image';
const isAvatar = request.url.includes('/avatars/');
const isEmoji = request.url.includes('/emoji/');
return isRemote && isImage && (isAvatar || isEmoji);
}, },
new CacheFirst({ new CacheFirst({
cacheName: 'remote-images', cacheName: 'remote-images',
plugins: [ plugins: [
new ExpirationPlugin({ new ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days maxAgeSeconds: 7 * 24 * 60 * 60, // 7 days
purgeOnQuotaError: true, purgeOnQuotaError: true,
}), }),

View file

@ -137,6 +137,7 @@ a.mention span {
transparent transparent
); );
background-repeat: no-repeat; background-repeat: no-repeat;
transition: opacity 0.3s ease-in-out;
} }
.timeline.contextual > li:first-child { .timeline.contextual > li:first-child {
background-position: 0 16px; background-position: 0 16px;
@ -273,6 +274,14 @@ a.mention span {
.timeline.contextual > li.thread .replies li:before { .timeline.contextual > li.thread .replies li:before {
left: calc(50px + 16px + 16px); left: calc(50px + 16px + 16px);
} }
.timeline.contextual.loading > li:not(.hero) {
opacity: 0.5;
pointer-events: none;
/* background-image: none !important; */
}
/* .timeline.contextual.loading > li:not(.hero):before {
content: none !important;
} */
.timeline-deck.compact .status { .timeline-deck.compact .status {
max-height: max(25vh, 160px); max-height: max(25vh, 160px);
@ -334,12 +343,12 @@ a.mention span {
} }
@keyframes slide-in { @keyframes slide-in {
0% { 0% {
opacity: 0; opacity: 0.5;
transform: translateX(100%); transform: translate3d(100%, 0, 0);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translate3d(0, 0, 0);
} }
} }
.deck-backdrop .deck { .deck-backdrop .deck {
@ -347,7 +356,7 @@ a.mention span {
max-width: 100vw; max-width: 100vw;
border-left: 1px solid var(--divider-color); border-left: 1px solid var(--divider-color);
background-color: var(--bg-color); background-color: var(--bg-color);
animation: slide-in 0.2s ease-out; animation: slide-in 0.5s var(--timing-function);
box-shadow: -1px 0 var(--bg-color); box-shadow: -1px 0 var(--bg-color);
} }
.deck-backdrop .deck .status { .deck-backdrop .deck .status {
@ -356,7 +365,7 @@ a.mention span {
.decks { .decks {
flex-grow: 1; flex-grow: 1;
transition: transform 0.2s ease-in-out; transition: transform 0.5s var(--timing-function);
} }
.deck-close { .deck-close {
@ -405,31 +414,6 @@ a.mention span {
width: 40em; width: 40em;
max-width: 100vw; max-width: 100vw;
padding: 16px; padding: 16px;
background-color: var(--bg-color);
border-radius: 8px;
border: 1px solid var(--divider-color);
overflow: auto;
max-height: 90vh;
max-height: 90dvh;
position: relative;
}
.box > :is(h1, h2, h3):first-of-type {
margin-top: 0;
}
.box .close-button {
position: sticky;
top: 0;
float: right;
margin: -16px -8px 0 0;
transform: translate(0, -8px);
}
.box-shadow {
box-shadow: 0px 36px 89px rgb(0 0 0 / 4%),
0px 23.3333px 52.1227px rgb(0 0 0 / 3%),
0px 13.8667px 28.3481px rgb(0 0 0 / 2%), 0px 7.2px 14.4625px rgb(0 0 0 / 2%),
0px 2.93333px 7.25185px rgb(0 0 0 / 2%),
0px 0.666667px 3.50231px rgb(0 0 0 / 1%);
} }
/* CAROUSEL */ /* CAROUSEL */
@ -463,6 +447,9 @@ a.mention span {
max-height: 100vh; max-height: 100vh;
max-height: 100dvh; max-height: 100dvh;
} }
.carousel > * video {
min-height: 80px;
}
.carousel-top-controls { .carousel-top-controls {
top: 0; top: 0;
@ -587,7 +574,7 @@ button.carousel-dot[disabled].active {
padding-right: max(16px, env(safe-area-inset-right)); padding-right: max(16px, env(safe-area-inset-right));
padding-bottom: max(16px, env(safe-area-inset-bottom)); padding-bottom: max(16px, env(safe-area-inset-bottom));
box-shadow: 0 -1px 32px var(--divider-color); box-shadow: 0 -1px 32px var(--divider-color);
animation: slide-up 0.2s ease-out; animation: slide-up 0.2s var(--timing-function);
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
} }
@ -632,13 +619,15 @@ button.carousel-dot[disabled].active {
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
border-radius: 8px; border-radius: 8px;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
box-shadow: 0 0 8px var(--bg-faded-color), 0 4px 8px var(--bg-faded-color),
0 2px 4px var(--bg-faded-color);
} }
.menu-container menu li { .menu-container menu li {
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style: none; list-style: none;
} }
.menu-container > button:is(:active, :focus) + menu, .menu-container > button:is(:hover, :active, :focus) + menu,
.menu-container menu:is(:hover, :active) { .menu-container menu:is(:hover, :active) {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
@ -663,7 +652,7 @@ button.carousel-dot[disabled].active {
display: flex; display: flex;
} }
.decks:has(~ .deck-backdrop) { .decks:has(~ .deck-backdrop) {
transform: translateX(-5vw); transform: translate3d(-5vw, 0, 0);
} }
.deck-backdrop .deck { .deck-backdrop .deck {
width: 50%; width: 50%;

View file

@ -104,7 +104,7 @@ async function startStream() {
export function App() { export function App() {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('loading');
useLayoutEffect(() => { useLayoutEffect(() => {
const theme = store.local.get('theme'); const theme = store.local.get('theme');
@ -144,6 +144,7 @@ export function App() {
url: `https://${instanceURL}`, url: `https://${instanceURL}`,
accessToken, accessToken,
disableVersionCheck: true, disableVersionCheck: true,
timeout: 30_000,
}); });
const mastoAccount = await masto.accounts.verifyCredentials(); const mastoAccount = await masto.accounts.verifyCredentials();
@ -185,6 +186,7 @@ export function App() {
url: `https://${instanceURL}`, url: `https://${instanceURL}`,
accessToken, accessToken,
disableVersionCheck: true, disableVersionCheck: true,
timeout: 30_000,
}); });
setIsLoggedIn(true); setIsLoggedIn(true);
} catch (e) { } catch (e) {
@ -192,6 +194,8 @@ export function App() {
} }
setUIState('default'); setUIState('default');
})(); })();
} else {
setUIState('default');
} }
}, []); }, []);

View file

@ -78,6 +78,7 @@
text-shadow: 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), text-shadow: 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color), 0 1px 10px var(--bg-color),
0 1px 10px var(--bg-color); 0 1px 10px var(--bg-color);
z-index: 2;
} }
#_compose-container .status-preview-legend.reply-to { #_compose-container .status-preview-legend.reply-to {
color: var(--reply-to-color); color: var(--reply-to-color);
@ -359,3 +360,10 @@
width: 100%; width: 100%;
color: var(--red-color); color: var(--red-color);
} }
@media (display-mode: standalone) {
/* No popping in standalone mode */
#compose-container .pop-button {
display: none;
}
}

View file

@ -256,6 +256,12 @@ function Compose({
const canClose = () => { const canClose = () => {
const { value, dataset } = textareaRef.current; const { value, dataset } = textareaRef.current;
// check if loading
if (uiState === 'loading') {
console.log('canClose', { uiState });
return false;
}
// check for status and media attachments // check for status and media attachments
const hasMediaAttachments = mediaAttachments.length > 0; const hasMediaAttachments = mediaAttachments.length > 0;
if (!value && !hasMediaAttachments) { if (!value && !hasMediaAttachments) {
@ -297,6 +303,7 @@ function Compose({
isSelf, isSelf,
hasOnlyAcct, hasOnlyAcct,
sameWithSource, sameWithSource,
uiState,
}); });
return false; return false;
@ -341,7 +348,8 @@ function Compose({
<span> <span>
<button <button
type="button" type="button"
class="light" class="light pop-button"
disabled={uiState === 'loading'}
onClick={() => { onClick={() => {
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window // If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
const containNonIDMediaAttachments = const containNonIDMediaAttachments =
@ -386,6 +394,7 @@ function Compose({
<button <button
type="button" type="button"
class="light close-button" class="light close-button"
disabled={uiState === 'loading'}
onClick={() => { onClick={() => {
if (confirmClose()) { if (confirmClose()) {
onClose(); onClose();
@ -399,7 +408,8 @@ function Compose({
hasOpener && ( hasOpener && (
<button <button
type="button" type="button"
class="light" class="light pop-button"
disabled={uiState === 'loading'}
onClick={() => { onClick={() => {
// If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window // If there are non-ID media attachments (not yet uploaded), show confirmation dialog because they are not going to be passed to the new window
const containNonIDMediaAttachments = const containNonIDMediaAttachments =
@ -532,7 +542,6 @@ function Compose({
const params = { const params = {
file, file,
description, description,
skipPolling: true,
}; };
return masto.mediaAttachments.create(params).then((res) => { return masto.mediaAttachments.create(params).then((res) => {
if (res.id) { if (res.id) {
@ -554,6 +563,7 @@ function Compose({
// Alert all the reasons // Alert all the reasons
results.forEach((result) => { results.forEach((result) => {
if (result.status === 'rejected') { if (result.status === 'rejected') {
console.error(result);
alert(result.reason || `Attachment #${i} failed`); alert(result.reason || `Attachment #${i} failed`);
} }
}); });

View file

@ -6,11 +6,18 @@ import states from '../utils/states';
import Avatar from './avatar'; import Avatar from './avatar';
function NameText({ account, showAvatar, showAcct, short, external }) { function NameText({ account, showAvatar, showAcct, short, external }) {
const { acct, avatar, avatarStatic, id, url, displayName, username, emojis } = const { acct, avatar, avatarStatic, id, url, displayName, emojis } = account;
account; let { username } = account;
const displayNameWithEmoji = emojifyText(displayName, emojis); const displayNameWithEmoji = emojifyText(displayName, emojis);
if (
!short &&
username.toLowerCase().trim() === (displayName || '').toLowerCase().trim()
) {
username = null;
}
return ( return (
<a <a
class={`name-text ${short ? 'short' : ''}`} class={`name-text ${short ? 'short' : ''}`}
@ -35,7 +42,7 @@ function NameText({ account, showAvatar, showAcct, short, external }) {
__html: displayNameWithEmoji, __html: displayNameWithEmoji,
}} }}
/> />
{!showAcct && ( {!showAcct && username && (
<> <>
{' '} {' '}
<i>@{username}</i> <i>@{username}</i>

View file

@ -62,6 +62,7 @@
padding: 16px 16px 20px; padding: 16px 16px 20px;
line-height: 1.5; line-height: 1.5;
align-items: flex-start; align-items: flex-start;
position: relative;
} }
.status.large { .status.large {
--fade-in-out-bg: linear-gradient( --fade-in-out-bg: linear-gradient(
@ -81,7 +82,7 @@
padding-top: 8px; padding-top: 8px;
} }
.status.small { .status.small {
font-size: 95%; /* font-size: 95%; */
} }
.status.skeleton { .status.skeleton {
color: var(--outline-color); color: var(--outline-color);
@ -283,6 +284,7 @@
} }
.status .media-video { .status .media-video {
position: relative; position: relative;
background-clip: padding-box;
} }
.status .media-video:before { .status .media-video:before {
/* draw a circle in the middle */ /* draw a circle in the middle */
@ -408,6 +410,16 @@ a.card:hover {
/* POLLS */ /* POLLS */
.poll {
transition: opacity 0.2s ease-in-out;
}
.poll.loading {
opacity: 0.5;
pointer-events: none;
}
.poll.read-only {
pointer-events: none;
}
.poll-option { .poll-option {
margin-top: 8px; margin-top: 8px;
padding: 8px; padding: 8px;
@ -607,6 +619,26 @@ a.card:hover {
color: var(--green-color); color: var(--green-color);
} }
/* BADGE */
.status-badge {
position: absolute;
top: 4px;
right: 4px;
line-height: 0;
pointer-events: none;
opacity: 0.75;
}
.status-badge .favourite {
color: var(--favourite-color);
}
.status-badge .reblog {
color: var(--reblog-color);
}
.status-badge .bookmark {
color: var(--link-color);
}
/* MISC */ /* MISC */
.status-aside { .status-aside {

View file

@ -130,7 +130,7 @@ function Status({
} }
} }
const [showSpoiler, setShowSpoiler] = useState(false); const showSpoiler = snapStates.spoilers.has(id) || false;
const debugHover = (e) => { const debugHover = (e) => {
if (e.shiftKey) { if (e.shiftKey) {
@ -197,6 +197,13 @@ function Status({
}`} }`}
onMouseEnter={debugHover} onMouseEnter={debugHover}
> >
{size !== 'l' && (
<div class="status-badge">
{reblogged && <Icon class="reblog" icon="rocket" size="s" />}
{favourited && <Icon class="favourite" icon="heart" size="s" />}
{bookmarked && <Icon class="bookmark" icon="bookmark" size="s" />}
</div>
)}
{size !== 's' && ( {size !== 's' && (
<a <a
href={url} href={url}
@ -293,7 +300,11 @@ function Status({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setShowSpoiler(!showSpoiler); if (showSpoiler) {
states.spoilers.delete(id);
} else {
states.spoilers.set(id, true);
}
}} }}
> >
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '} <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} />{' '}
@ -348,7 +359,15 @@ function Status({
}), }),
}} }}
/> />
{!!poll && <Poll poll={poll} readOnly={readOnly} />} {!!poll && (
<Poll
poll={poll}
readOnly={readOnly}
onUpdate={(newPoll) => {
states.statuses.get(id).poll = newPoll;
}}
/>
)}
{!spoilerText && sensitive && !!mediaAttachments.length && ( {!spoilerText && sensitive && !!mediaAttachments.length && (
<button <button
class="plain spoiler" class="plain spoiler"
@ -356,7 +375,11 @@ function Status({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setShowSpoiler(!showSpoiler); if (showSpoiler) {
states.spoilers.delete(id);
} else {
states.spoilers.add(id);
}
}} }}
> >
<Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} /> Sensitive <Icon icon={showSpoiler ? 'eye-open' : 'eye-close'} /> Sensitive
@ -455,6 +478,14 @@ function Status({
count={reblogsCount} count={reblogsCount}
onClick={async () => { onClick={async () => {
try { try {
if (!reblogged) {
const yes = confirm(
'Are you sure that you want to boost this post?',
);
if (!yes) {
return;
}
}
// Optimistic // Optimistic
states.statuses.set(id, { states.statuses.set(id, {
...status, ...status,
@ -607,7 +638,7 @@ video = Video clip
audio = Audio track audio = Audio track
*/ */
function Media({ media, showOriginal, onClick }) { function Media({ media, showOriginal, onClick = () => {} }) {
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } = const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
media; media;
const { original, small, focus } = meta || {}; const { original, small, focus } = meta || {};
@ -850,14 +881,9 @@ function Card({ card }) {
} }
} }
function Poll({ poll, readOnly }) { function Poll({ poll, readOnly, onUpdate = () => {} }) {
const [pollSnapshot, setPollSnapshot] = useState(poll);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
useEffect(() => {
setPollSnapshot(poll);
}, [poll]);
const { const {
expired, expired,
expiresAt, expiresAt,
@ -868,12 +894,16 @@ function Poll({ poll, readOnly }) {
voted, voted,
votersCount, votersCount,
votesCount, votesCount,
} = pollSnapshot; } = poll;
const expiresAtDate = !!expiresAt && new Date(expiresAt); const expiresAtDate = !!expiresAt && new Date(expiresAt);
return ( return (
<div class="poll"> <div
class={`poll ${readOnly ? 'read-only' : ''} ${
uiState === 'loading' ? 'loading' : ''
}`}
>
{voted || expired ? ( {voted || expired ? (
options.map((option, i) => { options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option; const { title, votesCount: optionVotesCount } = option;
@ -930,13 +960,9 @@ function Poll({ poll, readOnly }) {
choices: votes, choices: votes,
}); });
console.log(pollResponse); console.log(pollResponse);
setPollSnapshot(pollResponse); onUpdate(pollResponse);
setUIState('default'); setUIState('default');
}} }}
style={{
pointerEvents: uiState === 'loading' || readOnly ? 'none' : 'auto',
opacity: uiState === 'loading' ? 0.5 : 1,
}}
> >
{options.map((option, i) => { {options.map((option, i) => {
const { title } = option; const { title } = option;
@ -968,6 +994,31 @@ function Poll({ poll, readOnly }) {
)} )}
{!readOnly && ( {!readOnly && (
<p class="poll-meta"> <p class="poll-meta">
{!expired && (
<>
<button
type="button"
class="textual"
disabled={uiState === 'loading'}
onClick={(e) => {
e.preventDefault();
setUIState('loading');
(async () => {
try {
const pollResponse = await masto.poll.fetch(id);
onUpdate(pollResponse);
} catch (e) {
// Silent fail
}
setUIState('default');
})();
}}
>
Refresh
</button>{' '}
&bull;{' '}
</>
)}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '} <span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
{votersCount === 1 ? 'voter' : 'voters'} {votersCount === 1 ? 'voter' : 'voters'}
{votersCount !== votesCount && ( {votersCount !== votesCount && (
@ -1012,10 +1063,10 @@ function EditedAtModal({ statusID, onClose = () => {} }) {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
return ( return (
<div id="edit-history" class="box"> <div id="edit-history" class="sheet">
<button type="button" class="close-button plain large" onClick={onClose}> {/* <button type="button" class="close-button plain large" onClick={onClose}>
<Icon icon="x" alt="Close" /> <Icon icon="x" alt="Close" />
</button> </button> */}
<h2>Edit History</h2> <h2>Edit History</h2>
{uiState === 'error' && <p>Failed to load history</p>} {uiState === 'error' && <p>Failed to load history</p>}
{uiState === 'loading' && ( {uiState === 'loading' && (

View file

@ -29,6 +29,7 @@ if (window.opener) {
url: `https://${instanceURL}`, url: `https://${instanceURL}`,
accessToken, accessToken,
disableVersionCheck: true, disableVersionCheck: true,
timeout: 30_000,
}); });
console.info('Logged in successfully.'); console.info('Logged in successfully.');
} catch (e) { } catch (e) {

View file

@ -30,6 +30,8 @@
--img-bg-color: rgba(128, 128, 128, 0.2); --img-bg-color: rgba(128, 128, 128, 0.2);
--loader-color: #1c1e2199; --loader-color: #1c1e2199;
--comment-line-color: #e5e5e5; --comment-line-color: #e5e5e5;
--timing-function: cubic-bezier(0.3, 0.5, 0, 1);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -118,8 +120,9 @@ button,
vertical-align: middle; vertical-align: middle;
text-decoration: none; text-decoration: none;
} }
button > * { :is(button, .button) > * {
vertical-align: middle; vertical-align: middle;
pointer-events: none;
} }
:is(button, .button):not(:disabled, .disabled):hover { :is(button, .button):not(:disabled, .disabled):hover {
cursor: pointer; cursor: pointer;
@ -154,6 +157,15 @@ button > * {
padding: 12px; padding: 12px;
} }
:is(button, .button).textual {
padding: 0;
margin: 0;
vertical-align: baseline;
color: var(--link-color);
background-color: transparent;
border-radius: 0;
}
input[type='text'], input[type='text'],
textarea, textarea,
select { select {

View file

@ -82,6 +82,7 @@ function Home({ hidden }) {
const diffMins = Math.round(diff / 1000 / 60); const diffMins = Math.round(diff / 1000 / 60);
if (diffMins > 1) { if (diffMins > 1) {
console.log('visible', { lastHidden, diffMins }); console.log('visible', { lastHidden, diffMins });
setUIState('loading');
setTimeout(() => { setTimeout(() => {
(async () => { (async () => {
const newStatus = await masto.timelines.fetchHome({ const newStatus = await masto.timelines.fetchHome({
@ -91,6 +92,7 @@ function Home({ hidden }) {
if (newStatus.length && newStatus[0].id !== states.home[0].id) { if (newStatus.length && newStatus[0].id !== states.home[0].id) {
states.homeNew = newStatus; states.homeNew = newStatus;
} }
setUIState('default');
})(); })();
// loadStatuses(true); // loadStatuses(true);
// states.homeNew = []; // states.homeNew = [];
@ -101,6 +103,7 @@ function Home({ hidden }) {
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
return () => { return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
setUIState('default');
}; };
}, []); }, []);

26
src/pages/login.css Normal file
View file

@ -0,0 +1,26 @@
#login {
padding: 16px;
background-image: radial-gradient(
closest-side at 50% 50%,
var(--bg-color),
transparent
);
}
#login .error {
color: var(--red-color);
}
#login label p {
margin: 0 0 0.25em 0;
padding: 0;
text-transform: uppercase;
font-size: 90%;
font-weight: bold;
color: var(--text-insignificant-color);
}
#login input {
display: block;
width: 100%;
}

View file

@ -1,3 +1,5 @@
import './login.css';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import Loader from '../components/loader'; import Loader from '../components/loader';
@ -53,7 +55,7 @@ function Login() {
}; };
return ( return (
<main class="box" style={{ textAlign: 'center' }}> <main id="login" style={{ textAlign: 'center' }}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<h1>Log in</h1> <h1>Log in</h1>
<label> <label>
@ -82,8 +84,8 @@ function Login() {
<button class="large" disabled={uiState === 'loading'}> <button class="large" disabled={uiState === 'loading'}>
Log in Log in
</button>{' '} </button>{' '}
<Loader hidden={uiState !== 'loading'} />
</p> </p>
<Loader hidden={uiState !== 'loading'} />
<hr /> <hr />
<p> <p>
<a href="https://joinmastodon.org/servers" target="_blank"> <a href="https://joinmastodon.org/servers" target="_blank">

View file

@ -17,20 +17,20 @@
opacity: 0.75; opacity: 0.75;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
} }
.notification-type.favourite { .notification-type.notification-favourite {
color: var(--favourite-color); color: var(--favourite-color);
} }
.notification-type.reblog { .notification-type.notification-reblog {
color: var(--reblog-color); color: var(--reblog-color);
} }
.notification-type.poll, .notification-type.notification-poll,
.notification-type.mention { .notification-type.notification-mention {
color: var(--link-color); color: var(--link-color);
} }
.notification .status-link { .notification .status-link {
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
border: 1px solid var(--divider-color); border: 1px solid var(--outline-color);
max-height: 160px; max-height: 160px;
overflow: hidden; overflow: hidden;
/* fade out mask gradient bottom */ /* fade out mask gradient bottom */
@ -41,9 +41,16 @@
); );
filter: saturate(0.25); filter: saturate(0.25);
} }
.notification .status-link.status-type-mention {
max-height: 320px;
filter: none;
background-color: var(--bg-color);
margin-top: calc(-16px - 1px);
}
.notification .status-link:hover { .notification .status-link:hover {
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
filter: saturate(1); filter: saturate(1);
border-color: var(--outline-hover-color);
} }
.notification .status-link > * { .notification .status-link > * {
pointer-events: none; pointer-events: none;

View file

@ -41,7 +41,7 @@ const contentText = {
update: 'A status you interacted with has been edited.', update: 'A status you interacted with has been edited.',
}; };
const LIMIT = 20; const LIMIT = 30; // 30 is the maximum limit :(
function Notification({ notification }) { function Notification({ notification }) {
const { id, type, status, account, _accounts } = notification; const { id, type, status, account, _accounts } = notification;
@ -61,7 +61,7 @@ function Notification({ notification }) {
return ( return (
<> <>
<div <div
class={`notification-type ${type}`} class={`notification-type notification-${type}`}
title={new Date(notification.createdAt).toLocaleString()} title={new Date(notification.createdAt).toLocaleString()}
> >
<Icon <Icon
@ -82,34 +82,36 @@ function Notification({ notification }) {
/> />
</div> </div>
<div class="notification-content"> <div class="notification-content">
<p> {type !== 'mention' && (
{!/poll|update/i.test(type) && ( <p>
<> {!/poll|update/i.test(type) && (
{_accounts?.length > 1 ? ( <>
<> {_accounts?.length > 1 ? (
<b>{_accounts.length} people</b>{' '} <>
</> <b>{_accounts.length} people</b>{' '}
) : ( </>
<> ) : (
<NameText account={account} showAvatar />{' '} <>
</> <NameText account={account} showAvatar />{' '}
)} </>
</> )}
)} </>
{text} )}
{type === 'mention' && ( {text}
<span class="insignificant"> {type === 'mention' && (
{' '} <span class="insignificant">
{' '} {' '}
<relative-time {' '}
datetime={notification.createdAt} <relative-time
format="micro" datetime={notification.createdAt}
threshold="P1D" format="micro"
prefix="" threshold="P1D"
/> prefix=""
</span> />
)} </span>
</p> )}
</p>
)}
{_accounts?.length > 1 && ( {_accounts?.length > 1 && (
<p> <p>
{_accounts.map((account, i) => ( {_accounts.map((account, i) => (
@ -142,7 +144,10 @@ function Notification({ notification }) {
</p> </p>
)} )}
{status && ( {status && (
<Link class="status-link" href={`#/s/${actualStatusID}`}> <Link
class={`status-link status-type-${type}`}
href={`#/s/${actualStatusID}`}
>
<Status status={status} size="s" /> <Status status={status} size="s" />
</Link> </Link>
)} )}

View file

@ -1,10 +1,5 @@
#settings-container {
padding-bottom: 3em;
animation: fade-in 0.2s ease-out;
}
#settings-container h2 { #settings-container h2 {
font-size: .9em; font-size: 0.9em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
} }
@ -48,7 +43,7 @@
text-align: right; text-align: right;
} }
#settings-container div, #settings-container div,
#settings-container div > *{ #settings-container div > * {
vertical-align: middle; vertical-align: middle;
} }
#settings-container .avatar { #settings-container .avatar {
@ -63,7 +58,7 @@
overflow: hidden; overflow: hidden;
padding: 1px; padding: 1px;
} }
#settings-container .radio-group input[type="radio"] { #settings-container .radio-group input[type='radio'] {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
@ -87,4 +82,4 @@
} }
#settings-container .radio-group label:has(input:checked) input:checked + span { #settings-container .radio-group label:has(input:checked) input:checked + span {
color: inherit; color: inherit;
} }

View file

@ -23,10 +23,10 @@ function Settings({ onClose }) {
const [currentDefault, setCurrentDefault] = useState(0); const [currentDefault, setCurrentDefault] = useState(0);
return ( return (
<div id="settings-container" class="box"> <div id="settings-container" class="sheet">
<button type="button" class="close-button plain large" onClick={onClose}> {/* <button type="button" class="close-button plain large" onClick={onClose}>
<Icon icon="x" alt="Close" /> <Icon icon="x" alt="Close" />
</button> </button> */}
<h2>Accounts</h2> <h2>Accounts</h2>
<ul class="accounts-list"> <ul class="accounts-list">
{accounts.map((account, i) => { {accounts.map((account, i) => {

View file

@ -1,3 +1,4 @@
import debounce from 'just-debounce-it';
import { Link } from 'preact-router/match'; import { Link } from 'preact-router/match';
import { import {
useEffect, useEffect,
@ -13,124 +14,168 @@ import Loader from '../components/loader';
import Status from '../components/status'; import Status from '../components/status';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
function StatusPage({ id }) { function StatusPage({ id }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [statuses, setStatuses] = useState([{ id }]); const [statuses, setStatuses] = useState([]);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const userInitiated = useRef(true); // Initial open is user-initiated
const heroStatusRef = useRef(); const heroStatusRef = useRef();
useEffect(async () => { const scrollableRef = useRef();
const containsStatus = statuses.find((s) => s.id === id); useEffect(() => {
const statusesWithSameAccountID = statuses.filter( const onScroll = debounce(() => {
(s) => s.accountID === containsStatus?.accountID, // console.log('onScroll');
); const { scrollTop } = scrollableRef.current;
if (statusesWithSameAccountID.length > 1) { states.scrollPositions.set(id, scrollTop);
setStatuses( }, 100);
statusesWithSameAccountID.map((s) => ({ scrollableRef.current.addEventListener('scroll', onScroll, {
...s, passive: true,
thread: true, });
descendant: undefined, onScroll();
ancestor: undefined, return () => {
})), scrollableRef.current?.removeEventListener('scroll', onScroll);
); };
} else { }, [id]);
setStatuses([{ id }]);
}
useEffect(() => {
setUIState('loading'); setUIState('loading');
const hasStatus = snapStates.statuses.has(id); const containsStatus = statuses.find((s) => s.id === id);
let heroStatus = snapStates.statuses.get(id); if (!containsStatus) {
try { // Case 1: On first load, or when navigating to a status that's not cached at all
heroStatus = await masto.statuses.fetch(id); setStatuses([{ id }]);
states.statuses.set(id, heroStatus); } else {
} catch (e) { const cachedStatuses = store.session.getJSON('statuses-' + id);
// Silent fail if status is cached if (cachedStatuses) {
if (!hasStatus) { // Case 2: Looks like we've cached this status before, let's restore them to make it snappy
setUIState('error'); const reallyCachedStatuses = cachedStatuses.filter(
alert('Error fetching status'); (s) => snapStates.statuses.has(s.id),
// Some are not cached in the global state, so we need to filter them out
);
setStatuses(reallyCachedStatuses);
} else {
// Case 3: Unknown state, could be a sub-comment. Let's slice off all descendant statuses after the hero status to be safe because they are custom-rendered with sub-comments etc
const heroIndex = statuses.findIndex((s) => s.id === id);
const slicedStatuses = statuses.slice(0, heroIndex + 1);
setStatuses(slicedStatuses);
} }
return;
} }
try { (async () => {
const context = await masto.statuses.fetchContext(id); const hasStatus = snapStates.statuses.has(id);
const { ancestors, descendants } = context; let heroStatus = snapStates.statuses.get(id);
try {
ancestors.forEach((status) => { heroStatus = await masto.statuses.fetch(id);
states.statuses.set(status.id, status); states.statuses.set(id, heroStatus);
}); } catch (e) {
const nestedDescendants = []; // Silent fail if status is cached
descendants.forEach((status) => { if (!hasStatus) {
states.statuses.set(status.id, status); setUIState('error');
if (status.inReplyToAccountId === status.account.id) { alert('Error fetching status');
// If replying to self, it's part of the thread, level 1
nestedDescendants.push(status);
} else if (status.inReplyToId === heroStatus.id) {
// If replying to the hero status, it's a reply, level 1
nestedDescendants.push(status);
} else {
// If replying to someone else, it's a reply to a reply, level 2
const parent = descendants.find((s) => s.id === status.inReplyToId);
if (parent) {
if (!parent.__replies) {
parent.__replies = [];
}
parent.__replies.push(status);
} else {
// If no parent, it's probably a reply to a reply to a reply, level 3
console.warn('[LEVEL 3] No parent found for', status);
}
} }
}); return;
}
console.log({ ancestors, descendants, nestedDescendants }); try {
const context = await masto.statuses.fetchContext(id);
const { ancestors, descendants } = context;
const allStatuses = [ ancestors.forEach((status) => {
...ancestors.map((s) => ({ states.statuses.set(status.id, status);
id: s.id, });
ancestor: true, const nestedDescendants = [];
accountID: s.account.id, descendants.forEach((status) => {
})), states.statuses.set(status.id, status);
{ id, accountID: heroStatus.account.id }, if (status.inReplyToAccountId === status.account.id) {
...nestedDescendants.map((s) => ({ // If replying to self, it's part of the thread, level 1
id: s.id, nestedDescendants.push(status);
accountID: s.account.id, } else if (status.inReplyToId === heroStatus.id) {
descendant: true, // If replying to the hero status, it's a reply, level 1
thread: s.account.id === heroStatus.account.id, nestedDescendants.push(status);
replies: s.__replies?.map((r) => r.id), } else {
})), // If replying to someone else, it's a reply to a reply, level 2
]; const parent = descendants.find((s) => s.id === status.inReplyToId);
console.log({ allStatuses }); if (parent) {
setStatuses(allStatuses); if (!parent.__replies) {
} catch (e) { parent.__replies = [];
console.error(e); }
setUIState('error'); parent.__replies.push(status);
} } else {
// If no parent, it's probably a reply to a reply to a reply, level 3
console.warn('[LEVEL 3] No parent found for', status);
}
}
});
setUIState('default'); console.log({ ancestors, descendants, nestedDescendants });
const allStatuses = [
...ancestors.map((s) => ({
id: s.id,
ancestor: true,
accountID: s.account.id,
})),
{ id, accountID: heroStatus.account.id },
...nestedDescendants.map((s) => ({
id: s.id,
accountID: s.account.id,
descendant: true,
thread: s.account.id === heroStatus.account.id,
replies: s.__replies?.map((r) => r.id),
})),
];
setUIState('default');
console.log({ allStatuses });
setStatuses(allStatuses);
store.session.setJSON('statuses-' + id, allStatuses);
} catch (e) {
console.error(e);
setUIState('error');
}
})();
}, [id, snapStates.reloadStatusPage]); }, [id, snapStates.reloadStatusPage]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (heroStatusRef.current && statuses.length > 1) { if (!statuses.length) return;
heroStatusRef.current.scrollIntoView({ const isLoading = uiState === 'loading';
behavior: 'smooth', if (userInitiated.current) {
block: 'start', const hasAncestors = statuses.findIndex((s) => s.id === id) > 0; // Cannot use `ancestor` key because the hero state is dynamic
}); if (!isLoading && hasAncestors) {
// Case 1: User initiated, has ancestors, after statuses are loaded, SNAP to hero status
console.log('Case 1');
heroStatusRef.current?.scrollIntoView();
} else if (isLoading && statuses.length > 1) {
// Case 2: User initiated, while statuses are loading, SMOOTH-SCROLL to hero status
console.log('Case 2');
heroStatusRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
} else {
const scrollPosition = states.scrollPositions.get(id);
if (scrollPosition && scrollableRef.current) {
// Case 3: Not user initiated (e.g. back/forward button), restore to saved scroll position
console.log('Case 3');
scrollableRef.current.scrollTop = scrollPosition;
}
} }
}, [id]); console.log('No case', {
isLoading,
userInitiated: userInitiated.current,
statusesLength: statuses.length,
// scrollPosition,
});
useLayoutEffect(() => { if (!isLoading) {
const hasAncestor = statuses.some((s) => s.ancestor); // Reset user initiated flag after statuses are loaded
if (hasAncestor) { userInitiated.current = false;
heroStatusRef.current?.scrollIntoView({
// behavior: 'smooth',
block: 'start',
});
} }
}, [statuses]); }, [statuses, uiState]);
const heroStatus = snapStates.statuses.get(id); const heroStatus = snapStates.statuses.get(id);
const heroDisplayName = useMemo(() => { const heroDisplayName = useMemo(() => {
@ -177,11 +222,13 @@ function StatusPage({ id }) {
}, [statuses.length, limit]); }, [statuses.length, limit]);
const hasManyStatuses = statuses.length > 40; const hasManyStatuses = statuses.length > 40;
const hasDescendants = statuses.some((s) => s.descendant);
return ( return (
<div class="deck-backdrop"> <div class="deck-backdrop">
<Link href={closeLink}></Link> <Link href={closeLink}></Link>
<div <div
ref={scrollableRef}
class={`status-deck deck contained ${ class={`status-deck deck contained ${
statuses.length > 1 ? 'padded-bottom' : '' statuses.length > 1 ? 'padded-bottom' : ''
}`} }`}
@ -200,7 +247,11 @@ function StatusPage({ id }) {
</Link> </Link>
</div> </div>
</header> </header>
<ul class="timeline flat contextual"> <ul
class={`timeline flat contextual ${
uiState === 'loading' ? 'loading' : ''
}`}
>
{statuses.slice(0, limit).map((status) => { {statuses.slice(0, limit).map((status) => {
const { const {
id: statusID, id: statusID,
@ -216,7 +267,7 @@ function StatusPage({ id }) {
ref={isHero ? heroStatusRef : null} ref={isHero ? heroStatusRef : null}
class={`${ancestor ? 'ancestor' : ''} ${ class={`${ancestor ? 'ancestor' : ''} ${
descendant ? 'descendant' : '' descendant ? 'descendant' : ''
} ${thread ? 'thread' : ''}`} } ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
> >
{isHero ? ( {isHero ? (
<Status statusID={statusID} withinContext size="l" /> <Status statusID={statusID} withinContext size="l" />
@ -226,6 +277,9 @@ function StatusPage({ id }) {
status-link status-link
" "
href={`#/s/${statusID}`} href={`#/s/${statusID}`}
onClick={() => {
userInitiated.current = true;
}}
> >
<Status <Status
statusID={statusID} statusID={statusID}
@ -245,7 +299,13 @@ function StatusPage({ id }) {
<ul> <ul>
{replies.map((replyID) => ( {replies.map((replyID) => (
<li key={replyID}> <li key={replyID}>
<Link class="status-link" href={`#/s/${replyID}`}> <Link
class="status-link"
href={`#/s/${replyID}`}
onClick={() => {
userInitiated.current = true;
}}
>
<Status statusID={replyID} withinContext size="s" /> <Status statusID={replyID} withinContext size="s" />
</Link> </Link>
</li> </li>
@ -256,7 +316,7 @@ function StatusPage({ id }) {
{uiState === 'loading' && {uiState === 'loading' &&
isHero && isHero &&
!!heroStatus?.repliesCount && !!heroStatus?.repliesCount &&
statuses.length === 1 && ( !hasDescendants && (
<div class="status-loading"> <div class="status-loading">
<Loader /> <Loader />
</div> </div>

View file

@ -1,39 +1,41 @@
#welcome { #welcome {
text-align: center; text-align: center;
background-image: radial-gradient(
closest-side at 50% 50%,
var(--bg-faded-color),
transparent
);
padding: 16px;
} }
#welcome img { #welcome h1 {
margin-top: 16px; margin: 0;
height: auto; padding: 0;
font-size: 1.2em;
} }
@keyframes dance { #welcome img {
vertical-align: top;
}
#welcome h2 {
font-size: 3em;
letter-spacing: -0.05ex;
margin: 16px 0;
padding: 0;
/* gradiented text */
background: linear-gradient(45deg, var(--purple-color), var(--red-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@keyframes psychedelic {
0% { 0% {
transform: rotate(0deg); filter: hue-rotate(0deg);
}
20% {
transform: rotate(5deg);
}
40% {
transform: rotate(-5deg);
}
60% {
transform: rotate(5deg);
}
80% {
transform: rotate(-5deg);
} }
100% { 100% {
transform: rotate(0deg); filter: hue-rotate(360deg);
} }
} }
#welcome:hover img { #welcome:hover h2 {
animation: dance 2s infinite 15s linear; animation: psychedelic 60s infinite;
} }
#welcome .warning {
font-weight: bold;
padding: 16px;
background: lemonchiffon;
color: chocolate;
border-radius: 16px;
}

View file

@ -6,22 +6,25 @@ import useTitle from '../utils/useTitle';
function Welcome() { function Welcome() {
useTitle(); useTitle();
return ( return (
<main id="welcome" class="box"> <main id="welcome">
<img <h1>
src={logo} <img
alt="" src={logo}
width="140" alt=""
height="140" width="24"
style={{ height="24"
aspectRatio: '1/1', style={{
}} aspectRatio: '1/1',
/> }}
<h1>Welcome</h1> />{' '}
<p>Phanpy is a minimalistic opinionated Mastodon web client.</p> Phanpy
<p class="warning"> </h1>
🚧 This is an early ALPHA project. Many features are missing, many bugs <h2>
are present. Please report issues as detailed as possible. Thanks 🙏 Trunk-tastic
</p> <br />
Mastodon Experience
</h2>
<p>A minimalistic opinionated Mastodon web client.</p>
<p> <p>
<big> <big>
<b> <b>

View file

@ -10,8 +10,10 @@ export default proxy({
notifications: [], notifications: [],
notificationsNew: [], notificationsNew: [],
notificationsLastFetchTime: null, notificationsLastFetchTime: null,
accounts: new WeakMap(), accounts: new Map(),
reloadStatusPage: 0, reloadStatusPage: 0,
spoilers: proxyMap([]),
scrollPositions: new Map(),
// Modals // Modals
showCompose: false, showCompose: false,
showSettings: false, showSettings: false,

View file

@ -35,6 +35,12 @@ export default defineConfig({
sizes: '512x512', sizes: '512x512',
type: 'image/png', type: 'image/png',
}, },
{
src: 'logo-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
], ],
}, },
strategies: 'injectManifest', strategies: 'injectManifest',