Merge pull request #97 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-04-25 22:54:01 +08:00 committed by GitHub
commit ad45bf9d19
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 2523 additions and 958 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Lim Chee Aun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

18
package-lock.json generated
View file

@ -33,7 +33,7 @@
"toastify-js": "~1.12.0", "toastify-js": "~1.12.0",
"uid": "~2.0.2", "uid": "~2.0.2",
"use-debounce": "~9.0.3", "use-debounce": "~9.0.3",
"use-long-press": "~2.0.3", "use-long-press": "~3.0.4",
"use-resize-observer": "~9.1.0", "use-resize-observer": "~9.1.0",
"valtio": "1.9.0" "valtio": "1.9.0"
}, },
@ -6730,13 +6730,9 @@
} }
}, },
"node_modules/use-long-press": { "node_modules/use-long-press": {
"version": "2.0.3", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz", "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.0.4.tgz",
"integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==", "integrity": "sha512-+/qkbuRjsrzi30aSIE6lrq0+7TSGKUg6drbk/jSNqJqeWWRIjj5/XQoA9YzQC+IVVwkmcknK8MLi/HtAfNFvPA==",
"engines": {
"node": ">=10",
"npm": ">=5"
},
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
} }
@ -11861,9 +11857,9 @@
"requires": {} "requires": {}
}, },
"use-long-press": { "use-long-press": {
"version": "2.0.3", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz", "resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.0.4.tgz",
"integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==", "integrity": "sha512-+/qkbuRjsrzi30aSIE6lrq0+7TSGKUg6drbk/jSNqJqeWWRIjj5/XQoA9YzQC+IVVwkmcknK8MLi/HtAfNFvPA==",
"requires": {} "requires": {}
}, },
"use-resize-observer": { "use-resize-observer": {

View file

@ -35,7 +35,7 @@
"toastify-js": "~1.12.0", "toastify-js": "~1.12.0",
"uid": "~2.0.2", "uid": "~2.0.2",
"use-debounce": "~9.0.3", "use-debounce": "~9.0.3",
"use-long-press": "~2.0.3", "use-long-press": "~3.0.4",
"use-resize-observer": "~9.1.0", "use-resize-observer": "~9.1.0",
"valtio": "1.9.0" "valtio": "1.9.0"
}, },
@ -59,7 +59,11 @@
"postcss": { "postcss": {
"plugins": { "plugins": {
"postcss-dark-theme-class": {}, "postcss-dark-theme-class": {},
"postcss-preset-env": {} "postcss-preset-env": {
"features": {
"logical-properties-and-values": false
}
}
} }
}, },
"browserslist": [ "browserslist": [

View file

@ -33,6 +33,27 @@ const imageRoute = new Route(
); );
registerRoute(imageRoute); registerRoute(imageRoute);
const iconsRoute = new Route(
({ request, sameOrigin }) => {
const isIcon = request.url.includes('/icons/');
return sameOrigin && isIcon;
},
new CacheFirst({
cacheName: 'icons',
plugins: [
new ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
purgeOnQuotaError: true,
}),
new CacheableResponsePlugin({
statuses: [0, 200],
}),
],
}),
);
registerRoute(iconsRoute);
// 1-day cache for // 1-day cache for
// - /api/v1/instance // - /api/v1/instance
// - /api/v1/custom_emojis // - /api/v1/custom_emojis

View file

@ -20,6 +20,9 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
#app-standalone {
background-color: var(--bg-faded-color);
}
/* MENTIONS */ /* MENTIONS */
@ -53,6 +56,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
overscroll-behavior: contain; overscroll-behavior: contain;
scroll-behavior: smooth; scroll-behavior: smooth;
background-color: var(--bg-color); background-color: var(--bg-color);
/* This `transform` fixes carousel blocking vertical scrolling for pointer devices on iPad */
transform: translateZ(0);
} }
.deck-container[hidden] { .deck-container[hidden] {
display: block; display: block;
@ -154,6 +159,41 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
padding-bottom: 80dvh !important; padding-bottom: 80dvh !important;
} }
@keyframes indeterminate-bar {
0% {
transform: translateX(-50%);
opacity: 0.25;
}
50% {
opacity: 1;
}
100% {
transform: translateX(50%);
opacity: 0.25;
}
}
.deck > header.loading:after {
pointer-events: none;
content: '';
display: block;
height: 4px;
position: absolute;
bottom: 0;
width: 50%;
left: 25%;
background-image: radial-gradient(
farthest-side at bottom,
var(--link-color),
transparent
);
animation: indeterminate-bar 1s ease-in-out infinite alternate;
}
@media (min-width: 40em) {
.deck > header.loading:after {
height: 8px;
}
}
.timeline { .timeline {
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0;
@ -199,7 +239,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
} }
.timeline.contextual > li:first-child { .timeline.contextual > li:first-child {
background-position: 0 16px; background-position: 0 calc(16px + var(--avatar-size));
} }
.timeline.contextual > li:last-child { .timeline.contextual > li:last-child {
background-size: 100% 20px; background-size: 100% 20px;
@ -376,6 +416,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
padding-bottom: 12px; padding-bottom: 12px;
font-size: 90%; font-size: 90%;
} }
.timeline.contextual > li.ancestor .replies-link {
margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
);
}
.timeline.contextual > li.thread > .status-link .replies-link { .timeline.contextual > li.thread > .status-link .replies-link {
margin-left: calc( margin-left: calc(
var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end) var(--avatar-size) + var(--avatar-margin-start) + var(--avatar-margin-end)
@ -550,6 +595,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
} }
.timeline:not(.flat) > li.timeline-item-container { .timeline:not(.flat) > li.timeline-item-container {
--avatar-size: 50px;
--line-start: 40px; --line-start: 40px;
--line-width: 3px; --line-width: 3px;
--line-end: calc(var(--line-start) + var(--line-width)); --line-end: calc(var(--line-start) + var(--line-width));
@ -569,7 +615,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
border-bottom: 0; border-bottom: 0;
background-position: 0 16px; background-position: 0 calc(16px + var(--avatar-size));
} }
.timeline:not(.flat) > li.timeline-item-container-middle { .timeline:not(.flat) > li.timeline-item-container-middle {
margin-top: 0; margin-top: 0;
@ -583,21 +629,23 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
border-top-left-radius: 0; border-top-left-radius: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
border-top: 0; border-top: 0;
background-size: 100% 20px; background-size: 100% 16px;
} }
.timeline:not(.flat) .timeline:not(.flat)
> li:is(.timeline-item-container-middle, .timeline-item-container-end) > li:is(.timeline-item-container-middle, .timeline-item-container-end)
.status-reply-to:not(.visibility-direct) { .status-reply-to:not(.visibility-direct):not(.status-card) {
background-image: none; background-image: none;
} }
.status-loading { .status-loading {
text-align: center; text-align: center;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
max-width: var(--main-width);
} }
.status-error { .status-error {
text-align: center; text-align: center;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
max-width: var(--main-width);
} }
.status-link { .status-link {
@ -688,7 +736,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
counter-increment: index; counter-increment: index;
position: relative; position: relative;
} }
@media (hover: hover) or (pointer: fine) { @media (hover: hover) or (pointer: fine) or (min-width: 40em) {
.status-carousel ul { .status-carousel ul {
scroll-snap-type: none; scroll-snap-type: none;
} }
@ -779,12 +827,37 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
width: var(--main-width); width: var(--main-width);
max-width: 100vw; max-width: 100vw;
background-color: var(--bg-color); background-color: var(--bg-color);
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.slide-in:not(.deck-view-full) {
animation: slide-in 0.5s var(--timing-function);
}
.deck-backdrop .deck .status { .deck-backdrop .deck .status {
max-width: var(--main-width); max-width: var(--main-width);
} }
.deck-backdrop .deck .menu-switch-view {
display: none;
}
@media (min-width: 40em) {
.deck-backdrop .deck .menu-switch-view {
display: flex;
}
.deck-backdrop .deck.deck-view-full {
min-width: 100%;
background-image: radial-gradient(
circle,
transparent 30em,
var(--bg-faded-color)
);
}
.deck-backdrop .deck.deck-view-full > * {
max-width: calc(var(--main-width) + 32px);
margin: 0 auto;
}
.deck-backdrop .deck.deck-view-full .status {
max-width: 100%;
}
}
.deck-close { .deck-close {
color: var(--text-insignificant-color) !important; color: var(--text-insignificant-color) !important;
@ -848,6 +921,23 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
/* CAROUSEL */ /* CAROUSEL */
/* use snap, center children, max width viewport */ /* use snap, center children, max width viewport */
.media-modal-container {
position: relative;
width: 100%;
background-color: var(--backdrop-solid-color);
animation: appear 0.3s var(--timing-function) both;
}
.media-modal-container.loading {
display: flex;
justify-content: center;
align-items: center;
background-image: radial-gradient(
closest-side,
var(--bg-blur-color),
transparent
);
}
.carousel { .carousel {
display: flex; display: flex;
overflow-x: auto; overflow-x: auto;
@ -912,7 +1002,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
top: env(safe-area-inset-top, 0); top: env(safe-area-inset-top, 0);
} }
:is(.carousel-top-controls, .carousel-controls) { :is(.carousel-top-controls, .carousel-controls) {
position: fixed; position: absolute;
left: 0; left: 0;
left: env(safe-area-inset-left, 0); left: env(safe-area-inset-left, 0);
right: 0; right: 0;
@ -927,6 +1017,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
:is(.carousel-top-controls, .carousel-controls)[hidden] { :is(.carousel-top-controls, .carousel-controls)[hidden] {
opacity: 0; opacity: 0;
} }
.carousel-controls {
top: 45%;
}
:is(.button, button).carousel-button, :is(.button, button).carousel-button,
button.carousel-dot { button.carousel-dot {
@ -994,6 +1087,19 @@ body:has(.status-deck) .media-post-link {
display: none; display: none;
} }
/* ✨ New */
body:has(.media-modal-container + .status-deck) .media-post-link {
display: inline-block;
}
.media-modal-container + .status-deck {
/* display: none; */
position: absolute;
z-index: -1;
pointer-events: none;
user-select: none;
animation: none;
}
@media (min-width: calc(40em + 350px)) { @media (min-width: calc(40em + 350px)) {
.media-post-link .button-label { .media-post-link .button-label {
display: inline; display: inline;
@ -1019,6 +1125,26 @@ body:has(.status-deck) .media-post-link {
right: 350px; right: 350px;
width: auto; width: auto;
} }
/* ✨ New */
.deck-backdrop > a {
width: 100%;
flex-grow: 0;
}
.deck-backdrop .media-modal-container + .status-deck:not(.deck-view-full) {
/* display: block; */
/* width: 350px; */
min-width: 350px;
position: static;
z-index: 1;
pointer-events: auto;
user-select: auto;
}
.deck-backdrop .media-modal-container + .status-deck:not(.slide-in) {
animation: appear 0.3s ease-in-out;
}
body:has(.media-modal-container + .status-deck) .media-post-link {
display: none;
}
} }
/* COMPOSE BUTTON */ /* COMPOSE BUTTON */
@ -1084,6 +1210,7 @@ body:has(.status-deck) .media-post-link {
box-shadow: 0 -1px 32px var(--drop-shadow-color); box-shadow: 0 -1px 32px var(--drop-shadow-color);
animation: slide-up 0.3s var(--timing-function); animation: slide-up 0.3s var(--timing-function);
/* border: 1px solid var(--outline-color); */ /* border: 1px solid var(--outline-color); */
position: relative;
} }
.sheet-max { .sheet-max {
width: 90vw; width: 90vw;
@ -1092,12 +1219,52 @@ body:has(.status-deck) .media-post-link {
height: 90vh; height: 90vh;
height: 90dvh; height: 90dvh;
} }
.sheet .sheet-close {
position: absolute;
border-radius: 0;
padding: 0;
right: env(safe-area-inset-right);
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 2;
background-color: transparent;
background-image: radial-gradient(
circle,
var(--close-button-bg-color) 0px 14px,
transparent 14px
);
color: var(--close-button-color);
}
.sheet .sheet-close.outer {
margin-top: -44px;
background-image: radial-gradient(
circle,
var(--bg-faded-color) 0px 14px,
transparent 14px
);
}
.sheet .sheet-close:is(:hover, :focus) {
color: var(--close-button-hover-color);
}
.sheet .sheet-close:active {
background-image: radial-gradient(
circle,
var(--close-button-bg-active-color) 0px 14px,
transparent 14px
);
}
.sheet header { .sheet header {
padding: 16px 16px 8px; padding: 16px 16px 8px;
padding-left: max(16px, env(safe-area-inset-left)); padding-left: max(16px, env(safe-area-inset-left));
padding-right: max(16px, env(safe-area-inset-right)); padding-right: max(16px, env(safe-area-inset-right));
user-select: none; user-select: none;
} }
.sheet .sheet-close:not(.outer) + header {
padding-right: max(44px, env(safe-area-inset-right));
}
.sheet header :is(h1, h2, h3) { .sheet header :is(h1, h2, h3) {
margin: 0; margin: 0;
} }
@ -1213,9 +1380,9 @@ body:has(.status-deck) .media-post-link {
text-overflow: ellipsis; text-overflow: ellipsis;
line-height: 1.05; line-height: 1.05;
} }
.szh-menu .szh-menu__item * { /* .szh-menu .szh-menu__item * {
vertical-align: middle; vertical-align: middle;
} } */
.szh-menu .szh-menu__item a { .szh-menu .szh-menu__item a {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
@ -1227,6 +1394,8 @@ body:has(.status-deck) .media-post-link {
padding: 8px 16px !important; padding: 8px 16px !important;
margin: -8px -16px !important; margin: -8px -16px !important;
align-items: center; align-items: center;
user-select: none;
-webkit-touch-callout: none;
} }
.szh-menu .szh-menu__item a.is-active { .szh-menu .szh-menu__item a.is-active {
font-weight: bold; font-weight: bold;
@ -1268,6 +1437,18 @@ body:has(.status-deck) .media-post-link {
.szh-menu .menu-horizontal .szh-menu__item { .szh-menu .menu-horizontal .szh-menu__item {
flex: 1; flex: 1;
} }
.szh-menu .menu-horizontal .szh-menu__item:not(:only-child):first-child {
padding-right: 4px !important;
}
.szh-menu
.menu-horizontal
.szh-menu__item:not(:only-child):not(:first-child):not(:last-child) {
padding-left: 8px !important;
padding-right: 4px !important;
}
.szh-menu .menu-horizontal .szh-menu__item:not(:only-child):last-child {
padding-left: 8px !important;
}
.szh-menu .szh-menu__item .menu-shortcut { .szh-menu .szh-menu__item .menu-shortcut {
opacity: 0.5; opacity: 0.5;
font-weight: normal; font-weight: normal;
@ -1581,6 +1762,16 @@ ul.link-list li a .icon {
overscroll-behavior: auto; overscroll-behavior: auto;
flex-basis: min(100vw, 360px); flex-basis: min(100vw, 360px);
flex-shrink: 0; flex-shrink: 0;
box-shadow: -1px 0 var(--bg-color), -2px 0 var(--drop-shadow-color),
-3px 0 var(--bg-color);
}
#columns:has(> :nth-child(3)) > *:nth-child(even),
#columns:has(> :nth-child(3))
> *:nth-child(even)
.timeline-deck
> header
.header-grid {
background-color: var(--bg-blur-color);
} }
#columns .header-grid input { #columns .header-grid input {
pointer-events: none; pointer-events: none;
@ -1595,9 +1786,9 @@ ul.link-list li a .icon {
} }
@media (min-width: 40em) { @media (min-width: 40em) {
#columns { #columns {
gap: 16px; /* gap: 16px; */
padding: 16px; /* padding: 0 16px; */
background-color: var(--bg-blur-color); /* background-color: var(--bg-faded-color); */
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
justify-content: stretch; justify-content: stretch;
@ -1605,22 +1796,65 @@ ul.link-list li a .icon {
} }
#columns > * { #columns > * {
padding: 0 16px; padding: 0 16px;
border: var(--hairline-width) solid var(--outline-color); border-inline: var(--hairline-width) solid var(--bg-faded-color);
border-radius: 16px; /* border-radius: 16px; */
box-shadow: 0 4px 16px var(--drop-shadow-color); /* box-shadow: -4px 0 16px -8px var(--drop-shadow-color); */
height: unset; height: unset;
background-image: linear-gradient( /* background-color: var(--bg-faded-blur-color); */
/* backdrop-filter: blur(16px) saturate(3); */
/* background-image: linear-gradient(
160deg, 160deg,
transparent 20%, transparent 20%,
var(--bg-color), var(--bg-color),
transparent 75% transparent 75%
); ); */
/* position: sticky;
left: 0; */
/* transition: all 0.3s ease-out; */
} }
/* #columns > *:nth-child(2) {
left: 5%;
}
#columns > *:nth-child(3) {
left: 10%;
}
#columns > *:nth-child(4) {
left: 15%;
}
#columns > *:nth-child(5) {
left: 20%;
}
#columns > *:nth-child(6) {
left: 25%;
}
#columns > *:nth-child(7) {
left: 30%;
}
#columns > *:nth-child(8) {
left: 35%;
}
#columns > *:nth-child(9) {
left: 40%;
}
#columns > *:nth-child(10) {
left: 45%;
}
#columns > *:focus {
z-index: 1;
box-shadow: 0 0 32px var(--drop-shadow-color),
0 0 32px var(--drop-shadow-color);
} */
/* #columns:has(> *:focus) > *:not(:focus) > * {
filter: opacity(0.8);
} */
#columns > *:focus-visible, #columns > *:focus-visible,
#columns > *:has(:focus-visible) { #columns > *:has(:focus-visible) {
box-shadow: 0 4px 16px var(--drop-shadow-color), /* box-shadow: 0 4px 16px var(--drop-shadow-color),
0 4px 16px var(--drop-shadow-color); 0 4px 16px var(--drop-shadow-color); */
border-color: var(--outline-hover-color); /* border-color: var(--outline-hover-color); */
z-index: 1;
box-shadow: inset 0 0 0 1px var(--outline-hover-color);
} }
#columns .timeline:not(.flat) > li:has(.status-link.is-active), #columns .timeline:not(.flat) > li:has(.status-link.is-active),
#columns #columns
@ -1635,6 +1869,10 @@ ul.link-list li a .icon {
#columns .timeline-deck > header { #columns .timeline-deck > header {
margin: 0; margin: 0;
} }
#columns .timeline-deck > header[hidden] {
transform: none;
pointer-events: auto;
}
#columns li:has(.status-carousel) { #columns li:has(.status-carousel) {
width: auto; width: auto;
transform: none; transform: none;
@ -1714,7 +1952,7 @@ ul.link-list li a .icon {
.deck-container { .deck-container {
transition: transform 0.4s var(--timing-function); transition: transform 0.4s var(--timing-function);
} }
.deck-container:has(~ .deck-backdrop) { .deck-container:has(~ .deck-backdrop .deck) {
transition: transform 0.4s ease-out; transition: transform 0.4s ease-out;
transform: translate3d(-5vw, 0, 0); transform: translate3d(-5vw, 0, 0);
} }

View file

@ -13,7 +13,9 @@ import {
Routes, Routes,
useLocation, useLocation,
useNavigate, useNavigate,
useParams,
} from 'react-router-dom'; } from 'react-router-dom';
import 'swiped-events';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import AccountSheet from './components/account-sheet'; import AccountSheet from './components/account-sheet';
@ -33,6 +35,7 @@ import FollowedHashtags from './pages/followed-hashtags';
import Following from './pages/following'; import Following from './pages/following';
import Hashtag from './pages/hashtag'; import Hashtag from './pages/hashtag';
import Home from './pages/home'; import Home from './pages/home';
import HttpRoute from './pages/HttpRoute';
import List from './pages/list'; import List from './pages/list';
import Lists from './pages/lists'; import Lists from './pages/lists';
import Login from './pages/login'; import Login from './pages/login';
@ -189,12 +192,16 @@ function App() {
location, location,
}); });
if (/\/https?:/.test(location.pathname)) {
return <HttpRoute />;
}
const nonRootLocation = useMemo(() => { const nonRootLocation = useMemo(() => {
const { pathname } = location; const { pathname } = location;
return !/^\/(login|welcome)/.test(pathname); return !/^\/(login|welcome)/.test(pathname);
}, [location]); }, [location]);
// Change #app classname based on snapStates.settings.shortcutsViewMode // Change #app dataset based on snapStates.settings.shortcutsViewMode
useEffect(() => { useEffect(() => {
const $app = document.getElementById('app'); const $app = document.getElementById('app');
if ($app) { if ($app) {
@ -202,6 +209,12 @@ function App() {
} }
}, [snapStates.settings.shortcutsViewMode]); }, [snapStates.settings.shortcutsViewMode]);
// Add/Remove cloak class to body
useEffect(() => {
const $body = document.body;
$body.classList.toggle('cloak', snapStates.settings.cloakMode);
}, [snapStates.settings.cloakMode]);
return ( return (
<> <>
<Routes location={nonRootLocation || location}> <Routes location={nonRootLocation || location}>
@ -245,11 +258,14 @@ function App() {
<Route path="/:instance?/search" element={<Search />} /> <Route path="/:instance?/search" element={<Search />} />
{/* <Route path="/:anything" element={<NotFound />} /> */} {/* <Route path="/:anything" element={<NotFound />} /> */}
</Routes> </Routes>
<Routes> {uiState === 'default' && (
<Route path="/:instance?/s/:id" element={<Status />} /> <Routes>
</Routes> <Route path="/:instance?/s/:id" element={<StatusRoute />} />
</Routes>
)}
<div> <div>
{!snapStates.settings.shortcutsColumnsMode && {isLoggedIn &&
!snapStates.settings.shortcutsColumnsMode &&
snapStates.settings.shortcutsViewMode !== 'multi-column' && ( snapStates.settings.shortcutsViewMode !== 'multi-column' && (
<Shortcuts /> <Shortcuts />
)} )}
@ -356,7 +372,7 @@ function App() {
} }
}} }}
> >
<Drafts /> <Drafts onClose={() => (states.showDrafts = false)} />
</Modal> </Modal>
)} )}
{!!snapStates.showMediaModal && ( {!!snapStates.showMediaModal && (
@ -383,13 +399,16 @@ function App() {
)} )}
{!!snapStates.showShortcutsSettings && ( {!!snapStates.showShortcutsSettings && (
<Modal <Modal
class="light"
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
states.showShortcutsSettings = false; states.showShortcutsSettings = false;
} }
}} }}
> >
<ShortcutsSettings /> <ShortcutsSettings
onClose={() => (states.showShortcutsSettings = false)}
/>
</Modal> </Modal>
)} )}
<BackgroundService isLoggedIn={isLoggedIn} /> <BackgroundService isLoggedIn={isLoggedIn} />
@ -483,4 +502,10 @@ function BackgroundService({ isLoggedIn }) {
return null; return null;
} }
function StatusRoute() {
const params = useParams();
const { id, instance } = params;
return <Status id={id} instance={instance} />;
}
export { App }; export { App };

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

