MVP-ish filtered notifications UI

pull/465/head
Lim Chee Aun 2024-03-20 11:03:05 +08:00
rodzic da909e4084
commit 4c2210c68b
4 zmienionych plików z 520 dodań i 2 usunięć

Wyświetl plik

@ -104,4 +104,5 @@ export const ICONS = {
code: () => import('@iconify-icons/mingcute/code-line'),
copy: () => import('@iconify-icons/mingcute/copy-2-line'),
quote: () => import('@iconify-icons/mingcute/quote-left-line'),
settings: () => import('@iconify-icons/mingcute/settings-6-line'),
};

Wyświetl plik

@ -1,4 +1,5 @@
{
"@mastodon/edit-media-attributes": ">=4.1",
"@mastodon/list-exclusive": ">=4.2"
"@mastodon/list-exclusive": ">=4.2",
"@mastodon/filtered-notifications": "~4.3 || >=4.3"
}

Wyświetl plik

@ -421,3 +421,130 @@
color: var(--text-color);
background-color: var(--link-faded-color);
}
/* FILTERED NOTIFICATIONS */
.filtered-notifications {
padding-block-end: 16px;
summary {
padding: 8px 16px;
cursor: pointer;
font-weight: 600;
user-select: none;
margin: 16px 0 0;
color: var(--text-insignificant-color);
&::marker,
&::-webkit-details-marker {
color: var(--text-insignificant-color);
}
}
details[open] summary {
color: var(--text-color);
}
summary + ul {
}
ul {
list-style: none;
padding: 0;
margin: 0;
max-height: 50vh;
max-height: 50dvh;
overflow: auto;
border-top: 1px solid var(--outline-color);
border-bottom: 1px solid var(--outline-color);
background-color: var(--bg-faded-color);
@media (min-width: 40em) {
background-color: var(--bg-color);
border-radius: 16px;
border-width: 0;
}
li {
display: flex;
padding: 16px;
row-gap: 8px;
column-gap: 16px;
border-bottom: 1px solid var(--outline-color);
}
li:last-child {
border-bottom: none;
}
.request-notifcations {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.last-post {
> .status-link {
border-radius: 8px;
overflow: hidden;
--max-height: 160px;
max-height: var(--max-height);
border: 1px solid var(--outline-color);
&:is(:hover, :focus-visible) {
border-color: var(--outline-hover-color);
}
.status {
mask-image: linear-gradient(
to bottom,
black calc(var(--max-height) / 2),
transparent calc(var(--max-height) - 8px)
);
font-size: calc(var(--text-size) * 0.9);
.content-container {
pointer-events: none;
filter: saturate(0.5);
}
}
}
}
.request-notifications-account {
display: flex;
align-items: center;
gap: 4px;
}
}
.notification-request-buttons {
grid-area: buttons;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
button {
max-width: 30vw;
}
.notification-request-states {
min-height: 32px;
text-align: center;
vertical-align: middle;
.icon {
margin-inline: 8px;
&.notification-accepted {
color: var(--green-color);
}
&.notification-dismissed {
color: var(--red-color);
}
}
}
}
}
}

Wyświetl plik

