Merge pull request #97 from cheeaun/main

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

21
LICENSE Normal file
View file

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

18
package-lock.json generated
View file

@ -33,7 +33,7 @@
"toastify-js": "~1.12.0",
"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": {

View file

@ -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": [

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

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

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

View file

@ -44,6 +44,7 @@ function AccountBlock({
url,
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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' ? (

View file

@ -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'} />

View file

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

View file

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

View file

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

View file

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

View file

@ -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'} />

View file

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

View file

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

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

View file

@ -26,6 +26,8 @@
#shortcuts-settings-container .shortcuts-list li .shortcut-text {
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;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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>{' '}
&bull;{' '}
</>
)}
<span title={votesCount}>{shortenNumber(votesCount)}</span> vote
{votesCount === 1 ? '' : 's'}
{!!votersCount && votersCount !== votesCount && (
<>
{' '}
&bull;{' '}
<span title={votersCount}>{shortenNumber(votersCount)}</span>{' '}
voter
{votersCount === 1 ? '' : 's'}
</>
)}{' '}
&bull; {expired ? 'Ended' : 'Ending'}{' '}
{!!expiresAtDate && <RelativeTime datetime={expiresAtDate} />}
</p>
)}
</div>
);
}
function EditedAtModal({
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);

View file

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

View file

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

View file

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

View file

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

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

View file

@ -1,3 +1,4 @@
import { Menu, MenuItem } from '@szhsin/react-menu';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { 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>
}
/>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),

View file

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

View file

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

View file

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

View file

@ -27,6 +27,7 @@ const states = proxy({
spoilers: {},
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);
}
}
});

View file

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

View file

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

View file

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

View file

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