1
0
Fork 0

Compare commits

...

50 commits

Author SHA1 Message Date
Alexander Yakovlev e83f18430c Merge remote-tracking branch 'origin/main' 2024-01-09 11:33:30 +07:00
Chee Aun 96387c8abb
Merge pull request #392 from JerryLerman/main
Update README.md to add hear-me.social
2024-01-08 13:43:55 +08:00
Jerry Lerman 35958d429d
Update README.md to add hear-me.social
Added phanpy.hear-me.social to the list of deployed sites
2024-01-07 21:53:54 -05:00
Lim Chee Aun 99b0b7c096 Test disable viewScroll=close for hashtag page menu
Possible fix for self-auto-closing when focusing on the
input field to add hashtag and the software keyboard resizes
the page, causing scroll event to fire and close the menu itself
2024-01-07 12:30:51 +08:00
Lim Chee Aun e44ac16396 Fix flash of unscrolled position
Due to statuses being memo-ed, need to speed up the scroll position setup
2024-01-06 19:15:48 +08:00
Lim Chee Aun 147a12cbcb Handle cards with iframe embeds 2024-01-06 16:46:45 +08:00
Lim Chee Aun 16e2ac9bce Test better equal checks 2024-01-06 12:31:25 +08:00
Lim Chee Aun 1574be2b35 Test content-visibility: auto on off-screen columns 2024-01-06 12:23:43 +08:00
Lim Chee Aun 7223baaaad Better error handling for image desc generator
400 doesn't throw error
2024-01-06 12:23:15 +08:00
Lim Chee Aun 9cffd429b0 Potential fix to infinite loop of intersection observer 2024-01-06 03:15:24 +08:00
Lim Chee Aun 9a5d749b8d Better search suggestion styles
Lighter style and fifferentiate between hover and focus
2024-01-06 01:04:14 +08:00
Lim Chee Aun e43f2283dd Resolve account URLs too 2024-01-06 01:03:30 +08:00
Lim Chee Aun be5fcc35ac Comment line extended if there's status pre-meta 2024-01-05 19:18:05 +08:00
Lim Chee Aun 54314de976 Experiment unlinked replies (again)
But still show link to the post's "thread"
2024-01-05 19:15:22 +08:00
Lim Chee Aun bc2886f7e2 Ancestor indicator animates smoother with spring 2024-01-05 19:13:51 +08:00
Lim Chee Aun 2bc1b8387e Fix missing name & short_name inside webmanifest
Need to pass env prefix to loadEnv too
2024-01-05 09:14:09 +08:00
Lim Chee Aun 3989b218d0 Need to encode the query 2024-01-04 22:00:27 +08:00
Lim Chee Aun a8331375ba Double make sure header change doesn't block scrolling 2024-01-04 19:09:30 +08:00
Lim Chee Aun 6919975c6d Remove unneeded .inview 2024-01-04 19:08:51 +08:00
Lim Chee Aun c0987209a8 Only threadify & unfurl non-reblog post object 2024-01-04 18:56:11 +08:00
Lim Chee Aun d25c2df392 Warn if icon not found 2024-01-04 18:55:21 +08:00
Lim Chee Aun 848433365d Don't limit 80px if more than 2 media 2024-01-04 18:55:14 +08:00
Lim Chee Aun 3d4ebb8abe Adjust rootMargin 2024-01-03 10:54:55 +08:00
Lim Chee Aun 72dc4cc81b Test disable menu animation 2024-01-03 09:53:08 +08:00
Lim Chee Aun 92c0a8b4f0 Test memoize svg icon 2024-01-03 09:49:48 +08:00
Lim Chee Aun 1adcca5666 Fix destructure error 2024-01-03 07:27:39 +08:00
Lim Chee Aun b4d4c61128 Experiment delay render items in carousel 2024-01-02 19:56:54 +08:00
Lim Chee Aun 764125e6b9 Test replace scroll-based to inview 2024-01-02 19:26:05 +08:00
Lim Chee Aun 098df0ad2c Test move this out of component mount
It needs to run faster
2024-01-02 17:45:58 +08:00
Lim Chee Aun e41e49884f Less paragraph margins for status cards 2024-01-02 17:45:21 +08:00
Lim Chee Aun 852f7090f6 Status card style changes 2024-01-02 12:27:39 +08:00
Lim Chee Aun d54511aa10 Test a bunch of perf-related style changes 2024-01-02 12:27:22 +08:00
Lim Chee Aun d8ceb03d74 Throttle scroll events 2024-01-02 12:25:25 +08:00
Lim Chee Aun df393ae959 Use InView to replace nearReachStart 2024-01-02 12:25:01 +08:00
Lim Chee Aun 0ebbc5b34e Don't need nearReachEnd, use InView more 2024-01-02 12:24:03 +08:00
Lim Chee Aun cf52e0776e Don't need reachStart from useScroll 2024-01-02 12:20:36 +08:00
Lim Chee Aun b168707c14 Revert "Remove DEV check"
This reverts commit d2fb86036c.
2024-01-01 18:31:59 +08:00
Lim Chee Aun d2fb86036c Remove DEV check
It refers to local dev, not the dev site
2024-01-01 18:29:21 +08:00
Lim Chee Aun 62c8a51307 Test another temp color 2023-12-31 09:39:07 +08:00
Lim Chee Aun f056d7407a Attempt to fix iOS status bar color 2023-12-31 08:02:32 +08:00
Lim Chee Aun c3e40297e0 Add a little delay 2023-12-30 21:51:10 +08:00
Lim Chee Aun d6099df51b Experiment unindenting deep single replies 2023-12-30 21:16:30 +08:00
Lim Chee Aun 096bc69584 Fix child replies accidentally got GC-ed 2023-12-30 21:03:10 +08:00
Lim Chee Aun 32d32b72f4 Less radius for animated media 2023-12-30 20:29:21 +08:00
Lim Chee Aun 796b365fd8 Disable animation if hidden 2023-12-30 20:17:34 +08:00
Lim Chee Aun bd38122f1b Extract unfurling out of status component 2023-12-30 18:13:56 +08:00
Lim Chee Aun d7d838ebf8 Rebuild useScroll, less states 2023-12-29 18:29:08 +08:00
Lim Chee Aun de3787209e Make bold less bold 2023-12-29 18:16:19 +08:00
Lim Chee Aun 6500be2782 Disable hotkeys in quote posts 2023-12-29 18:16:08 +08:00
Lim Chee Aun 2240380f68 Fix wrong month shown for different system date formats 2023-12-29 14:27:43 +08:00
32 changed files with 1057 additions and 406 deletions

View file

@ -199,6 +199,7 @@ These are self-hosted by other wonderful folks.
- [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan) - [phanpy.vmst.io](https://phanpy.vmst.io/) by [@vmstan@vmst.io](https://vmst.io/@vmstan)
- [phanpy.gotosocial.social](https://phanpy.gotosocial.social/) by [@admin@gotosocial.social](https://gotosocial.social/@admin) - [phanpy.gotosocial.social](https://phanpy.gotosocial.social/) by [@admin@gotosocial.social](https://gotosocial.social/@admin)
- [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3) - [phanpy.bauxite.tech](https://phanpy.bauxite.tech) by [@b4ux1t3@hachyderm.io](https://hachyderm.io/@b4ux1t3)
- [phanpy.hear-me.social](https://phanpy.hear-me.social) by [@admin@hear-me.social](https://hear-me.social/@admin)
> Note: Add yours by creating a pull request. > Note: Add yours by creating a pull request.

View file

@ -31,14 +31,16 @@
name="theme-color" name="theme-color"
data-theme-setting="auto" data-theme-setting="auto"
content="#fff" content="#fff"
data-content-temp="#ffff" data-content="#fff"
data-content-temp="#fffa"
media="(prefers-color-scheme: light)" media="(prefers-color-scheme: light)"
/> />
<meta <meta
name="theme-color" name="theme-color"
data-theme-setting="auto" data-theme-setting="auto"
content="#242526" content="#242526"
data-content-temp="#242526ff" data-content="#242526"
data-content-temp="#242526aa"
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
/> />
<meta name="google" content="notranslate" /> <meta name="google" content="notranslate" />

18
package-lock.json generated
View file

@ -19,7 +19,7 @@
"dayjs": "~1.11.10", "dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0", "dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3", "fast-equals": "~5.0.1",
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0", "lz-string": "~1.5.0",
@ -4405,13 +4405,16 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": { "node_modules/fast-equals": {
"version": "3.0.3", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-3.0.3.tgz", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz",
"integrity": "sha512-NCe8qxnZFARSHGztGMZOO/PC1qa5MIFB5Hp66WdzbCRAz8U8US3bx1UTgLS49efBQPcUtO9gf5oVEY8o7y/7Kg==", "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==",
"license": "MIT" "engines": {
"node": ">=6.0.0"
}
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.2", "version": "3.3.2",
@ -5570,6 +5573,11 @@
"micro-memoize": "^4.1.2" "micro-memoize": "^4.1.2"
} }
}, },
"node_modules/moize/node_modules/fast-equals": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-3.0.3.tgz",
"integrity": "sha512-NCe8qxnZFARSHGztGMZOO/PC1qa5MIFB5Hp66WdzbCRAz8U8US3bx1UTgLS49efBQPcUtO9gf5oVEY8o7y/7Kg=="
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View file

@ -21,7 +21,7 @@
"dayjs": "~1.11.10", "dayjs": "~1.11.10",
"dayjs-twitter": "~0.5.0", "dayjs-twitter": "~0.5.0",
"fast-blurhash": "~1.1.2", "fast-blurhash": "~1.1.2",
"fast-deep-equal": "~3.1.3", "fast-equals": "~5.0.1",
"idb-keyval": "~6.2.1", "idb-keyval": "~6.2.1",
"just-debounce-it": "~3.2.0", "just-debounce-it": "~3.2.0",
"lz-string": "~1.5.0", "lz-string": "~1.5.0",

View file

