@blocklet/ui-react 2.12.61 → 2.12.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,18 +4,29 @@ import PropTypes from "prop-types";
4
4
  import { useCallback, useEffect } from "react";
5
5
  import { temp as colors } from "@arcblock/ux/lib/Colors";
6
6
  import { IconButton } from "@mui/material";
7
+ import { useSnackbar } from "notistack";
7
8
  import NotificationsOutlinedIcon from "@arcblock/icons/lib/Notification";
8
9
  import { useCreation } from "ahooks";
9
- import { EVENTS } from "@abtnode/constant";
10
+ import { EVENTS, WELLKNOWN_SERVICE_PATH_PREFIX } from "@abtnode/constant";
11
+ import { joinURL, withQuery } from "ufo";
10
12
  import { useListenWsClient } from "./ws.js";
13
+ import NotificationSnackbar from "../Notifications/Snackbar.js";
14
+ import { compareVersions } from "../utils.js";
15
+ const viewAllUrl = joinURL(WELLKNOWN_SERVICE_PATH_PREFIX, "user", "notifications");
16
+ const getNotificationLink = (notification) => {
17
+ return withQuery(viewAllUrl, {
18
+ id: notification.id,
19
+ severity: notification.severity || "all",
20
+ componentDid: notification.source === "system" ? "system" : notification.componentDid || "all"
21
+ });
22
+ };
11
23
  export default function NotificationAddon({ session = {} }) {
12
24
  const { unReadCount, user, setUnReadCount } = session;
13
25
  const userDid = useCreation(() => user?.did, [user]);
14
- const viewAllUrl = useCreation(() => {
15
- const { navigation } = window.blocklet ?? {};
16
- const notification = navigation?.find((x) => x.id === "/userCenter/notification");
17
- return notification?.link;
18
- });
26
+ const { enqueueSnackbar } = useSnackbar();
27
+ const serverVersion = useCreation(() => {
28
+ return window.blocklet?.serverVersion;
29
+ }, []);
19
30
  const wsClient = useListenWsClient("user");
20
31
  const listenEvent = useCreation(
21
32
  () => `${window.blocklet.did}/${userDid}/${EVENTS.NOTIFICATION_BLOCKLET_CREATE}`,
@@ -31,15 +42,27 @@ export default function NotificationAddon({ session = {} }) {
31
42
  const { receiver: notificationReceiver } = receivers[0] ?? {};
32
43
  if (notificationReceiver === userDid) {
33
44
  setUnReadCount((x) => x + 1);
45
+ const isCompatible = compareVersions(serverVersion, "1.16.42-beta-20250407");
46
+ if (notification.source === "component" && isCompatible) {
47
+ const link = getNotificationLink(notification);
48
+ const { severity, description } = notification || {};
49
+ const disableAutoHide = ["error", "warning"].includes(severity) || notification.sticky;
50
+ enqueueSnackbar(description, {
51
+ variant: severity,
52
+ autoHideDuration: disableAutoHide ? null : 5e3,
53
+ // eslint-disable-next-line react/no-unstable-nested-components
54
+ content: (key) => /* @__PURE__ */ jsx(NotificationSnackbar, { viewAllUrl: link, keyId: key, notification })
55
+ });
56
+ }
34
57
  }
35
58
  },
36
- [userDid, setUnReadCount]
59
+ [userDid, setUnReadCount, enqueueSnackbar, serverVersion]
37
60
  );
38
61
  const readListenCallback = useCallback(
39
62
  (data) => {
40
63
  const { receiver, readCount } = data ?? {};
41
64
  if (receiver === userDid) {
42
- setUnReadCount((x) => x - readCount <= 0 ? 0 : x - readCount);
65
+ setUnReadCount((x) => Math.max(x - readCount, 0));
43
66
  }
44
67
  },
45
68
  [userDid, setUnReadCount]
package/lib/utils.d.ts CHANGED
@@ -7,3 +7,4 @@ export function isIconifyString(str: any): boolean;
7
7
  export function matchPath(path: any): any;
8
8
  export function matchPaths(paths?: any[]): number;
9
9
  export function splitNavColumns(items: any, options?: {}): never[][];
10
+ export function compareVersions(version1: any, version2: any): any;
package/lib/utils.js CHANGED
@@ -1,3 +1,5 @@
1
+ import semver from 'semver';
2
+
1
3
  export const mapRecursive = (array, fn, childrenKey = 'children') => {
2
4
  return array.map((item) => {
3
5
  if (Array.isArray(item[childrenKey])) {
@@ -151,3 +153,27 @@ export const splitNavColumns = (items, options = {}) => {
151
153
 
152
154
  return result;
153
155
  };
156
+
157
+ /**
158
+ * 比较两个版本号,用于判断版本号是否兼容
159
+ * @param {*} version1
160
+ * @param {*} version2
161
+ * @returns 0: 版本相同, -1: version1 < version2, 1: version1 > version2
162
+ */
163
+ export const compareVersions = (version1, version2) => {
164
+ const getDateVersion = (version) => {
165
+ const match = version.match(/^(\d+\.\d+\.\d+(?:-[^-]+?-\d{8}))/);
166
+ return match ? match[1] : version;
167
+ };
168
+
169
+ const dateVersion1 = getDateVersion(version1);
170
+ const dateVersion2 = getDateVersion(version2);
171
+
172
+ // 如果基础版本相同,但完整版本不同(意味着有额外部分),返回false
173
+ if (dateVersion1 === dateVersion2 && version1 !== version2) {
174
+ return false;
175
+ }
176
+
177
+ // 其他情况正常比较
178
+ return semver.gte(dateVersion1, dateVersion2);
179
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blocklet/ui-react",
3
- "version": "2.12.61",
3
+ "version": "2.12.62",
4
4
  "description": "Some useful front-end web components that can be used in Blocklets.",
5
5
  "keywords": [
6
6
  "react",
@@ -33,30 +33,35 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@abtnode/constant": "^1.16.41",
36
- "@arcblock/bridge": "^2.12.61",
37
- "@arcblock/react-hooks": "^2.12.61",
36
+ "@abtnode/util": "^1.16.41",
37
+ "@arcblock/bridge": "^2.12.62",
38
+ "@arcblock/react-hooks": "^2.12.62",
38
39
  "@arcblock/ws": "^1.19.18",
39
40
  "@blocklet/did-space-react": "^1.0.41",
40
41
  "@iconify-icons/logos": "^1.2.36",
41
42
  "@iconify-icons/material-symbols": "^1.2.58",
43
+ "@iconify-icons/tabler": "^1.2.95",
42
44
  "@iconify/react": "^5.2.0",
43
45
  "ahooks": "^3.7.10",
44
46
  "axios": "^1.7.5",
45
47
  "clsx": "^2.1.0",
46
48
  "core-js": "^3.25.5",
47
49
  "dayjs": "^1.11.5",
50
+ "dompurify": "^3.2.1",
48
51
  "iconify-icon": "^1.0.8",
49
52
  "iconify-icons-material-symbols-400": "^0.0.1",
50
53
  "is-url": "^1.2.4",
51
54
  "js-cookie": "^2.2.1",
52
55
  "lodash": "^4.17.21",
53
56
  "moment-timezone": "^0.5.37",
57
+ "notistack": "^2.0.5",
54
58
  "p-all": "^5.0.0",
55
59
  "p-queue": "^6.6.2",
56
60
  "p-wait-for": "^5.0.2",
57
61
  "prop-types": "^15.8.1",
58
62
  "react-error-boundary": "^3.1.4",
59
63
  "react-placeholder": "^4.1.0",
64
+ "semver": "^7.6.3",
60
65
  "type-fest": "^4.22.0",
61
66
  "ua-parser-js": "^1.0.37",
62
67
  "ufo": "^1.5.3",
@@ -81,11 +86,12 @@
81
86
  "@babel/core": "^7.19.3",
82
87
  "@babel/preset-env": "^7.19.3",
83
88
  "@babel/preset-react": "^7.18.6",
89
+ "@types/dompurify": "^3.2.0",
84
90
  "@types/ua-parser-js": "^0.7.39",
85
91
  "eslint-plugin-react-hooks": "^4.6.0",
86
92
  "glob": "^10.3.3",
87
93
  "jest": "^29.7.0",
88
94
  "unbuild": "^2.0.0"
89
95
  },
90
- "gitHead": "ba8229f0a4a824a565e04056b78130c75b5bae14"
96
+ "gitHead": "30267c6d2ad1fdc5ce8c13c6d2323c8b7c58ee65"
91
97
  }
@@ -7,6 +7,7 @@ declare module '@arcblock/ux/lib/ErrorBoundary';
7
7
  declare module '@arcblock/did-connect/*';
8
8
  declare module '@arcblock/did-connect/lib/Session';
9
9
  declare module '@abtnode/constant';
10
+ declare module '@abtnode/util/lib/notification-preview/*';
10
11
 
11
12
  declare module 'is-url';
12
13
 
@@ -0,0 +1,270 @@
1
+ import { forwardRef, useEffect, useState } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import styled from '@emotion/styled';
4
+
5
+ import { amber, green, common } from '@mui/material/colors';
6
+ import IconButton from '@mui/material/IconButton';
7
+ import Box from '@mui/material/Box';
8
+ import Typography from '@mui/material/Typography';
9
+ import { useCreation } from 'ahooks';
10
+ import CloseIcon from '@mui/icons-material/Close';
11
+ import { useNavigate } from 'react-router-dom';
12
+ import { Icon, IconifyIcon } from '@iconify/react';
13
+ import CheckIcon from '@iconify-icons/tabler/circle-check';
14
+ import WarningIcon from '@iconify-icons/tabler/exclamation-circle';
15
+ import InfoIcon from '@iconify-icons/tabler/info-circle';
16
+ import ErrorIcon from '@iconify-icons/tabler/xbox-x';
17
+
18
+ import { useSnackbar, SnackbarContent } from 'notistack';
19
+
20
+ import useWidth from './hooks/use-width';
21
+ import useActivityTitle from './hooks/use-title';
22
+ import { isIncludeActivity, toClickableSpan, sanitize } from './utils';
23
+
24
+ // Define type for breakpoints
25
+ type BreakpointType = 'xl' | 'lg' | 'md' | 'sm';
26
+
27
+ // Define types for notification component
28
+ interface NotificationProps {
29
+ keyId: number;
30
+ notification: {
31
+ severity?: string;
32
+ title?: string;
33
+ description?: string;
34
+ activity?: any;
35
+ actorInfo?: any;
36
+ };
37
+ viewAllUrl: string;
38
+ content?: React.ReactNode;
39
+ }
40
+
41
+ const variants: Record<string, IconifyIcon> = {
42
+ normal: InfoIcon,
43
+ success: CheckIcon,
44
+ info: InfoIcon,
45
+ warning: WarningIcon,
46
+ error: ErrorIcon,
47
+ };
48
+
49
+ // Styled components
50
+ const CloseIconStyled = styled(CloseIcon)`
51
+ font-size: 20px;
52
+ `;
53
+
54
+ const MessageDiv = styled.div`
55
+ display: flex;
56
+ align-items: flex-start;
57
+ gap: 8px;
58
+ flex: 1;
59
+ width: 0;
60
+ `;
61
+
62
+ const ActionDiv = styled.div`
63
+ display: flex;
64
+ align-items: center;
65
+ margin-left: auto;
66
+ margin-right: -8px;
67
+ width: 44px;
68
+ `;
69
+
70
+ // Define breakpoints map
71
+ const breakpointsMap: Record<BreakpointType, string> = {
72
+ xl: '400px',
73
+ lg: '400px',
74
+ md: '400px',
75
+ sm: '300px',
76
+ };
77
+
78
+ const StyledSnackbarContent = styled(SnackbarContent)<{ severity?: string; breakpoint: string }>`
79
+ display: flex;
80
+ color: #fff;
81
+ align-items: center;
82
+ padding: 12px 16px;
83
+ border-radius: 4px;
84
+ box-shadow:
85
+ 0px 3px 5px -1px rgba(0, 0, 0, 0.2),
86
+ 0px 6px 10px 0px rgba(0, 0, 0, 0.14),
87
+ 0px 1px 18px 0px rgba(0, 0, 0, 0.12);
88
+
89
+ ${({ severity, breakpoint }) => {
90
+ const width = breakpointsMap[breakpoint as BreakpointType] || '400px';
91
+
92
+ if (severity === 'success') {
93
+ return `
94
+ background-color: ${green[600]} !important;
95
+ width: ${width};
96
+ `;
97
+ }
98
+
99
+ if (severity === 'error') {
100
+ return `
101
+ background-color: #d32f2f !important;
102
+ width: ${width};
103
+ `;
104
+ }
105
+
106
+ if (severity === 'info') {
107
+ return `
108
+ background-color: #1976d2 !important;
109
+ width: ${width};
110
+ `;
111
+ }
112
+
113
+ if (severity === 'warning') {
114
+ return `
115
+ background-color: ${amber[700]} !important;
116
+ width: ${width};
117
+ `;
118
+ }
119
+
120
+ return `
121
+ background-color: #333;
122
+ width: ${width};
123
+ `;
124
+ }}
125
+ `;
126
+
127
+ const ClickableDiv = styled.div`
128
+ cursor: pointer;
129
+ display: flex;
130
+ flex-direction: column;
131
+ .title {
132
+ overflow: hidden;
133
+ text-overflow: ellipsis;
134
+ display: -webkit-box;
135
+ -webkit-line-clamp: 1;
136
+ -webkit-box-orient: vertical;
137
+ font-weight: bold;
138
+ }
139
+ .desc {
140
+ overflow: hidden;
141
+ text-overflow: ellipsis;
142
+ display: -webkit-box;
143
+ -webkit-line-clamp: 3;
144
+ -webkit-box-orient: vertical;
145
+ word-break: break-word;
146
+ line-height: 1.2;
147
+ .link,
148
+ .dapp,
149
+ .common {
150
+ color: ${common.white};
151
+ }
152
+ }
153
+ `;
154
+
155
+ const NotificationComponent = forwardRef<HTMLDivElement, NotificationProps>(
156
+ ({ keyId: key, notification = {}, viewAllUrl, content }, ref) => {
157
+ const breakpoint = useWidth();
158
+ const [description, setDescription] = useState(notification.description || '');
159
+ const icon = variants[notification.severity || ''];
160
+
161
+ const { closeSnackbar } = useSnackbar();
162
+ const onClickDismiss = () => closeSnackbar(key);
163
+
164
+ useEffect(() => {
165
+ toClickableSpan(notification.description || '', 'en').then((res) => {
166
+ setDescription(res);
167
+ });
168
+ }, [notification.description]);
169
+
170
+ const navigate = useNavigate();
171
+ const onGoNotification = (e: any) => {
172
+ e.stopPropagation();
173
+ closeSnackbar(key);
174
+
175
+ // 已确认 viewAllUrl都是本地的相对地址
176
+ if (!e?.customPreventRedirect) {
177
+ navigate(viewAllUrl);
178
+ }
179
+ };
180
+
181
+ const includeActivity = useCreation(() => {
182
+ return isIncludeActivity(notification);
183
+ }, [notification]);
184
+ const activity = useCreation(() => {
185
+ return notification?.activity;
186
+ }, [notification]);
187
+
188
+ const activityMeta = useCreation(() => {
189
+ if (!activity || activity.type === 'tips') {
190
+ return null;
191
+ }
192
+
193
+ return activity?.meta;
194
+ }, [activity]);
195
+
196
+ const activityTitle = useActivityTitle({
197
+ activity,
198
+ users: [notification?.actorInfo],
199
+ actors: [notification?.activity?.actor],
200
+ extra: {
201
+ linkColor: common.white,
202
+ },
203
+ });
204
+
205
+ return (
206
+ <StyledSnackbarContent ref={ref} severity={notification.severity} breakpoint={breakpoint}>
207
+ <MessageDiv>
208
+ {icon ? <Icon icon={icon} fontSize={24} /> : null}
209
+ <ClickableDiv onClick={onGoNotification} style={{ width: 'calc(100% - 30px)' }}>
210
+ <Box>
211
+ {includeActivity ? (
212
+ <>
213
+ <span className="title">{activityTitle}</span>
214
+ {activityMeta ? (
215
+ <Typography
216
+ variant="subtitle2"
217
+ fontSize={16}
218
+ component="p"
219
+ sx={{
220
+ display: '-webkit-box',
221
+ overflow: 'hidden',
222
+ textOverflow: 'ellipsis',
223
+ WebkitLineClamp: 3,
224
+ WebkitBoxOrient: 'vertical',
225
+ color: common.white,
226
+ lineHeight: 1.2,
227
+ }}>
228
+ {activityMeta?.content}
229
+ </Typography>
230
+ ) : null}
231
+ </>
232
+ ) : (
233
+ <>
234
+ <span className="title">{notification.title}</span>
235
+ {content || (
236
+ <Typography
237
+ component="span"
238
+ className="desc"
239
+ dangerouslySetInnerHTML={{ __html: sanitize(description) }}
240
+ />
241
+ )}
242
+ </>
243
+ )}
244
+ </Box>
245
+ </ClickableDiv>
246
+ </MessageDiv>
247
+ <ActionDiv>
248
+ <IconButton key="close" aria-label="close" color="inherit" onClick={onClickDismiss} size="large">
249
+ <CloseIconStyled />
250
+ </IconButton>
251
+ </ActionDiv>
252
+ </StyledSnackbarContent>
253
+ );
254
+ }
255
+ );
256
+
257
+ NotificationComponent.displayName = 'NotificationComponent';
258
+
259
+ NotificationComponent.propTypes = {
260
+ viewAllUrl: PropTypes.string.isRequired,
261
+ keyId: PropTypes.number.isRequired,
262
+ notification: PropTypes.object.isRequired,
263
+ content: PropTypes.node,
264
+ };
265
+
266
+ NotificationComponent.defaultProps = {
267
+ content: null,
268
+ };
269
+
270
+ export default NotificationComponent;
@@ -0,0 +1,248 @@
1
+ /* eslint-disable react/prop-types */
2
+ import React, { MouseEvent } from 'react';
3
+ import { useCreation, useMemoizedFn } from 'ahooks';
4
+ import { temp as colors } from '@arcblock/ux/lib/Colors';
5
+ import Link from '@mui/material/Link';
6
+ import { WELLKNOWN_SERVICE_PATH_PREFIX } from '@abtnode/constant';
7
+ import { joinURL, withQuery } from 'ufo';
8
+ import isEmpty from 'lodash/isEmpty';
9
+ import { getActivityLink } from '../utils';
10
+
11
+ /**
12
+ * Activity types enum for type safety
13
+ * @readonly
14
+ * @enum {string}
15
+ */
16
+ const ACTIVITY_TYPES = {
17
+ COMMENT: 'comment',
18
+ LIKE: 'like',
19
+ FOLLOW: 'follow',
20
+ TIPS: 'tips',
21
+ MENTION: 'mention',
22
+ ASSIGN: 'assign',
23
+ } as const;
24
+
25
+ type ActivityTypeValues = (typeof ACTIVITY_TYPES)[keyof typeof ACTIVITY_TYPES];
26
+
27
+ /**
28
+ * Activity descriptions mapping
29
+ * @type {Object.<string, React.ReactNode>}
30
+ */
31
+ const ACTIVITY_DESCRIPTIONS: Record<ActivityTypeValues, (targetType: string, count?: number) => React.ReactNode> = {
32
+ comment: (targetType: string, count?: number) =>
33
+ count && count > 1 ? (
34
+ <>
35
+ left {count} comments on your {targetType}
36
+ </>
37
+ ) : (
38
+ <>commented on your {targetType}</>
39
+ ),
40
+ like: (targetType: string) => <>liked your {targetType}</>,
41
+ follow: () => <>followed you</>,
42
+ tips: (targetType: string) => <>gave tips to your {targetType}</>,
43
+ mention: (targetType: string) => <>mentioned you in {targetType}</>,
44
+ assign: () => <>assigned you a task</>,
45
+ };
46
+
47
+ interface UserData {
48
+ did: string;
49
+ fullName: string;
50
+ }
51
+
52
+ interface UserLinkProps {
53
+ user: UserData;
54
+ color?: string;
55
+ }
56
+
57
+ interface CustomMouseEvent extends MouseEvent<HTMLAnchorElement> {
58
+ customPreventRedirect?: boolean;
59
+ }
60
+
61
+ /**
62
+ * UserLink component for rendering a user's name as a profile link
63
+ * Memoized to prevent unnecessary re-renders
64
+ */
65
+ function UserLink({ user, color = colors.textBase }: UserLinkProps) {
66
+ const profileLink = withQuery(joinURL(WELLKNOWN_SERVICE_PATH_PREFIX, 'user'), { did: user.did });
67
+
68
+ return (
69
+ <Link
70
+ href={profileLink}
71
+ color={color}
72
+ fontWeight={600}
73
+ target="_blank"
74
+ sx={{ textDecoration: 'none', '&:hover': { cursor: 'pointer' } }}
75
+ onClick={(e: CustomMouseEvent) => {
76
+ e.customPreventRedirect = true;
77
+ }}>
78
+ {user.fullName}
79
+ </Link>
80
+ );
81
+ }
82
+
83
+ UserLink.displayName = 'UserLink';
84
+
85
+ interface ActivityTarget {
86
+ type: string;
87
+ name: string;
88
+ }
89
+
90
+ interface Activity {
91
+ type: ActivityTypeValues;
92
+ target: ActivityTarget;
93
+ }
94
+
95
+ interface ExtraParams {
96
+ linkColor?: string;
97
+ [key: string]: any;
98
+ }
99
+
100
+ interface ActivityTitleProps {
101
+ activity: Activity;
102
+ users: UserData[];
103
+ actors: string[];
104
+ extra?: ExtraParams;
105
+ mountPoint?: string;
106
+ }
107
+
108
+ /**
109
+ * A hook that returns a formatted activity title with linked usernames
110
+ * @param {Object} params - The parameters object
111
+ * @param {keyof typeof ACTIVITY_TYPES} params.type - The activity type
112
+ * @param {Object} params.target - The target object
113
+ * @param {Array<{did: string, fullName: string}>} params.users - Array of user objects
114
+ * @param {Object} params.extra - Extra parameters
115
+ * @returns {React.ReactNode} Formatted title with linked usernames
116
+ */
117
+ export default function useActivityTitle({ activity, users, actors, extra = {}, mountPoint = '' }: ActivityTitleProps) {
118
+ const { type, target } = activity || {};
119
+ const { type: targetType } = target || {};
120
+ const { linkColor = colors.textBase } = extra || {};
121
+
122
+ // Create a map of users by their DID for efficient lookup
123
+ const usersMap = useCreation(() => {
124
+ if (!Array.isArray(users)) return new Map<string, UserData>();
125
+ const map = new Map<string, UserData>();
126
+ users.forEach((user) => {
127
+ if (user?.did && !map.has(user.did)) {
128
+ map.set(user.did, user);
129
+ }
130
+ });
131
+ return map;
132
+ }, [users]);
133
+
134
+ // Get unique users from actors, using the map and providing fallback for missing users
135
+ const uniqueUsers = useCreation(() => {
136
+ if (!Array.isArray(actors)) return [];
137
+
138
+ return actors
139
+ .map((actorId) => {
140
+ if (!actorId) return null;
141
+ // If user exists in map, return the user object, otherwise create a basic object with DID
142
+ return usersMap.get(actorId) || { did: actorId, fullName: actorId?.substring(0, 8) };
143
+ })
144
+ .filter(Boolean) as UserData[];
145
+ }, [actors, usersMap]);
146
+
147
+ // Memoized function to format user names with links
148
+ const formatLinkedUserNames = useMemoizedFn(() => {
149
+ if (!Array.isArray(uniqueUsers) || uniqueUsers.length === 0) {
150
+ return null;
151
+ }
152
+
153
+ // Early return for single user case
154
+ if (uniqueUsers.length === 1) {
155
+ return <UserLink user={uniqueUsers[0]} color={linkColor} />;
156
+ }
157
+
158
+ // Get all users except the last one for multi-user cases
159
+ const initialUsers = uniqueUsers.slice(0, -1);
160
+ const lastUser = uniqueUsers[uniqueUsers.length - 1];
161
+
162
+ // Early return for two users case
163
+ if (uniqueUsers.length === 2) {
164
+ return (
165
+ <>
166
+ <UserLink user={initialUsers[0]} color={linkColor} />
167
+ {' and '}
168
+ <UserLink user={lastUser} color={linkColor} />
169
+ </>
170
+ );
171
+ }
172
+
173
+ // Handle three or more users
174
+ const isMoreThanThree = uniqueUsers.length > 3;
175
+ const displayUsers = isMoreThanThree ? uniqueUsers.slice(0, 2) : initialUsers;
176
+
177
+ return (
178
+ <>
179
+ {displayUsers.map((user, index) => (
180
+ <React.Fragment key={user.did}>
181
+ <UserLink user={user} color={linkColor} />
182
+ {index < displayUsers.length - 1 ? ', ' : ''}
183
+ </React.Fragment>
184
+ ))}
185
+ {isMoreThanThree ? (
186
+ `, and ${uniqueUsers.length - 2} others`
187
+ ) : (
188
+ <>
189
+ , and <UserLink user={lastUser} color={linkColor} />
190
+ </>
191
+ )}
192
+ </>
193
+ );
194
+ });
195
+
196
+ // Memoized function to get activity description
197
+ const getActivityDescription = useMemoizedFn(() => {
198
+ const descriptionFn = type ? ACTIVITY_DESCRIPTIONS[type] : null;
199
+ return descriptionFn ? descriptionFn(targetType, users.length) : null;
200
+ });
201
+
202
+ // Create the final title using memoization
203
+ const title = useCreation(() => {
204
+ const linkedNames = formatLinkedUserNames();
205
+ const description = getActivityDescription();
206
+
207
+ if (!linkedNames || !description) {
208
+ return null;
209
+ }
210
+
211
+ return (
212
+ <>
213
+ {linkedNames} {description}
214
+ </>
215
+ );
216
+ }, [type, targetType, uniqueUsers, formatLinkedUserNames, getActivityDescription]);
217
+
218
+ const targetLink = useCreation(() => {
219
+ if (!activity) return null;
220
+ const link = getActivityLink(activity);
221
+ if (link?.targetLink) {
222
+ return joinURL(mountPoint, link.targetLink);
223
+ }
224
+ return null;
225
+ }, [activity, mountPoint]);
226
+
227
+ if (!type || isEmpty(target)) {
228
+ return null;
229
+ }
230
+
231
+ return (
232
+ <>
233
+ {title}{' '}
234
+ {targetLink && (
235
+ <Link
236
+ href={targetLink}
237
+ color={linkColor}
238
+ target="_blank"
239
+ sx={{ textDecoration: 'none' }}
240
+ onClick={(e: CustomMouseEvent) => {
241
+ e.customPreventRedirect = true;
242
+ }}>
243
+ {target.name}
244
+ </Link>
245
+ )}
246
+ </>
247
+ );
248
+ }