@ -14,8 +14,10 @@ import FollowRequestButtons from '../components/follow-request-buttons';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Modal from '../components/modal';
import NavMenu from '../components/nav-menu';
import Notification from '../components/notification';
import Status from '../components/status';
import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content';
import groupNotifications from '../utils/group-notifications';
@ -23,8 +25,10 @@ import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time';
import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { saveStatus } from '../utils/states';
import { getCurrentInstance } from '../utils/store-utils';
import supports from '../utils/supports';
import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
@ -136,6 +140,28 @@ function Notifications({ columnMode }) {
}
}
const supportsFilteredNotifications = supports(
'@mastodon/filtered-notifications',
);
const [showNotificationsSettings, setShowNotificationsSettings] =
useState(false);
const [notificationsPolicy, setNotificationsPolicy] = useState({});
function fetchNotificationsPolicy() {
return masto.v1.notifications.policy.fetch().catch(() => {});
}
function loadNotificationsPolicy() {
fetchNotificationsPolicy()
.then((policy) => {
console.log('✨ Notifications policy', policy);
setNotificationsPolicy(policy);
})
.catch(() => {});
}
const [notificationsRequests, setNotificationsRequests] = useState(null);
function fetchNotificationsRequest() {
return masto.v1.notifications.requests.list();
}
const loadNotifications = (firstLoad) => {
setShowNew(false);
setUIState('loading');
@ -161,6 +187,10 @@ function Notifications({ columnMode }) {
setFollowRequests(requests);
})
.catch(() => {});
if (supportsFilteredNotifications) {
loadNotificationsPolicy();
}
}
const { done } = await fetchNotificationsPromise;
@ -384,7 +414,17 @@ function Notifications({ columnMode }) {
</div>
<h1>Notifications</h1>
<div class="header-side">
{/* <Loader hidden={uiState !== 'loading'} /> */}
{supportsFilteredNotifications && (
<button
type="button"
class="button plain"
onClick={() => {
setShowNotificationsSettings(true);
}}
>
<Icon icon="settings" size="l" alt="Notifications settings" />
</button>
)}
</div>
</div>
{showNew && uiState !== 'loading' && (
@ -489,6 +529,70 @@ function Notifications({ columnMode }) {
)}
</div>
)}
{supportsFilteredNotifications &&
notificationsPolicy?.summary?.pendingRequestsCount > 0 && (
<div class="filtered-notifications">
<details
onToggle={async (e) => {
const { open } = e.target;
if (open) {
const requests = await fetchNotificationsRequest();
setNotificationsRequests(requests);
console.log({ open, requests });
}
}}
>
<summary>
Filtered notifications from{' '}
{notificationsPolicy.summary.pendingRequestsCount} people
</summary>
{!notificationsRequests ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
notificationsRequests?.length > 0 && (
<ul>
{notificationsRequests.map((request) => (
<li key={request.id}>
<div class="request-notifcations">
{!request.lastStatus?.id && (
<AccountBlock
useAvatarStatic
showStats
account={request.account}
/>
)}
{request.lastStatus?.id && (
<div class="last-post">
<Link
class="status-link"
to={`/${instance}/s/${request.lastStatus.id}`}
>
<Status
status={request.lastStatus}
size="s"
readOnly
/>
</Link>
</div>
)}
<NotificationRequestModalButton request={request} />
</div>
<NotificationRequestButtons
request={request}
onChange={() => {
loadNotifications(true);
}}
/>
</li>
))}
</ul>
)
)}
</details>
</div>
)}
<div id="mentions-option">
<label>
<input
@ -597,6 +701,109 @@ function Notifications({ columnMode }) {
</InView>
)}
</div>
{supportsFilteredNotifications && showNotificationsSettings && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowNotificationsSettings(false);
}
}}
>
<div class="sheet" tabIndex="-1">
<button
type="button"
class="sheet-close"
onClick={() => setShowNotificationsSettings(false)}
>
<Icon icon="x" />
</button>
<header>
<h2>Notifications settings</h2>
</header>
<main>
<form
onSubmit={(e) => {
e.preventDefault();
const {
filterNotFollowing,
filterNotFollowers,
filterNewAccounts,
filterPrivateMentions,
} = e.target;
const allFilters = {
filterNotFollowing: filterNotFollowing.checked,
filterNotFollowers: filterNotFollowers.checked,
filterNewAccounts: filterNewAccounts.checked,
filterPrivateMentions: filterPrivateMentions.checked,
};
setNotificationsPolicy({
...notificationsPolicy,
...allFilters,
});
setShowNotificationsSettings(false);
(async () => {
try {
await masto.v1.notifications.policy.update(allFilters);
showToast('Notifications settings updated');
} catch (e) {
console.error(e);
}
})();
}}
>
<p>Filter out notifications from people:</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNotFollowing}
name="filterNotFollowing"
/>{' '}
You don't follow
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNotFollowers}
name="filterNotFollowers"
/>{' '}
Who don't follow you
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterNewAccounts}
name="filterNewAccounts"
/>{' '}
With a new account
</label>
</p>
<p>
<label>
<input
type="checkbox"
switch
defaultChecked={notificationsPolicy.filterPrivateMentions}
name="filterPrivateMentions"
/>{' '}
Who unsolicitedly private mention you
</label>
</p>
<p>
<button type="submit">Save</button>
</p>
</form>
</main>
</div>
</Modal>
)}
</div>
);
}
@ -679,4 +886,186 @@ function AnnouncementBlock({ announcement }) {
);
}
function fetchNotficationsByAccount(accountID) {
const { masto } = api();
return masto.v1.notifications.list({
accountID,
});
}
function NotificationRequestModalButton({ request }) {
const { instance } = api();
const [uiState, setUIState] = useState('loading');
const { account, lastStatus } = request;
const [showModal, setShowModal] = useState(false);
const [notifications, setNotifications] = useState([]);
function onClose() {
setShowModal(false);
}
useEffect(() => {
if (!request?.account?.id) return;
if (!showModal) return;
setUIState('loading');
(async () => {
const notifs = await fetchNotficationsByAccount(request.account.id);
setNotifications(notifs || []);
setUIState('default');
})();
}, [showModal, request?.account?.id]);
return (
<>
<button
type="button"
class="plain4 request-notifications-account"
onClick={() => {
setShowModal(true);
}}
>
<Icon icon="notification" class="more-insignificant" />{' '}
<small>View notifications from @{account.username}</small>{' '}
<Icon icon="chevron-down" />
</button>
{showModal && (
<Modal
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<b>Notifications from @{account.username}</b>
</header>
<main>
{uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : (
notifications.map((notification) => (
<div
class="notification-peek"
onClick={(e) => {
const { target } = e;
// If button or links
if (
e.target.tagName === 'BUTTON' ||
e.target.tagName === 'A'
) {
onClose();
}
}}
>
<Notification
instance={instance}
notification={notification}
isStatic
/>
</div>
))
)}
</main>
</div>
</Modal>
)}
</>
);
}
function NotificationRequestButtons({ request, onChange }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [requestState, setRequestState] = useState(null); // accept, dismiss
const hasRequestState = requestState !== null;
return (
<p class="notification-request-buttons">
<button
type="button"
disabled={uiState === 'loading' || hasRequestState}
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.notifications.requests
.$select(request.id)
.accept();
setRequestState('accept');
setUIState('default');
onChange({
request,
state: 'accept',
});
showToast(
`Notifications from @${request.account.username} will not be filtered from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to accept notification request`);
}
})();
}}
>
Allow
</button>{' '}
<button
type="button"
disabled={uiState === 'loading' || hasRequestState}
class="light danger"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.notifications.requests
.$select(request.id)
.dismiss();
setRequestState('dismiss');
setUIState('default');
onChange({
request,
state: 'dismiss',
});
showToast(
`Notifications from @${request.account.username} will not show up in Filtered notifications from now on.`,
);
} catch (error) {
setUIState('error');
console.error(error);
showToast(`Unable to dismiss notification request`);
}
})();
}}
>
Dismiss
</button>
<span class="notification-request-states">
{uiState === 'loading' ? (
<Loader abrupt />
) : requestState === 'accept' ? (
<Icon
icon="check-circle"
alt="Accepted"
class="notification-accepted"
/>
) : (
requestState === 'dismiss' && (
<Icon
icon="x-circle"
alt="Dismissed"
class="notification-dismissed"
/>
)
)}
</span>
</p>
);
}
export default memo(Notifications);