@ -86,7 +86,7 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
inset: 0; inset: 0;
} }
:is(#home-page, #welcome, #columns, #loader-root):has(~ .deck-container) { :is(#home-page, #welcome, #columns, #loader-root):has(~ .deck-container) {
display: block; /* display: block; */
position: absolute; position: absolute;
user-select: none; user-select: none;
pointer-events: none; pointer-events: none;
@ -144,9 +144,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
user-select: none; user-select: none;
} }
.deck > header .header-grid { .deck > header .header-grid {
background-color: var(--bg-blur-color); background-color: var(--bg-color);
/* background-color: var(--bg-blur-color);
background-image: linear-gradient(to bottom, var(--bg-color), transparent); background-image: linear-gradient(to bottom, var(--bg-color), transparent);
backdrop-filter: saturate(180%) blur(20px); backdrop-filter: saturate(180%) blur(20px); */
border-bottom: var(--hairline-width) solid var(--divider-color); border-bottom: var(--hairline-width) solid var(--divider-color);
min-height: 3em; min-height: 3em;
display: grid; display: grid;
@ -546,9 +547,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
list-style: none; list-style: none;
} }
.timeline.contextual > li .replies > .replies-summary { .timeline.contextual > li .replies > .replies-summary {
padding: 8px; --summary-padding: 8px;
padding: var(--summary-padding);
background-color: var(--bg-faded-color); background-color: var(--bg-faded-color);
display: inline-block; display: inline-flex;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
text-transform: uppercase; text-transform: uppercase;
@ -558,12 +560,67 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
box-shadow: 0 0 0 2px var(--bg-color); box-shadow: 0 0 0 2px var(--bg-color);
position: relative; position: relative;
list-style: none; list-style: none;
white-space: nowrap; gap: 8px;
align-items: center;
margin-right: calc(44px + 8px);
b { b {
font-weight: 500; font-weight: 500;
color: var(--text-color); color: var(--text-color);
} }
.avatars {
flex-shrink: 0;
transition: opacity 0.3s ease;
.avatar {
transition: transform 0.3s ease;
&:not(:first-child) {
margin: 0 0 0 -4px;
}
}
}
.replies-counts {
/* flex-grow: 1; */
> * {
display: inline-block;
}
}
.replies-summary-chevron {
transition: transform 0.3s ease;
}
.replies-parent-link {
position: absolute;
right: 4px;
height: 100%;
z-index: 2;
font-size: 16px;
font-weight: bold;
align-self: stretch;
text-decoration: none;
display: flex;
align-items: center;
padding: var(--summary-padding) calc(var(--summary-padding) * 2);
transform: translateX(100%);
margin: calc(-1 * var(--summary-padding)) calc(-1 * var(--summary-padding))
calc(-1 * var(--summary-padding)) 0;
border-radius: 8px;
background-color: var(--link-bg-color);
&:is(:hover, :focus) {
color: var(--link-text-color);
box-shadow: inset 0 0 0 2px var(--link-faded-color);
}
&:active {
background-color: var(--link-faded-color);
}
}
} }
.timeline.contextual > li .replies > .replies-summary::-webkit-details-marker { .timeline.contextual > li .replies > .replies-summary::-webkit-details-marker {
display: none; display: none;
@ -571,14 +628,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
.timeline.contextual > li .replies > .replies-summary > * { .timeline.contextual > li .replies > .replies-summary > * {
vertical-align: middle; vertical-align: middle;
} }
.timeline.contextual > li .replies > .replies-summary .avatars { .timeline.contextual
margin-right: 8px; > li
.replies
> *:not(:first-child) { > .replies-summary
margin: 0 0 0 -4px; .timeline.contextual
} > li
} .replies
.timeline.contextual > li .replies > .replies-summary:active, > .replies-summary:active,
.timeline.contextual > li .replies[open] > .replies-summary { .timeline.contextual > li .replies[open] > .replies-summary {
color: var(--text-color); color: var(--text-color);
background-color: var(--comment-line-color); background-color: var(--comment-line-color);
@ -590,6 +647,18 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
} }
.timeline.contextual > li .replies[open] > .replies-summary { .timeline.contextual > li .replies[open] > .replies-summary {
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
.avatars {
opacity: 0.5;
.avatar {
transform: rotate(-15deg);
}
}
.replies-summary-chevron {
transform: rotate(180deg);
}
} }
.timeline.contextual > li .replies .replies-summary[hidden] { .timeline.contextual > li .replies .replies-summary[hidden] {
display: none; display: none;
@ -686,6 +755,22 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
pointer-events: none; pointer-events: none;
} }
.timeline.contextual > li .replies {
> ul > li:only-child {
> .replies {
> ul > li:only-child {
margin-left: calc(-1 * var(--line-margin-end));
background-position: calc(16px) 0;
background-size: 100% calc(20px + 8px);
&:before {
display: none;
}
}
}
}
}
.timeline-deck.compact .status { .timeline-deck.compact .status {
max-height: max(25vh, 160px); max-height: max(25vh, 160px);
overflow: hidden; overflow: hidden;
@ -746,6 +831,11 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
border-top-right-radius: 0; border-top-right-radius: 0;
border-top: 0; border-top: 0;
background-size: 100% 16px; background-size: 100% 16px;
&:has(.status-pre-meta) {
/* 20px = icon of the pre-meta */
background-size: 100% calc(16px + 20px);
}
} }
.timeline:not(.flat) .timeline:not(.flat)
> li:is(.timeline-item-container-middle, .timeline-item-container-end) > li:is(.timeline-item-container-middle, .timeline-item-container-end)
@ -877,17 +967,20 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
text-shadow: 0 1px var(--bg-color); text-shadow: 0 1px var(--bg-color);
} }
.status-carousel > ul { .status-carousel > ul {
--carousel-gap: 16px;
display: flex; display: flex;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: clip;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
scroll-behavior: smooth; scroll-behavior: smooth;
margin: 0; margin: 0;
padding: 8px 16px; padding: 8px 16px;
gap: 16px; gap: var(--carousel-gap);
align-items: flex-start; align-items: flex-start;
counter-reset: index; counter-reset: index;
min-height: 160px; min-height: 160px;
max-height: 65vh;
max-height: 65dvh;
} }
.status-carousel > ul > li { .status-carousel > ul > li {
scroll-snap-align: center; scroll-snap-align: center;
@ -899,14 +992,23 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
max-height: 65vh; /* max-height: 65vh;
max-height: 65dvh; max-height: 65dvh; */
counter-increment: index; counter-increment: index;
position: relative; position: relative;
} }
.status-carousel > ul > li:is(:empty, :has(> a:empty)) { .status-carousel > ul > li:is(:empty, :has(> a:empty)) {
display: none; display: none;
} }
.status-carousel .status-carousel-beacon {
margin-right: calc(-1 * var(--carousel-gap));
pointer-events: none;
opacity: 0;
~ .status-carousel-beacon {
margin-left: calc(-1 * var(--carousel-gap));
}
}
/* /*
Assume that browsers that do support inline-size property also support container queries. Assume that browsers that do support inline-size property also support container queries.
https://www.smashingmagazine.com/2021/05/css-container-queries-use-cases-migration-strategies/#progressive-enhancement-polyfills https://www.smashingmagazine.com/2021/05/css-container-queries-use-cases-migration-strategies/#progressive-enhancement-polyfills
@ -925,6 +1027,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
} }
.status-carousel .content-container .content:only-child { .status-carousel .content-container .content:only-child {
font-size: calc(100% + 25% * max(2 - var(--content-text-weight), 0)); font-size: calc(100% + 25% * max(2 - var(--content-text-weight), 0));
&:has(.status-card) {
font-size: unset;
}
} }
/* .status-carousel /* .status-carousel
.content-container[data-content-text-weight='1'] .content-container[data-content-text-weight='1']
@ -1092,6 +1198,14 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
transform: translate(-50%, 0); transform: translate(-50%, 0);
font-size: 90%; font-size: 90%;
pointer-events: auto; pointer-events: auto;
transition: all 0.3s ease-in-out;
header[hidden] & {
opacity: 0;
transform: translate(-50%, -100%) scale(0.9);
pointer-events: none;
animation: none !important;
}
} }
.updates-button .icon { .updates-button .icon {
vertical-align: top; vertical-align: top;
@ -1398,7 +1512,7 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
right: max(16px, env(safe-area-inset-right)); right: max(16px, env(safe-area-inset-right));
padding: 16px; padding: 16px;
background-color: var(--button-bg-blur-color); background-color: var(--button-bg-blur-color);
backdrop-filter: blur(16px); /* backdrop-filter: blur(16px); */
z-index: 10; z-index: 10;
box-shadow: 0 3px 8px -1px var(--drop-shadow-color), box-shadow: 0 3px 8px -1px var(--drop-shadow-color),
0 10px 36px -4px var(--button-bg-blur-color); 0 10px 36px -4px var(--button-bg-blur-color);
@ -1611,7 +1725,7 @@ body > .szh-menu-container {
border-radius: 8px; border-radius: 8px;
box-shadow: 0 3px 16px -3px var(--drop-shadow-color); box-shadow: 0 3px 16px -3px var(--drop-shadow-color);
text-align: left; text-align: left;
animation: appear-smooth 0.15s ease-in-out; /* animation: appear-smooth 0.15s ease-in-out; */
width: 16em; width: 16em;
max-width: 90vw; max-width: 90vw;
/* overflow: hidden; */ /* overflow: hidden; */
@ -2146,6 +2260,9 @@ ul.link-list li a .icon {
.header-grid { .header-grid {
background-color: var(--bg-blur-color); background-color: var(--bg-blur-color);
} }
#columns > .deck-container > .timeline-deck {
content-visibility: auto;
}
#columns .header-grid input { #columns .header-grid input {
pointer-events: none; pointer-events: none;
} }
@ -2447,6 +2564,7 @@ ul.link-list li a .icon {
border-bottom: 0; border-bottom: 0;
border-radius: 16px; border-radius: 16px;
background-color: var(--bg-faded-blur-color); background-color: var(--bg-faded-blur-color);
backdrop-filter: blur(16px);
background-image: none; background-image: none;
border-radius: 16px; border-radius: 16px;
min-height: 4em; min-height: 4em;

View file

@ -224,7 +224,7 @@ if (isIOS) {
`meta[name="theme-color"][media*="${colorScheme}"]`, `meta[name="theme-color"][media*="${colorScheme}"]`,
); );
if ($meta) { if ($meta) {
const color = $meta.content; const color = $meta.dataset.content;
const tempColor = $meta.dataset.contentTemp; const tempColor = $meta.dataset.contentTemp;
$meta.content = tempColor || ''; $meta.content = tempColor || '';
setTimeout(() => { setTimeout(() => {

View file

@ -2,7 +2,7 @@ import './compose.css';
import '@github/text-expander-element'; import '@github/text-expander-element';
import { MenuItem } from '@szhsin/react-menu'; import { MenuItem } from '@szhsin/react-menu';
import equal from 'fast-deep-equal'; import { deepEqual } from 'fast-equals';
import { forwardRef } from 'preact/compat'; import { forwardRef } from 'preact/compat';
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
@ -502,7 +502,10 @@ function Compose({
mediaAttachments, mediaAttachments,
}, },
}; };
if (!equal(backgroundDraft, prevBackgroundDraft.current) && !canClose()) { if (
!deepEqual(backgroundDraft, prevBackgroundDraft.current) &&
!canClose()
) {
console.debug('not equal', backgroundDraft, prevBackgroundDraft.current); console.debug('not equal', backgroundDraft, prevBackgroundDraft.current);
db.drafts db.drafts
.set(key, { .set(key, {
@ -1838,10 +1841,17 @@ function MediaAttachment({
method: 'POST', method: 'POST',
body, body,
}).then((r) => r.json()); }).then((r) => r.json());
if (response.error) {
throw new Error(response.error);
}
setDescription(response.description); setDescription(response.description);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
showToast('Failed to generate description'); showToast(
`Failed to generate description${
e?.message ? `: ${e.message}` : ''
}`,
);
} finally { } finally {
setUIState('default'); setUIState('default');
toastRef.current?.hideToast?.(); toastRef.current?.hideToast?.();

View file

@ -0,0 +1,27 @@
.embed-modal-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
pointer-events: none;
.top-controls {
padding: 16px;
display: flex;
gap: 8px;
justify-content: space-between;
pointer-events: auto;
}
.embed-content {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
iframe {
pointer-events: auto;
max-width: 100%;
}
}
}

View file

@ -0,0 +1,28 @@
import './embed-modal.css';
import Icon from './icon';
function EmbedModal({ html, url, onClose = () => {} }) {
return (
<div class="embed-modal-container">
<div class="top-controls">
<button type="button" class="light" onClick={() => onClose()}>
<Icon icon="x" />
</button>
{url && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
class="button plain"
>
<span>Open link</span> <Icon icon="external" />
</a>
)}
</div>
<div class="embed-content" dangerouslySetInnerHTML={{ __html: html }} />
</div>
);
}
export default EmbedModal;

View file

@ -1,3 +1,4 @@
import moize from 'moize';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
const SIZES = { const SIZES = {
@ -110,6 +111,29 @@ export const ICONS = {
const ICONDATA = {}; const ICONDATA = {};
// Memoize the dangerouslySetInnerHTML of the SVGs
const SVGICon = moize(
function ({ size, width, height, body, rotate, flip }) {
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${width} ${height}`}
dangerouslySetInnerHTML={{ __html: body }}
style={{
transform: `${rotate ? `rotate(${rotate})` : ''} ${
flip ? `scaleX(-1)` : ''
}`,
}}
/>
);
},
{
isShallowEqual: true,
maxSize: Object.keys(ICONS).length,
},
);
function Icon({ function Icon({
icon, icon,
size = 'm', size = 'm',
@ -122,6 +146,11 @@ function Icon({
const iconSize = SIZES[size]; const iconSize = SIZES[size];
let iconBlock = ICONS[icon]; let iconBlock = ICONS[icon];
if (!iconBlock) {
console.warn(`Icon ${icon} not found`);
return null;
}
let rotate, flip; let rotate, flip;
if (Array.isArray(iconBlock)) { if (Array.isArray(iconBlock)) {
[iconBlock, rotate, flip] = iconBlock; [iconBlock, rotate, flip] = iconBlock;
@ -150,16 +179,24 @@ function Icon({
}} }}
> >
{iconData && ( {iconData && (
<svg // <svg
width={iconSize} // width={iconSize}
height={iconSize} // height={iconSize}
viewBox={`0 0 ${iconData.width} ${iconData.height}`} // viewBox={`0 0 ${iconData.width} ${iconData.height}`}
dangerouslySetInnerHTML={{ __html: iconData.body }} // dangerouslySetInnerHTML={{ __html: iconData.body }}
style={{ // style={{
transform: `${rotate ? `rotate(${rotate})` : ''} ${ // transform: `${rotate ? `rotate(${rotate})` : ''} ${
flip ? `scaleX(-1)` : '' // flip ? `scaleX(-1)` : ''
}`, // }`,
}} // }}
// />
<SVGICon
size={iconSize}
width={iconData.width}
height={iconData.height}
body={iconData.body}
rotate={rotate}
flip={flip}
/> />
)} )}
</span> </span>

View file

@ -34,7 +34,7 @@
word-break: break-word; word-break: break-word;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
mix-blend-mode: luminosity; /* mix-blend-mode: luminosity; */
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
line-clamp: 3; line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;

View file

@ -358,7 +358,7 @@ function Media({
<Parent <Parent
class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${ class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${
autoGIFAnimate ? 'media-contain' : '' autoGIFAnimate ? 'media-contain' : ''
}`} } ${hoverAnimate ? 'media-hover-animate' : ''}`}
data-orientation={orientation} data-orientation={orientation}
data-formatted-duration={ data-formatted-duration={
!showOriginal ? formattedDuration : undefined !showOriginal ? formattedDuration : undefined

View file

@ -10,6 +10,7 @@ import states from '../utils/states';
import AccountSheet from './account-sheet'; import AccountSheet from './account-sheet';
import Compose from './compose'; import Compose from './compose';
import Drafts from './drafts'; import Drafts from './drafts';
import EmbedModal from './embed-modal';
import GenericAccounts from './generic-accounts'; import GenericAccounts from './generic-accounts';
import MediaAltModal from './media-alt-modal'; import MediaAltModal from './media-alt-modal';
import MediaModal from './media-modal'; import MediaModal from './media-modal';
@ -200,6 +201,21 @@ export default function Modals() {
/> />
</Modal> </Modal>
)} )}
{!!snapStates.showEmbedModal && (
<Modal
onClose={() => {
states.showEmbedModal = false;
}}
>
<EmbedModal
html={snapStates.showEmbedModal.html}
url={snapStates.showEmbedModal.url}
onClose={() => {
states.showEmbedModal = false;
}}
/>
</Modal>
)}
</> </>
); );
} }

View file

@ -15,6 +15,22 @@ import Link from './link';
import Modal from './modal'; import Modal from './modal';
import Notification from './notification'; import Notification from './notification';
{
if ('serviceWorker' in navigator) {
console.log('👂👂👂 Listen to message');
navigator.serviceWorker.addEventListener('message', (event) => {
console.log('💥💥💥 Message event', event);
const { type, id, accessToken } = event?.data || {};
if (type === 'notification') {
states.routeNotification = {
id,
accessToken,
};
}
});
}
}
export default memo(function NotificationService() { export default memo(function NotificationService() {
if (!('serviceWorker' in navigator)) return null; if (!('serviceWorker' in navigator)) return null;
@ -82,25 +98,25 @@ export default memo(function NotificationService() {
})(); })();
}, [id, accessToken]); }, [id, accessToken]);
useLayoutEffect(() => { // useLayoutEffect(() => {
// Listen to message from service worker // // Listen to message from service worker
const handleMessage = (event) => { // const handleMessage = (event) => {
console.log('💥💥💥 Message event', event); // console.log('💥💥💥 Message event', event);
const { type, id, accessToken } = event?.data || {}; // const { type, id, accessToken } = event?.data || {};
if (type === 'notification') { // if (type === 'notification') {
states.routeNotification = { // states.routeNotification = {
id, // id,
accessToken, // accessToken,
}; // };
} // }
}; // };
console.log('👂👂👂 Listen to message'); // console.log('👂👂👂 Listen to message');
navigator.serviceWorker.addEventListener('message', handleMessage); // navigator.serviceWorker.addEventListener('message', handleMessage);
return () => { // return () => {
console.log('👂👂👂 Remove listen to message'); // console.log('👂👂👂 Remove listen to message');
navigator.serviceWorker.removeEventListener('message', handleMessage); // navigator.serviceWorker.removeEventListener('message', handleMessage);
}; // };
}, []); // }, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (navigator?.clearAppBadge) { if (navigator?.clearAppBadge) {

