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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "l-min-components",
3
- "version": "1.6.1263",
3
+ "version": "1.6.1267",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/assets",
@@ -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 (ensure consistency)
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
- // Check if date is valid before formatting
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
- // Helper to get the display label for a date group (like "Today", "Yesterday", "Apr 06")
679
- const getDisplayDateLabel = (dateString) => {
680
- // dateString is YYYY-MM-DD
681
- if (!dateString) return "Unknown Date";
697
+ // *** UPDATED getDisplayDateLabel ***
698
+ // Now accepts the full ISO timestamp string
699
+ const getDisplayDateLabel = (createdAtISOString) => {
700
+ if (!createdAtISOString) return "Unknown Date";
682
701
 
683
- const today = new Date();
684
- const yesterday = new Date(today);
685
- yesterday.setDate(today.getDate() - 1);
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
- // Use UTC dates for comparison to avoid timezone issues affecting "Today"/"Yesterday" logic
688
- const todayUtcString = getLocalDateString(today.toISOString());
689
- const yesterdayUtcString = getLocalDateString(yesterday.toISOString());
713
+ const now = new Date();
690
714
 
691
- if (dateString === todayUtcString) {
692
- return "Today";
693
- }
694
- if (dateString === yesterdayUtcString) {
695
- return "Yesterday";
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
- // Format other dates (e.g., "Apr 06")
699
- try {
700
- // Use the dateString directly which should be YYYY-MM-DD
701
- // Create date object assuming UTC to avoid timezone shifts in formatting
702
- const messageDate = new Date(dateString + "T00:00:00Z");
703
- // Check for invalid date object again before formatting
704
- if (isNaN(messageDate.getTime())) {
705
- return dateString; // Fallback to YYYY-MM-DD if parsing failed
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
- //sendMessage function inside useMessageKit:
719
- const sendMessage = async ({ text, media, courseId, accountId }) => {
720
- const clientTimestamp = new Date().toISOString();
721
- // Ensure crypto API is available, provide fallback
722
- const tempId =
723
- typeof crypto !== "undefined" && crypto.randomUUID
724
- ? crypto.randomUUID()
725
- : `temp_${Date.now()}_${Math.random()}`;
726
- const pendingKey = `pending_${courseId}_${accountId}`; // ALWAYS use this key for optimistic storage
727
-
728
- // --- Prepare Optimistic Message ---
729
- const optimisticMessage = {
730
- tempId,
731
- status: "pending",
732
- courseId, // Keep for identification within pendingKey if needed
733
- accountId, // Keep for identification within pendingKey if needed
734
- text,
735
- media,
736
- created_at: clientTimestamp,
737
- user_message: true, // Always true for optimistic messages
738
- error: null,
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
- // --- Store Optimistic Message using pendingKey ---
742
- setState((prevState) => {
743
- const currentOptimistic = prevState.optimisticMessages[pendingKey] || [];
744
- return {
745
- ...prevState,
746
- optimisticMessages: {
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
- try {
755
- // --- API Call ---
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
- if (selectedAccount.type.toLowerCase() === "enterprise") {
765
- response = await request({
766
- url: "/notify/v1/enterprise/chats/",
767
- params: requestParams,
768
- data: requestData,
769
- method: "Post",
770
- });
771
- } else if (
772
- String(selectedAccount.type).toLowerCase() === "instructor" &&
773
- affiliateAccount
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
- if (!response?.data) {
796
- throw new Error("API request succeeded but returned no data.");
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
- /** @type {SentMessageResponse} */
800
- const sentMessageData = response.data;
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
- // --- Update State on Success ---
824
- setState((prevState) => {
825
- let updatedOptimisticMessages = { ...prevState.optimisticMessages };
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
- if (filteredList.length < originalPendingList.length) {
839
- // Check if removal occurred
840
- optimisticRemoved = true;
841
- if (filteredList.length > 0) {
842
- updatedOptimisticMessages[pendingKey] = filteredList; // Update the list
843
- } else {
844
- delete updatedOptimisticMessages[pendingKey]; // Remove the key if list is empty
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
- if (!optimisticRemoved) {
849
- // Log if the message wasn't found (might indicate a race condition or logic issue)
850
- console.warn(
851
- `Optimistic message tempId ${tempId} not found in ${pendingKey} for removal upon success.`
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
- // --- 2. Add Confirmed Message to Chats[roomId] ---
856
- const messageDateLabel = getDisplayDateLabel(newChatMessage.date);
857
- // Get current history for the confirmed room ID, default to empty array
858
- const currentChatHistory = updatedChats[confirmedRoomId] || [];
859
- let updatedChatHistory = [...currentChatHistory];
917
+ // --- Handle SUCCESS ---
918
+ /** @type {SentMessageResponse} */
919
+ const sentMessageData = response.data;
920
+ const confirmedRoomId = sentMessageData.room;
921
+ const confirmedMessageId = sentMessageData.id;
860
922
 
861
- // Find if a group for this date already exists
862
- let dateGroupIndex = updatedChatHistory.findIndex(
863
- (group) => group.date === messageDateLabel
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
- if (dateGroupIndex !== -1) {
867
- // Group exists: Add message if it's not already there (e.g., from WebSocket echo)
868
- const dateGroup = updatedChatHistory[dateGroupIndex];
869
- if (!dateGroup.messages.some((msg) => msg.id === newChatMessage.id)) {
870
- // Add and re-sort messages within the group
871
- const updatedMessages = [
872
- ...dateGroup.messages,
873
- newChatMessage,
874
- ].sort(
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
- updatedChatHistory[dateGroupIndex] = {
880
- ...dateGroup,
881
- messages: updatedMessages,
882
- };
951
+ // Don't modify state if the queue front doesn't match
952
+ return prevState;
883
953
  }
884
- } else {
885
- // Group doesn't exist: Create new group and add it
886
- const newGroup = {
887
- date: messageDateLabel,
888
- dateString: newChatMessage.date, // Store YYYY-MM-DD for sorting
889
- messages: [newChatMessage],
890
- };
891
- updatedChatHistory.push(newGroup);
892
- // Sort the date groups themselves (e.g., "Today" first)
893
- updatedChatHistory.sort(
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
- // Find the room within this course group
910
- const roomIndex = courseGroup.rooms.findIndex(
911
- (room) => room.id === confirmedRoomId
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
- if (roomIndex !== -1) {
914
- roomExistsInState = true; // Mark room as found
915
- const updatedRooms = [...courseGroup.rooms];
916
- // Update the last message for the found room
917
- updatedRooms[roomIndex] = {
918
- ...updatedRooms[roomIndex],
919
- last_message: {
920
- id: newChatMessage.id,
921
- text: newChatMessage.text,
922
- media: newChatMessage.media, // Use the confirmed media details
923
- created_at: newChatMessage.created_at,
924
- },
925
- // Resetting unread count here might be incorrect if the user isn't viewing the room
926
- // Unread count updates should primarily happen on receiving messages or explicit read actions
927
- };
928
- // Return the updated course group
929
- return { ...courseGroup, rooms: updatedRooms };
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
- // If room not found in this course group, return group unchanged
932
- return courseGroup;
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
- // --- 4. Handle Room Not Found / Refetch ---
936
- // If the room's metadata wasn't in roomsByCourses, fetch the list again
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
- // --- 5. REMOVED Move Logic ---
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
- // --- Return Final State ---
948
- return {
949
- ...prevState,
950
- chats: updatedChats,
951
- optimisticMessages: updatedOptimisticMessages,
952
- roomsByCourses: updatedRoomsByCourses,
953
- };
954
- });
955
- } catch (error) {
956
- // --- Update State on Failure ---
957
- console.error("Failed to send message:", error);
958
- setState((prevState) => {
959
- const updatedOptimisticMessages = { ...prevState.optimisticMessages };
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
- // Find the optimistic message ONLY in the PENDING list
962
- if (updatedOptimisticMessages[pendingKey]) {
963
- const messageIndex = updatedOptimisticMessages[pendingKey].findIndex(
964
- (msg) => msg.tempId === tempId && msg.status === "pending" // Important: Only update if still pending
965
- );
966
- if (messageIndex !== -1) {
967
- // Update status to failed and store error message
968
- updatedOptimisticMessages[pendingKey][messageIndex] = {
969
- ...updatedOptimisticMessages[pendingKey][messageIndex],
970
- status: "failed",
971
- error: error?.message || "Unknown error", // Store simplified error
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
- `Optimistic message tempId ${tempId} in ${pendingKey} not found or not pending for failure update.`
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
- // Return state with updated optimistic message status
985
- return {
986
- ...prevState,
987
- optimisticMessages: updatedOptimisticMessages,
988
- };
989
- });
990
- // Optionally re-throw or handle the error further (e.g., show notification)
991
- }
992
- }; // End sendMessage
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 to avoid mutation issues
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 on adapted optimistic messages
1032
- is_read: false, // Implicitly false
1033
- is_delivered: false, // Implicitly 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
- // Sort primarily by creation timestamp using getTime() for reliable number comparison
1053
- const dateA = new Date(a.created_at);
1054
- const dateB = new Date(b.created_at);
1055
- const timeA = !isNaN(dateA.getTime()) ? dateA.getTime() : 0; // Handle potential invalid dates
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; dateString: string; messages: any[] }>} */
1199
+ /** @type {Record<string, { date: string; messages: any[] }>} */
1067
1200
  const groupedMessages = {};
1068
1201
 
1069
1202
  combinedFlatMessages.forEach((message) => {
1070
- // Use the pre-calculated date string (YYYY-MM-DD)
1071
- const dateString = message.date;
1072
- if (!dateString) {
1073
- // Safety check
1074
- console.warn("Message missing date string for grouping:", message);
1075
- return; // Skip messages without a valid date string
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 the grouped object back into an array of groups
1093
- const finalGroupedArray = Object.values(groupedMessages)
1094
- // Sort the groups themselves (e.g., Today first, then Yesterday, then older dates)
1095
- .sort(
1096
- (a, b) =>
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 chronologically
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
- // Return the final array structure expected by the UI
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
- if (!dateString) return 0; // Handle undefined/null dateString
1119
- if (displayLabel === "Today") return 2; // Highest priority
1120
- if (displayLabel === "Yesterday") return 1; // Next highest
1121
- // For specific dates, return the negative timestamp for descending sort
1122
- try {
1123
- const date = new Date(dateString + "T00:00:00Z"); // Use UTC base
1124
- if (isNaN(date.getTime())) return 0; // Low priority if invalid
1125
- // Older dates have smaller negative numbers (larger positive time), leading to descending sort
1126
- return -date.getTime();
1127
- } catch {
1128
- return 0; // Fallback for errors
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