Skip to content

Commit b831312

Browse files
committed
Refactored shared kudos rendering code into component for use in public kudos and kudos admin contexts
1 parent cc8bb32 commit b831312

File tree

3 files changed

+347
-620
lines changed

3 files changed

+347
-620
lines changed
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
import React, {useCallback, useContext, useEffect, useState} from 'react';
2+
import PropTypes from 'prop-types';
3+
import {
4+
Typography,
5+
Link
6+
} from '@mui/material';
7+
import { AppContext } from '../../context/AppContext';
8+
import {
9+
selectCsrfToken,
10+
selectActiveOrInactiveProfile
11+
} from '../../context/selectors';
12+
import { UPDATE_TOAST } from '../../context/actions';
13+
import { getCustomEmoji } from '../../api/emoji.js';
14+
import { Emoji } from 'emoji-picker-react';
15+
16+
import emojis from 'emoji-picker-react/src/data/emojis.json';
17+
18+
const propTypes = {
19+
kudos: PropTypes.shape({
20+
id: PropTypes.string.isRequired,
21+
message: PropTypes.string.isRequired,
22+
senderId: PropTypes.string.isRequired,
23+
recipientTeam: PropTypes.object,
24+
dateCreated: PropTypes.array.isRequired,
25+
dateApproved: PropTypes.array,
26+
recipientMembers: PropTypes.array
27+
}).isRequired,
28+
};
29+
30+
const EnhancedKudos = ({kudos}) => {
31+
const { state, dispatch } = useContext(AppContext);
32+
const csrf = selectCsrfToken(state);
33+
const [ emojiShortcodeMap, setEmojiShortcodeMap ] = useState({});
34+
const [ customLoaded, setCustomLoaded ] = useState(false);
35+
36+
useEffect(() => {
37+
let shortcodeMap = {};
38+
for (const category in emojis) {
39+
if (Object.hasOwn(emojis, category)) {
40+
let emojiList = emojis[category];
41+
emojiList.reduce((acc, current) => {
42+
current?.n?.forEach(
43+
name => (acc[name.replace(/\s/g, '_')] = { unified: current.u })
44+
);
45+
return acc;
46+
}, shortcodeMap);
47+
}
48+
}
49+
setEmojiShortcodeMap(shortcodeMap);
50+
}, []);
51+
52+
useEffect(() => {
53+
const loadCustomEmoji = async () => {
54+
let res = await getCustomEmoji(csrf);
55+
if (res && res.payload && res.payload.data && !res.error) {
56+
const shortcodeMap = { ...emojiShortcodeMap };
57+
let aliases = {};
58+
let customEmoji = res.payload.data;
59+
for(const emoji in customEmoji) {
60+
if(Object.hasOwn(customEmoji, emoji)) {
61+
if(customEmoji[emoji].startsWith("alias:")) {
62+
aliases[emoji] = { alias: customEmoji[emoji].substring("alias:".length) };
63+
} else {
64+
shortcodeMap[emoji] = { customUrl: customEmoji[emoji] };
65+
}
66+
}
67+
}
68+
for(const emoji in aliases) {
69+
if (Object.hasOwn(aliases, emoji)) {
70+
shortcodeMap[emoji] = shortcodeMap[aliases[emoji].alias];
71+
}
72+
}
73+
setEmojiShortcodeMap(shortcodeMap);
74+
setCustomLoaded(true);
75+
} else {
76+
window.snackDispatch({
77+
type: UPDATE_TOAST,
78+
payload: {
79+
severity: 'warning',
80+
toast: `Custom emoji could not be loaded: ${res.error}`
81+
}
82+
});
83+
}
84+
}
85+
86+
if(csrf && !customLoaded) {
87+
loadCustomEmoji();
88+
}
89+
}, [csrf, customLoaded, emojiShortcodeMap]);
90+
91+
const getEmojiDataByShortcode = useCallback(shortcode => {
92+
return emojiShortcodeMap[shortcode.toLowerCase()] || null;
93+
}, [emojiShortcodeMap]);
94+
95+
const regexIndexOf = useCallback((text, regex, start) => {
96+
const indexInSuffix = text.slice(start).search(regex);
97+
return indexInSuffix < 0 ? indexInSuffix : indexInSuffix + start;
98+
}, []);
99+
100+
// Replaces occurrences of a specific name in a message string with a MUI Link component.
101+
const linkMember = useCallback((member, name, message) => {
102+
const components = [];
103+
let currentMessage = message;
104+
let currentIndex = 0;
105+
let lastIndex = 0;
106+
107+
while (currentIndex < currentMessage.length) {
108+
const index = regexIndexOf(
109+
currentMessage,
110+
new RegExp('\\b' + name + '\\b', 'i'),
111+
currentIndex
112+
);
113+
if (index !== -1) {
114+
if (index > lastIndex) {
115+
components.push(currentMessage.slice(lastIndex, index));
116+
}
117+
components.push(
118+
<Link
119+
key={`${member.id}-${index}`}
120+
href={`/profile/${member.id}`}
121+
underline="hover"
122+
>
123+
{name}
124+
</Link>
125+
);
126+
currentIndex = index + name.length;
127+
lastIndex = currentIndex;
128+
} else {
129+
break;
130+
}
131+
}
132+
if (lastIndex < currentMessage.length) {
133+
components.push(currentMessage.slice(lastIndex));
134+
}
135+
return components.length === 0 ? [message] : components;
136+
}, []);
137+
138+
// Generates a list of unique name variations for a member to be used for linking.
139+
const searchNames = useCallback((member, members) => {
140+
const names = [];
141+
if (member.middleName)
142+
names.push(`${member.firstName} ${member.middleName} ${member.lastName}`);
143+
const firstAndLast = `${member.firstName} ${member.lastName}`;
144+
if (
145+
!members.some(
146+
k =>
147+
k.id !== member.id && firstAndLast === `${k.firstName} ${k.lastName}`
148+
)
149+
)
150+
names.push(firstAndLast);
151+
if (
152+
!members.some(
153+
k =>
154+
k.id !== member.id &&
155+
(member.lastName === k.lastName || member.lastName === k.firstName)
156+
)
157+
)
158+
names.push(member.lastName);
159+
if (
160+
!members.some(
161+
k =>
162+
k.id !== member.id &&
163+
(member.firstName === k.lastName || member.firstName === k.firstName)
164+
)
165+
)
166+
names.push(member.firstName);
167+
return names;
168+
}, []);
169+
170+
// Converts Slack-style links (<url|text> or <url>) in a string to <a> elements.
171+
const linkSlackUrls = useCallback(textLine => {
172+
const slackLinkRegex = /<([^<>|]*)(?:\|([^<>]*))?>/g;
173+
const components = [];
174+
let lastIndex = 0;
175+
let match;
176+
while ((match = slackLinkRegex.exec(textLine)) !== null) {
177+
const url = match[1];
178+
const linkText = match[2];
179+
if (match.index > lastIndex)
180+
components.push(textLine.slice(lastIndex, match.index));
181+
components.push(
182+
<a
183+
key={`slack-link-${match.index}`}
184+
href={url}
185+
target="_blank"
186+
rel="noopener noreferrer"
187+
>
188+
{linkText || url}
189+
</a>
190+
);
191+
lastIndex = slackLinkRegex.lastIndex;
192+
}
193+
if (lastIndex < textLine.length) components.push(textLine.slice(lastIndex));
194+
return components.length === 0 ? [textLine] : components;
195+
}, []);
196+
197+
const renderTextWithEmojis = useCallback(text => {
198+
const emojiShortcodeRegex = /:([a-zA-Z0-9_+-]+):/g; // Regex to find :shortcodes:
199+
const components = [];
200+
let lastIndex = 0;
201+
let match;
202+
203+
while ((match = emojiShortcodeRegex.exec(text)) !== null) {
204+
const shortcode = match[1];
205+
const emojiData = getEmojiDataByShortcode(shortcode);
206+
const precedingText = text.slice(lastIndex, match.index);
207+
208+
// Add text before the emoji shortcode
209+
if (precedingText) {
210+
components.push(precedingText);
211+
}
212+
213+
// Add the Emoji component or the original shortcode text
214+
if (emojiData) {
215+
if (emojiData.unified) {
216+
components.push(
217+
<Emoji
218+
key={`${match.index}-${shortcode}`} // Unique key
219+
unified={emojiData.unified}
220+
size={20} // Adjust size as needed
221+
/>
222+
);
223+
} else if (emojiData.customUrl) {
224+
// Render custom emoji using emojiUrl
225+
components.push(
226+
<img src={emojiData.customUrl} alt={shortcode} style={{ height: '20px', width: '20px', fontSize: '20px' }} />
227+
// Not sure why the below doesn't work. It seems like it should according to the docs. :shrug:s
228+
// <Emoji
229+
// key={`${match.index}-${shortcode}`}
230+
// emojiUrl={emojiData.customUrl}
231+
// size={20}
232+
// />
233+
);
234+
}
235+
} else {
236+
// If shortcode not found in map, render the original text
237+
components.push(match[0]);
238+
}
239+
240+
lastIndex = emojiShortcodeRegex.lastIndex;
241+
}
242+
243+
// Add any remaining text after the last shortcode
244+
const remainingText = text.slice(lastIndex);
245+
if (remainingText) {
246+
components.push(remainingText);
247+
}
248+
249+
// If the original text had no shortcodes, return it in an array
250+
return components.length === 0 ? [text] : components;
251+
}, [getEmojiDataByShortcode]);
252+
253+
// Creates the final array of React components for the message body,
254+
// processing Slack links, member names, and emojis.
255+
const createLinksAndEmojis = useCallback(
256+
kudosData => {
257+
const lines = [];
258+
let lineIndex = 0;
259+
const recipients = Array.isArray(kudosData.recipientMembers)
260+
? kudosData.recipientMembers
261+
: [];
262+
263+
for (const line of kudosData.message.split('\n')) {
264+
let components = linkSlackUrls(line);
265+
266+
// Process Member Name Links
267+
let componentsAfterNames = [];
268+
for (const component of components) {
269+
if (typeof component === 'string') {
270+
let currentStringSegments = [component];
271+
for (const member of recipients) {
272+
const names = searchNames(member, recipients);
273+
let nextStringSegments = [];
274+
for (const segment of currentStringSegments) {
275+
if (typeof segment === 'string') {
276+
let segmentProcessed = false;
277+
for (const name of names) {
278+
const built = linkMember(member, name, segment);
279+
if (
280+
built.length > 1 ||
281+
(built.length === 1 && built[0] !== segment)
282+
) {
283+
nextStringSegments.push(...built);
284+
segmentProcessed = true;
285+
break;
286+
}
287+
}
288+
if (!segmentProcessed) nextStringSegments.push(segment);
289+
} else {
290+
nextStringSegments.push(segment);
291+
}
292+
}
293+
currentStringSegments = nextStringSegments;
294+
}
295+
componentsAfterNames.push(...currentStringSegments);
296+
} else {
297+
componentsAfterNames.push(component);
298+
}
299+
}
300+
components = componentsAfterNames;
301+
302+
let finalComponents = [];
303+
for (const comp of components) {
304+
if (typeof comp === 'string') {
305+
finalComponents.push(...renderTextWithEmojis(comp)); // Spread the result
306+
} else {
307+
finalComponents.push(comp); // Keep existing non-string components
308+
}
309+
}
310+
311+
lines.push(
312+
<Typography
313+
key={`${kudosData.id}-line-${lineIndex}`}
314+
variant="body1"
315+
component="div"
316+
sx={{ lineHeight: '1.6' /* Improve spacing with emojis */ }}
317+
>
318+
{finalComponents}
319+
</Typography>
320+
);
321+
lineIndex++;
322+
}
323+
return lines;
324+
},
325+
[
326+
kudos.id,
327+
kudos.message,
328+
kudos.recipientMembers,
329+
linkMember,
330+
linkSlackUrls,
331+
searchNames,
332+
renderTextWithEmojis
333+
]
334+
);
335+
336+
return createLinksAndEmojis(kudos);
337+
};
338+
339+
EnhancedKudos.propTypes = propTypes;
340+
341+
export default EnhancedKudos;

0 commit comments

Comments
 (0)