Experimental opt-in server-side grouped notifications

This commit is contained in:
Lim Chee Aun 2024-07-12 18:57:48 +08:00
parent 57d6889826
commit a2f7638257
8 changed files with 246 additions and 22 deletions

View file

@ -149,6 +149,9 @@ function Notification({
moderation_warning, moderation_warning,
_accounts, _accounts,
_statuses, _statuses,
// Grouped notification
sampleAccounts,
notificationsCount,
} = notification; } = notification;
let { type } = notification; let { type } = notification;
@ -167,12 +170,14 @@ function Notification({
let favsCount = 0; let favsCount = 0;
let reblogsCount = 0; let reblogsCount = 0;
if (type === 'favourite+reblog') { if (type === 'favourite+reblog') {
for (const account of _accounts) { if (_accounts) {
if (account._types?.includes('favourite')) { for (const account of _accounts) {
favsCount++; if (account._types?.includes('favourite')) {
} favsCount++;
if (account._types?.includes('reblog')) { }
reblogsCount++; if (account._types?.includes('reblog')) {
reblogsCount++;
}
} }
} }
if (!reblogsCount && favsCount) type = 'favourite'; if (!reblogsCount && favsCount) type = 'favourite';
@ -296,6 +301,15 @@ function Notification({
people people
</b>{' '} </b>{' '}
</> </>
) : notificationsCount > 1 ? (
<>
<b>
<span title={notificationsCount}>
{shortenNumber(notificationsCount)}
</span>{' '}
people
</b>{' '}
</>
) : ( ) : (
account && ( account && (
<> <>
@ -405,6 +419,54 @@ function Notification({
</button> </button>
</p> </p>
)} )}
{!_accounts?.length && sampleAccounts?.length > 1 && (
<p class="avatars-stack">
{sampleAccounts.map((account) => (
<Fragment key={account.id}>
<a
key={account.id}
href={account.url}
rel="noopener noreferrer"
class="account-avatar-stack"
onClick={(e) => {
e.preventDefault();
states.showAccount = account;
}}
>
<Avatar
url={account.avatarStatic}
size="xxl"
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>{' '}
</Fragment>
))}
{notificationsCount > sampleAccounts.length && (
<Link
to={
instance ? `/${instance}/s/${status.id}` : `/s/${status.id}`
}
class="button small plain centered"
>
+{notificationsCount - sampleAccounts.length}
<Icon icon="chevron-right" />
</Link>
)}
</p>
)}
{_statuses?.length > 1 && ( {_statuses?.length > 1 && (
<ul class="notification-group-statuses"> <ul class="notification-group-statuses">
{_statuses.map((status) => ( {_statuses.map((status) => (

View file

@ -3,5 +3,6 @@
"@mastodon/list-exclusive": ">=4.2", "@mastodon/list-exclusive": ">=4.2",
"@mastodon/filtered-notifications": "~4.3 || >=4.3", "@mastodon/filtered-notifications": "~4.3 || >=4.3",
"@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3", "@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3",
"@mastodon/trending-link-posts": "~4.3 || >=4.3" "@mastodon/trending-link-posts": "~4.3 || >=4.3",
"@mastodon/grouped-notifications": "~4.3 || >=4.3"
} }

View file

@ -378,11 +378,17 @@ textarea:disabled {
width: 100%; width: 100%;
} }
button.small { :is(button, .button).small {
font-size: 90%; font-size: 90%;
padding: 4px 8px; padding: 4px 8px;
} }
.button.centered {
display: inline-flex;
justify-content: center;
align-items: center;
}
select.plain { select.plain {
border: 0; border: 0;
background-color: transparent; background-color: transparent;

View file

@ -17,6 +17,10 @@ import states, { saveStatus } from '../utils/states';
import { getCurrentAccountNS } from '../utils/store-utils'; import { getCurrentAccountNS } from '../utils/store-utils';
import Following from './following'; import Following from './following';
import {
getGroupedNotifications,
mastoFetchNotifications,
} from './notifications';
function Home() { function Home() {
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
@ -84,16 +88,13 @@ function NotificationsLink() {
); );
} }
const NOTIFICATIONS_LIMIT = 80;
const NOTIFICATIONS_DISPLAY_LIMIT = 5; const NOTIFICATIONS_DISPLAY_LIMIT = 5;
function NotificationsMenu({ anchorRef, state, onClose }) { function NotificationsMenu({ anchorRef, state, onClose }) {
const { masto, instance } = api(); const { masto, instance } = api();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const notificationsIterator = masto.v1.notifications.list({ const notificationsIterator = mastoFetchNotifications();
limit: NOTIFICATIONS_LIMIT,
});
async function fetchNotifications() { async function fetchNotifications() {
const allNotifications = await notificationsIterator.next(); const allNotifications = await notificationsIterator.next();
@ -106,7 +107,7 @@ function NotificationsMenu({ anchorRef, state, onClose }) {
}); });
}); });
const groupedNotifications = groupNotifications(notifications); const groupedNotifications = getGroupedNotifications(notifications);
states.notificationsLast = notifications[0]; states.notificationsLast = notifications[0];
states.notifications = groupedNotifications; states.notifications = groupedNotifications;

View file

@ -20,8 +20,11 @@ import Notification from '../components/notification';
import Status from '../components/status'; import Status from '../components/status';
import { api } from '../utils/api'; import { api } from '../utils/api';
import enhanceContent from '../utils/enhance-content'; import enhanceContent from '../utils/enhance-content';
import groupNotifications from '../utils/group-notifications'; import groupNotifications, {
groupNotifications2,
} from '../utils/group-notifications';
import handleContentLinks from '../utils/handle-content-links'; import handleContentLinks from '../utils/handle-content-links';
import mem from '../utils/mem';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import { getRegistration } from '../utils/push-notifications'; import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
@ -33,7 +36,8 @@ import usePageVisibility from '../utils/usePageVisibility';
import useScroll from '../utils/useScroll'; import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 80; const NOTIFICATIONS_LIMIT = 80;
const NOTIFICATIONS_GROUPED_LIMIT = 20;
const emptySearchParams = new URLSearchParams(); const emptySearchParams = new URLSearchParams();
const scrollIntoViewOptions = { const scrollIntoViewOptions = {
@ -42,6 +46,43 @@ const scrollIntoViewOptions = {
behavior: 'smooth', behavior: 'smooth',
}; };
const memSupportsGroupedNotifications = mem(
() => supports('@mastodon/grouped-notifications'),
{
maxAge: 1000 * 60 * 5, // 5 minutes
},
);
export function mastoFetchNotifications(opts = {}) {
const { masto } = api();
if (
states.settings.groupedNotificationsAlpha &&
memSupportsGroupedNotifications()
) {
// https://github.com/mastodon/mastodon/pull/29889
return masto.v2_alpha.notifications.list({
limit: NOTIFICATIONS_GROUPED_LIMIT,
...opts,
});
} else {
return masto.v1.notifications.list({
limit: NOTIFICATIONS_LIMIT,
...opts,
});
}
}
export function getGroupedNotifications(notifications) {
if (
states.settings.groupedNotificationsAlpha &&
memSupportsGroupedNotifications()
) {
return groupNotifications2(notifications);
} else {
return groupNotifications(notifications);
}
}
function Notifications({ columnMode }) { function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications'); useTitle('Notifications', '/notifications');
const { masto, instance } = api(); const { masto, instance } = api();
@ -67,8 +108,7 @@ function Notifications({ columnMode }) {
async function fetchNotifications(firstLoad) { async function fetchNotifications(firstLoad) {
if (firstLoad || !notificationsIterator.current) { if (firstLoad || !notificationsIterator.current) {
// Reset iterator // Reset iterator
notificationsIterator.current = masto.v1.notifications.list({ notificationsIterator.current = mastoFetchNotifications({
limit: LIMIT,
excludeTypes: ['follow_request'], excludeTypes: ['follow_request'],
}); });
} }
@ -115,10 +155,10 @@ function Notifications({ columnMode }) {
// console.log({ notifications }); // console.log({ notifications });
const groupedNotifications = groupNotifications(notifications); const groupedNotifications = getGroupedNotifications(notifications);
if (firstLoad) { if (firstLoad) {
states.notificationsLast = notifications[0]; states.notificationsLast = groupedNotifications[0];
states.notifications = groupedNotifications; states.notifications = groupedNotifications;
// Update last read marker // Update last read marker

View file

@ -21,6 +21,7 @@ import {
import showToast from '../utils/show-toast'; import showToast from '../utils/show-toast';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import supports from '../utils/supports';
const DEFAULT_TEXT_SIZE = 16; const DEFAULT_TEXT_SIZE = 16;
const TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20]; const TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20];
@ -496,6 +497,27 @@ function Settings({ onClose }) {
</div> </div>
</li> </li>
)} )}
{authenticated && supports('@mastodon/grouped-notifications') && (
<li>
<label>
<input
type="checkbox"
checked={snapStates.settings.groupedNotificationsAlpha}
onChange={(e) => {
states.settings.groupedNotificationsAlpha =
e.target.checked;
}}
/>{' '}
Server-side grouped notifications
</label>
<div class="sub-section insignificant">
<small>
Alpha-stage feature. Potentially improved grouping window
but basic grouping logic.
</small>
</div>
</li>
)}
{authenticated && ( {authenticated && (
<li> <li>
<label> <label>

View file

@ -28,7 +28,95 @@ export function fixNotifications(notifications) {
}); });
} }
function groupNotifications(notifications) { export function groupNotifications2(groupNotifications) {
// Massage grouped notifications to look like faux grouped notifications above
const newGroupNotifications = groupNotifications.map((gn) => {
const {
latestPageNotificationAt,
mostRecentNotificationId,
sampleAccounts,
notificationsCount,
} = gn;
return {
id: '' + mostRecentNotificationId,
createdAt: latestPageNotificationAt,
account: sampleAccounts[0],
...gn,
};
});
// DISABLED FOR NOW.
// Merge favourited and reblogged of same status into a single notification
// - new type: "favourite+reblog"
// - sum numbers for `notificationsCount` and `sampleAccounts`
// const mappedNotifications = {};
// const newNewGroupNotifications = [];
// for (let i = 0; i < newGroupNotifications.length; i++) {
// const gn = newGroupNotifications[i];
// const { type, status, createdAt, notificationsCount, sampleAccounts } = gn;
// const date = createdAt ? new Date(createdAt).toLocaleDateString() : '';
// let virtualType = type;
// if (type === 'favourite' || type === 'reblog') {
// virtualType = 'favourite+reblog';
// }
// const key = `${status?.id}-${virtualType}-${date}`;
// const mappedNotification = mappedNotifications[key];
// if (mappedNotification) {
// const accountIDs = mappedNotification.sampleAccounts.map((a) => a.id);
// sampleAccounts.forEach((a) => {
// if (!accountIDs.includes(a.id)) {
// mappedNotification.sampleAccounts.push(a);
// }
// });
// mappedNotification.notificationsCount = Math.max(
// mappedNotification.notificationsCount,
// notificationsCount,
// mappedNotification.sampleAccounts.length,
// );
// } else {
// mappedNotifications[key] = {
// ...gn,
// type: virtualType,
// };
// newNewGroupNotifications.push(mappedNotifications[key]);
// }
// }
// 2nd pass.
// - Group 1 account favourte/reblog multiple posts
// - _statuses: [status, status, ...]
const notificationsMap2 = {};
const newGroupNotifications2 = [];
for (let i = 0; i < newGroupNotifications.length; i++) {
const gn = newGroupNotifications[i];
const { type, account, _accounts, sampleAccounts, createdAt } = gn;
const date = createdAt ? new Date(createdAt).toLocaleDateString() : '';
const hasOneAccount =
sampleAccounts?.length === 1 || _accounts?.length === 1;
if ((type === 'favourite' || type === 'reblog') && hasOneAccount) {
const key = `${account?.id}-${type}-${date}`;
const mappedNotification = notificationsMap2[key];
if (mappedNotification) {
mappedNotification._statuses.push(gn.status);
mappedNotification.id += `-${gn.id}`;
} else {
let n = (notificationsMap2[key] = {
...gn,
type,
_statuses: [gn.status],
});
newGroupNotifications2.push(n);
}
} else {
newGroupNotifications2.push(gn);
}
}
return newGroupNotifications2;
}
export default function groupNotifications(notifications) {
// Filter out invalid notifications // Filter out invalid notifications
notifications = fixNotifications(notifications); notifications = fixNotifications(notifications);
@ -108,5 +196,3 @@ function groupNotifications(notifications) {
// return cleanNotifications; // return cleanNotifications;
return cleanNotifications2; return cleanNotifications2;
} }
export default groupNotifications;

View file

@ -70,6 +70,7 @@ const states = proxy({
mediaAltGenerator: false, mediaAltGenerator: false,
composerGIFPicker: false, composerGIFPicker: false,
cloakMode: false, cloakMode: false,
groupedNotificationsAlpha: false,
}, },
}); });
@ -104,6 +105,8 @@ export function initStates() {
states.settings.composerGIFPicker = states.settings.composerGIFPicker =
store.account.get('settings-composerGIFPicker') ?? false; store.account.get('settings-composerGIFPicker') ?? false;
states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false; states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false;
states.settings.groupedNotificationsAlpha =
store.account.get('settings-groupedNotificationsAlpha') ?? false;
} }
subscribeKey(states, 'notificationsLast', (v) => { subscribeKey(states, 'notificationsLast', (v) => {
@ -153,6 +156,9 @@ subscribe(states, (changes) => {
if (path.join('.') === 'settings.cloakMode') { if (path.join('.') === 'settings.cloakMode') {
store.account.set('settings-cloakMode', !!value); store.account.set('settings-cloakMode', !!value);
} }
if (path.join('.') === 'settings.groupedNotificationsAlpha') {
store.account.set('settings-groupedNotificationsAlpha', !!value);
}
} }
}); });