View file

@ -172,7 +172,9 @@ export const SHORTCUTS_META = {
id: 'search', id: 'search',
title: ({ query }) => (query ? `"${query}"` : 'Search'), title: ({ query }) => (query ? `"${query}"` : 'Search'),
path: ({ query }) => path: ({ query }) =>
query ? `/search?q=${query}&type=statuses` : '/search', query
? `/search?q=${encodeURIComponent(query)}&type=statuses`
: '/search',
icon: 'search', icon: 'search',
excludeViewMode: ({ query }) => (!query ? ['multi-column'] : []), excludeViewMode: ({ query }) => (!query ? ['multi-column'] : []),
}, },

View file

@ -59,8 +59,9 @@
left: 0; left: 0;
right: 0; right: 0;
z-index: 100; z-index: 100;
background-color: var(--bg-blur-color); background-color: var(--bg-color);
backdrop-filter: blur(16px) saturate(3); /* background-color: var(--bg-blur-color);
backdrop-filter: blur(16px) saturate(3); */
border-top: var(--hairline-width) solid var(--outline-color); border-top: var(--hairline-width) solid var(--outline-color);
box-shadow: 0 -8px 16px -8px var(--drop-shadow-color); box-shadow: 0 -8px 16px -8px var(--drop-shadow-color);
overflow: auto; overflow: auto;
@ -165,6 +166,7 @@ shortcuts .tab-bar[hidden] {
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
env(safe-area-inset-left); env(safe-area-inset-left);
background-color: var(--bg-faded-blur-color); background-color: var(--bg-faded-blur-color);
backdrop-filter: blur(16px);
border: 0; border: 0;
box-shadow: none; box-shadow: none;
border-bottom: var(--hairline-width) solid var(--bg-faded-color); border-bottom: var(--hairline-width) solid var(--bg-faded-color);

View file

@ -206,11 +206,11 @@
.status-card:not(.status-carousel .status) .status-card:not(.status-carousel .status)
:is(.content, .poll, .media-container) { :is(.content, .poll, .media-container) {
max-height: 160px !important; max-height: 160px !important;
overflow: hidden; overflow: clip;
} }
.status.small:not(.status-carousel .status) .status.small:not(.status-carousel .status, .status.large .status)
.status-card .status-card
:is(.content, .poll, .media-container) { :is(.content, .poll, .media-container:not(.media-gt2)) {
max-height: 80px !important; max-height: 80px !important;
} }
.status.large .status-card :is(.content, .poll, .media-container) { .status.large .status-card :is(.content, .poll, .media-container) {
@ -730,6 +730,9 @@
tab-size: 2; tab-size: 2;
text-wrap: pretty; text-wrap: pretty;
} }
.status-card .content p {
margin-block: min(0.25em, 4px);
}
.status .content p:first-child { .status .content p:first-child {
margin-block-start: 0; margin-block-start: 0;
} }
@ -767,6 +770,9 @@
.status .content ul { .status .content ul {
list-style-type: disc; list-style-type: disc;
} }
.status .content :is(strong, b) {
font-weight: 600;
}
.status .content .invisible { .status .content .invisible {
display: none; display: none;
} }
@ -937,6 +943,10 @@
} }
.status.large .media-container.media-eq1 { .status.large .media-container.media-eq1 {
max-height: min(var(--height), 60vh); max-height: min(var(--height), 60vh);
.media-gif.media-contain {
border-radius: 2px;
}
} }
.status.large .status.large
.media-container:not(.status-card .media-container).media-eq1 .media-container:not(.status-card .media-container).media-eq1
@ -952,6 +962,11 @@
height: auto; height: auto;
max-height: 60vh; max-height: 60vh;
} }
.status.status-card .media-container.media-eq1 .media {
max-height: 160px;
width: auto;
max-width: min(var(--width), 100%);
}
/* Special media borders */ /* Special media borders */
.status .media-container.media-eq2 .media:first-of-type { .status .media-container.media-eq2 .media:first-of-type {
border-radius: var(--media-radius) var(--media-radius-inner) border-radius: var(--media-radius) var(--media-radius-inner)
@ -1156,6 +1171,15 @@ body:has(#modal-container .carousel) .status .media img:hover {
.status .media-contain video { .status .media-contain video {
object-fit: scale-down !important; object-fit: scale-down !important;
} }
.status .media-eq1 .media-hover-animate {
transition: border-radius 0.15s ease-out;
transition-delay: 0.15s;
&:hover {
transition-delay: 0;
border-radius: 2px;
}
}
/* .status .media-audio { /* .status .media-audio {
border: 0; border: 0;
min-height: 0; min-height: 0;
@ -1941,7 +1965,7 @@ a.card:is(:hover, :focus):visited {
color: var(--media-fg-color); color: var(--media-fg-color);
background-color: var(--media-bg-color); background-color: var(--media-bg-color);
border: var(--hairline-width) solid var(--media-outline-color); border: var(--hairline-width) solid var(--media-outline-color);
mix-blend-mode: luminosity; /* mix-blend-mode: luminosity; */
border-radius: 4px; border-radius: 4px;
padding: 4px; padding: 4px;
opacity: 0.65; opacity: 0.65;

View file

@ -9,7 +9,7 @@ import {
MenuItem, MenuItem,
} from '@szhsin/react-menu'; } from '@szhsin/react-menu';
import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash'; import { decodeBlurHash, getBlurHashAverageColor } from 'fast-blurhash';
import pThrottle from 'p-throttle'; import { shallowEqual } from 'fast-equals';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { import {
useCallback, useCallback,
@ -20,10 +20,8 @@ import {
useState, useState,
} from 'preact/hooks'; } from 'preact/hooks';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { InView } from 'react-intersection-observer';
import { useLongPress } from 'use-long-press'; import { useLongPress } from 'use-long-press';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import { snapshot } from 'valtio/vanilla';
import AccountBlock from '../components/account-block'; import AccountBlock from '../components/account-block';
import EmojiText from '../components/emoji-text'; import EmojiText from '../components/emoji-text';
@ -54,6 +52,7 @@ import { speak, supportsTTS } from '../utils/speech';
import states, { getStatus, saveStatus, statusKey } from '../utils/states'; import states, { getStatus, saveStatus, statusKey } from '../utils/states';
import statusPeek from '../utils/status-peek'; import statusPeek from '../utils/status-peek';
import store from '../utils/store'; import store from '../utils/store';
import unfurlMastodonLink from '../utils/unfurl-link';
import useTruncated from '../utils/useTruncated'; import useTruncated from '../utils/useTruncated';
import visibilityIconsMap from '../utils/visibility-icons-map'; import visibilityIconsMap from '../utils/visibility-icons-map';
@ -68,10 +67,6 @@ import TranslationBlock from './translation-block';
const SHOW_COMMENT_COUNT_LIMIT = 280; const SHOW_COMMENT_COUNT_LIMIT = 280;
const INLINE_TRANSLATE_LIMIT = 140; const INLINE_TRANSLATE_LIMIT = 140;
const throttle = pThrottle({
limit: 1,
interval: 1000,
});
function fetchAccount(id, masto) { function fetchAccount(id, masto) {
return masto.v1.accounts.$select(id).fetch(); return masto.v1.accounts.$select(id).fetch();
@ -1025,7 +1020,7 @@ function Status({
}, },
); );
const hotkeysEnabled = !readOnly && !previewMode; const hotkeysEnabled = !readOnly && !previewMode && !quoted;
const rRef = useHotkeys('r, shift+r', replyStatus, { const rRef = useHotkeys('r, shift+r', replyStatus, {
enabled: hotkeysEnabled, enabled: hotkeysEnabled,
}); });
@ -1587,34 +1582,34 @@ function Status({
a.removeAttribute('target'); a.removeAttribute('target');
} }
}); });
if (previewMode) return; // if (previewMode) return;
// Unfurl Mastodon links // Unfurl Mastodon links
Array.from( // Array.from(
dom.querySelectorAll( // dom.querySelectorAll(
'a[href]:not(.u-url):not(.mention):not(.hashtag)', // 'a[href]:not(.u-url):not(.mention):not(.hashtag)',
), // ),
) // )
.filter((a) => { // .filter((a) => {
const url = a.href; // const url = a.href;
const isPostItself = // const isPostItself =
url === status.url || url === status.uri; // url === status.url || url === status.uri;
return !isPostItself && isMastodonLinkMaybe(url); // return !isPostItself && isMastodonLinkMaybe(url);
}) // })
.forEach((a, i) => { // .forEach((a, i) => {
unfurlMastodonLink(currentInstance, a.href).then( // unfurlMastodonLink(currentInstance, a.href).then(
(result) => { // (result) => {
if (!result) return; // if (!result) return;
a.removeAttribute('target'); // a.removeAttribute('target');
if (!sKey) return; // if (!sKey) return;
if (!Array.isArray(states.statusQuotes[sKey])) { // if (!Array.isArray(states.statusQuotes[sKey])) {
states.statusQuotes[sKey] = []; // states.statusQuotes[sKey] = [];
} // }
if (!states.statusQuotes[sKey][i]) { // if (!states.statusQuotes[sKey][i]) {
states.statusQuotes[sKey].splice(i, 0, result); // states.statusQuotes[sKey].splice(i, 0, result);
} // }
}, // },
); // );
}); // });
}, },
}), }),
}} }}
@ -1989,6 +1984,20 @@ function Card({ card, selfReferential, instance }) {
if (snapStates.unfurledLinks[url]) return null; if (snapStates.unfurledLinks[url]) return null;
const hasIframeHTML = /<iframe/i.test(html);
const handleClick = useCallback(
(e) => {
if (hasIframeHTML) {
e.preventDefault();
states.showEmbedModal = {
html,
url: url || embedUrl,
};
}
},
[hasIframeHTML],
);
if (hasText && (image || (type === 'photo' && blurhash))) { if (hasText && (image || (type === 'photo' && blurhash))) {
const domain = new URL(url).hostname const domain = new URL(url).hostname
.replace(/^www\./, '') .replace(/^www\./, '')
@ -2021,6 +2030,7 @@ function Card({ card, selfReferential, instance }) {
'--average-color': '--average-color':
rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`,
}} }}
onClick={handleClick}
> >
<div class="card-image"> <div class="card-image">
<img <img
@ -2059,6 +2069,7 @@ function Card({ card, selfReferential, instance }) {
target="_blank" target="_blank"
rel="nofollow noopener noreferrer" rel="nofollow noopener noreferrer"
class="card photo" class="card photo"
onClick={handleClick}
> >
<img <img
src={embedUrl} src={embedUrl}
@ -2073,42 +2084,46 @@ function Card({ card, selfReferential, instance }) {
/> />
</a> </a>
); );
} else if (type === 'video') { } else {
if (/youtube/i.test(providerName)) { if (type === 'video') {
// Get ID from e.g. https://www.youtube.com/watch?v=[VIDEO_ID] if (/youtube/i.test(providerName)) {
const videoID = url.match(/watch\?v=([^&]+)/)?.[1]; // Get ID from e.g. https://www.youtube.com/watch?v=[VIDEO_ID]
if (videoID) { const videoID = url.match(/watch\?v=([^&]+)/)?.[1];
return <lite-youtube videoid={videoID} nocookie></lite-youtube>; if (videoID) {
return <lite-youtube videoid={videoID} nocookie></lite-youtube>;
}
} }
// return (
// <div
// class="card video"
// style={{
// aspectRatio: `${width}/${height}`,
// }}
// dangerouslySetInnerHTML={{ __html: html }}
// />
// );
}
if (hasText && !image) {
const domain = new URL(url).hostname.replace(/^www\./, '');
return (
<a
href={cardStatusURL || url}
target={cardStatusURL ? null : '_blank'}
rel="nofollow noopener noreferrer"
class={`card link no-image`}
lang={language}
onClick={handleClick}
>
<div class="meta-container">
<p class="meta domain">
<Icon icon="link" size="s" /> <span>{domain}</span>
</p>
<p class="title">{title}</p>
<p class="meta">{description || providerName || authorName}</p>
</div>
</a>
);
} }
return (
<div
class="card video"
style={{
aspectRatio: `${width}/${height}`,
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
} else if (hasText && !image) {
const domain = new URL(url).hostname.replace(/^www\./, '');
return (
<a
href={cardStatusURL || url}
target={cardStatusURL ? null : '_blank'}
rel="nofollow noopener noreferrer"
class={`card link no-image`}
lang={language}
>
<div class="meta-container">
<p class="meta domain">
<Icon icon="link" size="s" /> <span>{domain}</span>
</p>
<p class="title">{title}</p>
<p class="meta">{description || providerName || authorName}</p>
</div>
</a>
);
} }
} }
@ -2257,130 +2272,6 @@ export function formatDuration(time) {
} }
} }
const denylistDomains = /(twitter|github)\.com/i;
const failedUnfurls = {};
function _unfurlMastodonLink(instance, url) {
const snapStates = snapshot(states);
if (denylistDomains.test(url)) {
return;
}
if (failedUnfurls[url]) {
return;
}
const instanceRegex = new RegExp(instance + '/');
if (instanceRegex.test(snapStates.unfurledLinks[url]?.url)) {
return Promise.resolve(snapStates.unfurledLinks[url]);
}
console.debug('🦦 Unfurling URL', url);
let remoteInstanceFetch;
let theURL = url;
// https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/elk\.[^\/]+\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/elk\.[^\/]+\//i, '');
}
// https://trunks.social/status/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/trunks\.[^\/]+\/status\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/trunks\.[^\/]+\/status\//i, '');
}
// https://phanpy.social/#/domain.com/s/123 -> https://domain.com/statuses/123
if (/\/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(theURL)) {
const urlAfterHash = theURL.split('/#/')[1];
const finalURL = urlAfterHash.replace(/\/s\//i, '/@fakeUsername/');
theURL = `https://${finalURL}`;
}
let urlObj;
try {
urlObj = new URL(theURL);
} catch (e) {
return;
}
const domain = urlObj.hostname;
const path = urlObj.pathname;
// Regex /:username/:id, where username = @username or @username@domain, id = number
const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i;
const statusMatch = statusRegex.exec(path);
if (statusMatch) {
const id = statusMatch[3];
const { masto } = api({ instance: domain });
remoteInstanceFetch = masto.v1.statuses
.$select(id)
.fetch()
.then((status) => {
if (status?.id) {
return {
status,
instance: domain,
};
} else {
throw new Error('No results');
}
});
}
const { masto } = api({ instance });
const mastoSearchFetch = masto.v2.search
.fetch({
q: theURL,
type: 'statuses',
resolve: true,
limit: 1,
})
.then((results) => {
if (results.statuses.length > 0) {
const status = results.statuses[0];
return {
status,
instance,
};
} else {
throw new Error('No results');
}
});
function handleFulfill(result) {
const { status, instance } = result;
const { id } = status;
const selfURL = `/${instance}/s/${id}`;
console.debug('🦦 Unfurled URL', url, id, selfURL);
const data = {
id,
instance,
url: selfURL,
};
states.unfurledLinks[url] = data;
saveStatus(status, instance, {
skipThreading: true,
});
return data;
}
function handleCatch(e) {
failedUnfurls[url] = true;
}
if (remoteInstanceFetch) {
// return Promise.any([remoteInstanceFetch, mastoSearchFetch])
// .then(handleFulfill)
// .catch(handleCatch);
// If mastoSearchFetch is fulfilled within 3s, return it, else return remoteInstanceFetch
const finalPromise = Promise.race([
mastoSearchFetch,
new Promise((resolve, reject) => setTimeout(reject, 3000)),
]).catch(() => {
// If remoteInstanceFetch is fullfilled, return it, else return mastoSearchFetch
return remoteInstanceFetch.catch(() => mastoSearchFetch);
});
return finalPromise.then(handleFulfill).catch(handleCatch);
} else {
return mastoSearchFetch.then(handleFulfill).catch(handleCatch);
}
}
function nicePostURL(url) { function nicePostURL(url) {
if (!url) return; if (!url) return;
const urlObj = new URL(url); const urlObj = new URL(url);
@ -2404,8 +2295,6 @@ function nicePostURL(url) {
); );
} }
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
function FilteredStatus({ function FilteredStatus({
status, status,
filterInfo, filterInfo,
@ -2592,4 +2481,12 @@ const QuoteStatuses = memo(({ id, instance, level = 0 }) => {
}); });
}); });
export default memo(Status); export default memo(Status, (oldProps, newProps) => {
// Shallow equal all props except 'status'
// This will be pure static until status ID changes
const { status, ...restOldProps } = oldProps;
const { status: newStatus, ...restNewProps } = newProps;
return (
status?.id === newStatus?.id && shallowEqual(restOldProps, restNewProps)
);
});

