diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 4054ae7d..0ef0c27d 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -1854,6 +1854,7 @@ C0A741000000000000000010 /* UserCreatorChatRoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000010 /* UserCreatorChatRoomView.swift */; }; C0A741000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */; }; C0A741000000000000000012 /* UserCreatorChatDatePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000012 /* UserCreatorChatDatePresentation.swift */; }; + C0A741000000000000000013 /* UserCreatorChatDateSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000013 /* UserCreatorChatDateSeparatorView.swift */; }; C0A742000000000000000001 /* MainChatRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000001 /* MainChatRoomsResponse.swift */; }; C0A742000000000000000002 /* MainChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000002 /* MainChatApi.swift */; }; C0A742000000000000000003 /* MainChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000003 /* MainChatRepository.swift */; }; @@ -1879,6 +1880,7 @@ C0A743000000000000000010 /* UserCreatorChatRoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000010 /* UserCreatorChatRoomView.swift */; }; C0A743000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */; }; C0A743000000000000000012 /* UserCreatorChatDatePresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000012 /* UserCreatorChatDatePresentation.swift */; }; + C0A743000000000000000013 /* UserCreatorChatDateSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000013 /* UserCreatorChatDateSeparatorView.swift */; }; C0D6E325AEFEEF476E7044AB /* CommunityPostCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82912F2F111593BE42E33C /* CommunityPostCard.swift */; }; C3CFF5936F1BAC5BC12E9DDB /* UpdateContentPreferenceRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDFAA64C2CA4FCBBF00A024 /* UpdateContentPreferenceRequest.swift */; }; C4A7064704896D68ACBA135F /* PushNotificationListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA7A276BF4A47206ED702A7 /* PushNotificationListItemView.swift */; }; @@ -3100,6 +3102,7 @@ C0A740000000000000000010 /* UserCreatorChatRoomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatRoomView.swift; sourceTree = ""; }; C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatSessionNotification.swift; sourceTree = ""; }; C0A740000000000000000012 /* UserCreatorChatDatePresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatDatePresentation.swift; sourceTree = ""; }; + C0A740000000000000000013 /* UserCreatorChatDateSeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatDateSeparatorView.swift; sourceTree = ""; }; C1165E3F4CA1AB019FC85067 /* LanguageRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageRepository.swift; sourceTree = ""; }; C40163E3E9DB325EA984D3C4 /* CreatorChannelFloatingIconButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFloatingIconButton.swift; sourceTree = ""; }; C6B18C00DE256DCC53030630 /* Spacing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Spacing.swift; sourceTree = ""; }; @@ -6195,6 +6198,7 @@ isa = PBXGroup; children = ( C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */, + C0A740000000000000000013 /* UserCreatorChatDateSeparatorView.swift */, ); path = Components; sourceTree = ""; @@ -7152,6 +7156,7 @@ C0A741000000000000000008 /* UserCreatorChatModels.swift in Sources */, C0A741000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */, C0A741000000000000000012 /* UserCreatorChatDatePresentation.swift in Sources */, + C0A741000000000000000013 /* UserCreatorChatDateSeparatorView.swift in Sources */, C0A74100000000000000000A /* UserCreatorChatApi.swift in Sources */, C0A74100000000000000000B /* UserCreatorChatRepository.swift in Sources */, C0A74100000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */, @@ -9037,6 +9042,7 @@ C0A743000000000000000008 /* UserCreatorChatModels.swift in Sources */, C0A743000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */, C0A743000000000000000012 /* UserCreatorChatDatePresentation.swift in Sources */, + C0A743000000000000000013 /* UserCreatorChatDateSeparatorView.swift in Sources */, C0A74300000000000000000A /* UserCreatorChatApi.swift in Sources */, C0A74300000000000000000B /* UserCreatorChatRepository.swift in Sources */, C0A74300000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */, diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components/UserCreatorChatDateSeparatorView.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components/UserCreatorChatDateSeparatorView.swift new file mode 100644 index 00000000..71fcac25 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components/UserCreatorChatDateSeparatorView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct UserCreatorChatDateSeparatorView: View { + @Environment(\.locale) private var locale + + let title: String + + var body: some View { + Text(title) + .font(dateFont) + .foregroundColor(Color.gray400) + .multilineTextAlignment(.center) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(title)) + } + + private var dateFont: SwiftUI.Font { + if let fontName = SodaTypography.caption3.localizedFontName(locale: locale) { + return .custom(fontName, size: SodaTypography.caption3.size, relativeTo: .caption) + } + return .system(.caption, design: .default, weight: SodaTypography.caption3.weight) + } +} diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDatePresentation.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDatePresentation.swift index 4cda499a..f5e07fec 100644 --- a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDatePresentation.swift +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDatePresentation.swift @@ -1,6 +1,26 @@ import Foundation +struct UserCreatorChatDatedMessage: Identifiable { + let message: UserCreatorChatDisplayMessage + let separatorDate: Date? + + var id: String { message.id } +} + enum UserCreatorChatDatePresentation { + static func rows(from messages: [UserCreatorChatDisplayMessage], calendar: Calendar, timeZone: TimeZone) -> [UserCreatorChatDatedMessage] { + var calendar = calendar + calendar.timeZone = timeZone + var previousDate: Date? + + return messages.filter { $0.messageType == "TEXT" }.map { message in + let date = Date(timeIntervalSince1970: TimeInterval(message.createdAt) / 1000) + let startsDay = previousDate.map { !calendar.isDate($0, inSameDayAs: date) } ?? true + previousDate = date + return UserCreatorChatDatedMessage(message: message, separatorDate: startsDay ? date : nil) + } + } + static func title(for date: Date, locale: Locale, calendar: Calendar, timeZone: TimeZone) -> String { var calendar = calendar calendar.timeZone = timeZone diff --git a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift index 8c784b29..26541766 100644 --- a/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift +++ b/SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift @@ -3,6 +3,8 @@ import UIKit import Combine struct UserCreatorChatRoomView: View { + @Environment(\.locale) private var locale + @Environment(\.timeZone) private var timeZone @StateObject private var viewModel = UserCreatorChatRoomViewModel() @State private var messageText = "" @@ -68,7 +70,12 @@ struct UserCreatorChatRoomView: View { } private var messageListView: some View { - GeometryReader { geometry in + let textMessages = self.textMessages + var calendar = Calendar.current + calendar.timeZone = timeZone + let rows = UserCreatorChatDatePresentation.rows(from: textMessages, calendar: calendar, timeZone: timeZone) + + return GeometryReader { geometry in ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { LazyVStack(spacing: SodaSpacing.s16) { @@ -79,14 +86,26 @@ struct UserCreatorChatRoomView: View { .padding(.vertical, SodaSpacing.s12) } - ForEach(textMessages) { message in - UserCreatorChatTextMessageItemView(message: message) - .id(message.id) - .onAppear { - if message.id == textMessages.first?.id { - viewModel.loadMore() - } + ForEach(rows) { row in + let message = row.message + VStack(spacing: SodaSpacing.s16) { + if let date = row.separatorDate { + UserCreatorChatDateSeparatorView(title: UserCreatorChatDatePresentation.title( + for: date, + locale: locale, + calendar: calendar, + timeZone: timeZone + )) } + + UserCreatorChatTextMessageItemView(message: message) + .id(message.id) + .onAppear { + if message.id == textMessages.first?.id { + viewModel.loadMore() + } + } + } } } .padding(.horizontal, SodaSpacing.s24) diff --git a/work/tests/UserCreatorChatDatePresentationCheck.swift b/work/tests/UserCreatorChatDatePresentationCheck.swift index e1a41888..e949d96d 100644 --- a/work/tests/UserCreatorChatDatePresentationCheck.swift +++ b/work/tests/UserCreatorChatDatePresentationCheck.swift @@ -21,6 +21,111 @@ enum UserCreatorChatDatePresentationCheck { } } + static func timestamp(_ value: String) -> Int64 { + Int64(ISO8601DateFormatter().date(from: value)!.timeIntervalSince1970 * 1000) + } + + static func serverMessage(_ id: Int, at value: String, type: String = "TEXT") -> UserCreatorChatDisplayMessage { + UserCreatorChatDisplayMessage(message: UserCreatorChatMessageItem( + messageId: id, + messageType: type, + mine: true, + createdAt: timestamp(value), + textMessage: type == "TEXT" ? "message \(id)" : nil, + voiceMessageUrl: nil, + senderId: 1, + senderNickname: "sender", + senderProfileImageUrl: nil + )) + } + + static func checkRows( + _ messages: [UserCreatorChatDisplayMessage], + calendar: Calendar, + timeZone: TimeZone, + expectedMessages: [UserCreatorChatDisplayMessage], + separatorIndices: [Int], + label: String + ) { + let rows = UserCreatorChatDatePresentation.rows(from: messages, calendar: calendar, timeZone: timeZone) + let expectedDates: [Date?] = expectedMessages.enumerated().map { index, message in + separatorIndices.contains(index) + ? Date(timeIntervalSince1970: TimeInterval(message.createdAt) / 1000) + : nil + } + check(rows.map(\.id) == expectedMessages.map(\.id), "\(label): preserve message IDs and order") + check(rows.map(\.message) == expectedMessages, "\(label): preserve complete messages and status") + check(rows.map(\.separatorDate) == expectedDates, "\(label): exact separator positions and timestamps") + } + + static func checkDatedRows(calendar: Calendar, utc: TimeZone, seoul: TimeZone, losAngeles: TimeZone) { + let first = serverMessage(1, at: "2026-10-16T10:00:00Z") + let second = serverMessage(2, at: "2026-10-16T11:00:00Z") + let third = serverMessage(3, at: "2026-10-16T12:00:00Z") + let previous = serverMessage(4, at: "2026-10-15T23:00:00Z") + let next = serverMessage(5, at: "2026-10-17T01:00:00Z") + + checkRows([], calendar: calendar, timeZone: utc, expectedMessages: [], separatorIndices: [], label: "empty") + let singleRows = UserCreatorChatDatePresentation.rows(from: [first], calendar: calendar, timeZone: utc) + check( + singleRows.first?.separatorDate == Date(timeIntervalSince1970: TimeInterval(first.createdAt) / 1000), + "single TEXT message must have its first separatorDate" + ) + checkRows([first], calendar: calendar, timeZone: utc, expectedMessages: [first], separatorIndices: [0], label: "single") + checkRows([second, third], calendar: calendar, timeZone: utc, expectedMessages: [second, third], separatorIndices: [0], label: "before prepend") + checkRows([first, second, third], calendar: calendar, timeZone: utc, expectedMessages: [first, second, third], separatorIndices: [0], label: "same day prepend moves first separator") + checkRows([previous, first, second, third], calendar: calendar, timeZone: utc, expectedMessages: [previous, first, second, third], separatorIndices: [0, 1], label: "different day prepend") + checkRows([previous, first, next], calendar: calendar, timeZone: utc, expectedMessages: [previous, first, next], separatorIndices: [0, 1, 2], label: "consecutive different days") + + let utcBoundary = [serverMessage(6, at: "2026-10-15T23:30:00Z"), serverMessage(7, at: "2026-10-16T00:30:00Z")] + checkRows(utcBoundary, calendar: calendar, timeZone: seoul, expectedMessages: utcBoundary, separatorIndices: [0], label: "UTC midnight is same Seoul day with UTC calendar") + checkRows(utcBoundary, calendar: calendar, timeZone: utc, expectedMessages: utcBoundary, separatorIndices: [0, 1], label: "same timestamps cross UTC day") + let seoulBoundary = [serverMessage(8, at: "2026-10-16T14:30:00Z"), serverMessage(9, at: "2026-10-16T15:30:00Z")] + checkRows(seoulBoundary, calendar: calendar, timeZone: seoul, expectedMessages: seoulBoundary, separatorIndices: [0, 1], label: "Seoul midnight within same UTC day") + checkRows(seoulBoundary, calendar: calendar, timeZone: utc, expectedMessages: seoulBoundary, separatorIndices: [0], label: "same timestamps remain same UTC day") + var mismatchedCalendar = calendar + mismatchedCalendar.timeZone = seoul + checkRows(seoulBoundary, calendar: mismatchedCalendar, timeZone: utc, expectedMessages: seoulBoundary, separatorIndices: [0], label: "explicit UTC overrides Seoul calendar") + + let repeatedHour = [serverMessage(10, at: "2026-11-01T08:30:00Z"), serverMessage(11, at: "2026-11-01T09:30:00Z")] + let beforeMidnight = serverMessage(12, at: "2026-11-02T07:30:00Z") + let midnight = serverMessage(13, at: "2026-11-02T08:00:00Z") + checkRows(repeatedHour, calendar: calendar, timeZone: losAngeles, expectedMessages: repeatedHour, separatorIndices: [0], label: "LA DST repeated 01:30") + let dstDay = repeatedHour + [beforeMidnight, midnight] + checkRows(dstDay, calendar: calendar, timeZone: losAngeles, expectedMessages: dstDay, separatorIndices: [0, 3], label: "LA 25-hour day ends at local midnight") + + let earlierVoice = serverMessage(14, at: "2026-10-14T10:00:00Z", type: "VOICE") + let betweenVoice = serverMessage(15, at: "2026-10-15T10:00:00Z", type: "VOICE") + let laterVoice = serverMessage(16, at: "2026-10-18T10:00:00Z", type: "VOICE") + checkRows([earlierVoice, betweenVoice, laterVoice], calendar: calendar, timeZone: utc, expectedMessages: [], separatorIndices: [], label: "VOICE-only has no orphan dates") + checkRows([earlierVoice, first, betweenVoice, second, laterVoice], calendar: calendar, timeZone: utc, expectedMessages: [first, second], separatorIndices: [0], label: "mixed VOICE cannot split same TEXT day") + checkRows([earlierVoice, first, betweenVoice, next, laterVoice], calendar: calendar, timeZone: utc, expectedMessages: [first, next], separatorIndices: [0, 1], label: "mixed VOICE preserves only displayed day boundaries") + + let pending = UserCreatorChatDisplayMessage(pendingText: "pending", requestId: "snapshot", createdAt: timestamp("2026-10-17T00:01:00Z")) + var failed = pending + failed.status = .failed + checkRows([first, pending], calendar: calendar, timeZone: utc, expectedMessages: [first, pending], separatorIndices: [0, 1], label: "pending local date snapshot") + checkRows([first, failed], calendar: calendar, timeZone: utc, expectedMessages: [first, failed], separatorIndices: [0, 1], label: "failed keeps pending date and ID") + let pendingDates = UserCreatorChatDatePresentation.rows(from: [first, pending], calendar: calendar, timeZone: utc).map(\.separatorDate) + let failedDates = UserCreatorChatDatePresentation.rows(from: [first, failed], calendar: calendar, timeZone: utc).map(\.separatorDate) + check(pendingDates == failedDates, "pending-to-failed changes no separator timestamps") + let serverSameDay = serverMessage(17, at: "2026-10-17T00:02:00Z") + let serverChangedDay = serverMessage(17, at: "2026-10-16T23:59:00Z") + checkRows([first, serverSameDay], calendar: calendar, timeZone: utc, expectedMessages: [first, serverSameDay], separatorIndices: [0, 1], label: "same day ACK replacement uses server timestamp and ID") + checkRows([first, serverChangedDay], calendar: calendar, timeZone: utc, expectedMessages: [first, serverChangedDay], separatorIndices: [0], label: "changed day ACK removes empty pending date and duplicate destination date") + checkRows([pending], calendar: calendar, timeZone: utc, expectedMessages: [pending], separatorIndices: [0], label: "only pending snapshot") + checkRows([serverChangedDay], calendar: calendar, timeZone: utc, expectedMessages: [serverChangedDay], separatorIndices: [0], label: "only pending replaced leaves only server date") + checkRows([first], calendar: calendar, timeZone: utc, expectedMessages: [first], separatorIndices: [0], label: "removed last day leaves no orphan") + checkRows([], calendar: calendar, timeZone: utc, expectedMessages: [], separatorIndices: [], label: "removed all messages leaves no orphan") + + let equalTimestamp = serverMessage(18, at: "2026-10-16T10:00:00Z") + checkRows([equalTimestamp, first, second], calendar: calendar, timeZone: utc, expectedMessages: [equalTimestamp, first, second], separatorIndices: [0], label: "equal timestamps keep supplied order and IDs") + checkRows([next, first, second, previous, third], calendar: calendar, timeZone: utc, expectedMessages: [next, first, second, previous, third], separatorIndices: [0, 1, 3, 4], label: "only adjacent days compared without sorting") + checkRows([first, first], calendar: calendar, timeZone: utc, expectedMessages: [first, first], separatorIndices: [0], label: "input is not deduplicated") + let milliseconds = UserCreatorChatDisplayMessage(pendingText: "fraction", requestId: "milliseconds", createdAt: first.createdAt + 123) + checkRows([milliseconds], calendar: calendar, timeZone: utc, expectedMessages: [milliseconds], separatorIndices: [0], label: "millisecond timestamp precision") + } + static func main() { let utc = TimeZone(secondsFromGMT: 0)! let seoul = TimeZone(identifier: "Asia/Seoul")! @@ -83,6 +188,7 @@ enum UserCreatorChatDatePresentationCheck { label: label ) } + checkDatedRows(calendar: calendar, utc: utc, seoul: seoul, losAngeles: losAngeles) print("PASS: \(assertions) assertions") } }