Compare commits
49 commits
96f1a9d9f2
...
2b7969f9ec
Author | SHA1 | Date | |
---|---|---|---|
Natsu Kagami | 2b7969f9ec | ||
4c4e89ac9d | |||
4da968df2e | |||
c6f368ac0b | |||
87e243ea58 | |||
66f9c3b918 | |||
137ad7f4dd | |||
8ddc44fba6 | |||
3721acf3d3 | |||
ab7df0f66c | |||
d1aedcaef2 | |||
691aea3389 | |||
72f204771f | |||
dba921a3fd | |||
4646859177 | |||
66fa6fbe52 | |||
861619ce57 | |||
71bf8608e6 | |||
2916d1146b | |||
d62712d587 | |||
a37c3d6081 | |||
73e995f494 | |||
1dc0069cdc | |||
a5532488aa | |||
c9545cdc34 | |||
e9075906f8 | |||
3339c5c1d6 | |||
965f948899 | |||
c0c2bb45fe | |||
106cd16e41 | |||
7145c20136 | |||
e4b6637680 | |||
cd57e97e2b | |||
c1588322aa | |||
3eda1e2267 | |||
3617bdc9cb | |||
26cf40dcea | |||
8ae9131543 | |||
1b0a77dfae | |||
e3f58442aa | |||
119dae29ca | |||
c538cfeaaa | |||
e153f9f541 | |||
42db913b22 | |||
834b1fe1e1 | |||
809b7cc2d2 | |||
673001e4e0 | |||
54e69ed23b | |||
7e1bb08b1b |
20
.github/workflows/prodtag.yml
vendored
20
.github/workflows/prodtag.yml
vendored
|
@ -1,4 +1,4 @@
|
|||
name: Auto-create tag on every push to `production`
|
||||
name: Auto-create tag/release on every push to `production`
|
||||
|
||||
on:
|
||||
push:
|
||||
|
@ -8,9 +8,23 @@ on:
|
|||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: production
|
||||
- run: git tag "`date +%Y.%m.%d`.`git rev-parse --short HEAD`" $(git rev-parse HEAD)
|
||||
- run: git push --tags
|
||||
# - run: git tag "`date +%Y.%m.%d`.`git rev-parse --short HEAD`" $(git rev-parse HEAD)
|
||||
# - run: git push --tags
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm ci && npm run build
|
||||
- run: cd dist && zip -r ../phanpy-dist.zip . && cd ..
|
||||
- id: tag_name
|
||||
run: echo ::set-output name=tag_name::$(date +%Y.%m.%d).$(git rev-parse --short HEAD)
|
||||
- uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.tag_name.outputs.tag_name }}
|
||||
generate_release_notes: true
|
||||
files: phanpy-dist.zip
|
||||
|
|
25
.github/workflows/tagrelease.yml
vendored
25
.github/workflows/tagrelease.yml
vendored
|
@ -1,25 +0,0 @@
|
|||
name: Create Release on every tag push in `production`
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: production
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: npm ci && npm run build
|
||||
- run: cd dist && zip -r ../phanpy-dist.zip . && cd ..
|
||||
- uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: phanpy-dist.zip
|
|
@ -138,10 +138,12 @@ Costs involved in running and developing this web app:
|
|||
|
||||
[Phanpy](https://bulbapedia.bulbagarden.net/wiki/Phanpy_(Pok%C3%A9mon)) is a Ground-type Pokémon.
|
||||
|
||||
## Maintainers
|
||||
## Maintainers + contributors
|
||||
|
||||
- [Chee Aun](https://github.com/cheeaun) ([Mastodon](https://mastodon.social/@cheeaun)) ([Twitter](https://twitter.com/cheeaun))
|
||||
|
||||
[![Contributors](https://contrib.rocks/image?repo=cheeaun/phanpy)](https://github.com/cheeaun/phanpy/graphs/contributors)
|
||||
|
||||
## Backstory
|
||||
|
||||
I am one of the earliest users of Twitter. Twitter was launched on [15 July 2006](https://en.wikipedia.org/wiki/Twitter). I joined on December 2006 and my [first tweet](https://twitter.com/cheeaun/status/1298723) was posted on 18 December 2006.
|
||||
|
|
|
@ -163,7 +163,6 @@ self.addEventListener('notificationclick', (event) => {
|
|||
const { access_token, notification_type } = data;
|
||||
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
|
||||
|
||||
event.notification.close();
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clients = await self.clients.matchAll({
|
||||
|
@ -180,12 +179,12 @@ self.addEventListener('notificationclick', (event) => {
|
|||
console.log('NOTIFICATION CLICK navigate', url);
|
||||
if (bestClient) {
|
||||
console.log('NOTIFICATION CLICK postMessage', bestClient);
|
||||
bestClient.focus();
|
||||
bestClient.postMessage?.({
|
||||
type: 'notification',
|
||||
id: tag,
|
||||
accessToken: access_token,
|
||||
});
|
||||
bestClient.focus();
|
||||
} else {
|
||||
console.log('NOTIFICATION CLICK openWindow', url);
|
||||
await self.clients.openWindow(url);
|
||||
|
@ -195,6 +194,7 @@ self.addEventListener('notificationclick', (event) => {
|
|||
console.log('NOTIFICATION CLICK openWindow', url);
|
||||
await self.clients.openWindow(url);
|
||||
}
|
||||
await event.notification.close();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
|
98
src/app.css
98
src/app.css
|
@ -676,6 +676,10 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
|||
position: relative;
|
||||
border-radius: 0;
|
||||
padding-block: 16px !important;
|
||||
|
||||
.avatars-bunch > .avatar:not(:first-child) {
|
||||
margin-left: -4px;
|
||||
}
|
||||
}
|
||||
.timeline .show-more:hover {
|
||||
filter: none !important;
|
||||
|
@ -1418,6 +1422,13 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
|||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
|
||||
svg {
|
||||
contain: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* TAG */
|
||||
|
@ -1431,6 +1442,7 @@ body:has(.media-modal-container + .status-deck) .media-post-link {
|
|||
display: inline-block;
|
||||
margin: 4px;
|
||||
align-self: center;
|
||||
text-align: center;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
@ -2108,6 +2120,79 @@ ul.link-list li a .icon {
|
|||
transparent
|
||||
);
|
||||
align-items: center;
|
||||
transition: opacity 0.3s ease-out;
|
||||
|
||||
&.loading,
|
||||
.loading > & {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
background-image: none;
|
||||
border: 2px solid transparent;
|
||||
margin: 0;
|
||||
/* appearance: none; */
|
||||
line-height: 1;
|
||||
font-size: 90%;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
> .icon {
|
||||
color: var(--link-color);
|
||||
}
|
||||
|
||||
&:placeholder-shown {
|
||||
color: var(--text-insignificant-color);
|
||||
}
|
||||
|
||||
&:is(:hover, :focus-visible) {
|
||||
border-color: var(--link-light-color);
|
||||
}
|
||||
&:focus {
|
||||
outline-color: var(--link-light-color);
|
||||
}
|
||||
&.is-active {
|
||||
border-color: var(--link-color);
|
||||
box-shadow: inset 0 0 8px var(--link-faded-color);
|
||||
}
|
||||
|
||||
:is(input, select) {
|
||||
background-color: transparent;
|
||||
background-image: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
appearance: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type='month'] {
|
||||
min-width: 6em;
|
||||
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
/* replace icon with triangle */
|
||||
opacity: 0.5;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none"><path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter-bar.centered {
|
||||
justify-content: center;
|
||||
|
@ -2125,14 +2210,19 @@ ul.link-list li a .icon {
|
|||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.3s ease-out;
|
||||
transition: border-color 0.3s ease-out;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-bar > a:is(:hover, :focus) {
|
||||
.filter-bar > a:focus-visible {
|
||||
border-color: var(--link-light-color);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.filter-bar > a:hover {
|
||||
border-color: var(--link-light-color);
|
||||
}
|
||||
}
|
||||
.filter-bar > a > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
@ -2205,6 +2295,10 @@ ul.link-list li a .icon {
|
|||
}
|
||||
.deck > header {
|
||||
text-shadow: 0 1px var(--bg-color);
|
||||
|
||||
form {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
.deck > header h1 {
|
||||
font-size: 1.5em;
|
||||
|
|
|
@ -165,6 +165,77 @@
|
|||
animation: fade-in 0.3s both ease-in-out 0.2s;
|
||||
}
|
||||
|
||||
.private-note-tag {
|
||||
z-index: 1;
|
||||
appearance: none;
|
||||
display: inline-block;
|
||||
color: var(--private-note-text-color);
|
||||
background-color: var(--private-note-bg-color);
|
||||
border: 1px solid var(--private-note-border-color);
|
||||
padding: 4px;
|
||||
line-height: normal;
|
||||
font-size: smaller;
|
||||
border-radius: 0;
|
||||
align-self: center !important;
|
||||
/* clip a dog ear on top right */
|
||||
clip-path: polygon(0 0, calc(100% - 4px) 0, 100% 4px, 100% 100%, 0 100%);
|
||||
/* 4x4px square on top right */
|
||||
background-size: 4px 4px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top right;
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--private-note-border-color),
|
||||
var(--private-note-border-color)
|
||||
);
|
||||
transition: transform 0.15s ease-in-out;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
span {
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
display: box;
|
||||
-webkit-box-orient: vertical;
|
||||
box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:hover:not(:active) {
|
||||
filter: none !important;
|
||||
transform: rotate(-0.5deg) scale(1.05);
|
||||
|
||||
span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
.account-container .private-note {
|
||||
font-size: 90%;
|
||||
color: var(--text-insignificant-color);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 12px;
|
||||
background-color: var(--bg-faded-color);
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
|
||||
b {
|
||||
font-size: 90%;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.account-container .note {
|
||||
font-size: 95%;
|
||||
line-height: 1.4;
|
||||
|
@ -228,10 +299,11 @@
|
|||
align-items: center;
|
||||
}
|
||||
.account-container .actions button {
|
||||
align-self: flex-end;
|
||||
/* align-self: flex-end; */
|
||||
}
|
||||
.account-container .actions .buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-container .account-metadata-box {
|
||||
|
@ -571,3 +643,30 @@
|
|||
drop-shadow(8px 0 8px var(--header-color-4, --bg-color));
|
||||
}
|
||||
}
|
||||
|
||||
#private-note-container {
|
||||
textarea {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
height: 33vh;
|
||||
min-height: 25vh;
|
||||
max-height: 50vh;
|
||||
color: var(--private-note-text-color);
|
||||
background-color: var(--private-note-bg-color);
|
||||
border: 1px solid var(--private-note-border-color);
|
||||
box-shadow: 0 2px 8px var(--drop-shadow-color);
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
|
||||
* {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -223,9 +223,11 @@ function AccountInfo({
|
|||
// On first load, fetch familiar followers, merge to top of results' `value`
|
||||
// Remove dups on every fetch
|
||||
if (firstLoad) {
|
||||
const familiarFollowers = await masto.v1.accounts
|
||||
.familiarFollowers(id)
|
||||
.fetch();
|
||||
const familiarFollowers = await masto.v1.accounts.familiarFollowers.fetch(
|
||||
{
|
||||
id: [id],
|
||||
},
|
||||
);
|
||||
familiarFollowersCache.current = familiarFollowers[0].accounts;
|
||||
newValue = [
|
||||
...familiarFollowersCache.current,
|
||||
|
@ -342,7 +344,18 @@ function AccountInfo({
|
|||
<main>
|
||||
<div class="note">
|
||||
<p>███████ ████ ████</p>
|
||||
<p>███████████████ ███████████████</p>
|
||||
<p>████ ████████ ██████ █████████ ████ ██</p>
|
||||
</div>
|
||||
<div class="account-metadata-box">
|
||||
<div class="profile-metadata">
|
||||
<div class="profile-field">
|
||||
<b class="more-insignificant">███</b>
|
||||
<p>██████</p>
|
||||
</div>
|
||||
<div class="profile-field">
|
||||
<b class="more-insignificant">████</b>
|
||||
<p>███████████</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div>
|
||||
|
@ -354,7 +367,15 @@ function AccountInfo({
|
|||
<div>
|
||||
<span>██</span> Posts
|
||||
</div>
|
||||
<div>Joined ██</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<span />
|
||||
<span class="buttons">
|
||||
<button type="button" title="More" class="plain" disabled>
|
||||
<Icon icon="more" size="l" alt="More" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
|
@ -548,11 +569,13 @@ function AccountInfo({
|
|||
tabIndex={0}
|
||||
to={accountLink}
|
||||
onClick={() => {
|
||||
states.showAccount = false;
|
||||
// states.showAccount = false;
|
||||
setTimeout(() => {
|
||||
states.showGenericAccounts = {
|
||||
heading: 'Followers',
|
||||
fetchAccounts: fetchFollowers,
|
||||
};
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
{!!familiarFollowers.length && (
|
||||
|
@ -579,11 +602,13 @@ function AccountInfo({
|
|||
tabIndex={0}
|
||||
to={accountLink}
|
||||
onClick={() => {
|
||||
states.showAccount = false;
|
||||
// states.showAccount = false;
|
||||
setTimeout(() => {
|
||||
states.showGenericAccounts = {
|
||||
heading: 'Following',
|
||||
fetchAccounts: fetchFollowing,
|
||||
};
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
<span title={followingCount}>
|
||||
|
@ -595,13 +620,13 @@ function AccountInfo({
|
|||
<LinkOrDiv
|
||||
class="insignificant"
|
||||
to={accountLink}
|
||||
onClick={
|
||||
standalone
|
||||
? undefined
|
||||
: () => {
|
||||
hideAllModals();
|
||||
}
|
||||
}
|
||||
// onClick={
|
||||
// standalone
|
||||
// ? undefined
|
||||
// : () => {
|
||||
// hideAllModals();
|
||||
// }
|
||||
// }
|
||||
>
|
||||
<span title={statusesCount}>
|
||||
{shortenNumber(statusesCount)}
|
||||
|
@ -624,9 +649,9 @@ function AccountInfo({
|
|||
<LinkOrDiv
|
||||
to={accountLink}
|
||||
class="account-metadata-box"
|
||||
onClick={() => {
|
||||
states.showAccount = false;
|
||||
}}
|
||||
// onClick={() => {
|
||||
// states.showAccount = false;
|
||||
// }}
|
||||
>
|
||||
<div class="shazam-container">
|
||||
<div class="shazam-container-inner">
|
||||
|
@ -643,7 +668,9 @@ function AccountInfo({
|
|||
>
|
||||
<div>
|
||||
{postingStats.daysSinceLastPost < 365
|
||||
? `Last ${postingStats.total} posts in the past
|
||||
? `Last ${postingStats.total} post${
|
||||
postingStats.total > 1 ? 's' : ''
|
||||
} in the past
|
||||
${postingStats.daysSinceLastPost} day${
|
||||
postingStats.daysSinceLastPost > 1 ? 's' : ''
|
||||
}`
|
||||
|
@ -770,6 +797,7 @@ function RelatedActions({
|
|||
requested,
|
||||
domainBlocking,
|
||||
endorsed,
|
||||
note: privateNote,
|
||||
} = relationship || {};
|
||||
|
||||
const [currentInfo, setCurrentInfo] = useState(null);
|
||||
|
@ -851,6 +879,7 @@ function RelatedActions({
|
|||
|
||||
const [showTranslatedBio, setShowTranslatedBio] = useState(false);
|
||||
const [showAddRemoveLists, setShowAddRemoveLists] = useState(false);
|
||||
const [showPrivateNoteModal, setShowPrivateNoteModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -861,9 +890,11 @@ function RelatedActions({
|
|||
) : !!lastStatusAt ? (
|
||||
<small class="insignificant">
|
||||
Last post:{' '}
|
||||
<span class="ib">
|
||||
{niceDateTime(lastStatusAt, {
|
||||
hideTime: true,
|
||||
})}
|
||||
</span>
|
||||
</small>
|
||||
) : (
|
||||
<span />
|
||||
|
@ -872,6 +903,19 @@ function RelatedActions({
|
|||
{blocking && <span class="tag danger">Blocked</span>}
|
||||
</span>{' '}
|
||||
<span class="buttons">
|
||||
{!!privateNote && (
|
||||
<button
|
||||
type="button"
|
||||
class="private-note-tag"
|
||||
title="Private note"
|
||||
onClick={() => {
|
||||
setShowPrivateNoteModal(true);
|
||||
}}
|
||||
dir="auto"
|
||||
>
|
||||
<span>{privateNote}</span>
|
||||
</button>
|
||||
)}
|
||||
<Menu
|
||||
instanceRef={menuInstanceRef}
|
||||
portal={{
|
||||
|
@ -925,6 +969,16 @@ function RelatedActions({
|
|||
<Icon icon="translate" />
|
||||
<span>Translate bio</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setShowPrivateNoteModal(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="pencil" />
|
||||
<span>
|
||||
{privateNote ? 'Edit private note' : 'Add private note'}
|
||||
</span>
|
||||
</MenuItem>
|
||||
{/* Add/remove from lists is only possible if following the account */}
|
||||
{following && (
|
||||
<MenuItem
|
||||
|
@ -1235,6 +1289,24 @@ function RelatedActions({
|
|||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{!!showPrivateNoteModal && (
|
||||
<Modal
|
||||
class="light"
|
||||
onClose={() => {
|
||||
setShowPrivateNoteModal(false);
|
||||
}}
|
||||
>
|
||||
<PrivateNoteSheet
|
||||
account={info}
|
||||
note={privateNote}
|
||||
onRelationshipChange={(relationship) => {
|
||||
setRelationship(relationship);
|
||||
// onRelationshipChange({ relationship, currentID: accountID.current });
|
||||
}}
|
||||
onClose={() => setShowPrivateNoteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -1432,4 +1504,95 @@ function AddRemoveListsSheet({ accountID, onClose }) {
|
|||
);
|
||||
}
|
||||
|
||||
function PrivateNoteSheet({
|
||||
account,
|
||||
note: initialNote,
|
||||
onRelationshipChange = () => {},
|
||||
onClose = () => {},
|
||||
}) {
|
||||
const { masto } = api();
|
||||
const [uiState, setUIState] = useState('default');
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (textareaRef.current && !initialNote) {
|
||||
timer = setTimeout(() => {
|
||||
textareaRef.current.focus?.();
|
||||
}, 100);
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div class="sheet" id="private-note-container">
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close" onClick={onClose}>
|
||||
<Icon icon="x" />
|
||||
</button>
|
||||
)}
|
||||
<header>
|
||||
<b>Private note about @{account?.username || account?.acct}</b>
|
||||
</header>
|
||||
<main>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const note = formData.get('note');
|
||||
if (note?.trim() !== initialNote?.trim()) {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const newRelationship = await masto.v1.accounts
|
||||
.$select(account?.id)
|
||||
.note.create({
|
||||
comment: note,
|
||||
});
|
||||
console.log('updated relationship', newRelationship);
|
||||
setUIState('default');
|
||||
onRelationshipChange(newRelationship);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setUIState('error');
|
||||
alert(e?.message || 'Unable to update private note.');
|
||||
}
|
||||
})();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="note"
|
||||
disabled={uiState === 'loading'}
|
||||
>
|
||||
{initialNote}
|
||||
</textarea>
|
||||
<footer>
|
||||
<button
|
||||
type="button"
|
||||
class="light"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<span>
|
||||
<Loader abrupt hidden={uiState !== 'loading'} />
|
||||
<button disabled={uiState === 'loading'} type="submit">
|
||||
Save & close
|
||||
</button>
|
||||
</span>
|
||||
</footer>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountInfo;
|
||||
|
|
|
@ -2,6 +2,7 @@ import { useEffect } from 'preact/hooks';
|
|||
|
||||
import { api } from '../utils/api';
|
||||
import states from '../utils/states';
|
||||
import useLocationChange from '../utils/useLocationChange';
|
||||
|
||||
import AccountInfo from './account-info';
|
||||
import Icon from './icon';
|
||||
|
@ -16,17 +17,19 @@ function AccountSheet({ account, instance: propInstance, onClose }) {
|
|||
}
|
||||
}, [account]);
|
||||
|
||||
useLocationChange(onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
class="sheet"
|
||||
onClick={(e) => {
|
||||
const accountBlock = e.target.closest('.account-block');
|
||||
if (accountBlock) {
|
||||
onClose({
|
||||
destination: 'account-statuses',
|
||||
});
|
||||
}
|
||||
}}
|
||||
// onClick={(e) => {
|
||||
// const accountBlock = e.target.closest('.account-block');
|
||||
// if (accountBlock) {
|
||||
// onClose({
|
||||
// destination: 'account-statuses',
|
||||
// });
|
||||
// }
|
||||
// }}
|
||||
>
|
||||
{!!onClose && (
|
||||
<button type="button" class="sheet-close outer" onClick={onClose}>
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
height: 100%;
|
||||
object-fit: cover;
|
||||
background-color: var(--img-bg-color);
|
||||
contain: none;
|
||||
}
|
||||
|
||||
.avatar[data-loaded],
|
||||
|
|
|
@ -5,6 +5,7 @@ import { InView } from 'react-intersection-observer';
|
|||
import { useSnapshot } from 'valtio';
|
||||
|
||||
import states from '../utils/states';
|
||||
import useLocationChange from '../utils/useLocationChange';
|
||||
|
||||
import AccountBlock from './account-block';
|
||||
import Icon from './icon';
|
||||
|
@ -16,6 +17,8 @@ export default function GenericAccounts({ onClose = () => {} }) {
|
|||
const [accounts, setAccounts] = useState([]);
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
useLocationChange(onClose);
|
||||
|
||||
if (!snapStates.showGenericAccounts) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { memo } from 'preact/compat';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
const SIZES = {
|
||||
|
@ -101,6 +102,7 @@ export const ICONS = {
|
|||
'account-warning': () => import('@iconify-icons/mingcute/user-warning-line'),
|
||||
keyboard: () => import('@iconify-icons/mingcute/keyboard-line'),
|
||||
cloud: () => import('@iconify-icons/mingcute/cloud-line'),
|
||||
month: () => import('@iconify-icons/mingcute/calendar-month-line'),
|
||||
};
|
||||
|
||||
function Icon({
|
||||
|
@ -133,9 +135,6 @@ function Icon({
|
|||
style={{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
lineHeight: 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
|
@ -156,4 +155,4 @@ function Icon({
|
|||
);
|
||||
}
|
||||
|
||||
export default Icon;
|
||||
export default memo(Icon);
|
||||
|
|
|
@ -2,14 +2,14 @@ import './loader.css';
|
|||
|
||||
function Loader({ abrupt, hidden, ...props }) {
|
||||
return (
|
||||
<div
|
||||
<span
|
||||
{...props}
|
||||
class={`loader-container ${abrupt ? 'abrupt' : ''} ${
|
||||
hidden ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<div class="loader" />
|
||||
</div>
|
||||
<span class="loader" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -130,6 +130,7 @@ function Media({
|
|||
enabled: pinchZoomEnabled,
|
||||
draggableUnZoomed: false,
|
||||
inertiaFriction: 0.9,
|
||||
doubleTapZoomOutOnMaxScale: true,
|
||||
containerProps: {
|
||||
className: 'media-zoom',
|
||||
style: {
|
||||
|
|
|
@ -117,9 +117,10 @@ export default function Modals() {
|
|||
instance={snapStates.showAccount?.instance}
|
||||
onClose={({ destination } = {}) => {
|
||||
states.showAccount = false;
|
||||
if (destination) {
|
||||
states.showAccounts = false;
|
||||
}
|
||||
// states.showGenericAccounts = false;
|
||||
// if (destination) {
|
||||
// states.showAccounts = false;
|
||||
// }
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
.nav-menu section:last-child {
|
||||
background-color: var(--bg-faded-color);
|
||||
margin-bottom: -8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 23em) {
|
||||
.nav-menu {
|
||||
display: grid;
|
||||
|
@ -8,6 +14,7 @@
|
|||
'left right';
|
||||
padding: 0;
|
||||
width: 22em;
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
.nav-menu .top-menu {
|
||||
grid-area: top;
|
||||
|
@ -27,7 +34,6 @@
|
|||
}
|
||||
}
|
||||
.nav-menu section:last-child {
|
||||
background-color: var(--bg-faded-color);
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--divider-color) 1px,
|
||||
|
@ -45,6 +51,15 @@
|
|||
animation: phanpying 0.2s ease-in-out both;
|
||||
border-top-right-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.divider-grow {
|
||||
flex-grow: 1;
|
||||
height: auto;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
.nav-menu section:last-child > .szh-menu__divider:first-child {
|
||||
display: none;
|
||||
|
|
|
@ -254,6 +254,7 @@ function NavMenu(props) {
|
|||
<Icon icon="block" size="l" />
|
||||
Blocked users…
|
||||
</MenuItem>
|
||||
<MenuDivider className="divider-grow" />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
states.showKeyboardShortcutsHelp = true;
|
||||
|
@ -268,7 +269,7 @@ function NavMenu(props) {
|
|||
}}
|
||||
>
|
||||
<Icon icon="shortcut" size="l" />{' '}
|
||||
<span>Shortcuts Settings…</span>
|
||||
<span>Shortcuts / Columns…</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
|
|
|
@ -249,8 +249,7 @@ function ShortcutsSettings({ onClose }) {
|
|||
</h2>
|
||||
</header>
|
||||
<main>
|
||||
<p>
|
||||
Specify a list of shortcuts that'll appear as:
|
||||
<p>Specify a list of shortcuts that'll appear as:</p>
|
||||
<div class="shortcuts-view-mode">
|
||||
{[
|
||||
{
|
||||
|
@ -298,7 +297,6 @@ function ShortcutsSettings({ onClose }) {
|
|||
<option value="multi-column">Multi-column</option>
|
||||
<option value="tab-menu-bar">Tab/Menu bar </option>
|
||||
</select> */}
|
||||
</p>
|
||||
{/* <p>
|
||||
<details>
|
||||
<summary class="insignificant">
|
||||
|
|
|
@ -1015,8 +1015,6 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-color);
|
||||
|
@ -1027,6 +1025,8 @@ body:has(#modal-container .carousel) .status .media img:hover {
|
|||
white-space: pre-line;
|
||||
overflow: auto;
|
||||
text-overflow: unset;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -579,7 +579,11 @@ function Status({
|
|||
try {
|
||||
const done = await confirmBoostStatus();
|
||||
if (!isSizeLarge && done) {
|
||||
showToast(reblogged ? 'Unboosted' : 'Boosted');
|
||||
showToast(
|
||||
reblogged
|
||||
? `Unboosted @${username || acct}'s post`
|
||||
: `Boosted @${username || acct}'s post`,
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
}}
|
||||
|
@ -597,7 +601,11 @@ function Status({
|
|||
try {
|
||||
favouriteStatus();
|
||||
if (!isSizeLarge) {
|
||||
showToast(favourited ? 'Unfavourited' : 'Favourited');
|
||||
showToast(
|
||||
favourited
|
||||
? `Unfavourited @${username || acct}'s post`
|
||||
: `Favourited @${username || acct}'s post`,
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
}}
|
||||
|
@ -621,7 +629,11 @@ function Status({
|
|||
try {
|
||||
bookmarkStatus();
|
||||
if (!isSizeLarge) {
|
||||
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
|
||||
showToast(
|
||||
bookmarked
|
||||
? `Unbookmarked @${username || acct}'s post`
|
||||
: `Bookmarked @${username || acct}'s post`,
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
}}
|
||||
|
@ -829,7 +841,11 @@ function Status({
|
|||
try {
|
||||
favouriteStatus();
|
||||
if (!isSizeLarge) {
|
||||
showToast(favourited ? 'Unfavourited' : 'Favourited');
|
||||
showToast(
|
||||
favourited
|
||||
? `Unfavourited @${username || acct}'s post`
|
||||
: `Favourited @${username || acct}'s post`,
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
@ -843,7 +859,11 @@ function Status({
|
|||
try {
|
||||
bookmarkStatus();
|
||||
if (!isSizeLarge) {
|
||||
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
|
||||
showToast(
|
||||
bookmarked
|
||||
? `Unbookmarked @${username || acct}'s post`
|
||||
: `Bookmarked @${username || acct}'s post`,
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
@ -858,7 +878,11 @@ function Status({
|
|||
try {
|
||||
const done = await confirmBoostStatus();
|
||||
if (!isSizeLarge && done) {
|
||||
showToast(reblogged ? 'Unboosted' : 'Boosted');
|
||||
showToast(
|
||||
reblogged
|
||||
? `Unboosted @${username || acct}'s post`
|
||||
: `Boosted @${username || acct}'s post`,
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
|
|
@ -334,7 +334,13 @@ function Timeline({
|
|||
</button>
|
||||
)}
|
||||
</header>
|
||||
{!!timelineStart && <div class="timeline-start">{timelineStart}</div>}
|
||||
{!!timelineStart && (
|
||||
<div
|
||||
class={`timeline-start ${uiState === 'loading' ? 'loading' : ''}`}
|
||||
>
|
||||
{timelineStart}
|
||||
</div>
|
||||
)}
|
||||
{!!items.length ? (
|
||||
<>
|
||||
<ul class="timeline">
|
||||
|
|
|
@ -62,6 +62,9 @@
|
|||
--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);
|
||||
--private-note-text-color: var(--text-color);
|
||||
--private-note-bg-color: color-mix(in srgb, yellow 20%, var(--bg-color));
|
||||
--private-note-border-color: rgba(0, 0, 0, 0.2);
|
||||
|
||||
/* Video colors won't change based on color scheme */
|
||||
--media-fg-color: #f0f2f5;
|
||||
|
@ -111,6 +114,7 @@
|
|||
--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);
|
||||
--private-note-border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
13
src/main.jsx
13
src/main.jsx
|
@ -11,6 +11,19 @@ if (import.meta.env.DEV) {
|
|||
import('preact/debug');
|
||||
}
|
||||
|
||||
// AbortSignal.timeout polyfill
|
||||
// Temporary fix from https://github.com/mo/abortcontroller-polyfill/issues/73#issuecomment-1541180943
|
||||
// Incorrect implementation, but should be good enough for now
|
||||
if ('AbortSignal' in window) {
|
||||
AbortSignal.timeout =
|
||||
AbortSignal.timeout ||
|
||||
((duration) => {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), duration);
|
||||
return controller.signal;
|
||||
});
|
||||
}
|
||||
|
||||
render(
|
||||
<HashRouter>
|
||||
<App />
|
||||
|
|
|
@ -10,24 +10,139 @@ import Link from '../components/link';
|
|||
import Menu2 from '../components/menu2';
|
||||
import Timeline from '../components/timeline';
|
||||
import { api } from '../utils/api';
|
||||
import pmem from '../utils/pmem';
|
||||
import showToast from '../utils/show-toast';
|
||||
import states from '../utils/states';
|
||||
import { saveStatus } from '../utils/states';
|
||||
import useTitle from '../utils/useTitle';
|
||||
|
||||
const LIMIT = 20;
|
||||
const MIN_YEAR = 1983;
|
||||
const MIN_YEAR_MONTH = `${MIN_YEAR}-01`; // Birth of the Internet
|
||||
|
||||
const supportsInputMonth = (() => {
|
||||
try {
|
||||
const input = document.createElement('input');
|
||||
input.setAttribute('type', 'month');
|
||||
return input.type === 'month';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
async function _isSearchEnabled(instance) {
|
||||
const { masto } = api({ instance });
|
||||
const results = await masto.v2.search.fetch({
|
||||
q: 'from:me',
|
||||
type: 'statuses',
|
||||
limit: 1,
|
||||
});
|
||||
return !!results?.statuses?.length;
|
||||
}
|
||||
const isSearchEnabled = pmem(_isSearchEnabled);
|
||||
|
||||
function AccountStatuses() {
|
||||
const snapStates = useSnapshot(states);
|
||||
const { id, ...params } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const month = searchParams.get('month');
|
||||
const excludeReplies = !searchParams.get('replies');
|
||||
const excludeBoosts = !!searchParams.get('boosts');
|
||||
const tagged = searchParams.get('tagged');
|
||||
const media = !!searchParams.get('media');
|
||||
const { masto, instance, authenticated } = api({ instance: params.instance });
|
||||
const accountStatusesIterator = useRef();
|
||||
|
||||
const allSearchParams = [month, excludeReplies, excludeBoosts, tagged, media];
|
||||
const [account, setAccount] = useState();
|
||||
const searchOffsetRef = useRef(0);
|
||||
useEffect(() => {
|
||||
searchOffsetRef.current = 0;
|
||||
}, allSearchParams);
|
||||
|
||||
const sameCurrentInstance = useMemo(
|
||||
() => instance === api().instance,
|
||||
[instance],
|
||||
);
|
||||
const [searchEnabled, setSearchEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
// Only enable for current logged-in instance
|
||||
// Most remote instances don't allow unauthenticated searches
|
||||
if (!sameCurrentInstance) return;
|
||||
if (!account?.acct) return;
|
||||
(async () => {
|
||||
const enabled = await isSearchEnabled(instance);
|
||||
console.log({ enabled });
|
||||
setSearchEnabled(enabled);
|
||||
})();
|
||||
}, [instance, sameCurrentInstance, account?.acct]);
|
||||
|
||||
async function fetchAccountStatuses(firstLoad) {
|
||||
const isValidMonth = /^\d{4}-[01]\d$/.test(month);
|
||||
const isValidYear = month?.split?.('-')?.[0] >= MIN_YEAR;
|
||||
if (isValidMonth && isValidYear) {
|
||||
if (!account) {
|
||||
return {
|
||||
value: [],
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
const [_year, _month] = month.split('-');
|
||||
const monthIndex = parseInt(_month, 10) - 1;
|
||||
// YYYY-MM (no day)
|
||||
// Search options:
|
||||
// - from:account
|
||||
// - after:YYYY-MM-DD (non-inclusive)
|
||||
// - before:YYYY-MM-DD (non-inclusive)
|
||||
|
||||
// Last day of previous month
|
||||
const after = new Date(_year, monthIndex, 0);
|
||||
const afterStr = `${after.getFullYear()}-${(after.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, '0')}-${after.getDate().toString().padStart(2, '0')}`;
|
||||
// First day of next month
|
||||
const before = new Date(_year, monthIndex + 1, 1);
|
||||
const beforeStr = `${before.getFullYear()}-${(before.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, '0')}-${before.getDate().toString().padStart(2, '0')}`;
|
||||
console.log({
|
||||
month,
|
||||
_year,
|
||||
_month,
|
||||
monthIndex,
|
||||
after,
|
||||
before,
|
||||
afterStr,
|
||||
beforeStr,
|
||||
});
|
||||
|
||||
let limit;
|
||||
if (firstLoad) {
|
||||
limit = LIMIT + 1;
|
||||
searchOffsetRef.current = 0;
|
||||
} else {
|
||||
limit = LIMIT + searchOffsetRef.current + 1;
|
||||
searchOffsetRef.current += LIMIT;
|
||||
}
|
||||
|
||||
const searchResults = await masto.v2.search.fetch({
|
||||
q: `from:${account.acct} after:${afterStr} before:${beforeStr}`,
|
||||
type: 'statuses',
|
||||
limit,
|
||||
offset: searchOffsetRef.current,
|
||||
});
|
||||
if (searchResults?.statuses?.length) {
|
||||
const value = searchResults.statuses.slice(0, LIMIT);
|
||||
value.forEach((item) => {
|
||||
saveStatus(item, instance);
|
||||
});
|
||||
const done = searchResults.statuses.length <= LIMIT;
|
||||
return { value, done };
|
||||
} else {
|
||||
return { value: [], done: true };
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
if (firstLoad) {
|
||||
const { value: pinnedStatuses } = await masto.v1.accounts
|
||||
|
@ -78,7 +193,6 @@ function AccountStatuses() {
|
|||
};
|
||||
}
|
||||
|
||||
const [account, setAccount] = useState();
|
||||
const [featuredTags, setFeaturedTags] = useState([]);
|
||||
useTitle(
|
||||
`${account?.displayName ? account.displayName + ' ' : ''}@${
|
||||
|
@ -98,7 +212,7 @@ function AccountStatuses() {
|
|||
try {
|
||||
const featuredTags = await masto.v1.accounts
|
||||
.$select(id)
|
||||
.featuredTags.list(id);
|
||||
.featuredTags.list();
|
||||
console.log({ featuredTags });
|
||||
setFeaturedTags(featuredTags);
|
||||
} catch (e) {
|
||||
|
@ -112,7 +226,8 @@ function AccountStatuses() {
|
|||
const filterBarRef = useRef();
|
||||
const TimelineStart = useMemo(() => {
|
||||
const cachedAccount = snapStates.accounts[`${id}@${instance}`];
|
||||
const filtered = !excludeReplies || excludeBoosts || tagged || media;
|
||||
const filtered =
|
||||
!excludeReplies || excludeBoosts || tagged || media || !!month;
|
||||
return (
|
||||
<>
|
||||
<AccountInfo
|
||||
|
@ -128,6 +243,7 @@ function AccountStatuses() {
|
|||
to={`/${instance}/a/${id}`}
|
||||
class="insignificant filter-clear"
|
||||
title="Clear filters"
|
||||
key="clear-filters"
|
||||
>
|
||||
<Icon icon="x" size="l" />
|
||||
</Link>
|
||||
|
@ -169,6 +285,7 @@ function AccountStatuses() {
|
|||
</Link>
|
||||
{featuredTags.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
to={`/${instance}/a/${id}${
|
||||
tagged === tag.name
|
||||
? ''
|
||||
|
@ -191,6 +308,48 @@ function AccountStatuses() {
|
|||
{/* <span class="filter-count">{tag.statusesCount}</span> */}
|
||||
</Link>
|
||||
))}
|
||||
{searchEnabled &&
|
||||
(supportsInputMonth ? (
|
||||
<label class={`filter-field ${month ? 'is-active' : ''}`}>
|
||||
<Icon icon="month" size="l" />
|
||||
<input
|
||||
type="month"
|
||||
disabled={!account?.acct}
|
||||
value={month || ''}
|
||||
min={MIN_YEAR_MONTH}
|
||||
max={new Date().toISOString().slice(0, 7)}
|
||||
onInput={(e) => {
|
||||
const { value } = e.currentTarget;
|
||||
setSearchParams(
|
||||
value
|
||||
? {
|
||||
month: value,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
// Fallback to <select> for month and <input type="number"> for year
|
||||
<MonthPicker
|
||||
class={`filter-field ${month ? 'is-active' : ''}`}
|
||||
disabled={!account?.acct}
|
||||
value={month || ''}
|
||||
min={MIN_YEAR_MONTH}
|
||||
max={new Date().toISOString().slice(0, 7)}
|
||||
onInput={(e) => {
|
||||
const { value } = e;
|
||||
setSearchParams(
|
||||
value
|
||||
? {
|
||||
month: value,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -198,11 +357,9 @@ function AccountStatuses() {
|
|||
id,
|
||||
instance,
|
||||
authenticated,
|
||||
excludeReplies,
|
||||
excludeBoosts,
|
||||
featuredTags,
|
||||
tagged,
|
||||
media,
|
||||
searchEnabled,
|
||||
...allSearchParams,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -217,7 +374,7 @@ function AccountStatuses() {
|
|||
(filterBarRef.current.offsetWidth - active.offsetWidth) / 2,
|
||||
});
|
||||
}
|
||||
}, [featuredTags, tagged, media, excludeReplies, excludeBoosts]);
|
||||
}, [featuredTags, searchEnabled, ...allSearchParams]);
|
||||
|
||||
const accountInstance = useMemo(() => {
|
||||
if (!account?.url) return null;
|
||||
|
@ -257,7 +414,13 @@ function AccountStatuses() {
|
|||
useItemID
|
||||
boostsCarousel={snapStates.settings.boostsCarousel}
|
||||
timelineStart={TimelineStart}
|
||||
refresh={excludeReplies + excludeBoosts + tagged + media}
|
||||
refresh={[
|
||||
excludeReplies,
|
||||
excludeBoosts,
|
||||
tagged,
|
||||
media,
|
||||
month + account?.acct,
|
||||
].toString()}
|
||||
headerEnd={
|
||||
<Menu2
|
||||
portal
|
||||
|
@ -302,4 +465,68 @@ function AccountStatuses() {
|
|||
);
|
||||
}
|
||||
|
||||
function MonthPicker(props) {
|
||||
const {
|
||||
class: className,
|
||||
disabled,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onInput = () => {},
|
||||
} = props;
|
||||
const [_year, _month] = value?.split('-') || [];
|
||||
const monthFieldRef = useRef();
|
||||
const yearFieldRef = useRef();
|
||||
|
||||
return (
|
||||
<div class={className}>
|
||||
<Icon icon="month" size="l" />
|
||||
<select
|
||||
ref={monthFieldRef}
|
||||
disabled={disabled}
|
||||
value={_month || ''}
|
||||
onInput={(e) => {
|
||||
const { value } = e.currentTarget;
|
||||
onInput({
|
||||
value: value ? `${yearFieldRef.current.value}-${value}` : '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Month</option>
|
||||
<option disabled>-----</option>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<option
|
||||
value={
|
||||
// Month is 1-indexed
|
||||
(i + 1).toString().padStart(2, '0')
|
||||
}
|
||||
key={i}
|
||||
>
|
||||
{new Date(0, i).toLocaleString('default', {
|
||||
month: 'long',
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
</select>{' '}
|
||||
<input
|
||||
ref={yearFieldRef}
|
||||
type="number"
|
||||
disabled={disabled}
|
||||
value={_year || new Date().getFullYear()}
|
||||
min={min?.slice(0, 4) || MIN_YEAR}
|
||||
max={max?.slice(0, 4) || new Date().getFullYear()}
|
||||
onInput={(e) => {
|
||||
const { value } = e.currentTarget;
|
||||
onInput({
|
||||
value: value ? `${value}-${monthFieldRef.current.value}` : '',
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
width: '4.5em',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountStatuses;
|
||||
|
|
|
@ -63,7 +63,7 @@ function NotificationsLink() {
|
|||
to="/notifications"
|
||||
class={`button plain notifications-button ${
|
||||
snapStates.notificationsShowNew ? 'has-badge' : ''
|
||||
} ${menuState}`}
|
||||
} ${menuState || ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.matchMedia('(min-width: calc(40em))').matches) {
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
padding: 0;
|
||||
height: 40em;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.notifications-menu .status {
|
||||
font-size: inherit;
|
||||
|
|
|
@ -40,9 +40,15 @@ ul.link-list.hashtag-list li a {
|
|||
}
|
||||
|
||||
@media (min-width: 40em) {
|
||||
#search-page header input {
|
||||
#search-page {
|
||||
header input {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-popover-container {
|
||||
|
|
|
@ -141,7 +141,7 @@ function Search(props) {
|
|||
return (
|
||||
<div id="search-page" class="deck-container" ref={scrollableRef}>
|
||||
<div class="timeline-deck deck">
|
||||
<header>
|
||||
<header class={uiState === 'loading' ? 'loading' : ''}>
|
||||
<div class="header-grid">
|
||||
<div class="header-side">
|
||||
<NavMenu />
|
||||
|
@ -152,7 +152,7 @@ function Search(props) {
|
|||
</header>
|
||||
<main>
|
||||
{!!q && (
|
||||
<div class="filter-bar">
|
||||
<div class={`filter-bar ${uiState === 'loading' ? 'loading' : ''}`}>
|
||||
{!!type && (
|
||||
<Link to={`/search${q ? `?q=${encodeURIComponent(q)}` : ''}`}>
|
||||
‹ All
|
||||
|
@ -181,7 +181,9 @@ function Search(props) {
|
|||
return 0;
|
||||
})
|
||||
.map((link) => (
|
||||
<Link to={link.to}>{link.label}</Link>
|
||||
<Link to={link.to} key={link.type}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -30,6 +30,10 @@
|
|||
|
||||
.ancestors-indicator {
|
||||
font-size: 70% !important;
|
||||
|
||||
& > .avatar ~ .avatar {
|
||||
margin-left: -4px;
|
||||
}
|
||||
}
|
||||
.ancestors-indicator:not([hidden]) {
|
||||
animation: slide-up 0.3s both ease-out 0.3s;
|
||||
|
|
|
@ -650,6 +650,189 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
|||
resetScrollPosition(status.id);
|
||||
}, []);
|
||||
|
||||
const renderStatus = (status) => {
|
||||
const {
|
||||
id: statusID,
|
||||
ancestor,
|
||||
isThread,
|
||||
descendant,
|
||||
thread,
|
||||
replies,
|
||||
repliesCount,
|
||||
weight,
|
||||
} = status;
|
||||
const isHero = statusID === id;
|
||||
// const StatusParent = useCallback(
|
||||
// (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 (
|
||||
<li
|
||||
key={statusID}
|
||||
ref={isHero ? heroStatusRef : null}
|
||||
class={`${ancestor ? 'ancestor' : ''} ${
|
||||
descendant ? 'descendant' : ''
|
||||
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
||||
>
|
||||
{isHero ? (
|
||||
<>
|
||||
<InView
|
||||
threshold={0.1}
|
||||
onChange={onView}
|
||||
class="status-focus"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Status
|
||||
statusID={statusID}
|
||||
instance={instance}
|
||||
withinContext
|
||||
size="l"
|
||||
enableTranslate
|
||||
forceTranslate={translate}
|
||||
/>
|
||||
</InView>
|
||||
{uiState !== 'loading' && !authenticated ? (
|
||||
<div class="post-status-banner">
|
||||
<p>
|
||||
You're not logged in. Interactions (reply, boost, etc) are not
|
||||
possible.
|
||||
</p>
|
||||
<Link to="/login" class="button">
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
!sameInstance && (
|
||||
<div class="post-status-banner">
|
||||
<p>
|
||||
This post is from another instance (<b>{instance}</b>).
|
||||
Interactions (reply, boost, etc) are not possible.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const results = await currentMasto.v2.search.fetch({
|
||||
q: heroStatus.url,
|
||||
type: 'statuses',
|
||||
resolve: true,
|
||||
limit: 1,
|
||||
});
|
||||
if (results.statuses.length) {
|
||||
const status = results.statuses[0];
|
||||
location.hash = currentInstance
|
||||
? `/${currentInstance}/s/${status.id}`
|
||||
: `/s/${status.id}`;
|
||||
} else {
|
||||
throw new Error('No results');
|
||||
}
|
||||
} catch (e) {
|
||||
setUIState('default');
|
||||
alert('Error: ' + e);
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Icon icon="transfer" /> Switch to my instance to enable
|
||||
interactions
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// <StatusParent>
|
||||
<Link
|
||||
class="status-link"
|
||||
to={instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`}
|
||||
onClick={() => {
|
||||
resetScrollPosition(statusID);
|
||||
}}
|
||||
>
|
||||
<Status
|
||||
statusID={statusID}
|
||||
instance={instance}
|
||||
withinContext
|
||||
size={thread || ancestor ? 'm' : 's'}
|
||||
enableTranslate
|
||||
onMediaClick={handleMediaClick}
|
||||
onStatusLinkClick={handleStatusLinkClick}
|
||||
/>
|
||||
{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" />{' '}
|
||||
<span title={replies.length}>
|
||||
{shortenNumber(replies.length)}
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
{/* </StatusParent> */}
|
||||
</Link>
|
||||
)}
|
||||
{descendant && replies?.length > 0 && (
|
||||
<SubComments
|
||||
instance={instance}
|
||||
replies={replies}
|
||||
hasParentThread={thread}
|
||||
level={1}
|
||||
accWeight={weight}
|
||||
openAll={totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT}
|
||||
/>
|
||||
)}
|
||||
{uiState === 'loading' &&
|
||||
isHero &&
|
||||
!!heroStatus?.repliesCount &&
|
||||
!hasDescendants && (
|
||||
<div class="status-loading">
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
{uiState === 'error' &&
|
||||
isHero &&
|
||||
!!heroStatus?.repliesCount &&
|
||||
!hasDescendants && (
|
||||
<div class="status-error">
|
||||
Unable to load replies.
|
||||
<br />
|
||||
<button
|
||||
type="button"
|
||||
class="plain"
|
||||
onClick={() => {
|
||||
states.reloadStatusPage++;
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex="-1"
|
||||
|
@ -869,196 +1052,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
|||
uiState === 'loading' ? 'loading' : ''
|
||||
}`}
|
||||
>
|
||||
{statuses.slice(0, limit).map((status) => {
|
||||
const {
|
||||
id: statusID,
|
||||
ancestor,
|
||||
isThread,
|
||||
descendant,
|
||||
thread,
|
||||
replies,
|
||||
repliesCount,
|
||||
weight,
|
||||
} = status;
|
||||
const isHero = statusID === id;
|
||||
// const StatusParent = useCallback(
|
||||
// (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 (
|
||||
<li
|
||||
key={statusID}
|
||||
ref={isHero ? heroStatusRef : null}
|
||||
class={`${ancestor ? 'ancestor' : ''} ${
|
||||
descendant ? 'descendant' : ''
|
||||
} ${thread ? 'thread' : ''} ${isHero ? 'hero' : ''}`}
|
||||
>
|
||||
{isHero ? (
|
||||
<>
|
||||
<InView
|
||||
threshold={0.1}
|
||||
onChange={onView}
|
||||
class="status-focus"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Status
|
||||
statusID={statusID}
|
||||
instance={instance}
|
||||
withinContext
|
||||
size="l"
|
||||
enableTranslate
|
||||
forceTranslate={translate}
|
||||
/>
|
||||
</InView>
|
||||
{uiState !== 'loading' && !authenticated ? (
|
||||
<div class="post-status-banner">
|
||||
<p>
|
||||
You're not logged in. Interactions (reply, boost, etc)
|
||||
are not possible.
|
||||
</p>
|
||||
<Link to="/login" class="button">
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
!sameInstance && (
|
||||
<div class="post-status-banner">
|
||||
<p>
|
||||
This post is from another instance (
|
||||
<b>{instance}</b>). Interactions (reply, boost, etc)
|
||||
are not possible.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={uiState === 'loading'}
|
||||
onClick={() => {
|
||||
setUIState('loading');
|
||||
(async () => {
|
||||
try {
|
||||
const results =
|
||||
await currentMasto.v2.search.fetch({
|
||||
q: heroStatus.url,
|
||||
type: 'statuses',
|
||||
resolve: true,
|
||||
limit: 1,
|
||||
});
|
||||
if (results.statuses.length) {
|
||||
const status = results.statuses[0];
|
||||
location.hash = currentInstance
|
||||
? `/${currentInstance}/s/${status.id}`
|
||||
: `/s/${status.id}`;
|
||||
} else {
|
||||
throw new Error('No results');
|
||||
}
|
||||
} catch (e) {
|
||||
setUIState('default');
|
||||
alert('Error: ' + e);
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
<Icon icon="transfer" /> Switch to my instance to
|
||||
enable interactions
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// <StatusParent>
|
||||
<Link
|
||||
class="status-link"
|
||||
to={
|
||||
instance ? `/${instance}/s/${statusID}` : `/s/${statusID}`
|
||||
}
|
||||
onClick={() => {
|
||||
resetScrollPosition(statusID);
|
||||
}}
|
||||
>
|
||||
<Status
|
||||
statusID={statusID}
|
||||
instance={instance}
|
||||
withinContext
|
||||
size={thread || ancestor ? 'm' : 's'}
|
||||
enableTranslate
|
||||
onMediaClick={handleMediaClick}
|
||||
onStatusLinkClick={handleStatusLinkClick}
|
||||
/>
|
||||
{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" />{' '}
|
||||
<span title={replies.length}>
|
||||
{shortenNumber(replies.length)}
|
||||
</span>
|
||||
</div>
|
||||
)} */}
|
||||
{/* </StatusParent> */}
|
||||
</Link>
|
||||
)}
|
||||
{descendant && replies?.length > 0 && (
|
||||
<SubComments
|
||||
instance={instance}
|
||||
replies={replies}
|
||||
hasParentThread={thread}
|
||||
level={1}
|
||||
accWeight={weight}
|
||||
openAll={
|
||||
totalDescendants.current < SUBCOMMENTS_OPEN_ALL_LIMIT
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{uiState === 'loading' &&
|
||||
isHero &&
|
||||
!!heroStatus?.repliesCount &&
|
||||
!hasDescendants && (
|
||||
<div class="status-loading">
|
||||
<Loader />
|
||||
</div>
|
||||
)}
|
||||
{uiState === 'error' &&
|
||||
isHero &&
|
||||
!!heroStatus?.repliesCount &&
|
||||
!hasDescendants && (
|
||||
<div class="status-error">
|
||||
Unable to load replies.
|
||||
<br />
|
||||
<button
|
||||
type="button"
|
||||
class="plain"
|
||||
onClick={() => {
|
||||
states.reloadStatusPage++;
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{statuses.slice(0, limit).map(renderStatus)}
|
||||
{showMore > 0 && (
|
||||
<li>
|
||||
<button
|
||||
|
@ -1068,7 +1062,7 @@ function StatusThread({ id, closeLink = '/', instance: propInstance }) {
|
|||
onClick={() => setLimit((l) => l + LIMIT)}
|
||||
style={{ marginBlockEnd: '6em' }}
|
||||
>
|
||||
<div class="ib">
|
||||
<div class="ib avatars-bunch">
|
||||
{/* show avatars for first 5 statuses */}
|
||||
{statuses.slice(limit, limit + 5).map((status) => (
|
||||
<Avatar
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import mem from './mem';
|
||||
|
||||
const div = document.createElement('div');
|
||||
function getHTMLText(html) {
|
||||
if (!html) return '';
|
||||
|
@ -10,4 +12,4 @@ function getHTMLText(html) {
|
|||
return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
export default getHTMLText;
|
||||
export default mem(getHTMLText);
|
||||
|
|
23
src/utils/useLocationChange.js
Normal file
23
src/utils/useLocationChange.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Hook that runs a callback when the location changes
|
||||
// Won't run on the first render
|
||||
|
||||
export default function useLocationChange(fn) {
|
||||
if (!fn) return;
|
||||
const location = useLocation();
|
||||
const currentLocationRef = useRef(location.pathname);
|
||||
useEffect(() => {
|
||||
// console.log('location', {
|
||||
// current: currentLocationRef.current,
|
||||
// next: location.pathname,
|
||||
// });
|
||||
if (
|
||||
currentLocationRef.current &&
|
||||
location.pathname !== currentLocationRef.current
|
||||
) {
|
||||
fn?.();
|
||||
}
|
||||
}, [location.pathname, fn]);
|
||||
}
|
Loading…
Reference in a new issue