this repo has no description
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});