this repo has no description
at main 806 lines 25 kB view raw
1import { msg, t } from '@lingui/core/macro'; 2import { Plural, Select, Trans, useLingui } from '@lingui/react/macro'; 3import { Fragment } from 'preact'; 4import { memo } from 'preact/compat'; 5 6import { api } from '../utils/api'; 7import { isFiltered } from '../utils/filters'; 8import shortenNumber from '../utils/shorten-number'; 9import states, { statusKey } from '../utils/states'; 10import { getCurrentAccountID } from '../utils/store-utils'; 11import useTruncated from '../utils/useTruncated'; 12 13import Avatar from './avatar'; 14import CustomEmoji from './custom-emoji'; 15import FollowRequestButtons from './follow-request-buttons'; 16import Icon from './icon'; 17import Link from './link'; 18import NameText from './name-text'; 19import Status from './status'; 20 21const NOTIFICATION_ICONS = { 22 mention: 'comment', 23 status: 'notification', 24 reblog: 'rocket', 25 follow: 'follow', 26 follow_request: 'follow-add', 27 favourite: 'heart', 28 poll: 'poll', 29 update: 'pencil', 30 'admin.signup': 'account-edit', 31 'admin.report': 'account-warning', 32 severed_relationships: 'heart-break', 33 moderation_warning: 'alert', 34 emoji_reaction: 'emoji2', 35 'pleroma:emoji_reaction': 'emoji2', 36 annual_report: 'celebrate', 37}; 38 39/* 40Notification types 41================== 42mention = Someone mentioned you in their status 43status = Someone you enabled notifications for has posted a status 44reblog = Someone boosted one of your statuses 45follow = Someone followed you 46follow_request = Someone requested to follow you 47favourite = Someone favourited one of your statuses 48poll = A poll you have voted in or created has ended 49update = A status you interacted with has been edited 50admin.sign_up = Someone signed up (optionally sent to admins) 51admin.report = A new report has been filed 52severed_relationships = Severed relationships 53moderation_warning = Moderation warning 54*/ 55 56function emojiText({ account, emoji, emoji_url }) { 57 let url; 58 let staticUrl; 59 if (typeof emoji_url === 'string') { 60 url = emoji_url; 61 } else { 62 url = emoji_url?.url; 63 staticUrl = emoji_url?.staticUrl; 64 } 65 const emojiObject = url ? ( 66 <CustomEmoji url={url} staticUrl={staticUrl} alt={emoji} /> 67 ) : ( 68 emoji 69 ); 70 return ( 71 <Trans> 72 {account} reacted to your post with {emojiObject} 73 </Trans> 74 ); 75} 76 77const contentText = { 78 status: ({ account }) => <Trans>{account} published a post.</Trans>, 79 reblog: ({ 80 count, 81 account, 82 postsCount, 83 postType, 84 components: { Subject }, 85 }) => ( 86 <Plural 87 value={count} 88 _1={ 89 <Plural 90 value={postsCount} 91 _1={ 92 <Select 93 value={postType} 94 _reply={<Trans>{account} boosted your reply.</Trans>} 95 other={<Trans>{account} boosted your post.</Trans>} 96 /> 97 } 98 other={ 99 <Trans> 100 {account} boosted {postsCount} of your posts. 101 </Trans> 102 } 103 /> 104 } 105 other={ 106 <Select 107 value={postType} 108 _reply={ 109 <Trans> 110 <Subject clickable={count > 1}> 111 <span title={count}>{shortenNumber(count)}</span> people 112 </Subject>{' '} 113 boosted your reply. 114 </Trans> 115 } 116 other={ 117 <Trans> 118 <Subject clickable={count > 1}> 119 <span title={count}>{shortenNumber(count)}</span> people 120 </Subject>{' '} 121 boosted your post. 122 </Trans> 123 } 124 /> 125 } 126 /> 127 ), 128 follow: ({ account, count, components: { Subject } }) => ( 129 <Plural 130 value={count} 131 _1={<Trans>{account} followed you.</Trans>} 132 other={ 133 <Trans> 134 <Subject clickable={count > 1}> 135 <span title={count}>{shortenNumber(count)}</span> people 136 </Subject>{' '} 137 followed you. 138 </Trans> 139 } 140 /> 141 ), 142 follow_request: ({ account }) => ( 143 <Trans>{account} requested to follow you.</Trans> 144 ), 145 favourite: ({ 146 account, 147 count, 148 postsCount, 149 postType, 150 components: { Subject }, 151 }) => ( 152 <Plural 153 value={count} 154 _1={ 155 <Plural 156 value={postsCount} 157 _1={ 158 <Select 159 value={postType} 160 _reply={<Trans>{account} liked your reply.</Trans>} 161 other={<Trans>{account} liked your post.</Trans>} 162 /> 163 } 164 other={ 165 <Trans> 166 {account} liked {postsCount} of your posts. 167 </Trans> 168 } 169 /> 170 } 171 other={ 172 <Select 173 value={postType} 174 _reply={ 175 <Trans> 176 <Subject clickable={count > 1}> 177 <span title={count}>{shortenNumber(count)}</span> people 178 </Subject>{' '} 179 liked your reply. 180 </Trans> 181 } 182 other={ 183 <Trans> 184 <Subject clickable={count > 1}> 185 <span title={count}>{shortenNumber(count)}</span> people 186 </Subject>{' '} 187 liked your post. 188 </Trans> 189 } 190 /> 191 } 192 /> 193 ), 194 poll: () => t`A poll you have voted in or created has ended.`, 195 'poll-self': () => t`A poll you have created has ended.`, 196 'poll-voted': () => t`A poll you have voted in has ended.`, 197 update: () => t`A post you interacted with has been edited.`, 198 'favourite+reblog': ({ 199 count, 200 account, 201 postsCount, 202 postType, 203 components: { Subject }, 204 }) => ( 205 <Plural 206 value={count} 207 _1={ 208 <Plural 209 value={postsCount} 210 _1={ 211 <Select 212 value={postType} 213 _reply={<Trans>{account} boosted & liked your reply.</Trans>} 214 other={<Trans>{account} boosted & liked your post.</Trans>} 215 /> 216 } 217 other={ 218 <Trans> 219 {account} boosted & liked {postsCount} of your posts. 220 </Trans> 221 } 222 /> 223 } 224 other={ 225 <Select 226 value={postType} 227 _reply={ 228 <Trans> 229 <Subject clickable={count > 1}> 230 <span title={count}>{shortenNumber(count)}</span> people 231 </Subject>{' '} 232 boosted & liked your reply. 233 </Trans> 234 } 235 other={ 236 <Trans> 237 <Subject clickable={count > 1}> 238 <span title={count}>{shortenNumber(count)}</span> people 239 </Subject>{' '} 240 boosted & liked your post. 241 </Trans> 242 } 243 /> 244 } 245 /> 246 ), 247 'admin.sign_up': ({ account }) => <Trans>{account} signed up.</Trans>, 248 'admin.report': ({ account, targetAccount }) => ( 249 <Trans> 250 {account} reported {targetAccount} 251 </Trans> 252 ), 253 severed_relationships: ({ name }) => ( 254 <Trans> 255 Lost connections with <i>{name}</i>. 256 </Trans> 257 ), 258 moderation_warning: () => ( 259 <b> 260 <Trans>Moderation warning</Trans> 261 </b> 262 ), 263 emoji_reaction: emojiText, 264 'pleroma:emoji_reaction': emojiText, 265 annual_report: ({ year }) => <Trans>Your {year} #Wrapstodon is here!</Trans>, 266}; 267 268// account_suspension, domain_block, user_domain_block 269const SEVERED_RELATIONSHIPS_TEXT = { 270 account_suspension: ({ from, targetName }) => ( 271 <Trans> 272 An admin from <i>{from}</i> has suspended <i>{targetName}</i>, which means 273 you can no longer receive updates from them or interact with them. 274 </Trans> 275 ), 276 domain_block: ({ from, targetName, followersCount, followingCount }) => ( 277 <Trans> 278 An admin from <i>{from}</i> has blocked <i>{targetName}</i>. Affected 279 followers: {followersCount}, followings: {followingCount}. 280 </Trans> 281 ), 282 user_domain_block: ({ targetName, followersCount, followingCount }) => ( 283 <Trans> 284 You have blocked <i>{targetName}</i>. Removed followers: {followersCount}, 285 followings: {followingCount}. 286 </Trans> 287 ), 288}; 289 290const MODERATION_WARNING_TEXT = { 291 none: msg`Your account has received a moderation warning.`, 292 disable: msg`Your account has been disabled.`, 293 mark_statuses_as_sensitive: msg`Some of your posts have been marked as sensitive.`, 294 delete_statuses: msg`Some of your posts have been deleted.`, 295 sensitive: msg`Your posts will be marked as sensitive from now on.`, 296 silence: msg`Your account has been limited.`, 297 suspend: msg`Your account has been suspended.`, 298}; 299 300const AVATARS_LIMIT = 30; 301 302function Notification({ 303 notification, 304 instance, 305 isStatic, 306 disableContextMenu, 307}) { 308 const { _ } = useLingui(); 309 const { masto } = api(); 310 const { 311 id, 312 status, 313 account, 314 report, 315 event, 316 moderation_warning, 317 annualReport, 318 // Client-side grouped notification 319 _ids, 320 _accounts, 321 _statuses, 322 _groupKeys, 323 // Server-side grouped notification 324 sampleAccounts, 325 notificationsCount, 326 groupKey, 327 _notificationsCount, 328 _sampleAccountsCount, 329 } = notification; 330 let { type } = notification; 331 332 if (type === 'mention' && !status) { 333 // Could be deleted 334 return null; 335 } 336 337 // status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update 338 const actualStatus = status?.reblog || status; 339 const actualStatusID = actualStatus?.id; 340 341 const currentAccount = getCurrentAccountID(); 342 const isSelf = currentAccount === account?.id; 343 const isVoted = status?.poll?.voted; 344 const isReplyToOthers = 345 !!status?.inReplyToAccountId && 346 status?.inReplyToAccountId !== currentAccount && 347 status?.account?.id === currentAccount; 348 349 let favsCount = 0; 350 let reblogsCount = 0; 351 if (type === 'favourite+reblog') { 352 if (_accounts) { 353 for (const account of _accounts) { 354 if (account._types?.includes('favourite')) { 355 favsCount++; 356 } 357 if (account._types?.includes('reblog')) { 358 reblogsCount++; 359 } 360 } 361 } 362 if (!reblogsCount && favsCount) type = 'favourite'; 363 if (!favsCount && reblogsCount) type = 'reblog'; 364 } 365 366 let text; 367 if (type === 'poll') { 368 text = contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']; 369 } else if (contentText[type]) { 370 text = contentText[type]; 371 } else { 372 // Anticipate unhandled notification types, possibly from Mastodon forks or non-Mastodon instances 373 // This surfaces the error to the user, hoping that users will report it 374 text = t`[Unknown notification type: ${type}]`; 375 } 376 377 const Subject = ({ clickable, ...props }) => 378 clickable ? ( 379 <b tabIndex="0" onClick={handleOpenGenericAccounts} {...props} /> 380 ) : ( 381 <b {...props} /> 382 ); 383 384 const diffCount = 385 notificationsCount > 0 && notificationsCount > sampleAccounts?.length; 386 const expandAccounts = diffCount ? 'remote' : 'local'; 387 388 if (typeof text === 'function') { 389 const count = 390 (type === 'favourite' || type === 'reblog') && notificationsCount 391 ? diffCount 392 ? notificationsCount 393 : sampleAccounts?.length 394 : _accounts?.length || sampleAccounts?.length || (account ? 1 : 0); 395 const postsCount = _statuses?.length || (status ? 1 : 0); 396 if (type === 'admin.report') { 397 const targetAccount = report?.targetAccount; 398 if (targetAccount) { 399 text = text({ 400 account: <NameText account={account} showAvatar />, 401 targetAccount: <NameText account={targetAccount} showAvatar />, 402 }); 403 } 404 } else if (type === 'severed_relationships') { 405 const targetName = event?.targetName; 406 if (targetName) { 407 text = text({ name: targetName }); 408 } 409 } else if ( 410 (type === 'emoji_reaction' || type === 'pleroma:emoji_reaction') && 411 notification.emoji 412 ) { 413 const emojiURL = 414 notification.emoji_url || // This is string 415 status?.emojis?.find?.( 416 (emoji) => 417 emoji?.shortcode === 418 notification.emoji.replace(/^:/, '').replace(/:$/, ''), 419 ); // Emoji object instead of string 420 text = text({ 421 account: <NameText account={account} showAvatar />, 422 emoji: notification.emoji, 423 emojiURL, 424 }); 425 } else if (type === 'annual_report') { 426 text = text({ 427 ...notification.annualReport, 428 }); 429 } else { 430 text = text({ 431 account: account ? ( 432 <NameText account={account} showAvatar /> 433 ) : ( 434 sampleAccounts?.[0] && ( 435 <NameText account={sampleAccounts[0]} showAvatar /> 436 ) 437 ), 438 count, 439 postsCount, 440 postType: isReplyToOthers ? 'reply' : 'post', 441 components: { Subject }, 442 }); 443 } 444 } 445 446 const formattedCreatedAt = 447 notification.createdAt && new Date(notification.createdAt).toLocaleString(); 448 449 const genericAccountsHeading = 450 { 451 'favourite+reblog': t`Boosted/Liked by…`, 452 favourite: t`Liked by…`, 453 reblog: t`Boosted by…`, 454 follow: t`Followed by…`, 455 }[type] || t`Accounts`; 456 const handleOpenGenericAccounts = () => { 457 states.showGenericAccounts = { 458 heading: genericAccountsHeading, 459 accounts: _accounts, 460 showReactions: type === 'favourite+reblog', 461 excludeRelationshipAttrs: type === 'follow' ? ['followedBy'] : [], 462 postID: statusKey(actualStatusID, instance), 463 }; 464 }; 465 466 console.debug('RENDER Notification', notification.id); 467 468 // If there's a status and filter action is 'hide', then the notification is hidden 469 if (!!status?.filtered) { 470 const isOwnPost = status?.account?.id === currentAccount; 471 const filterInfo = isFiltered(status.filtered, 'notifications'); 472 if (!isSelf && !isOwnPost && filterInfo?.action === 'hide') { 473 return null; 474 } 475 } 476 477 return ( 478 <div 479 class={`notification notification-${type}`} 480 data-notification-id={_ids || id} 481 data-group-key={_groupKeys?.join(' ') || groupKey} 482 tabIndex="0" 483 > 484 <div 485 class={`notification-type notification-${type}`} 486 title={formattedCreatedAt} 487 > 488 {type === 'favourite+reblog' ? ( 489 <> 490 <Icon icon="rocket" size="xl" alt={type} class="reblog-icon" /> 491 <Icon icon="heart" size="xl" alt={type} class="favourite-icon" /> 492 </> 493 ) : ( 494 <Icon 495 icon={NOTIFICATION_ICONS[type] || 'notification'} 496 size="xl" 497 alt={type} 498 /> 499 )} 500 </div> 501 <div class="notification-content"> 502 {/* {(type === 'favourite+reblog' || 503 type === 'favourite' || 504 type === 'reblog') && ( 505 <> 506 💥 {type} {expandAccounts}{' '} 507 <mark> 508 N{_notificationsCount?.join(',')} + A 509 {_sampleAccountsCount?.join(',')} 510 </mark>{' '} 511 ‒{' '} 512 <mark> 513 N{notificationsCount} + A{sampleAccounts?.length} 514 </mark> 515 </> 516 )} */} 517 {type !== 'mention' && ( 518 <> 519 <p>{text}</p> 520 {type === 'follow_request' && ( 521 <FollowRequestButtons accountID={account.id} /> 522 )} 523 {type === 'severed_relationships' && ( 524 <div> 525 {SEVERED_RELATIONSHIPS_TEXT[event.type]({ 526 from: instance, 527 ...event, 528 })} 529 <br /> 530 <a 531 href={`https://${instance}/severed_relationships`} 532 target="_blank" 533 rel="noopener" 534 > 535 <Trans> 536 Learn more <Icon icon="external" size="s" /> 537 </Trans> 538 </a> 539 . 540 </div> 541 )} 542 {type === 'moderation_warning' && !!moderation_warning && ( 543 <div> 544 {_(MODERATION_WARNING_TEXT[moderation_warning.action]())} 545 <br /> 546 <a 547 href={`/disputes/strikes/${moderation_warning.id}`} 548 target="_blank" 549 rel="noopener" 550 > 551 <Trans> 552 Learn more <Icon icon="external" size="s" /> 553 </Trans> 554 </a> 555 </div> 556 )} 557 {type === 'annual_report' && ( 558 <div> 559 <Link to={`/annual_report/${annualReport?.year}`}> 560 <Trans>View #Wrapstodon</Trans> 561 </Link> 562 </div> 563 )} 564 </> 565 )} 566 {_accounts?.length > 1 && ( 567 <p class="avatars-stack"> 568 {_accounts.slice(0, AVATARS_LIMIT).map((account) => ( 569 <Fragment key={account.id}> 570 <a 571 key={account.id} 572 href={account.url} 573 rel="noopener" 574 class="account-avatar-stack" 575 onClick={(e) => { 576 e.preventDefault(); 577 states.showAccount = account; 578 }} 579 > 580 <Avatar 581 url={account.avatarStatic} 582 size={ 583 _accounts.length <= 10 584 ? 'xxl' 585 : _accounts.length < 20 586 ? 'xl' 587 : 'l' 588 } 589 key={account.id} 590 alt={`${account.displayName} @${account.acct}`} 591 squircle={account?.bot} 592 /> 593 {type === 'favourite+reblog' && ( 594 <div class="account-sub-icons"> 595 {account._types.map((type) => ( 596 <Icon 597 icon={NOTIFICATION_ICONS[type]} 598 size="s" 599 class={`${type}-icon`} 600 /> 601 ))} 602 </div> 603 )} 604 </a>{' '} 605 </Fragment> 606 ))} 607 {(type === 'favourite+reblog' || 608 type === 'favourite' || 609 type === 'reblog') && 610 expandAccounts === 'remote' ? ( 611 <button 612 type="button" 613 class="small plain" 614 data-group-keys={_groupKeys?.join(' ')} 615 onClick={() => { 616 states.showGenericAccounts = { 617 heading: genericAccountsHeading, 618 accounts: _accounts, 619 fetchAccounts: async () => { 620 const keyAccounts = await Promise.allSettled( 621 _groupKeys.map(async (gKey) => { 622 const iterator = masto.v2.notifications 623 .$select(gKey) 624 .accounts.list() 625 .values(); 626 return [gKey, (await iterator.next()).value]; 627 }), 628 ); 629 const accounts = []; 630 for (const keyAccount of keyAccounts) { 631 const [key, _accounts] = keyAccount.value; 632 const type = /^favourite/.test(key) 633 ? 'favourite' 634 : /^reblog/.test(key) 635 ? 'reblog' 636 : null; 637 if (!type) continue; 638 for (const account of _accounts) { 639 const theAccount = accounts.find( 640 (a) => a.id === account.id, 641 ); 642 if (theAccount) { 643 theAccount._types.push(type); 644 } else { 645 account._types = [type]; 646 accounts.push(account); 647 } 648 } 649 } 650 return { 651 done: true, 652 value: accounts, 653 }; 654 }, 655 showReactions: type === 'favourite+reblog', 656 postID: statusKey(actualStatusID, instance), 657 }; 658 }} 659 > 660 + 661 {(type === 'favourite' || type === 'reblog') && 662 notificationsCount - _accounts.length} 663 <Icon icon="chevron-down" /> 664 </button> 665 ) : ( 666 <button 667 type="button" 668 class="small plain" 669 onClick={handleOpenGenericAccounts} 670 > 671 {_accounts.length > AVATARS_LIMIT && 672 `+${_accounts.length - AVATARS_LIMIT}`} 673 <Icon icon="chevron-down" /> 674 </button> 675 )} 676 </p> 677 )} 678 {!_accounts?.length && sampleAccounts?.length > 1 && ( 679 <p class="avatars-stack"> 680 {sampleAccounts.map((account) => ( 681 <Fragment key={account.id}> 682 <a 683 key={account.id} 684 href={account.url} 685 rel="noopener" 686 class="account-avatar-stack" 687 onClick={(e) => { 688 e.preventDefault(); 689 states.showAccount = account; 690 }} 691 > 692 <Avatar 693 url={account.avatarStatic} 694 size="xxl" 695 key={account.id} 696 alt={`${account.displayName} @${account.acct}`} 697 squircle={account?.bot} 698 /> 699 {/* {type === 'favourite+reblog' && ( 700 <div class="account-sub-icons"> 701 {account._types.map((type) => ( 702 <Icon 703 icon={NOTIFICATION_ICONS[type]} 704 size="s" 705 class={`${type}-icon`} 706 /> 707 ))} 708 </div> 709 )} */} 710 </a>{' '} 711 </Fragment> 712 ))} 713 {notificationsCount > sampleAccounts.length && ( 714 <Link 715 to={ 716 instance ? `/${instance}/s/${status.id}` : `/s/${status.id}` 717 } 718 class="button small plain centered" 719 > 720 +{notificationsCount - sampleAccounts.length} 721 <Icon icon="chevron-right" /> 722 </Link> 723 )} 724 </p> 725 )} 726 {_statuses?.length > 1 && ( 727 <ul class="notification-group-statuses"> 728 {_statuses.map((status) => ( 729 <li key={status.id}> 730 <TruncatedLink 731 class={`status-link status-type-${type}`} 732 to={ 733 instance ? `/${instance}/s/${status.id}` : `/s/${status.id}` 734 } 735 > 736 <Status 737 status={status} 738 size="s" 739 previewMode 740 allowContextMenu 741 allowFilters 742 /> 743 </TruncatedLink> 744 </li> 745 ))} 746 </ul> 747 )} 748 {status && (!_statuses?.length || _statuses?.length <= 1) && ( 749 <TruncatedLink 750 class={`status-link status-type-${type}`} 751 to={ 752 instance 753 ? `/${instance}/s/${actualStatusID}` 754 : `/s/${actualStatusID}` 755 } 756 onContextMenu={ 757 !disableContextMenu 758 ? (e) => { 759 const post = e.target.querySelector('.status'); 760 if (post) { 761 // Fire a custom event to open the context menu 762 if (e.metaKey) return; 763 e.preventDefault(); 764 post.dispatchEvent( 765 new MouseEvent('contextmenu', { 766 clientX: e.clientX, 767 clientY: e.clientY, 768 }), 769 ); 770 } 771 } 772 : undefined 773 } 774 > 775 {isStatic ? ( 776 <Status 777 status={actualStatus} 778 size="s" 779 readOnly 780 allowContextMenu 781 allowFilters 782 /> 783 ) : ( 784 <Status 785 statusID={actualStatusID} 786 size="s" 787 readOnly 788 allowContextMenu 789 allowFilters 790 /> 791 )} 792 </TruncatedLink> 793 )} 794 </div> 795 </div> 796 ); 797} 798 799function TruncatedLink(props) { 800 const ref = useTruncated(); 801 return <Link {...props} data-read-more={t`Read more →`} ref={ref} />; 802} 803 804export default memo(Notification, (oldProps, newProps) => { 805 return oldProps.notification?.id === newProps.notification?.id; 806});