l-min-components 1.6.1263 → 1.6.1267
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/package.json +1 -1
- package/src/hooks/messaging-kit/index.jsx +470 -343
package/package.json
CHANGED
|
@@ -226,6 +226,16 @@ import sound from "./new-notification-7-210334.mp3";
|
|
|
226
226
|
* @property {any | null} error - Stores error info if status is 'failed'.
|
|
227
227
|
*/
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Represents an item waiting in the message sending queue.
|
|
231
|
+
* @typedef {object} QueueItem
|
|
232
|
+
* @property {string} tempId - The temporary ID linking to the optimistic message.
|
|
233
|
+
* @property {string} courseId - The course ID for the API call.
|
|
234
|
+
* @property {string} accountId - The account ID for the API call.
|
|
235
|
+
* @property {string | null} text - The message text content.
|
|
236
|
+
* @property {any | null} media - The media data (e.g., File object, FormData details). Needs proper handling before API call.
|
|
237
|
+
*/
|
|
238
|
+
|
|
229
239
|
const useMessageKit = (/*affiliatesActive*/) => {
|
|
230
240
|
const cookieGrabber = (key = "") => {
|
|
231
241
|
const cookies = document.cookie;
|
|
@@ -304,8 +314,21 @@ const useMessageKit = (/*affiliatesActive*/) => {
|
|
|
304
314
|
* @type {Record<string, OptimisticMessage[]>}
|
|
305
315
|
*/
|
|
306
316
|
optimisticMessages: {},
|
|
317
|
+
/**
|
|
318
|
+
* Queue of messages waiting to be sent via API.
|
|
319
|
+
* @type {QueueItem[]}
|
|
320
|
+
*/
|
|
321
|
+
messageQueue: [],
|
|
322
|
+
/**
|
|
323
|
+
* Flag indicating if the queue processor is currently sending a message.
|
|
324
|
+
* @type {boolean}
|
|
325
|
+
*/
|
|
326
|
+
isProcessingQueue: false,
|
|
307
327
|
});
|
|
308
328
|
|
|
329
|
+
// Ref for synchronous queue processing lock
|
|
330
|
+
const isCurrentlyProcessingRef = useRef(false);
|
|
331
|
+
|
|
309
332
|
// State for rooms that should be automatically marked as read
|
|
310
333
|
const [autoReadRoomIds, setAutoReadRoomIds] = useState(() => new Set());
|
|
311
334
|
|
|
@@ -651,22 +674,18 @@ const useMessageKit = (/*affiliatesActive*/) => {
|
|
|
651
674
|
}, []);
|
|
652
675
|
// --- End functions to manage auto-read rooms ---
|
|
653
676
|
|
|
654
|
-
// Helper to get YYYY-MM-DD
|
|
677
|
+
// Helper to get YYYY-MM-DD - Useful for potential fallback or other date needs
|
|
655
678
|
const getLocalDateString = (isoTimestamp) => {
|
|
656
679
|
if (!isoTimestamp) return new Date().toISOString().split("T")[0];
|
|
657
680
|
try {
|
|
658
681
|
const date = new Date(isoTimestamp);
|
|
659
|
-
|
|
660
|
-
if (isNaN(date.getTime())) {
|
|
661
|
-
throw new Error("Invalid date object");
|
|
662
|
-
}
|
|
682
|
+
if (isNaN(date.getTime())) throw new Error("Invalid date object");
|
|
663
683
|
const year = date.getFullYear();
|
|
664
684
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
665
685
|
const day = String(date.getDate()).padStart(2, "0");
|
|
666
686
|
return `${year}-${month}-${day}`;
|
|
667
687
|
} catch (e) {
|
|
668
688
|
console.error("Error parsing date:", isoTimestamp, e);
|
|
669
|
-
// Fallback to current date string
|
|
670
689
|
const today = new Date();
|
|
671
690
|
const year = today.getFullYear();
|
|
672
691
|
const month = String(today.getMonth() + 1).padStart(2, "0");
|
|
@@ -675,321 +694,445 @@ const useMessageKit = (/*affiliatesActive*/) => {
|
|
|
675
694
|
}
|
|
676
695
|
};
|
|
677
696
|
|
|
678
|
-
//
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
if (!
|
|
697
|
+
// *** UPDATED getDisplayDateLabel ***
|
|
698
|
+
// Now accepts the full ISO timestamp string
|
|
699
|
+
const getDisplayDateLabel = (createdAtISOString) => {
|
|
700
|
+
if (!createdAtISOString) return "Unknown Date";
|
|
682
701
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
702
|
+
try {
|
|
703
|
+
const messageDate = new Date(createdAtISOString);
|
|
704
|
+
// Ensure the date parsed correctly
|
|
705
|
+
if (isNaN(messageDate.getTime())) {
|
|
706
|
+
console.warn(
|
|
707
|
+
"getDisplayDateLabel: Invalid date parsed from",
|
|
708
|
+
createdAtISOString
|
|
709
|
+
);
|
|
710
|
+
return createdAtISOString.split("T")[0] || "Invalid Date"; // Basic fallback
|
|
711
|
+
}
|
|
686
712
|
|
|
687
|
-
|
|
688
|
-
const todayUtcString = getLocalDateString(today.toISOString());
|
|
689
|
-
const yesterdayUtcString = getLocalDateString(yesterday.toISOString());
|
|
713
|
+
const now = new Date();
|
|
690
714
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
715
|
+
// Get midnight today in the browser's local time
|
|
716
|
+
const todayStart = new Date(
|
|
717
|
+
now.getFullYear(),
|
|
718
|
+
now.getMonth(),
|
|
719
|
+
now.getDate()
|
|
720
|
+
);
|
|
721
|
+
// Get midnight yesterday in the browser's local time
|
|
722
|
+
const yesterdayStart = new Date(todayStart);
|
|
723
|
+
yesterdayStart.setDate(todayStart.getDate() - 1);
|
|
724
|
+
// Get midnight tomorrow (needed for the upper bound of "today")
|
|
725
|
+
const tomorrowStart = new Date(todayStart);
|
|
726
|
+
tomorrowStart.setDate(todayStart.getDate() + 1);
|
|
727
|
+
|
|
728
|
+
// Compare the message's timestamp against local date boundaries
|
|
729
|
+
if (messageDate >= todayStart && messageDate < tomorrowStart) {
|
|
730
|
+
return "Today";
|
|
731
|
+
} else if (messageDate >= yesterdayStart && messageDate < todayStart) {
|
|
732
|
+
return "Yesterday";
|
|
733
|
+
} else {
|
|
734
|
+
// Format other dates using locale-specific representation
|
|
735
|
+
let options = { month: "short", day: "numeric" };
|
|
736
|
+
// Optionally add year if it's not the current year
|
|
737
|
+
if (messageDate.getFullYear() !== now.getFullYear()) {
|
|
738
|
+
options.year = "numeric";
|
|
739
|
+
}
|
|
740
|
+
// 'undefined' uses the browser's default locale
|
|
741
|
+
return messageDate.toLocaleDateString(undefined, options);
|
|
742
|
+
}
|
|
743
|
+
} catch (e) {
|
|
744
|
+
console.error(
|
|
745
|
+
"Error formatting display date label for:",
|
|
746
|
+
createdAtISOString,
|
|
747
|
+
e
|
|
748
|
+
);
|
|
749
|
+
return createdAtISOString.split("T")[0] || "Error Date"; // Basic fallback
|
|
696
750
|
}
|
|
751
|
+
};
|
|
697
752
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
753
|
+
// *** NEW getSortableTimestampForGroup ***
|
|
754
|
+
// Helper to get a reliable timestamp for sorting date groups
|
|
755
|
+
const getSortableTimestampForGroup = (group) => {
|
|
756
|
+
// Use the timestamp of the first message in the group for sorting
|
|
757
|
+
// Assumes messages within the group are sorted ascending by created_at
|
|
758
|
+
if (group?.messages?.length > 0 && group.messages[0]?.created_at) {
|
|
759
|
+
try {
|
|
760
|
+
const date = new Date(group.messages[0].created_at);
|
|
761
|
+
if (!isNaN(date.getTime())) {
|
|
762
|
+
// Return timestamp representing midnight of that day in local time for group sorting
|
|
763
|
+
const groupDateStart = new Date(
|
|
764
|
+
date.getFullYear(),
|
|
765
|
+
date.getMonth(),
|
|
766
|
+
date.getDate()
|
|
767
|
+
);
|
|
768
|
+
return groupDateStart.getTime(); // Use positive timestamp
|
|
769
|
+
}
|
|
770
|
+
} catch {
|
|
771
|
+
/* ignore errors, fallback below */
|
|
706
772
|
}
|
|
707
|
-
return messageDate.toLocaleDateString("en-US", {
|
|
708
|
-
timeZone: "UTC",
|
|
709
|
-
month: "short",
|
|
710
|
-
day: "numeric",
|
|
711
|
-
});
|
|
712
|
-
} catch (e) {
|
|
713
|
-
console.error("Error formatting date label for:", dateString, e);
|
|
714
|
-
return dateString; // Fallback
|
|
715
773
|
}
|
|
774
|
+
// Fallback if no valid message/timestamp found
|
|
775
|
+
return 0;
|
|
716
776
|
};
|
|
717
777
|
|
|
718
|
-
//
|
|
719
|
-
const sendMessage =
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
778
|
+
// This function now adds the message optimistically and places it in the queue.
|
|
779
|
+
const sendMessage = useCallback(
|
|
780
|
+
({ text, media, courseId, accountId }) => {
|
|
781
|
+
const clientTimestamp = new Date().toISOString();
|
|
782
|
+
const tempId =
|
|
783
|
+
typeof crypto !== "undefined" && crypto.randomUUID
|
|
784
|
+
? crypto.randomUUID()
|
|
785
|
+
: `temp_${Date.now()}_${Math.random()}`;
|
|
786
|
+
const pendingKey = `pending_${courseId}_${accountId}`;
|
|
787
|
+
|
|
788
|
+
// --- Prepare Optimistic Message ---
|
|
789
|
+
const optimisticMessage = {
|
|
790
|
+
tempId,
|
|
791
|
+
status: "pending", // Initially pending
|
|
792
|
+
courseId,
|
|
793
|
+
accountId,
|
|
794
|
+
text,
|
|
795
|
+
media, // Store original media reference for potential UI display needs
|
|
796
|
+
created_at: clientTimestamp,
|
|
797
|
+
user_message: true, // Always true
|
|
798
|
+
error: null,
|
|
799
|
+
};
|
|
740
800
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
801
|
+
// --- Prepare Queue Item ---
|
|
802
|
+
const queueItem = {
|
|
803
|
+
tempId,
|
|
804
|
+
courseId,
|
|
805
|
+
accountId,
|
|
806
|
+
text,
|
|
807
|
+
media, // Pass the media data to the queue item for sending
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
// --- Add Optimistic Message AND Queue Item to State ---
|
|
811
|
+
setState((prevState) => {
|
|
812
|
+
// Add optimistic message for UI update
|
|
813
|
+
const currentOptimistic =
|
|
814
|
+
prevState.optimisticMessages[pendingKey] || [];
|
|
815
|
+
const updatedOptimistic = {
|
|
747
816
|
...prevState.optimisticMessages,
|
|
748
817
|
[pendingKey]: [...currentOptimistic, optimisticMessage],
|
|
749
|
-
}
|
|
750
|
-
};
|
|
751
|
-
});
|
|
752
|
-
// --- End Optimistic Update ---
|
|
818
|
+
};
|
|
753
819
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
let response;
|
|
757
|
-
const requestData = { text, media };
|
|
758
|
-
const requestParams = {
|
|
759
|
-
account_id: accountId,
|
|
760
|
-
course_id: courseId,
|
|
761
|
-
_account: selectedAccount.id,
|
|
762
|
-
};
|
|
820
|
+
// Add item to the sending queue
|
|
821
|
+
const updatedQueue = [...prevState.messageQueue, queueItem];
|
|
763
822
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
) {
|
|
775
|
-
response = await request({
|
|
776
|
-
url: `/notify/v1/instructor/${affiliateAccount}/chats/`,
|
|
777
|
-
params: requestParams,
|
|
778
|
-
data: requestData,
|
|
779
|
-
method: "Post",
|
|
780
|
-
});
|
|
781
|
-
} else if (selectedAccount.type.toLowerCase() === "personal") {
|
|
782
|
-
response = await request({
|
|
783
|
-
url: `/notify/v1/chats/`,
|
|
784
|
-
params: requestParams,
|
|
785
|
-
data: requestData,
|
|
786
|
-
method: "Post",
|
|
787
|
-
});
|
|
788
|
-
} else {
|
|
789
|
-
throw new Error(
|
|
790
|
-
"Unsupported account type or configuration for sending messages."
|
|
791
|
-
);
|
|
792
|
-
}
|
|
793
|
-
// --- End API Call ---
|
|
823
|
+
return {
|
|
824
|
+
...prevState,
|
|
825
|
+
optimisticMessages: updatedOptimistic,
|
|
826
|
+
messageQueue: updatedQueue,
|
|
827
|
+
};
|
|
828
|
+
});
|
|
829
|
+
// NOTE: The actual API call is deferred to the queue processor effect.
|
|
830
|
+
},
|
|
831
|
+
[setState]
|
|
832
|
+
); // Dependency: setState
|
|
794
833
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
834
|
+
// Effect to process the message queue sequentially using a Ref lock
|
|
835
|
+
useEffect(() => {
|
|
836
|
+
// **Synchronous Check + Claim:**
|
|
837
|
+
// Use the ref for an immediate check. If already processing, bail out.
|
|
838
|
+
// Also check if the queue has items.
|
|
839
|
+
if (isCurrentlyProcessingRef.current || state.messageQueue.length === 0) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
798
842
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
const confirmedRoomId = sentMessageData.room; // The actual Room ID!
|
|
802
|
-
const confirmedMessageId = sentMessageData.id;
|
|
803
|
-
|
|
804
|
-
// --- Format Confirmed Message ---
|
|
805
|
-
/** @type {ChatMessage} */
|
|
806
|
-
const newChatMessage = {
|
|
807
|
-
id: confirmedMessageId,
|
|
808
|
-
text: sentMessageData.text,
|
|
809
|
-
media: sentMessageData.media, // Assuming structure compatibility
|
|
810
|
-
sender: {
|
|
811
|
-
// Sender info from server response
|
|
812
|
-
id: sentMessageData.sender.id,
|
|
813
|
-
first_name:
|
|
814
|
-
sentMessageData.sender.full_name || sentMessageData.sender.name,
|
|
815
|
-
},
|
|
816
|
-
is_read: sentMessageData.is_read,
|
|
817
|
-
is_delivered: sentMessageData.is_delivered,
|
|
818
|
-
created_at: sentMessageData.created_at,
|
|
819
|
-
user_message: sentMessageData.user_message, // Should be true from API
|
|
820
|
-
date: getLocalDateString(sentMessageData.created_at), // Use helper
|
|
821
|
-
};
|
|
843
|
+
// **Claim the processing slot synchronously**
|
|
844
|
+
isCurrentlyProcessingRef.current = true;
|
|
822
845
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
const updatedChats = { ...prevState.chats };
|
|
827
|
-
let updatedRoomsByCourses = [...prevState.roomsByCourses];
|
|
828
|
-
|
|
829
|
-
// --- 1. Remove Optimistic Message from Pending List ---
|
|
830
|
-
let optimisticRemoved = false;
|
|
831
|
-
if (updatedOptimisticMessages[pendingKey]) {
|
|
832
|
-
const originalPendingList = updatedOptimisticMessages[pendingKey];
|
|
833
|
-
// Filter out the message by its temporary ID
|
|
834
|
-
const filteredList = originalPendingList.filter(
|
|
835
|
-
(msg) => msg.tempId !== tempId
|
|
836
|
-
);
|
|
846
|
+
// Set the state flag as well (useful for potential UI indicators or triggering re-renders upon completion)
|
|
847
|
+
// Note: This state update is still async, but the ref prevents the race condition.
|
|
848
|
+
setState((prevState) => ({ ...prevState, isProcessingQueue: true }));
|
|
837
849
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
850
|
+
// Get the next message details from the front of the queue.
|
|
851
|
+
// Reading state here is fine, as we've already claimed the slot.
|
|
852
|
+
const messageToSend = state.messageQueue[0];
|
|
853
|
+
|
|
854
|
+
// Safety check: Ensure messageToSend exists (queue might have emptied)
|
|
855
|
+
if (!messageToSend) {
|
|
856
|
+
isCurrentlyProcessingRef.current = false; // Release the claim
|
|
857
|
+
setState((prevState) => ({ ...prevState, isProcessingQueue: false }));
|
|
858
|
+
console.warn(
|
|
859
|
+
"Queue Processor: Attempted to process but queue was empty."
|
|
860
|
+
);
|
|
861
|
+
return; // Nothing to process
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const { tempId, courseId, accountId, text, media } = messageToSend;
|
|
865
|
+
const pendingKey = `pending_${courseId}_${accountId}`;
|
|
866
|
+
|
|
867
|
+
// Define the async task for sending this specific message.
|
|
868
|
+
const processMessage = async () => {
|
|
869
|
+
try {
|
|
870
|
+
// --- Make API Call ---
|
|
871
|
+
let response;
|
|
872
|
+
// TODO: Handle media preparation (e.g., check if 'media' is File, create FormData)
|
|
873
|
+
const requestData = { text, media };
|
|
874
|
+
const requestParams = {
|
|
875
|
+
account_id: accountId,
|
|
876
|
+
course_id: courseId,
|
|
877
|
+
_account: selectedAccount.id,
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
if (selectedAccount.type.toLowerCase() === "enterprise") {
|
|
881
|
+
response = await request({
|
|
882
|
+
url: "/notify/v1/enterprise/chats/",
|
|
883
|
+
params: requestParams,
|
|
884
|
+
data: requestData,
|
|
885
|
+
method: "Post",
|
|
886
|
+
});
|
|
887
|
+
} else if (
|
|
888
|
+
String(selectedAccount.type).toLowerCase() === "instructor" &&
|
|
889
|
+
affiliateAccount
|
|
890
|
+
) {
|
|
891
|
+
response = await request({
|
|
892
|
+
url: `/notify/v1/instructor/${affiliateAccount}/chats/`,
|
|
893
|
+
params: requestParams,
|
|
894
|
+
data: requestData,
|
|
895
|
+
method: "Post",
|
|
896
|
+
});
|
|
897
|
+
} else if (selectedAccount.type.toLowerCase() === "personal") {
|
|
898
|
+
response = await request({
|
|
899
|
+
url: `/notify/v1/chats/`,
|
|
900
|
+
params: requestParams,
|
|
901
|
+
data: requestData,
|
|
902
|
+
method: "Post",
|
|
903
|
+
});
|
|
904
|
+
} else {
|
|
905
|
+
throw new Error(
|
|
906
|
+
"Queue Processor: Unsupported account type or configuration."
|
|
907
|
+
);
|
|
847
908
|
}
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
909
|
+
// --- End API Call ---
|
|
910
|
+
|
|
911
|
+
if (!response?.data) {
|
|
912
|
+
throw new Error(
|
|
913
|
+
"Queue Processor: API request succeeded but returned no data."
|
|
852
914
|
);
|
|
853
915
|
}
|
|
854
916
|
|
|
855
|
-
// ---
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
const
|
|
859
|
-
|
|
917
|
+
// --- Handle SUCCESS ---
|
|
918
|
+
/** @type {SentMessageResponse} */
|
|
919
|
+
const sentMessageData = response.data;
|
|
920
|
+
const confirmedRoomId = sentMessageData.room;
|
|
921
|
+
const confirmedMessageId = sentMessageData.id;
|
|
860
922
|
|
|
861
|
-
//
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
923
|
+
// Format confirmed message
|
|
924
|
+
/** @type {ChatMessage} */
|
|
925
|
+
const newChatMessage = {
|
|
926
|
+
id: confirmedMessageId,
|
|
927
|
+
text: sentMessageData.text,
|
|
928
|
+
media: sentMessageData.media,
|
|
929
|
+
sender: {
|
|
930
|
+
id: sentMessageData.sender.id,
|
|
931
|
+
first_name:
|
|
932
|
+
sentMessageData.sender.full_name || sentMessageData.sender.name,
|
|
933
|
+
},
|
|
934
|
+
is_read: sentMessageData.is_read,
|
|
935
|
+
is_delivered: sentMessageData.is_delivered,
|
|
936
|
+
created_at: sentMessageData.created_at,
|
|
937
|
+
user_message: sentMessageData.user_message,
|
|
938
|
+
date: getLocalDateString(sentMessageData.created_at), // Use existing helper
|
|
939
|
+
};
|
|
865
940
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
if (
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
(a, b) =>
|
|
876
|
-
new Date(a.created_at).getTime() -
|
|
877
|
-
new Date(b.created_at).getTime()
|
|
941
|
+
// Update state atomically after success
|
|
942
|
+
setState((prevState) => {
|
|
943
|
+
// Safety check: Ensure the message we processed is still the first one
|
|
944
|
+
if (
|
|
945
|
+
prevState.messageQueue.length === 0 ||
|
|
946
|
+
prevState.messageQueue[0].tempId !== tempId
|
|
947
|
+
) {
|
|
948
|
+
console.warn(
|
|
949
|
+
`Queue Processor: Mismatch processing successful message ${tempId}. Queue changed unexpectedly.`
|
|
878
950
|
);
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
messages: updatedMessages,
|
|
882
|
-
};
|
|
951
|
+
// Don't modify state if the queue front doesn't match
|
|
952
|
+
return prevState;
|
|
883
953
|
}
|
|
884
|
-
|
|
885
|
-
//
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
(a, b) =>
|
|
895
|
-
getSortableDateValue(b.date, b.dateString) -
|
|
896
|
-
getSortableDateValue(a.date, a.dateString)
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
// Update the chats object with the modified history for this room
|
|
900
|
-
updatedChats[confirmedRoomId] = updatedChatHistory;
|
|
901
|
-
|
|
902
|
-
// --- 3. Update Room List (roomsByCourses) ---
|
|
903
|
-
let roomExistsInState = false;
|
|
904
|
-
updatedRoomsByCourses = updatedRoomsByCourses.map((courseGroup) => {
|
|
905
|
-
// Check if this is the correct course group first
|
|
906
|
-
if (String(courseGroup.id) !== String(courseId)) {
|
|
907
|
-
return courseGroup;
|
|
954
|
+
|
|
955
|
+
// 1. Remove optimistic message
|
|
956
|
+
let updatedOptimisticMessages = { ...prevState.optimisticMessages };
|
|
957
|
+
if (updatedOptimisticMessages[pendingKey]) {
|
|
958
|
+
const filteredList = updatedOptimisticMessages[pendingKey].filter(
|
|
959
|
+
(msg) => msg.tempId !== tempId
|
|
960
|
+
);
|
|
961
|
+
if (filteredList.length === 0)
|
|
962
|
+
delete updatedOptimisticMessages[pendingKey];
|
|
963
|
+
else updatedOptimisticMessages[pendingKey] = filteredList;
|
|
908
964
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
965
|
+
|
|
966
|
+
// 2. Add confirmed message to chats
|
|
967
|
+
const updatedChats = { ...prevState.chats };
|
|
968
|
+
const messageDateLabel = getDisplayDateLabel(
|
|
969
|
+
newChatMessage.created_at
|
|
970
|
+
); // Use corrected helper
|
|
971
|
+
const currentChatHistory = updatedChats[confirmedRoomId] || [];
|
|
972
|
+
let updatedChatHistory = [...currentChatHistory];
|
|
973
|
+
let dateGroupIndex = updatedChatHistory.findIndex(
|
|
974
|
+
(g) => g.date === messageDateLabel
|
|
912
975
|
);
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
976
|
+
|
|
977
|
+
if (dateGroupIndex !== -1) {
|
|
978
|
+
// Add to existing group if not duplicate
|
|
979
|
+
if (
|
|
980
|
+
!updatedChatHistory[dateGroupIndex].messages.some(
|
|
981
|
+
(m) => m.id === newChatMessage.id
|
|
982
|
+
)
|
|
983
|
+
) {
|
|
984
|
+
updatedChatHistory[dateGroupIndex].messages = [
|
|
985
|
+
...updatedChatHistory[dateGroupIndex].messages,
|
|
986
|
+
newChatMessage,
|
|
987
|
+
].sort(
|
|
988
|
+
(a, b) =>
|
|
989
|
+
new Date(a.created_at).getTime() -
|
|
990
|
+
new Date(b.created_at).getTime()
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
} else {
|
|
994
|
+
// Create new group
|
|
995
|
+
updatedChatHistory.push({
|
|
996
|
+
date: messageDateLabel,
|
|
997
|
+
messages: [newChatMessage],
|
|
998
|
+
});
|
|
999
|
+
updatedChatHistory.sort(
|
|
1000
|
+
(a, b) =>
|
|
1001
|
+
getSortableTimestampForGroup(b) -
|
|
1002
|
+
getSortableTimestampForGroup(a)
|
|
1003
|
+
); // Use helper
|
|
930
1004
|
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1005
|
+
updatedChats[confirmedRoomId] = updatedChatHistory;
|
|
1006
|
+
|
|
1007
|
+
// 3. Update roomsByCourses last_message (if room metadata exists)
|
|
1008
|
+
let updatedRoomsByCourses = [...prevState.roomsByCourses];
|
|
1009
|
+
let roomExists = false;
|
|
1010
|
+
updatedRoomsByCourses = updatedRoomsByCourses.map((cg) => {
|
|
1011
|
+
if (String(cg.id) !== String(courseId)) return cg;
|
|
1012
|
+
const roomIdx = cg.rooms.findIndex((r) => r.id === confirmedRoomId);
|
|
1013
|
+
if (roomIdx !== -1) {
|
|
1014
|
+
roomExists = true;
|
|
1015
|
+
const updatedRoom = {
|
|
1016
|
+
...cg.rooms[roomIdx],
|
|
1017
|
+
last_message: {
|
|
1018
|
+
id: newChatMessage.id,
|
|
1019
|
+
text: newChatMessage.text,
|
|
1020
|
+
media: newChatMessage.media,
|
|
1021
|
+
created_at: newChatMessage.created_at,
|
|
1022
|
+
},
|
|
1023
|
+
};
|
|
1024
|
+
const updatedRooms = [...cg.rooms];
|
|
1025
|
+
updatedRooms[roomIdx] = updatedRoom;
|
|
1026
|
+
return { ...cg, rooms: updatedRooms };
|
|
1027
|
+
}
|
|
1028
|
+
return cg;
|
|
1029
|
+
});
|
|
934
1030
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
if (!roomExistsInState) {
|
|
938
|
-
console.log(
|
|
939
|
-
`Room ${confirmedRoomId} metadata not found in state after sending message, refetching room list.`
|
|
940
|
-
);
|
|
941
|
-
// Use setTimeout to defer the call slightly, avoiding potential issues during render
|
|
942
|
-
setTimeout(() => getMessageRoomsByCourses(null, true), 0);
|
|
943
|
-
}
|
|
1031
|
+
// 4. Remove processed item from queue (immutable update)
|
|
1032
|
+
const updatedQueue = prevState.messageQueue.slice(1);
|
|
944
1033
|
|
|
945
|
-
|
|
1034
|
+
// Trigger refetch if room metadata wasn't found (handled outside setState)
|
|
1035
|
+
if (!roomExists && typeof getMessageRoomsByCourses === "function") {
|
|
1036
|
+
// Check if function exists
|
|
1037
|
+
setTimeout(() => getMessageRoomsByCourses(null, true), 0);
|
|
1038
|
+
}
|
|
946
1039
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1040
|
+
// Return the combined state updates
|
|
1041
|
+
return {
|
|
1042
|
+
...prevState,
|
|
1043
|
+
optimisticMessages: updatedOptimisticMessages,
|
|
1044
|
+
chats: updatedChats,
|
|
1045
|
+
roomsByCourses: updatedRoomsByCourses,
|
|
1046
|
+
messageQueue: updatedQueue,
|
|
1047
|
+
isProcessingQueue: true, // Keep true until finally block resets it below
|
|
1048
|
+
};
|
|
1049
|
+
});
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
// --- Handle FAILURE ---
|
|
1052
|
+
console.error(
|
|
1053
|
+
`Queue Processor: Failed to send message tempId ${tempId}:`,
|
|
1054
|
+
error
|
|
1055
|
+
);
|
|
1056
|
+
setState((prevState) => {
|
|
1057
|
+
// Safety check for queue consistency
|
|
1058
|
+
if (
|
|
1059
|
+
prevState.messageQueue.length === 0 ||
|
|
1060
|
+
prevState.messageQueue[0].tempId !== tempId
|
|
1061
|
+
) {
|
|
1062
|
+
console.warn(
|
|
1063
|
+
`Queue Processor: Mismatch processing failed message ${tempId}. Queue changed unexpectedly.`
|
|
1064
|
+
);
|
|
1065
|
+
return prevState;
|
|
1066
|
+
}
|
|
960
1067
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1068
|
+
// 1. Update optimistic message status to 'failed'
|
|
1069
|
+
let updatedOptimisticMessages = { ...prevState.optimisticMessages };
|
|
1070
|
+
if (updatedOptimisticMessages[pendingKey]) {
|
|
1071
|
+
const messageIndex = updatedOptimisticMessages[
|
|
1072
|
+
pendingKey
|
|
1073
|
+
].findIndex((msg) => msg.tempId === tempId);
|
|
1074
|
+
if (
|
|
1075
|
+
messageIndex !== -1 &&
|
|
1076
|
+
updatedOptimisticMessages[pendingKey][messageIndex].status ===
|
|
1077
|
+
"pending"
|
|
1078
|
+
) {
|
|
1079
|
+
const failedMessage = {
|
|
1080
|
+
...updatedOptimisticMessages[pendingKey][messageIndex],
|
|
1081
|
+
status: "failed",
|
|
1082
|
+
error: error?.message || "Unknown error",
|
|
1083
|
+
};
|
|
1084
|
+
const updatedPendingList = [
|
|
1085
|
+
...updatedOptimisticMessages[pendingKey],
|
|
1086
|
+
];
|
|
1087
|
+
updatedPendingList[messageIndex] = failedMessage;
|
|
1088
|
+
updatedOptimisticMessages[pendingKey] = updatedPendingList;
|
|
1089
|
+
} else {
|
|
1090
|
+
console.warn(
|
|
1091
|
+
`Queue Processor: Optimistic message tempId ${tempId} in ${pendingKey} not found or not pending for failure update.`
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
973
1094
|
} else {
|
|
974
1095
|
console.warn(
|
|
975
|
-
`
|
|
1096
|
+
`Queue Processor: Pending key ${pendingKey} not found for failure update.`
|
|
976
1097
|
);
|
|
977
1098
|
}
|
|
978
|
-
} else {
|
|
979
|
-
console.warn(
|
|
980
|
-
`Pending key ${pendingKey} not found for failure update.`
|
|
981
|
-
);
|
|
982
|
-
}
|
|
983
1099
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1100
|
+
// 2. Remove failed item from queue (immutable update)
|
|
1101
|
+
const updatedQueue = prevState.messageQueue.slice(1);
|
|
1102
|
+
// Return the combined state updates
|
|
1103
|
+
return {
|
|
1104
|
+
...prevState,
|
|
1105
|
+
optimisticMessages: updatedOptimisticMessages,
|
|
1106
|
+
messageQueue: updatedQueue,
|
|
1107
|
+
isProcessingQueue: true, // Keep true until finally block resets it below
|
|
1108
|
+
};
|
|
1109
|
+
});
|
|
1110
|
+
} finally {
|
|
1111
|
+
// --- Finish Processing ---
|
|
1112
|
+
// **Crucial:** Release the synchronous lock first.
|
|
1113
|
+
isCurrentlyProcessingRef.current = false;
|
|
1114
|
+
// Then update the state flag. This state update will trigger a re-render.
|
|
1115
|
+
// If the queue still has items, the effect will run again, see the ref is false,
|
|
1116
|
+
// claim the lock, and start processing the next item.
|
|
1117
|
+
setState((prevState) => ({ ...prevState, isProcessingQueue: false }));
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
// Execute the async sending logic for the current item.
|
|
1122
|
+
processMessage();
|
|
1123
|
+
|
|
1124
|
+
// Dependencies:
|
|
1125
|
+
// - `state.isProcessingQueue`: Triggers check when processing finishes.
|
|
1126
|
+
// - `state.messageQueue`: Triggers check when queue content changes.
|
|
1127
|
+
// - `request`, `selectedAccount`, `affiliateAccount`, `getMessageRoomsByCourses`:
|
|
1128
|
+
// Required by exhaustive-deps for use within the effect's scope.
|
|
1129
|
+
}, [
|
|
1130
|
+
state.isProcessingQueue,
|
|
1131
|
+
state.messageQueue,
|
|
1132
|
+
// request,
|
|
1133
|
+
selectedAccount,
|
|
1134
|
+
affiliateAccount,
|
|
1135
|
+
]);
|
|
993
1136
|
|
|
994
1137
|
/**
|
|
995
1138
|
* Retrieves combined confirmed and optimistic messages for a specific chat,
|
|
@@ -1017,23 +1160,22 @@ const useMessageKit = (/*affiliatesActive*/) => {
|
|
|
1017
1160
|
|
|
1018
1161
|
// --- 3. Get Optimistic Messages (ONLY from pendingKey) ---
|
|
1019
1162
|
const uniqueOptimisticMessages = state.optimisticMessages[pendingKey]
|
|
1020
|
-
? [...state.optimisticMessages[pendingKey]] // Get a copy
|
|
1163
|
+
? [...state.optimisticMessages[pendingKey]] // Get a copy
|
|
1021
1164
|
: [];
|
|
1022
1165
|
|
|
1023
1166
|
// --- 4. Adapt Optimistic Messages to a structure suitable for sorting/grouping ---
|
|
1024
1167
|
// (Does NOT add 'sender')
|
|
1025
1168
|
const adaptedOptimisticMessages = uniqueOptimisticMessages.map(
|
|
1026
1169
|
(optMsg) => ({
|
|
1027
|
-
// Use tempId as the unique identifier
|
|
1028
1170
|
id: optMsg.tempId,
|
|
1029
1171
|
text: optMsg.text,
|
|
1030
|
-
media: optMsg.media,
|
|
1031
|
-
// NO SENDER FIELD
|
|
1032
|
-
is_read: false,
|
|
1033
|
-
is_delivered: false,
|
|
1172
|
+
media: optMsg.media, // The original media reference stored in optimistic state
|
|
1173
|
+
// NO SENDER FIELD added here
|
|
1174
|
+
is_read: false,
|
|
1175
|
+
is_delivered: false,
|
|
1034
1176
|
created_at: optMsg.created_at, // Client timestamp
|
|
1035
1177
|
user_message: optMsg.user_message, // Always true
|
|
1036
|
-
date: getLocalDateString(optMsg.created_at), // Use helper
|
|
1178
|
+
date: getLocalDateString(optMsg.created_at), // Use helper for basic date info
|
|
1037
1179
|
// Add optimistic-specific fields for UI:
|
|
1038
1180
|
status: optMsg.status, // 'pending' or 'failed'
|
|
1039
1181
|
error: optMsg.error, // Error message if failed
|
|
@@ -1042,64 +1184,50 @@ const useMessageKit = (/*affiliatesActive*/) => {
|
|
|
1042
1184
|
);
|
|
1043
1185
|
|
|
1044
1186
|
// --- 5. Combine and Sort All Messages ---
|
|
1045
|
-
// The combined list contains confirmed ChatMessages (with sender)
|
|
1046
|
-
// and adapted optimistic messages (without sender)
|
|
1047
1187
|
const combinedFlatMessages = [
|
|
1048
1188
|
...confirmedFlatMessages,
|
|
1049
1189
|
...adaptedOptimisticMessages,
|
|
1050
1190
|
];
|
|
1051
1191
|
combinedFlatMessages.sort((a, b) => {
|
|
1052
|
-
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
const timeB = !isNaN(dateB.getTime()) ? dateB.getTime() : 0;
|
|
1057
|
-
|
|
1058
|
-
if (timeA !== timeB) {
|
|
1059
|
-
return timeA - timeB; // Ascending time order
|
|
1060
|
-
}
|
|
1061
|
-
// If timestamps are identical (rare), maintain relative order or rely on stable sort.
|
|
1062
|
-
return 0;
|
|
1192
|
+
const timeA = new Date(a.created_at).getTime();
|
|
1193
|
+
const timeB = new Date(b.created_at).getTime();
|
|
1194
|
+
// Handle potential NaN from invalid dates
|
|
1195
|
+
return (isNaN(timeA) ? 0 : timeA) - (isNaN(timeB) ? 0 : timeB);
|
|
1063
1196
|
});
|
|
1064
1197
|
|
|
1065
1198
|
// --- 6. Regroup Sorted Messages by Date ---
|
|
1066
|
-
/** @type {Record<string, { date: string;
|
|
1199
|
+
/** @type {Record<string, { date: string; messages: any[] }>} */
|
|
1067
1200
|
const groupedMessages = {};
|
|
1068
1201
|
|
|
1069
1202
|
combinedFlatMessages.forEach((message) => {
|
|
1070
|
-
// Use the
|
|
1071
|
-
const
|
|
1072
|
-
if (
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1203
|
+
// Use the corrected helper with the full timestamp
|
|
1204
|
+
const displayLabel = getDisplayDateLabel(message.created_at);
|
|
1205
|
+
if (
|
|
1206
|
+
!displayLabel ||
|
|
1207
|
+
displayLabel === "Unknown Date" ||
|
|
1208
|
+
displayLabel === "Invalid Date" ||
|
|
1209
|
+
displayLabel === "Error Date"
|
|
1210
|
+
) {
|
|
1211
|
+
console.warn("Skipping message due to invalid date label:", message);
|
|
1212
|
+
return;
|
|
1076
1213
|
}
|
|
1077
|
-
// Get the display label ("Today", "Yesterday", "Apr 06")
|
|
1078
|
-
const displayLabel = getDisplayDateLabel(dateString);
|
|
1079
1214
|
|
|
1080
|
-
// Initialize group if it doesn't exist
|
|
1081
1215
|
if (!groupedMessages[displayLabel]) {
|
|
1082
1216
|
groupedMessages[displayLabel] = {
|
|
1083
|
-
date: displayLabel, // The display label
|
|
1084
|
-
dateString: dateString, // The underlying YYYY-MM-DD string
|
|
1217
|
+
date: displayLabel, // The display label (e.g., "Today")
|
|
1085
1218
|
messages: [],
|
|
1086
1219
|
};
|
|
1087
1220
|
}
|
|
1088
|
-
// Add the message (with or without sender property) to the correct group
|
|
1089
1221
|
groupedMessages[displayLabel].messages.push(message);
|
|
1090
1222
|
});
|
|
1091
1223
|
|
|
1092
|
-
// Convert
|
|
1093
|
-
const finalGroupedArray = Object.values(groupedMessages)
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
getSortableDateValue(b.date, b.dateString) -
|
|
1098
|
-
getSortableDateValue(a.date, a.dateString)
|
|
1099
|
-
);
|
|
1224
|
+
// Convert grouped object back to array and sort the groups using timestamps
|
|
1225
|
+
const finalGroupedArray = Object.values(groupedMessages).sort(
|
|
1226
|
+
(a, b) =>
|
|
1227
|
+
getSortableTimestampForGroup(b) - getSortableTimestampForGroup(a)
|
|
1228
|
+
); // Descending date order
|
|
1100
1229
|
|
|
1101
|
-
// Ensure messages *within* each final group are sorted
|
|
1102
|
-
// This should be guaranteed by step 5, but double-checking is safe.
|
|
1230
|
+
// Ensure messages *within* each final group are sorted (redundant but safe)
|
|
1103
1231
|
finalGroupedArray.forEach((group) => {
|
|
1104
1232
|
group.messages.sort(
|
|
1105
1233
|
(a, b) =>
|
|
@@ -1107,27 +1235,26 @@ const useMessageKit = (/*affiliatesActive*/) => {
|
|
|
1107
1235
|
);
|
|
1108
1236
|
});
|
|
1109
1237
|
|
|
1110
|
-
//
|
|
1111
|
-
return finalGroupedArray; // Array of { date: string, dateString: string, messages: any[] }
|
|
1238
|
+
return finalGroupedArray; // Array of { date: string, messages: any[] }
|
|
1112
1239
|
},
|
|
1113
1240
|
[state.chats, state.optimisticMessages]
|
|
1114
|
-
); // Dependencies
|
|
1241
|
+
); // Dependencies: only needs state parts used
|
|
1115
1242
|
|
|
1116
1243
|
// Helper to get a sortable value from a display date label
|
|
1117
|
-
const getSortableDateValue = (displayLabel, dateString) => {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
};
|
|
1244
|
+
// const getSortableDateValue = (displayLabel, dateString) => {
|
|
1245
|
+
// if (!dateString) return 0; // Handle undefined/null dateString
|
|
1246
|
+
// if (displayLabel === "Today") return 2; // Highest priority
|
|
1247
|
+
// if (displayLabel === "Yesterday") return 1; // Next highest
|
|
1248
|
+
// // For specific dates, return the negative timestamp for descending sort
|
|
1249
|
+
// try {
|
|
1250
|
+
// const date = new Date(dateString + "T00:00:00Z"); // Use UTC base
|
|
1251
|
+
// if (isNaN(date.getTime())) return 0; // Low priority if invalid
|
|
1252
|
+
// // Older dates have smaller negative numbers (larger positive time), leading to descending sort
|
|
1253
|
+
// return -date.getTime();
|
|
1254
|
+
// } catch {
|
|
1255
|
+
// return 0; // Fallback for errors
|
|
1256
|
+
// }
|
|
1257
|
+
// };
|
|
1131
1258
|
|
|
1132
1259
|
const pinRoom = async (roomId) => {
|
|
1133
1260
|
// Optimistic update
|