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",
|
||||
"uid": "~2.0.2",
|
||||
"use-debounce": "~9.0.3",
|
||||
"use-long-press": "~2.0.3",
|
||||
"use-long-press": "~3.0.4",
|
||||
"use-resize-observer": "~9.1.0",
|
||||
"valtio": "1.9.0"
|
||||
},
|
||||
|
@ -6730,13 +6730,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/use-long-press": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz",
|
||||
"integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==",
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.0.4.tgz",
|
||||
"integrity": "sha512-+/qkbuRjsrzi30aSIE6lrq0+7TSGKUg6drbk/jSNqJqeWWRIjj5/XQoA9YzQC+IVVwkmcknK8MLi/HtAfNFvPA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
|
@ -11861,9 +11857,9 @@
|
|||
"requires": {}
|
||||
},
|
||||
"use-long-press": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-2.0.3.tgz",
|
||||
"integrity": "sha512-n3cfv90Y1ldNt+hhXzxnxuLZmgLOOC/+qfLGoeEBgOxmnokPPt39MPF3KmvKriq5VMoJ7uQdVjHejCdHBt9anw==",
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/use-long-press/-/use-long-press-3.0.4.tgz",
|
||||
"integrity": "sha512-+/qkbuRjsrzi30aSIE6lrq0+7TSGKUg6drbk/jSNqJqeWWRIjj5/XQoA9YzQC+IVVwkmcknK8MLi/HtAfNFvPA==",
|
||||
"requires": {}
|
||||
},
|
||||
"use-resize-observer": {
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
"toastify-js": "~1.12.0",
|
||||
"uid": "~2.0.2",
|
||||
"use-debounce": "~9.0.3",
|
||||
"use-long-press": "~2.0.3",
|
||||
"use-long-press": "~3.0.4",
|
||||
"use-resize-observer": "~9.1.0",
|
||||
"valtio": "1.9.0"
|
||||
},
|
||||
|
@ -59,7 +59,11 @@
|
|||
"postcss": {
|
||||
"plugins": {
|
||||
"postcss-dark-theme-class": {},
|
||||
"postcss-preset-env": {}
|
||||
"postcss-preset-env": {
|
||||
"features": {
|
||||
"logical-properties-and-values": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
|
|
21
public/sw.js
21
public/sw.js
|
@ -33,6 +33,27 @@ const imageRoute = new Route(
|
|||
);
|
||||
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
|
||||
// - /api/v1/instance
|
||||
// - /api/v1/custom_emojis
|
||||
|
|
280
src/app.css
280
src/app.css
|
@ -20,6 +20,9 @@ body {
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#app-standalone {
|
||||
background-color: var(--bg-faded-color);
|
||||
}
|
||||
|
||||
/* MENTIONS */
|
||||
|
||||
|
@ -53,6 +56,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
background-color: var(--bg-color);
|
||||
/* This `transform` fixes carousel blocking vertical scrolling for pointer devices on iPad */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
.deck-container[hidden] {
|
||||
display: block;
|
||||
|
@ -154,6 +159,41 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
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 {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
|
@ -199,7 +239,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
.timeline.contextual > li:first-child {
|
||||
background-position: 0 16px;
|
||||
background-position: 0 calc(16px + var(--avatar-size));
|
||||
}
|
||||
.timeline.contextual > li:last-child {
|
||||
background-size: 100% 20px;
|
||||
|
@ -376,6 +416,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
padding-bottom: 12px;
|
||||
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 {
|
||||
margin-left: calc(
|
||||
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 {
|
||||
--avatar-size: 50px;
|
||||
--line-start: 40px;
|
||||
--line-width: 3px;
|
||||
--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-right-radius: 0;
|
||||
border-bottom: 0;
|
||||
background-position: 0 16px;
|
||||
background-position: 0 calc(16px + var(--avatar-size));
|
||||
}
|
||||
.timeline:not(.flat) > li.timeline-item-container-middle {
|
||||
margin-top: 0;
|
||||
|
@ -583,21 +629,23 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-top: 0;
|
||||
background-size: 100% 20px;
|
||||
background-size: 100% 16px;
|
||||
}
|
||||
.timeline:not(.flat)
|
||||
> 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;
|
||||
}
|
||||
|
||||
.status-loading {
|
||||
text-align: center;
|
||||
color: var(--text-insignificant-color);
|
||||
max-width: var(--main-width);
|
||||
}
|
||||
.status-error {
|
||||
text-align: center;
|
||||
color: var(--text-insignificant-color);
|
||||
max-width: var(--main-width);
|
||||
}
|
||||
|
||||
.status-link {
|
||||
|
@ -688,7 +736,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
counter-increment: index;
|
||||
position: relative;
|
||||
}
|
||||
@media (hover: hover) or (pointer: fine) {
|
||||
@media (hover: hover) or (pointer: fine) or (min-width: 40em) {
|
||||
.status-carousel ul {
|
||||
scroll-snap-type: none;
|
||||
}
|
||||
|
@ -779,12 +827,37 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
width: var(--main-width);
|
||||
max-width: 100vw;
|
||||
background-color: var(--bg-color);
|
||||
animation: slide-in 0.5s var(--timing-function);
|
||||
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 {
|
||||
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 {
|
||||
color: var(--text-insignificant-color) !important;
|
||||
|
@ -848,6 +921,23 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
/* CAROUSEL */
|
||||
/* 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 {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
|
@ -912,7 +1002,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
top: env(safe-area-inset-top, 0);
|
||||
}
|
||||
:is(.carousel-top-controls, .carousel-controls) {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
left: env(safe-area-inset-left, 0);
|
||||
right: 0;
|
||||
|
@ -927,6 +1017,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
:is(.carousel-top-controls, .carousel-controls)[hidden] {
|
||||
opacity: 0;
|
||||
}
|
||||
.carousel-controls {
|
||||
top: 45%;
|
||||
}
|
||||
|
||||
:is(.button, button).carousel-button,
|
||||
button.carousel-dot {
|
||||
|
@ -994,6 +1087,19 @@ body:has(.status-deck) .media-post-link {
|
|||
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-post-link .button-label {
|
||||
display: inline;
|
||||
|
@ -1019,6 +1125,26 @@ body:has(.status-deck) .media-post-link {
|
|||
right: 350px;
|
||||
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 */
|
||||
|
@ -1084,6 +1210,7 @@ body:has(.status-deck) .media-post-link {
|
|||
box-shadow: 0 -1px 32px var(--drop-shadow-color);
|
||||
animation: slide-up 0.3s var(--timing-function);
|
||||
/* border: 1px solid var(--outline-color); */
|
||||
position: relative;
|
||||
}
|
||||
.sheet-max {
|
||||
width: 90vw;
|
||||
|
@ -1092,12 +1219,52 @@ body:has(.status-deck) .media-post-link {
|
|||
height: 90vh;
|
||||
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 {
|
||||
padding: 16px 16px 8px;
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
user-select: none;
|
||||
}
|
||||
.sheet .sheet-close:not(.outer) + header {
|
||||
padding-right: max(44px, env(safe-area-inset-right));
|
||||
}
|
||||
.sheet header :is(h1, h2, h3) {
|
||||
margin: 0;
|
||||
}
|
||||
|
@ -1213,9 +1380,9 @@ body:has(.status-deck) .media-post-link {
|
|||
text-overflow: ellipsis;
|
||||
line-height: 1.05;
|
||||
}
|
||||
.szh-menu .szh-menu__item * {
|
||||
/* .szh-menu .szh-menu__item * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
} */
|
||||
.szh-menu .szh-menu__item a {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
@ -1227,6 +1394,8 @@ body:has(.status-deck) .media-post-link {
|
|||
padding: 8px 16px !important;
|
||||
margin: -8px -16px !important;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
.szh-menu .szh-menu__item a.is-active {
|
||||
font-weight: bold;
|
||||
|
@ -1268,6 +1437,18 @@ body:has(.status-deck) .media-post-link {
|
|||
.szh-menu .menu-horizontal .szh-menu__item {
|
||||
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 {
|
||||
opacity: 0.5;
|
||||
font-weight: normal;
|
||||
|
@ -1581,6 +1762,16 @@ ul.link-list li a .icon {
|
|||
overscroll-behavior: auto;
|
||||
flex-basis: min(100vw, 360px);
|
||||
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 {
|
||||
pointer-events: none;
|
||||
|
@ -1595,9 +1786,9 @@ ul.link-list li a .icon {
|
|||
}
|
||||
@media (min-width: 40em) {
|
||||
#columns {
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--bg-blur-color);
|
||||
/* gap: 16px; */
|
||||
/* padding: 0 16px; */
|
||||
/* background-color: var(--bg-faded-color); */
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
justify-content: stretch;
|
||||
|
@ -1605,22 +1796,65 @@ ul.link-list li a .icon {
|
|||
}
|
||||
#columns > * {
|
||||
padding: 0 16px;
|
||||
border: var(--hairline-width) solid var(--outline-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 16px var(--drop-shadow-color);
|
||||
border-inline: var(--hairline-width) solid var(--bg-faded-color);
|
||||
/* border-radius: 16px; */
|
||||
/* box-shadow: -4px 0 16px -8px var(--drop-shadow-color); */
|
||||
height: unset;
|
||||
background-image: linear-gradient(
|
||||
/* background-color: var(--bg-faded-blur-color); */
|
||||
/* backdrop-filter: blur(16px) saturate(3); */
|
||||
/* background-image: linear-gradient(
|
||||
160deg,
|
||||
transparent 20%,
|
||||
var(--bg-color),
|
||||
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 > *:has(:focus-visible) {
|
||||
box-shadow: 0 4px 16px var(--drop-shadow-color),
|
||||
0 4px 16px var(--drop-shadow-color);
|
||||
border-color: var(--outline-hover-color);
|
||||
/* box-shadow: 0 4px 16px var(--drop-shadow-color),
|
||||
0 4px 16px var(--drop-shadow-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
|
||||
|
@ -1635,6 +1869,10 @@ ul.link-list li a .icon {
|
|||
#columns .timeline-deck > header {
|
||||
margin: 0;
|
||||
}
|
||||
#columns .timeline-deck > header[hidden] {
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#columns li:has(.status-carousel) {
|
||||
width: auto;
|
||||
transform: none;
|
||||
|
@ -1714,7 +1952,7 @@ ul.link-list li a .icon {
|
|||
.deck-container {
|
||||
transition: transform 0.4s var(--timing-function);
|
||||
}
|
||||
.deck-container:has(~ .deck-backdrop) {
|
||||
.deck-container:has(~ .deck-backdrop .deck) {
|
||||
transition: transform 0.4s ease-out;
|
||||
transform: translate3d(-5vw, 0, 0);
|
||||
}
|
||||
|
|
35
src/app.jsx
35
src/app.jsx
|
@ -13,7 +13,9 @@ import {
|
|||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import 'swiped-events';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import AccountSheet from './components/account-sheet';
|
||||
|
@ -33,6 +35,7 @@ import FollowedHashtags from './pages/followed-hashtags';
|
|||
import Following from './pages/following';
|
||||
import Hashtag from './pages/hashtag';
|
||||
import Home from './pages/home';
|
||||
import HttpRoute from './pages/HttpRoute';
|
||||
import List from './pages/list';
|
||||
import Lists from './pages/lists';
|
||||
import Login from './pages/login';
|
||||
|
@ -189,12 +192,16 @@ function App() {
|
|||
location,
|
||||
});
|
||||
|
||||
if (/\/https?:/.test(location.pathname)) {
|
||||
return <HttpRoute />;
|
||||
}
|
||||
|
||||
const nonRootLocation = useMemo(() => {
|
||||
const { pathname } = location;
|
||||
return !/^\/(login|welcome)/.test(pathname);
|
||||
}, [location]);
|
||||
|
||||
// Change #app classname based on snapStates.settings.shortcutsViewMode
|
||||
// Change #app dataset based on snapStates.settings.shortcutsViewMode
|
||||
useEffect(() => {
|
||||
const $app = document.getElementById('app');
|
||||
if ($app) {
|
||||
|
@ -202,6 +209,12 @@ function App() {
|
|||
}
|
||||
}, [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 (
|
||||
<>
|
||||
<Routes location={nonRootLocation || location}>
|
||||
|
@ -245,11 +258,14 @@ function App() {
|
|||
<Route path="/:instance?/search" element={<Search />} />
|
||||
{/* <Route path="/:anything" element={<NotFound />} /> */}
|
||||
</Routes>
|
||||
{uiState === 'default' && (
|
||||
<Routes>
|
||||
<Route path="/:instance?/s/:id" element={<Status />} />
|
||||
<Route path="/:instance?/s/:id" element={<StatusRoute />} />
|
||||
</Routes>
|
||||
)}
|
||||
<div>
|
||||
{!snapStates.settings.shortcutsColumnsMode &&
|
||||
{isLoggedIn &&
|
||||
!snapStates.settings.shortcutsColumnsMode &&
|
||||
snapStates.settings.shortcutsViewMode !== 'multi-column' && (
|
||||
<Shortcuts />
|
||||
)}
|
||||
|
@ -356,7 +372,7 @@ function App() {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Drafts />
|
||||
<Drafts onClose={() => (states.showDrafts = false)} />
|
||||
</Modal>
|
||||
)}
|
||||
{!!snapStates.showMediaModal && (
|
||||
|
@ -383,13 +399,16 @@ function App() {
|
|||
)}
|
||||
{!!snapStates.showShortcutsSettings && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
states.showShortcutsSettings = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShortcutsSettings />
|
||||
<ShortcutsSettings
|
||||
onClose={() => (states.showShortcutsSettings = false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
<BackgroundService isLoggedIn={isLoggedIn} />
|
||||
|
@ -483,4 +502,10 @@ function BackgroundService({ isLoggedIn }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function StatusRoute() {
|
||||
const params = useParams();
|
||||
const { id, instance } = params;
|
||||
return <Status id={id} instance={instance} />;
|
||||
}
|
||||
|
||||
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,
|
||||
statusesCount,
|
||||
lastStatusAt,
|
||||
bot,
|
||||
} = account;
|
||||
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||||
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>
|
||||
{displayName ? (
|
||||
<b
|
||||
|
|
|
@ -110,7 +110,7 @@
|
|||
drop-shadow(2px 0 4px var(--header-color-4, --bg-color));
|
||||
}
|
||||
.account-container header .avatar:not(.has-alpha) img {
|
||||
border-radius: 50%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.account-container main > *:first-child {
|
||||
|
@ -138,7 +138,7 @@
|
|||
font-size: 90%;
|
||||
background-color: var(--bg-faded-color);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: 16px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.account-container .stats > * {
|
||||
|
@ -165,7 +165,9 @@
|
|||
.account-container .profile-metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
gap: 2px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.account-container .profile-field {
|
||||
min-width: 0;
|
||||
|
@ -173,7 +175,7 @@
|
|||
font-size: 90%;
|
||||
background-color: var(--bg-faded-color);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border-radius: 4px;
|
||||
filter: saturate(0.75);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
@ -198,12 +200,23 @@
|
|||
}
|
||||
|
||||
.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-bottom: 1px solid var(--outline-color);
|
||||
padding: 8px 0;
|
||||
font-size: 90%;
|
||||
line-height: 1.5;
|
||||
color: var(--text-insignificant-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timeline-start .account-container {
|
||||
|
|
|
@ -487,9 +487,10 @@ function RelatedActions({ info, instance, authenticated }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
{familiarFollowers?.length > 0 && (
|
||||
<p class="common-followers">
|
||||
Common followers{' '}
|
||||
<div class="common-followers" hidden={!familiarFollowers?.length}>
|
||||
<div class="common-followers-inner">
|
||||
<p>
|
||||
Also followed by{' '}
|
||||
<span class="ib">
|
||||
{familiarFollowers.map((follower) => (
|
||||
<a
|
||||
|
@ -507,22 +508,24 @@ function RelatedActions({ info, instance, authenticated }) {
|
|||
url={follower.avatarStatic}
|
||||
size="l"
|
||||
alt={`${follower.displayName} @${follower.acct}`}
|
||||
squircle={follower?.bot}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p class="actions">
|
||||
{followedBy ? (
|
||||
<span class="tag">Following you</span>
|
||||
) : !!lastStatusAt ? (
|
||||
<span class="insignificant">
|
||||
<small class="insignificant">
|
||||
Last status:{' '}
|
||||
{niceDateTime(lastStatusAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
</span>
|
||||
</small>
|
||||
) : (
|
||||
<span />
|
||||
)}{' '}
|
||||
|
@ -845,7 +848,11 @@ function RelatedActions({ info, instance, authenticated }) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<TranslatedBioSheet note={note} fields={fields} />
|
||||
<TranslatedBioSheet
|
||||
note={note}
|
||||
fields={fields}
|
||||
onClose={() => setShowTranslatedBio(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!showAddRemoveLists && (
|
||||
|
@ -857,7 +864,10 @@ function RelatedActions({ info, instance, authenticated }) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<AddRemoveListsSheet accountID={accountID.current} />
|
||||
<AddRemoveListsSheet
|
||||
accountID={accountID.current}
|
||||
onClose={() => setShowAddRemoveLists(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
|
@ -894,7 +904,7 @@ function niceAccountURL(url) {
|
|||
);
|
||||
}
|
||||
|
||||
function TranslatedBioSheet({ note, fields }) {
|
||||
function TranslatedBioSheet({ note, fields, onClose }) {
|
||||
const fieldsText =
|
||||
fields
|
||||
?.map(({ name, value }) => `${name}\n${getHTMLText(value)}`)
|
||||
|
@ -904,6 +914,11 @@ function TranslatedBioSheet({ note, fields }) {
|
|||
|
||||
return (
|
||||
<div class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>Translated Bio</h2>
|
||||
</header>
|
||||
|
@ -921,7 +936,7 @@ function TranslatedBioSheet({ note, fields }) {
|
|||
);
|
||||
}
|
||||
|
||||
function AddRemoveListsSheet({ accountID }) {
|
||||
function AddRemoveListsSheet({ accountID, onClose }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUiState] = useState('default');
|
||||
const [lists, setLists] = useState([]);
|
||||
|
@ -951,6 +966,11 @@ function AddRemoveListsSheet({ accountID }) {
|
|||
|
||||
return (
|
||||
<div class="sheet" id="list-add-remove-container">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>Add/Remove from Lists</h2>
|
||||
</header>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { api } from '../utils/api';
|
|||
import states from '../utils/states';
|
||||
|
||||
import AccountInfo from './account-info';
|
||||
import Icon from './icon';
|
||||
|
||||
function AccountSheet({ account, instance: propInstance, onClose }) {
|
||||
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
|
||||
instance={instance}
|
||||
authenticated={authenticated}
|
||||
|
|
|
@ -12,6 +12,9 @@
|
|||
.avatar.has-alpha {
|
||||
border-radius: 0;
|
||||
}
|
||||
.avatar:not(.has-alpha).squircle {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
|
|
|
@ -13,14 +13,16 @@ const SIZES = {
|
|||
|
||||
const alphaCache = {};
|
||||
|
||||
function Avatar({ url, size, alt = '', ...props }) {
|
||||
function Avatar({ url, size, alt = '', squircle, ...props }) {
|
||||
size = SIZES[size] || size || SIZES.m;
|
||||
const avatarRef = useRef();
|
||||
const isMissing = /missing\.png$/.test(url);
|
||||
return (
|
||||
<span
|
||||
ref={avatarRef}
|
||||
class={`avatar ${alphaCache[url] ? 'has-alpha' : ''}`}
|
||||
class={`avatar ${squircle ? 'squircle' : ''} ${
|
||||
alphaCache[url] ? 'has-alpha' : ''
|
||||
}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
|
|
|
@ -6,8 +6,10 @@ import Favourites from '../pages/favourites';
|
|||
import Following from '../pages/following';
|
||||
import Hashtag from '../pages/hashtag';
|
||||
import List from '../pages/list';
|
||||
import Mentions from '../pages/mentions';
|
||||
import Notifications from '../pages/notifications';
|
||||
import Public from '../pages/public';
|
||||
import Trending from '../pages/trending';
|
||||
import states from '../utils/states';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
|
@ -17,6 +19,7 @@ function Columns() {
|
|||
const { shortcuts } = snapStates;
|
||||
|
||||
const components = shortcuts.map((shortcut) => {
|
||||
if (!shortcut) return null;
|
||||
const { type, ...params } = shortcut;
|
||||
const Component = {
|
||||
following: Following,
|
||||
|
@ -26,6 +29,8 @@ function Columns() {
|
|||
bookmarks: Bookmarks,
|
||||
favourites: Favourites,
|
||||
hashtag: Hashtag,
|
||||
mentions: Mentions,
|
||||
trending: Trending,
|
||||
}[type];
|
||||
if (!Component) return null;
|
||||
return <Component {...params} />;
|
||||
|
|
|
@ -70,12 +70,10 @@
|
|||
overflow: auto;
|
||||
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;
|
||||
}
|
||||
#compose-container .status-preview :is(.hashtag, .time) {
|
||||
/* Prevent hashtags from being clickable */
|
||||
/* TODO: maybe use a different solution? */
|
||||
#compose-container .status-preview :is(.content-container, .time) {
|
||||
pointer-events: none;
|
||||
}
|
||||
#compose-container.standalone .status-preview * {
|
||||
|
@ -192,6 +190,8 @@
|
|||
background-color: inherit;
|
||||
border: 0;
|
||||
padding: 0 0 0 8px;
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
#compose-container .toolbar-button:not(.show-field) select {
|
||||
right: 0;
|
||||
|
|
|
@ -508,6 +508,7 @@ function Compose({
|
|||
url={currentAccountInfo.avatarStatic}
|
||||
size="xl"
|
||||
alt={currentAccountInfo.username}
|
||||
squircle={currentAccountInfo?.bot}
|
||||
/>
|
||||
)}
|
||||
{!standalone ? (
|
||||
|
@ -1083,7 +1084,24 @@ function Compose({
|
|||
disabled={uiState === 'loading'}
|
||||
>
|
||||
{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);
|
||||
})
|
||||
.map(([code, common, native]) => (
|
||||
|
@ -1486,6 +1504,15 @@ function MediaAttachment({
|
|||
}}
|
||||
>
|
||||
<div id="media-sheet" class="sheet sheet-max">
|
||||
<button
|
||||
type="button"
|
||||
class="sheet-close"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
}}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
<header>
|
||||
<h2>
|
||||
{
|
||||
|
@ -1724,6 +1751,11 @@ function CustomEmojisModal({
|
|||
|
||||
return (
|
||||
<div id="custom-emojis-sheet" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<b>Custom emojis</b>{' '}
|
||||
{uiState === 'loading' ? (
|
||||
|
|
|
@ -11,7 +11,7 @@ import { getCurrentAccountNS } from '../utils/store-utils';
|
|||
import Icon from './icon';
|
||||
import Loader from './loader';
|
||||
|
||||
function Drafts() {
|
||||
function Drafts({ onClose }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [drafts, setDrafts] = useState([]);
|
||||
|
@ -51,6 +51,11 @@ function Drafts() {
|
|||
|
||||
return (
|
||||
<div class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>
|
||||
Unsent drafts <Loader abrupt hidden={uiState !== 'loading'} />
|
||||
|
|
|
@ -77,6 +77,8 @@ const ICONS = {
|
|||
filter: 'mingcute:filter-2-line',
|
||||
chart: 'mingcute:chart-line-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');
|
||||
|
|
|
@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from 'preact/hooks';
|
|||
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function ListAddEdit({ list, onClose = () => {} }) {
|
||||
import Icon from './icon';
|
||||
|
||||
function ListAddEdit({ list, onClose }) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUiState] = useState('default');
|
||||
const editMode = !!list;
|
||||
|
@ -16,6 +18,11 @@ function ListAddEdit({ list, onClose = () => {} }) {
|
|||
}, [editMode]);
|
||||
return (
|
||||
<div class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}{' '}
|
||||
<header>
|
||||
<h2>{editMode ? 'Edit list' : 'New list'}</h2>
|
||||
</header>
|
||||
|
@ -52,7 +59,7 @@ function ListAddEdit({ list, onClose = () => {} }) {
|
|||
|
||||
console.log(listResult);
|
||||
setUiState('default');
|
||||
onClose({
|
||||
onClose?.({
|
||||
state: 'success',
|
||||
list: listResult,
|
||||
});
|
||||
|
@ -109,7 +116,7 @@ function ListAddEdit({ list, onClose = () => {} }) {
|
|||
try {
|
||||
await masto.v1.lists.remove(list.id);
|
||||
setUiState('default');
|
||||
onClose({
|
||||
onClose?.({
|
||||
state: 'deleted',
|
||||
});
|
||||
} catch (e) {
|
||||
|
|
|
@ -24,16 +24,16 @@ function MediaModal({
|
|||
useLayoutEffect(() => {
|
||||
carouselFocusItem.current?.scrollIntoView();
|
||||
|
||||
history.pushState({ mediaModal: true }, '');
|
||||
const handlePopState = (e) => {
|
||||
if (e.state?.mediaModal) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
// history.pushState({ mediaModal: true }, '');
|
||||
// const handlePopState = (e) => {
|
||||
// if (e.state?.mediaModal) {
|
||||
// onClose();
|
||||
// }
|
||||
// };
|
||||
// window.addEventListener('popstate', handlePopState);
|
||||
// return () => {
|
||||
// window.removeEventListener('popstate', handlePopState);
|
||||
// };
|
||||
}, []);
|
||||
const prevStatusID = useRef(statusID);
|
||||
useEffect(() => {
|
||||
|
@ -84,8 +84,15 @@ function MediaModal({
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let timer = setTimeout(() => {
|
||||
carouselRef.current?.focus?.();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="media-modal-container">
|
||||
<div
|
||||
ref={carouselRef}
|
||||
tabIndex="-1"
|
||||
|
@ -206,7 +213,11 @@ function MediaModal({
|
|||
</MenuLink>
|
||||
</Menu>{' '}
|
||||
<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"
|
||||
onClick={() => {
|
||||
// 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>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaAltModal({ alt }) {
|
||||
function MediaAltModal({ alt, onClose }) {
|
||||
const [forceTranslate, setForceTranslate] = useState(false);
|
||||
return (
|
||||
<div class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close outer" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header class="header-grid">
|
||||
<h2>Media description</h2>
|
||||
<div class="header-side">
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
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 Icon from './icon';
|
||||
import Link from './link';
|
||||
import { formatDuration } from './status';
|
||||
|
||||
/*
|
||||
|
@ -15,7 +16,7 @@ video = Video clip
|
|||
audio = Audio track
|
||||
*/
|
||||
|
||||
function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||
function Media({ media, to, showOriginal, autoAnimate, onClick = () => {} }) {
|
||||
const { blurhash, description, meta, previewUrl, remoteUrl, url, type } =
|
||||
media;
|
||||
const { original = {}, small, focus } = meta || {};
|
||||
|
@ -23,6 +24,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
const width = showOriginal ? original?.width : small?.width;
|
||||
const height = showOriginal ? original?.height : small?.height;
|
||||
const mediaURL = showOriginal ? url : previewUrl;
|
||||
const orientation = width >= height ? 'landscape' : 'portrait';
|
||||
|
||||
const rgbAverageColor = blurhash ? getBlurHashAverageColor(blurhash) : null;
|
||||
|
||||
|
@ -47,14 +49,20 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
if (media) {
|
||||
const value = make3dTransformValue({ x, y, scale });
|
||||
|
||||
if (scale === 1) {
|
||||
media.style.removeProperty('transform');
|
||||
} else {
|
||||
media.style.setProperty('transform', value);
|
||||
}
|
||||
|
||||
media.closest('.media-zoom').style.touchAction =
|
||||
scale <= 1 ? 'pan-x' : '';
|
||||
scale <= 1.01 ? 'pan-x' : '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [pinchZoomEnabled, setPinchZoomEnabled] = useState(false);
|
||||
const quickPinchZoomProps = {
|
||||
enabled: pinchZoomEnabled,
|
||||
draggableUnZoomed: false,
|
||||
inertiaFriction: 0.9,
|
||||
containerProps: {
|
||||
|
@ -71,11 +79,16 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
onUpdate,
|
||||
};
|
||||
|
||||
const Parent = useMemo(
|
||||
() => (to ? (props) => <Link to={to} {...props} /> : 'div'),
|
||||
[to],
|
||||
);
|
||||
|
||||
if (type === 'image' || (type === 'unknown' && previewUrl && url)) {
|
||||
// Note: type: unknown might not have width/height
|
||||
quickPinchZoomProps.containerProps.style.display = 'inherit';
|
||||
return (
|
||||
<div
|
||||
<Parent
|
||||
class={`media media-image`}
|
||||
onClick={onClick}
|
||||
style={
|
||||
|
@ -92,11 +105,13 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
onLoad={(e) => {
|
||||
e.target.closest('.media-image').style.backgroundImage = '';
|
||||
e.target.closest('.media-zoom').style.display = '';
|
||||
setPinchZoomEnabled(true);
|
||||
}}
|
||||
/>
|
||||
</QuickPinchZoom>
|
||||
|
@ -106,6 +121,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="lazy"
|
||||
style={{
|
||||
backgroundColor:
|
||||
|
@ -117,7 +133,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Parent>
|
||||
);
|
||||
} else if (type === 'gifv' || type === 'video') {
|
||||
const shortDuration = original.duration < 31;
|
||||
|
@ -134,6 +150,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
poster="${previewUrl}"
|
||||
width="${width}"
|
||||
height="${height}"
|
||||
data-orientation="${orientation}"
|
||||
preload="auto"
|
||||
autoplay
|
||||
muted="${isGIF}"
|
||||
|
@ -145,16 +162,16 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
`;
|
||||
|
||||
return (
|
||||
<div
|
||||
<Parent
|
||||
class={`media media-${isGIF ? 'gif' : 'video'} ${
|
||||
autoGIFAnimate ? 'media-contain' : ''
|
||||
}`}
|
||||
data-formatted-duration={formattedDuration}
|
||||
data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''}
|
||||
style={{
|
||||
backgroundColor:
|
||||
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
}}
|
||||
// style={{
|
||||
// backgroundColor:
|
||||
// rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
|
||||
// }}
|
||||
onClick={(e) => {
|
||||
if (hoverAnimate) {
|
||||
try {
|
||||
|
@ -180,7 +197,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
>
|
||||
{showOriginal || autoGIFAnimate ? (
|
||||
isGIF && showOriginal ? (
|
||||
<QuickPinchZoom {...quickPinchZoomProps}>
|
||||
<QuickPinchZoom {...quickPinchZoomProps} enabled>
|
||||
<div
|
||||
ref={mediaRef}
|
||||
dangerouslySetInnerHTML={{
|
||||
|
@ -203,6 +220,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
poster={previewUrl}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
preload="auto"
|
||||
// controls
|
||||
playsinline
|
||||
|
@ -216,6 +234,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="media-play">
|
||||
|
@ -223,12 +242,12 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Parent>
|
||||
);
|
||||
} else if (type === 'audio') {
|
||||
const formattedDuration = formatDuration(original.duration);
|
||||
return (
|
||||
<div
|
||||
<Parent
|
||||
class="media media-audio"
|
||||
data-formatted-duration={formattedDuration}
|
||||
onClick={onClick}
|
||||
|
@ -241,6 +260,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
alt={description}
|
||||
width={width}
|
||||
height={height}
|
||||
data-orientation={orientation}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
|
@ -249,7 +269,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
|
|||
<Icon icon="play" size="xxl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Parent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,8 @@ function NavMenu(props) {
|
|||
// User may choose pin or not to pin Following
|
||||
// If user doesn't pin Following, we show it in the menu
|
||||
const showFollowing =
|
||||
snapStates.settings.shortcutsColumnsMode &&
|
||||
(snapStates.settings.shortcutsColumnsMode ||
|
||||
snapStates.settings.shortcutsViewMode === 'multi-column') &&
|
||||
!snapStates.shortcuts.find((pin) => pin.type === 'following');
|
||||
|
||||
const bindLongPress = useLongPress(
|
||||
|
@ -32,6 +33,7 @@ function NavMenu(props) {
|
|||
states.showAccounts = true;
|
||||
},
|
||||
{
|
||||
threshold: 600,
|
||||
detect: 'touch',
|
||||
cancelOnMovement: true,
|
||||
},
|
||||
|
@ -66,6 +68,7 @@ function NavMenu(props) {
|
|||
currentAccount?.info?.avatarStatic
|
||||
}
|
||||
size="l"
|
||||
squircle={currentAccount?.info?.bot}
|
||||
/>
|
||||
)}
|
||||
<Icon icon="menu" size={moreThanOneAccount ? 's' : 'l'} />
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
.name-text {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: inline;
|
||||
}
|
||||
.name-text.show-acct {
|
||||
display: inline-block;
|
||||
}
|
||||
a.name-text:is(:hover, :focus) b,
|
||||
|
@ -15,4 +18,5 @@ a.name-text.short:is(:hover, :focus) i {
|
|||
|
||||
.name-text .avatar {
|
||||
vertical-align: middle;
|
||||
transform: translateY(-0.1em);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ function NameText({
|
|||
external,
|
||||
onClick,
|
||||
}) {
|
||||
const { acct, avatar, avatarStatic, id, url, displayName, emojis } = account;
|
||||
const { acct, avatar, avatarStatic, id, url, displayName, emojis, bot } =
|
||||
account;
|
||||
let { username } = account;
|
||||
|
||||
const displayNameWithEmoji = emojifyText(displayName, emojis);
|
||||
|
@ -36,7 +37,7 @@ function NameText({
|
|||
|
||||
return (
|
||||
<a
|
||||
class={`name-text ${short ? 'short' : ''}`}
|
||||
class={`name-text ${showAcct ? 'show-acct' : ''} ${short ? 'short' : ''}`}
|
||||
href={url}
|
||||
target={external ? '_blank' : null}
|
||||
title={`@${acct}`}
|
||||
|
@ -52,7 +53,7 @@ function NameText({
|
|||
>
|
||||
{showAvatar && (
|
||||
<>
|
||||
<Avatar url={avatarStatic || avatar} />{' '}
|
||||
<Avatar url={avatarStatic || avatar} squircle={bot} />{' '}
|
||||
</>
|
||||
)}
|
||||
{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 {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
line-height: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
#shortcuts-settings-container .shortcuts-list li .shortcut-actions {
|
||||
flex-shrink: 0;
|
||||
|
@ -119,3 +121,7 @@
|
|||
min-width: 0;
|
||||
max-width: 320px;
|
||||
}
|
||||
#shortcut-settings-form form footer {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import './shortcuts-settings.css';
|
||||
|
||||
import mem from 'mem';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import floatingButtonUrl from '../assets/floating-button.svg';
|
||||
|
@ -60,7 +60,8 @@ const TYPE_PARAMS = {
|
|||
text: 'Instance',
|
||||
name: 'instance',
|
||||
type: 'text',
|
||||
placeholder: 'e.g. mastodon.social',
|
||||
placeholder: 'Optional, e.g. mastodon.social',
|
||||
notRequired: true,
|
||||
},
|
||||
],
|
||||
trending: [
|
||||
|
@ -68,7 +69,8 @@ const TYPE_PARAMS = {
|
|||
text: 'Instance',
|
||||
name: 'instance',
|
||||
type: 'text',
|
||||
placeholder: 'e.g. mastodon.social',
|
||||
placeholder: 'Optional, e.g. mastodon.social',
|
||||
notRequired: true,
|
||||
},
|
||||
],
|
||||
search: [
|
||||
|
@ -94,6 +96,13 @@ const TYPE_PARAMS = {
|
|||
placeholder: 'e.g. PixelArt (Max 5, space-separated)',
|
||||
pattern: '[^#]+',
|
||||
},
|
||||
{
|
||||
text: 'Instance',
|
||||
name: 'instance',
|
||||
type: 'text',
|
||||
placeholder: 'Optional, e.g. mastodon.social',
|
||||
notRequired: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
export const SHORTCUTS_META = {
|
||||
|
@ -131,14 +140,15 @@ export const SHORTCUTS_META = {
|
|||
},
|
||||
public: {
|
||||
id: 'public',
|
||||
title: ({ local, instance }) =>
|
||||
`${local ? 'Local' : 'Federated'} (${instance})`,
|
||||
title: ({ local }) => (local ? 'Local' : 'Federated'),
|
||||
subtitle: ({ instance }) => instance || api().instance,
|
||||
path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`,
|
||||
icon: ({ local }) => (local ? 'group' : 'earth'),
|
||||
},
|
||||
trending: {
|
||||
id: 'trending',
|
||||
title: 'Trending',
|
||||
subtitle: ({ instance }) => instance || api().instance,
|
||||
path: ({ instance }) => `/${instance}/trending`,
|
||||
icon: 'chart',
|
||||
},
|
||||
|
@ -177,12 +187,14 @@ export const SHORTCUTS_META = {
|
|||
hashtag: {
|
||||
id: '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',
|
||||
},
|
||||
};
|
||||
|
||||
function ShortcutsSettings() {
|
||||
function ShortcutsSettings({ onClose }) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const { masto } = api();
|
||||
const { shortcuts } = snapStates;
|
||||
|
@ -219,6 +231,11 @@ function ShortcutsSettings() {
|
|||
|
||||
return (
|
||||
<div id="shortcuts-settings-container" class="sheet" tabindex="-1">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>
|
||||
<Icon icon="shortcut" /> Shortcuts{' '}
|
||||
|
@ -303,14 +320,17 @@ function ShortcutsSettings() {
|
|||
</p> */}
|
||||
{shortcuts.length > 0 ? (
|
||||
<ol class="shortcuts-list">
|
||||
{shortcuts.map((shortcut, i) => {
|
||||
{shortcuts.filter(Boolean).map((shortcut, i) => {
|
||||
const key = i + Object.values(shortcut);
|
||||
const { type } = shortcut;
|
||||
if (!SHORTCUTS_META[type]) return null;
|
||||
let { icon, title } = SHORTCUTS_META[type];
|
||||
let { icon, title, subtitle } = SHORTCUTS_META[type];
|
||||
if (typeof title === 'function') {
|
||||
title = title(shortcut, i);
|
||||
}
|
||||
if (typeof subtitle === 'function') {
|
||||
subtitle = subtitle(shortcut, i);
|
||||
}
|
||||
if (typeof icon === 'function') {
|
||||
icon = icon(shortcut, i);
|
||||
}
|
||||
|
@ -319,6 +339,12 @@ function ShortcutsSettings() {
|
|||
<Icon icon={icon} />
|
||||
<span class="shortcut-text">
|
||||
<AsyncText>{title}</AsyncText>
|
||||
{subtitle && (
|
||||
<>
|
||||
{' '}
|
||||
<small class="ib insignificant">{subtitle}</small>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span class="shortcut-actions">
|
||||
<button
|
||||
|
@ -354,6 +380,18 @@ function ShortcutsSettings() {
|
|||
<Icon icon="arrow-down" alt="Move down" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="plain small"
|
||||
onClick={() => {
|
||||
setShowForm({
|
||||
shortcut,
|
||||
shortcutIndex: i,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" alt="Edit" />
|
||||
</button>
|
||||
{/* <button
|
||||
type="button"
|
||||
class="plain small"
|
||||
onClick={() => {
|
||||
|
@ -361,16 +399,38 @@ function ShortcutsSettings() {
|
|||
}}
|
||||
>
|
||||
<Icon icon="x" alt="Remove" />
|
||||
</button>
|
||||
</button> */}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<p class="ui-state insignificant">
|
||||
No shortcuts yet. Add one from the form below.
|
||||
<div class="ui-state insignificant">
|
||||
<p>No shortcuts yet. Tap on the Add shortcut button.</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
|
||||
style={{
|
||||
|
@ -402,12 +462,17 @@ function ShortcutsSettings() {
|
|||
}}
|
||||
>
|
||||
<ShortcutForm
|
||||
disabled={shortcuts.length >= SHORTCUTS_LIMIT}
|
||||
shortcut={showForm.shortcut}
|
||||
shortcutIndex={showForm.shortcutIndex}
|
||||
lists={lists}
|
||||
followedHashtags={followedHashtags}
|
||||
onSubmit={(data) => {
|
||||
console.log('onSubmit', data);
|
||||
states.shortcuts.push(data);
|
||||
onSubmit={({ result, mode }) => {
|
||||
console.log('onSubmit', result);
|
||||
if (mode === 'edit') {
|
||||
states.shortcuts[showForm.shortcutIndex] = result;
|
||||
} else {
|
||||
states.shortcuts.push(result);
|
||||
}
|
||||
}}
|
||||
onClose={() => setShowForm(false)}
|
||||
/>
|
||||
|
@ -418,21 +483,49 @@ function ShortcutsSettings() {
|
|||
}
|
||||
|
||||
function ShortcutForm({
|
||||
type,
|
||||
lists,
|
||||
followedHashtags,
|
||||
onSubmit,
|
||||
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 (
|
||||
<div id="shortcut-settings-form" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>Add shortcut</h2>
|
||||
<h2>{editMode ? 'Edit' : 'Add'} shortcut</h2>
|
||||
</header>
|
||||
<main tabindex="-1">
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={(e) => {
|
||||
// Construct a nice object from form
|
||||
e.preventDefault();
|
||||
|
@ -440,13 +533,25 @@ function ShortcutForm({
|
|||
const result = {};
|
||||
data.forEach((value, key) => {
|
||||
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;
|
||||
onSubmit(result);
|
||||
onSubmit({
|
||||
result,
|
||||
mode: editMode ? 'edit' : 'add',
|
||||
});
|
||||
// Reset
|
||||
e.target.reset();
|
||||
setCurrentType(null);
|
||||
onClose();
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
|
@ -458,6 +563,7 @@ function ShortcutForm({
|
|||
onChange={(e) => {
|
||||
setCurrentType(e.target.value);
|
||||
}}
|
||||
defaultValue={editMode ? shortcut.type : undefined}
|
||||
name="type"
|
||||
>
|
||||
<option></option>
|
||||
|
@ -468,13 +574,17 @@ function ShortcutForm({
|
|||
</label>
|
||||
</p>
|
||||
{TYPE_PARAMS[currentType]?.map?.(
|
||||
({ text, name, type, placeholder, pattern }) => {
|
||||
({ text, name, type, placeholder, pattern, notRequired }) => {
|
||||
if (currentType === 'list') {
|
||||
return (
|
||||
<p>
|
||||
<label>
|
||||
<span>List</span>
|
||||
<select name="id" required disabled={disabled}>
|
||||
<select
|
||||
name="id"
|
||||
required={!notRequired}
|
||||
disabled={disabled}
|
||||
>
|
||||
{lists.map((list) => (
|
||||
<option value={list.id}>{list.title}</option>
|
||||
))}
|
||||
|
@ -492,7 +602,7 @@ function ShortcutForm({
|
|||
type={type}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
required={type === 'text'}
|
||||
required={type === 'text' && !notRequired}
|
||||
disabled={disabled}
|
||||
list={
|
||||
currentType === 'hashtag'
|
||||
|
@ -517,9 +627,23 @@ function ShortcutForm({
|
|||
);
|
||||
},
|
||||
)}
|
||||
<footer>
|
||||
<button type="submit" class="block" disabled={disabled}>
|
||||
Add
|
||||
{editMode ? 'Save' : 'Add'}
|
||||
</button>
|
||||
{editMode && (
|
||||
<button
|
||||
type="button"
|
||||
class="light danger"
|
||||
onClick={() => {
|
||||
states.shortcuts.splice(shortcutIndex, 1);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
@ -134,6 +134,14 @@ shortcuts .tab-bar[hidden] {
|
|||
#app[data-shortcuts-view-mode='tab-menu-bar'] .deck-container {
|
||||
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) {
|
||||
|
@ -172,6 +180,10 @@ shortcuts .tab-bar[hidden] {
|
|||
height: 44px;
|
||||
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(
|
||||
header[hidden]
|
||||
)
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import { SHORTCUTS_META } from '../components/shortcuts-settings';
|
||||
import { api } from '../utils/api';
|
||||
import states from '../utils/states';
|
||||
|
||||
import AsyncText from './AsyncText';
|
||||
|
@ -15,6 +16,7 @@ import Link from './link';
|
|||
import MenuLink from './menu-link';
|
||||
|
||||
function Shortcuts() {
|
||||
const { instance } = api();
|
||||
const snapStates = useSnapshot(states);
|
||||
const { shortcuts } = snapStates;
|
||||
|
||||
|
@ -30,17 +32,26 @@ function Shortcuts() {
|
|||
.map((pin, i) => {
|
||||
const { type, ...data } = pin;
|
||||
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') {
|
||||
id = id(data, i);
|
||||
}
|
||||
if (typeof path === 'function') {
|
||||
path = path(data, i);
|
||||
path = path(
|
||||
{
|
||||
...data,
|
||||
instance: data.instance || instance,
|
||||
},
|
||||
i,
|
||||
);
|
||||
}
|
||||
if (typeof title === 'function') {
|
||||
title = title(data, i);
|
||||
}
|
||||
if (typeof subtitle === 'function') {
|
||||
subtitle = subtitle(data, i);
|
||||
}
|
||||
if (typeof icon === 'function') {
|
||||
icon = icon(data, i);
|
||||
}
|
||||
|
@ -49,6 +60,7 @@ function Shortcuts() {
|
|||
id,
|
||||
path,
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
};
|
||||
})
|
||||
|
@ -73,10 +85,12 @@ function Shortcuts() {
|
|||
{snapStates.settings.shortcutsViewMode === 'tab-menu-bar' ? (
|
||||
<nav class="tab-bar">
|
||||
<ul>
|
||||
{formattedShortcuts.map(({ id, path, title, icon }, i) => {
|
||||
{formattedShortcuts.map(
|
||||
({ id, path, title, subtitle, icon }, i) => {
|
||||
return (
|
||||
<li key={i + title}>
|
||||
<Link
|
||||
class={subtitle ? 'has-subtitle' : ''}
|
||||
to={path}
|
||||
onClick={(e) => {
|
||||
if (e.target.classList.contains('is-active')) {
|
||||
|
@ -97,11 +111,18 @@ function Shortcuts() {
|
|||
<Icon icon={icon} size="xl" alt={title} />
|
||||
<span>
|
||||
<AsyncText>{title}</AsyncText>
|
||||
{subtitle && (
|
||||
<>
|
||||
<br />
|
||||
<small>{subtitle}</small>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
},
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
) : (
|
||||
|
@ -132,13 +153,21 @@ function Shortcuts() {
|
|||
</button>
|
||||
}
|
||||
>
|
||||
{formattedShortcuts.map(({ path, title, icon }, i) => {
|
||||
{formattedShortcuts.map(({ path, title, subtitle, icon }, i) => {
|
||||
return (
|
||||
<MenuLink to={path} key={i + title} class="glass-menu-item">
|
||||
<Icon icon={icon} size="l" />{' '}
|
||||
<span class="menu-grow">
|
||||
<span>
|
||||
<AsyncText>{title}</AsyncText>
|
||||
</span>
|
||||
{subtitle && (
|
||||
<>
|
||||
{' '}
|
||||
<small class="more-insignificant">{subtitle}</small>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span class="menu-shortcut hide-until-focus-visible">
|
||||
{i + 1}
|
||||
</span>
|
||||
|
|
|
@ -46,32 +46,21 @@
|
|||
overflow: hidden;
|
||||
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 {
|
||||
color: var(--reblog-color);
|
||||
margin-right: 4px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
/* STATUS */
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
padding: 16px 16px 20px;
|
||||
padding: 16px;
|
||||
line-height: 1.4;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
@media (min-width: 40em) {
|
||||
.status {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
font-size: var(--text-size);
|
||||
}
|
||||
.status.large {
|
||||
--fade-in-out-bg: linear-gradient(
|
||||
|
@ -88,6 +77,59 @@
|
|||
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 {
|
||||
0% {
|
||||
opacity: 1;
|
||||
|
@ -182,7 +224,7 @@
|
|||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.status:not(.small) .container {
|
||||
.status:not(.small) > .container {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
|
@ -337,7 +379,7 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.status.large .content-container {
|
||||
.status.large > .container > .content-container {
|
||||
margin-left: calc(-50px - 16px);
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
|
@ -392,8 +434,21 @@
|
|||
filter: none;
|
||||
image-rendering: auto;
|
||||
}
|
||||
.status .content a:not(.mention):not(:has(span)) {
|
||||
/* .status .content a:not(.mention):not(:has(span)) {
|
||||
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 {
|
||||
|
@ -459,7 +514,7 @@
|
|||
.status .content .ellipsis::after {
|
||||
content: '…';
|
||||
}
|
||||
.status.large .content {
|
||||
.status.large .content:not(.content .content) {
|
||||
font-size: 150%;
|
||||
font-size: min(calc(100% + 50% / var(--content-text-weight)), 150%);
|
||||
}
|
||||
|
@ -477,15 +532,25 @@
|
|||
grid-template-columns: 1fr 1fr;
|
||||
grid-auto-rows: 1fr;
|
||||
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;
|
||||
height: auto;
|
||||
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 {
|
||||
height: 200px;
|
||||
/* height: 200px; */
|
||||
max-height: max(200px, 40vh);
|
||||
}
|
||||
.status.large :is(.media-container, .media-container.media-gt2) {
|
||||
height: auto;
|
||||
|
@ -675,6 +740,26 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
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 {
|
||||
position: relative;
|
||||
}
|
||||
|
@ -830,6 +915,10 @@ a:focus-visible .card img {
|
|||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
}
|
||||
.card.no-image :is(.title, .meta) {
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
}
|
||||
.card .meta.domain {
|
||||
opacity: 1;
|
||||
color: var(--link-color);
|
||||
|
@ -858,6 +947,7 @@ a.card:is(:hover, :focus) {
|
|||
/* POLLS */
|
||||
|
||||
.poll {
|
||||
display: inline-block;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
margin-top: 8px;
|
||||
border-radius: 16px;
|
||||
|
@ -869,6 +959,7 @@ a.card:is(:hover, :focus) {
|
|||
var(--bg-faded-color)
|
||||
);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 0 0 1px var(--bg-color);
|
||||
}
|
||||
.poll.loading {
|
||||
opacity: 0.5;
|
||||
|
@ -1012,11 +1103,11 @@ a.card:is(:hover, :focus) {
|
|||
border: 1.5px solid transparent;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.status .action > button.plain:is(:hover, :focus) {
|
||||
.status .action > button.plain:not(:disabled):is(:hover, :focus) {
|
||||
color: var(--link-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);
|
||||
}
|
||||
.status .action > button.plain.reblog-button.checked {
|
||||
|
@ -1135,6 +1226,28 @@ a.card:is(:hover, :focus) {
|
|||
.status-badge .pin {
|
||||
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 */
|
||||
|
||||
|
@ -1172,6 +1285,7 @@ a.card:is(:hover, :focus) {
|
|||
#edit-history .history-item .status {
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* DELETED */
|
||||
|
|
|
@ -15,7 +15,6 @@ import pThrottle from 'p-throttle';
|
|||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import 'swiped-events';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import useResizeObserver from 'use-resize-observer';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -24,12 +23,15 @@ import AccountBlock from '../components/account-block';
|
|||
import Loader from '../components/loader';
|
||||
import Modal from '../components/modal';
|
||||
import NameText from '../components/name-text';
|
||||
import Poll from '../components/poll';
|
||||
import { api } from '../utils/api';
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import enhanceContent from '../utils/enhance-content';
|
||||
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
|
||||
import getHTMLText from '../utils/getHTMLText';
|
||||
import handleContentLinks from '../utils/handle-content-links';
|
||||
import htmlContentLength from '../utils/html-content-length';
|
||||
import isMastodonLinkMaybe from '../utils/isMastodonLinkMaybe';
|
||||
import niceDateTime from '../utils/nice-date-time';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import showToast from '../utils/show-toast';
|
||||
|
@ -79,6 +81,8 @@ function Status({
|
|||
enableTranslate,
|
||||
previewMode,
|
||||
allowFilters,
|
||||
onMediaClick,
|
||||
quoted,
|
||||
}) {
|
||||
if (skeleton) {
|
||||
return (
|
||||
|
@ -118,6 +122,7 @@ function Status({
|
|||
displayName,
|
||||
username,
|
||||
emojis: accountEmojis,
|
||||
bot,
|
||||
},
|
||||
id,
|
||||
repliesCount,
|
||||
|
@ -151,7 +156,7 @@ function Status({
|
|||
_filtered,
|
||||
} = status;
|
||||
|
||||
console.debug('RENDER Status', id, status?.account.displayName);
|
||||
console.debug('RENDER Status', id, status?.account.displayName, quoted);
|
||||
|
||||
const debugHover = (e) => {
|
||||
if (e.shiftKey) {
|
||||
|
@ -175,10 +180,12 @@ function Status({
|
|||
const createdAtDate = new Date(createdAt);
|
||||
const editedAtDate = new Date(editedAt);
|
||||
|
||||
const currentAccount = useMemo(() => {
|
||||
return store.session.get('currentAccount');
|
||||
}, []);
|
||||
const isSelf = useMemo(() => {
|
||||
const currentAccount = store.session.get('currentAccount');
|
||||
return currentAccount && currentAccount === accountId;
|
||||
}, [accountId]);
|
||||
}, [accountId, currentAccount]);
|
||||
|
||||
let inReplyToAccountRef = mentions?.find(
|
||||
(mention) => mention.id === inReplyToAccountId,
|
||||
|
@ -200,6 +207,9 @@ function Status({
|
|||
.catch((e) => {});
|
||||
}
|
||||
}
|
||||
const mentionSelf =
|
||||
inReplyToAccountId === currentAccount ||
|
||||
mentions?.find((mention) => mention.id === currentAccount);
|
||||
|
||||
const showSpoiler = !!snapStates.spoilers[id] || false;
|
||||
|
||||
|
@ -457,12 +467,9 @@ function Status({
|
|||
)}
|
||||
{!isSizeLarge && sameInstance && (
|
||||
<>
|
||||
<MenuItem onClick={replyStatus}>
|
||||
<Icon icon="reply" />
|
||||
<span>Reply</span>
|
||||
</MenuItem>
|
||||
{canBoost && (
|
||||
<div class="menu-horizontal">
|
||||
<MenuItem
|
||||
disabled={!canBoost}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const done = await boostStatus();
|
||||
|
@ -480,7 +487,6 @@ function Status({
|
|||
/>
|
||||
<span>{reblogged ? 'Unboost' : 'Boost…'}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
try {
|
||||
|
@ -498,6 +504,12 @@ function Status({
|
|||
/>
|
||||
<span>{favourited ? 'Unfavourite' : 'Favourite'}</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
<div class="menu-horizontal">
|
||||
<MenuItem onClick={replyStatus}>
|
||||
<Icon icon="reply" />
|
||||
<span>Reply</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
try {
|
||||
|
@ -510,11 +522,12 @@ function Status({
|
|||
<Icon
|
||||
icon="bookmark"
|
||||
style={{
|
||||
color: bookmarked && 'var(--favourite-color)',
|
||||
color: bookmarked && 'var(--link-color)',
|
||||
}}
|
||||
/>
|
||||
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{enableTranslate && (
|
||||
|
@ -570,9 +583,41 @@ function Status({
|
|||
</MenuItem>
|
||||
)}
|
||||
</div>
|
||||
{isSelf && (
|
||||
{(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 ? (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<Icon icon="unmute" />
|
||||
<span>Unmute conversation</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="mute" />
|
||||
<span>Mute conversation</span>
|
||||
</>
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isSelf && (
|
||||
<div class="menu-horizontal">
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showCompose = {
|
||||
|
@ -606,7 +651,7 @@ function Status({
|
|||
<span>Delete…</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -627,6 +672,7 @@ function Status({
|
|||
setIsContextMenuOpen(true);
|
||||
},
|
||||
{
|
||||
threshold: 600,
|
||||
captureEvent: true,
|
||||
detect: 'touch',
|
||||
cancelOnMovement: true,
|
||||
|
@ -645,7 +691,7 @@ function Status({
|
|||
m: 'medium',
|
||||
l: 'large',
|
||||
}[size]
|
||||
} ${_deleted ? 'status-deleted' : ''}`}
|
||||
} ${_deleted ? 'status-deleted' : ''} ${quoted ? 'status-card' : ''}`}
|
||||
onMouseEnter={debugHover}
|
||||
onContextMenu={(e) => {
|
||||
if (size === 'l') return;
|
||||
|
@ -670,7 +716,11 @@ function Status({
|
|||
state={isContextMenuOpen ? 'open' : undefined}
|
||||
anchorPoint={contextMenuAnchorPoint}
|
||||
direction="right"
|
||||
onClose={() => setIsContextMenuOpen(false)}
|
||||
onClose={() => {
|
||||
setIsContextMenuOpen(false);
|
||||
// statusRef.current?.focus?.();
|
||||
statusRef.current?.closest('[tabindex]')?.focus?.();
|
||||
}}
|
||||
portal={{
|
||||
target: document.body,
|
||||
}}
|
||||
|
@ -713,7 +763,7 @@ function Status({
|
|||
};
|
||||
}}
|
||||
>
|
||||
<Avatar url={avatarStatic || avatar} size="xxl" />
|
||||
<Avatar url={avatarStatic || avatar} size="xxl" squircle={bot} />
|
||||
</a>
|
||||
)}
|
||||
<div class="container">
|
||||
|
@ -841,10 +891,15 @@ function Status({
|
|||
<div
|
||||
class="content"
|
||||
lang={language}
|
||||
dir="auto"
|
||||
ref={spoilerContentRef}
|
||||
data-read-more={readMoreText}
|
||||
>
|
||||
<p>{spoilerText}</p>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: emojifyText(spoilerText, emojis),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class={`light spoiler ${showSpoiler ? 'spoiling' : ''}`}
|
||||
|
@ -867,8 +922,11 @@ function Status({
|
|||
<div
|
||||
class="content"
|
||||
lang={language}
|
||||
dir="auto"
|
||||
ref={contentRef}
|
||||
data-read-more={readMoreText}
|
||||
>
|
||||
<div
|
||||
onClick={handleContentLinks({ mentions, instance, previewMode })}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: enhanceContent(content, {
|
||||
|
@ -884,21 +942,32 @@ function Status({
|
|||
});
|
||||
if (previewMode) return;
|
||||
// Unfurl Mastodon links
|
||||
dom
|
||||
.querySelectorAll(
|
||||
Array.from(
|
||||
dom.querySelectorAll(
|
||||
'a[href]:not(.u-url):not(.mention):not(.hashtag)',
|
||||
),
|
||||
)
|
||||
.forEach((a) => {
|
||||
if (isMastodonLinkMaybe(a.href)) {
|
||||
unfurlMastodonLink(currentInstance, a.href).then(() => {
|
||||
.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
|
||||
lang={language}
|
||||
|
@ -981,16 +1050,16 @@ function Status({
|
|||
key={media.id}
|
||||
media={media}
|
||||
autoAnimate={isSizeLarge}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
states.showMediaModal = {
|
||||
mediaAttachments,
|
||||
index: i,
|
||||
instance,
|
||||
statusID: readOnly ? null : id,
|
||||
};
|
||||
}}
|
||||
to={`/${instance}/s/${id}?${
|
||||
withinContext ? 'media' : 'media-only'
|
||||
}=${i + 1}`}
|
||||
onClick={
|
||||
onMediaClick
|
||||
? (e) => {
|
||||
onMediaClick(e, i, media, status);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -999,7 +1068,8 @@ function Status({
|
|||
!sensitive &&
|
||||
!spoilerText &&
|
||||
!poll &&
|
||||
!mediaAttachments.length && (
|
||||
!mediaAttachments.length &&
|
||||
!snapStates.statusQuotes[sKey] && (
|
||||
<Card card={card} instance={currentInstance} />
|
||||
)}
|
||||
</div>
|
||||
|
@ -1143,7 +1213,11 @@ function Status({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ReactionsModal statusID={id} instance={instance} />
|
||||
<ReactionsModal
|
||||
statusID={id}
|
||||
instance={instance}
|
||||
onClose={() => setShowReactions(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</article>
|
||||
|
@ -1151,6 +1225,7 @@ function Status({
|
|||
}
|
||||
|
||||
function Card({ card, instance }) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const {
|
||||
blurhash,
|
||||
title,
|
||||
|
@ -1203,6 +1278,8 @@ function Card({ card, instance }) {
|
|||
// );
|
||||
// }
|
||||
|
||||
if (snapStates.unfurledLinks[url]) return null;
|
||||
|
||||
if (hasText && (image || (!type !== 'photo' && blurhash))) {
|
||||
const domain = new URL(url).hostname.replace(/^www\./, '');
|
||||
let blurhashImage;
|
||||
|
@ -1285,216 +1362,30 @@ function Card({ card, instance }) {
|
|||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Poll({
|
||||
poll,
|
||||
lang,
|
||||
readOnly,
|
||||
refresh = () => {},
|
||||
votePoll = () => {},
|
||||
}) {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
||||
const {
|
||||
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);
|
||||
|
||||
} else if (hasText && !image) {
|
||||
const domain = new URL(url).hostname.replace(/^www\./, '');
|
||||
return (
|
||||
<div
|
||||
lang={lang}
|
||||
class={`poll ${readOnly ? 'read-only' : ''} ${
|
||||
uiState === 'loading' ? 'loading' : ''
|
||||
}`}
|
||||
onDblClick={() => {
|
||||
setShowResults(!showResults);
|
||||
}}
|
||||
<a
|
||||
href={cardStatusURL || url}
|
||||
target={cardStatusURL ? null : '_blank'}
|
||||
rel="nofollow noopener noreferrer"
|
||||
class={`card link no-image`}
|
||||
>
|
||||
{(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 class="meta-container">
|
||||
<p class="meta domain">{domain}</p>
|
||||
<p class="title">{title}</p>
|
||||
<p class="meta">{description || providerName || authorName}</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</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">{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({
|
||||
statusID,
|
||||
instance,
|
||||
fetchStatusHistory = () => {},
|
||||
onClose = () => {},
|
||||
onClose,
|
||||
}) {
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [editHistory, setEditHistory] = useState([]);
|
||||
|
@ -1516,10 +1407,12 @@ function EditedAtModal({
|
|||
|
||||
return (
|
||||
<div id="edit-history" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
{/* <button type="button" class="close-button plain large" onClick={onClose}>
|
||||
<Icon icon="x" alt="Close" />
|
||||
</button> */}
|
||||
<h2>Edit History</h2>
|
||||
{uiState === 'error' && <p>Failed to load history</p>}
|
||||
{uiState === 'loading' && (
|
||||
|
@ -1565,7 +1458,7 @@ function EditedAtModal({
|
|||
}
|
||||
|
||||
const REACTIONS_LIMIT = 80;
|
||||
function ReactionsModal({ statusID, instance }) {
|
||||
function ReactionsModal({ statusID, instance, onClose }) {
|
||||
const { masto } = api({ instance });
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
|
@ -1641,6 +1534,11 @@ function ReactionsModal({ statusID, instance }) {
|
|||
|
||||
return (
|
||||
<div id="reactions-container" class="sheet">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>Boosted/Favourited by…</h2>
|
||||
</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 failedUnfurls = {};
|
||||
|
||||
|
@ -1817,10 +1711,14 @@ function _unfurlMastodonLink(instance, url) {
|
|||
const statusURL = `/${domain}/s/${id}`;
|
||||
const result = {
|
||||
id,
|
||||
instance: domain,
|
||||
url: statusURL,
|
||||
};
|
||||
console.debug('🦦 Unfurled URL', url, id, statusURL);
|
||||
states.unfurledLinks[url] = result;
|
||||
saveStatus(status, domain, {
|
||||
skipThreading: true,
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
failedUnfurls[url] = true;
|
||||
|
@ -1847,10 +1745,14 @@ function _unfurlMastodonLink(instance, url) {
|
|||
const statusURL = `/${instance}/s/${id}`;
|
||||
const result = {
|
||||
id,
|
||||
instance,
|
||||
url: statusURL,
|
||||
};
|
||||
console.debug('🦦 Unfurled URL', url, id, statusURL);
|
||||
states.unfurledLinks[url] = result;
|
||||
saveStatus(status, instance, {
|
||||
skipThreading: true,
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
failedUnfurls[url] = true;
|
||||
|
@ -1863,7 +1765,11 @@ function _unfurlMastodonLink(instance, url) {
|
|||
// Silently fail
|
||||
});
|
||||
|
||||
if (remoteInstanceFetch) {
|
||||
return Promise.any([remoteInstanceFetch, mastoSearchFetch]);
|
||||
} else {
|
||||
return mastoSearchFetch;
|
||||
}
|
||||
}
|
||||
|
||||
function nicePostURL(url) {
|
||||
|
@ -1914,7 +1820,7 @@ function safeBoundingBoxPadding() {
|
|||
|
||||
function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
|
||||
const {
|
||||
account: { avatar, avatarStatic },
|
||||
account: { avatar, avatarStatic, bot },
|
||||
createdAt,
|
||||
visibility,
|
||||
reblog,
|
||||
|
@ -1930,6 +1836,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
|
|||
setShowPeek(true);
|
||||
},
|
||||
{
|
||||
threshold: 600,
|
||||
captureEvent: true,
|
||||
detect: 'touch',
|
||||
cancelOnMovement: true,
|
||||
|
@ -1959,7 +1866,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
|
|||
<span>Filtered</span>
|
||||
<span>{filterTitleStr}</span>
|
||||
</b>{' '}
|
||||
<Avatar url={avatarStatic || avatar} />
|
||||
<Avatar url={avatarStatic || avatar} squircle={bot} />
|
||||
<span class="status-filtered-info">
|
||||
<span class="status-filtered-info-1">
|
||||
<NameText account={status.account} instance={instance} />{' '}
|
||||
|
@ -1979,6 +1886,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
|
|||
<>
|
||||
<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">
|
||||
<main tabIndex="-1">
|
||||
<p class="heading">
|
||||
<button
|
||||
type="button"
|
||||
class="sheet-close"
|
||||
onClick={() => setShowPeek(false)}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
<header>
|
||||
<b class="status-filtered-badge">Filtered</b> {filterTitleStr}
|
||||
</p>
|
||||
</header>
|
||||
<main tabIndex="-1">
|
||||
<Link
|
||||
class="status-link"
|
||||
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);
|
||||
|
|
|
@ -42,6 +42,8 @@ function Timeline({
|
|||
const [visible, setVisible] = useState(true);
|
||||
const scrollableRef = useRef();
|
||||
|
||||
console.debug('RENDER Timeline', id, refresh);
|
||||
|
||||
const loadItems = useDebouncedCallback(
|
||||
(firstLoad) => {
|
||||
setShowNew(false);
|
||||
|
@ -269,6 +271,7 @@ function Timeline({
|
|||
loadItems(true);
|
||||
}
|
||||
}}
|
||||
class={uiState === 'loading' ? 'loading' : ''}
|
||||
>
|
||||
<div class="header-grid">
|
||||
<div class="header-side">
|
||||
|
@ -283,7 +286,7 @@ function Timeline({
|
|||
</div>
|
||||
{title && (titleComponent ? titleComponent : <h1>{title}</h1>)}
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
{/* <Loader hidden={uiState !== 'loading'} /> */}
|
||||
{!!headerEnd && headerEnd}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -383,6 +386,10 @@ function Timeline({
|
|||
? `/${instance}/s/${statusID}`
|
||||
: `/s/${statusID}`;
|
||||
const isMiddle = i > 0 && i < items.length - 1;
|
||||
const isSpoiler = item.sensitive && !!item.spoilerText;
|
||||
const showCompact =
|
||||
(isSpoiler && i > 0) ||
|
||||
(manyItems && isMiddle && type === 'thread');
|
||||
return (
|
||||
<li
|
||||
key={`timeline-${statusID}`}
|
||||
|
@ -395,7 +402,7 @@ function Timeline({
|
|||
}`}
|
||||
>
|
||||
<Link class="status-link timeline-item" to={url}>
|
||||
{manyItems && isMiddle && type === 'thread' ? (
|
||||
{showCompact ? (
|
||||
<TimelineStatusCompact
|
||||
status={item}
|
||||
instance={instance}
|
||||
|
@ -577,6 +584,14 @@ function TimelineStatusCompact({ status, instance }) {
|
|||
)}
|
||||
<div class="content-compact" title={statusPeekText}>
|
||||
{statusPeekText}
|
||||
{status.sensitive && status.spoilerText && (
|
||||
<>
|
||||
{' '}
|
||||
<span class="spoiler-badge">
|
||||
<Icon icon="eye-close" size="s" />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
|
|
@ -43,7 +43,7 @@ function TranslationBlock({
|
|||
return {
|
||||
provider: 'lingva',
|
||||
content: res.translation,
|
||||
detectedSourceLanguage: res.info.detectedSource,
|
||||
detectedSourceLanguage: res.info?.detectedSource,
|
||||
info: res.info,
|
||||
};
|
||||
});
|
||||
|
@ -139,7 +139,7 @@ function TranslationBlock({
|
|||
) : (
|
||||
!!translatedContent && (
|
||||
<>
|
||||
<output class="translated-content" lang={targetLang}>
|
||||
<output class="translated-content" lang={targetLang} dir="auto">
|
||||
{translatedContent}
|
||||
</output>
|
||||
{!!pronunciationContent && (
|
||||
|
|
|
@ -43,10 +43,15 @@
|
|||
--outline-hover-color: rgba(128, 128, 128, 0.7);
|
||||
--divider-color: rgba(0, 0, 0, 0.1);
|
||||
--backdrop-color: rgba(0, 0, 0, 0.05);
|
||||
--backdrop-solid-color: #ccc;
|
||||
--img-bg-color: rgba(128, 128, 128, 0.2);
|
||||
--loader-color: #1c1e2199;
|
||||
--comment-line-color: #e5e5e5;
|
||||
--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);
|
||||
}
|
||||
|
@ -78,9 +83,14 @@
|
|||
--divider-color: rgba(255, 255, 255, 0.1);
|
||||
--bg-blur-color: #24252699;
|
||||
--backdrop-color: rgba(0, 0, 0, 0.5);
|
||||
--backdrop-solid-color: #333;
|
||||
--loader-color: #f0f2f599;
|
||||
--comment-line-color: #565656;
|
||||
--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;
|
||||
}
|
||||
|
||||
[dir] {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
html {
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import './index.css';
|
||||
|
||||
import './cloak-mode.css';
|
||||
|
||||
import { render } from 'preact';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
|
||||
|
@ -35,3 +37,7 @@ setTimeout(() => {
|
|||
localStorage.removeItem('settings:boostsCarousel');
|
||||
} catch (e) {}
|
||||
}, 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 { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
@ -8,6 +9,7 @@ import Link from '../components/link';
|
|||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
import emojifyText from '../utils/emojify-text';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
import { saveStatus } from '../utils/states';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
@ -128,18 +130,33 @@ function AccountStatuses() {
|
|||
)}
|
||||
<Link
|
||||
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
|
||||
onClick={() => {
|
||||
if (excludeReplies) {
|
||||
showToast('Showing post with replies');
|
||||
}
|
||||
}}
|
||||
class={excludeReplies ? '' : 'is-active'}
|
||||
>
|
||||
+ Replies
|
||||
</Link>
|
||||
<Link
|
||||
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
|
||||
onClick={() => {
|
||||
if (!excludeBoosts) {
|
||||
showToast('Showing posts without boosts');
|
||||
}
|
||||
}}
|
||||
class={!excludeBoosts ? '' : 'is-active'}
|
||||
>
|
||||
- Boosts
|
||||
</Link>
|
||||
<Link
|
||||
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
|
||||
onClick={() => {
|
||||
if (!media) {
|
||||
showToast('Showing posts with media');
|
||||
}
|
||||
}}
|
||||
class={media ? 'is-active' : ''}
|
||||
>
|
||||
Media
|
||||
|
@ -151,6 +168,11 @@ function AccountStatuses() {
|
|||
? ''
|
||||
: `?tagged=${encodeURIComponent(tag.name)}`
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (tagged !== tag.name) {
|
||||
showToast(`Showing posts tagged with #${tag.name}`);
|
||||
}
|
||||
}}
|
||||
class={tagged === tag.name ? 'is-active' : ''}
|
||||
>
|
||||
<span>
|
||||
|
@ -191,6 +213,14 @@ function AccountStatuses() {
|
|||
}
|
||||
}, [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 (
|
||||
<Timeline
|
||||
key={id}
|
||||
|
@ -224,6 +254,49 @@ function AccountStatuses() {
|
|||
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||
timelineStart={TimelineStart}
|
||||
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 (
|
||||
<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">
|
||||
<h2>Accounts</h2>
|
||||
</header>
|
||||
|
|
|
@ -18,6 +18,8 @@ function Following({ title, path, id, ...props }) {
|
|||
const homeIterator = useRef();
|
||||
const latestItem = useRef();
|
||||
|
||||
console.debug('RENDER Following', title, id);
|
||||
|
||||
async function fetchHome(firstLoad) {
|
||||
if (firstLoad || !homeIterator.current) {
|
||||
homeIterator.current = masto.v1.timelines.listHome({ limit: LIMIT });
|
||||
|
|
|
@ -32,8 +32,10 @@ function Hashtags(props) {
|
|||
hashtags.sort();
|
||||
hashtag = hashtags[0];
|
||||
|
||||
const { masto, instance } = api({ instance: params.instance });
|
||||
const { authenticated } = api();
|
||||
const { masto, instance, authenticated } = api({
|
||||
instance: props?.instance || params.instance,
|
||||
});
|
||||
const { authenticated: currentAuthenticated } = api();
|
||||
const hashtagTitle = hashtags.map((t) => `#${t}`).join(' ');
|
||||
const title = instance ? `${hashtagTitle} on ${instance}` : hashtagTitle;
|
||||
useTitle(title, `/:instance?/t/:hashtag`);
|
||||
|
@ -99,7 +101,7 @@ function Hashtags(props) {
|
|||
|
||||
return (
|
||||
<Timeline
|
||||
key={hashtagTitle}
|
||||
key={instance + hashtagTitle}
|
||||
title={title}
|
||||
titleComponent={
|
||||
!!instance && (
|
||||
|
@ -232,6 +234,7 @@ function Hashtags(props) {
|
|||
{hashtags.map((t, i) => (
|
||||
<MenuItem
|
||||
key={t}
|
||||
disabled={hashtags.length === 1}
|
||||
onClick={(e) => {
|
||||
hashtags.splice(i, 1);
|
||||
hashtags.sort();
|
||||
|
@ -252,11 +255,12 @@ function Hashtags(props) {
|
|||
</MenuGroup>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
disabled={!authenticated}
|
||||
disabled={!currentAuthenticated}
|
||||
onClick={() => {
|
||||
const shortcut = {
|
||||
type: 'hashtag',
|
||||
hashtag: hashtags.join(' '),
|
||||
instance,
|
||||
};
|
||||
// Check if already exists
|
||||
const exists = states.shortcuts.some(
|
||||
|
@ -269,7 +273,8 @@ function Hashtags(props) {
|
|||
shortcut.hashtag
|
||||
.split(/[\s+]+/)
|
||||
.sort()
|
||||
.join(' '),
|
||||
.join(' ') &&
|
||||
(s.instance ? s.instance === shortcut.instance : true),
|
||||
);
|
||||
if (exists) {
|
||||
alert('This shortcut already exists');
|
||||
|
@ -281,6 +286,23 @@ function Hashtags(props) {
|
|||
>
|
||||
<Icon icon="shortcut" /> <span>Add to Shorcuts</span>
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { memo } from 'preact/compat';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
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 { InView } from 'react-intersection-observer';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import AccountBlock from '../components/account-block';
|
||||
import Icon from '../components/icon';
|
||||
|
@ -13,12 +14,13 @@ import Modal from '../components/modal';
|
|||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
import { filteredItems } from '../utils/filters';
|
||||
import { saveStatus } from '../utils/states';
|
||||
import states, { saveStatus } from '../utils/states';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
function List(props) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const { masto, instance } = api();
|
||||
const id = props?.id || useParams()?.id;
|
||||
const navigate = useNavigate();
|
||||
|
@ -93,7 +95,7 @@ function List(props) {
|
|||
fetchItems={fetchList}
|
||||
checkForUpdates={checkForUpdates}
|
||||
useItemID
|
||||
boostsCarousel
|
||||
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||
allowFilters
|
||||
// refresh={reloadCount}
|
||||
headerStart={
|
||||
|
@ -166,7 +168,10 @@ function List(props) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ListManageMembers listID={id} />
|
||||
<ListManageMembers
|
||||
listID={id}
|
||||
onClose={() => setShowManageMembersModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
|
@ -174,7 +179,7 @@ function List(props) {
|
|||
}
|
||||
|
||||
const MEMBERS_LIMIT = 40;
|
||||
function ListManageMembers({ listID }) {
|
||||
function ListManageMembers({ listID, onClose }) {
|
||||
// Show list of members with [Remove] button
|
||||
// API only returns 40 members at a time, so this need to be paginated with infinite scroll
|
||||
// Show [Add] button after removing a member
|
||||
|
@ -220,6 +225,11 @@ function ListManageMembers({ listID }) {
|
|||
|
||||
return (
|
||||
<div class="sheet" id="list-manage-members-container">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>Manage members</h2>
|
||||
</header>
|
||||
|
|
|
@ -22,5 +22,32 @@
|
|||
|
||||
#login input {
|
||||
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 cachedInstanceURL = store.local.get('instanceURL');
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const [instanceText, setInstanceText] = useState(
|
||||
cachedInstanceURL?.toLowerCase() || '',
|
||||
);
|
||||
|
||||
const [instancesList, setInstancesList] = useState([]);
|
||||
useEffect(() => {
|
||||
|
@ -29,20 +32,13 @@ function Login() {
|
|||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedInstanceURL) {
|
||||
instanceURLRef.current.value = cachedInstanceURL.toLowerCase();
|
||||
}
|
||||
}, []);
|
||||
// useEffect(() => {
|
||||
// if (cachedInstanceURL) {
|
||||
// instanceURLRef.current.value = cachedInstanceURL.toLowerCase();
|
||||
// }
|
||||
// }, []);
|
||||
|
||||
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(/^@?[^@]+@/, '');
|
||||
const submitInstance = (instanceURL) => {
|
||||
store.local.set('instanceURL', instanceURL);
|
||||
|
||||
(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 (
|
||||
<main id="login" style={{ textAlign: 'center' }}>
|
||||
<form onSubmit={onSubmit}>
|
||||
|
@ -78,34 +90,57 @@ function Login() {
|
|||
<label>
|
||||
<p>Instance</p>
|
||||
<input
|
||||
value={instanceText}
|
||||
required
|
||||
type="text"
|
||||
class="large"
|
||||
id="instanceURL"
|
||||
ref={instanceURLRef}
|
||||
disabled={uiState === 'loading'}
|
||||
list="instances-list"
|
||||
// list="instances-list"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
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) => (
|
||||
<option value={instance} />
|
||||
))}
|
||||
</datalist>
|
||||
</datalist> */}
|
||||
</label>
|
||||
{uiState === 'error' && (
|
||||
<p class="error">
|
||||
Failed to log in. Please try again or another instance.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<div>
|
||||
<button class="large" disabled={uiState === 'loading'}>
|
||||
Log in
|
||||
</button>{' '}
|
||||
</p>
|
||||
</div>
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
<hr />
|
||||
<p>
|
||||
|
|
|
@ -162,6 +162,7 @@ function Notifications() {
|
|||
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}}
|
||||
class={uiState === 'loading' ? 'loading' : ''}
|
||||
>
|
||||
<div class="header-grid">
|
||||
<div class="header-side">
|
||||
|
@ -172,7 +173,7 @@ function Notifications() {
|
|||
</div>
|
||||
<h1>Notifications</h1>
|
||||
<div class="header-side">
|
||||
<Loader hidden={uiState !== 'loading'} />
|
||||
{/* <Loader hidden={uiState !== 'loading'} /> */}
|
||||
</div>
|
||||
</div>
|
||||
{snapStates.notificationsShowNew && uiState !== 'loading' && (
|
||||
|
@ -403,6 +404,7 @@ function Notification({ notification, instance }) {
|
|||
}
|
||||
key={account.id}
|
||||
alt={`${account.displayName} @${account.acct}`}
|
||||
squircle={account?.bot}
|
||||
/>
|
||||
{type === 'favourite+reblog' && (
|
||||
<div class="account-sub-icons">
|
||||
|
|
|
@ -4,6 +4,7 @@ import { useRef } from 'preact/hooks';
|
|||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import logo from '../assets/logo.svg';
|
||||
import Icon from '../components/icon';
|
||||
import RelativeTime from '../components/relative-time';
|
||||
import targetLanguages from '../data/lingva-target-languages';
|
||||
import getTranslateTargetLanguage from '../utils/get-translate-target-language';
|
||||
|
@ -26,6 +27,11 @@ function Settings({ onClose }) {
|
|||
|
||||
return (
|
||||
<div id="settings-container" class="sheet" tabIndex="-1">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<h2>Settings</h2>
|
||||
</header>
|
||||
|
@ -253,6 +259,27 @@ function Settings({ onClose }) {
|
|||
</p>
|
||||
</div>
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -48,6 +48,7 @@
|
|||
white-space: pre-wrap;
|
||||
line-height: 1.2;
|
||||
max-width: var(--main-width);
|
||||
z-index: 1;
|
||||
}
|
||||
.post-status-banner > p:first-of-type {
|
||||
margin-top: 0;
|
||||
|
|
|
@ -3,10 +3,17 @@ import './status.css';
|
|||
import { Menu, MenuDivider, MenuHeader, MenuItem } from '@szhsin/react-menu';
|
||||
import debounce from 'just-debounce-it';
|
||||
import pRetry from 'p-retry';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { memo } from 'preact/compat';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'preact/hooks';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
import { matchPath, useNavigate, useParams } from 'react-router-dom';
|
||||
import { matchPath, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useSnapshot } from 'valtio';
|
||||
|
||||
|
@ -14,6 +21,7 @@ import Avatar from '../components/avatar';
|
|||
import Icon from '../components/icon';
|
||||
import Link from '../components/link';
|
||||
import Loader from '../components/loader';
|
||||
import MediaModal from '../components/media-modal';
|
||||
import NameText from '../components/name-text';
|
||||
import RelativeTime from '../components/relative-time';
|
||||
import Status from '../components/status';
|
||||
|
@ -21,6 +29,7 @@ import { api } from '../utils/api';
|
|||
import htmlContentLength from '../utils/html-content-length';
|
||||
import shortenNumber from '../utils/shorten-number';
|
||||
import states, {
|
||||
getStatus,
|
||||
saveStatus,
|
||||
statusKey,
|
||||
threadifyStatus,
|
||||
|
@ -30,6 +39,8 @@ import { getCurrentAccount } from '../utils/store-utils';
|
|||
import useScroll from '../utils/useScroll';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
import getInstanceStatusURL from './../utils/get-instance-status-url';
|
||||
|
||||
const LIMIT = 40;
|
||||
const THREAD_LIMIT = 20;
|
||||
|
||||
|
@ -40,16 +51,106 @@ function resetScrollPosition(id) {
|
|||
delete states.scrollPositions[id];
|
||||
}
|
||||
|
||||
function StatusPage() {
|
||||
const { id, ...params } = useParams();
|
||||
function StatusPage(params) {
|
||||
const { id } = params;
|
||||
const { masto, instance } = api({ instance: params.instance });
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const mediaParam = searchParams.get('media');
|
||||
const mediaOnlyParam = searchParams.get('media-only');
|
||||
const mediaIndex = parseInt(mediaParam || mediaOnlyParam, 10);
|
||||
let showMedia = mediaIndex > 0;
|
||||
const mediaStatusID = searchParams.get('mediaStatusID');
|
||||
const mediaStatus = getStatus(mediaStatusID, instance);
|
||||
if (mediaStatusID && !mediaStatus) {
|
||||
showMedia = false;
|
||||
}
|
||||
const showMediaOnly = showMedia && !!mediaOnlyParam;
|
||||
|
||||
const sKey = statusKey(id, instance);
|
||||
const [heroStatus, setHeroStatus] = useState(states.statuses[sKey]);
|
||||
|
||||
const closeLink = useMemo(() => {
|
||||
const { prevLocation } = states;
|
||||
const pathname =
|
||||
(prevLocation?.pathname || '') + (prevLocation?.search || '');
|
||||
const matchStatusPath =
|
||||
matchPath('/:instance/s/:id', pathname) || matchPath('/s/:id', pathname);
|
||||
if (!pathname || matchStatusPath) {
|
||||
return '/';
|
||||
}
|
||||
return pathname;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!heroStatus && showMedia) {
|
||||
(async () => {
|
||||
try {
|
||||
const status = await masto.v1.statuses.fetch(id);
|
||||
saveStatus(status, instance);
|
||||
setHeroStatus(status);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Unable to load status.');
|
||||
location.hash = closeLink;
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [showMedia]);
|
||||
|
||||
const mediaAttachments = mediaStatusID
|
||||
? mediaStatus?.mediaAttachments
|
||||
: heroStatus?.mediaAttachments;
|
||||
|
||||
return (
|
||||
<div class="deck-backdrop">
|
||||
{showMedia ? (
|
||||
mediaAttachments?.length ? (
|
||||
<MediaModal
|
||||
mediaAttachments={mediaAttachments}
|
||||
statusID={mediaStatusID || id}
|
||||
instance={instance}
|
||||
index={mediaIndex - 1}
|
||||
onClose={() => {
|
||||
if (showMediaOnly) {
|
||||
location.hash = closeLink;
|
||||
} else {
|
||||
searchParams.delete('media');
|
||||
searchParams.delete('mediaStatusID');
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div class="media-modal-container loading">
|
||||
<Loader abrupt />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Link to={closeLink} />
|
||||
)}
|
||||
{!showMediaOnly && (
|
||||
<StatusThread
|
||||
id={id}
|
||||
instance={params.instance}
|
||||
closeLink={closeLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const mediaParam = searchParams.get('media');
|
||||
const showMedia = parseInt(mediaParam, 10) > 0;
|
||||
const [viewMode, setViewMode] = useState(searchParams.get('view'));
|
||||
const { masto, instance } = api({ instance: propInstance });
|
||||
const {
|
||||
masto: currentMasto,
|
||||
instance: currentInstance,
|
||||
authenticated,
|
||||
} = api();
|
||||
const sameInstance = instance === currentInstance;
|
||||
const navigate = useNavigate();
|
||||
const snapStates = useSnapshot(states);
|
||||
const [statuses, setStatuses] = useState([]);
|
||||
const [uiState, setUIState] = useState('default');
|
||||
|
@ -69,7 +170,7 @@ function StatusPage() {
|
|||
states.scrollPositions[id] = scrollTop;
|
||||
}
|
||||
}, 50);
|
||||
scrollableRef.current.addEventListener('scroll', onScroll, {
|
||||
scrollableRef.current?.addEventListener('scroll', onScroll, {
|
||||
passive: true,
|
||||
});
|
||||
onScroll();
|
||||
|
@ -142,6 +243,9 @@ function StatusPage() {
|
|||
skipThreading: true,
|
||||
});
|
||||
});
|
||||
const ancestorsIsThread = ancestors.every(
|
||||
(s) => s.account.id === heroStatus.account.id,
|
||||
);
|
||||
const nestedDescendants = [];
|
||||
descendants.forEach((status) => {
|
||||
saveStatus(status, instance, {
|
||||
|
@ -153,6 +257,13 @@ function StatusPage() {
|
|||
} else if (status.inReplyToId === heroStatus.id) {
|
||||
// If replying to the hero status, it's a reply, level 1
|
||||
nestedDescendants.push(status);
|
||||
} else if (
|
||||
!status.inReplyToAccountId &&
|
||||
nestedDescendants.find((s) => s.id === status.inReplyToId) &&
|
||||
status.account.id === heroStatus.account.id
|
||||
) {
|
||||
// If replying to hero's own statuses, it's part of the thread, level 1
|
||||
nestedDescendants.push(status);
|
||||
} else {
|
||||
// If replying to someone else, it's a reply to a reply, level 2
|
||||
const parent = descendants.find((s) => s.id === status.inReplyToId);
|
||||
|
@ -184,7 +295,9 @@ function StatusPage() {
|
|||
...ancestors.map((s) => ({
|
||||
id: s.id,
|
||||
ancestor: true,
|
||||
isThread: ancestorsIsThread,
|
||||
accountID: s.account.id,
|
||||
repliesCount: s.repliesCount,
|
||||
})),
|
||||
{ id, accountID: heroStatus.account.id },
|
||||
...nestedDescendants.map((s) => ({
|
||||
|
@ -201,6 +314,13 @@ function StatusPage() {
|
|||
offsetTop: heroStatusRef.current?.offsetTop,
|
||||
scrollTop: scrollableRef.current?.scrollTop,
|
||||
};
|
||||
|
||||
// Set limit to hero's index
|
||||
const heroLimit = allStatuses.findIndex((s) => s.id === id);
|
||||
if (heroLimit >= limit) {
|
||||
setLimit(heroLimit + 1);
|
||||
}
|
||||
|
||||
console.log({ allStatuses });
|
||||
setStatuses(allStatuses);
|
||||
cachedStatusesMap[id] = allStatuses;
|
||||
|
@ -326,23 +446,6 @@ function StatusPage() {
|
|||
return postInstance === instance;
|
||||
}, [postInstance, instance]);
|
||||
|
||||
const closeLink = useMemo(() => {
|
||||
const { prevLocation } = snapStates;
|
||||
const pathname =
|
||||
(prevLocation?.pathname || '') + (prevLocation?.search || '');
|
||||
if (
|
||||
!pathname ||
|
||||
matchPath('/:instance/s/:id', pathname) ||
|
||||
matchPath('/s/:id', pathname)
|
||||
) {
|
||||
return '/';
|
||||
}
|
||||
return pathname;
|
||||
}, []);
|
||||
const onClose = () => {
|
||||
states.showMediaModal = false;
|
||||
};
|
||||
|
||||
const [limit, setLimit] = useState(LIMIT);
|
||||
const showMore = useMemo(() => {
|
||||
// return number of statuses to show
|
||||
|
@ -362,10 +465,20 @@ function StatusPage() {
|
|||
return top > 0 ? 'down' : 'up';
|
||||
}, [heroInView]);
|
||||
|
||||
useHotkeys(['esc', 'backspace'], () => {
|
||||
// location.hash = closeLink;
|
||||
onClose();
|
||||
navigate(closeLink);
|
||||
useHotkeys(
|
||||
'esc',
|
||||
() => {
|
||||
location.hash = closeLink;
|
||||
},
|
||||
{
|
||||
// If media is open, esc to close media first
|
||||
// Else close the status page
|
||||
enabled: !showMedia,
|
||||
},
|
||||
);
|
||||
// For backspace, will always close both media and status page
|
||||
useHotkeys('backspace', () => {
|
||||
location.hash = closeLink;
|
||||
});
|
||||
|
||||
useHotkeys('j', () => {
|
||||
|
@ -459,18 +572,31 @@ function StatusPage() {
|
|||
distanceFromStartPx: 16,
|
||||
});
|
||||
|
||||
const initialPageState = useRef(showMedia ? 'media+status' : 'status');
|
||||
|
||||
const handleMediaClick = useCallback((e, i, media, status) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSearchParams({
|
||||
media: i + 1,
|
||||
mediaStatusID: status.id,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div class="deck-backdrop">
|
||||
<Link to={closeLink} onClick={onClose}></Link>
|
||||
<div
|
||||
tabIndex="-1"
|
||||
ref={scrollableRef}
|
||||
class={`status-deck deck contained ${
|
||||
statuses.length > 1 ? 'padded-bottom' : ''
|
||||
} ${initialPageState.current === 'status' ? 'slide-in' : ''} ${
|
||||
viewMode ? `deck-view-${viewMode}` : ''
|
||||
}`}
|
||||
>
|
||||
<header
|
||||
class={`${heroInView ? 'inview' : ''}`}
|
||||
class={`${heroInView ? 'inview' : ''} ${
|
||||
uiState === 'loading' ? 'loading' : ''
|
||||
}`}
|
||||
onDblClick={(e) => {
|
||||
// reload statuses
|
||||
states.reloadStatusPage++;
|
||||
|
@ -544,9 +670,6 @@ function StatusPage() {
|
|||
)}
|
||||
</h1>
|
||||
<div class="header-side">
|
||||
{uiState === 'loading' ? (
|
||||
<Loader abrupt />
|
||||
) : (
|
||||
<Menu
|
||||
align="end"
|
||||
portal={{
|
||||
|
@ -568,6 +691,32 @@ function StatusPage() {
|
|||
<Icon icon="refresh" />
|
||||
<span>Refresh</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className="menu-switch-view"
|
||||
onClick={() => {
|
||||
setViewMode(viewMode === 'full' ? null : 'full');
|
||||
searchParams.delete('media');
|
||||
searchParams.delete('media-only');
|
||||
if (viewMode === 'full') {
|
||||
searchParams.delete('view');
|
||||
} else {
|
||||
searchParams.set('view', 'full');
|
||||
}
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
{
|
||||
'': 'layout5',
|
||||
full: 'layout4',
|
||||
}[viewMode || '']
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
Switch to {viewMode === 'full' ? 'Side Peek' : 'Full'} view
|
||||
</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// Click all buttons with class .spoiler but not .spoiling
|
||||
|
@ -581,8 +730,7 @@ function StatusPage() {
|
|||
});
|
||||
}}
|
||||
>
|
||||
<Icon icon="eye-open" />{' '}
|
||||
<span>Show all sensitive content</span>
|
||||
<Icon icon="eye-open" /> <span>Show all sensitive content</span>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuHeader className="plain">Experimental</MenuHeader>
|
||||
|
@ -591,7 +739,7 @@ function StatusPage() {
|
|||
onClick={() => {
|
||||
const statusURL = getInstanceStatusURL(heroStatus.url);
|
||||
if (statusURL) {
|
||||
navigate(statusURL);
|
||||
location.hash = statusURL;
|
||||
} else {
|
||||
alert('Unable to switch');
|
||||
}
|
||||
|
@ -603,12 +751,7 @@ function StatusPage() {
|
|||
</small>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)}
|
||||
<Link
|
||||
class="button plain deck-close"
|
||||
to={closeLink}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Link class="button plain deck-close" to={closeLink}>
|
||||
<Icon icon="x" size="xl" />
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -624,9 +767,11 @@ function StatusPage() {
|
|||
const {
|
||||
id: statusID,
|
||||
ancestor,
|
||||
isThread,
|
||||
descendant,
|
||||
thread,
|
||||
replies,
|
||||
repliesCount,
|
||||
} = status;
|
||||
const isHero = statusID === id;
|
||||
return (
|
||||
|
@ -656,8 +801,8 @@ function StatusPage() {
|
|||
{uiState !== 'loading' && !authenticated ? (
|
||||
<div class="post-status-banner">
|
||||
<p>
|
||||
You're not logged in. Interactions (reply, boost,
|
||||
etc) are not possible.
|
||||
You're not logged in. Interactions (reply, boost, etc)
|
||||
are not possible.
|
||||
</p>
|
||||
<Link to="/login" class="button">
|
||||
Log in
|
||||
|
@ -668,8 +813,8 @@ function StatusPage() {
|
|||
<div class="post-status-banner">
|
||||
<p>
|
||||
This post is from another instance (
|
||||
<b>{instance}</b>). Interactions (reply, boost,
|
||||
etc) are not possible.
|
||||
<b>{instance}</b>). Interactions (reply, boost, etc)
|
||||
are not possible.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -678,8 +823,7 @@ function StatusPage() {
|
|||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const results =
|
||||
await currentMasto.v2.search({
|
||||
const results = await currentMasto.v2.search({
|
||||
q: heroStatus.url,
|
||||
type: 'statuses',
|
||||
resolve: true,
|
||||
|
@ -687,11 +831,9 @@ function StatusPage() {
|
|||
});
|
||||
if (results.statuses.length) {
|
||||
const status = results.statuses[0];
|
||||
navigate(
|
||||
currentInstance
|
||||
location.hash = currentInstance
|
||||
? `/${currentInstance}/s/${status.id}`
|
||||
: `/s/${status.id}`,
|
||||
);
|
||||
: `/s/${status.id}`;
|
||||
} else {
|
||||
throw new Error('No results');
|
||||
}
|
||||
|
@ -714,9 +856,7 @@ function StatusPage() {
|
|||
<Link
|
||||
class="status-link"
|
||||
to={
|
||||
instance
|
||||
? `/${instance}/s/${statusID}`
|
||||
: `/s/${statusID}`
|
||||
instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
|
||||
}
|
||||
onClick={() => {
|
||||
resetScrollPosition(statusID);
|
||||
|
@ -728,7 +868,16 @@ function StatusPage() {
|
|||
withinContext
|
||||
size={thread || ancestor ? 'm' : 's'}
|
||||
enableTranslate
|
||||
onMediaClick={handleMediaClick}
|
||||
/>
|
||||
{ancestor && isThread && repliesCount > 1 && (
|
||||
<div class="replies-link">
|
||||
<Icon icon="comment" />{' '}
|
||||
<span title={repliesCount}>
|
||||
{shortenNumber(repliesCount)}
|
||||
</span>
|
||||
</div>
|
||||
)}{' '}
|
||||
{/* {replies?.length > LIMIT && (
|
||||
<div class="replies-link">
|
||||
<Icon icon="comment" />{' '}
|
||||
|
@ -821,7 +970,6 @@ function StatusPage() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -832,6 +980,7 @@ function SubComments({
|
|||
hasParentThread,
|
||||
level,
|
||||
}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Set isBrief = true:
|
||||
// - if less than or 2 replies
|
||||
// - if replies have no sub-replies
|
||||
|
@ -872,6 +1021,15 @@ function SubComments({
|
|||
(!hasParentThread || replies.length === 1) && (isBrief || !hasManyStatuses);
|
||||
const openBefore = cachedRepliesToggle[replies[0].id];
|
||||
|
||||
const handleMediaClick = useCallback((e, i, media, status) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSearchParams({
|
||||
media: i + 1,
|
||||
mediaStatusID: status.id,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<details
|
||||
class="replies"
|
||||
|
@ -894,6 +1052,7 @@ function SubComments({
|
|||
key={a.id}
|
||||
url={a.avatarStatic}
|
||||
title={`${a.displayName} @${a.username}`}
|
||||
squircle={a?.bot}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
|
@ -930,6 +1089,7 @@ function SubComments({
|
|||
withinContext
|
||||
size="s"
|
||||
enableTranslate
|
||||
onMediaClick={handleMediaClick}
|
||||
/>
|
||||
{!r.replies?.length && r.repliesCount > 0 && (
|
||||
<div class="replies-link">
|
||||
|
@ -955,19 +1115,4 @@ function SubComments({
|
|||
);
|
||||
}
|
||||
|
||||
const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/([^\/]+)\/?$/i;
|
||||
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 StatusPage;
|
||||
export default memo(StatusPage);
|
||||
|
|
|
@ -44,3 +44,45 @@
|
|||
#welcome:hover h2 {
|
||||
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 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 Link from '../components/link';
|
||||
import states from '../utils/states';
|
||||
|
@ -36,6 +41,74 @@ function Welcome() {
|
|||
</b>
|
||||
</big>
|
||||
</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 />
|
||||
<p>
|
||||
<a href="https://github.com/cheeaun/phanpy" target="_blank">
|
||||
|
|
|
@ -93,6 +93,8 @@ export async function initAccount(client, instance, accessToken) {
|
|||
const masto = client;
|
||||
const mastoAccount = await masto.v1.accounts.verifyCredentials();
|
||||
|
||||
store.session.set('currentAccount', mastoAccount.id);
|
||||
|
||||
saveAccount({
|
||||
info: mastoAccount,
|
||||
instanceURL: instance.toLowerCase(),
|
||||
|
|
|
@ -10,11 +10,32 @@ function enhanceContent(content, opts = {}) {
|
|||
|
||||
// Add target="_blank" to all links with no target="_blank"
|
||||
// E.g. `note` in `account`
|
||||
const links = Array.from(dom.querySelectorAll('a:not([target="_blank"])'));
|
||||
links.forEach((link) => {
|
||||
const noTargetBlankLinks = Array.from(
|
||||
dom.querySelectorAll('a:not([target="_blank"])'),
|
||||
);
|
||||
noTargetBlankLinks.forEach((link) => {
|
||||
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
|
||||
// ======
|
||||
// Convert :shortcode: to <img />
|
||||
|
@ -113,6 +134,40 @@ function enhanceContent(content, opts = {}) {
|
|||
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) {
|
||||
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: {},
|
||||
scrollPositions: {},
|
||||
unfurledLinks: {},
|
||||
statusQuotes: {},
|
||||
accounts: {},
|
||||
// Modals
|
||||
showCompose: false,
|
||||
|
@ -50,6 +51,7 @@ const states = proxy({
|
|||
store.account.get('settings-contentTranslationTargetLanguage') || null,
|
||||
contentTranslationHideLanguages:
|
||||
store.account.get('settings-contentTranslationHideLanguages') || [],
|
||||
cloakMode: store.account.get('settings-cloakMode') ?? false,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -87,6 +89,9 @@ subscribe(states, (changes) => {
|
|||
if (path?.[0] === '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) {
|
||||
let newValues = [];
|
||||
|
@ -50,24 +50,32 @@ export function groupBoosts(values) {
|
|||
}
|
||||
|
||||
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;
|
||||
const s = getStatus(item.reblog.id, instance);
|
||||
if (s) {
|
||||
const statusKey = `${instance}-${item.reblog.id}`;
|
||||
const boosterID = boostedStatusIDs[statusKey];
|
||||
if (boosterID && boosterID !== item.id) {
|
||||
console.warn(
|
||||
`🚫 Duplicate boost by ${item.account.displayName}`,
|
||||
item,
|
||||
s,
|
||||
item.reblog,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const s2 = getStatus(item.id, instance);
|
||||
if (s2) {
|
||||
console.warn('🚫 Re-boosted boost', item);
|
||||
return false;
|
||||
} else {
|
||||
boostedStatusIDs[statusKey] = item.id;
|
||||
}
|
||||
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) {
|
||||
|
|
|
@ -19,6 +19,7 @@ export default function useScroll({
|
|||
|
||||
useEffect(() => {
|
||||
const scrollableElement = scrollableRef.current;
|
||||
if (!scrollableElement) return {};
|
||||
let previousScrollStart = isVertical
|
||||
? scrollableElement.scrollTop
|
||||
: scrollableElement.scrollLeft;
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { useEffect } from 'preact/hooks';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
import { useSnapshot } from 'valtio';
|
||||
import { subscribeKey } from 'valtio/utils';
|
||||
|
||||
import states from './states';
|
||||
|
||||
const { VITE_CLIENT_NAME: CLIENT_NAME } = import.meta.env;
|
||||
|
||||
export default function useTitle(title, path) {
|
||||
const snapStates = useSnapshot(states);
|
||||
const { currentLocation } = snapStates;
|
||||
function setTitle() {
|
||||
const { currentLocation } = states;
|
||||
const hasPaths = Array.isArray(path);
|
||||
let paths = hasPaths ? path : [];
|
||||
// Workaround for matchPath not working for optional path segments
|
||||
|
@ -23,9 +23,14 @@ export default function useTitle(title, path) {
|
|||
} else if (path) {
|
||||
matched = matchPath(path, currentLocation);
|
||||
}
|
||||
console.debug({ paths, matched, currentLocation });
|
||||
useEffect(() => {
|
||||
if (!matched) return;
|
||||
console.log('setTitle', { title, path, currentLocation, paths, matched });
|
||||
if (matched) {
|
||||
document.title = title ? `${title} / ${CLIENT_NAME}` : CLIENT_NAME;
|
||||
}, [title, matched]);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTitle();
|
||||
return subscribeKey(states, 'currentLocation', setTitle);
|
||||
}, [title, path]);
|
||||
}
|
||||
|
|
|
@ -94,6 +94,15 @@ export default defineConfig({
|
|||
main: resolve(__dirname, '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