this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Experimental opt-in server-side grouped notifications

+246 -22
+68 -6
src/components/notification.jsx
··· 149 149 moderation_warning, 150 150 _accounts, 151 151 _statuses, 152 + // Grouped notification 153 + sampleAccounts, 154 + notificationsCount, 152 155 } = notification; 153 156 let { type } = notification; 154 157 ··· 167 170 let favsCount = 0; 168 171 let reblogsCount = 0; 169 172 if (type === 'favourite+reblog') { 170 - for (const account of _accounts) { 171 - if (account._types?.includes('favourite')) { 172 - favsCount++; 173 - } 174 - if (account._types?.includes('reblog')) { 175 - reblogsCount++; 173 + if (_accounts) { 174 + for (const account of _accounts) { 175 + if (account._types?.includes('favourite')) { 176 + favsCount++; 177 + } 178 + if (account._types?.includes('reblog')) { 179 + reblogsCount++; 180 + } 176 181 } 177 182 } 178 183 if (!reblogsCount && favsCount) type = 'favourite'; ··· 296 301 people 297 302 </b>{' '} 298 303 </> 304 + ) : notificationsCount > 1 ? ( 305 + <> 306 + <b> 307 + <span title={notificationsCount}> 308 + {shortenNumber(notificationsCount)} 309 + </span>{' '} 310 + people 311 + </b>{' '} 312 + </> 299 313 ) : ( 300 314 account && ( 301 315 <> ··· 403 417 `+${_accounts.length - AVATARS_LIMIT}`} 404 418 <Icon icon="chevron-down" /> 405 419 </button> 420 + </p> 421 + )} 422 + {!_accounts?.length && sampleAccounts?.length > 1 && ( 423 + <p class="avatars-stack"> 424 + {sampleAccounts.map((account) => ( 425 + <Fragment key={account.id}> 426 + <a 427 + key={account.id} 428 + href={account.url} 429 + rel="noopener noreferrer" 430 + class="account-avatar-stack" 431 + onClick={(e) => { 432 + e.preventDefault(); 433 + states.showAccount = account; 434 + }} 435 + > 436 + <Avatar 437 + url={account.avatarStatic} 438 + size="xxl" 439 + key={account.id} 440 + alt={`${account.displayName} @${account.acct}`} 441 + squircle={account?.bot} 442 + /> 443 + {/* {type === 'favourite+reblog' && ( 444 + <div class="account-sub-icons"> 445 + {account._types.map((type) => ( 446 + <Icon 447 + icon={NOTIFICATION_ICONS[type]} 448 + size="s" 449 + class={`${type}-icon`} 450 + /> 451 + ))} 452 + </div> 453 + )} */} 454 + </a>{' '} 455 + </Fragment> 456 + ))} 457 + {notificationsCount > sampleAccounts.length && ( 458 + <Link 459 + to={ 460 + instance ? `/${instance}/s/${status.id}` : `/s/${status.id}` 461 + } 462 + class="button small plain centered" 463 + > 464 + +{notificationsCount - sampleAccounts.length} 465 + <Icon icon="chevron-right" /> 466 + </Link> 467 + )} 406 468 </p> 407 469 )} 408 470 {_statuses?.length > 1 && (
+2 -1
src/data/features.json
··· 3 3 "@mastodon/list-exclusive": ">=4.2", 4 4 "@mastodon/filtered-notifications": "~4.3 || >=4.3", 5 5 "@mastodon/fetch-multiple-statuses": "~4.3 || >=4.3", 6 - "@mastodon/trending-link-posts": "~4.3 || >=4.3" 6 + "@mastodon/trending-link-posts": "~4.3 || >=4.3", 7 + "@mastodon/grouped-notifications": "~4.3 || >=4.3" 7 8 }
+7 -1
src/index.css
··· 378 378 width: 100%; 379 379 } 380 380 381 - button.small { 381 + :is(button, .button).small { 382 382 font-size: 90%; 383 383 padding: 4px 8px; 384 + } 385 + 386 + .button.centered { 387 + display: inline-flex; 388 + justify-content: center; 389 + align-items: center; 384 390 } 385 391 386 392 select.plain {
+6 -5
src/pages/home.jsx
··· 17 17 import { getCurrentAccountNS } from '../utils/store-utils'; 18 18 19 19 import Following from './following'; 20 + import { 21 + getGroupedNotifications, 22 + mastoFetchNotifications, 23 + } from './notifications'; 20 24 21 25 function Home() { 22 26 const snapStates = useSnapshot(states); ··· 84 88 ); 85 89 } 86 90 87 - const NOTIFICATIONS_LIMIT = 80; 88 91 const NOTIFICATIONS_DISPLAY_LIMIT = 5; 89 92 function NotificationsMenu({ anchorRef, state, onClose }) { 90 93 const { masto, instance } = api(); 91 94 const snapStates = useSnapshot(states); 92 95 const [uiState, setUIState] = useState('default'); 93 96 94 - const notificationsIterator = masto.v1.notifications.list({ 95 - limit: NOTIFICATIONS_LIMIT, 96 - }); 97 + const notificationsIterator = mastoFetchNotifications(); 97 98 98 99 async function fetchNotifications() { 99 100 const allNotifications = await notificationsIterator.next(); ··· 106 107 }); 107 108 }); 108 109 109 - const groupedNotifications = groupNotifications(notifications); 110 + const groupedNotifications = getGroupedNotifications(notifications); 110 111 111 112 states.notificationsLast = notifications[0]; 112 113 states.notifications = groupedNotifications;
+46 -6
src/pages/notifications.jsx
··· 20 20 import Status from '../components/status'; 21 21 import { api } from '../utils/api'; 22 22 import enhanceContent from '../utils/enhance-content'; 23 - import groupNotifications from '../utils/group-notifications'; 23 + import groupNotifications, { 24 + groupNotifications2, 25 + } from '../utils/group-notifications'; 24 26 import handleContentLinks from '../utils/handle-content-links'; 27 + import mem from '../utils/mem'; 25 28 import niceDateTime from '../utils/nice-date-time'; 26 29 import { getRegistration } from '../utils/push-notifications'; 27 30 import shortenNumber from '../utils/shorten-number'; ··· 33 36 import useScroll from '../utils/useScroll'; 34 37 import useTitle from '../utils/useTitle'; 35 38 36 - const LIMIT = 80; 39 + const NOTIFICATIONS_LIMIT = 80; 40 + const NOTIFICATIONS_GROUPED_LIMIT = 20; 37 41 const emptySearchParams = new URLSearchParams(); 38 42 39 43 const scrollIntoViewOptions = { ··· 42 46 behavior: 'smooth', 43 47 }; 44 48 49 + const memSupportsGroupedNotifications = mem( 50 + () => supports('@mastodon/grouped-notifications'), 51 + { 52 + maxAge: 1000 * 60 * 5, // 5 minutes 53 + }, 54 + ); 55 + 56 + export function mastoFetchNotifications(opts = {}) { 57 + const { masto } = api(); 58 + if ( 59 + states.settings.groupedNotificationsAlpha && 60 + memSupportsGroupedNotifications() 61 + ) { 62 + // https://github.com/mastodon/mastodon/pull/29889 63 + return masto.v2_alpha.notifications.list({ 64 + limit: NOTIFICATIONS_GROUPED_LIMIT, 65 + ...opts, 66 + }); 67 + } else { 68 + return masto.v1.notifications.list({ 69 + limit: NOTIFICATIONS_LIMIT, 70 + ...opts, 71 + }); 72 + } 73 + } 74 + 75 + export function getGroupedNotifications(notifications) { 76 + if ( 77 + states.settings.groupedNotificationsAlpha && 78 + memSupportsGroupedNotifications() 79 + ) { 80 + return groupNotifications2(notifications); 81 + } else { 82 + return groupNotifications(notifications); 83 + } 84 + } 85 + 45 86 function Notifications({ columnMode }) { 46 87 useTitle('Notifications', '/notifications'); 47 88 const { masto, instance } = api(); ··· 67 108 async function fetchNotifications(firstLoad) { 68 109 if (firstLoad || !notificationsIterator.current) { 69 110 // Reset iterator 70 - notificationsIterator.current = masto.v1.notifications.list({ 71 - limit: LIMIT, 111 + notificationsIterator.current = mastoFetchNotifications({ 72 112 excludeTypes: ['follow_request'], 73 113 }); 74 114 } ··· 115 155 116 156 // console.log({ notifications }); 117 157 118 - const groupedNotifications = groupNotifications(notifications); 158 + const groupedNotifications = getGroupedNotifications(notifications); 119 159 120 160 if (firstLoad) { 121 - states.notificationsLast = notifications[0]; 161 + states.notificationsLast = groupedNotifications[0]; 122 162 states.notifications = groupedNotifications; 123 163 124 164 // Update last read marker
+22
src/pages/settings.jsx
··· 21 21 import showToast from '../utils/show-toast'; 22 22 import states from '../utils/states'; 23 23 import store from '../utils/store'; 24 + import supports from '../utils/supports'; 24 25 25 26 const DEFAULT_TEXT_SIZE = 16; 26 27 const TEXT_SIZES = [14, 15, 16, 17, 18, 19, 20]; ··· 492 493 img-alt-api 493 494 </a> 494 495 . May not work well. Only for images and in English. 496 + </small> 497 + </div> 498 + </li> 499 + )} 500 + {authenticated && supports('@mastodon/grouped-notifications') && ( 501 + <li> 502 + <label> 503 + <input 504 + type="checkbox" 505 + checked={snapStates.settings.groupedNotificationsAlpha} 506 + onChange={(e) => { 507 + states.settings.groupedNotificationsAlpha = 508 + e.target.checked; 509 + }} 510 + />{' '} 511 + Server-side grouped notifications 512 + </label> 513 + <div class="sub-section insignificant"> 514 + <small> 515 + Alpha-stage feature. Potentially improved grouping window 516 + but basic grouping logic. 495 517 </small> 496 518 </div> 497 519 </li>
+89 -3
src/utils/group-notifications.jsx
··· 28 28 }); 29 29 } 30 30 31 - function groupNotifications(notifications) { 31 + export function groupNotifications2(groupNotifications) { 32 + // Massage grouped notifications to look like faux grouped notifications above 33 + const newGroupNotifications = groupNotifications.map((gn) => { 34 + const { 35 + latestPageNotificationAt, 36 + mostRecentNotificationId, 37 + sampleAccounts, 38 + notificationsCount, 39 + } = gn; 40 + 41 + return { 42 + id: '' + mostRecentNotificationId, 43 + createdAt: latestPageNotificationAt, 44 + account: sampleAccounts[0], 45 + ...gn, 46 + }; 47 + }); 48 + 49 + // DISABLED FOR NOW. 50 + // Merge favourited and reblogged of same status into a single notification 51 + // - new type: "favourite+reblog" 52 + // - sum numbers for `notificationsCount` and `sampleAccounts` 53 + // const mappedNotifications = {}; 54 + // const newNewGroupNotifications = []; 55 + // for (let i = 0; i < newGroupNotifications.length; i++) { 56 + // const gn = newGroupNotifications[i]; 57 + // const { type, status, createdAt, notificationsCount, sampleAccounts } = gn; 58 + // const date = createdAt ? new Date(createdAt).toLocaleDateString() : ''; 59 + // let virtualType = type; 60 + // if (type === 'favourite' || type === 'reblog') { 61 + // virtualType = 'favourite+reblog'; 62 + // } 63 + // const key = `${status?.id}-${virtualType}-${date}`; 64 + // const mappedNotification = mappedNotifications[key]; 65 + // if (mappedNotification) { 66 + // const accountIDs = mappedNotification.sampleAccounts.map((a) => a.id); 67 + // sampleAccounts.forEach((a) => { 68 + // if (!accountIDs.includes(a.id)) { 69 + // mappedNotification.sampleAccounts.push(a); 70 + // } 71 + // }); 72 + // mappedNotification.notificationsCount = Math.max( 73 + // mappedNotification.notificationsCount, 74 + // notificationsCount, 75 + // mappedNotification.sampleAccounts.length, 76 + // ); 77 + // } else { 78 + // mappedNotifications[key] = { 79 + // ...gn, 80 + // type: virtualType, 81 + // }; 82 + // newNewGroupNotifications.push(mappedNotifications[key]); 83 + // } 84 + // } 85 + 86 + // 2nd pass. 87 + // - Group 1 account favourte/reblog multiple posts 88 + // - _statuses: [status, status, ...] 89 + const notificationsMap2 = {}; 90 + const newGroupNotifications2 = []; 91 + for (let i = 0; i < newGroupNotifications.length; i++) { 92 + const gn = newGroupNotifications[i]; 93 + const { type, account, _accounts, sampleAccounts, createdAt } = gn; 94 + const date = createdAt ? new Date(createdAt).toLocaleDateString() : ''; 95 + const hasOneAccount = 96 + sampleAccounts?.length === 1 || _accounts?.length === 1; 97 + if ((type === 'favourite' || type === 'reblog') && hasOneAccount) { 98 + const key = `${account?.id}-${type}-${date}`; 99 + const mappedNotification = notificationsMap2[key]; 100 + if (mappedNotification) { 101 + mappedNotification._statuses.push(gn.status); 102 + mappedNotification.id += `-${gn.id}`; 103 + } else { 104 + let n = (notificationsMap2[key] = { 105 + ...gn, 106 + type, 107 + _statuses: [gn.status], 108 + }); 109 + newGroupNotifications2.push(n); 110 + } 111 + } else { 112 + newGroupNotifications2.push(gn); 113 + } 114 + } 115 + 116 + return newGroupNotifications2; 117 + } 118 + 119 + export default function groupNotifications(notifications) { 32 120 // Filter out invalid notifications 33 121 notifications = fixNotifications(notifications); 34 122 ··· 108 196 // return cleanNotifications; 109 197 return cleanNotifications2; 110 198 } 111 - 112 - export default groupNotifications;
+6
src/utils/states.js
··· 70 70 mediaAltGenerator: false, 71 71 composerGIFPicker: false, 72 72 cloakMode: false, 73 + groupedNotificationsAlpha: false, 73 74 }, 74 75 }); 75 76 ··· 104 105 states.settings.composerGIFPicker = 105 106 store.account.get('settings-composerGIFPicker') ?? false; 106 107 states.settings.cloakMode = store.account.get('settings-cloakMode') ?? false; 108 + states.settings.groupedNotificationsAlpha = 109 + store.account.get('settings-groupedNotificationsAlpha') ?? false; 107 110 } 108 111 109 112 subscribeKey(states, 'notificationsLast', (v) => { ··· 152 155 } 153 156 if (path.join('.') === 'settings.cloakMode') { 154 157 store.account.set('settings-cloakMode', !!value); 158 + } 159 + if (path.join('.') === 'settings.groupedNotificationsAlpha') { 160 + store.account.set('settings-groupedNotificationsAlpha', !!value); 155 161 } 156 162 } 157 163 });