View file

@ -12,6 +12,7 @@ import { groupBoosts, groupContext } from '../utils/timeline-utils';
import useInterval from '../utils/useInterval'; import useInterval from '../utils/useInterval';
import usePageVisibility from '../utils/usePageVisibility'; import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll'; import useScroll from '../utils/useScroll';
import useScrollFn from '../utils/useScrollFn';
import Icon from './icon'; import Icon from './icon';
import Link from './link'; import Link from './link';
@ -203,17 +204,48 @@ function Timeline({
} }
}); });
const { // const {
scrollDirection, // scrollDirection,
nearReachStart, // nearReachStart,
nearReachEnd, // nearReachEnd,
reachStart, // reachStart,
reachEnd, // reachEnd,
} = useScroll({ // } = useScroll({
scrollableRef, // scrollableRef,
distanceFromEnd: 2, // distanceFromEnd: 2,
scrollThresholdStart: 44, // scrollThresholdStart: 44,
}); // });
const headerRef = useRef();
// const [hiddenUI, setHiddenUI] = useState(false);
const [nearReachStart, setNearReachStart] = useState(false);
useScrollFn(
{
scrollableRef,
distanceFromEnd: 2,
scrollThresholdStart: 44,
},
({
scrollDirection,
nearReachStart,
nearReachEnd,
reachStart,
reachEnd,
}) => {
// setHiddenUI(scrollDirection === 'end' && !nearReachEnd);
if (headerRef.current) {
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
headerRef.current.hidden = hiddenUI;
}
setNearReachStart(nearReachStart);
if (reachStart) {
loadItems(true);
}
// else if (nearReachEnd || (reachEnd && showMore)) {
// loadItems();
// }
},
[],
);
useEffect(() => { useEffect(() => {
scrollableRef.current?.scrollTo({ top: 0 }); scrollableRef.current?.scrollTo({ top: 0 });
@ -223,17 +255,17 @@ function Timeline({
loadItems(true); loadItems(true);
}, [refresh]); }, [refresh]);
useEffect(() => { // useEffect(() => {
if (reachStart) { // if (reachStart) {
loadItems(true); // loadItems(true);
} // }
}, [reachStart]); // }, [reachStart]);
useEffect(() => { // useEffect(() => {
if (nearReachEnd || (reachEnd && showMore)) { // if (nearReachEnd || (reachEnd && showMore)) {
loadItems(); // loadItems();
} // }
}, [nearReachEnd, showMore]); // }, [nearReachEnd, showMore]);
const prevView = useRef(view); const prevView = useRef(view);
useEffect(() => { useEffect(() => {
@ -304,7 +336,7 @@ function Timeline({
: null, : null,
); );
const hiddenUI = scrollDirection === 'end' && !nearReachStart; // const hiddenUI = scrollDirection === 'end' && !nearReachStart;
return ( return (
<FilterContext.Provider value={filterContext}> <FilterContext.Provider value={filterContext}>
@ -321,7 +353,8 @@ function Timeline({
> >
<div class="timeline-deck deck"> <div class="timeline-deck deck">
<header <header
hidden={hiddenUI} ref={headerRef}
// hidden={hiddenUI}
onClick={(e) => { onClick={(e) => {
if (!e.target.closest('a, button')) { if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({ scrollableRef.current?.scrollTo({
@ -356,7 +389,7 @@ function Timeline({
</div> </div>
{items.length > 0 && {items.length > 0 &&
uiState !== 'loading' && uiState !== 'loading' &&
!hiddenUI && // !hiddenUI &&
showNew && ( showNew && (
<button <button
class="updates-button shiny-pill" class="updates-button shiny-pill"
@ -419,6 +452,8 @@ function Timeline({
{uiState === 'default' && {uiState === 'default' &&
(showMore ? ( (showMore ? (
<InView <InView
root={scrollableRef.current}
rootMargin={`0px 0px ${screen.height * 1.5}px 0px`}
onChange={(inView) => { onChange={(inView) => {
if (inView) { if (inView) {
loadItems(); loadItems();
@ -657,12 +692,33 @@ function TimelineItem({
function StatusCarousel({ title, class: className, children }) { function StatusCarousel({ title, class: className, children }) {
const carouselRef = useRef(); const carouselRef = useRef();
const { reachStart, reachEnd, init } = useScroll({ // const { reachStart, reachEnd, init } = useScroll({
scrollableRef: carouselRef, // scrollableRef: carouselRef,
direction: 'horizontal', // direction: 'horizontal',
}); // });
const startButtonRef = useRef();
const endButtonRef = useRef();
// useScrollFn(
// {
// scrollableRef: carouselRef,
// direction: 'horizontal',
// init: true,
// },
// ({ reachStart, reachEnd }) => {
// if (startButtonRef.current) startButtonRef.current.disabled = reachStart;
// if (endButtonRef.current) endButtonRef.current.disabled = reachEnd;
// },
// [],
// );
// useEffect(() => {
// init?.();
// }, []);
const [render, setRender] = useState(false);
useEffect(() => { useEffect(() => {
init?.(); setTimeout(() => {
setRender(true);
}, 1);
}, []); }, []);
return ( return (
@ -671,9 +727,10 @@ function StatusCarousel({ title, class: className, children }) {
<h3>{title}</h3> <h3>{title}</h3>
<span> <span>
<button <button
ref={startButtonRef}
type="button" type="button"
class="small plain2" class="small plain2"
disabled={reachStart} // disabled={reachStart}
onClick={() => { onClick={() => {
carouselRef.current?.scrollBy({ carouselRef.current?.scrollBy({
left: -Math.min(320, carouselRef.current?.offsetWidth), left: -Math.min(320, carouselRef.current?.offsetWidth),
@ -684,9 +741,10 @@ function StatusCarousel({ title, class: className, children }) {
<Icon icon="chevron-left" /> <Icon icon="chevron-left" />
</button>{' '} </button>{' '}
<button <button
ref={endButtonRef}
type="button" type="button"
class="small plain2" class="small plain2"
disabled={reachEnd} // disabled={reachEnd}
onClick={() => { onClick={() => {
carouselRef.current?.scrollBy({ carouselRef.current?.scrollBy({
left: Math.min(320, carouselRef.current?.offsetWidth), left: Math.min(320, carouselRef.current?.offsetWidth),
@ -698,7 +756,23 @@ function StatusCarousel({ title, class: className, children }) {
</button> </button>
</span> </span>
</header> </header>
<ul ref={carouselRef}>{children}</ul> <ul ref={carouselRef}>
<InView
class="status-carousel-beacon"
onChange={(inView) => {
if (startButtonRef.current)
startButtonRef.current.disabled = inView;
}}
/>
{children[0]}
{render && children.slice(1)}
<InView
class="status-carousel-beacon"
onChange={(inView) => {
if (endButtonRef.current) endButtonRef.current.disabled = inView;
}}
/>
</ul>
</div> </div>
); );
} }

View file

@ -32,6 +32,7 @@
--text-color: #1c1e21; --text-color: #1c1e21;
--text-insignificant-color: #1c1e2199; --text-insignificant-color: #1c1e2199;
--link-color: var(--blue-color); --link-color: var(--blue-color);
--link-bg-color: #4169e122;
--link-light-color: #4169e199; --link-light-color: #4169e199;
--link-faded-color: #4169e155; --link-faded-color: #4169e155;
--link-bg-hover-color: #f0f2f599; --link-bg-hover-color: #f0f2f599;
@ -89,6 +90,7 @@
--media-outline-color: color-mix(in lch, var(--media-fg-color), transparent); --media-outline-color: color-mix(in lch, var(--media-fg-color), transparent);
--timing-function: cubic-bezier(0.3, 0.5, 0, 1); --timing-function: cubic-bezier(0.3, 0.5, 0, 1);
--spring-timing-funtion: cubic-bezier(0.175, 0.885, 0.32, 1.275);
} }
@media (min-resolution: 2dppx) { @media (min-resolution: 2dppx) {
@ -447,6 +449,17 @@ kbd {
} }
} }
@keyframes slide-up-smooth {
0% {
opacity: 0;
transform: translateY(100%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes position-object { @keyframes position-object {
0% { 0% {
object-position: 50% 50%; object-position: 50% 50%;

View file

@ -377,14 +377,14 @@ function AccountStatuses() {
} }
: {}, : {},
); );
const [year, month] = value.split('-');
const monthIndex = parseInt(month, 10) - 1;
const date = new Date(year, monthIndex);
showToast( showToast(
`Showing posts in ${new Date(value).toLocaleString( `Showing posts in ${date.toLocaleString('default', {
'default', month: 'long',
{ year: 'numeric',
month: 'long', })}`,
year: 'numeric',
},
)}`,
); );
}} }}
/> />

View file

@ -164,7 +164,7 @@ function Hashtags({ media: mediaView, columnMode, ...props }) {
portal portal
setDownOverflow setDownOverflow
overflow="auto" overflow="auto"
viewScroll="close" // viewScroll="close"
position="anchor" position="anchor"
menuButton={ menuButton={
<button type="button" class="plain"> <button type="button" class="plain">

View file

@ -37,16 +37,20 @@ export default function HttpRoute() {
const { masto: currentMasto, instance: currentInstance } = api(); const { masto: currentMasto, instance: currentInstance } = api();
const result = await currentMasto.v2.search.fetch({ const result = await currentMasto.v2.search.fetch({
q: url, q: url,
type: 'statuses',
limit: 1, limit: 1,
resolve: true, resolve: true,
}); });
if (result.statuses.length) { if (result.statuses.length) {
const status = result.statuses[0]; const status = result.statuses[0];
window.location.hash = `/${currentInstance}/s/${status.id}?view=full`; window.location.hash = `/${currentInstance}/s/${status.id}?view=full`;
} else { } else if (result.accounts.length) {
const account = result.accounts[0];
window.location.hash = `/${currentInstance}/a/${account.id}`;
} else if (statusURL) {
// Fallback to original URL, which will probably show error // Fallback to original URL, which will probably show error
window.location.hash = statusURL + '?view=full'; window.location.hash = statusURL + '?view=full';
} else {
setUIState('error');
} }
} }
})(); })();

View file

@ -115,22 +115,25 @@ ul.link-list.hashtag-list li a {
display: none; display: none;
} }
.search-popover-item:is(:hover, :focus, .focus) { .search-popover-item:is(:hover, :focus, .focus) {
background-color: var(--button-bg-color); background-color: var(--link-bg-color);
color: var(--button-text-color); color: var(--text-color);
}
.search-popover-item:is(:focus, .focus) {
box-shadow: inset 4px 0 0 0 var(--button-bg-color);
} }
.search-popover-item :is(mark, q) { .search-popover-item :is(mark, q) {
background-color: var(--bg-faded-blur-color); color: var(--text-color);
color: inherit; background-color: var(--link-bg-color);
} }
.search-popover-item:is(:hover, :focus, .focus) :is(mark, q) { .search-popover-item:is(:hover, :focus, .focus) :is(mark, q) {
background-color: var(--button-bg-color); background-color: var(--link-bg-color);
} }
.search-popover:hover .search-popover-item.focus:not(:hover, :focus), .search-popover:hover .search-popover-item.focus:not(:hover, :focus),
.search-popover:hover .search-popover:hover
.search-popover-item.focus:not(:hover, :focus) .search-popover-item.focus:not(:hover, :focus)
:is(mark, q) { :is(mark, q) {
background-color: unset; /* background-color: unset; */
color: unset; /* color: unset; */
} }
.search-popover-item > span { .search-popover-item > span {
min-width: 0; min-width: 0;

View file

@ -17,7 +17,6 @@ import { api } from '../utils/api';
import { fetchRelationships } from '../utils/relationships'; import { fetchRelationships } from '../utils/relationships';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import usePageVisibility from '../utils/usePageVisibility'; import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const SHORT_LIMIT = 5; const SHORT_LIMIT = 5;
@ -151,11 +150,9 @@ function Search({ columnMode, ...props }) {
})(); })();
} }
const { reachStart } = useScroll({
scrollableRef,
});
const lastHiddenTime = useRef(); const lastHiddenTime = useRef();
usePageVisibility((visible) => { usePageVisibility((visible) => {
const reachStart = scrollableRef.current?.scrollTop === 0;
if (visible && reachStart) { if (visible && reachStart) {
const timeDiff = Date.now() - lastHiddenTime.current; const timeDiff = Date.now() - lastHiddenTime.current;
if (!lastHiddenTime.current || timeDiff > 1000 * 3) { if (!lastHiddenTime.current || timeDiff > 1000 * 3) {

View file

@ -7,15 +7,12 @@
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
align-self: stretch;
} }
.status-deck header h1 .deck-back { .status-deck header h1 .deck-back {
margin-left: -16px; margin-left: -16px;
} }
.status-deck header.inview h1 {
font-weight: bold;
}
.hero-heading { .hero-heading {
font-size: var(--text-size); font-size: var(--text-size);
display: inline-block; display: inline-block;
@ -36,7 +33,7 @@
} }
} }
.ancestors-indicator:not([hidden]) { .ancestors-indicator:not([hidden]) {
animation: slide-up 0.3s both ease-out 0.3s; animation: slide-up-smooth 0.3s both var(--spring-timing-funtion) 0.3s;
} }
.ancestors-indicator[hidden] { .ancestors-indicator[hidden] {
opacity: 0; opacity: 0;

View file

@ -184,6 +184,15 @@ function StatusPage(params) {
); );
} }
function StatusParent(props) {
const { linkable, to, onClick, ...restProps } = props;
return linkable ? (
<Link class="status-link" to={to} onClick={onClick} {...restProps} />
) : (
<div class="status-focus" tabIndex={0} {...restProps} />
);
}
function StatusThread({ id, closeLink = '/', instance: propInstance }) { function StatusThread({ id, closeLink = '/', instance: propInstance }) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const mediaParam = searchParams.get('media'); const mediaParam = searchParams.get('media');
@ -429,7 +438,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}; };
useEffect(initContext, [id, masto]); useEffect(initContext, [id, masto]);
useEffect(() => { useLayoutEffect(() => {
if (!statuses.length) return; if (!statuses.length) return;
console.debug('STATUSES', statuses); console.debug('STATUSES', statuses);
const scrollPosition = scrollPositions[id]; const scrollPosition = scrollPositions[id];
@ -545,7 +554,6 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
const ancestors = statuses.filter((s) => s.ancestor); const ancestors = statuses.filter((s) => s.ancestor);
const [heroInView, setHeroInView] = useState(true); const [heroInView, setHeroInView] = useState(true);
const onView = useDebouncedCallback(setHeroInView, 100);
const heroPointer = useMemo(() => { const heroPointer = useMemo(() => {
// get top offset of heroStatus // get top offset of heroStatus
if (!heroStatusRef.current || heroInView) return null; if (!heroStatusRef.current || heroInView) return null;
@ -652,10 +660,11 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
} }
}); });
const { nearReachStart } = useScroll({ const [reachTopPost, setReachTopPost] = useState(false);
scrollableRef, // const { nearReachStart } = useScroll({
distanceFromStartPx: 16, // scrollableRef,
}); // distanceFromStartPx: 16,
// });
const initialPageState = useRef(showMedia ? 'media+status' : 'status'); const initialPageState = useRef(showMedia ? 'media+status' : 'status');
@ -693,7 +702,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}, [mediaStatusID, showMedia]); }, [mediaStatusID, showMedia]);
const renderStatus = useCallback( const renderStatus = useCallback(
(status) => { (status, i) => {
const { const {
id: statusID, id: statusID,
ancestor, ancestor,
@ -705,24 +714,8 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
weight, weight,
} = status; } = status;
const isHero = statusID === id; const isHero = statusID === id;
// const StatusParent = useCallback( const isLinkable = isThread || ancestor;
// (props) =>
// isThread || thread || ancestor ? (
// <Link
// class="status-link"
// to={
// instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
// }
// onClick={() => {
// resetScrollPosition(statusID);
// }}
// {...props}
// />
// ) : (
// <div class="status-focus" tabIndex={0} {...props} />
// ),
// [isThread, thread],
// );
return ( return (
<li <li
key={statusID} key={statusID}
@ -735,7 +728,13 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
<> <>
<InView <InView
threshold={0.1} threshold={0.1}
onChange={onView} onChange={(inView) => {
queueMicrotask(() => {
requestAnimationFrame(() => {
setHeroInView(inView);
});
});
}}
class="status-focus" class="status-focus"
tabIndex={0} tabIndex={0}
> >
@ -802,23 +801,52 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
)} )}
</> </>
) : ( ) : (
// <StatusParent> <StatusParent
<Link linkable={isLinkable}
class="status-link"
to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`} to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`}
onClick={() => { onClick={() => {
resetScrollPosition(statusID); resetScrollPosition(statusID);
}} }}
> >
<Status {/* <Link
statusID={statusID} class="status-link"
instance={instance} to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`}
withinContext onClick={() => {
size={thread || ancestor ? 'm' : 's'} resetScrollPosition(statusID);
enableTranslate }}
onMediaClick={handleMediaClick} > */}
onStatusLinkClick={handleStatusLinkClick} {i === 0 && ancestor ? (
/> <InView
threshold={0.5}
onChange={(inView) => {
queueMicrotask(() => {
requestAnimationFrame(() => {
setReachTopPost(inView);
});
});
}}
>
<Status
statusID={statusID}
instance={instance}
withinContext
size={thread || ancestor ? 'm' : 's'}
enableTranslate
onMediaClick={handleMediaClick}
onStatusLinkClick={handleStatusLinkClick}
/>
</InView>
) : (
<Status
statusID={statusID}
instance={instance}
withinContext
size={thread || ancestor ? 'm' : 's'}
enableTranslate
onMediaClick={handleMediaClick}
onStatusLinkClick={handleStatusLinkClick}
/>
)}
{ancestor && repliesCount > 1 && ( {ancestor && repliesCount > 1 && (
<div class="replies-link"> <div class="replies-link">
<Icon icon="comment2" />{' '} <Icon icon="comment2" />{' '}
@ -835,8 +863,8 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
</span> </span>
</div> </div>
)} */} )} */}
{/* </StatusParent> */} </StatusParent>
</Link> // </Link>
)} )}
{descendant && replies?.length > 0 && ( {descendant && replies?.length > 0 && (
<SubComments <SubComments
@ -846,6 +874,10 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
level={1} level={1}
accWeight={weight} accWeight={weight}
openAll={totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT} openAll={totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT}
parentLink={{
to: instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`,
onClick: () => resetScrollPosition(statusID),
}}
/> />
)} )}
{uiState === 'loading' && {uiState === 'loading' &&
@ -901,6 +933,24 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
return STATUS_URL_REGEX.test(states.prevLocation?.pathname); return STATUS_URL_REGEX.test(states.prevLocation?.pathname);
}, [sKey]); }, [sKey]);
const moreStatusesKeys = useMemo(() => {
if (!showMore) return [];
const ids = [];
function getIDs(status) {
ids.push(status.id);
if (status.replies) {
status.replies.forEach(getIDs);
}
}
statuses.slice(limit).forEach(getIDs);
return ids.map((id) => statusKey(id, instance));
}, [showMore, statuses, limit, instance]);
const statusesList = useMemo(
() => statuses.slice(0, limit).map(renderStatus),
[statuses, limit, renderStatus],
);
return ( return (
<div <div
tabIndex="-1" tabIndex="-1"
@ -922,9 +972,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
}} }}
> >
<header <header
class={`${heroInView ? 'inview' : ''} ${ class={`${uiState === 'loading' ? 'loading' : ''}`}
uiState === 'loading' ? 'loading' : ''
}`}
onDblClick={(e) => { onDblClick={(e) => {
// reload statuses // reload statuses
states.reloadStatusPage++; states.reloadStatusPage++;
@ -998,7 +1046,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
behavior: 'smooth', behavior: 'smooth',
}); });
}} }}
hidden={!ancestors.length || nearReachStart} hidden={!ancestors.length || reachTopPost}
title={`${ancestors.length} posts above Go to top`} title={`${ancestors.length} posts above Go to top`}
> >
<Icon icon="arrow-up" /> <Icon icon="arrow-up" />
@ -1147,7 +1195,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
uiState === 'loading' ? 'loading' : '' uiState === 'loading' ? 'loading' : ''
}`} }`}
> >
{statuses.slice(0, limit).map(renderStatus)} {statusesList}
{showMore > 0 && ( {showMore > 0 && (
<li> <li>
<button <button
@ -1156,10 +1204,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
disabled={uiState === 'loading'} disabled={uiState === 'loading'}
onClick={() => setLimit((l) => l + LIMIT)} onClick={() => setLimit((l) => l + LIMIT)}
style={{ marginBlockEnd: '6em' }} style={{ marginBlockEnd: '6em' }}
data-state-post-ids={statuses data-state-post-ids={moreStatusesKeys.join(' ')}
.slice(limit)
.map((s) => statusKey(s.id, instance))
.join(' ')}
> >
<div class="ib avatars-bunch"> <div class="ib avatars-bunch">
{/* show avatars for first 5 statuses */} {/* show avatars for first 5 statuses */}
@ -1218,6 +1263,7 @@ function SubComments({
level, level,
accWeight, accWeight,
openAll, openAll,
parentLink,
}) { }) {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -1304,34 +1350,49 @@ function SubComments({
/> />
))} ))}
</span> </span>
<b> <span class="replies-counts">
<span title={replies.length}>{shortenNumber(replies.length)}</span>{' '} <b>
repl <span title={replies.length}>{shortenNumber(replies.length)}</span>{' '}
{replies.length === 1 ? 'y' : 'ies'} repl
</b> {replies.length === 1 ? 'y' : 'ies'}
{!sameCount && totalComments > 1 && ( </b>
<> {!sameCount && totalComments > 1 && (
{' '} <>
&middot;{' '} {' '}
<span> &middot;{' '}
<span title={totalComments}>{shortenNumber(totalComments)}</span>{' '} <span>
comment <span title={totalComments}>
{totalComments === 1 ? '' : 's'} {shortenNumber(totalComments)}
</span> </span>{' '}
</> comment
{totalComments === 1 ? '' : 's'}
</span>
</>
)}
</span>
<Icon icon="chevron-down" class="replies-summary-chevron" />
{!!parentLink && (
<Link
class="replies-parent-link"
to={parentLink.to}
onClick={parentLink.onClick}
title="View post with its replies"
>
&raquo;
</Link>
)} )}
</summary> </summary>
<ul> <ul>
{replies.map((r) => ( {replies.map((r) => (
<li key={r.id}> <li key={r.id}>
<Link {/* <Link
class="status-link" class="status-link"
to={instance ? `/${instance}/s/${r.id}` : `/s/${r.id}`} to={instance ? `/${instance}/s/${r.id}` : `/s/${r.id}`}
onClick={() => { onClick={() => {
resetScrollPosition(r.id); resetScrollPosition(r.id);
}} }}
> > */}
{/* <div class="status-focus" tabIndex={0}> */} <div class="status-focus" tabIndex={0}>
<Status <Status
statusID={r.id} statusID={r.id}
instance={instance} instance={instance}
@ -1348,8 +1409,8 @@ function SubComments({
</span> </span>
</div> </div>
)} )}
{/* </div> */} </div>
</Link> {/* </Link> */}
{r.replies?.length && ( {r.replies?.length && (
<SubComments <SubComments
instance={instance} instance={instance}
@ -1357,6 +1418,12 @@ function SubComments({
level={level + 1} level={level + 1}
accWeight={!open ? r.weight : totalWeight} accWeight={!open ? r.weight : totalWeight}
openAll={openAll} openAll={openAll}
parentLink={{
to: instance ? `/${instance}/s/${r.id}` : `/s/${r.id}`,
onClick: () => {
resetScrollPosition(r.id);
},
}}
/> />
)} )}
</li> </li>

View file

@ -22,7 +22,7 @@ export function getInstanceStatusObject(url) {
}; };
} }
} }
return null; return {};
} }
function getInstanceStatusURL(url) { function getInstanceStatusURL(url) {

View file

@ -1,10 +1,13 @@
import { deepEqual } from 'fast-equals';
import { proxy, subscribe } from 'valtio'; import { proxy, subscribe } from 'valtio';
import { subscribeKey } from 'valtio/utils'; import { subscribeKey } from 'valtio/utils';
import { api } from './api'; import { api } from './api';
import isMastodonLinkMaybe from './isMastodonLinkMaybe';
import pmem from './pmem'; import pmem from './pmem';
import rateLimit from './ratelimit'; import rateLimit from './ratelimit';
import store from './store'; import store from './store';
import unfurlMastodonLink from './unfurl-link';
const states = proxy({ const states = proxy({
appVersion: {}, appVersion: {},
@ -47,6 +50,7 @@ const states = proxy({
showKeyboardShortcutsHelp: false, showKeyboardShortcutsHelp: false,
showGenericAccounts: false, showGenericAccounts: false,
showMediaAlt: false, showMediaAlt: false,
showEmbedModal: false,
// Shortcuts // Shortcuts
shortcuts: [], shortcuts: [],
// Settings // Settings
@ -148,6 +152,7 @@ export function hideAllModals() {
states.showKeyboardShortcutsHelp = false; states.showKeyboardShortcutsHelp = false;
states.showGenericAccounts = false; states.showGenericAccounts = false;
states.showMediaAlt = false; states.showMediaAlt = false;
states.showEmbedModal = false;
} }
export function statusKey(id, instance) { export function statusKey(id, instance) {
@ -168,13 +173,15 @@ export function saveStatus(status, instance, opts) {
opts = instance; opts = instance;
instance = null; instance = null;
} }
const { override, skipThreading } = Object.assign( const {
{ override: true, skipThreading: false }, override = true,
opts, skipThreading = false,
); skipUnfurling = false,
} = opts || {};
if (!status) return; if (!status) return;
const oldStatus = getStatus(status.id, instance); const oldStatus = getStatus(status.id, instance);
if (!override && oldStatus) return; if (!override && oldStatus) return;
if (deepEqual(status, oldStatus)) return;
queueMicrotask(() => { queueMicrotask(() => {
const key = statusKey(status.id, instance); const key = statusKey(status.id, instance);
if (oldStatus?._pinned) status._pinned = oldStatus._pinned; if (oldStatus?._pinned) status._pinned = oldStatus._pinned;
@ -189,12 +196,14 @@ export function saveStatus(status, instance, opts) {
// THREAD TRAVERSER // THREAD TRAVERSER
if (!skipThreading) { if (!skipThreading) {
queueMicrotask(() => { queueMicrotask(() => {
threadifyStatus(status, instance); threadifyStatus(status.reblog || status, instance);
if (status.reblog) { });
queueMicrotask(() => { }
threadifyStatus(status.reblog, instance);
}); // UNFURLER
} if (!skipUnfurling) {
queueMicrotask(() => {
unfurlStatus(status.reblog || status, instance);
}); });
} }
} }
@ -240,6 +249,38 @@ function _threadifyStatus(status, propInstance) {
} }
export const threadifyStatus = rateLimit(_threadifyStatus, 100); export const threadifyStatus = rateLimit(_threadifyStatus, 100);
const fauxDiv = document.createElement('div');
export function unfurlStatus(status, instance) {
const { instance: currentInstance } = api();
const content = status?.content;
const hasLink = /<a/i.test(content);
if (hasLink) {
const sKey = statusKey(status?.id, instance);
fauxDiv.innerHTML = content;
const links = fauxDiv.querySelectorAll(
'a[href]:not(.u-url):not(.mention):not(.hashtag)',
);
[...links]
.filter((a) => {
const url = a.href;
const isPostItself = url === status.url || url === status.uri;
return !isPostItself && isMastodonLinkMaybe(url);
})
.forEach((a, i) => {
unfurlMastodonLink(currentInstance, a.href).then((result) => {
if (!result) return;
if (!sKey) return;
if (!Array.isArray(states.statusQuotes[sKey])) {
states.statusQuotes[sKey] = [];
}
if (!states.statusQuotes[sKey][i]) {
states.statusQuotes[sKey].splice(i, 0, result);
}
});
});
}
}
const fetchStatus = pmem((statusID, masto) => { const fetchStatus = pmem((statusID, masto) => {
return masto.v1.statuses.$select(statusID).fetch(); return masto.v1.statuses.$select(statusID).fetch();
}); });

136
src/utils/unfurl-link.jsx Normal file
View file

@ -0,0 +1,136 @@
import pThrottle from 'p-throttle';
import { snapshot } from 'valtio/vanilla';
import { api } from './api';
import states, { saveStatus } from './states';
export const throttle = pThrottle({
limit: 1,
interval: 1000,
});
const denylistDomains = /(twitter|github)\.com/i;
const failedUnfurls = {};
function _unfurlMastodonLink(instance, url) {
const snapStates = snapshot(states);
if (denylistDomains.test(url)) {
return;
}
if (failedUnfurls[url]) {
return;
}
const instanceRegex = new RegExp(instance + '/');
if (instanceRegex.test(snapStates.unfurledLinks[url]?.url)) {
return Promise.resolve(snapStates.unfurledLinks[url]);
}
console.debug('🦦 Unfurling URL', url);
let remoteInstanceFetch;
let theURL = url;
// https://elk.zone/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/elk\.[^\/]+\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/elk\.[^\/]+\//i, '');
}
// https://trunks.social/status/domain.com/@stest/123 -> https://domain.com/@stest/123
if (/\/\/trunks\.[^\/]+\/status\/[^\/]+\.[^\/]+/i.test(theURL)) {
theURL = theURL.replace(/trunks\.[^\/]+\/status\//i, '');
}
// https://phanpy.social/#/domain.com/s/123 -> https://domain.com/statuses/123
if (/\/#\/[^\/]+\.[^\/]+\/s\/.+/i.test(theURL)) {
const urlAfterHash = theURL.split('/#/')[1];
const finalURL = urlAfterHash.replace(/\/s\//i, '/@fakeUsername/');
theURL = `https://${finalURL}`;
}
let urlObj;
try {
urlObj = new URL(theURL);
} catch (e) {
return;
}
const domain = urlObj.hostname;
const path = urlObj.pathname;
// Regex /:username/:id, where username = @username or @username@domain, id = number
const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/(\d+)$/i;
const statusMatch = statusRegex.exec(path);
if (statusMatch) {
const id = statusMatch[3];
const { masto } = api({ instance: domain });
remoteInstanceFetch = masto.v1.statuses
.$select(id)
.fetch()
.then((status) => {
if (status?.id) {
return {
status,
instance: domain,
};
} else {
throw new Error('No results');
}
});
}
const { masto } = api({ instance });
const mastoSearchFetch = masto.v2.search
.fetch({
q: theURL,
type: 'statuses',
resolve: true,
limit: 1,
})
.then((results) => {
if (results.statuses.length > 0) {
const status = results.statuses[0];
return {
status,
instance,
};
} else {
throw new Error('No results');
}
});
function handleFulfill(result) {
const { status, instance } = result;
const { id } = status;
const selfURL = `/${instance}/s/${id}`;
console.debug('🦦 Unfurled URL', url, id, selfURL);
const data = {
id,
instance,
url: selfURL,
};
states.unfurledLinks[url] = data;
saveStatus(status, instance, {
skipThreading: true,
});
return data;
}
function handleCatch(e) {
failedUnfurls[url] = true;
}
if (remoteInstanceFetch) {
// return Promise.any([remoteInstanceFetch, mastoSearchFetch])
// .then(handleFulfill)
// .catch(handleCatch);
// If mastoSearchFetch is fulfilled within 3s, return it, else return remoteInstanceFetch
const finalPromise = Promise.race([
mastoSearchFetch,
new Promise((resolve, reject) => setTimeout(reject, 3000)),
]).catch(() => {
// If remoteInstanceFetch is fullfilled, return it, else return mastoSearchFetch
return remoteInstanceFetch.catch(() => mastoSearchFetch);
});
return finalPromise.then(handleFulfill).catch(handleCatch);
} else {
return mastoSearchFetch.then(handleFulfill).catch(handleCatch);
}
}
const unfurlMastodonLink = throttle(_unfurlMastodonLink);
export default unfurlMastodonLink;

130
src/utils/useScrollFn.js Normal file
View file

@ -0,0 +1,130 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { useThrottledCallback } from 'use-debounce';
export default function useScrollFn(
{
scrollableRef,
distanceFromStart = 1, // ratio of clientHeight/clientWidth
distanceFromEnd = 1, // ratio of clientHeight/clientWidth
scrollThresholdStart = 10,
scrollThresholdEnd = 10,
direction = 'vertical',
distanceFromStartPx: _distanceFromStartPx,
distanceFromEndPx: _distanceFromEndPx,
init,
} = {},
callback,
deps,
) {
if (!callback) return;
const [scrollDirection, setScrollDirection] = useState(null);
const [reachStart, setReachStart] = useState(false);
const [reachEnd, setReachEnd] = useState(false);
const [nearReachStart, setNearReachStart] = useState(false);
const [nearReachEnd, setNearReachEnd] = useState(false);
const isVertical = direction === 'vertical';
const previousScrollStart = useRef(null);
const onScroll = useThrottledCallback(() => {
const scrollableElement = scrollableRef.current;
const {
scrollTop,
scrollLeft,
scrollHeight,
scrollWidth,
clientHeight,
clientWidth,
} = scrollableElement;
const scrollStart = isVertical ? scrollTop : scrollLeft;
const scrollDimension = isVertical ? scrollHeight : scrollWidth;
const clientDimension = isVertical ? clientHeight : clientWidth;
const scrollDistance = Math.abs(scrollStart - previousScrollStart.current);
const distanceFromStartPx =
_distanceFromStartPx ||
Math.min(
clientDimension * distanceFromStart,
scrollDimension,
scrollStart,
);
const distanceFromEndPx =
_distanceFromEndPx ||
Math.min(
clientDimension * distanceFromEnd,
scrollDimension,
scrollDimension - scrollStart - clientDimension,
);
if (
scrollDistance >=
(previousScrollStart.current < scrollStart
? scrollThresholdEnd
: scrollThresholdStart)
) {
setScrollDirection(
previousScrollStart.current < scrollStart ? 'end' : 'start',
);
previousScrollStart.current = scrollStart;
}
setReachStart(scrollStart <= 0);
setReachEnd(scrollStart + clientDimension >= scrollDimension);
setNearReachStart(scrollStart <= distanceFromStartPx);
setNearReachEnd(
scrollStart + clientDimension >= scrollDimension - distanceFromEndPx,
);
}, 500);
useLayoutEffect(() => {
const scrollableElement = scrollableRef.current;
if (!scrollableElement) return {};
previousScrollStart.current =
scrollableElement[isVertical ? 'scrollTop' : 'scrollLeft'];
scrollableElement.addEventListener('scroll', onScroll, { passive: true });
return () => scrollableElement.removeEventListener('scroll', onScroll);
}, [
distanceFromStart,
distanceFromEnd,
scrollThresholdStart,
scrollThresholdEnd,
]);
useEffect(() => {
callback({
scrollDirection,
reachStart,
reachEnd,
nearReachStart,
nearReachEnd,
});
}, [
scrollDirection,
reachStart,
reachEnd,
nearReachStart,
nearReachEnd,
...deps,
]);
useEffect(() => {
if (init && scrollableRef.current) {
queueMicrotask(() => {
scrollableRef.current.dispatchEvent(new Event('scroll'));
});
}
}, [init]);
// return {
// scrollDirection,
// reachStart,
// reachEnd,
// nearReachStart,
// nearReachEnd,
// init: () => {
// if (scrollableRef.current) {
// scrollableRef.current.dispatchEvent(new Event('scroll'));
// }
// },
// };
}

View file

@ -9,11 +9,12 @@ import htmlPlugin from 'vite-plugin-html-config';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import removeConsole from 'vite-plugin-remove-console'; import removeConsole from 'vite-plugin-remove-console';
const allowedEnvPrefixes = ['VITE_', 'PHANPY_'];
const { NODE_ENV } = process.env; const { NODE_ENV } = process.env;
const { const {
PHANPY_CLIENT_NAME: CLIENT_NAME, PHANPY_CLIENT_NAME: CLIENT_NAME,
PHANPY_APP_ERROR_LOGGING: ERROR_LOGGING, PHANPY_APP_ERROR_LOGGING: ERROR_LOGGING,
} = loadEnv('production', process.cwd()); } = loadEnv('production', process.cwd(), allowedEnvPrefixes);
const now = new Date(); const now = new Date();
let commitHash; let commitHash;
@ -35,7 +36,7 @@ const rollbarCode = fs.readFileSync(
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
base: './', base: './',
envPrefix: ['VITE_', 'PHANPY_'], envPrefix: allowedEnvPrefixes,
mode: NODE_ENV, mode: NODE_ENV,
define: { define: {
__BUILD_TIME__: JSON.stringify(now), __BUILD_TIME__: JSON.stringify(now),