react-mention-input 1.1.29 → 1.1.31

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.1.31] - 2026-02-02
9
+
10
+ ### Fixed
11
+ - **Font restriction enforcement**: Added paste event handler to strip all formatting (color, font-size, font-weight, font-family) from pasted content
12
+ - Pasted text now uses the component's standard CSS styling instead of preserving source formatting
13
+ - Prevents visual inconsistencies when users paste content from Word, websites, emails, or other formatted sources
14
+
15
+ ### Technical Details
16
+ - Implemented `handlePaste` function using standard Clipboard API (`clipboardData.getData('text/plain')`)
17
+ - Preserves newlines, special characters, and cursor position
18
+ - Maintains full backward compatibility with existing features (mentions, links, images, keyboard shortcuts)
19
+ - Uses W3C standard DOM APIs (Selection and Range)
20
+
21
+ ## [1.1.30] - Previous Release
22
+
23
+ (Previous versions not documented)
@@ -444,6 +444,33 @@ var MentionInput = function (_a) {
444
444
  }
445
445
  }
446
446
  };
447
+ var handlePaste = function (event) {
448
+ // Prevent default paste behavior to control formatting
449
+ event.preventDefault();
450
+ // Get plain text from clipboard (strips all HTML/formatting)
451
+ var text = event.clipboardData.getData('text/plain');
452
+ // If no text, do nothing
453
+ if (!text)
454
+ return;
455
+ // Get current selection
456
+ var selection = window.getSelection();
457
+ if (!selection || !selection.rangeCount)
458
+ return;
459
+ // Get the range where text will be inserted
460
+ var range = selection.getRangeAt(0);
461
+ // Delete any selected content first
462
+ range.deleteContents();
463
+ // Create a text node with the plain text (preserves newlines)
464
+ var textNode = document.createTextNode(text);
465
+ range.insertNode(textNode);
466
+ // Move cursor to end of inserted text
467
+ range.setStartAfter(textNode);
468
+ range.setEndAfter(textNode);
469
+ selection.removeAllRanges();
470
+ selection.addRange(range);
471
+ // Trigger input change to update state and apply mention/link highlighting
472
+ handleInputChange();
473
+ };
447
474
  var handleKeyDown = function (event) {
448
475
  if (event.key === "Enter" && !event.shiftKey) {
449
476
  event.preventDefault(); // Prevent newline in content-editable
@@ -475,7 +502,7 @@ var MentionInput = function (_a) {
475
502
  React.createElement("span", { className: "attachment-icon" }, attachmentButtonIcon || "📷")),
476
503
  React.createElement("div", { className: "mention-input-wrapper" },
477
504
  (!inputValue || !inputRef.current || ((_b = inputRef.current) === null || _b === void 0 ? void 0 : _b.innerText.trim()) === "") && (React.createElement("span", { className: "placeholder" }, placeholder)),
478
- React.createElement("div", { ref: inputRef, contentEditable: true, suppressContentEditableWarning: true, className: "mention-input ".concat(inputClassName || ""), onInput: handleInputChange, onKeyDown: handleKeyDown, onFocus: function () { return document.execCommand('styleWithCSS', false, 'false'); } })),
505
+ React.createElement("div", { ref: inputRef, contentEditable: true, suppressContentEditableWarning: true, className: "mention-input ".concat(inputClassName || ""), onInput: handleInputChange, onKeyDown: handleKeyDown, onPaste: handlePaste, onFocus: function () { return document.execCommand('styleWithCSS', false, 'false'); } })),
479
506
  React.createElement("button", { onClick: handleSendMessage, className: "send-button ".concat(sendBtnClassName || ""), "aria-label": "Send message" }, sendButtonIcon || "➤"),
480
507
  React.createElement("input", { type: "file", ref: fileInputRef, accept: "image/*", onChange: handleImageSelect, style: { display: 'none' } }),
481
508
  isUploading && (React.createElement("div", { className: "upload-loading" },
@@ -18,7 +18,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
18
18
  }
19
19
  return to.concat(ar || Array.prototype.slice.call(from));
20
20
  };
21
- import React, { useState } from "react";
21
+ import React, { useState, memo } from "react";
22
22
  import "./ShowMessageCard.css";
23
23
  import { useProtectedImage } from "./useProtectedImage";
24
24
  export var ShowMessageCard = function (_a) {
@@ -50,8 +50,8 @@ export var ShowMessageCard = function (_a) {
50
50
  .join("");
51
51
  return initials;
52
52
  };
53
- // Component to render protected images
54
- var ProtectedImage = function (_a) {
53
+ // Component to render protected images - memoized to prevent recreation on every render
54
+ var ProtectedImage = memo(function (_a) {
55
55
  var url = _a.url, alt = _a.alt, className = _a.className, style = _a.style, containerClassName = _a.containerClassName, containerStyle = _a.containerStyle, onError = _a.onError, _b = _a.renderInContainer, renderInContainer = _b === void 0 ? true : _b;
56
56
  var displayUrl = useProtectedImage({
57
57
  url: url,
@@ -66,7 +66,7 @@ export var ShowMessageCard = function (_a) {
66
66
  return (React.createElement("div", { className: containerClassName, style: containerStyle }, imgElement));
67
67
  }
68
68
  return imgElement;
69
- };
69
+ });
70
70
  // Helper function to extract hashtags and mentions from text
71
71
  var extractTagsAndMentions = function (text) {
72
72
  // First extract from HTML with spans
@@ -7,6 +7,7 @@ interface UseProtectedImageOptions {
7
7
  * Custom hook to handle protected image URLs that require authentication tokens in headers.
8
8
  * For protected URLs, it fetches the image with auth headers and converts it to a blob URL.
9
9
  * For non-protected URLs, it returns the URL as-is.
10
+ * Uses a module-level cache to prevent blinking on re-renders.
10
11
  */
11
12
  export declare const useProtectedImage: ({ url, isProtected, getAuthHeaders, }: UseProtectedImageOptions) => string | null;
12
13
  export {};
@@ -46,40 +46,49 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
46
46
  }
47
47
  };
48
48
  import { useState, useEffect, useRef } from 'react';
49
+ // Module-level cache to persist blob URLs across component re-renders
50
+ var blobUrlCache = new Map();
51
+ var fetchingUrls = new Set();
49
52
  /**
50
53
  * Custom hook to handle protected image URLs that require authentication tokens in headers.
51
54
  * For protected URLs, it fetches the image with auth headers and converts it to a blob URL.
52
55
  * For non-protected URLs, it returns the URL as-is.
56
+ * Uses a module-level cache to prevent blinking on re-renders.
53
57
  */
54
58
  export var useProtectedImage = function (_a) {
55
59
  var url = _a.url, isProtected = _a.isProtected, getAuthHeaders = _a.getAuthHeaders;
56
- var _b = useState(null), blobUrl = _b[0], setBlobUrl = _b[1];
57
- var blobUrlRef = useRef(null);
58
- var blobUrlForUrlRef = useRef(null); // Track which URL the blob URL corresponds to
60
+ var _b = useState(function () {
61
+ // Initialize from cache if available
62
+ return url ? blobUrlCache.get(url) || null : null;
63
+ }), blobUrl = _b[0], setBlobUrl = _b[1];
59
64
  var previousUrlRef = useRef(null);
60
65
  var isProtectedRef = useRef(isProtected);
61
66
  var getAuthHeadersRef = useRef(getAuthHeaders);
67
+ var mountedRef = useRef(true);
62
68
  // Update refs when props change (but don't trigger re-fetch)
63
69
  useEffect(function () {
64
70
  isProtectedRef.current = isProtected;
65
71
  getAuthHeadersRef.current = getAuthHeaders;
66
72
  }, [isProtected, getAuthHeaders]);
67
73
  useEffect(function () {
68
- // If URL hasn't changed, don't do anything
74
+ mountedRef.current = true;
75
+ return function () {
76
+ mountedRef.current = false;
77
+ };
78
+ }, []);
79
+ useEffect(function () {
80
+ // Always check cache first and restore if needed (synchronous)
81
+ if (url && blobUrlCache.has(url) && !blobUrl) {
82
+ var cachedBlobUrl = blobUrlCache.get(url);
83
+ setBlobUrl(cachedBlobUrl);
84
+ }
85
+ // If URL hasn't changed, keep using cached blob URL
69
86
  if (url === previousUrlRef.current) {
70
87
  return;
71
88
  }
72
- // Store previous URL and blob URL for later cleanup
73
89
  var oldUrl = previousUrlRef.current;
74
- var oldBlobUrl = blobUrlRef.current;
75
90
  previousUrlRef.current = url;
76
91
  if (!url) {
77
- // If URL is null, clean up old blob URL and clear state
78
- if (oldBlobUrl) {
79
- URL.revokeObjectURL(oldBlobUrl);
80
- blobUrlRef.current = null;
81
- blobUrlForUrlRef.current = null;
82
- }
83
92
  setBlobUrl(null);
84
93
  return;
85
94
  }
@@ -87,23 +96,35 @@ export var useProtectedImage = function (_a) {
87
96
  var shouldUseAuth = typeof isProtectedRef.current === 'boolean'
88
97
  ? isProtectedRef.current
89
98
  : isProtectedRef.current ? isProtectedRef.current(url) : false;
90
- // If not protected, clean up old blob URL and use original URL
99
+ // If not protected, use original URL
91
100
  if (!shouldUseAuth || !getAuthHeadersRef.current) {
92
- if (oldBlobUrl) {
93
- URL.revokeObjectURL(oldBlobUrl);
94
- blobUrlRef.current = null;
95
- blobUrlForUrlRef.current = null;
101
+ // Clear any cached blob URL for this URL
102
+ if (blobUrlCache.has(url)) {
103
+ var cachedBlobUrl = blobUrlCache.get(url);
104
+ URL.revokeObjectURL(cachedBlobUrl);
105
+ blobUrlCache.delete(url);
96
106
  }
97
107
  setBlobUrl(null);
98
108
  return;
99
109
  }
110
+ // Check cache first - if we have a cached blob URL, use it immediately
111
+ if (blobUrlCache.has(url)) {
112
+ var cachedBlobUrl = blobUrlCache.get(url);
113
+ setBlobUrl(cachedBlobUrl);
114
+ return;
115
+ }
116
+ // If already fetching this URL, don't fetch again
117
+ if (fetchingUrls.has(url)) {
118
+ return;
119
+ }
100
120
  // Fetch protected image with auth headers
121
+ fetchingUrls.add(url);
101
122
  var fetchProtectedImage = function () { return __awaiter(void 0, void 0, void 0, function () {
102
123
  var headers, response, blob, newBlobUrl, error_1;
103
124
  return __generator(this, function (_a) {
104
125
  switch (_a.label) {
105
126
  case 0:
106
- _a.trys.push([0, 4, , 5]);
127
+ _a.trys.push([0, 4, 5, 6]);
107
128
  return [4 /*yield*/, Promise.resolve(getAuthHeadersRef.current())];
108
129
  case 1:
109
130
  headers = _a.sent();
@@ -120,44 +141,36 @@ export var useProtectedImage = function (_a) {
120
141
  case 3:
121
142
  blob = _a.sent();
122
143
  newBlobUrl = URL.createObjectURL(blob);
123
- // Only update if URL hasn't changed during fetch
124
- if (previousUrlRef.current === url) {
125
- // Revoke old blob URL now that new one is ready
126
- if (oldBlobUrl && oldUrl !== url) {
127
- URL.revokeObjectURL(oldBlobUrl);
128
- }
129
- blobUrlRef.current = newBlobUrl;
130
- blobUrlForUrlRef.current = url;
144
+ // Only update if URL hasn't changed during fetch and component is still mounted
145
+ if (previousUrlRef.current === url && mountedRef.current) {
146
+ // Cache the blob URL
147
+ blobUrlCache.set(url, newBlobUrl);
131
148
  setBlobUrl(newBlobUrl);
132
149
  }
133
150
  else {
134
- // URL changed during fetch, revoke this blob URL
151
+ // URL changed during fetch or component unmounted, revoke this blob URL
135
152
  URL.revokeObjectURL(newBlobUrl);
136
153
  }
137
- return [3 /*break*/, 5];
154
+ return [3 /*break*/, 6];
138
155
  case 4:
139
156
  error_1 = _a.sent();
140
157
  console.error('Error fetching protected image:', error_1);
141
- // On error, only clear if URL hasn't changed
142
- if (previousUrlRef.current === url) {
158
+ // On error, only clear if URL hasn't changed and component is mounted
159
+ if (previousUrlRef.current === url && mountedRef.current) {
143
160
  setBlobUrl(null);
144
161
  }
145
- return [3 /*break*/, 5];
146
- case 5: return [2 /*return*/];
162
+ return [3 /*break*/, 6];
163
+ case 5:
164
+ fetchingUrls.delete(url);
165
+ return [7 /*endfinally*/];
166
+ case 6: return [2 /*return*/];
147
167
  }
148
168
  });
149
169
  }); };
150
170
  fetchProtectedImage();
151
- // Cleanup function - revoke blob URL on unmount
152
- return function () {
153
- // On unmount, clean up the current blob URL
154
- if (blobUrlRef.current) {
155
- URL.revokeObjectURL(blobUrlRef.current);
156
- blobUrlRef.current = null;
157
- blobUrlForUrlRef.current = null;
158
- }
159
- };
160
- }, [url]); // Only depend on url, not isProtected or getAuthHeaders
171
+ }, [url]); // Only depend on url
172
+ // Cleanup: Don't revoke blob URLs on unmount - keep them in cache for reuse
173
+ // They will be cleaned up when the URL changes or when the cache is cleared
161
174
  // Return blob URL if available, otherwise return original URL (or null)
162
175
  if (!url) {
163
176
  return null;
@@ -165,5 +178,10 @@ export var useProtectedImage = function (_a) {
165
178
  var isUrlProtected = typeof isProtectedRef.current === 'boolean'
166
179
  ? isProtectedRef.current
167
180
  : isProtectedRef.current ? isProtectedRef.current(url) : false;
168
- return blobUrl || (!isUrlProtected ? url : null);
181
+ // For protected URLs, return cached blob URL or current blobUrl state
182
+ // For non-protected URLs, return the original URL
183
+ if (isUrlProtected) {
184
+ return blobUrl || blobUrlCache.get(url) || null;
185
+ }
186
+ return url;
169
187
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-mention-input",
3
- "version": "1.1.29",
3
+ "version": "1.1.31",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "scripts": {
@@ -42,4 +42,4 @@
42
42
  "react": "^18.3.1",
43
43
  "react-dom": "^18.3.1"
44
44
  }
45
- }
45
+ }
@@ -65,14 +65,14 @@ const MentionInput: React.FC<MentionInputProps> = ({
65
65
  const [imageUrl, setImageUrl] = useState<string | null>(null);
66
66
  const [isUploading, setIsUploading] = useState<boolean>(false);
67
67
  const [isDraggingOver, setIsDraggingOver] = useState<boolean>(false);
68
-
68
+
69
69
  // Use protected image hook to handle authenticated image URLs
70
70
  const displayImageUrl = useProtectedImage({
71
71
  url: imageUrl,
72
72
  isProtected: isProtectedUrl,
73
73
  getAuthHeaders,
74
74
  });
75
-
75
+
76
76
 
77
77
 
78
78
  const inputRef = useRef<HTMLDivElement>(null);
@@ -85,13 +85,13 @@ const MentionInput: React.FC<MentionInputProps> = ({
85
85
  const highlightMentionsAndLinks = (text: string): string => {
86
86
  // Regular expression for detecting links
87
87
  const linkRegex = /(https?:\/\/[^\s]+)/g;
88
-
88
+
89
89
  // Highlight links
90
90
  let highlightedText = text.replace(
91
91
  linkRegex,
92
92
  '<a href="$1" target="_blank" rel="noopener noreferrer" class="link-highlight">$1</a>'
93
93
  );
94
-
94
+
95
95
  // Highlight mentions manually based on `userSelectListRef`
96
96
  userSelectListRef?.current.forEach((userName) => {
97
97
  const mentionPattern = new RegExp(`@${userName}(\\s|$)`, "g");
@@ -102,7 +102,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
102
102
  }
103
103
  );
104
104
  });
105
-
105
+
106
106
  return highlightedText;
107
107
  };
108
108
 
@@ -128,7 +128,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
128
128
  document.removeEventListener("mousedown", handleClickOutside);
129
129
  };
130
130
  }, [showSuggestions]);
131
-
131
+
132
132
 
133
133
  const restoreCaretPosition = (node: HTMLElement, caretOffset: number) => {
134
134
  const range = document.createRange();
@@ -268,29 +268,29 @@ const MentionInput: React.FC<MentionInputProps> = ({
268
268
  const newCaretOffset = getCurrentCaretOffset();
269
269
 
270
270
  caretOffsetRef.current = newCaretOffset;
271
-
271
+
272
272
  const plainText = inputRef.current.innerText;
273
273
  setInputValue(plainText);
274
-
274
+
275
275
  // Process for mention suggestions
276
276
  const mentionMatch = plainText.slice(0, newCaretOffset).match(/@(\S*)$/);
277
277
  if (mentionMatch) {
278
278
  const query = mentionMatch[1].toLowerCase();
279
- const filteredUsers = query === "" ? users : users.filter((user) =>
279
+ const filteredUsers = query === "" ? users : users.filter((user) =>
280
280
  user.name.toLowerCase().includes(query)
281
281
  );
282
-
282
+
283
283
  setSuggestions(filteredUsers);
284
284
  setShowSuggestions(filteredUsers.length > 0);
285
285
  } else {
286
286
  setShowSuggestions(false);
287
287
  }
288
-
288
+
289
289
  // Only apply highlighting if we have mentions or links to highlight
290
290
  if (userSelectListRef.current.length > 0 || plainText.match(/(https?:\/\/[^\s]+)/g)) {
291
291
  const currentHTML = inputRef.current.innerHTML;
292
292
  const htmlWithHighlights = highlightMentionsAndLinks(plainText);
293
-
293
+
294
294
  // Only update if the highlighted HTML is different to avoid cursor jumping
295
295
  if (currentHTML !== htmlWithHighlights) {
296
296
  inputRef.current.innerHTML = htmlWithHighlights;
@@ -299,7 +299,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
299
299
  }
300
300
  }
301
301
  };
302
-
302
+
303
303
 
304
304
  const renderSuggestions = () => {
305
305
  if (!showSuggestions || !inputRef.current) return null;
@@ -312,7 +312,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
312
312
  .join("");
313
313
  return initials;
314
314
  };
315
-
315
+
316
316
  const inputRect = inputRef.current.getBoundingClientRect();
317
317
  const scrollLeft =
318
318
  window.scrollX ?? window.pageXOffset ?? document.documentElement.scrollLeft ?? 0;
@@ -349,7 +349,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
349
349
  }
350
350
 
351
351
  return ReactDOM.createPortal(
352
- <div
352
+ <div
353
353
  className={`suggestion-container ${suggestionListClassName || ''}`}
354
354
  style={styles}
355
355
  >
@@ -378,15 +378,15 @@ const MentionInput: React.FC<MentionInputProps> = ({
378
378
 
379
379
  const handleSuggestionClick = (user: User) => {
380
380
  if (!inputRef.current) return;
381
-
381
+
382
382
  const plainText = inputValue;
383
383
  const caretOffset = caretOffsetRef.current;
384
384
  const mentionMatch = plainText.slice(0, caretOffset).match(/@(\S*)$/);
385
-
385
+
386
386
  if (!userSelectListRef.current.includes(user.name)) {
387
387
  userSelectListRef.current.push(user.name);
388
388
  }
389
-
389
+
390
390
  // Check if the ID is already stored
391
391
  const isIdExists = userSelectListWithIdsRef.current.some(
392
392
  (item) => item.id === user.id
@@ -394,26 +394,26 @@ const MentionInput: React.FC<MentionInputProps> = ({
394
394
  if (!isIdExists) {
395
395
  userSelectListWithIdsRef.current.push(user);
396
396
  }
397
-
397
+
398
398
  if (!mentionMatch) return;
399
-
399
+
400
400
  const mentionIndex = plainText.slice(0, caretOffset).lastIndexOf("@");
401
-
401
+
402
402
  // Append space after the mention
403
403
  const newValue =
404
404
  plainText.substring(0, mentionIndex + 1) + user.name + " " + plainText.substring(caretOffset);
405
-
405
+
406
406
  setInputValue(newValue);
407
407
  inputRef.current.innerText = newValue;
408
-
408
+
409
409
  // Highlight mentions and links with &nbsp;
410
410
  const htmlWithHighlights = highlightMentionsAndLinks(newValue);
411
-
411
+
412
412
  // Set highlighted content
413
413
  inputRef.current.innerHTML = htmlWithHighlights;
414
-
414
+
415
415
  setShowSuggestions(false);
416
-
416
+
417
417
  // Adjust caret position after adding the mention and space
418
418
  const mentionEnd = mentionIndex + user.name.length + 1;
419
419
  restoreCaretPosition(inputRef.current, mentionEnd + 1); // +1 for the space
@@ -449,7 +449,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
449
449
  const rect = e.currentTarget.getBoundingClientRect();
450
450
  const x = e.clientX;
451
451
  const y = e.clientY;
452
-
452
+
453
453
  if (
454
454
  x <= rect.left ||
455
455
  x >= rect.right ||
@@ -532,6 +532,40 @@ const MentionInput: React.FC<MentionInputProps> = ({
532
532
  }
533
533
  };
534
534
 
535
+ const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
536
+ // Prevent default paste behavior to control formatting
537
+ event.preventDefault();
538
+
539
+ // Get plain text from clipboard (strips all HTML/formatting)
540
+ const text = event.clipboardData.getData('text/plain');
541
+
542
+ // If no text, do nothing
543
+ if (!text) return;
544
+
545
+ // Get current selection
546
+ const selection = window.getSelection();
547
+ if (!selection || !selection.rangeCount) return;
548
+
549
+ // Get the range where text will be inserted
550
+ const range = selection.getRangeAt(0);
551
+
552
+ // Delete any selected content first
553
+ range.deleteContents();
554
+
555
+ // Create a text node with the plain text (preserves newlines)
556
+ const textNode = document.createTextNode(text);
557
+ range.insertNode(textNode);
558
+
559
+ // Move cursor to end of inserted text
560
+ range.setStartAfter(textNode);
561
+ range.setEndAfter(textNode);
562
+ selection.removeAllRanges();
563
+ selection.addRange(range);
564
+
565
+ // Trigger input change to update state and apply mention/link highlighting
566
+ handleInputChange();
567
+ };
568
+
535
569
  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
536
570
  if (event.key === "Enter" && !event.shiftKey) {
537
571
  event.preventDefault(); // Prevent newline in content-editable
@@ -558,9 +592,9 @@ const MentionInput: React.FC<MentionInputProps> = ({
558
592
  <div className={`mention-container ${containerClassName || ""}`}>
559
593
  {displayImageUrl && selectedImage && (
560
594
  <div className={`image-preview-card ${attachedImageContainerClassName || ""}`} style={attachedImageContainerStyle}>
561
- <img
562
- src={displayImageUrl}
563
- alt="Preview"
595
+ <img
596
+ src={displayImageUrl}
597
+ alt="Preview"
564
598
  className={imgClassName || ""}
565
599
  style={imgStyle}
566
600
  />
@@ -569,8 +603,8 @@ const MentionInput: React.FC<MentionInputProps> = ({
569
603
  </button>
570
604
  </div>
571
605
  )}
572
-
573
- <div
606
+
607
+ <div
574
608
  className={`mention-input-container ${inputContainerClassName || ""} ${isDraggingOver ? 'dragging-over' : ''}`}
575
609
  onDragOver={handleDragOver}
576
610
  onDragLeave={handleDragLeave}
@@ -584,7 +618,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
584
618
  </div>
585
619
  </div>
586
620
  )}
587
-
621
+
588
622
  <button
589
623
  onClick={() => fileInputRef.current?.click()}
590
624
  className="attachment-button"
@@ -593,7 +627,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
593
627
  >
594
628
  <span className="attachment-icon">{attachmentButtonIcon || "📷"}</span>
595
629
  </button>
596
-
630
+
597
631
  <div className="mention-input-wrapper">
598
632
  {(!inputValue || !inputRef.current || inputRef.current?.innerText.trim() === "") && (
599
633
  <span className="placeholder">{placeholder}</span>
@@ -605,10 +639,11 @@ const MentionInput: React.FC<MentionInputProps> = ({
605
639
  className={`mention-input ${inputClassName || ""}`}
606
640
  onInput={handleInputChange}
607
641
  onKeyDown={handleKeyDown}
642
+ onPaste={handlePaste}
608
643
  onFocus={() => document.execCommand('styleWithCSS', false, 'false')}
609
644
  />
610
645
  </div>
611
-
646
+
612
647
  <button
613
648
  onClick={handleSendMessage}
614
649
  className={`send-button ${sendBtnClassName || ""}`}
@@ -616,7 +651,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
616
651
  >
617
652
  {sendButtonIcon || "➤"}
618
653
  </button>
619
-
654
+
620
655
  <input
621
656
  type="file"
622
657
  ref={fileInputRef}
@@ -624,7 +659,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
624
659
  onChange={handleImageSelect}
625
660
  style={{ display: 'none' }}
626
661
  />
627
-
662
+
628
663
  {isUploading && (
629
664
  <div className="upload-loading">
630
665
  <span>Uploading...</span>
@@ -1,4 +1,4 @@
1
- import React, { CSSProperties, useState, ReactNode } from "react";
1
+ import React, { CSSProperties, useState, ReactNode, useMemo, memo } from "react";
2
2
  import "./ShowMessageCard.css";
3
3
  import { useProtectedImage } from "./useProtectedImage";
4
4
 
@@ -112,8 +112,8 @@ export const ShowMessageCard: React.FC<ShowMessageCardProps> = ({
112
112
  return initials;
113
113
  };
114
114
 
115
- // Component to render protected images
116
- const ProtectedImage: React.FC<{
115
+ // Component to render protected images - memoized to prevent recreation on every render
116
+ const ProtectedImage = memo<{
117
117
  url: string;
118
118
  alt: string;
119
119
  className?: string;
@@ -122,7 +122,7 @@ export const ShowMessageCard: React.FC<ShowMessageCardProps> = ({
122
122
  containerStyle?: CSSProperties;
123
123
  onError?: () => void;
124
124
  renderInContainer?: boolean;
125
- }> = ({ url, alt, className, style, containerClassName, containerStyle, onError, renderInContainer = true }) => {
125
+ }>(({ url, alt, className, style, containerClassName, containerStyle, onError, renderInContainer = true }) => {
126
126
  const displayUrl = useProtectedImage({
127
127
  url,
128
128
  isProtected: isProtectedUrl,
@@ -152,7 +152,7 @@ export const ShowMessageCard: React.FC<ShowMessageCardProps> = ({
152
152
  }
153
153
 
154
154
  return imgElement;
155
- };
155
+ });
156
156
 
157
157
  // Helper function to extract hashtags and mentions from text
158
158
  const extractTagsAndMentions = (text: string) => {
@@ -6,22 +6,29 @@ interface UseProtectedImageOptions {
6
6
  getAuthHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
7
7
  }
8
8
 
9
+ // Module-level cache to persist blob URLs across component re-renders
10
+ const blobUrlCache = new Map<string, string>();
11
+ const fetchingUrls = new Set<string>();
12
+
9
13
  /**
10
14
  * Custom hook to handle protected image URLs that require authentication tokens in headers.
11
15
  * For protected URLs, it fetches the image with auth headers and converts it to a blob URL.
12
16
  * For non-protected URLs, it returns the URL as-is.
17
+ * Uses a module-level cache to prevent blinking on re-renders.
13
18
  */
14
19
  export const useProtectedImage = ({
15
20
  url,
16
21
  isProtected,
17
22
  getAuthHeaders,
18
23
  }: UseProtectedImageOptions): string | null => {
19
- const [blobUrl, setBlobUrl] = useState<string | null>(null);
20
- const blobUrlRef = useRef<string | null>(null);
21
- const blobUrlForUrlRef = useRef<string | null>(null); // Track which URL the blob URL corresponds to
24
+ const [blobUrl, setBlobUrl] = useState<string | null>(() => {
25
+ // Initialize from cache if available
26
+ return url ? blobUrlCache.get(url) || null : null;
27
+ });
22
28
  const previousUrlRef = useRef<string | null>(null);
23
29
  const isProtectedRef = useRef(isProtected);
24
30
  const getAuthHeadersRef = useRef(getAuthHeaders);
31
+ const mountedRef = useRef(true);
25
32
 
26
33
  // Update refs when props change (but don't trigger re-fetch)
27
34
  useEffect(() => {
@@ -30,23 +37,28 @@ export const useProtectedImage = ({
30
37
  }, [isProtected, getAuthHeaders]);
31
38
 
32
39
  useEffect(() => {
33
- // If URL hasn't changed, don't do anything
40
+ mountedRef.current = true;
41
+ return () => {
42
+ mountedRef.current = false;
43
+ };
44
+ }, []);
45
+
46
+ useEffect(() => {
47
+ // Always check cache first and restore if needed (synchronous)
48
+ if (url && blobUrlCache.has(url) && !blobUrl) {
49
+ const cachedBlobUrl = blobUrlCache.get(url)!;
50
+ setBlobUrl(cachedBlobUrl);
51
+ }
52
+
53
+ // If URL hasn't changed, keep using cached blob URL
34
54
  if (url === previousUrlRef.current) {
35
55
  return;
36
56
  }
37
57
 
38
- // Store previous URL and blob URL for later cleanup
39
58
  const oldUrl = previousUrlRef.current;
40
- const oldBlobUrl = blobUrlRef.current;
41
59
  previousUrlRef.current = url;
42
60
 
43
61
  if (!url) {
44
- // If URL is null, clean up old blob URL and clear state
45
- if (oldBlobUrl) {
46
- URL.revokeObjectURL(oldBlobUrl);
47
- blobUrlRef.current = null;
48
- blobUrlForUrlRef.current = null;
49
- }
50
62
  setBlobUrl(null);
51
63
  return;
52
64
  }
@@ -56,18 +68,32 @@ export const useProtectedImage = ({
56
68
  ? isProtectedRef.current
57
69
  : isProtectedRef.current ? isProtectedRef.current(url) : false;
58
70
 
59
- // If not protected, clean up old blob URL and use original URL
71
+ // If not protected, use original URL
60
72
  if (!shouldUseAuth || !getAuthHeadersRef.current) {
61
- if (oldBlobUrl) {
62
- URL.revokeObjectURL(oldBlobUrl);
63
- blobUrlRef.current = null;
64
- blobUrlForUrlRef.current = null;
73
+ // Clear any cached blob URL for this URL
74
+ if (blobUrlCache.has(url)) {
75
+ const cachedBlobUrl = blobUrlCache.get(url)!;
76
+ URL.revokeObjectURL(cachedBlobUrl);
77
+ blobUrlCache.delete(url);
65
78
  }
66
79
  setBlobUrl(null);
67
80
  return;
68
81
  }
69
82
 
83
+ // Check cache first - if we have a cached blob URL, use it immediately
84
+ if (blobUrlCache.has(url)) {
85
+ const cachedBlobUrl = blobUrlCache.get(url)!;
86
+ setBlobUrl(cachedBlobUrl);
87
+ return;
88
+ }
89
+
90
+ // If already fetching this URL, don't fetch again
91
+ if (fetchingUrls.has(url)) {
92
+ return;
93
+ }
94
+
70
95
  // Fetch protected image with auth headers
96
+ fetchingUrls.add(url);
71
97
  const fetchProtectedImage = async () => {
72
98
  try {
73
99
  const headers = await Promise.resolve(getAuthHeadersRef.current!());
@@ -85,40 +111,31 @@ export const useProtectedImage = ({
85
111
  const blob = await response.blob();
86
112
  const newBlobUrl = URL.createObjectURL(blob);
87
113
 
88
- // Only update if URL hasn't changed during fetch
89
- if (previousUrlRef.current === url) {
90
- // Revoke old blob URL now that new one is ready
91
- if (oldBlobUrl && oldUrl !== url) {
92
- URL.revokeObjectURL(oldBlobUrl);
93
- }
94
- blobUrlRef.current = newBlobUrl;
95
- blobUrlForUrlRef.current = url;
114
+ // Only update if URL hasn't changed during fetch and component is still mounted
115
+ if (previousUrlRef.current === url && mountedRef.current) {
116
+ // Cache the blob URL
117
+ blobUrlCache.set(url, newBlobUrl);
96
118
  setBlobUrl(newBlobUrl);
97
119
  } else {
98
- // URL changed during fetch, revoke this blob URL
120
+ // URL changed during fetch or component unmounted, revoke this blob URL
99
121
  URL.revokeObjectURL(newBlobUrl);
100
122
  }
101
123
  } catch (error) {
102
124
  console.error('Error fetching protected image:', error);
103
- // On error, only clear if URL hasn't changed
104
- if (previousUrlRef.current === url) {
125
+ // On error, only clear if URL hasn't changed and component is mounted
126
+ if (previousUrlRef.current === url && mountedRef.current) {
105
127
  setBlobUrl(null);
106
128
  }
129
+ } finally {
130
+ fetchingUrls.delete(url);
107
131
  }
108
132
  };
109
133
 
110
134
  fetchProtectedImage();
135
+ }, [url]); // Only depend on url
111
136
 
112
- // Cleanup function - revoke blob URL on unmount
113
- return () => {
114
- // On unmount, clean up the current blob URL
115
- if (blobUrlRef.current) {
116
- URL.revokeObjectURL(blobUrlRef.current);
117
- blobUrlRef.current = null;
118
- blobUrlForUrlRef.current = null;
119
- }
120
- };
121
- }, [url]); // Only depend on url, not isProtected or getAuthHeaders
137
+ // Cleanup: Don't revoke blob URLs on unmount - keep them in cache for reuse
138
+ // They will be cleaned up when the URL changes or when the cache is cleared
122
139
 
123
140
  // Return blob URL if available, otherwise return original URL (or null)
124
141
  if (!url) {
@@ -128,6 +145,13 @@ export const useProtectedImage = ({
128
145
  const isUrlProtected = typeof isProtectedRef.current === 'boolean'
129
146
  ? isProtectedRef.current
130
147
  : isProtectedRef.current ? isProtectedRef.current(url) : false;
131
- return blobUrl || (!isUrlProtected ? url : null);
148
+
149
+ // For protected URLs, return cached blob URL or current blobUrl state
150
+ // For non-protected URLs, return the original URL
151
+ if (isUrlProtected) {
152
+ return blobUrl || blobUrlCache.get(url) || null;
153
+ }
154
+
155
+ return url;
132
156
  };
133
157