commit
ad45bf9d19
21
LICENSE
Normal file
21
LICENSE
Normal 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
18
package-lock.json
generated
|
@ -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": {
|
||||||
|
|
|
@ -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": [
|
||||||
|
|
21
public/sw.js
21
public/sw.js
|
@ -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
|
||||||
|
|
280
src/app.css
280
src/app.css
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
39
src/app.jsx
39
src/app.jsx
|
@ -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 };
|
||||||
|
|
BIN
src/assets/features/boosts-carousel.jpg
Normal file
BIN
src/assets/features/boosts-carousel.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 41 KiB |
BIN
src/assets/features/grouped-notifications.jpg
Normal file
BIN
src/assets/features/grouped-notifications.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 62 KiB |
BIN
src/assets/features/multi-column.jpg
Normal file
BIN
src/assets/features/multi-column.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 48 KiB |
BIN
src/assets/features/multi-hashtag-timeline.jpg
Normal file
BIN
src/assets/features/multi-hashtag-timeline.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 31 KiB |
BIN
src/assets/features/nested-comments-thread.jpg
Normal file
BIN
src/assets/features/nested-comments-thread.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
21
src/cloak-mode.css
Normal file
21
src/cloak-mode.css
Normal 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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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%;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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} />;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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' ? (
|
||||||
|
|
|
@ -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'} />
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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'} />
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
231
src/components/poll.jsx
Normal 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>{' '}
|
||||||
|
•{' '}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
|
||||||
|
{votesCount === 1 ? '' : 's'}
|
||||||
|
{!!votersCount && votersCount !== votesCount && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
•{' '}
|
||||||
|
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
||||||
|
voter
|
||||||
|
{votersCount === 1 ? '' : 's'}
|
||||||
|
</>
|
||||||
|
)}{' '}
|
||||||
|
• {expired ? 'Ended' : 'Ending'}{' '}
|
||||||
|
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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>{' '}
|
|
||||||
•{' '}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
|
|
||||||
{votesCount === 1 ? '' : 's'}
|
|
||||||
{!!votersCount && votersCount !== votesCount && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
•{' '}
|
|
||||||
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
|
|
||||||
voter
|
|
||||||
{votersCount === 1 ? '' : 's'}
|
|
||||||
</>
|
|
||||||
)}{' '}
|
|
||||||
• {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);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
28
src/pages/HttpRoute.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
19
src/utils/get-instance-status-url.js
Normal file
19
src/utils/get-instance-status-url.js
Normal 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;
|
6
src/utils/isMastodonLinkMaybe.jsx
Normal file
6
src/utils/isMastodonLinkMaybe.jsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue