1
0
Fork 0

Notifications popover, for larger screens

This commit is contained in:
Lim Chee Aun 2023-04-30 21:03:09 +08:00
parent c9dbe23347
commit ef06faf259
6 changed files with 493 additions and 298 deletions

View file

@ -97,6 +97,8 @@ function NavMenu(props) {
{...props}
overflow="auto"
viewScroll="close"
position="anchor"
align="center"
boundingBoxPadding="8 8 8 8"
unmountOnClose
>

View file

@ -0,0 +1,250 @@
import states from '../utils/states';
import store from '../utils/store';
import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import NameText from './name-text';
import RelativeTime from './relative-time';
import Status from './status';
const NOTIFICATION_ICONS = {
mention: 'comment',
status: 'notification',
reblog: 'rocket',
follow: 'follow',
follow_request: 'follow-add',
favourite: 'heart',
poll: 'poll',
update: 'pencil',
};
/*
Notification types
==================
mention = Someone mentioned you in their status
status = Someone you enabled notifications for has posted a status
reblog = Someone boosted one of your statuses
follow = Someone followed you
follow_request = Someone requested to follow you
favourite = Someone favourited one of your statuses
poll = A poll you have voted in or created has ended
update = A status you interacted with has been edited
admin.sign_up = Someone signed up (optionally sent to admins)
admin.report = A new report has been filed
*/
const contentText = {
mention: 'mentioned you in their post.',
status: 'published a post.',
reblog: 'boosted your post.',
follow: 'followed you.',
follow_request: 'requested to follow you.',
favourite: 'favourited your post.',
poll: 'A poll you have voted in or created has ended.',
'poll-self': 'A poll you have created has ended.',
'poll-voted': 'A poll you have voted in has ended.',
update: 'A post you interacted with has been edited.',
'favourite+reblog': 'boosted & favourited your post.',
};
function Notification({ notification, instance }) {
const { id, status, account, _accounts } = notification;
let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
const actualStatusID = status?.reblog?.id || status?.id;
const currentAccount = store.session.get('currentAccount');
const isSelf = currentAccount === account?.id;
const isVoted = status?.poll?.voted;
let favsCount = 0;
let reblogsCount = 0;
if (type === 'favourite+reblog') {
for (const account of _accounts) {
if (account._types?.includes('favourite')) {
favsCount++;
}
if (account._types?.includes('reblog')) {
reblogsCount++;
}
}
if (!reblogsCount && favsCount) type = 'favourite';
if (!favsCount && reblogsCount) type = 'reblog';
}
const text =
type === 'poll'
? contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']
: contentText[type];
return (
<div class={`notification notification-${type}`} tabIndex="0">
<div
class={`notification-type notification-${type}`}
title={new Date(notification.createdAt).toLocaleString()}
>
{type === 'favourite+reblog' ? (
<>
<Icon icon="rocket" size="xl" alt={type} class="reblog-icon" />
<Icon icon="heart" size="xl" alt={type} class="favourite-icon" />
</>
) : (
<Icon
icon={NOTIFICATION_ICONS[type] || 'notification'}
size="xl"
alt={type}
/>
)}
</div>
<div class="notification-content">
{type !== 'mention' && (
<>
<p>
{!/poll|update/i.test(type) && (
<>
{_accounts?.length > 1 ? (
<>
<b>{_accounts.length} people</b>{' '}
</>
) : (
<>
<NameText account={account} showAvatar />{' '}
</>
)}
</>
)}
{text}
{type === 'mention' && (
<span class="insignificant">
{' '}
{' '}
<RelativeTime
datetime={notification.createdAt}
format="micro"
/>
</span>
)}
</p>
{type === 'follow_request' && (
<FollowRequestButtons
accountID={account.id}
onChange={() => {
loadNotifications(true);
}}
/>
)}
</>
)}
{_accounts?.length > 1 && (
<p class="avatars-stack">
{_accounts.map((account, i) => (
<>
<a
href={account.url}
rel="noopener noreferrer"
class="account-avatar-stack"
onClick={(e) => {
e.preventDefault();
states.showAccount = account;
}}
>
<Avatar
url={account.avatarStatic}
size={
_accounts.length <= 10
? 'xxl'
: _accounts.length < 100
? 'xl'
: _accounts.length < 1000
? 'l'
: _accounts.length < 2000
? 'm'
: 's' // My god, this person is popular!
}
key={account.id}
alt={`${account.displayName} @${account.acct}`}
squircle={account?.bot}
/>
{type === 'favourite+reblog' && (
<div class="account-sub-icons">
{account._types.map((type) => (
<Icon
icon={NOTIFICATION_ICONS[type]}
size="s"
class={`${type}-icon`}
/>
))}
</div>
)}
</a>{' '}
</>
))}
</p>
)}
{status && (
<Link
class={`status-link status-type-${type}`}
to={
instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`
}
>
<Status statusID={actualStatusID} size="s" />
</Link>
)}
</div>
</div>
);
}
function FollowRequestButtons({ accountID, onChange }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
return (
<p>
<button
type="button"
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.followRequests.authorize(accountID);
onChange();
} catch (e) {
console.error(e);
setUIState('default');
}
})();
}}
>
Accept
</button>{' '}
<button
type="button"
disabled={uiState === 'loading'}
class="light danger"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.followRequests.reject(accountID);
onChange();
} catch (e) {
console.error(e);
setUIState('default');
}
})();
}}
>
Reject
</button>
<Loader hidden={uiState !== 'loading'} />
</p>
);
}
export default Notification;

View file

@ -1,13 +1,20 @@
import './notifications-menu.css';
import { ControlledMenu } from '@szhsin/react-menu';
import { memo } from 'preact/compat';
import { useEffect } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Columns from '../components/columns';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import Notification from '../components/notification';
import { api } from '../utils/api';
import db from '../utils/db';
import groupNotifications from '../utils/group-notifications';
import openCompose from '../utils/open-compose';
import states from '../utils/states';
import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils';
import Following from './following';
@ -27,6 +34,9 @@ function Home() {
})();
}, []);
const notificationLinkRef = useRef();
const [menuState, setMenuState] = useState('closed');
return (
<>
{(snapStates.settings.shortcutsColumnsMode ||
@ -40,17 +50,31 @@ function Home() {
id="home"
headerStart={false}
headerEnd={
<Link
to="/notifications"
class={`button plain notifications-button ${
snapStates.notificationsShowNew ? 'has-badge' : ''
}`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
</Link>
<>
<Link
ref={notificationLinkRef}
to="/notifications"
class={`button plain notifications-button ${
snapStates.notificationsShowNew ? 'has-badge' : ''
} ${menuState}`}
onClick={(e) => {
e.stopPropagation();
if (window.matchMedia('(min-width: calc(40em))').matches) {
e.preventDefault();
setMenuState((state) =>
state === 'closed' ? 'open' : 'closed',
);
}
}}
>
<Icon icon="notification" size="l" alt="Notifications" />
</Link>
<NotificationsMenu
state={menuState}
anchorRef={notificationLinkRef}
onClose={() => setMenuState('closed')}
/>
</>
}
/>
)}
@ -76,4 +100,112 @@ function Home() {
);
}
const NOTIFICATIONS_LIMIT = 30;
const NOTIFICATIONS_DISPLAY_LIMIT = 5;
function NotificationsMenu({ anchorRef, state, onClose }) {
const { masto, instance } = api();
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const notificationsIterator = masto.v1.notifications.list({
limit: NOTIFICATIONS_LIMIT,
});
async function fetchNotifications() {
const allNotifications = await notificationsIterator.next();
const notifications = allNotifications.value;
if (notifications?.length) {
notifications.forEach((notification) => {
saveStatus(notification.status, instance, {
skipThreading: true,
});
});
const groupedNotifications = groupNotifications(notifications);
states.notificationsLast = notifications[0];
states.notifications = groupedNotifications;
}
states.notificationsShowNew = false;
states.notificationsLastFetchTime = Date.now();
return allNotifications;
}
function loadNotifications() {
setUIState('loading');
(async () => {
try {
await fetchNotifications();
setUIState('default');
} catch (e) {
setUIState('error');
}
})();
}
useEffect(() => {
loadNotifications();
}, []);
return (
<ControlledMenu
menuClassName="notifications-menu"
state={state}
anchorRef={anchorRef}
onClose={onClose}
portal={{
target: document.body,
}}
overflow="auto"
viewScroll="close"
position="anchor"
align="center"
boundingBoxPadding="8 8 8 8"
unmountOnClose
>
<header>
<h2>Notifications</h2>
</header>
{snapStates.notifications.length ? (
<>
{snapStates.notifications
.slice(0, NOTIFICATIONS_DISPLAY_LIMIT)
.map((notification) => (
<Notification
key={notification.id}
instance={instance}
notification={notification}
/>
))}
</>
) : uiState === 'loading' ? (
<div class="ui-state">
<Loader abrupt />
</div>
) : (
uiState === 'error' && (
<div class="ui-state">
<p>Unable to fetch notifications.</p>
<p>
<button type="button" onClick={loadNotifications}>
Try again
</button>
</p>
</div>
)
)}
<footer>
<Link to="/mentions" class="button plain">
<Icon icon="at" /> <span>Mentions</span>
</Link>
<Link to="/notifications" class="button plain2">
<b>See all</b> <Icon icon="arrow-right" />
</Link>
</footer>
</ControlledMenu>
);
}
export default memo(Home);

View file

@ -0,0 +1,51 @@
@keyframes bell {
0% {
transform: rotate(0deg);
}
33% {
transform: rotate(5deg);
}
66% {
transform: rotate(-10deg);
}
100% {
transform: rotate(0deg);
}
}
.notifications-button.open {
animation: bell 0.3s ease-out both;
transform-origin: 50% 0;
}
.notifications-menu {
width: 23em;
font-size: 90%;
padding: 0;
height: 40em;
overflow: auto;
}
.notifications-menu header {
padding: 16px;
margin: 0;
border-bottom: var(--hairline-width) solid var(--outline-color);
}
.notifications-menu header h2 {
margin: 0;
padding: 0;
font-size: 1.2em;
}
.notifications-menu .notification {
animation: appear-smooth 0.3s ease-out 0.1s both;
}
.notifications-menu footer {
animation: slide-up 0.3s ease-out 0.2s both;
position: sticky;
bottom: 0;
border-top: var(--hairline-width) solid var(--outline-color);
background-color: var(--bg-blur-color);
backdrop-filter: blur(16px);
padding: 16px;
gap: 8px;
display: flex;
justify-content: space-between;
}

View file

@ -4,61 +4,18 @@ import { memo } from 'preact/compat';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar';
import Icon from '../components/icon';
import Link from '../components/link';
import Loader from '../components/loader';
import NameText from '../components/name-text';
import NavMenu from '../components/nav-menu';
import RelativeTime from '../components/relative-time';
import Status from '../components/status';
import Notification from '../components/notification';
import { api } from '../utils/api';
import groupNotifications from '../utils/group-notifications';
import niceDateTime from '../utils/nice-date-time';
import states, { saveStatus } from '../utils/states';
import store from '../utils/store';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
/*
Notification types
==================
mention = Someone mentioned you in their status
status = Someone you enabled notifications for has posted a status
reblog = Someone boosted one of your statuses
follow = Someone followed you
follow_request = Someone requested to follow you
favourite = Someone favourited one of your statuses
poll = A poll you have voted in or created has ended
update = A status you interacted with has been edited
admin.sign_up = Someone signed up (optionally sent to admins)
admin.report = A new report has been filed
*/
const contentText = {
mention: 'mentioned you in their post.',
status: 'published a post.',
reblog: 'boosted your post.',
follow: 'followed you.',
follow_request: 'requested to follow you.',
favourite: 'favourited your post.',
poll: 'A poll you have voted in or created has ended.',
'poll-self': 'A poll you have created has ended.',
'poll-voted': 'A poll you have voted in has ended.',
update: 'A post you interacted with has been edited.',
'favourite+reblog': 'boosted & favourited your post.',
};
const NOTIFICATION_ICONS = {
mention: 'comment',
status: 'notification',
reblog: 'rocket',
follow: 'follow',
follow_request: 'follow-add',
favourite: 'heart',
poll: 'poll',
update: 'pencil',
};
const LIMIT = 30; // 30 is the maximum limit :(
function Notifications() {
@ -287,245 +244,5 @@ function Notifications() {
</div>
);
}
function Notification({ notification, instance }) {
const { id, status, account, _accounts } = notification;
let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
const actualStatusID = status?.reblog?.id || status?.id;
const currentAccount = store.session.get('currentAccount');
const isSelf = currentAccount === account?.id;
const isVoted = status?.poll?.voted;
let favsCount = 0;
let reblogsCount = 0;
if (type === 'favourite+reblog') {
for (const account of _accounts) {
if (account._types?.includes('favourite')) {
favsCount++;
}
if (account._types?.includes('reblog')) {
reblogsCount++;
}
}
if (!reblogsCount && favsCount) type = 'favourite';
if (!favsCount && reblogsCount) type = 'reblog';
}
const text =
type === 'poll'
? contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']
: contentText[type];
return (
<div class={`notification notification-${type}`} tabIndex="0">
<div
class={`notification-type notification-${type}`}
title={new Date(notification.createdAt).toLocaleString()}
>
{type === 'favourite+reblog' ? (
<>
<Icon icon="rocket" size="xl" alt={type} class="reblog-icon" />
<Icon icon="heart" size="xl" alt={type} class="favourite-icon" />
</>
) : (
<Icon
icon={NOTIFICATION_ICONS[type] || 'notification'}
size="xl"
alt={type}
/>
)}
</div>
<div class="notification-content">
{type !== 'mention' && (
<>
<p>
{!/poll|update/i.test(type) && (
<>
{_accounts?.length > 1 ? (
<>
<b>{_accounts.length} people</b>{' '}
</>
) : (
<>
<NameText account={account} showAvatar />{' '}
</>
)}
</>
)}
{text}
{type === 'mention' && (
<span class="insignificant">
{' '}
{' '}
<RelativeTime
datetime={notification.createdAt}
format="micro"
/>
</span>
)}
</p>
{type === 'follow_request' && (
<FollowRequestButtons
accountID={account.id}
onChange={() => {
loadNotifications(true);
}}
/>
)}
</>
)}
{_accounts?.length > 1 && (
<p class="avatars-stack">
{_accounts.map((account, i) => (
<>
<a
href={account.url}
rel="noopener noreferrer"
class="account-avatar-stack"
onClick={(e) => {
e.preventDefault();
states.showAccount = account;
}}
>
<Avatar
url={account.avatarStatic}
size={
_accounts.length <= 10
? 'xxl'
: _accounts.length < 100
? 'xl'
: _accounts.length < 1000
? 'l'
: _accounts.length < 2000
? 'm'
: 's' // My god, this person is popular!
}
key={account.id}
alt={`${account.displayName} @${account.acct}`}
squircle={account?.bot}
/>
{type === 'favourite+reblog' && (
<div class="account-sub-icons">
{account._types.map((type) => (
<Icon
icon={NOTIFICATION_ICONS[type]}
size="s"
class={`${type}-icon`}
/>
))}
</div>
)}
</a>{' '}
</>
))}
</p>
)}
{status && (
<Link
class={`status-link status-type-${type}`}
to={
instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`
}
>
<Status statusID={actualStatusID} size="s" />
</Link>
)}
</div>
</div>
);
}
function FollowRequestButtons({ accountID, onChange }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
return (
<p>
<button
type="button"
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.followRequests.authorize(accountID);
onChange();
} catch (e) {
console.error(e);
setUIState('default');
}
})();
}}
>
Accept
</button>{' '}
<button
type="button"
disabled={uiState === 'loading'}
class="light danger"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.followRequests.reject(accountID);
onChange();
} catch (e) {
console.error(e);
setUIState('default');
}
})();
}}
>
Reject
</button>
<Loader hidden={uiState !== 'loading'} />
</p>
);
}
function groupNotifications(notifications) {
// Create new flat list of notifications
// Combine sibling notifications based on type and status id
// Concat all notification.account into an array of _accounts
const notificationsMap = {};
const cleanNotifications = [];
for (let i = 0, j = 0; i < notifications.length; i++) {
const notification = notifications[i];
const { status, account, type, createdAt } = notification;
const date = new Date(createdAt).toLocaleDateString();
let virtualType = type;
if (type === 'favourite' || type === 'reblog') {
virtualType = 'favourite+reblog';
}
const key = `${status?.id}-${virtualType}-${date}`;
const mappedNotification = notificationsMap[key];
if (virtualType === 'follow_request') {
cleanNotifications[j++] = notification;
} else if (mappedNotification?.account) {
const mappedAccount = mappedNotification._accounts.find(
(a) => a.id === account.id,
);
if (mappedAccount) {
mappedAccount._types.push(type);
mappedAccount._types.sort().reverse();
} else {
account._types = [type];
mappedNotification._accounts.push(account);
}
} else {
account._types = [type];
let n = (notificationsMap[key] = {
...notification,
type: virtualType,
_accounts: [account],
});
cleanNotifications[j++] = n;
}
}
return cleanNotifications;
}
export default memo(Notifications);

View file

@ -0,0 +1,43 @@
function groupNotifications(notifications) {
// Create new flat list of notifications
// Combine sibling notifications based on type and status id
// Concat all notification.account into an array of _accounts
const notificationsMap = {};
const cleanNotifications = [];
for (let i = 0, j = 0; i < notifications.length; i++) {
const notification = notifications[i];
const { status, account, type, createdAt } = notification;
const date = new Date(createdAt).toLocaleDateString();
let virtualType = type;
if (type === 'favourite' || type === 'reblog') {
virtualType = 'favourite+reblog';
}
const key = `${status?.id}-${virtualType}-${date}`;
const mappedNotification = notificationsMap[key];
if (virtualType === 'follow_request') {
cleanNotifications[j++] = notification;
} else if (mappedNotification?.account) {
const mappedAccount = mappedNotification._accounts.find(
(a) => a.id === account.id,
);
if (mappedAccount) {
mappedAccount._types.push(type);
mappedAccount._types.sort().reverse();
} else {
account._types = [type];
mappedNotification._accounts.push(account);
}
} else {
account._types = [type];
let n = (notificationsMap[key] = {
...notification,
type: virtualType,
_accounts: [account],
});
cleanNotifications[j++] = n;
}
}
return cleanNotifications;
}
export default groupNotifications;