feat(chat): 대화 날짜 구분선을 연결한다
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatSessionNotification.swift; sourceTree = "<group>"; };
|
||||
C0A740000000000000000012 /* UserCreatorChatDatePresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatDatePresentation.swift; sourceTree = "<group>"; };
|
||||
C0A740000000000000000013 /* UserCreatorChatDateSeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatDateSeparatorView.swift; sourceTree = "<group>"; };
|
||||
C1165E3F4CA1AB019FC85067 /* LanguageRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageRepository.swift; sourceTree = "<group>"; };
|
||||
C40163E3E9DB325EA984D3C4 /* CreatorChannelFloatingIconButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFloatingIconButton.swift; sourceTree = "<group>"; };
|
||||
C6B18C00DE256DCC53030630 /* Spacing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Spacing.swift; sourceTree = "<group>"; };
|
||||
@@ -6195,6 +6198,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */,
|
||||
C0A740000000000000000013 /* UserCreatorChatDateSeparatorView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
@@ -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
|
||||
|
||||
@@ -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,7 +86,18 @@ struct UserCreatorChatRoomView: View {
|
||||
.padding(.vertical, SodaSpacing.s12)
|
||||
}
|
||||
|
||||
ForEach(textMessages) { message in
|
||||
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 {
|
||||
@@ -89,6 +107,7 @@ struct UserCreatorChatRoomView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, SodaSpacing.s24)
|
||||
.padding(.vertical, SodaSpacing.s12)
|
||||
.background(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user