21
src/cloak-mode.css Normal file
View file

@ -0,0 +1,21 @@
body.cloak .name-text,
body.cloak .name-text *,
body.cloak .status .content-container,
body.cloak .status .content-container *,
body.cloak .account-container :is(header, main > *:not(.actions)),
body.cloak .account-container :is(header, main > *:not(.actions)) *,
body.cloak .header-account,
body.cloak .account-block {
text-decoration-thickness: 1.1em;
text-decoration-line: line-through;
text-rendering: optimizeSpeed;
filter: opacity(0.5);
}
body.cloak .status :is(img, video, audio),
body.cloak .avatar,
body.cloak .emoji,
body.cloak .header-banner {
filter: contrast(0) !important;
background-color: #000 !important;
}

View file

@ -44,6 +44,7 @@ function AccountBlock({
url, url,
statusesCount, statusesCount,
lastStatusAt, lastStatusAt,
bot,
} = account; } = account;
const displayNameWithEmoji = emojifyText(displayName, emojis); const displayNameWithEmoji = emojifyText(displayName, emojis);
const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct]; const [_, acct1, acct2] = acct.match(/([^@]+)(@.+)/i) || [, acct];
@ -68,7 +69,7 @@ function AccountBlock({
} }
}} }}
> >
<Avatar url={avatar} size={avatarSize} /> <Avatar url={avatar} size={avatarSize} squircle={bot} />
<span> <span>
{displayName ? ( {displayName ? (
<b <b

View file

@ -110,7 +110,7 @@
drop-shadow(2px 0 4px var(--header-color-4, --bg-color)); drop-shadow(2px 0 4px var(--header-color-4, --bg-color));
} }
.account-container header .avatar:not(.has-alpha) img { .account-container header .avatar:not(.has-alpha) img {
border-radius: 50%; border-radius: inherit;
} }
.account-container main > *:first-child { .account-container main > *:first-child {
@ -138,7 +138,7 @@
font-size: 90%; font-size: 90%;
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 16px;
line-height: 1.25; line-height: 1.25;
} }
.account-container .stats > * { .account-container .stats > * {
@ -165,7 +165,9 @@
.account-container .profile-metadata { .account-container .profile-metadata {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 2px;
border-radius: 16px;
overflow: hidden;
} }
.account-container .profile-field { .account-container .profile-field {
min-width: 0; min-width: 0;
@ -173,7 +175,7 @@
font-size: 90%; font-size: 90%;
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
padding: 12px; padding: 12px;
border-radius: 8px; border-radius: 4px;
filter: saturate(0.75); filter: saturate(0.75);
line-height: 1.25; line-height: 1.25;
} }
@ -198,12 +200,23 @@
} }
.account-container .common-followers { .account-container .common-followers {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.5s ease-in-out;
}
.account-container .common-followers[hidden] {
grid-template-rows: 0fr;
}
.account-container .common-followers > .common-followers-inner {
overflow: hidden;
}
.account-container .common-followers p {
font-size: 90%;
color: var(--text-insignificant-color);
border-top: 1px solid var(--outline-color); border-top: 1px solid var(--outline-color);
border-bottom: 1px solid var(--outline-color); border-bottom: 1px solid var(--outline-color);
padding: 8px 0; padding: 8px 0;
font-size: 90%; margin: 0;
line-height: 1.5;
color: var(--text-insignificant-color);
} }
.timeline-start .account-container { .timeline-start .account-container {

View file

@ -487,42 +487,45 @@ function RelatedActions({ info, instance, authenticated }) {
return ( return (
<> <>
{familiarFollowers?.length > 0 && ( <div class="common-followers" hidden={!familiarFollowers?.length}>
<p class="common-followers"> <div class="common-followers-inner">
Common followers{' '} <p>
<span class="ib"> Also followed by{' '}
{familiarFollowers.map((follower) => ( <span class="ib">
<a {familiarFollowers.map((follower) => (
href={follower.url} <a
rel="noopener noreferrer" href={follower.url}
onClick={(e) => { rel="noopener noreferrer"
e.preventDefault(); onClick={(e) => {
states.showAccount = { e.preventDefault();
account: follower, states.showAccount = {
instance, account: follower,
}; instance,
}} };
> }}
<Avatar >
url={follower.avatarStatic} <Avatar
size="l" url={follower.avatarStatic}
alt={`${follower.displayName} @${follower.acct}`} size="l"
/> alt={`${follower.displayName} @${follower.acct}`}
</a> squircle={follower?.bot}
))} />
</span> </a>
</p> ))}
)} </span>
</p>
</div>
</div>
<p class="actions"> <p class="actions">
{followedBy ? ( {followedBy ? (
<span class="tag">Following you</span> <span class="tag">Following you</span>
) : !!lastStatusAt ? ( ) : !!lastStatusAt ? (
<span class="insignificant"> <small class="insignificant">
Last status:{' '} Last status:{' '}
{niceDateTime(lastStatusAt, { {niceDateTime(lastStatusAt, {
hideTime: true, hideTime: true,
})} })}
</span> </small>
) : ( ) : (
<span /> <span />
)}{' '} )}{' '}
@ -845,7 +848,11 @@ function RelatedActions({ info, instance, authenticated }) {
} }
}} }}
> >
<TranslatedBioSheet note={note} fields={fields} /> <TranslatedBioSheet
note={note}
fields={fields}
onClose={() => setShowTranslatedBio(false)}
/>
</Modal> </Modal>
)} )}
{!!showAddRemoveLists && ( {!!showAddRemoveLists && (
@ -857,7 +864,10 @@ function RelatedActions({ info, instance, authenticated }) {
} }
}} }}
> >
<AddRemoveListsSheet accountID={accountID.current} /> <AddRemoveListsSheet
accountID={accountID.current}
onClose={() => setShowAddRemoveLists(false)}
/>
</Modal> </Modal>
)} )}
</> </>
@ -894,7 +904,7 @@ function niceAccountURL(url) {
); );
} }
function TranslatedBioSheet({ note, fields }) { function TranslatedBioSheet({ note, fields, onClose }) {
const fieldsText = const fieldsText =
fields fields
?.map(({ name, value }) => `${name}\n${getHTMLText(value)}`) ?.map(({ name, value }) => `${name}\n${getHTMLText(value)}`)
@ -904,6 +914,11 @@ function TranslatedBioSheet({ note, fields }) {
return ( return (
<div class="sheet"> <div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
<h2>Translated Bio</h2> <h2>Translated Bio</h2>
</header> </header>
@ -921,7 +936,7 @@ function TranslatedBioSheet({ note, fields }) {
); );
} }
function AddRemoveListsSheet({ accountID }) { function AddRemoveListsSheet({ accountID, onClose }) {
const { masto } = api(); const { masto } = api();
const [uiState, setUiState] = useState('default'); const [uiState, setUiState] = useState('default');
const [lists, setLists] = useState([]); const [lists, setLists] = useState([]);
@ -951,6 +966,11 @@ function AddRemoveListsSheet({ accountID }) {
return ( return (
<div class="sheet" id="list-add-remove-container"> <div class="sheet" id="list-add-remove-container">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
<h2>Add/Remove from Lists</h2> <h2>Add/Remove from Lists</h2>
</header> </header>

View file

@ -5,6 +5,7 @@ import { api } from '../utils/api';
import states from '../utils/states'; import states from '../utils/states';
import AccountInfo from './account-info'; import AccountInfo from './account-info';
import Icon from './icon';
function AccountSheet({ account, instance: propInstance, onClose }) { function AccountSheet({ account, instance: propInstance, onClose }) {
const { masto, instance, authenticated } = api({ instance: propInstance }); const { masto, instance, authenticated } = api({ instance: propInstance });
@ -31,6 +32,11 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
} }
}} }}
> >
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<AccountInfo <AccountInfo
instance={instance} instance={instance}
authenticated={authenticated} authenticated={authenticated}

View file

@ -12,6 +12,9 @@
.avatar.has-alpha { .avatar.has-alpha {
border-radius: 0; border-radius: 0;
} }
.avatar:not(.has-alpha).squircle {
border-radius: 12px;
}
.avatar img { .avatar img {
width: 100%; width: 100%;

View file

@ -13,14 +13,16 @@ const SIZES = {
const alphaCache = {}; const alphaCache = {};
function Avatar({ url, size, alt = '', ...props }) { function Avatar({ url, size, alt = '', squircle, ...props }) {
size = SIZES[size] || size || SIZES.m; size = SIZES[size] || size || SIZES.m;
const avatarRef = useRef(); const avatarRef = useRef();
const isMissing = /missing\.png$/.test(url); const isMissing = /missing\.png$/.test(url);
return ( return (
<span <span
ref={avatarRef} ref={avatarRef}
class={`avatar ${alphaCache[url] ? 'has-alpha' : ''}`} class={`avatar ${squircle ? 'squircle' : ''} ${
alphaCache[url] ? 'has-alpha' : ''
}`}
style={{ style={{
width: size, width: size,
height: size, height: size,

View file

@ -6,8 +6,10 @@ import Favourites from '../pages/favourites';
import Following from '../pages/following'; import Following from '../pages/following';
import Hashtag from '../pages/hashtag'; import Hashtag from '../pages/hashtag';
import List from '../pages/list'; import List from '../pages/list';
import Mentions from '../pages/mentions';
import Notifications from '../pages/notifications'; import Notifications from '../pages/notifications';
import Public from '../pages/public'; import Public from '../pages/public';
import Trending from '../pages/trending';
import states from '../utils/states'; import states from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
@ -17,6 +19,7 @@ function Columns() {
const { shortcuts } = snapStates; const { shortcuts } = snapStates;
const components = shortcuts.map((shortcut) => { const components = shortcuts.map((shortcut) => {
if (!shortcut) return null;
const { type, ...params } = shortcut; const { type, ...params } = shortcut;
const Component = { const Component = {
following: Following, following: Following,
@ -26,6 +29,8 @@ function Columns() {
bookmarks: Bookmarks, bookmarks: Bookmarks,
favourites: Favourites, favourites: Favourites,
hashtag: Hashtag, hashtag: Hashtag,
mentions: Mentions,
trending: Trending,
}[type]; }[type];
if (!Component) return null; if (!Component) return null;
return <Component {...params} />; return <Component {...params} />;

View file

@ -70,12 +70,10 @@
overflow: auto; overflow: auto;
box-shadow: 0 -3px 12px -3px var(--drop-shadow-color); box-shadow: 0 -3px 12px -3px var(--drop-shadow-color);
} }
#compose-container .status-preview:has(.status-badge) { #compose-container .status-preview:has(.status-badge:not(:empty)) {
border-top-right-radius: 8px; border-top-right-radius: 8px;
} }
#compose-container .status-preview :is(.hashtag, .time) { #compose-container .status-preview :is(.content-container, .time) {
/* Prevent hashtags from being clickable */
/* TODO: maybe use a different solution? */
pointer-events: none; pointer-events: none;
} }
#compose-container.standalone .status-preview * { #compose-container.standalone .status-preview * {
@ -192,6 +190,8 @@
background-color: inherit; background-color: inherit;
border: 0; border: 0;
padding: 0 0 0 8px; padding: 0 0 0 8px;
margin: 0;
appearance: none;
} }
#compose-container .toolbar-button:not(.show-field) select { #compose-container .toolbar-button:not(.show-field) select {
right: 0; right: 0;

View file

@ -508,6 +508,7 @@ function Compose({
url={currentAccountInfo.avatarStatic} url={currentAccountInfo.avatarStatic}
size="xl" size="xl"
alt={currentAccountInfo.username} alt={currentAccountInfo.username}
squircle={currentAccountInfo?.bot}
/> />
)} )}
{!standalone ? ( {!standalone ? (
@ -1083,7 +1084,24 @@ function Compose({
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
> >
{supportedLanguages {supportedLanguages
.sort(([, commonA], [, commonB]) => { .sort(([codeA, commonA], [codeB, commonB]) => {
const { contentTranslationHideLanguages = [] } =
states.settings;
// Sort codes that same as language, prevLanguage, DEFAULT_LANGUAGE and all the ones in states.settings.contentTranslationHideLanguages, to the top
if (
codeA === language ||
codeA === prevLanguage ||
codeA === DEFAULT_LANG ||
contentTranslationHideLanguages?.includes(codeA)
)
return -1;
if (
codeB === language ||
codeB === prevLanguage ||
codeB === DEFAULT_LANG ||
contentTranslationHideLanguages?.includes(codeB)
)
return 1;
return commonA.localeCompare(commonB); return commonA.localeCompare(commonB);
}) })
.map(([code, common, native]) => ( .map(([code, common, native]) => (
@ -1486,6 +1504,15 @@ function MediaAttachment({
}} }}
> >
<div id="media-sheet" class="sheet sheet-max"> <div id="media-sheet" class="sheet sheet-max">
<button
type="button"
class="sheet-close"
onClick={() => {
setShowModal(false);
}}
>
<Icon icon="x" />
</button>
<header> <header>
<h2> <h2>
{ {
@ -1724,6 +1751,11 @@ function CustomEmojisModal({
return ( return (
<div id="custom-emojis-sheet" class="sheet"> <div id="custom-emojis-sheet" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
<b>Custom emojis</b>{' '} <b>Custom emojis</b>{' '}
{uiState === 'loading' ? ( {uiState === 'loading' ? (

View file

@ -11,7 +11,7 @@ import { getCurrentAccountNS } from '../utils/store-utils';
import Icon from './icon'; import Icon from './icon';
import Loader from './loader'; import Loader from './loader';
function Drafts() { function Drafts({ onClose }) {
const { masto } = api(); const { masto } = api();
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [drafts, setDrafts] = useState([]); const [drafts, setDrafts] = useState([]);
@ -51,6 +51,11 @@ function Drafts() {
return ( return (
<div class="sheet"> <div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
<h2> <h2>
Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} /> Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} />

View file

@ -77,6 +77,8 @@ const ICONS = {
filter: 'mingcute:filter-2-line', filter: 'mingcute:filter-2-line',
chart: 'mingcute:chart-line-line', chart: 'mingcute:chart-line-line',
react: 'mingcute:react-line', react: 'mingcute:react-line',
layout4: 'mingcute:layout-4-line',
layout5: 'mingcute:layout-5-line',
}; };
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js'); const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');

View file

@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from 'preact/hooks';
import { api } from '../utils/api'; import { api } from '../utils/api';
function ListAddEdit({ list, onClose = () => {} }) { import Icon from './icon';
function ListAddEdit({ list, onClose }) {
const { masto } = api(); const { masto } = api();
const [uiState, setUiState] = useState('default'); const [uiState, setUiState] = useState('default');
const editMode = !!list; const editMode = !!list;
@ -16,6 +18,11 @@ function ListAddEdit({ list, onClose = () => {} }) {
}, [editMode]); }, [editMode]);
return ( return (
<div class="sheet"> <div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}{' '}
<header> <header>
<h2>{editMode ? 'Edit list' : 'New list'}</h2> <h2>{editMode ? 'Edit list' : 'New list'}</h2>
</header> </header>
@ -52,7 +59,7 @@ function ListAddEdit({ list, onClose = () => {} }) {
console.log(listResult); console.log(listResult);
setUiState('default'); setUiState('default');
onClose({ onClose?.({
state: 'success', state: 'success',
list: listResult, list: listResult,
}); });
@ -109,7 +116,7 @@ function ListAddEdit({ list, onClose = () => {} }) {
try { try {
await masto.v1.lists.remove(list.id); await masto.v1.lists.remove(list.id);
setUiState('default'); setUiState('default');
onClose({ onClose?.({
state: 'deleted', state: 'deleted',
}); });
} catch (e) { } catch (e) {

View file

@ -24,16 +24,16 @@ function MediaModal({
useLayoutEffect(() => { useLayoutEffect(() => {
carouselFocusItem.current?.scrollIntoView(); carouselFocusItem.current?.scrollIntoView();
history.pushState({ mediaModal: true }, ''); // history.pushState({ mediaModal: true }, '');
const handlePopState = (e) => { // const handlePopState = (e) => {
if (e.state?.mediaModal) { // if (e.state?.mediaModal) {
onClose(); // onClose();
} // }
}; // };
window.addEventListener('popstate', handlePopState); // window.addEventListener('popstate', handlePopState);
return () => { // return () => {
window.removeEventListener('popstate', handlePopState); // window.removeEventListener('popstate', handlePopState);
}; // };
}, []); }, []);
const prevStatusID = useRef(statusID); const prevStatusID = useRef(statusID);
useEffect(() => { useEffect(() => {
@ -84,8 +84,15 @@ function MediaModal({
}; };
}, []); }, []);
useEffect(() => {
let timer = setTimeout(() => {
carouselRef.current?.focus?.();
}, 100);
return () => clearTimeout(timer);
}, []);
return ( return (
<> <div class="media-modal-container">
<div <div
ref={carouselRef} ref={carouselRef}
tabIndex="-1" tabIndex="-1"
@ -206,7 +213,11 @@ function MediaModal({
</MenuLink> </MenuLink>
</Menu>{' '} </Menu>{' '}
<Link <Link
to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`} to={`${instance ? `/${instance}` : ''}/s/${statusID}${
window.matchMedia('(min-width: calc(40em + 350px))').matches
? `?media=${currentIndex + 1}`
: ''
}`}
class="button carousel-button media-post-link plain3" class="button carousel-button media-post-link plain3"
onClick={() => { onClick={() => {
// if small screen (not media query min-width 40em + 350px), run onClose // if small screen (not media query min-width 40em + 350px), run onClose
@ -264,17 +275,25 @@ function MediaModal({
} }
}} }}
> >
<MediaAltModal alt={showMediaAlt} /> <MediaAltModal
alt={showMediaAlt}
onClose={() => setShowMediaAlt(false)}
/>
</Modal> </Modal>
)} )}
</> </div>
); );
} }
function MediaAltModal({ alt }) { function MediaAltModal({ alt, onClose }) {
const [forceTranslate, setForceTranslate] = useState(false); const [forceTranslate, setForceTranslate] = useState(false);
return ( return (
<div class="sheet"> <div class="sheet">
{!!onClose && (
<button type="button" class="sheet-close outer" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid"> <header class="header-grid">
<h2>Media description</h2> <h2>Media description</h2>
<div class="header-side"> <div class="header-side">

View file

@ -1,8 +1,9 @@
import { getBlurHashAverageColor } from 'fast-blurhash'; import { getBlurHashAverageColor } from 'fast-blurhash';
import { useCallback, useRef } from 'preact/hooks'; import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom'; import QuickPinchZoom, { make3dTransformValue } from 'react-quick-pinch-zoom';
import Icon from './icon'; import Icon from './icon';
import Link from './link';
import { formatDuration } from './status'; import { formatDuration } from './status';
/* /*
@ -15,7 +16,7 @@ video = Video clip
audio = Audio track audio = Audio track
*/ */
function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { function Media({ media, to, showOriginal, autoAnimate, 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 || {};
@ -23,6 +24,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
const width = showOriginal ? original?.width : small?.width; const width = showOriginal ? original?.width : small?.width;
const height = showOriginal ? original?.height : small?.height; const height = showOriginal ? original?.height : small?.height;
const mediaURL = showOriginal ? url : previewUrl; const mediaURL = showOriginal ? url : previewUrl;
const orientation = width >= height ? 'landscape' : 'portrait';
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null; const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
@ -47,14 +49,20 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
if (media) { if (media) {
const value = make3dTransformValue({ x, y, scale }); const value = make3dTransformValue({ x, y, scale });
media.style.setProperty('transform', value); if (scale === 1) {
media.style.removeProperty('transform');
} else {
media.style.setProperty('transform', value);
}
media.closest('.media-zoom').style.touchAction = media.closest('.media-zoom').style.touchAction =
scale <= 1 ? 'pan-x' : ''; scale <= 1.01 ? 'pan-x' : '';
} }
}, []); }, []);
const [pinchZoomEnabled, setPinchZoomEnabled] = useState(false);
const quickPinchZoomProps = { const quickPinchZoomProps = {
enabled: pinchZoomEnabled,
draggableUnZoomed: false, draggableUnZoomed: false,
inertiaFriction: 0.9, inertiaFriction: 0.9,
containerProps: { containerProps: {
@ -71,11 +79,16 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
onUpdate, onUpdate,
}; };
const Parent = useMemo(
() => (to ? (props) => <Link to={to} {...props} /> : 'div'),
[to],
);
if (type === 'image' || (type === 'unknown' && previewUrl && url)) { if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
// Note: type: unknown might not have width/height // Note: type: unknown might not have width/height
quickPinchZoomProps.containerProps.style.display = 'inherit'; quickPinchZoomProps.containerProps.style.display = 'inherit';
return ( return (
<div <Parent
class={`media media-image`} class={`media media-image`}
onClick={onClick} onClick={onClick}
style={ style={
@ -92,11 +105,13 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
alt={description} alt={description}
width={width} width={width}
height={height} height={height}
data-orientation={orientation}
loading="eager" loading="eager"
decoding="async" decoding="async"
onLoad={(e) => { onLoad={(e) => {
e.target.closest('.media-image').style.backgroundImage = ''; e.target.closest('.media-image').style.backgroundImage = '';
e.target.closest('.media-zoom').style.display = ''; e.target.closest('.media-zoom').style.display = '';
setPinchZoomEnabled(true);
}} }}
/> />
</QuickPinchZoom> </QuickPinchZoom>
@ -106,6 +121,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
alt={description} alt={description}
width={width} width={width}
height={height} height={height}
data-orientation={orientation}
loading="lazy" loading="lazy"
style={{ style={{
backgroundColor: backgroundColor:
@ -117,7 +133,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
}} }}
/> />
)} )}
</div> </Parent>
); );
} else if (type === 'gifv' || type === 'video') { } else if (type === 'gifv' || type === 'video') {
const shortDuration = original.duration < 31; const shortDuration = original.duration < 31;
@ -134,6 +150,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
poster="${previewUrl}" poster="${previewUrl}"
width="${width}" width="${width}"
height="${height}" height="${height}"
data-orientation="${orientation}"
preload="auto" preload="auto"
autoplay autoplay
muted="${isGIF}" muted="${isGIF}"
@ -145,16 +162,16 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
`; `;
return ( return (
<div <Parent
class={`media media-${isGIF ? 'gif' : 'video'} ${ class={`media media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : '' autoGIFAnimate ? 'media-contain' : ''
}`} }`}
data-formatted-duration={formattedDuration} data-formatted-duration={formattedDuration}
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''} data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
style={{ // style={{
backgroundColor: // backgroundColor:
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, // rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
}} // }}
onClick={(e) => { onClick={(e) => {
if (hoverAnimate) { if (hoverAnimate) {
try { try {
@ -180,7 +197,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
> >
{showOriginal || autoGIFAnimate ? ( {showOriginal || autoGIFAnimate ? (
isGIF && showOriginal ? ( isGIF && showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps}> <QuickPinchZoom {...quickPinchZoomProps} enabled>
<div <div
ref={mediaRef} ref={mediaRef}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
@ -203,6 +220,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
poster={previewUrl} poster={previewUrl}
width={width} width={width}
height={height} height={height}
data-orientation={orientation}
preload="auto" preload="auto"
// controls // controls
playsinline playsinline
@ -216,6 +234,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
alt={description} alt={description}
width={width} width={width}
height={height} height={height}
data-orientation={orientation}
loading="lazy" loading="lazy"
/> />
<div class="media-play"> <div class="media-play">
@ -223,12 +242,12 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
</div> </div>
</> </>
)} )}
</div> </Parent>
); );
} else if (type === 'audio') { } else if (type === 'audio') {
const formattedDuration = formatDuration(original.duration); const formattedDuration = formatDuration(original.duration);
return ( return (
<div <Parent
class="media media-audio" class="media media-audio"
data-formatted-duration={formattedDuration} data-formatted-duration={formattedDuration}
onClick={onClick} onClick={onClick}
@ -241,6 +260,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
alt={description} alt={description}
width={width} width={width}
height={height} height={height}
data-orientation={orientation}
loading="lazy" loading="lazy"
/> />
) : null} ) : null}
@ -249,7 +269,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
<Icon icon="play" size="xxl" /> <Icon icon="play" size="xxl" />
</div> </div>
)} )}
</div> </Parent>
); );
} }
} }

View file

@ -24,7 +24,8 @@ function NavMenu(props) {
// User may choose pin or not to pin Following // User may choose pin or not to pin Following
// If user doesn't pin Following, we show it in the menu // If user doesn't pin Following, we show it in the menu
const showFollowing = const showFollowing =
snapStates.settings.shortcutsColumnsMode && (snapStates.settings.shortcutsColumnsMode ||
snapStates.settings.shortcutsViewMode === 'multi-column') &&
!snapStates.shortcuts.find((pin) => pin.type === 'following'); !snapStates.shortcuts.find((pin) => pin.type === 'following');
const bindLongPress = useLongPress( const bindLongPress = useLongPress(
@ -32,6 +33,7 @@ function NavMenu(props) {
states.showAccounts = true; states.showAccounts = true;
}, },
{ {
threshold: 600,
detect: 'touch', detect: 'touch',
cancelOnMovement: true, cancelOnMovement: true,
}, },
@ -66,6 +68,7 @@ function NavMenu(props) {
currentAccount?.info?.avatarStatic currentAccount?.info?.avatarStatic
} }
size="l" size="l"
squircle={currentAccount?.info?.bot}
/> />
)} )}
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} /> <Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} />

View file

@ -1,6 +1,9 @@
.name-text { .name-text {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
display: inline;
}
.name-text.show-acct {
display: inline-block; display: inline-block;
} }
a.name-text:is(:hover, :focus) b, a.name-text:is(:hover, :focus) b,
@ -15,4 +18,5 @@ a.name-text.short:is(:hover, :focus) i {
.name-text .avatar { .name-text .avatar {
vertical-align: middle; vertical-align: middle;
transform: translateY(-0.1em);
} }

View file

@ -14,7 +14,8 @@ function NameText({
external, external,
onClick, onClick,
}) { }) {
const { acct, avatar, avatarStatic, id, url, displayName, emojis } = account; const { acct, avatar, avatarStatic, id, url, displayName, emojis, bot } =
account;
let { username } = account; let { username } = account;
const displayNameWithEmoji = emojifyText(displayName, emojis); const displayNameWithEmoji = emojifyText(displayName, emojis);
@ -36,7 +37,7 @@ function NameText({
return ( return (
<a <a
class={`name-text ${short ? 'short' : ''}`} class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
href={url} href={url}
target={external ? '_blank' : null} target={external ? '_blank' : null}
title={`@${acct}`} title={`@${acct}`}
@ -52,7 +53,7 @@ function NameText({
> >
{showAvatar && ( {showAvatar && (
<> <>
<Avatar url={avatarStatic || avatar} />{' '} <Avatar url={avatarStatic || avatar} squircle={bot} />{' '}
</> </>
)} )}
{displayName && !short ? ( {displayName && !short ? (

231
src/components/poll.jsx Normal file
View file

@ -0,0 +1,231 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import emojifyText from '../utils/emojify-text';
import shortenNumber from '../utils/shorten-number';
import Icon from './icon';
import RelativeTime from './relative-time';
export default function Poll({
poll,
lang,
readOnly,
refresh = () => {},
votePoll = () => {},
}) {
const [uiState, setUIState] = useState('default');
const {
expired,
expiresAt,
id,
multiple,
options,
ownVotes,
voted,
votersCount,
votesCount,
emojis,
} = poll;
const expiresAtDate = !!expiresAt && new Date(expiresAt); // Update poll at point of expiry
// NOTE: Disable this because setTimeout runs immediately if delay is too large
// https://stackoverflow.com/a/56718027/20838
// useEffect(() => {
// let timeout;
// if (!expired && expiresAtDate) {
// const ms = expiresAtDate.getTime() - Date.now() + 1; // +1 to give it a little buffer
// if (ms > 0) {
// timeout = setTimeout(() => {
// setUIState('loading');
// (async () => {
// // await refresh();
// setUIState('default');
// })();
// }, ms);
// }
// }
// return () => {
// clearTimeout(timeout);
// };
// }, [expired, expiresAtDate]);
const pollVotesCount = votersCount || votesCount;
let roundPrecision = 0;
if (pollVotesCount <= 1000) {
roundPrecision = 0;
} else if (pollVotesCount <= 10000) {
roundPrecision = 1;
} else if (pollVotesCount <= 100000) {
roundPrecision = 2;
}
const [showResults, setShowResults] = useState(false);
const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null);
const pollRef = useRef();
useEffect(() => {
const handleSwipe = () => {
console.log('swiped left');
setShowResults(!showResults);
};
pollRef.current?.addEventListener?.('swiped-left', handleSwipe);
return () => {
pollRef.current?.removeEventListener?.('swiped-left', handleSwipe);
};
}, [showResults]);
return (
<div
ref={pollRef}
lang={lang}
dir="auto"
class={`poll ${readOnly ? 'read-only' : ''} ${
uiState === 'loading' ? 'loading' : ''
}`}
onDblClick={() => {
setShowResults(!showResults);
}}
>
{(showResults && optionsHaveVoteCounts) || voted || expired ? (
<div class="poll-options">
{options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option;
const percentage = pollVotesCount
? ((optionVotesCount / pollVotesCount) * 100).toFixed(
roundPrecision,
)
: 0; // check if current poll choice is the leading one
const isLeading =
optionVotesCount > 0 &&
optionVotesCount ===
Math.max(...options.map((o) => o.votesCount));
return (
<div
key={`${i}-${title}-${optionVotesCount}`}
class={`poll-option poll-result ${
isLeading ? 'poll-option-leading' : ''
}`}
style={{
'--percentage': `${percentage}%`,
}}
>
<div class="poll-option-title">
<span
dangerouslySetInnerHTML={{
__html: emojifyText(title, emojis),
}}
/>
{voted && ownVotes.includes(i) && (
<>
{' '}
<Icon icon="check-circle" />
</>
)}
</div>
<div
class="poll-option-votes"
title={`${optionVotesCount} vote${
optionVotesCount === 1 ? '' : 's'
}`}
>
{percentage}%
</div>
</div>
);
})}
</div>
) : (
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const choices = [];
formData.forEach((value, key) => {
if (key === 'poll') {
choices.push(value);
}
});
if (!choices.length) return;
setUIState('loading');
await votePoll(choices);
setUIState('default');
}}
>
<div class="poll-options">
{options.map((option, i) => {
const { title } = option;
return (
<div class="poll-option">
<label class="poll-label">
<input
type={multiple ? 'checkbox' : 'radio'}
name="poll"
value={i}
disabled={uiState === 'loading'}
readOnly={readOnly}
/>
<span
class="poll-option-title"
dangerouslySetInnerHTML={{
__html: emojifyText(title, emojis),
}}
/>
</label>
</div>
);
})}
</div>
{!readOnly && (
<button
class="poll-vote-button"
type="submit"
disabled={uiState === 'loading'}
>
Vote
</button>
)}
</form>
)}
{!readOnly && (
<p class="poll-meta">
{!expired && (
<>
<button
type="button"
class="textual"
disabled={uiState === 'loading'}
onClick={(e) => {
e.preventDefault();
setUIState('loading');
(async () => {
await refresh();
setUIState('default');
})();
}}
>
Refresh
</button>{' '}
&bull;{' '}
</>
)}
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
{!!votersCount && votersCount !== votesCount && (
<>
{' '}
&bull;{' '}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
</p>
)}
</div>
);
}

View file

@ -26,6 +26,8 @@
#shortcuts-settings-container .shortcuts-list li .shortcut-text { #shortcuts-settings-container .shortcuts-list li .shortcut-text {
flex-grow: 1; flex-grow: 1;
min-width: 0; min-width: 0;
line-height: 1;
word-break: break-word;
} }
#shortcuts-settings-container .shortcuts-list li .shortcut-actions { #shortcuts-settings-container .shortcuts-list li .shortcut-actions {
flex-shrink: 0; flex-shrink: 0;
@ -119,3 +121,7 @@
min-width: 0; min-width: 0;
max-width: 320px; max-width: 320px;
} }
#shortcut-settings-form form footer {
display: flex;
gap: 16px;
}

View file

@ -1,7 +1,7 @@
import './shortcuts-settings.css'; import './shortcuts-settings.css';
import mem from 'mem'; import mem from 'mem';
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import floatingButtonUrl from '../assets/floating-button.svg'; import floatingButtonUrl from '../assets/floating-button.svg';
@ -60,7 +60,8 @@ const TYPE_PARAMS = {
text: 'Instance', text: 'Instance',
name: 'instance', name: 'instance',
type: 'text', type: 'text',
placeholder: 'e.g. mastodon.social', placeholder: 'Optional, e.g. mastodon.social',
notRequired: true,
}, },
], ],
trending: [ trending: [
@ -68,7 +69,8 @@ const TYPE_PARAMS = {
text: 'Instance', text: 'Instance',
name: 'instance', name: 'instance',
type: 'text', type: 'text',
placeholder: 'e.g. mastodon.social', placeholder: 'Optional, e.g. mastodon.social',
notRequired: true,
}, },
], ],
search: [ search: [
@ -94,6 +96,13 @@ const TYPE_PARAMS = {
placeholder: 'e.g. PixelArt (Max 5, space-separated)', placeholder: 'e.g. PixelArt (Max 5, space-separated)',
pattern: '[^#]+', pattern: '[^#]+',
}, },
{
text: 'Instance',
name: 'instance',
type: 'text',
placeholder: 'Optional, e.g. mastodon.social',
notRequired: true,
},
], ],
}; };
export const SHORTCUTS_META = { export const SHORTCUTS_META = {
@ -131,14 +140,15 @@ export const SHORTCUTS_META = {
}, },
public: { public: {
id: 'public', id: 'public',
title: ({ local, instance }) => title: ({ local }) => (local ? 'Local' : 'Federated'),
`${local ? 'Local' : 'Federated'} (${instance})`, subtitle: ({ instance }) => instance || api().instance,
path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`, path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`,
icon: ({ local }) => (local ? 'group' : 'earth'), icon: ({ local }) => (local ? 'group' : 'earth'),
}, },
trending: { trending: {
id: 'trending', id: 'trending',
title: 'Trending', title: 'Trending',
subtitle: ({ instance }) => instance || api().instance,
path: ({ instance }) => `/${instance}/trending`, path: ({ instance }) => `/${instance}/trending`,
icon: 'chart', icon: 'chart',
}, },
@ -177,12 +187,14 @@ export const SHORTCUTS_META = {
hashtag: { hashtag: {
id: 'hashtag', id: 'hashtag',
title: ({ hashtag }) => hashtag, title: ({ hashtag }) => hashtag,
path: ({ hashtag }) => `/t/${hashtag.split(/\s+/).join('+')}`, subtitle: ({ instance }) => instance || api().instance,
path: ({ hashtag, instance }) =>
`${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}`,
icon: 'hashtag', icon: 'hashtag',
}, },
}; };
function ShortcutsSettings() { function ShortcutsSettings({ onClose }) {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { masto } = api(); const { masto } = api();
const { shortcuts } = snapStates; const { shortcuts } = snapStates;
@ -219,6 +231,11 @@ function ShortcutsSettings() {
return ( return (
<div id="shortcuts-settings-container" class="sheet" tabindex="-1"> <div id="shortcuts-settings-container" class="sheet" tabindex="-1">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
<h2> <h2>
<Icon icon="shortcut" /> Shortcuts{' '} <Icon icon="shortcut" /> Shortcuts{' '}
@ -303,14 +320,17 @@ function ShortcutsSettings() {
</p> */} </p> */}
{shortcuts.length > 0 ? ( {shortcuts.length > 0 ? (
<ol class="shortcuts-list"> <ol class="shortcuts-list">
{shortcuts.map((shortcut, i) => { {shortcuts.filter(Boolean).map((shortcut, i) => {
const key = i + Object.values(shortcut); const key = i + Object.values(shortcut);
const { type } = shortcut; const { type } = shortcut;
if (!SHORTCUTS_META[type]) return null; if (!SHORTCUTS_META[type]) return null;
let { icon, title } = SHORTCUTS_META[type]; let { icon, title, subtitle } = SHORTCUTS_META[type];
if (typeof title === 'function') { if (typeof title === 'function') {
title = title(shortcut, i); title = title(shortcut, i);
} }
if (typeof subtitle === 'function') {
subtitle = subtitle(shortcut, i);
}
if (typeof icon === 'function') { if (typeof icon === 'function') {
icon = icon(shortcut, i); icon = icon(shortcut, i);
} }
@ -319,6 +339,12 @@ function ShortcutsSettings() {
<Icon icon={icon} /> <Icon icon={icon} />
<span class="shortcut-text"> <span class="shortcut-text">
<AsyncText>{title}</AsyncText> <AsyncText>{title}</AsyncText>
{subtitle && (
<>
{' '}
<small class="ib insignificant">{subtitle}</small>
</>
)}
</span> </span>
<span class="shortcut-actions"> <span class="shortcut-actions">
<button <button
@ -354,6 +380,18 @@ function ShortcutsSettings() {
<Icon icon="arrow-down" alt="Move down" /> <Icon icon="arrow-down" alt="Move down" />
</button> </button>
<button <button
type="button"
class="plain small"
onClick={() => {
setShowForm({
shortcut,
shortcutIndex: i,
});
}}
>
<Icon icon="pencil" alt="Edit" />
</button>
{/* <button
type="button" type="button"
class="plain small" class="plain small"
onClick={() => { onClick={() => {
@ -361,16 +399,38 @@ function ShortcutsSettings() {
}} }}
> >
<Icon icon="x" alt="Remove" /> <Icon icon="x" alt="Remove" />
</button> </button> */}
</span> </span>
</li> </li>
); );
})} })}
</ol> </ol>
) : ( ) : (
<p class="ui-state insignificant"> <div class="ui-state insignificant">
No shortcuts yet. Add one from the form below. <p>No shortcuts yet. Tap on the Add shortcut button.</p>
</p> <p>
Not sure what to add?
<br />
Try adding{' '}
<a
href="#"
onClick={(e) => {
e.preventDefault();
states.shortcuts = [
{
type: 'following',
},
{
type: 'notifications',
},
];
}}
>
Home / Following and Notifications
</a>{' '}
first.
</p>
</div>
)} )}
<p <p
style={{ style={{
@ -402,12 +462,17 @@ function ShortcutsSettings() {
}} }}
> >
<ShortcutForm <ShortcutForm
disabled={shortcuts.length >= SHORTCUTS_LIMIT} shortcut={showForm.shortcut}
shortcutIndex={showForm.shortcutIndex}
lists={lists} lists={lists}
followedHashtags={followedHashtags} followedHashtags={followedHashtags}
onSubmit={(data) => { onSubmit={({ result, mode }) => {
console.log('onSubmit', data); console.log('onSubmit', result);
states.shortcuts.push(data); if (mode === 'edit') {
states.shortcuts[showForm.shortcutIndex] = result;
} else {
states.shortcuts.push(result);
}
}} }}
onClose={() => setShowForm(false)} onClose={() => setShowForm(false)}
/> />
@ -418,21 +483,49 @@ function ShortcutsSettings() {
} }
function ShortcutForm({ function ShortcutForm({
type,
lists, lists,
followedHashtags, followedHashtags,
onSubmit, onSubmit,
disabled, disabled,
onClose = () => {}, shortcut,
shortcutIndex,
onClose,
}) { }) {
const [currentType, setCurrentType] = useState(type); console.log('shortcut', shortcut);
const editMode = !!shortcut;
const [currentType, setCurrentType] = useState(shortcut?.type || null);
const formRef = useRef();
useEffect(() => {
if (editMode && currentType && TYPE_PARAMS[currentType]) {
// Populate form
const form = formRef.current;
TYPE_PARAMS[currentType].forEach(({ name, type }) => {
const input = form.querySelector(`[name="${name}"]`);
if (input && shortcut[name]) {
if (type === 'checkbox') {
input.checked = shortcut[name] === 'on' ? true : false;
} else {
input.value = shortcut[name];
}
}
});
}
}, [editMode, currentType]);
return ( return (
<div id="shortcut-settings-form" class="sheet"> <div id="shortcut-settings-form" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
<h2>Add shortcut</h2> <h2>{editMode ? 'Edit' : 'Add'} shortcut</h2>
</header> </header>
<main tabindex="-1"> <main tabindex="-1">
<form <form
ref={formRef}
onSubmit={(e) => { onSubmit={(e) => {
// Construct a nice object from form // Construct a nice object from form
e.preventDefault(); e.preventDefault();
@ -440,13 +533,25 @@ function ShortcutForm({
const result = {}; const result = {};
data.forEach((value, key) => { data.forEach((value, key) => {
result[key] = value?.trim(); result[key] = value?.trim();
if (key === 'instance') {
// Remove protocol and trailing slash
result[key] = result[key]
.replace(/^https?:\/\//, '')
.replace(/\/+$/, '');
// Remove @acct@ or acct@ from instance URL
result[key] = result[key].replace(/^@?[^@]+@/, '');
}
}); });
console.log('result', result);
if (!result.type) return; if (!result.type) return;
onSubmit(result); onSubmit({
result,
mode: editMode ? 'edit' : 'add',
});
// Reset // Reset
e.target.reset(); e.target.reset();
setCurrentType(null); setCurrentType(null);
onClose(); onClose?.();
}} }}
> >
<p> <p>
@ -458,6 +563,7 @@ function ShortcutForm({
onChange={(e) => { onChange={(e) => {
setCurrentType(e.target.value); setCurrentType(e.target.value);
}} }}
defaultValue={editMode ? shortcut.type : undefined}
name="type" name="type"
> >
<option></option> <option></option>
@ -468,13 +574,17 @@ function ShortcutForm({
</label> </label>
</p> </p>
{TYPE_PARAMS[currentType]?.map?.( {TYPE_PARAMS[currentType]?.map?.(
({ text, name, type, placeholder, pattern }) => { ({ text, name, type, placeholder, pattern, notRequired }) => {
if (currentType === 'list') { if (currentType === 'list') {
return ( return (
<p> <p>
<label> <label>
<span>List</span> <span>List</span>
<select name="id" required disabled={disabled}> <select
name="id"
required={!notRequired}
disabled={disabled}
>
{lists.map((list) => ( {lists.map((list) => (
<option value={list.id}>{list.title}</option> <option value={list.id}>{list.title}</option>
))} ))}
@ -492,7 +602,7 @@ function ShortcutForm({
type={type} type={type}
name={name} name={name}
placeholder={placeholder} placeholder={placeholder}
required={type === 'text'} required={type === 'text' && !notRequired}
disabled={disabled} disabled={disabled}
list={ list={
currentType === 'hashtag' currentType === 'hashtag'
@ -517,9 +627,23 @@ function ShortcutForm({
); );
}, },
)} )}
<button type="submit" class="block" disabled={disabled}> <footer>
Add <button type="submit" class="block" disabled={disabled}>
</button> {editMode ? 'Save' : 'Add'}
</button>
{editMode && (
<button
type="button"
class="light danger"
onClick={() => {
states.shortcuts.splice(shortcutIndex, 1);
onClose?.();
}}
>
Remove
</button>
)}
</footer>
</form> </form>
</main> </main>
</div> </div>

View file

@ -134,6 +134,14 @@ shortcuts .tab-bar[hidden] {
#app[data-shortcuts-view-mode='tab-menu-bar'] .deck-container { #app[data-shortcuts-view-mode='tab-menu-bar'] .deck-container {
padding-bottom: 52px; padding-bottom: 52px;
} }
#shortcuts .tab-bar li a.has-subtitle .icon,
#shortcuts .tab-bar li a.has-subtitle .icon svg {
width: 14px !important;
height: 14px !important;
}
#shortcuts .tab-bar li a span {
line-height: 1;
}
} }
@media (min-width: 40em) { @media (min-width: 40em) {
@ -172,6 +180,10 @@ shortcuts .tab-bar[hidden] {
height: 44px; height: 44px;
gap: 4px; gap: 4px;
} }
#shortcuts .tab-bar li a span {
text-align: left;
line-height: 1;
}
#app:has(#home-page):not(:has(#home-page ~ .deck-container)):has( #app:has(#home-page):not(:has(#home-page ~ .deck-container)):has(
header[hidden] header[hidden]
) )

View file

@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { SHORTCUTS_META } from '../components/shortcuts-settings'; import { SHORTCUTS_META } from '../components/shortcuts-settings';
import { api } from '../utils/api';
import states from '../utils/states'; import states from '../utils/states';
import AsyncText from './AsyncText'; import AsyncText from './AsyncText';
@ -15,6 +16,7 @@ import Link from './link';
import MenuLink from './menu-link'; import MenuLink from './menu-link';
function Shortcuts() { function Shortcuts() {
const { instance } = api();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const { shortcuts } = snapStates; const { shortcuts } = snapStates;
@ -30,17 +32,26 @@ function Shortcuts() {
.map((pin, i) => { .map((pin, i) => {
const { type, ...data } = pin; const { type, ...data } = pin;
if (!SHORTCUTS_META[type]) return null; if (!SHORTCUTS_META[type]) return null;
let { id, path, title, icon } = SHORTCUTS_META[type]; let { id, path, title, subtitle, icon } = SHORTCUTS_META[type];
if (typeof id === 'function') { if (typeof id === 'function') {
id = id(data, i); id = id(data, i);
} }
if (typeof path === 'function') { if (typeof path === 'function') {
path = path(data, i); path = path(
{
...data,
instance: data.instance || instance,
},
i,
);
} }
if (typeof title === 'function') { if (typeof title === 'function') {
title = title(data, i); title = title(data, i);
} }
if (typeof subtitle === 'function') {
subtitle = subtitle(data, i);
}
if (typeof icon === 'function') { if (typeof icon === 'function') {
icon = icon(data, i); icon = icon(data, i);
} }
@ -49,6 +60,7 @@ function Shortcuts() {
id, id,
path, path,
title, title,
subtitle,
icon, icon,
}; };
}) })
@ -73,35 +85,44 @@ function Shortcuts() {
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? ( {snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
<nav class="tab-bar"> <nav class="tab-bar">
<ul> <ul>
{formattedShortcuts.map(({ id, path, title, icon }, i) => { {formattedShortcuts.map(
return ( ({ id, path, title, subtitle, icon }, i) => {
<li key={i + title}> return (
<Link <li key={i + title}>
to={path} <Link
onClick={(e) => { class={subtitle ? 'has-subtitle' : ''}
if (e.target.classList.contains('is-active')) { to={path}
e.preventDefault(); onClick={(e) => {
const page = document.getElementById(`${id}-page`); if (e.target.classList.contains('is-active')) {
console.log(id, page); e.preventDefault();
if (page) { const page = document.getElementById(`${id}-page`);
page.scrollTop = 0; console.log(id, page);
const updatesButton = if (page) {
page.querySelector('.updates-button'); page.scrollTop = 0;
if (updatesButton) { const updatesButton =
updatesButton.click(); page.querySelector('.updates-button');
if (updatesButton) {
updatesButton.click();
}
} }
} }
} }}
}} >
> <Icon icon={icon} size="xl" alt={title} />
<Icon icon={icon} size="xl" alt={title} /> <span>
<span> <AsyncText>{title}</AsyncText>
<AsyncText>{title}</AsyncText> {subtitle && (
</span> <>
</Link> <br />
</li> <small>{subtitle}</small>
); </>
})} )}
</span>
</Link>
</li>
);
},
)}
</ul> </ul>
</nav> </nav>
) : ( ) : (
@ -132,12 +153,20 @@ function Shortcuts() {
</button> </button>
} }
> >
{formattedShortcuts.map(({ path, title, icon }, i) => { {formattedShortcuts.map(({ path, title, subtitle, icon }, i) => {
return ( return (
<MenuLink to={path} key={i + title} class="glass-menu-item"> <MenuLink to={path} key={i + title} class="glass-menu-item">
<Icon icon={icon} size="l" />{' '} <Icon icon={icon} size="l" />{' '}
<span class="menu-grow"> <span class="menu-grow">
<AsyncText>{title}</AsyncText> <span>
<AsyncText>{title}</AsyncText>
</span>
{subtitle && (
<>
{' '}
<small class="more-insignificant">{subtitle}</small>
</>
)}
</span> </span>
<span class="menu-shortcut hide-until-focus-visible"> <span class="menu-shortcut hide-until-focus-visible">
{i + 1} {i + 1}

View file

@ -46,32 +46,21 @@
overflow: hidden; overflow: hidden;
margin-bottom: -8px; margin-bottom: -8px;
} }
.status-pre-meta .name-text {
display: inline-flex;
gap: 4px;
align-items: center;
}
.status-pre-meta > * {
vertical-align: middle;
}
.status-reblog .status-pre-meta .icon { .status-reblog .status-pre-meta .icon {
color: var(--reblog-color); color: var(--reblog-color);
margin-right: 4px; margin-right: 4px;
vertical-align: text-bottom;
} }
/* STATUS */ /* STATUS */
.status { .status {
display: flex; display: flex;
padding: 16px 16px 20px; padding: 16px;
line-height: 1.4; line-height: 1.4;
align-items: flex-start; align-items: flex-start;
position: relative; position: relative;
} font-size: var(--text-size);
@media (min-width: 40em) {
.status {
padding-bottom: 16px;
}
} }
.status.large { .status.large {
--fade-in-out-bg: linear-gradient( --fade-in-out-bg: linear-gradient(
@ -88,6 +77,59 @@
background-image: var(--fade-in-out-bg), var(--yellow-stripes); background-image: var(--fade-in-out-bg), var(--yellow-stripes);
} }
.status-card-link {
text-decoration: none;
color: var(--text-color);
}
.status-card-link:is(:hover, :focus) .status-card {
border-color: var(--outline-hover-color);
box-shadow: inset 0 0 0 4px var(--bg-faded-blur-color);
}
.status-card-link:is(:hover, :focus) .status-card img {
animation: position-object 5s ease-in-out 1s 5;
}
.status-card-link:is(:active) .status-card {
background-color: var(--bg-faded-color);
}
.status-card {
font-size: calc(var(--text-size) * 0.9);
margin: 1em 0 0;
border-radius: 16px;
padding: 12px;
border: 1px solid var(--outline-color);
background-color: var(--bg-color);
box-shadow: inset 0 0 4px var(--outline-color);
/* box-shadow: inset 0 0 0 2px var(--bg-faded-color); */
/* filter: drop-shadow(0 2px 4px var(--bg-faded-color)); */
}
.status-card:has(.status-badge:not(:empty)) {
border-top-right-radius: 8px;
}
.status-card > * {
pointer-events: none;
}
.status-card :is(.content, .poll, .media-container) {
max-height: 160px !important;
overflow: hidden;
}
.status.small .status-card :is(.content, .poll, .media-container) {
max-height: 80px !important;
}
.status-card :is(.content, .poll) {
font-size: inherit !important;
mask-image: linear-gradient(to bottom, #000 80px, transparent);
}
.status.small .status-card :is(.content, .poll) {
mask-image: linear-gradient(to bottom, #000 40px, transparent);
}
.status-card .card {
display: none;
}
.timeline-deck .status-card .content.truncated:after {
/* Don't show "Read more" in status cards */
content: none !important;
}
@keyframes skeleton-breathe { @keyframes skeleton-breathe {
0% { 0% {
opacity: 1; opacity: 1;
@ -182,7 +224,7 @@
flex-grow: 1; flex-grow: 1;
min-width: 0; min-width: 0;
} }
.status:not(.small) .container { .status:not(.small) > .container {
padding-left: 12px; padding-left: 12px;
} }
@ -337,7 +379,7 @@
text-align: center; text-align: center;
} }
.status.large .content-container { .status.large > .container > .content-container {
margin-left: calc(-50px - 16px); margin-left: calc(-50px - 16px);
padding-top: 10px; padding-top: 10px;
padding-bottom: 10px; padding-bottom: 10px;
@ -392,8 +434,21 @@
filter: none; filter: none;
image-rendering: auto; image-rendering: auto;
} }
.status .content a:not(.mention):not(:has(span)) { /* .status .content a:not(.mention):not(:has(span)) {
color: inherit; color: inherit;
} */
.status.compact-thread .spoiler-badge {
font-size: smaller;
color: var(--button-bg-color);
border: 1px dashed var(--button-bg-color);
padding: 2px 4px;
border-radius: 16px;
display: inline-flex;
margin: 4px;
align-items: center;
justify-content: center;
background: var(--bg-faded-color);
} }
.timeline-deck .status .content { .timeline-deck .status .content {
@ -459,7 +514,7 @@
.status .content .ellipsis::after { .status .content .ellipsis::after {
content: '…'; content: '…';
} }
.status.large .content { .status.large .content:not(.content .content) {
font-size: 150%; font-size: 150%;
font-size: min(calc(100% + 50% / var(--content-text-weight)), 150%); font-size: min(calc(100% + 50% / var(--content-text-weight)), 150%);
} }
@ -477,15 +532,25 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-auto-rows: 1fr; grid-auto-rows: 1fr;
gap: 2px; gap: 2px;
height: 160px; /* height: 160px; */
min-height: 44px;
height: auto;
max-height: max(160px, 33vh);
} }
.status .media-container.media-eq1 { /* .status .media-container.media-eq1 {
min-height: 44px; min-height: 44px;
height: auto; height: auto;
max-height: 160px; max-height: 160px;
} */
.status:not(.large):not(.status-carousel .status)
.media-container.media-eq1:has([data-orientation='portrait']) {
width: 85%;
min-width: 160px;
max-height: 200px;
} }
.status .media-container.media-gt2 { .status .media-container.media-gt2 {
height: 200px; /* height: 200px; */
max-height: max(200px, 40vh);
} }
.status.large :is(.media-container, .media-container.media-gt2) { .status.large :is(.media-container, .media-container.media-gt2) {
height: auto; height: auto;
@ -675,6 +740,26 @@ body:has(#modal-container .carousel) .status .media img:hover {
background-blend-mode: multiply; background-blend-mode: multiply;
} }
.status:not(.large) .hashtag-stuffing {
opacity: 0.75;
transition: opacity 0.2s ease-in-out;
}
.status:not(.large) .hashtag-stuffing:is(:hover, :focus, :focus-within) {
opacity: 1;
}
.status:not(.large) .hashtag-stuffing {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.status:not(.large) .hashtag-stuffing:first-child {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
white-space: normal;
}
.carousel-item { .carousel-item {
position: relative; position: relative;
} }
@ -830,6 +915,10 @@ a:focus-visible .card img {
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2; line-clamp: 2;
} }
.card.no-image :is(.title, .meta) {
-webkit-line-clamp: 3;
line-clamp: 3;
}
.card .meta.domain { .card .meta.domain {
opacity: 1; opacity: 1;
color: var(--link-color); color: var(--link-color);
@ -858,6 +947,7 @@ a.card:is(:hover, :focus) {
/* POLLS */ /* POLLS */
.poll { .poll {
display: inline-block;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
margin-top: 8px; margin-top: 8px;
border-radius: 16px; border-radius: 16px;
@ -869,6 +959,7 @@ a.card:is(:hover, :focus) {
var(--bg-faded-color) var(--bg-faded-color)
); );
overflow: hidden; overflow: hidden;
box-shadow: inset 0 0 0 1px var(--bg-color);
} }
.poll.loading { .poll.loading {
opacity: 0.5; opacity: 0.5;
@ -1012,11 +1103,11 @@ a.card:is(:hover, :focus) {
border: 1.5px solid transparent; border: 1.5px solid transparent;
backdrop-filter: none; backdrop-filter: none;
} }
.status .action > button.plain:is(:hover, :focus) { .status .action > button.plain:not(:disabled):is(:hover, :focus) {
color: var(--link-color); color: var(--link-color);
background-color: var(--button-plain-bg-hover-color); background-color: var(--button-plain-bg-hover-color);
} }
.status .action > button.plain.reblog-button:is(:hover, :focus) { .status .action > button.plain.reblog-button:not(:disabled):is(:hover, :focus) {
color: var(--reblog-color); color: var(--reblog-color);
} }
.status .action > button.plain.reblog-button.checked { .status .action > button.plain.reblog-button.checked {
@ -1135,6 +1226,28 @@ a.card:is(:hover, :focus) {
.status-badge .pin { .status-badge .pin {
color: var(--red-color); color: var(--red-color);
} }
@keyframes swoosh-from-right {
0% {
opacity: 0;
transform: translateX(300%);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.status-badge > * {
animation: swoosh-from-right 1s cubic-bezier(0.51, 0.28, 0.16, 1.26) both;
}
.status-badge > *:nth-child(2) {
animation-delay: 0.1s;
}
.status-badge > *:nth-child(3) {
animation-delay: 0.2s;
}
.status-badge > *:nth-child(4) {
animation-delay: 0.3s;
}
/* MISC */ /* MISC */
@ -1172,6 +1285,7 @@ a.card:is(:hover, :focus) {
#edit-history .history-item .status { #edit-history .history-item .status {
border: 1px solid var(--outline-color); border: 1px solid var(--outline-color);
border-radius: 8px; border-radius: 8px;
pointer-events: none;
} }
/* DELETED */ /* DELETED */

View file

@ -15,7 +15,6 @@ import pThrottle from 'p-throttle';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import 'swiped-events';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import useResizeObserver from 'use-resize-observer'; import useResizeObserver from 'use-resize-observer';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -24,12 +23,15 @@ import AccountBlock from '../components/account-block';
import Loader from '../components/loader'; import Loader from '../components/loader';
import Modal from '../components/modal'; import Modal from '../components/modal';
import NameText from '../components/name-text'; import NameText from '../components/name-text';
import Poll from '../components/poll';
import { api } from '../utils/api'; import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import getHTMLText from '../utils/getHTMLText'; import getHTMLText from '../utils/getHTMLText';
import handleContentLinks from '../utils/handle-content-links'; import handleContentLinks from '../utils/handle-content-links';
import htmlContentLength from '../utils/html-content-length'; import htmlContentLength from '../utils/html-content-length';
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
@ -79,6 +81,8 @@ function Status({
enableTranslate, enableTranslate,
previewMode, previewMode,
allowFilters, allowFilters,
onMediaClick,
quoted,
}) { }) {
if (skeleton) { if (skeleton) {
return ( return (
@ -118,6 +122,7 @@ function Status({
displayName, displayName,
username, username,
emojis: accountEmojis, emojis: accountEmojis,
bot,
}, },
id, id,
repliesCount, repliesCount,
@ -151,7 +156,7 @@ function Status({
_filtered, _filtered,
} = status; } = status;
console.debug('RENDER Status', id, status?.account.displayName); console.debug('RENDER Status', id, status?.account.displayName, quoted);
const debugHover = (e) => { const debugHover = (e) => {
if (e.shiftKey) { if (e.shiftKey) {
@ -175,10 +180,12 @@ function Status({
const createdAtDate = new Date(createdAt); const createdAtDate = new Date(createdAt);
const editedAtDate = new Date(editedAt); const editedAtDate = new Date(editedAt);
const currentAccount = useMemo(() => {
return store.session.get('currentAccount');
}, []);
const isSelf = useMemo(() => { const isSelf = useMemo(() => {
const currentAccount = store.session.get('currentAccount');
return currentAccount && currentAccount === accountId; return currentAccount && currentAccount === accountId;
}, [accountId]); }, [accountId, currentAccount]);
let inReplyToAccountRef = mentions?.find( let inReplyToAccountRef = mentions?.find(
(mention) => mention.id === inReplyToAccountId, (mention) => mention.id === inReplyToAccountId,
@ -200,6 +207,9 @@ function Status({
.catch((e) => {}); .catch((e) => {});
} }
} }
const mentionSelf =
inReplyToAccountId === currentAccount ||
mentions?.find((mention) => mention.id === currentAccount);
const showSpoiler = !!snapStates.spoilers[id] || false; const showSpoiler = !!snapStates.spoilers[id] || false;
@ -457,12 +467,9 @@ function Status({
)} )}
{!isSizeLarge && sameInstance && ( {!isSizeLarge && sameInstance && (
<> <>
<MenuItem onClick={replyStatus}> <div class="menu-horizontal">
<Icon icon="reply" />
<span>Reply</span>
</MenuItem>
{canBoost && (
<MenuItem <MenuItem
disabled={!canBoost}
onClick={async () => { onClick={async () => {
try { try {
const done = await boostStatus(); const done = await boostStatus();
@ -480,41 +487,47 @@ function Status({
/> />
<span>{reblogged ? 'Unboost' : 'Boost…'}</span> <span>{reblogged ? 'Unboost' : 'Boost…'}</span>
</MenuItem> </MenuItem>
)} <MenuItem
<MenuItem onClick={() => {
onClick={() => { try {
try { favouriteStatus();
favouriteStatus(); if (!isSizeLarge)
if (!isSizeLarge) showToast(favourited ? 'Unfavourited' : 'Favourited');
showToast(favourited ? 'Unfavourited' : 'Favourited'); } catch (e) {}
} catch (e) {}
}}
>
<Icon
icon="heart"
style={{
color: favourited && 'var(--favourite-color)',
}} }}
/> >
<span>{favourited ? 'Unfavourite' : 'Favourite'}</span> <Icon
</MenuItem> icon="heart"
<MenuItem style={{
onClick={() => { color: favourited && 'var(--favourite-color)',
try { }}
bookmarkStatus(); />
if (!isSizeLarge) <span>{favourited ? 'Unfavourite' : 'Favourite'}</span>
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked'); </MenuItem>
} catch (e) {} </div>
}} <div class="menu-horizontal">
> <MenuItem onClick={replyStatus}>
<Icon <Icon icon="reply" />
icon="bookmark" <span>Reply</span>
style={{ </MenuItem>
color: bookmarked && 'var(--favourite-color)', <MenuItem
onClick={() => {
try {
bookmarkStatus();
if (!isSizeLarge)
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
} catch (e) {}
}} }}
/> >
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span> <Icon
</MenuItem> icon="bookmark"
style={{
color: bookmarked && 'var(--link-color)',
}}
/>
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
</MenuItem>
</div>
</> </>
)} )}
{enableTranslate && ( {enableTranslate && (
@ -570,9 +583,41 @@ function Status({
</MenuItem> </MenuItem>
)} )}
</div> </div>
{(isSelf || mentionSelf) && <MenuDivider />}
{(isSelf || mentionSelf) && (
<MenuItem
onClick={async () => {
try {
const newStatus = await masto.v1.statuses[
muted ? 'unmute' : 'mute'
](id);
saveStatus(newStatus, instance);
showToast(muted ? 'Conversation unmuted' : 'Conversation muted');
} catch (e) {
console.error(e);
showToast(
muted
? 'Unable to unmute conversation'
: 'Unable to mute conversation',
);
}
}}
>
{muted ? (
<>
<Icon icon="unmute" />
<span>Unmute conversation</span>
</>
) : (
<>
<Icon icon="mute" />
<span>Mute conversation</span>
</>
)}
</MenuItem>
)}
{isSelf && ( {isSelf && (
<> <div class="menu-horizontal">
<MenuDivider />
<MenuItem <MenuItem
onClick={() => { onClick={() => {
states.showCompose = { states.showCompose = {
@ -606,7 +651,7 @@ function Status({
<span>Delete</span> <span>Delete</span>
</MenuItem> </MenuItem>
)} )}
</> </div>
)} )}
</> </>
); );
@ -627,6 +672,7 @@ function Status({
setIsContextMenuOpen(true); setIsContextMenuOpen(true);
}, },
{ {
threshold: 600,
captureEvent: true, captureEvent: true,
detect: 'touch', detect: 'touch',
cancelOnMovement: true, cancelOnMovement: true,
@ -645,7 +691,7 @@ function Status({
m: 'medium', m: 'medium',
l: 'large', l: 'large',
}[size] }[size]
} ${_deleted ? 'status-deleted' : ''}`} } ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''}`}
onMouseEnter={debugHover} onMouseEnter={debugHover}
onContextMenu={(e) => { onContextMenu={(e) => {
if (size === 'l') return; if (size === 'l') return;
@ -670,7 +716,11 @@ function Status({
state={isContextMenuOpen ? 'open' : undefined} state={isContextMenuOpen ? 'open' : undefined}
anchorPoint={contextMenuAnchorPoint} anchorPoint={contextMenuAnchorPoint}
direction="right" direction="right"
onClose={() => setIsContextMenuOpen(false)} onClose={() => {
setIsContextMenuOpen(false);
// statusRef.current?.focus?.();
statusRef.current?.closest('[tabindex]')?.focus?.();
}}
portal={{ portal={{
target: document.body, target: document.body,
}} }}
@ -713,7 +763,7 @@ function Status({
}; };
}} }}
> >
<Avatar url={avatarStatic || avatar} size="xxl" /> <Avatar url={avatarStatic || avatar} size="xxl" squircle={bot} />
</a> </a>
)} )}
<div class="container"> <div class="container">
@ -841,10 +891,15 @@ function Status({
<div <div
class="content" class="content"
lang={language} lang={language}
dir="auto"
ref={spoilerContentRef} ref={spoilerContentRef}
data-read-more={readMoreText} data-read-more={readMoreText}
> >
<p>{spoilerText}</p> <p
dangerouslySetInnerHTML={{
__html: emojifyText(spoilerText, emojis),
}}
/>
</div> </div>
<button <button
class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`} class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`}
@ -867,38 +922,52 @@ function Status({
<div <div
class="content" class="content"
lang={language} lang={language}
dir="auto"
ref={contentRef} ref={contentRef}
data-read-more={readMoreText} data-read-more={readMoreText}
onClick={handleContentLinks({ mentions, instance, previewMode })} >
dangerouslySetInnerHTML={{ <div
__html: enhanceContent(content, { onClick={handleContentLinks({ mentions, instance, previewMode })}
emojis, dangerouslySetInnerHTML={{
postEnhanceDOM: (dom) => { __html: enhanceContent(content, {
// Remove target="_blank" from links emojis,
dom postEnhanceDOM: (dom) => {
.querySelectorAll('a.u-url[target="_blank"]') // Remove target="_blank" from links
.forEach((a) => { dom
if (!/http/i.test(a.innerText.trim())) { .querySelectorAll('a.u-url[target="_blank"]')
a.removeAttribute('target'); .forEach((a) => {
} if (!/http/i.test(a.innerText.trim())) {
});
if (previewMode) return;
// Unfurl Mastodon links
dom
.querySelectorAll(
'a[href]:not(.u-url):not(.mention):not(.hashtag)',
)
.forEach((a) => {
if (isMastodonLinkMaybe(a.href)) {
unfurlMastodonLink(currentInstance, a.href).then(() => {
a.removeAttribute('target'); a.removeAttribute('target');
}); }
} });
}); if (previewMode) return;
}, // Unfurl Mastodon links
}), Array.from(
}} dom.querySelectorAll(
/> 'a[href]:not(.u-url):not(.mention):not(.hashtag)',
),
)
.filter((a) => isMastodonLinkMaybe(a.href))
.forEach((a, i) => {
unfurlMastodonLink(currentInstance, a.href).then(
(result) => {
if (!result) return;
a.removeAttribute('target');
if (!Array.isArray(states.statusQuotes[sKey])) {
states.statusQuotes[sKey] = [];
}
if (!states.statusQuotes[sKey][i]) {
states.statusQuotes[sKey].splice(i, 0, result);
}
},
);
});
},
}),
}}
/>
<QuoteStatuses id={id} instance={instance} level={quoted} />
</div>
{!!poll && ( {!!poll && (
<Poll <Poll
lang={language} lang={language}
@ -981,16 +1050,16 @@ function Status({
key={media.id} key={media.id}
media={media} media={media}
autoAnimate={isSizeLarge} autoAnimate={isSizeLarge}
onClick={(e) => { to={`/${instance}/s/${id}?${
e.preventDefault(); withinContext ? 'media' : 'media-only'
e.stopPropagation(); }=${i + 1}`}
states.showMediaModal = { onClick={
mediaAttachments, onMediaClick
index: i, ? (e) => {
instance, onMediaClick(e, i, media, status);
statusID: readOnly ? null : id, }
}; : undefined
}} }
/> />
))} ))}
</div> </div>
@ -999,7 +1068,8 @@ function Status({
!sensitive && !sensitive &&
!spoilerText && !spoilerText &&
!poll && !poll &&
!mediaAttachments.length && ( !mediaAttachments.length &&
!snapStates.statusQuotes[sKey] && (
<Card card={card} instance={currentInstance} /> <Card card={card} instance={currentInstance} />
)} )}
</div> </div>
@ -1143,7 +1213,11 @@ function Status({
} }
}} }}
> >
<ReactionsModal statusID={id} instance={instance} /> <ReactionsModal
statusID={id}
instance={instance}
onClose={() => setShowReactions(false)}
/>
</Modal> </Modal>
)} )}
</article> </article>
@ -1151,6 +1225,7 @@ function Status({
} }
function Card({ card, instance }) { function Card({ card, instance }) {
const snapStates = useSnapshot(states);
const { const {
blurhash, blurhash,
title, title,
@ -1203,6 +1278,8 @@ function Card({ card, instance }) {
// ); // );
// } // }
if (snapStates.unfurledLinks[url]) return null;
if (hasText && (image || (!type !== 'photo' && blurhash))) { if (hasText && (image || (!type !== 'photo' && blurhash))) {
const domain = new URL(url).hostname.replace(/^www\./, ''); const domain = new URL(url).hostname.replace(/^www\./, '');
let blurhashImage; let blurhashImage;
@ -1285,216 +1362,30 @@ function Card({ card, instance }) {
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
); );
} } else if (hasText && !image) {
} const domain = new URL(url).hostname.replace(/^www\./, '');
return (
function Poll({ <a
poll, href={cardStatusURL || url}
lang, target={cardStatusURL ? null : '_blank'}
readOnly, rel="nofollow noopener noreferrer"
refresh = () => {}, class={`card link no-image`}
votePoll = () => {}, >
}) { <div class="meta-container">
const [uiState, setUIState] = useState('default'); <p class="meta domain">{domain}</p>
<p class="title">{title}</p>
const { <p class="meta">{description || providerName || authorName}</p>
expired,
expiresAt,
id,
multiple,
options,
ownVotes,
voted,
votersCount,
votesCount,
} = poll;
const expiresAtDate = !!expiresAt && new Date(expiresAt);
// Update poll at point of expiry
// NOTE: Disable this because setTimeout runs immediately if delay is too large
// https://stackoverflow.com/a/56718027/20838
// useEffect(() => {
// let timeout;
// if (!expired && expiresAtDate) {
// const ms = expiresAtDate.getTime() - Date.now() + 1; // +1 to give it a little buffer
// if (ms > 0) {
// timeout = setTimeout(() => {
// setUIState('loading');
// (async () => {
// // await refresh();
// setUIState('default');
// })();
// }, ms);
// }
// }
// return () => {
// clearTimeout(timeout);
// };
// }, [expired, expiresAtDate]);
const pollVotesCount = votersCount || votesCount;
let roundPrecision = 0;
if (pollVotesCount <= 1000) {
roundPrecision = 0;
} else if (pollVotesCount <= 10000) {
roundPrecision = 1;
} else if (pollVotesCount <= 100000) {
roundPrecision = 2;
}
const [showResults, setShowResults] = useState(false);
const optionsHaveVoteCounts = options.every((o) => o.votesCount !== null);
return (
<div
lang={lang}
class={`poll ${readOnly ? 'read-only' : ''} ${
uiState === 'loading' ? 'loading' : ''
}`}
onDblClick={() => {
setShowResults(!showResults);
}}
>
{(showResults && optionsHaveVoteCounts) || voted || expired ? (
<div class="poll-options">
{options.map((option, i) => {
const { title, votesCount: optionVotesCount } = option;
const percentage = pollVotesCount
? ((optionVotesCount / pollVotesCount) * 100).toFixed(
roundPrecision,
)
: 0;
// check if current poll choice is the leading one
const isLeading =
optionVotesCount > 0 &&
optionVotesCount ===
Math.max(...options.map((o) => o.votesCount));
return (
<div
key={`${i}-${title}-${optionVotesCount}`}
class={`poll-option poll-result ${
isLeading ? 'poll-option-leading' : ''
}`}
style={{
'--percentage': `${percentage}%`,
}}
>
<div class="poll-option-title">
{title}
{voted && ownVotes.includes(i) && (
<>
{' '}
<Icon icon="check-circle" />
</>
)}
</div>
<div
class="poll-option-votes"
title={`${optionVotesCount} vote${
optionVotesCount === 1 ? '' : 's'
}`}
>
{percentage}%
</div>
</div>
);
})}
</div> </div>
) : ( </a>
<form );
onSubmit={async (e) => { }
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const choices = [];
formData.forEach((value, key) => {
if (key === 'poll') {
choices.push(value);
}
});
if (!choices.length) return;
setUIState('loading');
await votePoll(choices);
setUIState('default');
}}
>
<div class="poll-options">
{options.map((option, i) => {
const { title } = option;
return (
<div class="poll-option">
<label class="poll-label">
<input
type={multiple ? 'checkbox' : 'radio'}
name="poll"
value={i}
disabled={uiState === 'loading'}
readOnly={readOnly}
/>
<span class="poll-option-title">{title}</span>
</label>
</div>
);
})}
</div>
{!readOnly && (
<button
class="poll-vote-button"
type="submit"
disabled={uiState === 'loading'}
>
Vote
</button>
)}
</form>
)}
{!readOnly && (
<p class="poll-meta">
{!expired && (
<>
<button
type="button"
class="textual"
disabled={uiState === 'loading'}
onClick={(e) => {
e.preventDefault();
setUIState('loading');
(async () => {
await refresh();
setUIState('default');
})();
}}
>
Refresh
</button>{' '}
&bull;{' '}
</>
)}
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
{!!votersCount && votersCount !== votesCount && (
<>
{' '}
&bull;{' '}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
</p>
)}
</div>
);
} }
function EditedAtModal({ function EditedAtModal({
statusID, statusID,
instance, instance,
fetchStatusHistory = () => {}, fetchStatusHistory = () => {},
onClose = () => {}, onClose,
}) { }) {
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [editHistory, setEditHistory] = useState([]); const [editHistory, setEditHistory] = useState([]);
@ -1516,10 +1407,12 @@ function EditedAtModal({
return ( return (
<div id="edit-history" class="sheet"> <div id="edit-history" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
{/* <button type="button" class="close-button plain large" onClick={onClose}>
<Icon icon="x" alt="Close" />
</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' && (
@ -1565,7 +1458,7 @@ function EditedAtModal({
} }
const REACTIONS_LIMIT = 80; const REACTIONS_LIMIT = 80;
function ReactionsModal({ statusID, instance }) { function ReactionsModal({ statusID, instance, onClose }) {
const { masto } = api({ instance }); const { masto } = api({ instance });
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
@ -1641,6 +1534,11 @@ function ReactionsModal({ statusID, instance }) {
return ( return (
<div id="reactions-container" class="sheet"> <div id="reactions-container" class="sheet">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
<h2>Boosted/Favourited by</h2> <h2>Boosted/Favourited by</h2>
</header> </header>
@ -1780,10 +1678,6 @@ export function formatDuration(time) {
} }
} }
function isMastodonLinkMaybe(url) {
return /^https:\/\/.*\/\d+$/i.test(url);
}
const denylistDomains = /(twitter|github)\.com/i; const denylistDomains = /(twitter|github)\.com/i;
const failedUnfurls = {}; const failedUnfurls = {};
@ -1817,10 +1711,14 @@ function _unfurlMastodonLink(instance, url) {
const statusURL = `/${domain}/s/${id}`; const statusURL = `/${domain}/s/${id}`;
const result = { const result = {
id, id,
instance: domain,
url: statusURL, url: statusURL,
}; };
console.debug('🦦 Unfurled URL', url, id, statusURL); console.debug('🦦 Unfurled URL', url, id, statusURL);
states.unfurledLinks[url] = result; states.unfurledLinks[url] = result;
saveStatus(status, domain, {
skipThreading: true,
});
return result; return result;
} else { } else {
failedUnfurls[url] = true; failedUnfurls[url] = true;
@ -1847,10 +1745,14 @@ function _unfurlMastodonLink(instance, url) {
const statusURL = `/${instance}/s/${id}`; const statusURL = `/${instance}/s/${id}`;
const result = { const result = {
id, id,
instance,
url: statusURL, url: statusURL,
}; };
console.debug('🦦 Unfurled URL', url, id, statusURL); console.debug('🦦 Unfurled URL', url, id, statusURL);
states.unfurledLinks[url] = result; states.unfurledLinks[url] = result;
saveStatus(status, instance, {
skipThreading: true,
});
return result; return result;
} else { } else {
failedUnfurls[url] = true; failedUnfurls[url] = true;
@ -1863,7 +1765,11 @@ function _unfurlMastodonLink(instance, url) {
// Silently fail // Silently fail
}); });
return Promise.any([remoteInstanceFetch, mastoSearchFetch]); if (remoteInstanceFetch) {
return Promise.any([remoteInstanceFetch, mastoSearchFetch]);
} else {
return mastoSearchFetch;
}
} }
function nicePostURL(url) { function nicePostURL(url) {
@ -1914,7 +1820,7 @@ function safeBoundingBoxPadding() {
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
const { const {
account: { avatar, avatarStatic }, account: { avatar, avatarStatic, bot },
createdAt, createdAt,
visibility, visibility,
reblog, reblog,
@ -1930,6 +1836,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
setShowPeek(true); setShowPeek(true);
}, },
{ {
threshold: 600,
captureEvent: true, captureEvent: true,
detect: 'touch', detect: 'touch',
cancelOnMovement: true, cancelOnMovement: true,
@ -1959,7 +1866,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
<span>Filtered</span> <span>Filtered</span>
<span>{filterTitleStr}</span> <span>{filterTitleStr}</span>
</b>{' '} </b>{' '}
<Avatar url={avatarStatic || avatar} /> <Avatar url={avatarStatic || avatar} squircle={bot} />
<span class="status-filtered-info"> <span class="status-filtered-info">
<span class="status-filtered-info-1"> <span class="status-filtered-info-1">
<NameText account={status.account} instance={instance} />{' '} <NameText account={status.account} instance={instance} />{' '}
@ -1979,6 +1886,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
<> <>
<Avatar <Avatar
url={reblog.account.avatarStatic || reblog.account.avatar} url={reblog.account.avatarStatic || reblog.account.avatar}
squircle={bot}
/>{' '} />{' '}
</> </>
)} )}
@ -1996,10 +1904,17 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
}} }}
> >
<div id="filtered-status-peek" class="sheet"> <div id="filtered-status-peek" class="sheet">
<button
type="button"
class="sheet-close"
onClick={() => setShowPeek(false)}
>
<Icon icon="x" />
</button>
<header>
<b class="status-filtered-badge">Filtered</b> {filterTitleStr}
</header>
<main tabIndex="-1"> <main tabIndex="-1">
<p class="heading">
<b class="status-filtered-badge">Filtered</b> {filterTitleStr}
</p>
<Link <Link
class="status-link" class="status-link"
to={`/${instance}/s/${status.id}`} to={`/${instance}/s/${status.id}`}
@ -2020,4 +1935,31 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
); );
} }
const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
const snapStates = useSnapshot(states);
const sKey = statusKey(id, instance);
const quotes = snapStates.statusQuotes[sKey];
if (!quotes?.length) return;
if (level > 2) return;
return quotes.map((q) => {
return (
<Link
key={q.instance + q.id}
to={`${q.instance ? `/${q.instance}` : ''}/s/${q.id}`}
class="status-card-link"
>
<Status
statusID={q.id}
instance={q.instance}
size="s"
quoted={level + 1}
previewMode
/>
</Link>
);
});
});
export default memo(Status); export default memo(Status);

View file

@ -42,6 +42,8 @@ function Timeline({
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
const scrollableRef = useRef(); const scrollableRef = useRef();
console.debug('RENDER Timeline', id, refresh);
const loadItems = useDebouncedCallback( const loadItems = useDebouncedCallback(
(firstLoad) => { (firstLoad) => {
setShowNew(false); setShowNew(false);
@ -269,6 +271,7 @@ function Timeline({
loadItems(true); loadItems(true);
} }
}} }}
class={uiState === 'loading' ? 'loading' : ''}
> >
<div class="header-grid"> <div class="header-grid">
<div class="header-side"> <div class="header-side">
@ -283,7 +286,7 @@ function Timeline({
</div> </div>
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)} {title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
<div class="header-side"> <div class="header-side">
<Loader hidden={uiState !== 'loading'} /> {/* <Loader hidden={uiState !== 'loading'} /> */}
{!!headerEnd && headerEnd} {!!headerEnd && headerEnd}
</div> </div>
</div> </div>
@ -383,6 +386,10 @@ function Timeline({
? `/${instance}/s/${statusID}` ? `/${instance}/s/${statusID}`
: `/s/${statusID}`; : `/s/${statusID}`;
const isMiddle = i > 0 && i < items.length - 1; const isMiddle = i > 0 && i < items.length - 1;
const isSpoiler = item.sensitive && !!item.spoilerText;
const showCompact =
(isSpoiler && i > 0) ||
(manyItems && isMiddle && type === 'thread');
return ( return (
<li <li
key={`timeline-${statusID}`} key={`timeline-${statusID}`}
@ -395,7 +402,7 @@ function Timeline({
}`} }`}
> >
<Link class="status-link timeline-item" to={url}> <Link class="status-link timeline-item" to={url}>
{manyItems && isMiddle && type === 'thread' ? ( {showCompact ? (
<TimelineStatusCompact <TimelineStatusCompact
status={item} status={item}
instance={instance} instance={instance}
@ -577,6 +584,14 @@ function TimelineStatusCompact({ status, instance }) {
)} )}
<div class="content-compact" title={statusPeekText}> <div class="content-compact" title={statusPeekText}>
{statusPeekText} {statusPeekText}
{status.sensitive && status.spoilerText && (
<>
{' '}
<span class="spoiler-badge">
<Icon icon="eye-close" size="s" />
</span>
</>
)}
</div> </div>
</article> </article>
); );

View file

@ -43,7 +43,7 @@ function TranslationBlock({
return { return {
provider: 'lingva', provider: 'lingva',
content: res.translation, content: res.translation,
detectedSourceLanguage: res.info.detectedSource, detectedSourceLanguage: res.info?.detectedSource,
info: res.info, info: res.info,
}; };
}); });
@ -139,7 +139,7 @@ function TranslationBlock({
) : ( ) : (
!!translatedContent && ( !!translatedContent && (
<> <>
<output class="translated-content" lang={targetLang}> <output class="translated-content" lang={targetLang} dir="auto">
{translatedContent} {translatedContent}
</output> </output>
{!!pronunciationContent && ( {!!pronunciationContent && (

View file

@ -43,10 +43,15 @@
--outline-hover-color: rgba(128, 128, 128, 0.7); --outline-hover-color: rgba(128, 128, 128, 0.7);
--divider-color: rgba(0, 0, 0, 0.1); --divider-color: rgba(0, 0, 0, 0.1);
--backdrop-color: rgba(0, 0, 0, 0.05); --backdrop-color: rgba(0, 0, 0, 0.05);
--backdrop-solid-color: #ccc;
--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;
--drop-shadow-color: rgba(0, 0, 0, 0.15); --drop-shadow-color: rgba(0, 0, 0, 0.15);
--close-button-bg-color: rgba(0, 0, 0, 0.1);
--close-button-bg-active-color: rgba(0, 0, 0, 0.2);
--close-button-color: rgba(0, 0, 0, 0.5);
--close-button-hover-color: rgba(0, 0, 0, 1);
--timing-function: cubic-bezier(0.3, 0.5, 0, 1); --timing-function: cubic-bezier(0.3, 0.5, 0, 1);
} }
@ -78,9 +83,14 @@
--divider-color: rgba(255, 255, 255, 0.1); --divider-color: rgba(255, 255, 255, 0.1);
--bg-blur-color: #24252699; --bg-blur-color: #24252699;
--backdrop-color: rgba(0, 0, 0, 0.5); --backdrop-color: rgba(0, 0, 0, 0.5);
--backdrop-solid-color: #333;
--loader-color: #f0f2f599; --loader-color: #f0f2f599;
--comment-line-color: #565656; --comment-line-color: #565656;
--drop-shadow-color: rgba(0, 0, 0, 0.5); --drop-shadow-color: rgba(0, 0, 0, 0.5);
--close-button-bg-color: rgba(255, 255, 255, 0.2);
--close-button-bg-active-color: rgba(255, 255, 255, 0.15);
--close-button-color: rgba(255, 255, 255, 0.5);
--close-button-hover-color: rgba(255, 255, 255, 1);
} }
} }
@ -90,6 +100,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
[dir] {
text-align: start;
}
html { html {
text-size-adjust: 100%; text-size-adjust: 100%;
} }

View file

@ -1,5 +1,7 @@
import './index.css'; import './index.css';
import './cloak-mode.css';
import { render } from 'preact'; import { render } from 'preact';
import { HashRouter } from 'react-router-dom'; import { HashRouter } from 'react-router-dom';
@ -35,3 +37,7 @@ setTimeout(() => {
localStorage.removeItem('settings:boostsCarousel'); localStorage.removeItem('settings:boostsCarousel');
} catch (e) {} } catch (e) {}
}, 5000); }, 5000);
window.__CLOAK__ = () => {
document.body.classList.toggle('cloak');
};

28
src/pages/HttpRoute.jsx Normal file
View file

@ -0,0 +1,28 @@
import { useLocation } from 'react-router-dom';
import Link from '../components/link';
import getInstanceStatusURL from '../utils/get-instance-status-url';
export default function HttpRoute() {
const location = useLocation();
const url = location.pathname.replace(/^\//, '');
const statusURL = getInstanceStatusURL(url);
if (statusURL) {
window.location.hash = statusURL + '?view=full';
return null;
}
return (
<div class="ui-state" tabIndex="-1">
<h2>Unable to process URL</h2>
<p>
<a href={url} target="_blank">
{url}
</a>
</p>
<hr />
<p>
<Link to="/">Go home</Link>
</p>
</div>
);
}

View file

@ -1,3 +1,4 @@
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -8,6 +9,7 @@ import Link from '../components/link';
import Timeline from '../components/timeline'; import Timeline from '../components/timeline';
import { api } from '../utils/api'; import { api } from '../utils/api';
import emojifyText from '../utils/emojify-text'; import emojifyText from '../utils/emojify-text';
import showToast from '../utils/show-toast';
import states from '../utils/states'; import states from '../utils/states';
import { saveStatus } from '../utils/states'; import { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
@ -128,18 +130,33 @@ function AccountStatuses() {
)} )}
<Link <Link
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`} to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
onClick={() => {
if (excludeReplies) {
showToast('Showing post with replies');
}
}}
class={excludeReplies ? '' : 'is-active'} class={excludeReplies ? '' : 'is-active'}
> >
+ Replies + Replies
</Link> </Link>
<Link <Link
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`} to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
onClick={() => {
if (!excludeBoosts) {
showToast('Showing posts without boosts');
}
}}
class={!excludeBoosts ? '' : 'is-active'} class={!excludeBoosts ? '' : 'is-active'}
> >
- Boosts - Boosts
</Link> </Link>
<Link <Link
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`} to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
onClick={() => {
if (!media) {
showToast('Showing posts with media');
}
}}
class={media ? 'is-active' : ''} class={media ? 'is-active' : ''}
> >
Media Media
@ -151,6 +168,11 @@ function AccountStatuses() {
? '' ? ''
: `?tagged=${encodeURIComponent(tag.name)}` : `?tagged=${encodeURIComponent(tag.name)}`
}`} }`}
onClick={() => {
if (tagged !== tag.name) {
showToast(`Showing posts tagged with #${tag.name}`);
}
}}
class={tagged === tag.name ? 'is-active' : ''} class={tagged === tag.name ? 'is-active' : ''}
> >
<span> <span>
@ -191,6 +213,14 @@ function AccountStatuses() {
} }
}, [featuredTags, tagged, media, excludeReplies, excludeBoosts]); }, [featuredTags, tagged, media, excludeReplies, excludeBoosts]);
const accountInstance = useMemo(() => {
if (!account?.url) return null;
const domain = new URL(account.url).hostname;
return domain;
}, [account]);
const sameInstance = instance === accountInstance;
const allowSwitch = !!account && !sameInstance;
return ( return (
<Timeline <Timeline
key={id} key={id}
@ -224,6 +254,49 @@ function AccountStatuses() {
boostsCarousel={snapStates.settings.boostsCarousel} boostsCarousel={snapStates.settings.boostsCarousel}
timelineStart={TimelineStart} timelineStart={TimelineStart}
refresh={excludeReplies + excludeBoosts + tagged + media} refresh={excludeReplies + excludeBoosts + tagged + media}
headerEnd={
<Menu
portal={{
target: document.body,
}}
// setDownOverflow
overflow="auto"
viewScroll="close"
position="anchor"
boundingBoxPadding="8 8 8 8"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
</button>
}
>
<MenuItem
disabled={!allowSwitch}
onClick={() => {
(async () => {
try {
const { masto } = api({
instance: accountInstance,
});
const acc = await masto.v1.accounts.lookup({
acct: account.acct,
});
const { id } = acc;
location.hash = `/${accountInstance}/a/${id}`;
} catch (e) {
console.error(e);
alert('Unable to fetch account info');
}
})();
}}
>
<Icon icon="transfer" />{' '}
<small class="menu-double-lines">
Switch to account's instance (<b>{accountInstance}</b>)
</small>
</MenuItem>
</Menu>
}
/> />
); );
} }

View file

@ -23,6 +23,11 @@ function Accounts({ onClose }) {
return ( return (
<div id="settings-container" class="sheet" tabIndex="-1"> <div id="settings-container" class="sheet" tabIndex="-1">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header class="header-grid"> <header class="header-grid">
<h2>Accounts</h2> <h2>Accounts</h2>
</header> </header>

View file

@ -18,6 +18,8 @@ function Following({ title, path, id, ...props }) {
const homeIterator = useRef(); const homeIterator = useRef();
const latestItem = useRef(); const latestItem = useRef();
console.debug('RENDER Following', title, id);
async function fetchHome(firstLoad) { async function fetchHome(firstLoad) {
if (firstLoad || !homeIterator.current) { if (firstLoad || !homeIterator.current) {
homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT }); homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT });

View file

@ -32,8 +32,10 @@ function Hashtags(props) {
hashtags.sort(); hashtags.sort();
hashtag = hashtags[0]; hashtag = hashtags[0];
const { masto, instance } = api({ instance: params.instance }); const { masto, instance, authenticated } = api({
const { authenticated } = api(); instance: props?.instance || params.instance,
});
const { authenticated: currentAuthenticated } = api();
const hashtagTitle = hashtags.map((t) => `#${t}`).join(' '); const hashtagTitle = hashtags.map((t) => `#${t}`).join(' ');
const title = instance ? `${hashtagTitle} on ${instance}` : hashtagTitle; const title = instance ? `${hashtagTitle} on ${instance}` : hashtagTitle;
useTitle(title, `/:instance?/t/:hashtag`); useTitle(title, `/:instance?/t/:hashtag`);
@ -99,7 +101,7 @@ function Hashtags(props) {
return ( return (
<Timeline <Timeline
key={hashtagTitle} key={instance + hashtagTitle}
title={title} title={title}
titleComponent={ titleComponent={
!!instance && ( !!instance && (
@ -232,6 +234,7 @@ function Hashtags(props) {
{hashtags.map((t, i) => ( {hashtags.map((t, i) => (
<MenuItem <MenuItem
key={t} key={t}
disabled={hashtags.length === 1}
onClick={(e) => { onClick={(e) => {
hashtags.splice(i, 1); hashtags.splice(i, 1);
hashtags.sort(); hashtags.sort();
@ -252,11 +255,12 @@ function Hashtags(props) {
</MenuGroup> </MenuGroup>
<MenuDivider /> <MenuDivider />
<MenuItem <MenuItem
disabled={!authenticated} disabled={!currentAuthenticated}
onClick={() => { onClick={() => {
const shortcut = { const shortcut = {
type: 'hashtag', type: 'hashtag',
hashtag: hashtags.join(' '), hashtag: hashtags.join(' '),
instance,
}; };
// Check if already exists // Check if already exists
const exists = states.shortcuts.some( const exists = states.shortcuts.some(
@ -269,7 +273,8 @@ function Hashtags(props) {
shortcut.hashtag shortcut.hashtag
.split(/[\s+]+/) .split(/[\s+]+/)
.sort() .sort()
.join(' '), .join(' ') &&
(s.instance ? s.instance === shortcut.instance : true),
); );
if (exists) { if (exists) {
alert('This shortcut already exists'); alert('This shortcut already exists');
@ -281,6 +286,23 @@ function Hashtags(props) {
> >
<Icon icon="shortcut" /> <span>Add to Shorcuts</span> <Icon icon="shortcut" /> <span>Add to Shorcuts</span>
</MenuItem> </MenuItem>
<MenuItem
onClick={() => {
let newInstance = prompt(
'Enter a new instance e.g. "mastodon.social"',
);
if (!/\./.test(newInstance)) {
if (newInstance) alert('Invalid instance');
return;
}
if (newInstance) {
newInstance = newInstance.toLowerCase().trim();
navigate(`/${newInstance}/t/${hashtags.join('+')}`);
}
}}
>
<Icon icon="bus" /> <span>Go to another instance</span>
</MenuItem>
</Menu> </Menu>
} }
/> />

View file

@ -1,3 +1,4 @@
import { memo } from 'preact/compat';
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
@ -75,4 +76,4 @@ function Home() {
); );
} }
export default Home; export default memo(Home);

View file

@ -4,6 +4,7 @@ import { Menu, MenuItem } from '@szhsin/react-menu';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { InView } from 'react-intersection-observer'; import { InView } from 'react-intersection-observer';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import AccountBlock from '../components/account-block'; import AccountBlock from '../components/account-block';
import Icon from '../components/icon'; import Icon from '../components/icon';
@ -13,12 +14,13 @@ import Modal from '../components/modal';
import Timeline from '../components/timeline'; import Timeline from '../components/timeline';
import { api } from '../utils/api'; import { api } from '../utils/api';
import { filteredItems } from '../utils/filters'; import { filteredItems } from '../utils/filters';
import { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 20; const LIMIT = 20;
function List(props) { function List(props) {
const snapStates = useSnapshot(states);
const { masto, instance } = api(); const { masto, instance } = api();
const id = props?.id || useParams()?.id; const id = props?.id || useParams()?.id;
const navigate = useNavigate(); const navigate = useNavigate();
@ -93,7 +95,7 @@ function List(props) {
fetchItems={fetchList} fetchItems={fetchList}
checkForUpdates={checkForUpdates} checkForUpdates={checkForUpdates}
useItemID useItemID
boostsCarousel boostsCarousel={snapStates.settings.boostsCarousel}
allowFilters allowFilters
// refresh={reloadCount} // refresh={reloadCount}
headerStart={ headerStart={
@ -166,7 +168,10 @@ function List(props) {
} }
}} }}
> >
<ListManageMembers listID={id} /> <ListManageMembers
listID={id}
onClose={() => setShowManageMembersModal(false)}
/>
</Modal> </Modal>
)} )}
</> </>
@ -174,7 +179,7 @@ function List(props) {
} }
const MEMBERS_LIMIT = 40; const MEMBERS_LIMIT = 40;
function ListManageMembers({ listID }) { function ListManageMembers({ listID, onClose }) {
// Show list of members with [Remove] button // Show list of members with [Remove] button
// API only returns 40 members at a time, so this need to be paginated with infinite scroll // API only returns 40 members at a time, so this need to be paginated with infinite scroll
// Show [Add] button after removing a member // Show [Add] button after removing a member
@ -220,6 +225,11 @@ function ListManageMembers({ listID }) {
return ( return (
<div class="sheet" id="list-manage-members-container"> <div class="sheet" id="list-manage-members-container">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
<h2>Manage members</h2> <h2>Manage members</h2>
</header> </header>

View file

@ -22,5 +22,32 @@
#login input { #login input {
display: block; display: block;
width: 100%; width: 15em;
margin: 0 auto;
max-width: 100%;
transition: all 0.2s ease-in-out;
}
#instances-suggestions {
margin: 0.2em 0 0;
padding: 0;
list-style: none;
width: 90vw;
max-width: 40em;
overflow: auto;
white-space: nowrap;
mask-image: linear-gradient(
to right,
transparent,
black 1.2em,
black calc(100% - 5em),
transparent
);
animation: fade-in 0.2s ease-in-out;
height: 2.5em;
}
#instances-suggestions li {
display: inline-block;
margin: 0;
padding: 0;
} }

View file

@ -14,6 +14,9 @@ function Login() {
const instanceURLRef = useRef(); const instanceURLRef = useRef();
const cachedInstanceURL = store.local.get('instanceURL'); const cachedInstanceURL = store.local.get('instanceURL');
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [instanceText, setInstanceText] = useState(
cachedInstanceURL?.toLowerCase() || '',
);
const [instancesList, setInstancesList] = useState([]); const [instancesList, setInstancesList] = useState([]);
useEffect(() => { useEffect(() => {
@ -29,20 +32,13 @@ function Login() {
})(); })();
}, []); }, []);
useEffect(() => { // useEffect(() => {
if (cachedInstanceURL) { // if (cachedInstanceURL) {
instanceURLRef.current.value = cachedInstanceURL.toLowerCase(); // instanceURLRef.current.value = cachedInstanceURL.toLowerCase();
} // }
}, []); // }, []);
const onSubmit = (e) => { const submitInstance = (instanceURL) => {
e.preventDefault();
const { elements } = e.target;
let instanceURL = elements.instanceURL.value.toLowerCase();
// Remove protocol from instance URL
instanceURL = instanceURL.replace(/^https?:\/\//, '').replace(/\/+$/, '');
// Remove @acct@ or acct@ from instance URL
instanceURL = instanceURL.replace(/^@?[^@]+@/, '');
store.local.set('instanceURL', instanceURL); store.local.set('instanceURL', instanceURL);
(async () => { (async () => {
@ -71,6 +67,22 @@ function Login() {
})(); })();
}; };
const onSubmit = (e) => {
e.preventDefault();
const { elements } = e.target;
let instanceURL = elements.instanceURL.value.toLowerCase();
// Remove protocol from instance URL
instanceURL = instanceURL.replace(/^https?:\/\//, '').replace(/\/+$/, '');
// Remove @acct@ or acct@ from instance URL
instanceURL = instanceURL.replace(/^@?[^@]+@/, '');
if (!/\./.test(instanceURL)) {
instanceURL = instancesList.find((instance) =>
instance.includes(instanceURL),
);
}
submitInstance(instanceURL);
};
return ( return (
<main id="login" style={{ textAlign: 'center' }}> <main id="login" style={{ textAlign: 'center' }}>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
@ -78,34 +90,57 @@ function Login() {
<label> <label>
<p>Instance</p> <p>Instance</p>
<input <input
value={instanceText}
required required
type="text" type="text"
class="large" class="large"
id="instanceURL" id="instanceURL"
ref={instanceURLRef} ref={instanceURLRef}
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
list="instances-list" // list="instances-list"
autocorrect="off" autocorrect="off"
autocapitalize="off" autocapitalize="off"
autocomplete="off" autocomplete="off"
spellcheck={false} spellcheck={false}
placeholder="instance domain"
onInput={(e) => {
setInstanceText(e.target.value);
}}
/> />
<datalist id="instances-list"> <ul id="instances-suggestions">
{instancesList
.filter((instance) => instance.includes(instanceText))
.slice(0, 10)
.map((instance) => (
<li>
<button
type="button"
class="plain4"
onClick={() => {
submitInstance(instance);
}}
>
{instance}
</button>
</li>
))}
</ul>
{/* <datalist id="instances-list">
{instancesList.map((instance) => ( {instancesList.map((instance) => (
<option value={instance} /> <option value={instance} />
))} ))}
</datalist> </datalist> */}
</label> </label>
{uiState === 'error' && ( {uiState === 'error' && (
<p class="error"> <p class="error">
Failed to log in. Please try again or another instance. Failed to log in. Please try again or another instance.
</p> </p>
)} )}
<p> <div>
<button class="large" disabled={uiState === 'loading'}> <button class="large" disabled={uiState === 'loading'}>
Log in Log in
</button>{' '} </button>{' '}
</p> </div>
<Loader hidden={uiState !== 'loading'} /> <Loader hidden={uiState !== 'loading'} />
<hr /> <hr />
<p> <p>

View file

@ -162,6 +162,7 @@ function Notifications() {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
} }
}} }}
class={uiState === 'loading' ? 'loading' : ''}
> >
<div class="header-grid"> <div class="header-grid">
<div class="header-side"> <div class="header-side">
@ -172,7 +173,7 @@ function Notifications() {
</div> </div>
<h1>Notifications</h1> <h1>Notifications</h1>
<div class="header-side"> <div class="header-side">
<Loader hidden={uiState !== 'loading'} /> {/* <Loader hidden={uiState !== 'loading'} /> */}
</div> </div>
</div> </div>
{snapStates.notificationsShowNew && uiState !== 'loading' && ( {snapStates.notificationsShowNew && uiState !== 'loading' && (
@ -403,6 +404,7 @@ function Notification({ notification, instance }) {
} }
key={account.id} key={account.id}
alt={`${account.displayName} @${account.acct}`} alt={`${account.displayName} @${account.acct}`}
squircle={account?.bot}
/> />
{type === 'favourite+reblog' && ( {type === 'favourite+reblog' && (
<div class="account-sub-icons"> <div class="account-sub-icons">

View file

@ -4,6 +4,7 @@ import { useRef } from 'preact/hooks';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import logo from '../assets/logo.svg'; import logo from '../assets/logo.svg';
import Icon from '../components/icon';
import RelativeTime from '../components/relative-time'; import RelativeTime from '../components/relative-time';
import targetLanguages from '../data/lingva-target-languages'; import targetLanguages from '../data/lingva-target-languages';
import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import getTranslateTargetLanguage from '../utils/get-translate-target-language';
@ -26,6 +27,11 @@ function Settings({ onClose }) {
return ( return (
<div id="settings-container" class="sheet" tabIndex="-1"> <div id="settings-container" class="sheet" tabIndex="-1">
{!!onClose && (
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
)}
<header> <header>
<h2>Settings</h2> <h2>Settings</h2>
</header> </header>
@ -253,6 +259,27 @@ function Settings({ onClose }) {
</p> </p>
</div> </div>
</li> </li>
<li>
<label>
<input
type="checkbox"
checked={snapStates.settings.cloakMode}
onChange={(e) => {
states.settings.cloakMode = e.target.checked;
}}
/>{' '}
Cloak mode{' '}
<span class="insignificant">
(<samp>Text</samp> <samp></samp>)
</span>
</label>
<div class="sub-section insignificant">
<small>
Replace text as blocks, useful when taking screenshots, for
privacy reasons.
</small>
</div>
</li>
<li> <li>
<button <button
type="button" type="button"

View file

@ -48,6 +48,7 @@
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.2; line-height: 1.2;
max-width: var(--main-width); max-width: var(--main-width);
z-index: 1;
} }
.post-status-banner > p:first-of-type { .post-status-banner > p:first-of-type {
margin-top: 0; margin-top: 0;

File diff suppressed because it is too large Load diff

View file

@ -44,3 +44,45 @@
#welcome:hover h2 { #welcome:hover h2 {
animation: psychedelic 10s infinite alternate; animation: psychedelic 10s infinite alternate;
} }
#why-container summary {
font-weight: bold;
margin: 16px 0;
padding: 0;
text-decoration: underline;
cursor: pointer;
}
#why-container[open] summary {
text-decoration: none;
opacity: 0.5;
}
#why-container .sections section {
text-align: start;
max-width: 480px;
background-color: var(--bg-color);
border-radius: 30px;
border: 1px solid var(--bg-color);
font-weight: 600;
font-size: 106.25%;
overflow: hidden;
box-shadow: 0 0 0 1px var(--outline-color),
0 4px 16px -8px var(--drop-shadow-color);
margin-bottom: 16px;
}
#why-container .sections section h4 {
margin: 0;
padding: 30px 30px 0;
font-size: 111.765%;
color: var(--blue-color);
font-weight: 600;
}
#why-container .sections section p {
margin-inline: 30px;
margin-bottom: 30px;
}
#why-container .sections section img {
width: 100%;
height: auto;
border-top: 1px solid var(--outline-color);
}

View file

@ -1,5 +1,10 @@
import './welcome.css'; import './welcome.css';
import boostsCarouselUrl from '../assets/features/boosts-carousel.jpg';
import groupedNotificationsUrl from '../assets/features/grouped-notifications.jpg';
import multiColumnUrl from '../assets/features/multi-column.jpg';
import multiHashtagTimelineUrl from '../assets/features/multi-hashtag-timeline.jpg';
import nestedCommentsThreadUrl from '../assets/features/nested-comments-thread.jpg';
import logo from '../assets/logo.svg'; import logo from '../assets/logo.svg';
import Link from '../components/link'; import Link from '../components/link';
import states from '../utils/states'; import states from '../utils/states';
@ -36,6 +41,74 @@ function Welcome() {
</b> </b>
</big> </big>
</p> </p>
<details id="why-container">
<summary>Why Phanpy?</summary>
<div class="sections">
<section>
<h4>Boosts Carousel</h4>
<p>
Visually separate original posts and re-shared posts (boosted
posts).
</p>
<img
src={boostsCarouselUrl}
alt="Screenshot of Boosts Carousel"
loading="lazy"
/>
</section>
<section>
<h4>Nested comments thread</h4>
<p>Effortlessly follow conversations. Semi-collapsible replies.</p>
<img
src={nestedCommentsThreadUrl}
alt="Screenshot of nested comments thread"
loading="lazy"
/>
</section>
<section>
<h4>Grouped notifications</h4>
<p>
Similar notifications are grouped and collapsed to reduce clutter.
</p>
<img
src={groupedNotificationsUrl}
alt="Screenshot of grouped notifications"
loading="lazy"
/>
</section>
<section>
<h4>Single or multi-column</h4>
<p>
By default, single column for zen-mode seekers. Configurable
multi-column for power users.
</p>
<img
src={multiColumnUrl}
alt="Screenshot of multi-column UI"
loading="lazy"
/>
</section>
<section>
<h4>Multi-hashtag timeline</h4>
<p>Up to 5 hashtags combined into a single timeline.</p>
<img
src={multiHashtagTimelineUrl}
alt="Screenshot of multi-hashtag timeline with a form to add more hashtags"
loading="lazy"
/>
</section>
<p>Convinced yet?</p>
<p>
<big>
<b>
<Link to="/login" class="button">
Log in
</Link>
</b>
</big>
</p>
</div>
</details>
<hr /> <hr />
<p> <p>
<a href="https://github.com/cheeaun/phanpy" target="_blank"> <a href="https://github.com/cheeaun/phanpy" target="_blank">

View file

@ -93,6 +93,8 @@ export async function initAccount(client, instance, accessToken) {
const masto = client; const masto = client;
const mastoAccount = await masto.v1.accounts.verifyCredentials(); const mastoAccount = await masto.v1.accounts.verifyCredentials();
store.session.set('currentAccount', mastoAccount.id);
saveAccount({ saveAccount({
info: mastoAccount, info: mastoAccount,
instanceURL: instance.toLowerCase(), instanceURL: instance.toLowerCase(),

View file

@ -10,11 +10,32 @@ function enhanceContent(content, opts = {}) {
// Add target="_blank" to all links with no target="_blank" // Add target="_blank" to all links with no target="_blank"
// E.g. `note` in `account` // E.g. `note` in `account`
const links = Array.from(dom.querySelectorAll('a:not([target="_blank"])')); const noTargetBlankLinks = Array.from(
links.forEach((link) => { dom.querySelectorAll('a:not([target="_blank"])'),
);
noTargetBlankLinks.forEach((link) => {
link.setAttribute('target', '_blank'); link.setAttribute('target', '_blank');
}); });
// Spanify un-spanned mentions
const notMentionLinks = Array.from(dom.querySelectorAll('a[href]'));
notMentionLinks.forEach((link) => {
const text = link.innerText.trim();
const hasChildren = link.querySelector('*');
// If text looks like @username@domain, then it's a mention
if (/^@[^@]+(@[^@]+)?$/g.test(text)) {
// Only show @username
const username = text.split('@')[1];
if (!hasChildren) link.innerHTML = `@<span>${username}</span>`;
link.classList.add('mention');
}
// If text looks like #hashtag, then it's a hashtag
if (/^#[^#]+$/g.test(text)) {
if (!hasChildren) link.innerHTML = `#<span>${text.slice(1)}</span>`;
link.classList.add('mention', 'hashtag');
}
});
// EMOJIS // EMOJIS
// ====== // ======
// Convert :shortcode: to <img /> // Convert :shortcode: to <img />
@ -113,6 +134,40 @@ function enhanceContent(content, opts = {}) {
node.replaceWith(...nodes); node.replaceWith(...nodes);
}); });
// HASHTAG STUFFING
// ================
// Get the <p> that contains a lot of hashtags, add a class to it
const hashtagStuffedParagraph = Array.from(dom.querySelectorAll('p')).find(
(p) => {
let hashtagCount = 0;
for (let i = 0; i < p.childNodes.length; i++) {
const node = p.childNodes[i];
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
if (text !== '') {
return false;
}
} else if (node.tagName === 'A') {
const linkText = node.textContent.trim();
if (!linkText || !linkText.startsWith('#')) {
return false;
} else {
hashtagCount++;
}
} else {
return false;
}
}
// Only consider "stuffing" if there are more than 3 hashtags
return hashtagCount > 3;
},
);
if (hashtagStuffedParagraph) {
hashtagStuffedParagraph.classList.add('hashtag-stuffing');
hashtagStuffedParagraph.title = hashtagStuffedParagraph.innerText;
}
if (postEnhanceDOM) { if (postEnhanceDOM) {
postEnhanceDOM(dom); // mutate dom postEnhanceDOM(dom); // mutate dom
} }

View file

@ -0,0 +1,19 @@
export const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/([^\/]+)\/?$/i;
export const statusNoteRegex = /\/notes\/([^\/]+)\/?$/i;
function getInstanceStatusURL(url) {
// Regex /:username/:id, where username = @username or @username@domain, id = anything
const { hostname, pathname } = new URL(url);
const [, username, domain, id] = pathname.match(statusRegex) || [];
if (id) {
return `/${hostname}/s/${id}`;
}
const [, noteId] = pathname.match(statusNoteRegex) || [];
if (noteId) {
return `/${hostname}/s/${noteId}`;
}
}
export default getInstanceStatusURL;

View file

@ -0,0 +1,6 @@
export default function isMastodonLinkMaybe(url) {
return (
/^https:\/\/.*\/\d+$/i.test(url) ||
/^https:\/\/.*\/notes\/[a-z0-9]+$/i.test(url) // Misskey, Calckey
);
}

View file

@ -27,6 +27,7 @@ const states = proxy({
spoilers: {}, spoilers: {},
scrollPositions: {}, scrollPositions: {},
unfurledLinks: {}, unfurledLinks: {},
statusQuotes: {},
accounts: {}, accounts: {},
// Modals // Modals
showCompose: false, showCompose: false,
@ -50,6 +51,7 @@ const states = proxy({
store.account.get('settings-contentTranslationTargetLanguage') || null, store.account.get('settings-contentTranslationTargetLanguage') || null,
contentTranslationHideLanguages: contentTranslationHideLanguages:
store.account.get('settings-contentTranslationHideLanguages') || [], store.account.get('settings-contentTranslationHideLanguages') || [],
cloakMode: store.account.get('settings-cloakMode') ?? false,
}, },
}); });
@ -87,6 +89,9 @@ subscribe(states, (changes) => {
if (path?.[0] === 'shortcuts') { if (path?.[0] === 'shortcuts') {
store.account.set('shortcuts', states.shortcuts); store.account.set('shortcuts', states.shortcuts);
} }
if (path.join('.') === 'settings.cloakMode') {
store.account.set('settings-cloakMode', !!value);
}
} }
}); });

View file

@ -1,4 +1,4 @@
import { getStatus } from './states'; import store from './store';
export function groupBoosts(values) { export function groupBoosts(values) {
let newValues = []; let newValues = [];
@ -50,24 +50,32 @@ export function groupBoosts(values) {
} }
export function dedupeBoosts(items, instance) { export function dedupeBoosts(items, instance) {
return items.filter((item) => { const boostedStatusIDs = store.account.get('boostedStatusIDs') || {};
const filteredItems = items.filter((item) => {
if (!item.reblog) return true; if (!item.reblog) return true;
const s = getStatus(item.reblog.id, instance); const statusKey = `${instance}-${item.reblog.id}`;
if (s) { const boosterID = boostedStatusIDs[statusKey];
if (boosterID && boosterID !== item.id) {
console.warn( console.warn(
`🚫 Duplicate boost by ${item.account.displayName}`, `🚫 Duplicate boost by ${item.account.displayName}`,
item, item,
s, item.reblog,
); );
return false; return false;
} } else {
const s2 = getStatus(item.id, instance); boostedStatusIDs[statusKey] = item.id;
if (s2) {
console.warn('🚫 Re-boosted boost', item);
return false;
} }
return true; return true;
}); });
// Limit to 50
const keys = Object.keys(boostedStatusIDs);
if (keys.length > 50) {
keys.slice(0, keys.length - 50).forEach((key) => {
delete boostedStatusIDs[key];
});
}
store.account.set('boostedStatusIDs', boostedStatusIDs);
return filteredItems;
} }
export function groupContext(items) { export function groupContext(items) {

View file

@ -19,6 +19,7 @@ export default function useScroll({
useEffect(() => { useEffect(() => {
const scrollableElement = scrollableRef.current; const scrollableElement = scrollableRef.current;
if (!scrollableElement) return {};
let previousScrollStart = isVertical let previousScrollStart = isVertical
? scrollableElement.scrollTop ? scrollableElement.scrollTop
: scrollableElement.scrollLeft; : scrollableElement.scrollLeft;

View file

@ -1,31 +1,36 @@
import { useEffect } from 'preact/hooks'; import { useEffect } from 'preact/hooks';
import { matchPath } from 'react-router-dom'; import { matchPath } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { subscribeKey } from 'valtio/utils';
import states from './states'; import states from './states';
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env; const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
export default function useTitle(title, path) { export default function useTitle(title, path) {
const snapStates = useSnapshot(states); function setTitle() {
const { currentLocation } = snapStates; const { currentLocation } = states;
const hasPaths = Array.isArray(path); const hasPaths = Array.isArray(path);
let paths = hasPaths ? path : []; let paths = hasPaths ? path : [];
// Workaround for matchPath not working for optional path segments // Workaround for matchPath not working for optional path segments
// https://github.com/remix-run/react-router/discussions/9862 // https://github.com/remix-run/react-router/discussions/9862
if (!hasPaths && /:?\w+\?/.test(path)) { if (!hasPaths && /:?\w+\?/.test(path)) {
paths.push(path.replace(/(:\w+)\?/g, '$1')); paths.push(path.replace(/(:\w+)\?/g, '$1'));
paths.push(path.replace(/\/?:\w+\?/g, '')); paths.push(path.replace(/\/?:\w+\?/g, ''));
}
let matched = false;
if (paths.length) {
matched = paths.some((p) => matchPath(p, currentLocation));
} else if (path) {
matched = matchPath(path, currentLocation);
}
console.log('setTitle', { title, path, currentLocation, paths, matched });
if (matched) {
document.title = title ? `${title} / ${CLIENT_NAME}` : CLIENT_NAME;
}
} }
let matched = false;
if (paths.length) {
matched = paths.some((p) => matchPath(p, currentLocation));
} else if (path) {
matched = matchPath(path, currentLocation);
}
console.debug({ paths, matched, currentLocation });
useEffect(() => { useEffect(() => {
if (!matched) return; setTitle();
document.title = title ? `${title} / ${CLIENT_NAME}` : CLIENT_NAME; return subscribeKey(states, 'currentLocation', setTitle);
}, [title, matched]); }, [title, path]);
} }

View file

@ -94,6 +94,15 @@ export default defineConfig({
main: resolve(__dirname, 'index.html'), main: resolve(__dirname, 'index.html'),
compose: resolve(__dirname, 'compose/index.html'), compose: resolve(__dirname, 'compose/index.html'),
}, },
output: {
chunkFileNames: (chunkInfo) => {
const { facadeModuleId } = chunkInfo;
if (facadeModuleId && facadeModuleId.includes('icon')) {
return 'assets/icons/[name]-[hash].js';
}
return 'assets/[name]-[hash].js';
},
},
}, },
}, },
}); });