Compare commits
7 Commits
5213f0d98d
...
359df77b83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
359df77b83 | ||
|
|
eaed309a1d | ||
|
|
47fe0e787d | ||
|
|
ec79e3ce8b | ||
|
|
e717a12344 | ||
|
|
2dfff32ac1 | ||
|
|
ce191539f2 |
@@ -1811,6 +1811,16 @@
|
|||||||
C0A741000000000000000005 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000005 /* MainChatView.swift */; };
|
C0A741000000000000000005 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000005 /* MainChatView.swift */; };
|
||||||
C0A741000000000000000006 /* MainChatRoomListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000006 /* MainChatRoomListItem.swift */; };
|
C0A741000000000000000006 /* MainChatRoomListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000006 /* MainChatRoomListItem.swift */; };
|
||||||
C0A741000000000000000007 /* MainChatFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000007 /* MainChatFilter.swift */; };
|
C0A741000000000000000007 /* MainChatFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000007 /* MainChatFilter.swift */; };
|
||||||
|
C0A741000000000000000008 /* UserCreatorChatModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000008 /* UserCreatorChatModels.swift */; };
|
||||||
|
C0A741000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */; };
|
||||||
|
C0A74100000000000000000A /* UserCreatorChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000A /* UserCreatorChatApi.swift */; };
|
||||||
|
C0A74100000000000000000B /* UserCreatorChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000B /* UserCreatorChatRepository.swift */; };
|
||||||
|
C0A74100000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */; };
|
||||||
|
C0A74100000000000000000D /* WebSocketChatClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000D /* WebSocketChatClient.swift */; };
|
||||||
|
C0A74100000000000000000E /* UserCreatorChatTextMessageItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */; };
|
||||||
|
C0A74100000000000000000F /* UserCreatorChatRoomViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000F /* UserCreatorChatRoomViewModel.swift */; };
|
||||||
|
C0A741000000000000000010 /* UserCreatorChatRoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000010 /* UserCreatorChatRoomView.swift */; };
|
||||||
|
C0A741000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */; };
|
||||||
C0A742000000000000000001 /* MainChatRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000001 /* MainChatRoomsResponse.swift */; };
|
C0A742000000000000000001 /* MainChatRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000001 /* MainChatRoomsResponse.swift */; };
|
||||||
C0A742000000000000000002 /* MainChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000002 /* MainChatApi.swift */; };
|
C0A742000000000000000002 /* MainChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000002 /* MainChatApi.swift */; };
|
||||||
C0A742000000000000000003 /* MainChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000003 /* MainChatRepository.swift */; };
|
C0A742000000000000000003 /* MainChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000003 /* MainChatRepository.swift */; };
|
||||||
@@ -1825,6 +1835,16 @@
|
|||||||
C0A743000000000000000005 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000005 /* MainChatView.swift */; };
|
C0A743000000000000000005 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000005 /* MainChatView.swift */; };
|
||||||
C0A743000000000000000006 /* MainChatRoomListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000006 /* MainChatRoomListItem.swift */; };
|
C0A743000000000000000006 /* MainChatRoomListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000006 /* MainChatRoomListItem.swift */; };
|
||||||
C0A743000000000000000007 /* MainChatFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000007 /* MainChatFilter.swift */; };
|
C0A743000000000000000007 /* MainChatFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000007 /* MainChatFilter.swift */; };
|
||||||
|
C0A743000000000000000008 /* UserCreatorChatModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000008 /* UserCreatorChatModels.swift */; };
|
||||||
|
C0A743000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */; };
|
||||||
|
C0A74300000000000000000A /* UserCreatorChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000A /* UserCreatorChatApi.swift */; };
|
||||||
|
C0A74300000000000000000B /* UserCreatorChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000B /* UserCreatorChatRepository.swift */; };
|
||||||
|
C0A74300000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */; };
|
||||||
|
C0A74300000000000000000D /* WebSocketChatClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000D /* WebSocketChatClient.swift */; };
|
||||||
|
C0A74300000000000000000E /* UserCreatorChatTextMessageItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */; };
|
||||||
|
C0A74300000000000000000F /* UserCreatorChatRoomViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A74000000000000000000F /* UserCreatorChatRoomViewModel.swift */; };
|
||||||
|
C0A743000000000000000010 /* UserCreatorChatRoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000010 /* UserCreatorChatRoomView.swift */; };
|
||||||
|
C0A743000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */; };
|
||||||
C0D6E325AEFEEF476E7044AB /* CommunityPostCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82912F2F111593BE42E33C /* CommunityPostCard.swift */; };
|
C0D6E325AEFEEF476E7044AB /* CommunityPostCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE82912F2F111593BE42E33C /* CommunityPostCard.swift */; };
|
||||||
C3CFF5936F1BAC5BC12E9DDB /* UpdateContentPreferenceRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDFAA64C2CA4FCBBF00A024 /* UpdateContentPreferenceRequest.swift */; };
|
C3CFF5936F1BAC5BC12E9DDB /* UpdateContentPreferenceRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDFAA64C2CA4FCBBF00A024 /* UpdateContentPreferenceRequest.swift */; };
|
||||||
C45F071870A4DB48ABAE8BC5 /* YandexAdSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F064EC9D5711B91979F45C05 /* YandexAdSupport.swift */; };
|
C45F071870A4DB48ABAE8BC5 /* YandexAdSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F064EC9D5711B91979F45C05 /* YandexAdSupport.swift */; };
|
||||||
@@ -2975,6 +2995,16 @@
|
|||||||
C0A740000000000000000005 /* MainChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatView.swift; sourceTree = "<group>"; };
|
C0A740000000000000000005 /* MainChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatView.swift; sourceTree = "<group>"; };
|
||||||
C0A740000000000000000006 /* MainChatRoomListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatRoomListItem.swift; sourceTree = "<group>"; };
|
C0A740000000000000000006 /* MainChatRoomListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatRoomListItem.swift; sourceTree = "<group>"; };
|
||||||
C0A740000000000000000007 /* MainChatFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatFilter.swift; sourceTree = "<group>"; };
|
C0A740000000000000000007 /* MainChatFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatFilter.swift; sourceTree = "<group>"; };
|
||||||
|
C0A740000000000000000008 /* UserCreatorChatModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatModels.swift; sourceTree = "<group>"; };
|
||||||
|
C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatDisplayMessage.swift; sourceTree = "<group>"; };
|
||||||
|
C0A74000000000000000000A /* UserCreatorChatApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatApi.swift; sourceTree = "<group>"; };
|
||||||
|
C0A74000000000000000000B /* UserCreatorChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatRepository.swift; sourceTree = "<group>"; };
|
||||||
|
C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatWebSocketModels.swift; sourceTree = "<group>"; };
|
||||||
|
C0A74000000000000000000D /* WebSocketChatClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketChatClient.swift; sourceTree = "<group>"; };
|
||||||
|
C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatTextMessageItemView.swift; sourceTree = "<group>"; };
|
||||||
|
C0A74000000000000000000F /* UserCreatorChatRoomViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCreatorChatRoomViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
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>"; };
|
||||||
C1165E3F4CA1AB019FC85067 /* LanguageRepository.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LanguageRepository.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>"; };
|
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>"; };
|
C6B18C00DE256DCC53030630 /* Spacing.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Spacing.swift; sourceTree = "<group>"; };
|
||||||
@@ -5842,6 +5872,7 @@
|
|||||||
C0A74000000000000000000101 /* Models */,
|
C0A74000000000000000000101 /* Models */,
|
||||||
C0A74000000000000000000102 /* Repository */,
|
C0A74000000000000000000102 /* Repository */,
|
||||||
C0A74000000000000000000103 /* Components */,
|
C0A74000000000000000000103 /* Components */,
|
||||||
|
C0A74000000000000000000104 /* UserCreatorChat */,
|
||||||
C0A740000000000000000004 /* MainChatViewModel.swift */,
|
C0A740000000000000000004 /* MainChatViewModel.swift */,
|
||||||
C0A740000000000000000005 /* MainChatView.swift */,
|
C0A740000000000000000005 /* MainChatView.swift */,
|
||||||
C0A740000000000000000007 /* MainChatFilter.swift */,
|
C0A740000000000000000007 /* MainChatFilter.swift */,
|
||||||
@@ -5874,6 +5905,55 @@
|
|||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
C0A74000000000000000000104 /* UserCreatorChat */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0A74000000000000000000105 /* Models */,
|
||||||
|
C0A74000000000000000000106 /* Repository */,
|
||||||
|
C0A74000000000000000000107 /* WebSocket */,
|
||||||
|
C0A74000000000000000000108 /* Components */,
|
||||||
|
C0A74000000000000000000F /* UserCreatorChatRoomViewModel.swift */,
|
||||||
|
C0A740000000000000000010 /* UserCreatorChatRoomView.swift */,
|
||||||
|
C0A740000000000000000011 /* UserCreatorChatSessionNotification.swift */,
|
||||||
|
);
|
||||||
|
path = UserCreatorChat;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C0A74000000000000000000105 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0A740000000000000000008 /* UserCreatorChatModels.swift */,
|
||||||
|
C0A740000000000000000009 /* UserCreatorChatDisplayMessage.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C0A74000000000000000000106 /* Repository */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0A74000000000000000000A /* UserCreatorChatApi.swift */,
|
||||||
|
C0A74000000000000000000B /* UserCreatorChatRepository.swift */,
|
||||||
|
);
|
||||||
|
path = Repository;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C0A74000000000000000000107 /* WebSocket */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0A74000000000000000000C /* UserCreatorChatWebSocketModels.swift */,
|
||||||
|
C0A74000000000000000000D /* WebSocketChatClient.swift */,
|
||||||
|
);
|
||||||
|
path = WebSocket;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C0A74000000000000000000108 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C0A74000000000000000000E /* UserCreatorChatTextMessageItemView.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
C46DB3E329B349CBB3F472DF /* Views */ = {
|
C46DB3E329B349CBB3F472DF /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -6774,6 +6854,16 @@
|
|||||||
C0A741000000000000000005 /* MainChatView.swift in Sources */,
|
C0A741000000000000000005 /* MainChatView.swift in Sources */,
|
||||||
C0A741000000000000000006 /* MainChatRoomListItem.swift in Sources */,
|
C0A741000000000000000006 /* MainChatRoomListItem.swift in Sources */,
|
||||||
C0A741000000000000000007 /* MainChatFilter.swift in Sources */,
|
C0A741000000000000000007 /* MainChatFilter.swift in Sources */,
|
||||||
|
C0A741000000000000000008 /* UserCreatorChatModels.swift in Sources */,
|
||||||
|
C0A741000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */,
|
||||||
|
C0A74100000000000000000A /* UserCreatorChatApi.swift in Sources */,
|
||||||
|
C0A74100000000000000000B /* UserCreatorChatRepository.swift in Sources */,
|
||||||
|
C0A74100000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */,
|
||||||
|
C0A74100000000000000000D /* WebSocketChatClient.swift in Sources */,
|
||||||
|
C0A74100000000000000000E /* UserCreatorChatTextMessageItemView.swift in Sources */,
|
||||||
|
C0A74100000000000000000F /* UserCreatorChatRoomViewModel.swift in Sources */,
|
||||||
|
C0A741000000000000000010 /* UserCreatorChatRoomView.swift in Sources */,
|
||||||
|
C0A741000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */,
|
||||||
C0A701000000000000000001 /* MainContentView.swift in Sources */,
|
C0A701000000000000000001 /* MainContentView.swift in Sources */,
|
||||||
C0A701000000000000000002 /* MainContentTab.swift in Sources */,
|
C0A701000000000000000002 /* MainContentTab.swift in Sources */,
|
||||||
C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
||||||
@@ -8618,6 +8708,16 @@
|
|||||||
C0A743000000000000000005 /* MainChatView.swift in Sources */,
|
C0A743000000000000000005 /* MainChatView.swift in Sources */,
|
||||||
C0A743000000000000000006 /* MainChatRoomListItem.swift in Sources */,
|
C0A743000000000000000006 /* MainChatRoomListItem.swift in Sources */,
|
||||||
C0A743000000000000000007 /* MainChatFilter.swift in Sources */,
|
C0A743000000000000000007 /* MainChatFilter.swift in Sources */,
|
||||||
|
C0A743000000000000000008 /* UserCreatorChatModels.swift in Sources */,
|
||||||
|
C0A743000000000000000009 /* UserCreatorChatDisplayMessage.swift in Sources */,
|
||||||
|
C0A74300000000000000000A /* UserCreatorChatApi.swift in Sources */,
|
||||||
|
C0A74300000000000000000B /* UserCreatorChatRepository.swift in Sources */,
|
||||||
|
C0A74300000000000000000C /* UserCreatorChatWebSocketModels.swift in Sources */,
|
||||||
|
C0A74300000000000000000D /* WebSocketChatClient.swift in Sources */,
|
||||||
|
C0A74300000000000000000E /* UserCreatorChatTextMessageItemView.swift in Sources */,
|
||||||
|
C0A74300000000000000000F /* UserCreatorChatRoomViewModel.swift in Sources */,
|
||||||
|
C0A743000000000000000010 /* UserCreatorChatRoomView.swift in Sources */,
|
||||||
|
C0A743000000000000000011 /* UserCreatorChatSessionNotification.swift in Sources */,
|
||||||
C0A703000000000000000001 /* MainContentView.swift in Sources */,
|
C0A703000000000000000001 /* MainContentView.swift in Sources */,
|
||||||
C0A703000000000000000002 /* MainContentTab.swift in Sources */,
|
C0A703000000000000000002 /* MainContentTab.swift in Sources */,
|
||||||
C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
enum AppDeepLinkAction {
|
enum AppDeepLinkAction {
|
||||||
case live(roomId: Int)
|
case live(roomId: Int)
|
||||||
|
case userCreatorChat(roomId: Int)
|
||||||
case content(contentId: Int)
|
case content(contentId: Int)
|
||||||
case series(seriesId: Int)
|
case series(seriesId: Int)
|
||||||
case community(creatorId: Int, postId: Int?)
|
case community(creatorId: Int, postId: Int?)
|
||||||
@@ -57,6 +58,10 @@ enum AppDeepLinkHandler {
|
|||||||
AppState.shared.pushRoomId = 0
|
AppState.shared.pushRoomId = 0
|
||||||
AppState.shared.pushRoomId = roomId
|
AppState.shared.pushRoomId = roomId
|
||||||
|
|
||||||
|
case .userCreatorChat(let roomId):
|
||||||
|
guard roomId > 0 else { return }
|
||||||
|
AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
|
||||||
|
|
||||||
case .content(let contentId):
|
case .content(let contentId):
|
||||||
guard contentId > 0 else { return }
|
guard contentId > 0 else { return }
|
||||||
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
|
AppState.shared.setAppStep(step: .contentDetail(contentId: contentId))
|
||||||
@@ -166,6 +171,12 @@ enum AppDeepLinkHandler {
|
|||||||
}
|
}
|
||||||
return .live(roomId: roomId)
|
return .live(roomId: roomId)
|
||||||
|
|
||||||
|
case "chat":
|
||||||
|
guard let identifier = identifier, let roomId = Int(identifier), roomId > 0 else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .userCreatorChat(roomId: roomId)
|
||||||
|
|
||||||
case "content":
|
case "content":
|
||||||
guard let identifier = identifier, let contentId = Int(identifier), contentId > 0 else {
|
guard let identifier = identifier, let contentId = Int(identifier), contentId > 0 else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -180,7 +180,11 @@ enum AppStep {
|
|||||||
case characterDetail(characterId: Int)
|
case characterDetail(characterId: Int)
|
||||||
|
|
||||||
case chatRoom(id: Int)
|
case chatRoom(id: Int)
|
||||||
|
|
||||||
|
case userCreatorChatRoom(roomId: Int)
|
||||||
|
|
||||||
|
case userCreatorChatCreator(creatorId: Int)
|
||||||
|
|
||||||
case newCharacterAll
|
case newCharacterAll
|
||||||
|
|
||||||
case originalWorkDetail(originalId: Int)
|
case originalWorkDetail(originalId: Int)
|
||||||
|
|||||||
@@ -321,6 +321,12 @@ struct AppStepLayerView: View {
|
|||||||
case .chatRoom(let id):
|
case .chatRoom(let id):
|
||||||
ChatRoomView(roomId: id)
|
ChatRoomView(roomId: id)
|
||||||
|
|
||||||
|
case .userCreatorChatRoom(let roomId):
|
||||||
|
UserCreatorChatRoomView(roomId: roomId)
|
||||||
|
|
||||||
|
case .userCreatorChatCreator(let creatorId):
|
||||||
|
UserCreatorChatRoomView(creatorId: creatorId)
|
||||||
|
|
||||||
case .newCharacterAll:
|
case .newCharacterAll:
|
||||||
NewCharacterListView()
|
NewCharacterListView()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,28 @@ import Foundation
|
|||||||
// String Catalog를 사용하지 않는 컨텍스트에서 사용할 하드코딩 맵 기반 i18n.
|
// String Catalog를 사용하지 않는 컨텍스트에서 사용할 하드코딩 맵 기반 i18n.
|
||||||
// 기준 언어 선택은 LanguageHeaderProvider.current("ko"|"en"|"ja").
|
// 기준 언어 선택은 LanguageHeaderProvider.current("ko"|"en"|"ja").
|
||||||
enum I18n {
|
enum I18n {
|
||||||
|
enum UserCreatorChat {
|
||||||
|
static var messagePlaceholder: String {
|
||||||
|
pick(ko: "메시지를 입력하세요", en: "Enter a message", ja: "メッセージを入力してください")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var sendingStatus: String {
|
||||||
|
pick(ko: "전송 중", en: "Sending", ja: "送信中")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var failedStatus: String {
|
||||||
|
pick(ko: "실패", en: "Failed", ja: "失敗")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var joinFailureToast: String {
|
||||||
|
pick(
|
||||||
|
ko: "대화방에 접속하지 못했습니다.",
|
||||||
|
en: "Failed to join the chat room.",
|
||||||
|
ja: "チャットルームに接続できませんでした。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 채팅방(캐릭터 톡) 관련 문자열
|
// 채팅방(캐릭터 톡) 관련 문자열
|
||||||
enum ChatRoom {
|
enum ChatRoom {
|
||||||
// 잠금된 메시지 다이얼로그
|
// 잠금된 메시지 다이얼로그
|
||||||
@@ -1409,9 +1431,9 @@ enum I18n {
|
|||||||
enum Splash {
|
enum Splash {
|
||||||
static var tagline: String {
|
static var tagline: String {
|
||||||
pick(
|
pick(
|
||||||
ko: "목소리로 만나는 새로운 세계",
|
ko: "당신의 최애가 기다리는 곳",
|
||||||
en: "A new world you meet by voice",
|
en: "Meet Your Favorites",
|
||||||
ja: "声でつながる新しいワールド"
|
ja: "推しが待つ場所"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3078,7 +3100,7 @@ If you block this user, the following features will be restricted.
|
|||||||
}
|
}
|
||||||
|
|
||||||
static var mostCommentedAudioSectionTitle: String {
|
static var mostCommentedAudioSectionTitle: String {
|
||||||
pick(ko: "최근 댓글이 많은 오디오", en: "Most commented audio", ja: "最近コメントが多いオーディオ")
|
pick(ko: "크리에이터의 댓글 많은 오디오", en: "Most commented audio", ja: "最近コメントが多いオーディオ")
|
||||||
}
|
}
|
||||||
|
|
||||||
static var recommendedAudioSectionTitle: String {
|
static var recommendedAudioSectionTitle: String {
|
||||||
@@ -3412,6 +3434,7 @@ If you block this user, the following features will be restricted.
|
|||||||
static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") }
|
static var follow: String { pick(ko: "팔로우", en: "Follow", ja: "フォロー") }
|
||||||
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
|
static var following: String { pick(ko: "팔로잉", en: "Following", ja: "フォロー中") }
|
||||||
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") }
|
static var talk: String { pick(ko: "대화하기", en: "Start chat", ja: "会話する") }
|
||||||
|
static var dm: String { pick(ko: "DM 보내기", en: "Send DM", ja: "DMを送る") }
|
||||||
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") }
|
static var uploadCommunityPost: String { pick(ko: "커뮤니티 글 올리기", en: "Upload community post", ja: "コミュニティ投稿を作成") }
|
||||||
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
|
static var uploadAudioContent: String { pick(ko: "오디오 콘텐츠 올리기", en: "Upload audio content", ja: "オーディオコンテンツを投稿") }
|
||||||
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }
|
static var createLive: String { pick(ko: "라이브 만들기", en: "Create live", ja: "ライブを作成") }
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ struct SettingsView: View {
|
|||||||
ContentPlayManager.shared.stopAudio()
|
ContentPlayManager.shared.stopAudio()
|
||||||
ContentPlayerPlayManager.shared.resetPlayer()
|
ContentPlayerPlayManager.shared.resetPlayer()
|
||||||
viewModel.logout {
|
viewModel.logout {
|
||||||
|
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
|
||||||
self.isShowLogoutDialog = false
|
self.isShowLogoutDialog = false
|
||||||
|
|
||||||
UserDefaults.reset()
|
UserDefaults.reset()
|
||||||
@@ -239,6 +240,7 @@ struct SettingsView: View {
|
|||||||
ContentPlayManager.shared.stopAudio()
|
ContentPlayManager.shared.stopAudio()
|
||||||
ContentPlayerPlayManager.shared.resetPlayer()
|
ContentPlayerPlayManager.shared.resetPlayer()
|
||||||
viewModel.logoutAllDevice {
|
viewModel.logoutAllDevice {
|
||||||
|
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
|
||||||
self.isShowLogoutAllDeviceDialog = false
|
self.isShowLogoutAllDeviceDialog = false
|
||||||
UserDefaults.reset()
|
UserDefaults.reset()
|
||||||
AppState.shared.isRestartApp = true
|
AppState.shared.isRestartApp = true
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ final class SignOutViewModel: ObservableObject {
|
|||||||
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
|
let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData)
|
||||||
|
|
||||||
if decoded.success {
|
if decoded.success {
|
||||||
|
NotificationCenter.default.post(name: .userCreatorChatSessionClose, object: nil)
|
||||||
UserDefaults.reset()
|
UserDefaults.reset()
|
||||||
AppState.shared.isRestartApp = true
|
AppState.shared.isRestartApp = true
|
||||||
AppState.shared.setAppStep(step: .splash)
|
AppState.shared.setAppStep(step: .splash)
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ struct SplashView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
Image("splash_bg")
|
Color.black
|
||||||
.resizable()
|
.ignoresSafeArea()
|
||||||
.scaledToFill()
|
|
||||||
.edgesIgnoringSafeArea(.all)
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -31,13 +29,9 @@ struct SplashView: View {
|
|||||||
|
|
||||||
Image("splash_text_logo")
|
Image("splash_text_logo")
|
||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
|
|
||||||
Text(I18n.Splash.brandName)
|
Spacer()
|
||||||
.appFont(size: 21, weight: .bold)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding(.top, 52)
|
|
||||||
}
|
}
|
||||||
.padding(.bottom, screenSize().height * 100 / 2337)
|
|
||||||
|
|
||||||
if isShowMaintenancePopup {
|
if isShowMaintenancePopup {
|
||||||
SodaDialog(
|
SodaDialog(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct CommunityPostCard: View {
|
|||||||
let isLike: Bool
|
let isLike: Bool
|
||||||
let likeCount: Int
|
let likeCount: Int
|
||||||
let commentCount: Int
|
let commentCount: Int
|
||||||
|
let onTapPost: () -> Void
|
||||||
let onTapLike: () -> Void
|
let onTapLike: () -> Void
|
||||||
let onTapComment: () -> Void
|
let onTapComment: () -> Void
|
||||||
let onTapPurchase: () -> Void
|
let onTapPurchase: () -> Void
|
||||||
@@ -36,6 +37,7 @@ struct CommunityPostCard: View {
|
|||||||
isLike: Bool,
|
isLike: Bool,
|
||||||
likeCount: Int,
|
likeCount: Int,
|
||||||
commentCount: Int,
|
commentCount: Int,
|
||||||
|
onTapPost: @escaping () -> Void = {},
|
||||||
onTapLike: @escaping () -> Void = {},
|
onTapLike: @escaping () -> Void = {},
|
||||||
onTapComment: @escaping () -> Void = {},
|
onTapComment: @escaping () -> Void = {},
|
||||||
onTapPurchase: @escaping () -> Void = {}
|
onTapPurchase: @escaping () -> Void = {}
|
||||||
@@ -52,6 +54,7 @@ struct CommunityPostCard: View {
|
|||||||
self.isLike = isLike
|
self.isLike = isLike
|
||||||
self.likeCount = likeCount
|
self.likeCount = likeCount
|
||||||
self.commentCount = commentCount
|
self.commentCount = commentCount
|
||||||
|
self.onTapPost = onTapPost
|
||||||
self.onTapLike = onTapLike
|
self.onTapLike = onTapLike
|
||||||
self.onTapComment = onTapComment
|
self.onTapComment = onTapComment
|
||||||
self.onTapPurchase = onTapPurchase
|
self.onTapPurchase = onTapPurchase
|
||||||
@@ -76,6 +79,8 @@ struct CommunityPostCard: View {
|
|||||||
.padding(SodaSpacing.s14)
|
.padding(SodaSpacing.s14)
|
||||||
.background(Color.gray900)
|
.background(Color.gray900)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous))
|
||||||
|
.onTapGesture(perform: onTapPost)
|
||||||
.onChange(of: isLike) { newValue in
|
.onChange(of: isLike) { newValue in
|
||||||
localIsLike = newValue
|
localIsLike = newValue
|
||||||
}
|
}
|
||||||
@@ -183,6 +188,19 @@ struct CommunityPostCard: View {
|
|||||||
|
|
||||||
private var reactionBar: some View {
|
private var reactionBar: some View {
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 15) {
|
||||||
|
Button(action: onTapComment) {
|
||||||
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image("ic_feed_community_reply")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("\(commentCount)")
|
||||||
|
.appFont(.body2)
|
||||||
|
.foregroundColor(Color.gray400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
toggleLike()
|
toggleLike()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -197,19 +215,6 @@ struct CommunityPostCard: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button(action: onTapComment) {
|
|
||||||
HStack(spacing: SodaSpacing.s4) {
|
|
||||||
Image("ic_feed_community_reply")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
|
|
||||||
Text("\(commentCount)")
|
|
||||||
.appFont(.body2)
|
|
||||||
.foregroundColor(Color.gray400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
.frame(height: 24)
|
.frame(height: 24)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,18 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
|
|
||||||
private var reactionBar: some View {
|
private var reactionBar: some View {
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 15) {
|
||||||
|
if post.isCommentAvailable && !isPaidLocked {
|
||||||
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image("ic_feed_community_reply")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("\(post.commentCount)")
|
||||||
|
.appFont(size: 16, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !isPaidLocked {
|
if !isPaidLocked {
|
||||||
Button {
|
Button {
|
||||||
localIsLike.toggle()
|
localIsLike.toggle()
|
||||||
@@ -238,18 +250,6 @@ struct CreatorChannelCommunityListItem: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
if post.isCommentAvailable && !isPaidLocked {
|
|
||||||
HStack(spacing: SodaSpacing.s4) {
|
|
||||||
Image("ic_feed_community_reply")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
|
|
||||||
Text("\(post.commentCount)")
|
|
||||||
.appFont(size: 16, weight: .regular)
|
|
||||||
.foregroundColor(Color.gray400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(height: 24)
|
.frame(height: 24)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,24 +21,32 @@ struct CreatorChannelCommunityPostDetailCommentListView: View {
|
|||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, SodaSpacing.s14)
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
|
||||||
LazyVStack(alignment: .leading, spacing: SodaSpacing.s20) {
|
LazyVStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
ForEach(comments, id: \.commentId) { comment in
|
ForEach(Array(comments.enumerated()), id: \.element.commentId) { index, comment in
|
||||||
ZStack(alignment: .topTrailing) {
|
VStack(alignment: .leading, spacing: SodaSpacing.s14) {
|
||||||
CreatorChannelCommunityPostDetailCommentRow(
|
ZStack(alignment: .topTrailing) {
|
||||||
comment: comment,
|
CreatorChannelCommunityPostDetailCommentRow(
|
||||||
creatorId: creatorId,
|
comment: comment,
|
||||||
onTapReplyDetail: { onTapReplyDetail(comment) },
|
creatorId: creatorId,
|
||||||
onTapMore: { onTapMore(comment) }
|
onTapReplyDetail: { onTapReplyDetail(comment) },
|
||||||
)
|
onTapMore: { onTapMore(comment) }
|
||||||
|
|
||||||
if selectedComment?.commentId == comment.commentId {
|
|
||||||
CreatorChannelActionPopup(
|
|
||||||
canEdit: canEdit(comment),
|
|
||||||
canDelete: canDelete(comment),
|
|
||||||
onTapEdit: { onTapEdit(comment) },
|
|
||||||
onTapDelete: { onTapDelete(comment) }
|
|
||||||
)
|
)
|
||||||
.padding(.top, 28)
|
|
||||||
|
if selectedComment?.commentId == comment.commentId {
|
||||||
|
CreatorChannelActionPopup(
|
||||||
|
canEdit: canEdit(comment),
|
||||||
|
canDelete: canDelete(comment),
|
||||||
|
onTapEdit: { onTapEdit(comment) },
|
||||||
|
onTapDelete: { onTapDelete(comment) }
|
||||||
|
)
|
||||||
|
.padding(.top, 28)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if index < comments.count - 1 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray800)
|
||||||
|
.frame(height: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { onAppearComment(comment) }
|
.onAppear { onAppearComment(comment) }
|
||||||
|
|||||||
@@ -155,6 +155,18 @@ struct CreatorChannelCommunityPostDetailContentView: View {
|
|||||||
private var reactionBar: some View {
|
private var reactionBar: some View {
|
||||||
HStack(spacing: 15) {
|
HStack(spacing: 15) {
|
||||||
if isPaidLocked == false {
|
if isPaidLocked == false {
|
||||||
|
if detail.isCommentAvailable {
|
||||||
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
|
Image("ic_feed_community_reply")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
|
||||||
|
Text("\(detail.commentCount)")
|
||||||
|
.appFont(size: 16, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: onTapLike) {
|
Button(action: onTapLike) {
|
||||||
HStack(spacing: SodaSpacing.s4) {
|
HStack(spacing: SodaSpacing.s4) {
|
||||||
Image(detail.isLiked ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
Image(detail.isLiked ? "ic_feed_community_heart_fill" : "ic_feed_community_heart")
|
||||||
@@ -167,18 +179,6 @@ struct CreatorChannelCommunityPostDetailContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
if detail.isCommentAvailable {
|
|
||||||
HStack(spacing: SodaSpacing.s4) {
|
|
||||||
Image("ic_feed_community_reply")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 18, height: 18)
|
|
||||||
|
|
||||||
Text("\(detail.commentCount)")
|
|
||||||
.appFont(size: 16, weight: .regular)
|
|
||||||
.foregroundColor(Color.gray500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 24)
|
.frame(height: 24)
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ struct CreatorChannelCommunityPostDetailView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if detail.isCommentAvailable {
|
if detail.isCommentAvailable {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray800)
|
||||||
|
.frame(height: 1)
|
||||||
|
|
||||||
CreatorChannelCommunityPostDetailCommentListView(
|
CreatorChannelCommunityPostDetailCommentListView(
|
||||||
creatorId: detail.creatorId,
|
creatorId: detail.creatorId,
|
||||||
postId: detail.postId,
|
postId: detail.postId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Kingfisher
|
|||||||
struct CreatorChannelHeaderSection: View {
|
struct CreatorChannelHeaderSection: View {
|
||||||
let creator: CreatorChannelCreatorResponse
|
let creator: CreatorChannelCreatorResponse
|
||||||
let onTapTalk: () -> Void
|
let onTapTalk: () -> Void
|
||||||
|
let onTapDm: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
@@ -52,7 +53,9 @@ struct CreatorChannelHeaderSection: View {
|
|||||||
|
|
||||||
CreatorChannelTalkActionSection(
|
CreatorChannelTalkActionSection(
|
||||||
isAiChatAvailable: creator.isAiChatAvailable,
|
isAiChatAvailable: creator.isAiChatAvailable,
|
||||||
onTapTalk: onTapTalk
|
isDmAvailable: creator.isDmAvailable,
|
||||||
|
onTapTalk: onTapTalk,
|
||||||
|
onTapDm: onTapDm
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@@ -234,7 +234,11 @@ struct CreatorChannelView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var headerSection: some View {
|
private var headerSection: some View {
|
||||||
if let creator = viewModel.response?.creator {
|
if let creator = viewModel.response?.creator {
|
||||||
CreatorChannelHeaderSection(creator: creator, onTapTalk: handleTalkTap)
|
CreatorChannelHeaderSection(
|
||||||
|
creator: creator,
|
||||||
|
onTapTalk: handleTalkTap,
|
||||||
|
onTapDm: handleDmTap
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +548,18 @@ struct CreatorChannelView: View {
|
|||||||
startChat(characterId: characterId)
|
startChat(characterId: characterId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleDmTap() {
|
||||||
|
guard let creatorId = viewModel.response?.creator.creatorId, creatorId > 0 else { return }
|
||||||
|
|
||||||
|
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
AppState.shared.setAppStep(step: .login)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
AppState.shared.setAppStep(step: .userCreatorChatCreator(creatorId: creatorId))
|
||||||
|
}
|
||||||
|
|
||||||
private func startChat(characterId: Int) {
|
private func startChat(characterId: Int) {
|
||||||
viewModel.createChatRoom(characterId: characterId) { chatRoomId in
|
viewModel.createChatRoom(characterId: characterId) { chatRoomId in
|
||||||
AppState.shared.setAppStep(step: .chatRoom(id: chatRoomId))
|
AppState.shared.setAppStep(step: .chatRoom(id: chatRoomId))
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import SwiftUI
|
|||||||
|
|
||||||
struct CreatorChannelTalkActionSection: View {
|
struct CreatorChannelTalkActionSection: View {
|
||||||
let isAiChatAvailable: Bool
|
let isAiChatAvailable: Bool
|
||||||
|
let isDmAvailable: Bool
|
||||||
let onTapTalk: () -> Void
|
let onTapTalk: () -> Void
|
||||||
|
let onTapDm: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack(spacing: SodaSpacing.s8) {
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if isAiChatAvailable {
|
if isAiChatAvailable {
|
||||||
@@ -28,6 +30,26 @@ struct CreatorChannelTalkActionSection: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isDmAvailable {
|
||||||
|
Button(action: onTapDm) {
|
||||||
|
HStack(spacing: SodaSpacing.s6) {
|
||||||
|
Image("ic_new_talk")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
|
||||||
|
Text(I18n.CreatorChannelHome.dm)
|
||||||
|
.appFont(size: 16, weight: .medium)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s12)
|
||||||
|
.overlay(
|
||||||
|
Capsule()
|
||||||
|
.stroke(Color.white.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.frame(height: 44)
|
||||||
|
|||||||
@@ -81,8 +81,13 @@ struct MainChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleChatRoomTap(roomId: Int, chatType: String) {
|
private func handleChatRoomTap(roomId: Int, chatType: String) {
|
||||||
guard chatType == "AI" else { return }
|
guard roomId > 0 else { return }
|
||||||
AppState.shared.setAppStep(step: .chatRoom(id: roomId))
|
|
||||||
|
if chatType == "AI" {
|
||||||
|
AppState.shared.setAppStep(step: .chatRoom(id: roomId))
|
||||||
|
} else if chatType == "DM" {
|
||||||
|
AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct UserCreatorChatTextMessageItemView: View {
|
||||||
|
let message: UserCreatorChatDisplayMessage
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var body: some View {
|
||||||
|
if message.mine {
|
||||||
|
mineMessageRow
|
||||||
|
} else {
|
||||||
|
receivedMessageRow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mineMessageRow: some View {
|
||||||
|
HStack(alignment: .bottom, spacing: SodaSpacing.s6) {
|
||||||
|
Spacer(minLength: SodaSpacing.s48)
|
||||||
|
mineMessageMeta
|
||||||
|
messageBubble
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var receivedMessageRow: some View {
|
||||||
|
HStack(alignment: .top, spacing: SodaSpacing.s6) {
|
||||||
|
senderProfileImage
|
||||||
|
VStack(alignment: .leading, spacing: SodaSpacing.s4) {
|
||||||
|
senderNickname
|
||||||
|
HStack(alignment: .bottom, spacing: SodaSpacing.s6) {
|
||||||
|
messageBubble
|
||||||
|
timeText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: SodaSpacing.s48)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var senderProfileImage: some View {
|
||||||
|
DownsampledKFImage(
|
||||||
|
url: URL(string: message.senderProfileImageUrl ?? ""),
|
||||||
|
size: CGSize(width: SodaSpacing.s32, height: SodaSpacing.s32)
|
||||||
|
)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var senderNickname: some View {
|
||||||
|
Text(message.senderNickname ?? "")
|
||||||
|
.appFont(size: 12, weight: .medium)
|
||||||
|
.foregroundColor(Color.gray400)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var messageBubble: some View {
|
||||||
|
Text(message.textMessage ?? "")
|
||||||
|
.appFont(size: 14, weight: .regular)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
.padding(.vertical, SodaSpacing.s12)
|
||||||
|
.background(message.mine ? Color.soda400 : Color.gray900)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mineMessageMeta: some View {
|
||||||
|
VStack(alignment: .trailing, spacing: SodaSpacing.s4) {
|
||||||
|
statusText
|
||||||
|
timeText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeText: some View {
|
||||||
|
Text(formatTime(from: message.createdAt))
|
||||||
|
.appFont(size: 11, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusText: some View {
|
||||||
|
switch message.status {
|
||||||
|
case .sent:
|
||||||
|
EmptyView()
|
||||||
|
case .pending:
|
||||||
|
Text(I18n.UserCreatorChat.sendingStatus)
|
||||||
|
.appFont(size: 11, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
case .failed:
|
||||||
|
Text(I18n.UserCreatorChat.failedStatus)
|
||||||
|
.appFont(size: 11, weight: .regular)
|
||||||
|
.foregroundColor(Color.red400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(from timestamp: Int64) -> String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
|
||||||
|
return date.convertDateFormat(dateFormat: "a hh:mm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorChatTextMessageItemView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
VStack(spacing: SodaSpacing.s16) {
|
||||||
|
UserCreatorChatTextMessageItemView(
|
||||||
|
message: UserCreatorChatDisplayMessage(
|
||||||
|
pendingText: "안녕하세요",
|
||||||
|
requestId: "preview",
|
||||||
|
createdAt: 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(SodaSpacing.s24)
|
||||||
|
.background(Color.black)
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum UserCreatorChatDisplayMessageStatus: Equatable {
|
||||||
|
case sent
|
||||||
|
case pending
|
||||||
|
case failed
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorChatDisplayMessage: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let requestId: String?
|
||||||
|
let messageId: Int?
|
||||||
|
let messageType: String
|
||||||
|
let mine: Bool
|
||||||
|
let createdAt: Int64
|
||||||
|
let textMessage: String?
|
||||||
|
let voiceMessageUrl: String?
|
||||||
|
let senderId: Int?
|
||||||
|
let senderNickname: String?
|
||||||
|
let senderProfileImageUrl: String?
|
||||||
|
var status: UserCreatorChatDisplayMessageStatus
|
||||||
|
|
||||||
|
init(message: UserCreatorChatMessageItem) {
|
||||||
|
self.id = "server-\(message.messageId)"
|
||||||
|
self.requestId = nil
|
||||||
|
self.messageId = message.messageId
|
||||||
|
self.messageType = message.messageType
|
||||||
|
self.mine = message.mine
|
||||||
|
self.createdAt = message.createdAt
|
||||||
|
self.textMessage = message.textMessage
|
||||||
|
self.voiceMessageUrl = message.voiceMessageUrl
|
||||||
|
self.senderId = message.senderId
|
||||||
|
self.senderNickname = message.senderNickname
|
||||||
|
self.senderProfileImageUrl = message.senderProfileImageUrl
|
||||||
|
self.status = .sent
|
||||||
|
}
|
||||||
|
|
||||||
|
init(pendingText text: String, requestId: String, createdAt: Int64) {
|
||||||
|
self.id = "pending-\(requestId)"
|
||||||
|
self.requestId = requestId
|
||||||
|
self.messageId = nil
|
||||||
|
self.messageType = "TEXT"
|
||||||
|
self.mine = true
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.textMessage = text
|
||||||
|
self.voiceMessageUrl = nil
|
||||||
|
self.senderId = nil
|
||||||
|
self.senderNickname = nil
|
||||||
|
self.senderProfileImageUrl = nil
|
||||||
|
self.status = .pending
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct UserCreatorCreateRoomRequest: Encodable {
|
||||||
|
let creatorId: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorCreateRoomResponse: Decodable {
|
||||||
|
let roomId: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorOpenRoomResponse: Decodable {
|
||||||
|
let roomId: Int
|
||||||
|
let opponentNickname: String
|
||||||
|
let opponentProfileImageUrl: String?
|
||||||
|
let messages: [UserCreatorChatMessageItem]
|
||||||
|
let hasMore: Bool
|
||||||
|
let nextCursor: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorChatMessagesResponse: Decodable {
|
||||||
|
let messages: [UserCreatorChatMessageItem]
|
||||||
|
let hasMore: Bool
|
||||||
|
let nextCursor: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorChatMessageItem: Codable, Identifiable {
|
||||||
|
let messageId: Int
|
||||||
|
let messageType: String
|
||||||
|
let mine: Bool
|
||||||
|
let createdAt: Int64
|
||||||
|
let textMessage: String?
|
||||||
|
let voiceMessageUrl: String?
|
||||||
|
let senderId: Int
|
||||||
|
let senderNickname: String
|
||||||
|
let senderProfileImageUrl: String?
|
||||||
|
|
||||||
|
var id: Int { messageId }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorVoiceMessageResponse: Decodable {
|
||||||
|
let message: UserCreatorChatMessageItem
|
||||||
|
let deliveredRealtime: Bool
|
||||||
|
let pushSent: Bool
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import Foundation
|
||||||
|
import Moya
|
||||||
|
|
||||||
|
enum UserCreatorChatApi {
|
||||||
|
case createRoom(request: UserCreatorCreateRoomRequest)
|
||||||
|
case openRoom(roomId: Int, limit: Int)
|
||||||
|
case getMessages(roomId: Int, cursor: Int?, limit: Int)
|
||||||
|
case sendVoiceMessage(roomId: Int, parameters: [MultipartFormData])
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UserCreatorChatApi: TargetType {
|
||||||
|
var baseURL: URL {
|
||||||
|
return URL(string: BASE_URL)!
|
||||||
|
}
|
||||||
|
|
||||||
|
var path: String {
|
||||||
|
switch self {
|
||||||
|
case .createRoom:
|
||||||
|
return "/api/v2/user-creator-chat/rooms/create"
|
||||||
|
case .openRoom(let roomId, _):
|
||||||
|
return "/api/v2/user-creator-chat/rooms/\(roomId)/open"
|
||||||
|
case .getMessages(let roomId, _, _):
|
||||||
|
return "/api/v2/user-creator-chat/rooms/\(roomId)/messages"
|
||||||
|
case .sendVoiceMessage(let roomId, _):
|
||||||
|
return "/api/v2/user-creator-chat/rooms/\(roomId)/messages/voice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var method: Moya.Method {
|
||||||
|
switch self {
|
||||||
|
case .createRoom, .sendVoiceMessage:
|
||||||
|
return .post
|
||||||
|
case .openRoom, .getMessages:
|
||||||
|
return .get
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var task: Moya.Task {
|
||||||
|
switch self {
|
||||||
|
case .createRoom(let request):
|
||||||
|
return .requestJSONEncodable(request)
|
||||||
|
case .openRoom(_, let limit):
|
||||||
|
return .requestParameters(parameters: ["limit": limit], encoding: URLEncoding.queryString)
|
||||||
|
case .getMessages(_, let cursor, let limit):
|
||||||
|
var parameters: [String: Any] = ["limit": limit]
|
||||||
|
if let cursor {
|
||||||
|
parameters["cursor"] = cursor
|
||||||
|
}
|
||||||
|
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
|
||||||
|
case .sendVoiceMessage(_, let parameters):
|
||||||
|
return .uploadMultipart(parameters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers: [String: String]? {
|
||||||
|
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CombineMoya
|
||||||
|
import Moya
|
||||||
|
|
||||||
|
final class UserCreatorChatRepository {
|
||||||
|
private let api = MoyaProvider<UserCreatorChatApi>()
|
||||||
|
|
||||||
|
func createRoom(creatorId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.createRoom(request: UserCreatorCreateRoomRequest(creatorId: creatorId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func openRoom(roomId: Int, limit: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.openRoom(roomId: roomId, limit: limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMessages(roomId: Int, cursor: Int?, limit: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return api.requestPublisher(.getMessages(roomId: roomId, cursor: cursor, limit: limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVoiceMessage(roomId: Int, voiceData: Data) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
let requestData = Data("{\"recipientId\": null}".utf8)
|
||||||
|
let fileName = "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).m4a"
|
||||||
|
let multipartData = [
|
||||||
|
MultipartFormData(provider: .data(voiceData), name: "voiceMessageFile", fileName: fileName, mimeType: "audio/m4a"),
|
||||||
|
MultipartFormData(provider: .data(requestData), name: "request")
|
||||||
|
]
|
||||||
|
return api.requestPublisher(.sendVoiceMessage(roomId: roomId, parameters: multipartData))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct UserCreatorChatRoomView: View {
|
||||||
|
@StateObject private var viewModel = UserCreatorChatRoomViewModel()
|
||||||
|
@State private var messageText = ""
|
||||||
|
|
||||||
|
private let roomId: Int?
|
||||||
|
private let creatorId: Int?
|
||||||
|
|
||||||
|
init(roomId: Int) {
|
||||||
|
self.roomId = roomId
|
||||||
|
self.creatorId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init(creatorId: Int) {
|
||||||
|
self.roomId = nil
|
||||||
|
self.creatorId = creatorId
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
BaseView(isLoading: $viewModel.isLoading) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerView
|
||||||
|
|
||||||
|
messageListView
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
inputBarView
|
||||||
|
}
|
||||||
|
.background(Color.black.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
.onAppear(perform: enterRoom)
|
||||||
|
.onDisappear { viewModel.leaveAndClose() }
|
||||||
|
.sodaToast(
|
||||||
|
isPresented: $viewModel.isShowPopup,
|
||||||
|
message: viewModel.errorMessage,
|
||||||
|
autohideIn: 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerView: some View {
|
||||||
|
HStack(spacing: SodaSpacing.s12) {
|
||||||
|
Image("ic_back")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.onTapGesture { AppState.shared.back() }
|
||||||
|
|
||||||
|
DownsampledKFImage(
|
||||||
|
url: URL(string: viewModel.opponentProfileImageUrl ?? ""),
|
||||||
|
size: CGSize(width: 36, height: 36)
|
||||||
|
)
|
||||||
|
.clipShape(Circle())
|
||||||
|
|
||||||
|
Text(viewModel.opponentNickname)
|
||||||
|
.appFont(size: 14, weight: .bold)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s16)
|
||||||
|
.padding(.vertical, SodaSpacing.s8)
|
||||||
|
.frame(width: screenSize().width, height: 60)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var messageListView: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
LazyVStack(spacing: SodaSpacing.s16) {
|
||||||
|
if viewModel.isLoadingNextPage {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.tint(Color.soda400)
|
||||||
|
.padding(.vertical, SodaSpacing.s12)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(textMessages) { message in
|
||||||
|
UserCreatorChatTextMessageItemView(message: message)
|
||||||
|
.id(message.id)
|
||||||
|
.onAppear {
|
||||||
|
if message.id == textMessages.first?.id {
|
||||||
|
viewModel.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s24)
|
||||||
|
.padding(.vertical, SodaSpacing.s12)
|
||||||
|
.background(
|
||||||
|
GeometryReader { contentGeometry in
|
||||||
|
Color.clear.preference(
|
||||||
|
key: UserCreatorChatContentHeightPreferenceKey.self,
|
||||||
|
value: contentGeometry.size.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.frame(minHeight: geometry.size.height, alignment: .bottom)
|
||||||
|
}
|
||||||
|
.onPreferenceChange(UserCreatorChatContentHeightPreferenceKey.self) { contentHeight in
|
||||||
|
guard contentHeight <= geometry.size.height else { return }
|
||||||
|
viewModel.loadMore()
|
||||||
|
}
|
||||||
|
.onChange(of: textMessages.last?.id) { _ in
|
||||||
|
scrollToBottom(proxy)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.socketState) { state in
|
||||||
|
guard state == .joined else { return }
|
||||||
|
scrollToBottom(proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inputBarView: some View {
|
||||||
|
HStack(spacing: SodaSpacing.s8) {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
if messageText.isEmpty {
|
||||||
|
Text(I18n.UserCreatorChat.messagePlaceholder)
|
||||||
|
.appFont(size: 14, weight: .regular)
|
||||||
|
.foregroundColor(Color.gray500)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextField("", text: $messageText)
|
||||||
|
.appFont(size: 14, weight: .regular)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.accentColor(Color.soda400)
|
||||||
|
.disabled(isInputEnabled == false)
|
||||||
|
.onSubmit { sendText() }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s16)
|
||||||
|
.padding(.vertical, SodaSpacing.s12)
|
||||||
|
.background(Color.gray900)
|
||||||
|
.cornerRadius(999)
|
||||||
|
|
||||||
|
Button(action: sendText) {
|
||||||
|
Image("ic_message_send")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.opacity(isSendEnabled ? 1 : 0.4)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isSendEnabled == false)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, SodaSpacing.s12)
|
||||||
|
.padding(.vertical, SodaSpacing.s12)
|
||||||
|
.frame(width: screenSize().width)
|
||||||
|
.background(Color.black)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var textMessages: [UserCreatorChatDisplayMessage] {
|
||||||
|
viewModel.messages.filter { $0.messageType == "TEXT" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isInputEnabled: Bool {
|
||||||
|
viewModel.socketState == .joined
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isSendEnabled: Bool {
|
||||||
|
isInputEnabled && messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enterRoom() {
|
||||||
|
if let roomId {
|
||||||
|
viewModel.enter(roomId: roomId)
|
||||||
|
} else if let creatorId {
|
||||||
|
viewModel.enter(creatorId: creatorId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendText() {
|
||||||
|
let trimmedText = messageText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard isInputEnabled, trimmedText.isEmpty == false else { return }
|
||||||
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
viewModel.sendText(trimmedText)
|
||||||
|
messageText = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollToBottom(_ proxy: ScrollViewProxy) {
|
||||||
|
guard let lastMessage = textMessages.last else { return }
|
||||||
|
withAnimation(.easeOut(duration: 0.3)) {
|
||||||
|
proxy.scrollTo(lastMessage.id, anchor: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct UserCreatorChatContentHeightPreferenceKey: PreferenceKey {
|
||||||
|
static var defaultValue: CGFloat = 0
|
||||||
|
|
||||||
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||||
|
value = nextValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorChatRoomView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
UserCreatorChatRoomView(roomId: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class UserCreatorChatRoomViewModel: ObservableObject {
|
||||||
|
@Published var isLoading = false
|
||||||
|
@Published var isLoadingNextPage = false
|
||||||
|
@Published var isShowPopup = false
|
||||||
|
@Published var errorMessage = ""
|
||||||
|
@Published private(set) var roomId = 0
|
||||||
|
@Published private(set) var opponentNickname = ""
|
||||||
|
@Published private(set) var opponentProfileImageUrl: String?
|
||||||
|
@Published private(set) var messages = [UserCreatorChatDisplayMessage]()
|
||||||
|
@Published private(set) var hasMore = false
|
||||||
|
@Published private(set) var nextCursor: Int?
|
||||||
|
@Published private(set) var socketState: UserCreatorChatSocketState = .disconnected
|
||||||
|
|
||||||
|
private let repository = UserCreatorChatRepository()
|
||||||
|
private let socketClient = WebSocketChatClient()
|
||||||
|
private var subscription = Set<AnyCancellable>()
|
||||||
|
private var ackTimeouts: [String: DispatchWorkItem] = [:]
|
||||||
|
private var reconnectWorkItem: DispatchWorkItem?
|
||||||
|
private var isScreenAlive = true
|
||||||
|
private var shouldReconnect = true
|
||||||
|
private var isAppForeground = UIApplication.shared.applicationState != .background
|
||||||
|
private var hasOpenedRoom = false
|
||||||
|
private var joinRetryCount = 0
|
||||||
|
private let pageLimit = 20
|
||||||
|
private let pendingMatchClockSkewToleranceMilliseconds: Int64 = 5 * 60 * 1000
|
||||||
|
|
||||||
|
init() {
|
||||||
|
bindSocket()
|
||||||
|
observeLifecycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
leaveAndClose()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enter(roomId: Int) {
|
||||||
|
guard roomId > 0 else { return }
|
||||||
|
prepareEnter()
|
||||||
|
openRoom(roomId: roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enter(creatorId: Int) {
|
||||||
|
guard creatorId > 0 else { return }
|
||||||
|
prepareEnter()
|
||||||
|
|
||||||
|
repository.createRoom(creatorId: creatorId)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
switch result {
|
||||||
|
case .finished:
|
||||||
|
DEBUG_LOG("finish")
|
||||||
|
case .failure(let error):
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyRestFailure(message: nil)
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorCreateRoomResponse>.self, from: response.data)
|
||||||
|
if let data = decoded.data, decoded.success, data.roomId > 0 {
|
||||||
|
self.openRoom(roomId: data.roomId)
|
||||||
|
} else {
|
||||||
|
self.applyRestFailure(message: decoded.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyRestFailure(message: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendText(_ text: String) {
|
||||||
|
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard trimmedText.isEmpty == false else { return }
|
||||||
|
guard socketState == .joined, roomId > 0 else { return }
|
||||||
|
guard let requestId = socketClient.sendText(trimmedText) else { return }
|
||||||
|
|
||||||
|
let pendingMessage = UserCreatorChatDisplayMessage(
|
||||||
|
pendingText: trimmedText,
|
||||||
|
requestId: requestId,
|
||||||
|
createdAt: Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
)
|
||||||
|
messages.append(pendingMessage)
|
||||||
|
startAckTimeout(requestId: requestId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMore() {
|
||||||
|
guard roomId > 0,
|
||||||
|
hasMore,
|
||||||
|
isLoading == false,
|
||||||
|
isLoadingNextPage == false,
|
||||||
|
socketState == .joined else { return }
|
||||||
|
isLoadingNextPage = true
|
||||||
|
|
||||||
|
repository.getMessages(roomId: roomId, cursor: nextCursor, limit: pageLimit)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
switch result {
|
||||||
|
case .finished:
|
||||||
|
DEBUG_LOG("finish")
|
||||||
|
case .failure(let error):
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.isLoadingNextPage = false
|
||||||
|
self.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
self.isLoadingNextPage = false
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorChatMessagesResponse>.self, from: response.data)
|
||||||
|
if let data = decoded.data, decoded.success {
|
||||||
|
let previousCursor = self.nextCursor
|
||||||
|
self.hasMore = data.hasMore
|
||||||
|
self.nextCursor = data.nextCursor
|
||||||
|
self.mergeServerMessages(data.messages)
|
||||||
|
|
||||||
|
let shouldContinuePastVoiceOnlyPage = data.hasMore
|
||||||
|
&& data.nextCursor != previousCursor
|
||||||
|
&& data.messages.contains(where: { $0.messageType == "TEXT" }) == false
|
||||||
|
if shouldContinuePastVoiceOnlyPage {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.errorMessage = I18n.Common.commonError
|
||||||
|
self.isShowPopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVoiceMessage(soundData: Data) {
|
||||||
|
guard roomId > 0 else { return }
|
||||||
|
|
||||||
|
repository.sendVoiceMessage(roomId: roomId, voiceData: soundData)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { result in
|
||||||
|
switch result {
|
||||||
|
case .finished:
|
||||||
|
DEBUG_LOG("finish")
|
||||||
|
case .failure(let error):
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
}
|
||||||
|
} receiveValue: { response in
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorVoiceMessageResponse>.self, from: response.data)
|
||||||
|
if let data = decoded.data, decoded.success {
|
||||||
|
DEBUG_LOG("deliveredRealtime=\(data.deliveredRealtime), pushSent=\(data.pushSent)")
|
||||||
|
} else if let message = decoded.message {
|
||||||
|
ERROR_LOG(message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func leaveAndClose() {
|
||||||
|
isScreenAlive = false
|
||||||
|
shouldReconnect = false
|
||||||
|
cancelReconnect()
|
||||||
|
cancelAckTimeouts()
|
||||||
|
subscription.removeAll()
|
||||||
|
socketClient.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareEnter() {
|
||||||
|
cancelReconnect()
|
||||||
|
isScreenAlive = true
|
||||||
|
shouldReconnect = true
|
||||||
|
isAppForeground = UIApplication.shared.applicationState != .background
|
||||||
|
hasOpenedRoom = false
|
||||||
|
joinRetryCount = 0
|
||||||
|
isLoading = true
|
||||||
|
isShowPopup = false
|
||||||
|
errorMessage = ""
|
||||||
|
messages = []
|
||||||
|
hasMore = false
|
||||||
|
nextCursor = nil
|
||||||
|
roomId = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openRoom(roomId: Int) {
|
||||||
|
guard roomId > 0 else { return }
|
||||||
|
|
||||||
|
repository.openRoom(roomId: roomId, limit: pageLimit)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.isScreenAlive, self.shouldReconnect else { return }
|
||||||
|
switch result {
|
||||||
|
case .finished:
|
||||||
|
DEBUG_LOG("finish")
|
||||||
|
case .failure(let error):
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyRestFailure(message: nil)
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.isScreenAlive, self.shouldReconnect else { return }
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorOpenRoomResponse>.self, from: response.data)
|
||||||
|
if let data = decoded.data, decoded.success, data.roomId > 0 {
|
||||||
|
self.applyOpenRoom(data)
|
||||||
|
if self.isAppForeground {
|
||||||
|
self.socketClient.connect(roomId: data.roomId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.applyRestFailure(message: decoded.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyRestFailure(message: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyOpenRoom(_ data: UserCreatorOpenRoomResponse) {
|
||||||
|
roomId = data.roomId
|
||||||
|
hasOpenedRoom = true
|
||||||
|
opponentNickname = data.opponentNickname
|
||||||
|
opponentProfileImageUrl = data.opponentProfileImageUrl
|
||||||
|
hasMore = data.hasMore
|
||||||
|
nextCursor = data.nextCursor
|
||||||
|
messages = data.messages
|
||||||
|
.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
.map { UserCreatorChatDisplayMessage(message: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bindSocket() {
|
||||||
|
socketClient.onStateChange = { [weak self] state in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.socketState = state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socketClient.onEvent = { [weak self] event in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.handleSocketEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSocketEvent(_ event: UserCreatorChatSocketEvent) {
|
||||||
|
switch event {
|
||||||
|
case .joined:
|
||||||
|
cancelReconnect()
|
||||||
|
joinRetryCount = 0
|
||||||
|
syncLatestMessages(updatePagination: true) { [weak self] isSuccess in
|
||||||
|
guard isSuccess else { return }
|
||||||
|
self?.loadMoreIfLatestPageHasNoText()
|
||||||
|
}
|
||||||
|
case .joinFailed(let message):
|
||||||
|
retryJoinOrFail(messageKey: message)
|
||||||
|
case .sendAck(let requestId, let message):
|
||||||
|
handleSendAck(requestId: requestId, message: message)
|
||||||
|
case .message(let message):
|
||||||
|
mergeServerMessages([message])
|
||||||
|
case .pong:
|
||||||
|
DEBUG_LOG("PONG handled")
|
||||||
|
case .error(let messageKey):
|
||||||
|
if socketState != .joined {
|
||||||
|
retryJoinOrFail(messageKey: messageKey)
|
||||||
|
} else if let messageKey {
|
||||||
|
ERROR_LOG(messageKey)
|
||||||
|
}
|
||||||
|
case .closed:
|
||||||
|
reconnectIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSendAck(requestId: String, message: UserCreatorChatMessageItem?) {
|
||||||
|
ackTimeouts[requestId]?.cancel()
|
||||||
|
ackTimeouts[requestId] = nil
|
||||||
|
|
||||||
|
guard let message else {
|
||||||
|
let pendingMessage = messages.first(where: { $0.requestId == requestId && $0.status == .pending })
|
||||||
|
|
||||||
|
syncLatestMessages { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
if let index = self.messages.firstIndex(where: { $0.requestId == requestId && $0.status == .pending }) {
|
||||||
|
if self.hasMatchingOwnServerMessage(for: pendingMessage) {
|
||||||
|
self.messages.remove(at: index)
|
||||||
|
} else {
|
||||||
|
self.messages[index].status = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pendingIndex = messages.firstIndex(where: { $0.requestId == requestId }) {
|
||||||
|
messages.remove(at: pendingIndex)
|
||||||
|
}
|
||||||
|
mergeServerMessages([message])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startAckTimeout(requestId: String) {
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if let index = self.messages.firstIndex(where: { $0.requestId == requestId && $0.status == .pending }) {
|
||||||
|
self.messages[index].status = .failed
|
||||||
|
}
|
||||||
|
self.ackTimeouts[requestId] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ackTimeouts[requestId] = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasMatchingOwnServerMessage(for pendingMessage: UserCreatorChatDisplayMessage?) -> Bool {
|
||||||
|
guard let pendingMessage else { return false }
|
||||||
|
return messages.contains { message in
|
||||||
|
return message.messageId != nil
|
||||||
|
&& message.mine
|
||||||
|
&& message.textMessage == pendingMessage.textMessage
|
||||||
|
&& isWithinPendingMatchWindow(message.createdAt, pendingMessage.createdAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isWithinPendingMatchWindow(_ lhsCreatedAt: Int64, _ rhsCreatedAt: Int64) -> Bool {
|
||||||
|
abs(lhsCreatedAt - rhsCreatedAt) <= pendingMatchClockSkewToleranceMilliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mergeServerMessages(_ serverMessages: [UserCreatorChatMessageItem]) {
|
||||||
|
var merged = messages
|
||||||
|
let visibleServerMessages = serverMessages
|
||||||
|
.filter { $0.messageType != "VOICE" }
|
||||||
|
.sorted { $0.createdAt < $1.createdAt }
|
||||||
|
|
||||||
|
for serverMessage in visibleServerMessages {
|
||||||
|
let serverDisplayMessage = UserCreatorChatDisplayMessage(message: serverMessage)
|
||||||
|
if let index = merged.firstIndex(where: { $0.messageId == serverMessage.messageId }) {
|
||||||
|
merged[index] = serverDisplayMessage
|
||||||
|
} else {
|
||||||
|
removeMatchingLocalMessage(for: serverMessage, from: &merged)
|
||||||
|
merged.append(serverDisplayMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = merged.sorted { lhs, rhs in
|
||||||
|
if lhs.createdAt == rhs.createdAt {
|
||||||
|
return lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
return lhs.createdAt < rhs.createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeMatchingLocalMessage(
|
||||||
|
for serverMessage: UserCreatorChatMessageItem,
|
||||||
|
from messages: inout [UserCreatorChatDisplayMessage]
|
||||||
|
) {
|
||||||
|
guard serverMessage.messageType == "TEXT", serverMessage.mine else { return }
|
||||||
|
let matchingIndexes = messages.indices.filter { index in
|
||||||
|
let message = messages[index]
|
||||||
|
return message.messageId == nil
|
||||||
|
&& message.requestId != nil
|
||||||
|
&& message.mine
|
||||||
|
&& message.messageType == "TEXT"
|
||||||
|
&& message.textMessage == serverMessage.textMessage
|
||||||
|
&& isWithinPendingMatchWindow(serverMessage.createdAt, message.createdAt)
|
||||||
|
}
|
||||||
|
guard let index = matchingIndexes.min(by: { lhs, rhs in
|
||||||
|
let lhsDistance = abs(serverMessage.createdAt - messages[lhs].createdAt)
|
||||||
|
let rhsDistance = abs(serverMessage.createdAt - messages[rhs].createdAt)
|
||||||
|
if lhsDistance == rhsDistance {
|
||||||
|
return messages[lhs].createdAt > messages[rhs].createdAt
|
||||||
|
}
|
||||||
|
return lhsDistance < rhsDistance
|
||||||
|
}) else { return }
|
||||||
|
|
||||||
|
if let requestId = messages[index].requestId {
|
||||||
|
ackTimeouts[requestId]?.cancel()
|
||||||
|
ackTimeouts[requestId] = nil
|
||||||
|
}
|
||||||
|
messages.remove(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncLatestMessages(
|
||||||
|
updatePagination: Bool = false,
|
||||||
|
completion: ((Bool) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
guard roomId > 0 else {
|
||||||
|
completion?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
repository.openRoom(roomId: roomId, limit: pageLimit)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] result in
|
||||||
|
switch result {
|
||||||
|
case .finished:
|
||||||
|
DEBUG_LOG("finish")
|
||||||
|
case .failure(let error):
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self?.applyRestFailure(message: nil)
|
||||||
|
completion?(false)
|
||||||
|
}
|
||||||
|
} receiveValue: { [weak self] response in
|
||||||
|
guard let self else {
|
||||||
|
completion?(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let decoded = try JSONDecoder().decode(ApiResponse<UserCreatorOpenRoomResponse>.self, from: response.data)
|
||||||
|
if let data = decoded.data, decoded.success {
|
||||||
|
if updatePagination {
|
||||||
|
self.hasMore = data.hasMore
|
||||||
|
self.nextCursor = data.nextCursor
|
||||||
|
}
|
||||||
|
self.mergeServerMessages(data.messages)
|
||||||
|
self.isLoading = false
|
||||||
|
completion?(true)
|
||||||
|
} else {
|
||||||
|
self.applyRestFailure(message: decoded.message)
|
||||||
|
completion?(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
self.applyRestFailure(message: nil)
|
||||||
|
completion?(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retryJoinOrFail(messageKey: String?) {
|
||||||
|
if let messageKey {
|
||||||
|
ERROR_LOG(messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard joinRetryCount < 3, roomId > 0 else {
|
||||||
|
cancelReconnect()
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = I18n.UserCreatorChat.joinFailureToast
|
||||||
|
isShowPopup = true
|
||||||
|
shouldReconnect = false
|
||||||
|
socketClient.closeWithoutLeave(notify: false)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
guard let self, self.isScreenAlive, self.shouldReconnect == false else { return }
|
||||||
|
AppState.shared.back()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
joinRetryCount += 1
|
||||||
|
isLoading = true
|
||||||
|
socketClient.closeWithoutLeave(notify: false)
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reconnectIfNeeded() {
|
||||||
|
guard isScreenAlive, shouldReconnect, isAppForeground, hasOpenedRoom, roomId > 0 else { return }
|
||||||
|
isLoading = true
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeLifecycle() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleDidEnterBackground),
|
||||||
|
name: UIApplication.didEnterBackgroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleWillEnterForeground),
|
||||||
|
name: UIApplication.willEnterForegroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleCloseNotification),
|
||||||
|
name: .userCreatorChatSessionClose,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleDidEnterBackground() {
|
||||||
|
isAppForeground = false
|
||||||
|
cancelReconnect()
|
||||||
|
failPendingMessages()
|
||||||
|
cancelAckTimeouts()
|
||||||
|
socketClient.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleWillEnterForeground() {
|
||||||
|
isAppForeground = true
|
||||||
|
guard isScreenAlive, shouldReconnect, hasOpenedRoom, roomId > 0 else { return }
|
||||||
|
cancelReconnect()
|
||||||
|
isLoading = true
|
||||||
|
socketClient.connect(roomId: roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleCloseNotification() {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.leaveAndClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyRestFailure(message: String?) {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = message ?? I18n.Common.commonError
|
||||||
|
isShowPopup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelAckTimeouts() {
|
||||||
|
ackTimeouts.values.forEach { $0.cancel() }
|
||||||
|
ackTimeouts.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failPendingMessages() {
|
||||||
|
for index in messages.indices where messages[index].status == .pending {
|
||||||
|
messages[index].status = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMoreIfLatestPageHasNoText() {
|
||||||
|
guard messages.contains(where: { $0.messageType == "TEXT" }) == false else { return }
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleReconnect() {
|
||||||
|
cancelReconnect()
|
||||||
|
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isScreenAlive,
|
||||||
|
self.shouldReconnect,
|
||||||
|
self.isAppForeground,
|
||||||
|
self.hasOpenedRoom,
|
||||||
|
self.roomId > 0 else { return }
|
||||||
|
self.reconnectWorkItem = nil
|
||||||
|
self.socketClient.connect(roomId: self.roomId)
|
||||||
|
}
|
||||||
|
reconnectWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelReconnect() {
|
||||||
|
reconnectWorkItem?.cancel()
|
||||||
|
reconnectWorkItem = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let userCreatorChatSessionClose = Notification.Name("userCreatorChatSessionClose")
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum UserCreatorChatSocketState: Equatable {
|
||||||
|
case disconnected
|
||||||
|
case connecting
|
||||||
|
case socketOpen
|
||||||
|
case joining
|
||||||
|
case joined
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserCreatorChatSocketEvent {
|
||||||
|
case joined
|
||||||
|
case joinFailed(message: String?)
|
||||||
|
case sendAck(requestId: String, message: UserCreatorChatMessageItem?)
|
||||||
|
case message(UserCreatorChatMessageItem)
|
||||||
|
case pong
|
||||||
|
case error(messageKey: String?)
|
||||||
|
case closed(Error?)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserCreatorChatSocketMessageType: String {
|
||||||
|
case joinRoom = "JOIN_ROOM"
|
||||||
|
case sendText = "SEND_TEXT"
|
||||||
|
case leaveRoom = "LEAVE_ROOM"
|
||||||
|
case ping = "PING"
|
||||||
|
case joined = "JOINED"
|
||||||
|
case sendAck = "SEND_ACK"
|
||||||
|
case message = "MESSAGE"
|
||||||
|
case pong = "PONG"
|
||||||
|
case error = "ERROR"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorChatIncomingEnvelope: Decodable {
|
||||||
|
let type: String
|
||||||
|
let requestId: String?
|
||||||
|
let roomId: Int?
|
||||||
|
let payload: UserCreatorChatIncomingPayload?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCreatorChatIncomingPayload: Decodable {
|
||||||
|
let messageKey: String?
|
||||||
|
let message: UserCreatorChatMessageItem?
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
messageKey = try container.decodeIfPresent(String.self, forKey: .messageKey)
|
||||||
|
|
||||||
|
if let nestedMessage = try container.decodeIfPresent(UserCreatorChatMessageItem.self, forKey: .message) {
|
||||||
|
message = nestedMessage
|
||||||
|
} else {
|
||||||
|
message = try? UserCreatorChatMessageItem(from: decoder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case messageKey
|
||||||
|
case message
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class WebSocketChatClient: NSObject {
|
||||||
|
private var session: URLSession?
|
||||||
|
private var task: URLSessionWebSocketTask?
|
||||||
|
private var heartbeatTimer: Timer?
|
||||||
|
private var joinTimeoutWorkItem: DispatchWorkItem?
|
||||||
|
private var roomId: Int = 0
|
||||||
|
private var isClosing = false
|
||||||
|
private let joinTimeout: TimeInterval = 15
|
||||||
|
|
||||||
|
private(set) var state: UserCreatorChatSocketState = .disconnected {
|
||||||
|
didSet {
|
||||||
|
onStateChange?(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var onStateChange: ((UserCreatorChatSocketState) -> Void)?
|
||||||
|
var onEvent: ((UserCreatorChatSocketEvent) -> Void)?
|
||||||
|
|
||||||
|
func connect(roomId: Int) {
|
||||||
|
performOnMain {
|
||||||
|
connectOnMain(roomId: roomId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendText(_ text: String) -> String? {
|
||||||
|
return performOnMain {
|
||||||
|
sendTextOnMain(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ping() {
|
||||||
|
performOnMain {
|
||||||
|
pingOnMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
performOnMain {
|
||||||
|
closeOnMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeWithoutLeave(notify: Bool = true) {
|
||||||
|
performOnMain {
|
||||||
|
closeWithoutLeaveOnMain(notify: notify)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectOnMain(roomId: Int) {
|
||||||
|
guard roomId > 0 else { return }
|
||||||
|
closeWithoutLeaveOnMain(notify: false)
|
||||||
|
|
||||||
|
self.roomId = roomId
|
||||||
|
isClosing = false
|
||||||
|
state = .connecting
|
||||||
|
|
||||||
|
guard let url = makeWebSocketURL() else {
|
||||||
|
state = .disconnected
|
||||||
|
onEvent?(.closed(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue("Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
|
let delegateQueue = OperationQueue()
|
||||||
|
delegateQueue.maxConcurrentOperationCount = 1
|
||||||
|
let session = URLSession(configuration: .default, delegate: self, delegateQueue: delegateQueue)
|
||||||
|
let task = session.webSocketTask(with: request)
|
||||||
|
self.session = session
|
||||||
|
self.task = task
|
||||||
|
task.resume()
|
||||||
|
receiveLoop(for: task)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendTextOnMain(_ text: String) -> String? {
|
||||||
|
guard state == .joined, roomId > 0 else { return nil }
|
||||||
|
let requestId = UUID().uuidString
|
||||||
|
send(type: .sendText, requestId: requestId, payload: ["textMessage": text])
|
||||||
|
return requestId
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pingOnMain() {
|
||||||
|
guard state == .joined, roomId > 0 else { return }
|
||||||
|
send(type: .ping, requestId: UUID().uuidString, payload: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeOnMain() {
|
||||||
|
isClosing = true
|
||||||
|
stopHeartbeat()
|
||||||
|
cancelJoinTimeout()
|
||||||
|
|
||||||
|
guard let task else {
|
||||||
|
state = .disconnected
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == .joined, roomId > 0 {
|
||||||
|
send(type: .leaveRoom, requestId: UUID().uuidString, payload: [:]) { [weak self] _ in
|
||||||
|
self?.cancelTask(task)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cancelTask(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeWithoutLeaveOnMain(notify: Bool) {
|
||||||
|
isClosing = true
|
||||||
|
stopHeartbeat()
|
||||||
|
cancelJoinTimeout()
|
||||||
|
if let task {
|
||||||
|
cancelTask(task)
|
||||||
|
} else {
|
||||||
|
state = .disconnected
|
||||||
|
if notify {
|
||||||
|
onEvent?(.closed(nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func joinRoomIfPossible() {
|
||||||
|
guard state == .socketOpen, roomId > 0 else { return }
|
||||||
|
state = .joining
|
||||||
|
startJoinTimeout()
|
||||||
|
send(type: .joinRoom, requestId: UUID().uuidString, payload: [:]) { [weak self] error in
|
||||||
|
guard let error else { return }
|
||||||
|
self?.handleJoinFailure(message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(
|
||||||
|
type: UserCreatorChatSocketMessageType,
|
||||||
|
requestId: String,
|
||||||
|
payload: [String: Any],
|
||||||
|
completion: ((Error?) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
guard roomId > 0 else {
|
||||||
|
completion?(WebSocketChatClientSendError.invalidState)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard type == .joinRoom || state == .joined else {
|
||||||
|
completion?(WebSocketChatClientSendError.invalidState)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let dictionary: [String: Any] = [
|
||||||
|
"type": type.rawValue,
|
||||||
|
"requestId": requestId,
|
||||||
|
"roomId": roomId,
|
||||||
|
"payload": payload
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: dictionary),
|
||||||
|
let json = String(data: data, encoding: .utf8) else {
|
||||||
|
completion?(WebSocketChatClientSendError.encodingFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
DEBUG_LOG(json)
|
||||||
|
guard let task else {
|
||||||
|
completion?(WebSocketChatClientSendError.taskUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task.send(.string(json)) { error in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let error {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
}
|
||||||
|
completion?(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func receiveLoop(for task: URLSessionWebSocketTask) {
|
||||||
|
task.receive { [weak self] result in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self, self.isCurrentTask(task) else { return }
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let message):
|
||||||
|
self.handle(message)
|
||||||
|
self.receiveLoop(for: task)
|
||||||
|
case .failure(let error):
|
||||||
|
self.finishCurrentTask(task, error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(_ message: URLSessionWebSocketTask.Message) {
|
||||||
|
let rawText: String?
|
||||||
|
switch message {
|
||||||
|
case .string(let text):
|
||||||
|
rawText = text
|
||||||
|
case .data(let data):
|
||||||
|
rawText = String(data: data, encoding: .utf8)
|
||||||
|
@unknown default:
|
||||||
|
rawText = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let rawText else { return }
|
||||||
|
DEBUG_LOG(rawText)
|
||||||
|
|
||||||
|
guard let data = rawText.data(using: .utf8),
|
||||||
|
let envelope = try? JSONDecoder().decode(UserCreatorChatIncomingEnvelope.self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch envelope.type {
|
||||||
|
case UserCreatorChatSocketMessageType.joined.rawValue:
|
||||||
|
guard state == .joining else { return }
|
||||||
|
cancelJoinTimeout()
|
||||||
|
state = .joined
|
||||||
|
startHeartbeat()
|
||||||
|
onEvent?(.joined)
|
||||||
|
case UserCreatorChatSocketMessageType.sendAck.rawValue:
|
||||||
|
if let requestId = envelope.requestId {
|
||||||
|
onEvent?(.sendAck(requestId: requestId, message: envelope.payload?.message))
|
||||||
|
}
|
||||||
|
case UserCreatorChatSocketMessageType.message.rawValue:
|
||||||
|
if let message = envelope.payload?.message {
|
||||||
|
onEvent?(.message(message))
|
||||||
|
}
|
||||||
|
case UserCreatorChatSocketMessageType.pong.rawValue:
|
||||||
|
DEBUG_LOG("PONG")
|
||||||
|
onEvent?(.pong)
|
||||||
|
case UserCreatorChatSocketMessageType.error.rawValue:
|
||||||
|
if state != .joined {
|
||||||
|
cancelJoinTimeout()
|
||||||
|
}
|
||||||
|
let messageKey = envelope.payload?.messageKey
|
||||||
|
if let messageKey {
|
||||||
|
ERROR_LOG(messageKey)
|
||||||
|
}
|
||||||
|
onEvent?(.error(messageKey: messageKey))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startHeartbeat() {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, self.state == .joined else { return }
|
||||||
|
self.stopHeartbeat()
|
||||||
|
self.heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
|
||||||
|
self?.ping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startJoinTimeout() {
|
||||||
|
cancelJoinTimeout()
|
||||||
|
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
guard let self, self.state == .joining else { return }
|
||||||
|
self.joinTimeoutWorkItem = nil
|
||||||
|
self.handleJoinFailure(message: "JOINED timeout")
|
||||||
|
}
|
||||||
|
joinTimeoutWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + joinTimeout, execute: workItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelJoinTimeout() {
|
||||||
|
joinTimeoutWorkItem?.cancel()
|
||||||
|
joinTimeoutWorkItem = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleJoinFailure(message: String?) {
|
||||||
|
guard state == .joining else { return }
|
||||||
|
if let message {
|
||||||
|
ERROR_LOG(message)
|
||||||
|
}
|
||||||
|
closeWithoutLeaveOnMain(notify: false)
|
||||||
|
onEvent?(.joinFailed(message: message))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopHeartbeat() {
|
||||||
|
let invalidate = { [weak self] in
|
||||||
|
self?.heartbeatTimer?.invalidate()
|
||||||
|
self?.heartbeatTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if Thread.isMainThread {
|
||||||
|
invalidate()
|
||||||
|
} else {
|
||||||
|
DispatchQueue.main.async(execute: invalidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelTask(_ task: URLSessionWebSocketTask) {
|
||||||
|
cancelJoinTimeout()
|
||||||
|
task.cancel(with: .normalClosure, reason: nil)
|
||||||
|
if isCurrentTask(task) {
|
||||||
|
self.task = nil
|
||||||
|
session?.invalidateAndCancel()
|
||||||
|
session = nil
|
||||||
|
state = .disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishCurrentTask(_ task: URLSessionWebSocketTask, error: Error?) {
|
||||||
|
guard isCurrentTask(task) else { return }
|
||||||
|
let shouldNotify = isClosing == false
|
||||||
|
|
||||||
|
stopHeartbeat()
|
||||||
|
cancelJoinTimeout()
|
||||||
|
self.task = nil
|
||||||
|
session?.invalidateAndCancel()
|
||||||
|
session = nil
|
||||||
|
state = .disconnected
|
||||||
|
|
||||||
|
if shouldNotify {
|
||||||
|
if let error {
|
||||||
|
ERROR_LOG(error.localizedDescription)
|
||||||
|
}
|
||||||
|
onEvent?(.closed(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isCurrentTask(_ task: URLSessionWebSocketTask) -> Bool {
|
||||||
|
self.task === task
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeWebSocketURL() -> URL? {
|
||||||
|
guard var components = URLComponents(string: BASE_URL), let scheme = components.scheme else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
components.scheme = scheme == "http" ? "ws" : "wss"
|
||||||
|
components.path = "/ws/v2/user-creator-chat"
|
||||||
|
components.query = nil
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performOnMain<T>(_ work: () -> T) -> T {
|
||||||
|
if Thread.isMainThread {
|
||||||
|
return work()
|
||||||
|
}
|
||||||
|
return DispatchQueue.main.sync(execute: work)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WebSocketChatClientSendError: LocalizedError {
|
||||||
|
case invalidState
|
||||||
|
case encodingFailed
|
||||||
|
case taskUnavailable
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidState:
|
||||||
|
return "WebSocket is not ready to send"
|
||||||
|
case .encodingFailed:
|
||||||
|
return "Failed to encode WebSocket message"
|
||||||
|
case .taskUnavailable:
|
||||||
|
return "WebSocket task is unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WebSocketChatClient: URLSessionWebSocketDelegate {
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
webSocketTask: URLSessionWebSocketTask,
|
||||||
|
didOpenWithProtocol protocol: String?
|
||||||
|
) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self, self.isCurrentTask(webSocketTask) else { return }
|
||||||
|
self.state = .socketOpen
|
||||||
|
self.joinRoomIfPossible()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
webSocketTask: URLSessionWebSocketTask,
|
||||||
|
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||||
|
reason: Data?
|
||||||
|
) {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.finishCurrentTask(webSocketTask, error: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,18 @@ struct MainHomeFollowingNewsSection: View {
|
|||||||
let recentNews: [FollowingNewsResponse]
|
let recentNews: [FollowingNewsResponse]
|
||||||
let onTapCreator: (Int) -> Void
|
let onTapCreator: (Int) -> Void
|
||||||
let onTapContent: (Int) -> Void
|
let onTapContent: (Int) -> Void
|
||||||
|
let onTapCommunityPost: (Int) -> Void
|
||||||
|
|
||||||
init(
|
init(
|
||||||
recentNews: [FollowingNewsResponse],
|
recentNews: [FollowingNewsResponse],
|
||||||
onTapCreator: @escaping (Int) -> Void = { _ in },
|
onTapCreator: @escaping (Int) -> Void = { _ in },
|
||||||
onTapContent: @escaping (Int) -> Void = { _ in }
|
onTapContent: @escaping (Int) -> Void = { _ in },
|
||||||
|
onTapCommunityPost: @escaping (Int) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
self.recentNews = recentNews
|
self.recentNews = recentNews
|
||||||
self.onTapCreator = onTapCreator
|
self.onTapCreator = onTapCreator
|
||||||
self.onTapContent = onTapContent
|
self.onTapContent = onTapContent
|
||||||
|
self.onTapCommunityPost = onTapCommunityPost
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -82,7 +85,13 @@ struct MainHomeFollowingNewsSection: View {
|
|||||||
createdAt: communityPost.relativeCreatedAtText(),
|
createdAt: communityPost.relativeCreatedAtText(),
|
||||||
isLike: false,
|
isLike: false,
|
||||||
likeCount: communityPost.likeCount,
|
likeCount: communityPost.likeCount,
|
||||||
commentCount: communityPost.commentCount
|
commentCount: communityPost.commentCount,
|
||||||
|
onTapPost: {
|
||||||
|
onTapCommunityPost(communityPost.postId)
|
||||||
|
},
|
||||||
|
onTapComment: {
|
||||||
|
onTapCommunityPost(communityPost.postId)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct MainHomeFollowingView: View {
|
|||||||
let onTapChatTab: () -> Void
|
let onTapChatTab: () -> Void
|
||||||
let onTapChatRoom: (Int) -> Void
|
let onTapChatRoom: (Int) -> Void
|
||||||
let onTapSchedule: (CreatorActivityType, Int) -> Void
|
let onTapSchedule: (CreatorActivityType, Int) -> Void
|
||||||
|
let onTapCommunityPost: (Int) -> Void
|
||||||
|
|
||||||
@StateObject private var viewModel = MainHomeFollowingViewModel()
|
@StateObject private var viewModel = MainHomeFollowingViewModel()
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ struct MainHomeFollowingView: View {
|
|||||||
onTapFollowingAll: @escaping () -> Void = {},
|
onTapFollowingAll: @escaping () -> Void = {},
|
||||||
onTapChatTab: @escaping () -> Void = {},
|
onTapChatTab: @escaping () -> Void = {},
|
||||||
onTapChatRoom: @escaping (Int) -> Void = { _ in },
|
onTapChatRoom: @escaping (Int) -> Void = { _ in },
|
||||||
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in }
|
onTapSchedule: @escaping (CreatorActivityType, Int) -> Void = { _, _ in },
|
||||||
|
onTapCommunityPost: @escaping (Int) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
self.onTapLive = onTapLive
|
self.onTapLive = onTapLive
|
||||||
self.onTapCreator = onTapCreator
|
self.onTapCreator = onTapCreator
|
||||||
@@ -30,6 +32,7 @@ struct MainHomeFollowingView: View {
|
|||||||
self.onTapChatTab = onTapChatTab
|
self.onTapChatTab = onTapChatTab
|
||||||
self.onTapChatRoom = onTapChatRoom
|
self.onTapChatRoom = onTapChatRoom
|
||||||
self.onTapSchedule = onTapSchedule
|
self.onTapSchedule = onTapSchedule
|
||||||
|
self.onTapCommunityPost = onTapCommunityPost
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -93,7 +96,8 @@ struct MainHomeFollowingView: View {
|
|||||||
MainHomeFollowingNewsSection(
|
MainHomeFollowingNewsSection(
|
||||||
recentNews: response.recentNews,
|
recentNews: response.recentNews,
|
||||||
onTapCreator: onTapCreator,
|
onTapCreator: onTapCreator,
|
||||||
onTapContent: onTapContent
|
onTapContent: onTapContent,
|
||||||
|
onTapCommunityPost: onTapCommunityPost
|
||||||
)
|
)
|
||||||
.padding(.top, 48)
|
.padding(.top, 48)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ struct MainHomeView: View {
|
|||||||
let onTapCreator: (Int) -> Void
|
let onTapCreator: (Int) -> Void
|
||||||
let onTapContent: (Int) -> Void
|
let onTapContent: (Int) -> Void
|
||||||
let onTapCharacter: (Int) -> Void
|
let onTapCharacter: (Int) -> Void
|
||||||
let onTapCommunity: (Int) -> Void
|
let onTapCommunityPost: (Int) -> Void
|
||||||
let onTapBanner: (RecommendationBannerResponse) -> Void
|
let onTapBanner: (RecommendationBannerResponse) -> Void
|
||||||
let onTapFollowAll: (@escaping () -> Void) -> Void
|
let onTapFollowAll: (@escaping () -> Void) -> Void
|
||||||
let onSelectFollowingTab: () -> Bool
|
let onSelectFollowingTab: () -> Bool
|
||||||
@@ -69,7 +69,7 @@ struct MainHomeView: View {
|
|||||||
onTapCreator: onTapCreator,
|
onTapCreator: onTapCreator,
|
||||||
onTapContent: onTapContent,
|
onTapContent: onTapContent,
|
||||||
onTapCharacter: onTapCharacter,
|
onTapCharacter: onTapCharacter,
|
||||||
onTapCommunity: onTapCommunity,
|
onTapCommunityPost: onTapCommunityPost,
|
||||||
onTapBanner: onTapBanner,
|
onTapBanner: onTapBanner,
|
||||||
onTapFollowAll: onTapFollowAll
|
onTapFollowAll: onTapFollowAll
|
||||||
)
|
)
|
||||||
@@ -84,7 +84,8 @@ struct MainHomeView: View {
|
|||||||
onTapFollowingAll: onTapFollowingAll,
|
onTapFollowingAll: onTapFollowingAll,
|
||||||
onTapChatTab: onTapChatTab,
|
onTapChatTab: onTapChatTab,
|
||||||
onTapChatRoom: onTapChatRoom,
|
onTapChatRoom: onTapChatRoom,
|
||||||
onTapSchedule: onTapFollowingSchedule
|
onTapSchedule: onTapFollowingSchedule,
|
||||||
|
onTapCommunityPost: onTapCommunityPost
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +118,7 @@ struct MainHomeView_Previews: PreviewProvider {
|
|||||||
onTapCreator: { _ in },
|
onTapCreator: { _ in },
|
||||||
onTapContent: { _ in },
|
onTapContent: { _ in },
|
||||||
onTapCharacter: { _ in },
|
onTapCharacter: { _ in },
|
||||||
onTapCommunity: { _ in },
|
onTapCommunityPost: { _ in },
|
||||||
onTapBanner: { _ in },
|
onTapBanner: { _ in },
|
||||||
onTapFollowAll: { action in action() },
|
onTapFollowAll: { action in action() },
|
||||||
onSelectFollowingTab: { true },
|
onSelectFollowingTab: { true },
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ struct MainHomeActiveCreatorSection: View {
|
|||||||
let items: [HomeActiveCreatorItem]
|
let items: [HomeActiveCreatorItem]
|
||||||
let onTapLive: (Int) -> Void
|
let onTapLive: (Int) -> Void
|
||||||
let onTapContent: (Int) -> Void
|
let onTapContent: (Int) -> Void
|
||||||
let onTapCommunity: (Int) -> Void
|
let onTapCommunityPost: (Int) -> Void
|
||||||
|
|
||||||
init(
|
init(
|
||||||
items: [HomeActiveCreatorItem],
|
items: [HomeActiveCreatorItem],
|
||||||
onTapLive: @escaping (Int) -> Void = { _ in },
|
onTapLive: @escaping (Int) -> Void = { _ in },
|
||||||
onTapContent: @escaping (Int) -> Void = { _ in },
|
onTapContent: @escaping (Int) -> Void = { _ in },
|
||||||
onTapCommunity: @escaping (Int) -> Void = { _ in }
|
onTapCommunityPost: @escaping (Int) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
self.items = items
|
self.items = items
|
||||||
self.onTapLive = onTapLive
|
self.onTapLive = onTapLive
|
||||||
self.onTapContent = onTapContent
|
self.onTapContent = onTapContent
|
||||||
self.onTapCommunity = onTapCommunity
|
self.onTapCommunityPost = onTapCommunityPost
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -46,7 +46,7 @@ struct MainHomeActiveCreatorSection: View {
|
|||||||
case .audio:
|
case .audio:
|
||||||
onTapContent(targetId)
|
onTapContent(targetId)
|
||||||
case .community:
|
case .community:
|
||||||
onTapCommunity(targetId)
|
onTapCommunityPost(targetId)
|
||||||
case .unknown:
|
case .unknown:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MainHomePopularCommunitySection: View {
|
struct MainHomePopularCommunitySection: View {
|
||||||
let items: [HomePopularCommunityPostItem]
|
let items: [HomePopularCommunityPostItem]
|
||||||
let onTapCommunity: (Int) -> Void
|
let onTapCommunityPost: (Int) -> Void
|
||||||
let onTapLike: (Int) -> Void
|
let onTapLike: (Int) -> Void
|
||||||
let onTapPurchase: (HomePopularCommunityPostItem) -> Void
|
let onTapPurchase: (HomePopularCommunityPostItem) -> Void
|
||||||
|
|
||||||
init(
|
init(
|
||||||
items: [HomePopularCommunityPostItem],
|
items: [HomePopularCommunityPostItem],
|
||||||
onTapCommunity: @escaping (Int) -> Void = { _ in },
|
onTapCommunityPost: @escaping (Int) -> Void = { _ in },
|
||||||
onTapLike: @escaping (Int) -> Void = { _ in },
|
onTapLike: @escaping (Int) -> Void = { _ in },
|
||||||
onTapPurchase: @escaping (HomePopularCommunityPostItem) -> Void = { _ in }
|
onTapPurchase: @escaping (HomePopularCommunityPostItem) -> Void = { _ in }
|
||||||
) {
|
) {
|
||||||
self.items = items
|
self.items = items
|
||||||
self.onTapCommunity = onTapCommunity
|
self.onTapCommunityPost = onTapCommunityPost
|
||||||
self.onTapLike = onTapLike
|
self.onTapLike = onTapLike
|
||||||
self.onTapPurchase = onTapPurchase
|
self.onTapPurchase = onTapPurchase
|
||||||
}
|
}
|
||||||
@@ -38,11 +38,14 @@ struct MainHomePopularCommunitySection: View {
|
|||||||
isLike: item.isLiked,
|
isLike: item.isLiked,
|
||||||
likeCount: item.likeCount,
|
likeCount: item.likeCount,
|
||||||
commentCount: item.commentCount,
|
commentCount: item.commentCount,
|
||||||
|
onTapPost: {
|
||||||
|
onTapCommunityPost(item.postId)
|
||||||
|
},
|
||||||
onTapLike: {
|
onTapLike: {
|
||||||
onTapLike(item.postId)
|
onTapLike(item.postId)
|
||||||
},
|
},
|
||||||
onTapComment: {
|
onTapComment: {
|
||||||
onTapCommunity(item.creatorId)
|
onTapCommunityPost(item.postId)
|
||||||
},
|
},
|
||||||
onTapPurchase: {
|
onTapPurchase: {
|
||||||
onTapPurchase(item)
|
onTapPurchase(item)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ struct MainHomeRecommendationView: View {
|
|||||||
let onTapCreator: (Int) -> Void
|
let onTapCreator: (Int) -> Void
|
||||||
let onTapContent: (Int) -> Void
|
let onTapContent: (Int) -> Void
|
||||||
let onTapCharacter: (Int) -> Void
|
let onTapCharacter: (Int) -> Void
|
||||||
let onTapCommunity: (Int) -> Void
|
let onTapCommunityPost: (Int) -> Void
|
||||||
let onTapBanner: (RecommendationBannerResponse) -> Void
|
let onTapBanner: (RecommendationBannerResponse) -> Void
|
||||||
let onTapFollowAll: (@escaping () -> Void) -> Void
|
let onTapFollowAll: (@escaping () -> Void) -> Void
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ struct MainHomeRecommendationView: View {
|
|||||||
items: viewModel.recommendations?.recentlyActiveCreators ?? [],
|
items: viewModel.recommendations?.recentlyActiveCreators ?? [],
|
||||||
onTapLive: onTapLive,
|
onTapLive: onTapLive,
|
||||||
onTapContent: onTapContent,
|
onTapContent: onTapContent,
|
||||||
onTapCommunity: onTapCommunity
|
onTapCommunityPost: onTapCommunityPost
|
||||||
)
|
)
|
||||||
|
|
||||||
MainHomeRecentDebutCreatorSection(
|
MainHomeRecentDebutCreatorSection(
|
||||||
@@ -50,18 +50,6 @@ struct MainHomeRecommendationView: View {
|
|||||||
onTapCharacter: onTapCharacter
|
onTapCharacter: onTapCharacter
|
||||||
)
|
)
|
||||||
|
|
||||||
MainHomeGenreCreatorSection(
|
|
||||||
items: viewModel.recommendations?.genreCreators ?? [],
|
|
||||||
isFollowAllCompleted: viewModel.isFollowAllCompleted,
|
|
||||||
isFollowAllLoading: viewModel.isFollowAllLoading,
|
|
||||||
onTapCreator: onTapCreator,
|
|
||||||
onTapFollowAll: { creatorIds, completionKey in
|
|
||||||
onTapFollowAll {
|
|
||||||
viewModel.followAll(creatorIds: creatorIds, completionKey: completionKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
MainHomeCheerCreatorSection(
|
MainHomeCheerCreatorSection(
|
||||||
items: viewModel.recommendations?.cheerCreators ?? [],
|
items: viewModel.recommendations?.cheerCreators ?? [],
|
||||||
isFollowAllCompleted: viewModel.isFollowAllCompleted("cheer"),
|
isFollowAllCompleted: viewModel.isFollowAllCompleted("cheer"),
|
||||||
@@ -79,7 +67,7 @@ struct MainHomeRecommendationView: View {
|
|||||||
|
|
||||||
MainHomePopularCommunitySection(
|
MainHomePopularCommunitySection(
|
||||||
items: viewModel.recommendations?.popularCommunityPosts ?? [],
|
items: viewModel.recommendations?.popularCommunityPosts ?? [],
|
||||||
onTapCommunity: onTapCommunity,
|
onTapCommunityPost: onTapCommunityPost,
|
||||||
onTapLike: { postId in
|
onTapLike: { postId in
|
||||||
viewModel.likeCommunityPost(postId: postId)
|
viewModel.likeCommunityPost(postId: postId)
|
||||||
},
|
},
|
||||||
@@ -132,7 +120,7 @@ struct MainHomeRecommendationView_Previews: PreviewProvider {
|
|||||||
onTapCreator: { _ in },
|
onTapCreator: { _ in },
|
||||||
onTapContent: { _ in },
|
onTapContent: { _ in },
|
||||||
onTapCharacter: { _ in },
|
onTapCharacter: { _ in },
|
||||||
onTapCommunity: { _ in },
|
onTapCommunityPost: { _ in },
|
||||||
onTapBanner: { _ in },
|
onTapBanner: { _ in },
|
||||||
onTapFollowAll: { action in action() }
|
onTapFollowAll: { action in action() }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ struct MainView: View {
|
|||||||
onTapCreator: handleRecommendationCreatorTap,
|
onTapCreator: handleRecommendationCreatorTap,
|
||||||
onTapContent: handleRecommendationContentTap,
|
onTapContent: handleRecommendationContentTap,
|
||||||
onTapCharacter: handleRecommendationCreatorTap,
|
onTapCharacter: handleRecommendationCreatorTap,
|
||||||
onTapCommunity: handleRecommendationCommunityTap,
|
onTapCommunityPost: handleRecommendationCommunityPostTap,
|
||||||
onTapBanner: handleRecommendationBannerTap,
|
onTapBanner: handleRecommendationBannerTap,
|
||||||
onTapFollowAll: handleRecommendationFollowAllTap,
|
onTapFollowAll: handleRecommendationFollowAllTap,
|
||||||
onSelectFollowingTab: handleFollowingTabSelection,
|
onSelectFollowingTab: handleFollowingTabSelection,
|
||||||
@@ -494,10 +494,10 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleRecommendationCommunityTap(creatorId: Int) {
|
private func handleRecommendationCommunityPostTap(postId: Int) {
|
||||||
guard creatorId > 0 else { return }
|
guard postId > 0 else { return }
|
||||||
performRecommendationDetailAction {
|
performRecommendationDetailAction {
|
||||||
appState.setAppStep(step: .creatorCommunityAll(creatorId: creatorId))
|
appState.setAppStep(step: .creatorChannelCommunityPostDetail(postId: postId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +527,9 @@ struct MainView: View {
|
|||||||
case .audio:
|
case .audio:
|
||||||
handleRecommendationContentTap(contentId: targetId)
|
handleRecommendationContentTap(contentId: targetId)
|
||||||
case .community:
|
case .community:
|
||||||
handleRecommendationCommunityTap(creatorId: targetId)
|
performRecommendationDetailAction {
|
||||||
|
appState.setAppStep(step: .creatorCommunityAll(creatorId: targetId))
|
||||||
|
}
|
||||||
case .unknown:
|
case .unknown:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
- 기존 `SodaLive/Sources/Home/HomeApi.swift`에는 `/api/v2/home/recommendations`를 추가하지 않는다.
|
- 기존 `SodaLive/Sources/Home/HomeApi.swift`에는 `/api/v2/home/recommendations`를 추가하지 않는다.
|
||||||
- 외부 라이브러리는 추가하지 않는다.
|
- 외부 라이브러리는 추가하지 않는다.
|
||||||
|
|
||||||
|
### 2026-07-12 장르 크리에이터 섹션 미표시 반영
|
||||||
|
|
||||||
|
- 목적: 메인 홈 추천 탭에서 서버 응답의 `genreCreators` 기반 장르(카테고리)별 크리에이터 UI를 표시하지 않는다.
|
||||||
|
- 수행 내용:
|
||||||
|
- `MainHomeRecommendationView`의 추천 탭 섹션 조립에서 `MainHomeGenreCreatorSection` 호출을 제거한다.
|
||||||
|
- `HomeRecommendationResponse.genreCreators` 모델 필드는 API 응답 디코딩을 위해 유지한다.
|
||||||
|
- 검증:
|
||||||
|
- `MainHomeRecommendationView`에서 `genreCreators`와 `MainHomeGenreCreatorSection` 참조가 없어야 한다.
|
||||||
|
- 추천 탭의 인접 섹션인 AI 캐릭터, 응원 크리에이터, 인기 커뮤니티, 사업자 정보 섹션은 기존 순서로 유지되어야 한다.
|
||||||
|
|
||||||
## 주요 대상 파일
|
## 주요 대상 파일
|
||||||
|
|
||||||
### 확인/보완
|
### 확인/보완
|
||||||
@@ -392,6 +402,7 @@
|
|||||||
- `price <= 0 || existOrdered == true`이면 일반 이미지로 표시한다.
|
- `price <= 0 || existOrdered == true`이면 일반 이미지로 표시한다.
|
||||||
- `구매완료` 버튼은 표시하지 않는다.
|
- `구매완료` 버튼은 표시하지 않는다.
|
||||||
- `creatorProfileImage`, `createdAt`, `likeCount`, `commentCount`를 표시한다.
|
- `creatorProfileImage`, `createdAt`, `likeCount`, `commentCount`를 표시한다.
|
||||||
|
- 반응 정보는 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
|
||||||
- `audioUrl`이 있으면 이미지 중앙 재생 UI를 표시한다.
|
- `audioUrl`이 있으면 이미지 중앙 재생 UI를 표시한다.
|
||||||
- 기존 커뮤니티 구매 API를 재사용해 유료 미구매 포스트 구매를 지원한다.
|
- 기존 커뮤니티 구매 API를 재사용해 유료 미구매 포스트 구매를 지원한다.
|
||||||
- `createdAt`은 UTC로 파싱해 디바이스 Timezone 기준 상대 날짜로 표시한다.
|
- `createdAt`은 UTC로 파싱해 디바이스 Timezone 기준 상대 날짜로 표시한다.
|
||||||
@@ -477,8 +488,76 @@
|
|||||||
- 실행 명령: `rg "### Phase [0-9]+:|- \\[[ x]\\] \\*\\*Task [0-9]+\\.[0-9]+:" docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md`
|
- 실행 명령: `rg "### Phase [0-9]+:|- \\[[ x]\\] \\*\\*Task [0-9]+\\.[0-9]+:" docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md`
|
||||||
- 기대 결과: phase heading과 `Task N.N` 형식 체크박스가 검색된다.
|
- 기대 결과: phase heading과 `Task N.N` 형식 체크박스가 검색된다.
|
||||||
|
|
||||||
|
### Phase 20: 추천 커뮤니티 게시물 상세 진입 보정
|
||||||
|
|
||||||
|
- [x] **Task 20.1: PRD/계획 문서에 커뮤니티 게시물 상세 이동 요구사항 추가**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `docs/20260602_메인_홈_추천_UI_API_연동/prd.md`
|
||||||
|
- 수정: `docs/20260602_메인_홈_추천_UI_API_연동/plan-task.md`
|
||||||
|
- 작업 내용:
|
||||||
|
- 최근 활동 크리에이터의 `activityType == COMMUNITY` 탭 액션을 커뮤니티 게시물 상세 이동으로 명시한다.
|
||||||
|
- 인기 커뮤니티 섹션의 게시물 탭 액션을 커뮤니티 게시물 상세 이동으로 명시한다.
|
||||||
|
- `targetId`는 최근 활동 `COMMUNITY`일 때 커뮤니티 게시물 `postId`로 취급한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "activityType == COMMUNITY|커뮤니티 게시물 상세|targetId.*postId|인기 커뮤니티" docs/20260602_메인_홈_추천_UI_API_연동`
|
||||||
|
- 기대 결과: PRD와 계획 문서에서 이번 상세 이동 요구사항이 검색된다.
|
||||||
|
|
||||||
|
- [x] **Task 20.2: 추천 탭 커뮤니티 게시물 상세 라우팅 연결**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeActiveCreatorSection.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomePopularCommunitySection.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 추천 탭 커뮤니티 callback을 `postId` 기준으로 명확히 연결한다.
|
||||||
|
- 최근 활동 크리에이터의 `COMMUNITY` 타입은 `targetId`를 `postId`로 전달한다.
|
||||||
|
- 인기 커뮤니티 카드 일반 탭과 댓글 아이콘 탭은 `postId`로 커뮤니티 게시물 상세에 진입한다.
|
||||||
|
- 좋아요, 구매, 오디오 재생 액션은 기존 동작을 유지한다.
|
||||||
|
- 팔로잉 탭 스케줄의 기존 `.community` 라우팅은 이번 추천 탭 범위 밖이므로 변경하지 않는다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "onTapCommunityPost|handleRecommendationCommunityPostTap|creatorChannelCommunityPostDetail|onTapPost|onTapComment|handleFollowingScheduleTap" SodaLive/Sources/V2/Main SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`
|
||||||
|
- 기대 결과: 추천 탭 커뮤니티 액션은 `postId` 기반 상세 이동을 사용하고, 팔로잉 스케줄 community 라우팅은 기존 분기를 유지한다.
|
||||||
|
|
||||||
|
- [x] **Task 20.3: 정적 검증과 빌드 검증**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인: `SodaLive/Sources/V2/Main/**`
|
||||||
|
- 확인: `SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift`
|
||||||
|
- 확인: `SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
- 작업 내용:
|
||||||
|
- 변경 파일의 공백 오류와 프로젝트 파일 유효성을 확인한다.
|
||||||
|
- 가능한 빌드를 실행한다.
|
||||||
|
- 실행 결과를 검증 기록에 누적한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `git diff --check`
|
||||||
|
- 기대 결과: 출력 없이 성공한다.
|
||||||
|
- 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
- 기대 결과: `OK`
|
||||||
|
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||||
|
- 기대 결과: `BUILD SUCCEEDED`
|
||||||
|
|
||||||
## 검증 기록
|
## 검증 기록
|
||||||
|
|
||||||
|
### 2026-07-10 Phase 20 추천 커뮤니티 게시물 상세 진입 보정 완료
|
||||||
|
|
||||||
|
- 목적: 메인 홈 추천 탭의 최근 활동 `COMMUNITY`와 인기 커뮤니티 게시물 탭 액션을 커뮤니티 게시물 상세 페이지로 연결
|
||||||
|
- 수행 내용:
|
||||||
|
- PRD에 최근 활동 `activityType == COMMUNITY`의 `targetId`를 커뮤니티 게시물 `postId`로 취급한다는 기준을 추가
|
||||||
|
- PRD에 인기 커뮤니티 게시물 일반 탭/댓글 아이콘 탭이 커뮤니티 게시물 상세 페이지로 이동한다는 기준을 추가
|
||||||
|
- `MainHomeView`, `MainHomeRecommendationView`, `MainHomeActiveCreatorSection`, `MainHomePopularCommunitySection`의 추천 커뮤니티 callback 이름을 `onTapCommunityPost`로 정리
|
||||||
|
- `MainView.handleRecommendationCommunityPostTap(postId:)`를 추가해 추천 탭 커뮤니티 액션을 `AppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)`로 연결
|
||||||
|
- `CommunityPostCard`에 기본 no-op `onTapPost`를 추가하고, 인기 커뮤니티 섹션에서 일반 탭과 댓글 아이콘 탭 모두 `postId` 기반 상세 이동으로 연결
|
||||||
|
- 팔로잉 스케줄의 기존 `.community` 라우팅은 `creatorCommunityAll(creatorId:)`로 유지하고 로그인 guard 흐름도 유지
|
||||||
|
- 검증:
|
||||||
|
- `rg "activityType == COMMUNITY|커뮤니티 게시물 상세|targetId.*postId|인기 커뮤니티" docs/20260602_메인_홈_추천_UI_API_연동` 실행, PRD/계획 문서에 요구사항 반영 확인
|
||||||
|
- `rg -n "onTapCommunityPost|handleRecommendationCommunityPostTap|creatorChannelCommunityPostDetail|onTapPost|onTapComment|handleFollowingScheduleTap|creatorCommunityAll" SodaLive/Sources/V2/Main SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` 실행, 추천 탭 `postId` 상세 이동과 팔로잉 스케줄 기존 분기 확인
|
||||||
|
- `rg -n "onTapCommunity:|handleRecommendationCommunityTap" SodaLive/Sources/V2/Main SodaLive/Sources/V2/Component/Card/CommunityPostCard.swift` 실행, 기존 모호한 추천 커뮤니티 callback 이름 제거 확인
|
||||||
|
- `git diff --check` 실행, 출력 없이 성공 확인
|
||||||
|
- `plutil -lint SodaLive.xcodeproj/project.pbxproj` 실행, `OK` 확인
|
||||||
|
- 샌드박스 내 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 실행, CoreSimulator/로그 접근 제한으로 실패 확인
|
||||||
|
- 승인 후 동일 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` 재실행, `BUILD SUCCEEDED` 확인
|
||||||
|
|
||||||
### 2026-06-30 Phase 17~19 최종 조립/검증/문서 갱신 완료
|
### 2026-06-30 Phase 17~19 최종 조립/검증/문서 갱신 완료
|
||||||
|
|
||||||
- 목적: V2 Main Home 추천 탭 섹션 조립 순서와 빈 섹션 숨김 조건을 최종 확인하고, 프로젝트 포함/정적 점검/빌드 검증 결과를 계획 문서에 반영
|
- 목적: V2 Main Home 추천 탭 섹션 조립 순서와 빈 섹션 숨김 조건을 최종 확인하고, 프로젝트 포함/정적 점검/빌드 검증 결과를 계획 문서에 반영
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ enum class RecommendedActivityType(val code: String) {
|
|||||||
- 민감 콘텐츠 보기 설정이 꺼져 있으면 `AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)`로 안내 문구를 예약하고 `AppState.shared.setAppStep(step: .contentViewSettings)`로 이동한다.
|
- 민감 콘텐츠 보기 설정이 꺼져 있으면 `AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)`로 안내 문구를 예약하고 `AppState.shared.setAppStep(step: .contentViewSettings)`로 이동한다.
|
||||||
- 위 조건을 통과한 경우에만 `liveDetail`, `creatorDetail`, `contentDetail`, `characterDetail` 등 실제 상세 이동을 실행한다.
|
- 위 조건을 통과한 경우에만 `liveDetail`, `creatorDetail`, `contentDetail`, `characterDetail` 등 실제 상세 이동을 실행한다.
|
||||||
- 추천 탭 현재 라이브 아이템은 `roomId`가 항상 존재하는 값으로 취급하고, 아이템 탭 시 상세 화면을 거치지 않고 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 호출해 방 상세 조회, 입장 전 결제 확인, 비밀방 비밀번호 dialog, `LiveRoomViewV2` 입장을 재사용한다.
|
- 추천 탭 현재 라이브 아이템은 `roomId`가 항상 존재하는 값으로 취급하고, 아이템 탭 시 상세 화면을 거치지 않고 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 호출해 방 상세 조회, 입장 전 결제 확인, 비밀방 비밀번호 dialog, `LiveRoomViewV2` 입장을 재사용한다.
|
||||||
|
- 최근 활동 크리에이터에서 `activityType == COMMUNITY`인 아이템은 `targetId`를 커뮤니티 게시물 `postId`로 취급하고, 탭 시 커뮤니티 게시물 상세 페이지로 이동한다.
|
||||||
|
- 인기 커뮤니티 섹션의 커뮤니티 게시물은 `postId` 기준으로 커뮤니티 게시물 상세 페이지로 이동한다. 좋아요, 구매, 오디오 재생 액션은 기존 개별 액션을 유지한다.
|
||||||
|
|
||||||
라이브 항목 주의사항:
|
라이브 항목 주의사항:
|
||||||
- 기존 홈의 라이브 guard는 `roomId`와 `isAdult`를 함께 받아 성인 여부를 판단한다.
|
- 기존 홈의 라이브 guard는 `roomId`와 `isAdult`를 함께 받아 성인 여부를 판단한다.
|
||||||
@@ -203,7 +205,9 @@ enum class RecommendedActivityType(val code: String) {
|
|||||||
- 유료 미구매 커뮤니티 포스트는 기존 커뮤니티 구매 API로 구매 가능해야 한다.
|
- 유료 미구매 커뮤니티 포스트는 기존 커뮤니티 구매 API로 구매 가능해야 한다.
|
||||||
- `구매완료` 버튼은 표시하지 않는다.
|
- `구매완료` 버튼은 표시하지 않는다.
|
||||||
- 작성자, 본문, 생성 시간, 좋아요 수, 댓글 수를 표시한다.
|
- 작성자, 본문, 생성 시간, 좋아요 수, 댓글 수를 표시한다.
|
||||||
|
- 반응 정보는 Figma 기준으로 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
|
||||||
- 생성 시간은 UTC 값을 디바이스 Timezone 기준 상대 날짜로 표시한다.
|
- 생성 시간은 UTC 값을 디바이스 Timezone 기준 상대 날짜로 표시한다.
|
||||||
|
- 게시물 카드의 일반 탭 액션과 댓글 아이콘 탭 액션은 커뮤니티 게시물 상세 페이지로 이동한다.
|
||||||
|
|
||||||
### 7.5 사업자 정보 더보기/접기
|
### 7.5 사업자 정보 더보기/접기
|
||||||
|
|
||||||
@@ -298,6 +302,7 @@ MainHomeView
|
|||||||
- `FeedCommunityView`에 `구매완료` 버튼이 표시되지 않는다.
|
- `FeedCommunityView`에 `구매완료` 버튼이 표시되지 않는다.
|
||||||
- 사업자 정보는 마지막 섹션에서 화면 width를 채우고, 기본 3줄 말줄임표, 더보기, 전체 표시, 접기 전환이 동작한다.
|
- 사업자 정보는 마지막 섹션에서 화면 width를 채우고, 기본 3줄 말줄임표, 더보기, 전체 표시, 접기 전환이 동작한다.
|
||||||
- 상세 진입 탭 액션은 기존 홈과 같은 로그인, 한국 사용자 본인인증, 민감 콘텐츠 보기 설정 guard를 통과한 뒤에만 실행된다.
|
- 상세 진입 탭 액션은 기존 홈과 같은 로그인, 한국 사용자 본인인증, 민감 콘텐츠 보기 설정 guard를 통과한 뒤에만 실행된다.
|
||||||
|
- 최근 활동 크리에이터의 `COMMUNITY` 활동과 인기 커뮤니티 섹션의 게시물은 커뮤니티 게시물 상세 페이지로 이동한다.
|
||||||
- `MainHomeView`는 추천/랭킹/팔로잉 탭 shell 역할만 하고, 추천 탭 섹션 조립은 `MainHomeRecommendationView`에 분리되어 있다.
|
- `MainHomeView`는 추천/랭킹/팔로잉 탭 shell 역할만 하고, 추천 탭 섹션 조립은 `MainHomeRecommendationView`에 분리되어 있다.
|
||||||
- 반복 UI는 기존 widget 또는 공용 컴포넌트를 재사용하고 불필요하게 중복 구현하지 않는다.
|
- 반복 UI는 기존 widget 또는 공용 컴포넌트를 재사용하고 불필요하게 중복 구현하지 않는다.
|
||||||
- 빌드가 성공하고, 가능하면 ViewModel 디코딩/상태 전환 검증을 수행한다.
|
- 빌드가 성공하고, 가능하면 ViewModel 디코딩/상태 전환 검증을 수행한다.
|
||||||
@@ -307,7 +312,7 @@ MainHomeView
|
|||||||
- 각 섹션별 최대 표시 개수와 가로/세로 스크롤 정책이 Figma 기준 그대로인지, 서버 응답 전체를 모두 표시해야 하는지 확인이 필요하다.
|
- 각 섹션별 최대 표시 개수와 가로/세로 스크롤 정책이 Figma 기준 그대로인지, 서버 응답 전체를 모두 표시해야 하는지 확인이 필요하다.
|
||||||
- `HomeLiveItem`에 민감/성인 라이브 여부를 판단할 `isAdult` 또는 동등한 필드가 추가되는지, 아니면 서버에서 추천 라이브를 사전 필터링하는지 확인이 필요하다.
|
- `HomeLiveItem`에 민감/성인 라이브 여부를 판단할 `isAdult` 또는 동등한 필드가 추가되는지, 아니면 서버에서 추천 라이브를 사전 필터링하는지 확인이 필요하다.
|
||||||
- 커뮤니티 `audioUrl`을 추천 탭 카드에서 표시/재생해야 하는지 확인이 필요하다.
|
- 커뮤니티 `audioUrl`을 추천 탭 카드에서 표시/재생해야 하는지 확인이 필요하다.
|
||||||
- 각 카드 터치 시 상세 이동 대상이 모두 정의되어 있는지 확인이 필요하다.
|
- 각 카드 터치 시 상세 이동 대상이 모두 정의되어 있는지 확인이 필요하다. 단, 최근 활동 `COMMUNITY`와 인기 커뮤니티 게시물은 커뮤니티 게시물 상세 페이지로 확정한다.
|
||||||
|
|
||||||
## 12. Verification Plan
|
## 12. Verification Plan
|
||||||
- 문서 검증: API URL, 응답 모델, 제외 섹션, FeedCommunityView, 사업자 정보 요구가 PRD와 계획 문서에 반영되었는지 확인한다.
|
- 문서 검증: API URL, 응답 모델, 제외 섹션, FeedCommunityView, 사업자 정보 요구가 PRD와 계획 문서에 반영되었는지 확인한다.
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
## 구현 제약
|
## 구현 제약
|
||||||
|
|
||||||
- `FollowingCommunityPostNewsResponse`에는 `creatorId`가 없다.
|
- `FollowingCommunityPostNewsResponse`에는 `creatorId`가 없다.
|
||||||
- 현재 기존 커뮤니티 게시글 직접 진입 구조는 `AppState.setPendingCommunityCommentDeepLink(creatorId:postId:)`와 `.creatorCommunityAll(creatorId:)` 조합이므로 `postId`만으로는 기존 화면에 정확히 진입할 수 없다.
|
- 현재 코드에는 `AppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)`와 `CreatorChannelCommunityPostDetailView(postId:onCommunityRefresh:)`가 있어 `postId` 단독 상세 진입이 가능하다.
|
||||||
- `COMMUNITY_POST` tap 액션은 구현 전 서버가 `creatorId`를 추가하거나, `postId` 단독 상세 진입 route/API가 있는지 확인해야 한다. 이 확인 없이 임의로 수정 화면(`creatorCommunityModify`)이나 잘못된 creator 화면으로 이동하지 않는다.
|
- `COMMUNITY_POST` tap 액션은 `communityPost.postId`를 이 route로 전달한다. 임의로 수정 화면(`creatorCommunityModify`)이나 잘못된 creator 화면으로 이동하지 않는다.
|
||||||
|
|
||||||
## 주요 대상 파일
|
## 주요 대상 파일
|
||||||
|
|
||||||
@@ -339,6 +339,7 @@
|
|||||||
- `COMMUNITY_POST`는 `communityPost` payload가 있을 때만 렌더링한다.
|
- `COMMUNITY_POST`는 `communityPost` payload가 있을 때만 렌더링한다.
|
||||||
- 표시 값은 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 사용한다.
|
- 표시 값은 `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 사용한다.
|
||||||
- 기존 `CommunityPostCard`를 재사용할 경우 `audioUrl = nil`, `price = 0`, `existOrdered = true`, `isLike = false`로 매핑한다.
|
- 기존 `CommunityPostCard`를 재사용할 경우 `audioUrl = nil`, `price = 0`, `existOrdered = true`, `isLike = false`로 매핑한다.
|
||||||
|
- 반응 정보는 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
|
||||||
- `createdAt`은 기존 `CommunityPostCard`의 `createdAt` 표시 형식에 맞춰 전달한다.
|
- `createdAt`은 기존 `CommunityPostCard`의 `createdAt` 표시 형식에 맞춰 전달한다.
|
||||||
- 검증 기준:
|
- 검증 기준:
|
||||||
- 실행 명령: `rg "COMMUNITY_POST|communityPost|CommunityPostCard|postId|creatorProfileImage|likeCount|commentCount|audioUrl: nil|price: 0|existOrdered: true" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift`
|
- 실행 명령: `rg "COMMUNITY_POST|communityPost|CommunityPostCard|postId|creatorProfileImage|likeCount|commentCount|audioUrl: nil|price: 0|existOrdered: true" SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift`
|
||||||
@@ -346,18 +347,18 @@
|
|||||||
|
|
||||||
- [x] **Task 9.2: 커뮤니티 게시글 tap 라우팅 제약 처리**
|
- [x] **Task 9.2: 커뮤니티 게시글 tap 라우팅 제약 처리**
|
||||||
- 대상 파일:
|
- 대상 파일:
|
||||||
- 확인: `SodaLive/Sources/App/AppState.swift`
|
|
||||||
- 확인: `SodaLive/Sources/App/AppStep.swift`
|
- 확인: `SodaLive/Sources/App/AppStep.swift`
|
||||||
- 확인: `SodaLive/Sources/ContentView.swift`
|
- 확인: `SodaLive/Sources/ContentView.swift`
|
||||||
- 확인: `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift`
|
- 확인: `SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
|
- 확인: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift`
|
||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- `postId` 단독으로 게시글 상세/댓글 화면에 진입 가능한 기존 route가 있는지 확인한다.
|
- `postId` 단독으로 게시글 상세/댓글 화면에 진입 가능한 기존 route가 있는지 확인한다.
|
||||||
- 현재 확인된 기존 route가 `creatorId`를 요구하는 경우, 잘못된 화면 이동을 구현하지 않는다.
|
- `AppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)`를 확인한다.
|
||||||
- 서버 응답에 `creatorId`가 추가되거나 `postId` 단독 route가 생기기 전까지 `COMMUNITY_POST` tap은 표시 카드 내부의 좋아요/댓글 표시만 제공하고 상세 진입은 연결하지 않는다.
|
- `COMMUNITY_POST` tap은 `postId` 기준 커뮤니티 게시글 상세 진입으로 연결한다.
|
||||||
- 이 제약을 구현 결과 보고와 검증 기록에 남긴다.
|
- 잘못된 creator 화면이나 수정 화면으로 이동하지 않는다.
|
||||||
- 검증 기준:
|
- 검증 기준:
|
||||||
- 실행 명령: `rg "setPendingCommunityCommentDeepLink|consumePendingCommunityCommentPostId|creatorCommunityAll|case chatRoom|case creatorCommunity" SodaLive/Sources/App/AppState.swift SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift`
|
- 실행 명령: `rg "creatorChannelCommunityPostDetail|CreatorChannelCommunityPostDetailView|handleRecommendationCommunityPostTap" SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
- 기대 결과: 기존 커뮤니티 직접 진입 경로가 `creatorId`를 요구하는지 확인된다.
|
- 기대 결과: `postId` 단독 커뮤니티 게시글 상세 route와 기존 홈 추천 탭 커뮤니티 게시글 handler가 확인된다.
|
||||||
|
|
||||||
### Phase 10: 최근 소식 오디오/화보 콘텐츠 카드
|
### Phase 10: 최근 소식 오디오/화보 콘텐츠 카드
|
||||||
|
|
||||||
@@ -430,7 +431,7 @@
|
|||||||
- content는 기존 `handleRecommendationContentTap`을 재사용한다.
|
- content는 기존 `handleRecommendationContentTap`을 재사용한다.
|
||||||
- chat room은 `.chatRoom(id:)`로 이동한다.
|
- chat room은 `.chatRoom(id:)`로 이동한다.
|
||||||
- following all은 `.followingList`로 이동한다.
|
- following all은 `.followingList`로 이동한다.
|
||||||
- community post는 Task 9.2 제약에 따라 postId 단독 route가 없으면 상세 진입을 연결하지 않는다.
|
- community post는 Task 9.2 확인 결과에 따라 `postId` 단독 route인 `.creatorChannelCommunityPostDetail(postId:)`로 이동한다.
|
||||||
- 검증 기준:
|
- 검증 기준:
|
||||||
- 실행 명령: `rg "onTapFollowingAll|onTapChatRoom|onTapFollowingSchedule|handleRecommendationLiveTap|handleRecommendationCreatorTap|handleRecommendationContentTap|followingList|chatRoom\\(id:" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`
|
- 실행 명령: `rg "onTapFollowingAll|onTapChatRoom|onTapFollowingSchedule|handleRecommendationLiveTap|handleRecommendationCreatorTap|handleRecommendationContentTap|followingList|chatRoom\\(id:" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
- 기대 결과: 팔로잉 탭의 상세 진입 callback이 기존 MainView guard/route에 연결된다.
|
- 기대 결과: 팔로잉 탭의 상세 진입 callback이 기존 MainView guard/route에 연결된다.
|
||||||
@@ -486,6 +487,33 @@
|
|||||||
- 실행 명령: 수동 확인
|
- 실행 명령: 수동 확인
|
||||||
- 기대 결과: PRD 성공 기준과 Figma 섹션 순서가 충족된다.
|
- 기대 결과: PRD 성공 기준과 Figma 섹션 순서가 충족된다.
|
||||||
|
|
||||||
|
### Phase 13: 최근 소식 커뮤니티 게시글 상세 진입 보강
|
||||||
|
|
||||||
|
- [x] **Task 13.1: COMMUNITY_POST 상세 진입 문서 갱신**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `docs/20260630_메인_홈_팔로잉_탭/prd.md`
|
||||||
|
- 수정: `docs/20260630_메인_홈_팔로잉_탭/plan-task.md`
|
||||||
|
- 작업 내용:
|
||||||
|
- 현재 코드에 `postId` 단독 커뮤니티 게시글 상세 route가 있음을 기록한다.
|
||||||
|
- 기존 `creatorId` 요구 제약 기록을 최신 route 기준으로 보정한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "creatorChannelCommunityPostDetail|COMMUNITY_POST|postId 단독" docs/20260630_메인_홈_팔로잉_탭/prd.md docs/20260630_메인_홈_팔로잉_탭/plan-task.md`
|
||||||
|
- 기대 결과: 문서가 `COMMUNITY_POST` 상세 진입 범위와 route를 명시한다.
|
||||||
|
|
||||||
|
- [x] **Task 13.2: COMMUNITY_POST tap callback 연결**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
|
||||||
|
- 확인: `SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `MainHomeFollowingNewsSection`에 `onTapCommunityPost` callback을 추가한다.
|
||||||
|
- `CommunityPostCard`의 post/comment tap에서 `communityPost.postId`를 callback으로 전달한다.
|
||||||
|
- `MainHomeFollowingView`와 `MainHomeView`를 통해 기존 `MainView.handleRecommendationCommunityPostTap(postId:)`로 연결한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "onTapCommunityPost|handleRecommendationCommunityPostTap|creatorChannelCommunityPostDetail|CommunityPostCard" SodaLive/Sources/V2/Main/Home/Following SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
|
- 기대 결과: 최근 소식 `COMMUNITY_POST` card tap이 기존 커뮤니티 게시글 상세 route로 연결된다.
|
||||||
|
|
||||||
## 검증 기록
|
## 검증 기록
|
||||||
|
|
||||||
- 2026-06-30: `docs/agent-guides/documentation-policy.md`, `build-test-verification.md`, `code-style.md`를 확인해 계획 문서 경로, Phase/checklist 형식, 검증 명령 기준을 반영했다.
|
- 2026-06-30: `docs/agent-guides/documentation-policy.md`, `build-test-verification.md`, `code-style.md`를 확인해 계획 문서 경로, Phase/checklist 형식, 검증 명령 기준을 반영했다.
|
||||||
@@ -575,3 +603,9 @@
|
|||||||
- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataPhase12" build`를 실행해 Phase 12 빌드 성공을 확인했다. 기존 duplicate build file/run script/AppIntents/dependency scan warning은 남아 있으나 컴파일 오류는 없었다.
|
- 2026-07-01: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataPhase12" build`를 실행해 Phase 12 빌드 성공을 확인했다. 기존 duplicate build file/run script/AppIntents/dependency scan warning은 남아 있으나 컴파일 오류는 없었다.
|
||||||
- 2026-07-01: Phase 12.4 수동 UI 검증 항목 중 실제 비로그인/로그인 탭 이동, `/api/v2/home/following` 실응답 섹션 표시, 타입별 UI 표시 확인은 실행 가능한 시뮬레이터 세션과 로그인 계정/API 데이터가 필요해 이번 정적/빌드 검증 범위에서는 완료하지 않았다. 대신 코드 흐름상 비로그인 guard, API endpoint, 섹션 조립, `전체` 버튼 `Color.soda400`, 타입별 recent news 분기, root/section 고정 width/height 미사용 원칙을 정적 검색과 빌드로 확인했다.
|
- 2026-07-01: Phase 12.4 수동 UI 검증 항목 중 실제 비로그인/로그인 탭 이동, `/api/v2/home/following` 실응답 섹션 표시, 타입별 UI 표시 확인은 실행 가능한 시뮬레이터 세션과 로그인 계정/API 데이터가 필요해 이번 정적/빌드 검증 범위에서는 완료하지 않았다. 대신 코드 흐름상 비로그인 guard, API endpoint, 섹션 조립, `전체` 버튼 `Color.soda400`, 타입별 recent news 분기, root/section 고정 width/height 미사용 원칙을 정적 검색과 빌드로 확인했다.
|
||||||
- 2026-07-01: 독립 explore 점검에서도 `project.pbxproj` 등록, placeholder 제거, Figma localhost asset 미사용, 팔로잉 API/I18n/라우팅 반영을 확인했다. 잔여 리스크로 `MainHomeTab.title`의 기존 탭 타이틀 하드코딩과 계획된 제약사항인 `COMMUNITY_POST` 상세/댓글 진입 미연결을 기록했다.
|
- 2026-07-01: 독립 explore 점검에서도 `project.pbxproj` 등록, placeholder 제거, Figma localhost asset 미사용, 팔로잉 API/I18n/라우팅 반영을 확인했다. 잔여 리스크로 `MainHomeTab.title`의 기존 탭 타이틀 하드코딩과 계획된 제약사항인 `COMMUNITY_POST` 상세/댓글 진입 미연결을 기록했다.
|
||||||
|
- 2026-07-12: `AppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)`, `ContentView`의 `CreatorChannelCommunityPostDetailView(postId:onCommunityRefresh:)`, `MainView.handleRecommendationCommunityPostTap(postId:)`를 확인해 `postId` 단독 커뮤니티 게시글 상세 진입 route가 있음을 확인했다.
|
||||||
|
- 2026-07-12: Phase 13을 추가해 `MainHomeFollowingNewsSection -> MainHomeFollowingView -> MainHomeView -> MainView.handleRecommendationCommunityPostTap(postId:)` 경로로 최근 소식 `COMMUNITY_POST` card/comment tap을 `.creatorChannelCommunityPostDetail(postId:)` 라우팅에 연결했다.
|
||||||
|
- 2026-07-12: `rg "onTapCommunityPost|handleRecommendationCommunityPostTap|creatorChannelCommunityPostDetail|onTapPost:|onTapComment:" SodaLive/Sources/V2/Main/Home/Following SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`로 팔로잉 최근 소식 `COMMUNITY_POST` tap callback과 기존 상세 route 연결을 확인했다.
|
||||||
|
- 2026-07-12: `git diff --check -- SodaLive/Sources/V2/Main/Home/Following/Components/MainHomeFollowingNewsSection.swift SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift SodaLive/Sources/V2/Main/Home/MainHomeView.swift docs/20260630_메인_홈_팔로잉_탭/prd.md docs/20260630_메인_홈_팔로잉_탭/plan-task.md` 실행 결과 출력 없이 종료되어 변경 파일 공백 오류가 없음을 확인했다.
|
||||||
|
- 2026-07-12: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataFollowingCommunityPost" build`는 로컬에 team `3A8QGPMRXM`용 iOS Development signing certificate이 없어 코드 서명 단계에서 실패했다.
|
||||||
|
- 2026-07-12: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -destination "generic/platform=iOS Simulator" -derivedDataPath "/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/SodaLiveDerivedDataFollowingCommunityPostSimulator" CODE_SIGNING_ALLOWED=NO build`는 의존성 컴파일 중 600초 제한으로 `BUILD INTERRUPTED`되어 완료 여부를 확인하지 못했다.
|
||||||
|
|||||||
@@ -417,13 +417,13 @@ Figma 참조:
|
|||||||
- `CONTENT_RANKING` 탭 시 `contentRanking.contentId`로 콘텐츠 상세로 이동한다.
|
- `CONTENT_RANKING` 탭 시 `contentRanking.contentId`로 콘텐츠 상세로 이동한다.
|
||||||
- `COMMUNITY_POST`
|
- `COMMUNITY_POST`
|
||||||
- 기존 `CommunityPostCard` 또는 기존 커뮤니티 Feed View를 재사용한다.
|
- 기존 `CommunityPostCard` 또는 기존 커뮤니티 Feed View를 재사용한다.
|
||||||
|
- 반응 정보는 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
|
||||||
- `communityPost` payload를 사용한다.
|
- `communityPost` payload를 사용한다.
|
||||||
- `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 표시한다.
|
- `postId`, `creatorProfileImage`, `creatorNickname`, `imageUrl`, `content`, `createdAt`, `likeCount`, `commentCount`를 표시한다.
|
||||||
- 기존 `CommunityPostCard`를 재사용할 경우 `audioUrl = nil`, `price = 0`, `existOrdered = true`로 매핑해 표시 전용으로 사용한다.
|
- 기존 `CommunityPostCard`를 재사용할 경우 `audioUrl = nil`, `price = 0`, `existOrdered = true`로 매핑해 표시 전용으로 사용한다.
|
||||||
- 타입과 대응되는 payload가 `nil`이면 해당 item은 숨긴다.
|
- 타입과 대응되는 payload가 `nil`이면 해당 item은 숨긴다.
|
||||||
- 현재 기존 커뮤니티 게시글 직접 진입 구조는 `creatorId`를 요구하지만 `FollowingCommunityPostNewsResponse`에는 `creatorId`가 없다.
|
- 현재 코드에는 `postId` 단독으로 진입 가능한 `AppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)` 라우트가 있으므로 `communityPost.postId` 기준으로 커뮤니티 게시글 상세 화면에 이동한다.
|
||||||
- `postId` 단독 상세 route/API가 없으면 잘못된 creator 화면이나 수정 화면으로 이동하지 않는다.
|
- 잘못된 creator 화면이나 수정 화면으로 이동하지 않는다.
|
||||||
- 서버 응답에 `creatorId`가 추가되거나 `postId` 단독 route/API가 확인되면 `communityPost.postId` 기준 커뮤니티 게시글 진입 흐름을 연결한다.
|
|
||||||
- `AUDIO_CONTENT`
|
- `AUDIO_CONTENT`
|
||||||
- Figma `node-id=1229-27212` 기준 오디오 콘텐츠 소식 카드로 표시한다.
|
- Figma `node-id=1229-27212` 기준 오디오 콘텐츠 소식 카드로 표시한다.
|
||||||
- `audioContent` payload를 사용한다.
|
- `audioContent` payload를 사용한다.
|
||||||
@@ -449,7 +449,7 @@ Figma 참조:
|
|||||||
- 토큰이 비어 있으면 `AppState.shared.setAppStep(step: .login)`으로 이동한다.
|
- 토큰이 비어 있으면 `AppState.shared.setAppStep(step: .login)`으로 이동한다.
|
||||||
- 라이브 진입은 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 재사용한다.
|
- 라이브 진입은 기존 `LiveViewModel.enterLiveRoom(roomId:)` 흐름을 재사용한다.
|
||||||
- 콘텐츠, 크리에이터, 커뮤니티, 채팅 이동은 기존 `AppStep` 라우팅을 재사용한다.
|
- 콘텐츠, 크리에이터, 커뮤니티, 채팅 이동은 기존 `AppStep` 라우팅을 재사용한다.
|
||||||
- 단, `COMMUNITY_POST`는 `postId` 단독 진입 가능 route가 확인되기 전까지 표시만 제공하고 상세 진입을 연결하지 않는다.
|
- `COMMUNITY_POST`는 `AppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)`를 사용해 `postId` 단독으로 커뮤니티 게시글 상세 화면에 진입한다.
|
||||||
- 서버 응답에 없는 성인/민감 콘텐츠 여부를 클라이언트에서 임의 추정하지 않는다.
|
- 서버 응답에 없는 성인/민감 콘텐츠 여부를 클라이언트에서 임의 추정하지 않는다.
|
||||||
|
|
||||||
## 8. UX / UI Expectations
|
## 8. UX / UI Expectations
|
||||||
@@ -546,7 +546,7 @@ Figma 참조:
|
|||||||
- 최근 소식의 `CREATOR_RANKING`은 `creatorRanking` payload로 랭킹 소식 카드가 표시된다.
|
- 최근 소식의 `CREATOR_RANKING`은 `creatorRanking` payload로 랭킹 소식 카드가 표시된다.
|
||||||
- 최근 소식의 `CONTENT_RANKING`은 `contentRanking` payload로 랭킹 소식 카드가 표시된다.
|
- 최근 소식의 `CONTENT_RANKING`은 `contentRanking` payload로 랭킹 소식 카드가 표시된다.
|
||||||
- 최근 소식의 `COMMUNITY_POST`는 `communityPost` payload로 기존 커뮤니티 Feed View 또는 표시 전용 variant가 표시된다.
|
- 최근 소식의 `COMMUNITY_POST`는 `communityPost` payload로 기존 커뮤니티 Feed View 또는 표시 전용 variant가 표시된다.
|
||||||
- `COMMUNITY_POST`는 `postId` 단독 진입 가능 route가 확인되기 전까지 잘못된 화면 이동을 하지 않는다.
|
- `COMMUNITY_POST`는 `communityPost.postId`로 커뮤니티 게시글 상세 화면에 이동한다.
|
||||||
- 최근 소식의 `AUDIO_CONTENT`는 `audioContent` payload로 Figma 오디오 콘텐츠 카드 형태가 표시된다.
|
- 최근 소식의 `AUDIO_CONTENT`는 `audioContent` payload로 Figma 오디오 콘텐츠 카드 형태가 표시된다.
|
||||||
- 최근 소식의 `PHOTO_CONTENT`는 `photoContent` payload로 Figma 화보 콘텐츠 카드 형태가 표시된다.
|
- 최근 소식의 `PHOTO_CONTENT`는 `photoContent` payload로 Figma 화보 콘텐츠 카드 형태가 표시된다.
|
||||||
- 최근 소식 시간 표시는 `visibleFromAtUtc` 기준으로 표시된다.
|
- 최근 소식 시간 표시는 `visibleFromAtUtc` 기준으로 표시된다.
|
||||||
@@ -569,4 +569,4 @@ Figma 참조:
|
|||||||
- `SodaLive/Sources/UI/Theme/Color.swift`에서 `Color.soda400`이 `#00BDF7`임을 확인했다.
|
- `SodaLive/Sources/UI/Theme/Color.swift`에서 `Color.soda400`이 `#00BDF7`임을 확인했다.
|
||||||
- 사용자 답변을 반영해 `ChatRoomListItemResponse`, `CreatorActivityType`, nested 최근 소식 response 구조, `visibleFromAtUtc` 시간 표시 기준, 기존 전체 팔로잉 목록 화면 재사용, 팔로잉 탭 선택 시 로그인 guard 정책을 확정 요구사항으로 이동했다.
|
- 사용자 답변을 반영해 `ChatRoomListItemResponse`, `CreatorActivityType`, nested 최근 소식 response 구조, `visibleFromAtUtc` 시간 표시 기준, 기존 전체 팔로잉 목록 화면 재사용, 팔로잉 탭 선택 시 로그인 guard 정책을 확정 요구사항으로 이동했다.
|
||||||
- empty state 문구를 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`, API 실패 문구를 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`로 확정했다.
|
- empty state 문구를 `팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.`, API 실패 문구를 `팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.`로 확정했다.
|
||||||
- 기존 커뮤니티 게시글 직접 진입 구조가 `creatorId`를 요구하는 반면 `FollowingCommunityPostNewsResponse`에는 `creatorId`가 없으므로, `COMMUNITY_POST` tap은 `postId` 단독 route/API 확인 전까지 잘못된 화면 이동을 구현하지 않는 제약을 반영했다.
|
- 2026-07-12: 현재 코드의 `AppStep.creatorChannelCommunityPostDetail(postId:onCommunityRefresh:)`, `CreatorChannelCommunityPostDetailView(postId:onCommunityRefresh:)`, `/api/v2/creator-channels/community-posts/{postId}` 흐름을 확인해 `COMMUNITY_POST` tap을 `postId` 단독 상세 진입으로 연결하도록 요구사항을 갱신했다.
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ Figma 참조:
|
|||||||
- 응답 모델은 `CreatorChannelCommunityPostResponse`를 재사용한다.
|
- 응답 모델은 `CreatorChannelCommunityPostResponse`를 재사용한다.
|
||||||
- 공지 섹션은 일반 커뮤니티 섹션과 시각 구성이 다를 수 있으므로 별도 section component로 분리한다.
|
- 공지 섹션은 일반 커뮤니티 섹션과 시각 구성이 다를 수 있으므로 별도 section component로 분리한다.
|
||||||
- `imageUrl`, `audioUrl`, `price`, `existOrdered`, `likeCount`, `commentCount` 처리는 기존 커뮤니티 카드 패턴을 따른다.
|
- `imageUrl`, `audioUrl`, `price`, `existOrdered`, `likeCount`, `commentCount` 처리는 기존 커뮤니티 카드 패턴을 따른다.
|
||||||
|
- 반응 정보는 Figma 기준으로 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
|
||||||
- 공지 전체보기 또는 item tap destination은 기존 커뮤니티 상세/목록 흐름이 있으면 연결하고, 없으면 placeholder로 둔다.
|
- 공지 전체보기 또는 item tap destination은 기존 커뮤니티 상세/목록 흐름이 있으면 연결하고, 없으면 placeholder로 둔다.
|
||||||
|
|
||||||
### 7.11 스케줄 섹션
|
### 7.11 스케줄 섹션
|
||||||
@@ -276,9 +277,16 @@ Figma 참조:
|
|||||||
- `communities`를 사용해 커뮤니티 게시물 목록을 표시한다.
|
- `communities`를 사용해 커뮤니티 게시물 목록을 표시한다.
|
||||||
- V2 공용 `CommunityPostCard`가 요구사항과 맞으면 재사용한다.
|
- V2 공용 `CommunityPostCard`가 요구사항과 맞으면 재사용한다.
|
||||||
- `imageUrl`, `audioUrl`, `price`, `existOrdered`, `likeCount`, `commentCount`를 표시 규칙에 반영한다.
|
- `imageUrl`, `audioUrl`, `price`, `existOrdered`, `likeCount`, `commentCount`를 표시 규칙에 반영한다.
|
||||||
|
- 반응 정보는 Figma 기준으로 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
|
||||||
- 유료/잠금 게시물은 기존 커뮤니티 잠금/구매 표시 패턴을 따른다.
|
- 유료/잠금 게시물은 기존 커뮤니티 잠금/구매 표시 패턴을 따른다.
|
||||||
- 홈에서는 Figma 기준 노출 개수만 표시하고 `전체보기` 버튼으로 커뮤니티 tab placeholder 또는 기존 커뮤니티 목록으로 이동한다.
|
- 홈에서는 Figma 기준 노출 개수만 표시하고 `전체보기` 버튼으로 커뮤니티 tab placeholder 또는 기존 커뮤니티 목록으로 이동한다.
|
||||||
|
|
||||||
|
### 7.14.1 커뮤니티 게시글 상세
|
||||||
|
|
||||||
|
- 게시글 본문 영역과 댓글 목록 사이에는 Figma 기준 `gray/800` 1pt 하단 border를 표시한다.
|
||||||
|
- 댓글 목록에서는 댓글 row 사이에 `gray/800` 1pt separator를 표시한다.
|
||||||
|
- 상세 본문의 반응 정보도 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
|
||||||
|
|
||||||
### 7.15 팬Talk 섹션
|
### 7.15 팬Talk 섹션
|
||||||
|
|
||||||
- `fanTalk.totalCount`와 `fanTalk.latestFanTalk`를 사용한다.
|
- `fanTalk.totalCount`와 `fanTalk.latestFanTalk`를 사용한다.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
- Figma 추천 시리즈: `24:6770` (이번 범위 제외)
|
- Figma 추천 시리즈: `24:6770` (이번 범위 제외)
|
||||||
- Figma 무료 오디오: `24:6807`
|
- Figma 무료 오디오: `24:6807`
|
||||||
- Figma 포인트 오디오: `24:6813`
|
- Figma 포인트 오디오: `24:6813`
|
||||||
- Figma 최근 댓글이 많은 오디오: `24:6820`
|
- Figma 크리에이터의 댓글 많은 오디오: `24:6820`
|
||||||
- Figma 키워드의 오디오: `24:6829` (이번 범위 제외)
|
- Figma 키워드의 오디오: `24:6829` (이번 범위 제외)
|
||||||
- Figma 추천 오디오: `24:6842`
|
- Figma 추천 오디오: `24:6842`
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
- PRD `7.6 제외 대상 섹션`은 Phase 11에서 검증한다.
|
- PRD `7.6 제외 대상 섹션`은 Phase 11에서 검증한다.
|
||||||
- PRD `7.7 Tag display`는 Phase 4의 `MainContentAudioTagOverlay`를 기준으로 이후 섹션에서 재사용한다.
|
- PRD `7.7 Tag display`는 Phase 4의 `MainContentAudioTagOverlay`를 기준으로 이후 섹션에서 재사용한다.
|
||||||
- PRD `7.8 Navigation`은 Phase 3의 배너 callback 설계, Phase 6의 original series 탭, Phase 12의 전체 라우팅 검증에서 처리한다.
|
- PRD `7.8 Navigation`은 Phase 3의 배너 callback 설계, Phase 6의 original series 탭, Phase 12의 전체 라우팅 검증에서 처리한다.
|
||||||
- PRD `8. UX / UI Expectations` 중 `최근 댓글이 많은 오디오` 댓글 preview bubble에는 nickname을 표시하지 않는다.
|
- PRD `8. UX / UI Expectations` 중 `크리에이터의 댓글 많은 오디오` 댓글 preview bubble에는 nickname을 표시하지 않는다.
|
||||||
- PRD `10. Success Criteria`는 Phase 12의 빌드 검증과 각 Phase 검증 기준으로 확인한다.
|
- PRD `10. Success Criteria`는 Phase 12의 빌드 검증과 각 Phase 검증 기준으로 확인한다.
|
||||||
- PRD `11. Open Questions`는 현재 `미결정 사항 없음`이므로 계획 문서에 구현 보류 항목을 남기지 않는다.
|
- PRD `11. Open Questions`는 현재 `미결정 사항 없음`이므로 계획 문서에 구현 보류 항목을 남기지 않는다.
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
- `SodaLive/Sources/V2/Component/AudioContentCard.swift`
|
- `SodaLive/Sources/V2/Component/AudioContentCard.swift`
|
||||||
- `새로 올라온 오디오`, `무료 오디오`, `포인트 오디오`, `추천 오디오` 카드 후보. 태그 overlay가 없으므로 썸네일 overlay 슬롯 추가 또는 새 wrapper 컴포넌트 검토가 필요하다.
|
- `새로 올라온 오디오`, `무료 오디오`, `포인트 오디오`, `추천 오디오` 카드 후보. 태그 overlay가 없으므로 썸네일 overlay 슬롯 추가 또는 새 wrapper 컴포넌트 검토가 필요하다.
|
||||||
- `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelAudioContentListItem.swift`
|
- `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelAudioContentListItem.swift`
|
||||||
- `New&Hot`, `최근 댓글이 많은 오디오` list row의 크기, 태그, 가격 표시 로직 참고 후보.
|
- `New&Hot`, `크리에이터의 댓글 많은 오디오` list row의 크기, 태그, 가격 표시 로직 참고 후보.
|
||||||
- `SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesListItem.swift`
|
- `SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesListItem.swift`
|
||||||
- `ic_series_original` + `img_new_only` 기반 `ONLY` 표시 참고 후보.
|
- `ic_series_original` + `img_new_only` 기반 `ONLY` 표시 참고 후보.
|
||||||
- `SodaLive/Resources/Assets.xcassets/v2/ic_series_original.imageset`
|
- `SodaLive/Resources/Assets.xcassets/v2/ic_series_original.imageset`
|
||||||
@@ -333,13 +333,13 @@
|
|||||||
- 실행 명령: `rg "pointAudios|포인트 오디오|isPointAvailable|MainContentAudioHorizontalCardSection" SodaLive/Sources/V2/Main/Content/Recommendation SodaLive/Sources/I18n/I18n.swift`
|
- 실행 명령: `rg "pointAudios|포인트 오디오|isPointAvailable|MainContentAudioHorizontalCardSection" SodaLive/Sources/V2/Main/Content/Recommendation SodaLive/Sources/I18n/I18n.swift`
|
||||||
- 기대 결과: 포인트 오디오 섹션과 포인트 태그 조건이 확인된다.
|
- 기대 결과: 포인트 오디오 섹션과 포인트 태그 조건이 확인된다.
|
||||||
|
|
||||||
### Phase 9: 최근 댓글이 많은 오디오 섹션
|
### Phase 9: 크리에이터의 댓글 많은 오디오 섹션
|
||||||
|
|
||||||
- [x] **Task 9.1: 댓글 카드 carousel 섹션 생성**
|
- [x] **Task 9.1: 댓글 카드 carousel 섹션 생성**
|
||||||
- 대상 파일:
|
- 대상 파일:
|
||||||
- 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift`
|
- 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift`
|
||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- Figma `24:6820`의 `최근 댓글이 많은 오디오`를 `mostCommentedAudios`로 표시한다.
|
- Figma `24:6820`의 `크리에이터의 댓글 많은 오디오`를 `mostCommentedAudios`로 표시한다.
|
||||||
- 오디오 row와 댓글 preview bubble을 하나의 카드로 묶고, 카드 단위로 가로 스크롤한다.
|
- 오디오 row와 댓글 preview bubble을 하나의 카드로 묶고, 카드 단위로 가로 스크롤한다.
|
||||||
- `latestComment`를 댓글 본문으로 표시한다.
|
- `latestComment`를 댓글 본문으로 표시한다.
|
||||||
- `latestCommentWriterProfileImageUrl`을 댓글 작성자 profile image로 표시한다.
|
- `latestCommentWriterProfileImageUrl`을 댓글 작성자 profile image로 표시한다.
|
||||||
@@ -587,7 +587,7 @@
|
|||||||
- `SodaLive/Sources/V2/Main/MainView.swift`
|
- `SodaLive/Sources/V2/Main/MainView.swift`
|
||||||
- Figma node `24:6820`
|
- Figma node `24:6820`
|
||||||
- 결과:
|
- 결과:
|
||||||
- 최근 댓글이 많은 오디오 댓글 bubble에는 nickname을 표시하지 않는 것으로 확정했다.
|
- 크리에이터의 댓글 많은 오디오 댓글 bubble에는 nickname을 표시하지 않는 것으로 확정했다.
|
||||||
- `AudioBannerResponse.link`는 deep link 우선 처리 후 외부 브라우저를 여는 것으로 확정했다.
|
- `AudioBannerResponse.link`는 deep link 우선 처리 후 외부 브라우저를 여는 것으로 확정했다.
|
||||||
- `AudioBannerResponse.seriesId`, `OriginalSeriesResponse.seriesId`는 `seriesDetail` route로 이동하는 것으로 확정했다.
|
- `AudioBannerResponse.seriesId`, `OriginalSeriesResponse.seriesId`는 `seriesDetail` route로 이동하는 것으로 확정했다.
|
||||||
- 이번 작업은 문서 보완만 수행했으므로 빌드/테스트는 실행하지 않았다.
|
- 이번 작업은 문서 보완만 수행했으므로 빌드/테스트는 실행하지 않았다.
|
||||||
@@ -598,7 +598,7 @@
|
|||||||
- 결과:
|
- 결과:
|
||||||
- PRD Core Requirements와 계획 Phase의 대응 관계를 추가했다.
|
- PRD Core Requirements와 계획 Phase의 대응 관계를 추가했다.
|
||||||
- PRD에서 해소된 Open Questions가 계획 문서에 구현 보류 항목으로 남지 않게 정리했다.
|
- PRD에서 해소된 Open Questions가 계획 문서에 구현 보류 항목으로 남지 않게 정리했다.
|
||||||
- 최근 댓글이 많은 오디오 댓글 bubble에 nickname이 없어야 한다는 검증 기준을 분리했다.
|
- 크리에이터의 댓글 많은 오디오 댓글 bubble에 nickname이 없어야 한다는 검증 기준을 분리했다.
|
||||||
- 이번 작업은 문서 보완만 수행했으므로 빌드/테스트는 실행하지 않았다.
|
- 이번 작업은 문서 보완만 수행했으므로 빌드/테스트는 실행하지 않았다.
|
||||||
- 2026-07-06 PRD 동기화 점검 후 계획 보완:
|
- 2026-07-06 PRD 동기화 점검 후 계획 보완:
|
||||||
- 실행/확인:
|
- 실행/확인:
|
||||||
@@ -624,7 +624,7 @@
|
|||||||
- 결과:
|
- 결과:
|
||||||
- `.content` 탭을 신규 `MainContentView`로 교체하고 고정 title bar, 내부 `추천` tab bar, 추천 body 스크롤 구조를 구현했다.
|
- `.content` 탭을 신규 `MainContentView`로 교체하고 고정 title bar, 내부 `추천` tab bar, 추천 body 스크롤 구조를 구현했다.
|
||||||
- `GET /api/v2/audio/recommendations` endpoint, repository, response model, ViewModel 상태/디코딩/I18n 문구를 추가했다.
|
- `GET /api/v2/audio/recommendations` endpoint, repository, response model, ViewModel 상태/디코딩/I18n 문구를 추가했다.
|
||||||
- 배너, 오직 보이스온에서만, New&Hot, 새로 올라온 오디오, 무료 오디오, 포인트 오디오, 최근 댓글이 많은 오디오, 추천 오디오 순서로 섹션을 구현했다.
|
- 배너, 오직 보이스온에서만, New&Hot, 새로 올라온 오디오, 무료 오디오, 포인트 오디오, 크리에이터의 댓글 많은 오디오, 추천 오디오 순서로 섹션을 구현했다.
|
||||||
- `추천 시리즈`, `키워드의 오디오`는 구현하지 않았고, 최근 댓글 섹션에는 nickname UI를 추가하지 않았다.
|
- `추천 시리즈`, `키워드의 오디오`는 구현하지 않았고, 최근 댓글 섹션에는 nickname UI를 추가하지 않았다.
|
||||||
- `plutil`은 `OK`, `xcodebuild`는 `BUILD SUCCEEDED`로 통과했다.
|
- `plutil`은 `OK`, `xcodebuild`는 `BUILD SUCCEEDED`로 통과했다.
|
||||||
- 2026-07-06 오디오 card 공용 컴포넌트 정리:
|
- 2026-07-06 오디오 card 공용 컴포넌트 정리:
|
||||||
@@ -651,3 +651,11 @@
|
|||||||
- 콘텐츠 탭 title bar를 `DefaultTitleBar(title: MainTab.content.title)`로 변경해 좌측에 메인 탭명 `콘텐츠`를 고정 표시하도록 반영했다.
|
- 콘텐츠 탭 title bar를 `DefaultTitleBar(title: MainTab.content.title)`로 변경해 좌측에 메인 탭명 `콘텐츠`를 고정 표시하도록 반영했다.
|
||||||
- 우측 액션은 `ic_bar_cash`, `ic_bar_search`, `ic_bar_storage` 순서로 정리하고, `ic_bar_storage`는 `MainContentView.onTapStorage`와 `MainView.handleContentStorageTap`을 통해 `.myBox(currentTab: .orderlist)`로 이동하도록 연결했다.
|
- 우측 액션은 `ic_bar_cash`, `ic_bar_search`, `ic_bar_storage` 순서로 정리하고, `ic_bar_storage`는 `MainContentView.onTapStorage`와 `MainView.handleContentStorageTap`을 통해 `.myBox(currentTab: .orderlist)`로 이동하도록 연결했다.
|
||||||
- 홈 탭은 기존 `HomeTitleBar`와 `ic_bar_bell` 동작을 유지한다.
|
- 홈 탭은 기존 `HomeTitleBar`와 `ic_bar_bell` 동작을 유지한다.
|
||||||
|
|
||||||
|
- 2026-07-12 섹션 타이틀 변경 기록:
|
||||||
|
- 실행/확인:
|
||||||
|
- `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- `docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md`
|
||||||
|
- `docs/20260705_메인_콘텐츠_내부_추천_탭/plan-task.md`
|
||||||
|
- 결과:
|
||||||
|
- 요청에 따라 `최근 댓글이 많은 오디오` 섹션 타이틀을 `크리에이터의 댓글 많은 오디오`로 변경했다.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Figma 참조:
|
|||||||
- 추천 시리즈: `24:6770` (이번 범위 제외)
|
- 추천 시리즈: `24:6770` (이번 범위 제외)
|
||||||
- 무료 오디오: `24:6807`
|
- 무료 오디오: `24:6807`
|
||||||
- 포인트 오디오: `24:6813`
|
- 포인트 오디오: `24:6813`
|
||||||
- 최근 댓글이 많은 오디오: `24:6820`
|
- 크리에이터의 댓글 많은 오디오: `24:6820`
|
||||||
- 키워드의 오디오: `24:6829` (이번 범위 제외)
|
- 키워드의 오디오: `24:6829` (이번 범위 제외)
|
||||||
- 추천 오디오: `24:6842`
|
- 추천 오디오: `24:6842`
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ Figma 참조:
|
|||||||
- `banners`, `originalSeries`, `latestAudios`, `newAndHotAudios`, `freeAudios`, `pointAudios`, `mostCommentedAudios`, `recommendedAudios`를 응답 모델에 반영한다.
|
- `banners`, `originalSeries`, `latestAudios`, `newAndHotAudios`, `freeAudios`, `pointAudios`, `mostCommentedAudios`, `recommendedAudios`를 응답 모델에 반영한다.
|
||||||
- `오직 보이스온에서만!` 섹션에는 `originalSeries`를 사용하고, `ic_series_original` + `img_new_only` 조합으로 `ONLY` 표시를 노출한다.
|
- `오직 보이스온에서만!` 섹션에는 `originalSeries`를 사용하고, `ic_series_original` + `img_new_only` 조합으로 `ONLY` 표시를 노출한다.
|
||||||
- `새로 올라온 오디오`, `무료 오디오`, `포인트 오디오`, `추천 오디오`는 기존 `AudioContentCard` 재사용 또는 최소 보완을 우선 검토한다.
|
- `새로 올라온 오디오`, `무료 오디오`, `포인트 오디오`, `추천 오디오`는 기존 `AudioContentCard` 재사용 또는 최소 보완을 우선 검토한다.
|
||||||
- `New&Hot`, `최근 댓글이 많은 오디오`는 기존 `CreatorChannelAudioContentListItem`의 row/태그 표시 규칙을 참고한다.
|
- `New&Hot`, `크리에이터의 댓글 많은 오디오`는 기존 `CreatorChannelAudioContentListItem`의 row/태그 표시 규칙을 참고한다.
|
||||||
- 오디오 item 탭 시 기존 content detail 진입 guard 흐름을 재사용한다.
|
- 오디오 item 탭 시 기존 content detail 진입 guard 흐름을 재사용한다.
|
||||||
|
|
||||||
## 4. Non-Goals
|
## 4. Non-Goals
|
||||||
@@ -57,7 +57,7 @@ Figma 참조:
|
|||||||
- 사용자는 `New&Hot` 오디오를 리스트 형태로 빠르게 훑어보고 싶다.
|
- 사용자는 `New&Hot` 오디오를 리스트 형태로 빠르게 훑어보고 싶다.
|
||||||
- 사용자는 보이스온에서만 제공되는 오리지널 시리즈를 `ONLY` 표시로 구분하고 싶다.
|
- 사용자는 보이스온에서만 제공되는 오리지널 시리즈를 `ONLY` 표시로 구분하고 싶다.
|
||||||
- 사용자는 무료 오디오와 포인트 사용 가능 오디오를 별도 섹션으로 탐색하고 싶다.
|
- 사용자는 무료 오디오와 포인트 사용 가능 오디오를 별도 섹션으로 탐색하고 싶다.
|
||||||
- 사용자는 최근 댓글이 많은 오디오와 최신 댓글 작성자 프로필을 함께 보고 싶다.
|
- 사용자는 크리에이터의 댓글 많은 오디오와 최신 댓글 작성자 프로필을 함께 보고 싶다.
|
||||||
- 사용자는 추천 오디오를 2열 grid로 탐색하고 마음에 드는 콘텐츠 상세로 이동하고 싶다.
|
- 사용자는 추천 오디오를 2열 grid로 탐색하고 마음에 드는 콘텐츠 상세로 이동하고 싶다.
|
||||||
|
|
||||||
## 7. Core Requirements
|
## 7. Core Requirements
|
||||||
@@ -104,7 +104,7 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다
|
|||||||
- 새로 올라온 오디오: `latestAudios`
|
- 새로 올라온 오디오: `latestAudios`
|
||||||
- 무료 오디오: `freeAudios`
|
- 무료 오디오: `freeAudios`
|
||||||
- 포인트 오디오: `pointAudios`
|
- 포인트 오디오: `pointAudios`
|
||||||
- 최근 댓글이 많은 오디오: `mostCommentedAudios`
|
- 크리에이터의 댓글 많은 오디오: `mostCommentedAudios`
|
||||||
- 추천 오디오: `recommendedAudios`
|
- 추천 오디오: `recommendedAudios`
|
||||||
|
|
||||||
### 7.6 제외 대상 섹션
|
### 7.6 제외 대상 섹션
|
||||||
@@ -137,8 +137,8 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다
|
|||||||
- 배너는 Figma `24:6741` 기준으로 가로 carousel과 `01 / 20` page indicator를 표시한다.
|
- 배너는 Figma `24:6741` 기준으로 가로 carousel과 `01 / 20` page indicator를 표시한다.
|
||||||
- 일반 오디오 카드 섹션은 가로 스크롤 카드 목록으로 표시한다.
|
- 일반 오디오 카드 섹션은 가로 스크롤 카드 목록으로 표시한다.
|
||||||
- `New&Hot`은 3개 row를 한 묶음으로 구성하고 묶음 단위로 가로 스크롤한다.
|
- `New&Hot`은 3개 row를 한 묶음으로 구성하고 묶음 단위로 가로 스크롤한다.
|
||||||
- `최근 댓글이 많은 오디오`는 오디오 row와 댓글 preview bubble을 하나의 카드로 묶어 가로 스크롤한다.
|
- `크리에이터의 댓글 많은 오디오`는 오디오 row와 댓글 preview bubble을 하나의 카드로 묶어 가로 스크롤한다.
|
||||||
- `최근 댓글이 많은 오디오`의 댓글 preview bubble에는 nickname을 표시하지 않는다. Figma `24:6820` 기준으로 `latestCommentWriterProfileImageUrl`과 `latestComment` 댓글 텍스트 영역만 사용한다.
|
- `크리에이터의 댓글 많은 오디오`의 댓글 preview bubble에는 nickname을 표시하지 않는다. Figma `24:6820` 기준으로 `latestCommentWriterProfileImageUrl`과 `latestComment` 댓글 텍스트 영역만 사용한다.
|
||||||
- `추천 오디오`는 2열 grid로 표시한다.
|
- `추천 오디오`는 2열 grid로 표시한다.
|
||||||
- 텍스트가 길면 한 줄 또는 Figma 기준 line limit로 말줄임 처리한다.
|
- 텍스트가 길면 한 줄 또는 Figma 기준 line limit로 말줄임 처리한다.
|
||||||
- 카드/row의 루트 레이아웃은 가능한 부모 width, grid column, aspect ratio 기반으로 구성한다.
|
- 카드/row의 루트 레이아웃은 가능한 부모 width, grid column, aspect ratio 기반으로 구성한다.
|
||||||
@@ -176,3 +176,4 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다
|
|||||||
- 2026-07-05: 최초 문서 작성 시 `plan-task.md`만 작성되어 신규 기능 문서 정책과 맞지 않았다. 본 PRD를 추가해 정책 누락을 보완한다.
|
- 2026-07-05: 최초 문서 작성 시 `plan-task.md`만 작성되어 신규 기능 문서 정책과 맞지 않았다. 본 PRD를 추가해 정책 누락을 보완한다.
|
||||||
- 2026-07-05: 사용자 확인으로 댓글 preview bubble에는 nickname을 표시하지 않고, 배너 `link`는 deep link 우선 후 외부 브라우저로 열며, `seriesId`는 `seriesDetail` route로 이동하도록 확정했다.
|
- 2026-07-05: 사용자 확인으로 댓글 preview bubble에는 nickname을 표시하지 않고, 배너 `link`는 deep link 우선 후 외부 브라우저로 열며, `seriesId`는 `seriesDetail` route로 이동하도록 확정했다.
|
||||||
- 2026-07-08: Figma `24:6738` 기준으로 콘텐츠 탭 title bar가 홈 탭 로고/bell 구성이 아니라 좌측 고정 title `콘텐츠`와 우측 `ic_bar_cash`/`ic_bar_search`/`ic_bar_storage` 구성을 사용하도록 요구사항을 보완했다.
|
- 2026-07-08: Figma `24:6738` 기준으로 콘텐츠 탭 title bar가 홈 탭 로고/bell 구성이 아니라 좌측 고정 title `콘텐츠`와 우측 `ic_bar_cash`/`ic_bar_search`/`ic_bar_storage` 구성을 사용하도록 요구사항을 보완했다.
|
||||||
|
- 2026-07-12: 요청에 따라 `최근 댓글이 많은 오디오` 섹션 타이틀을 `크리에이터의 댓글 많은 오디오`로 변경했다.
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Components/CreatorChannelCommunityListItem.swift`
|
||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- Figma `290:9061`, `665:19021`, `290:9066`, `665:19024` 기준으로 작성자, 시간, 본문, 이미지/GIF, 오디오 재생 버튼, 가격, 더보기, 좋아요, 댓글 표시를 구성한다.
|
- Figma `290:9061`, `665:19021`, `290:9066`, `665:19024` 기준으로 작성자, 시간, 본문, 이미지/GIF, 오디오 재생 버튼, 가격, 더보기, 좋아요, 댓글 표시를 구성한다.
|
||||||
|
- 반응 정보는 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
|
||||||
- `isCommentAvailable == false`이면 댓글 영역을 숨긴다.
|
- `isCommentAvailable == false`이면 댓글 영역을 숨긴다.
|
||||||
- 유료 미구매 상태이면 회색 `RoundedRectangle`과 가격 UI를 표시하고 오디오 재생 버튼을 숨긴다.
|
- 유료 미구매 상태이면 회색 `RoundedRectangle`과 가격 UI를 표시하고 오디오 재생 버튼을 숨긴다.
|
||||||
- `imageUrl == nil`이면 유/무료 관계없이 이미지 영역을 표시하지 않는다.
|
- `imageUrl == nil`이면 유/무료 관계없이 이미지 영역을 표시하지 않는다.
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ data class CreatorChannelCommunityPostResponse(
|
|||||||
### 5.3 List layout
|
### 5.3 List layout
|
||||||
- 리스트형은 Figma `290:9061`과 `665:19021`을 기준으로 표시한다.
|
- 리스트형은 Figma `290:9061`과 `665:19021`을 기준으로 표시한다.
|
||||||
- 작성자 프로필 이미지, 닉네임, 상대 시간, 본문, 이미지/오디오 영역, 좋아요 수, 댓글 수를 표시한다.
|
- 작성자 프로필 이미지, 닉네임, 상대 시간, 본문, 이미지/오디오 영역, 좋아요 수, 댓글 수를 표시한다.
|
||||||
|
- 반응 정보는 Figma 기준으로 댓글 아이콘/댓글 수를 먼저, 좋아요 아이콘/좋아요 수를 뒤에 표시한다.
|
||||||
- `isPinned == true`이면 게시글에 고정 표시를 제공한다.
|
- `isPinned == true`이면 게시글에 고정 표시를 제공한다.
|
||||||
- `price > 0 && existOrdered == false && isOwnPost == false`이면 유료 미구매 상태로 표시한다.
|
- `price > 0 && existOrdered == false && isOwnPost == false`이면 유료 미구매 상태로 표시한다.
|
||||||
- 유료 미구매 상태에서는 이미지 URL도 `nil`로 내려올 수 있으므로 이미지 영역에 회색 `RoundedRectangle`을 표시한다.
|
- 유료 미구매 상태에서는 이미지 URL도 `nil`로 내려올 수 있으므로 이미지 영역에 회색 `RoundedRectangle`을 표시한다.
|
||||||
|
|||||||
@@ -181,6 +181,7 @@
|
|||||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift`
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/Components/CreatorChannelCommunityPostDetailContentView.swift`
|
||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- Figma 기준으로 작성자 프로필, 닉네임, 상대 시간, 본문, 이미지/잠금/오디오 영역, 좋아요/댓글 reaction을 표시한다.
|
- Figma 기준으로 작성자 프로필, 닉네임, 상대 시간, 본문, 이미지/잠금/오디오 영역, 좋아요/댓글 reaction을 표시한다.
|
||||||
|
- 반응 정보는 댓글 icon/count를 먼저, 좋아요 icon/count를 뒤에 표시한다.
|
||||||
- 유료 미구매 상태는 `price > 0 && existOrdered == false && isOwnPost == false`로 판정한다.
|
- 유료 미구매 상태는 `price > 0 && existOrdered == false && isOwnPost == false`로 판정한다.
|
||||||
- 유료 미구매 상태에서는 `gray800` 계열 이미지 영역, 잠금 아이콘, 캐시 가격 capsule을 표시한다.
|
- 유료 미구매 상태에서는 `gray800` 계열 이미지 영역, 잠금 아이콘, 캐시 가격 capsule을 표시한다.
|
||||||
- 구매 완료 상태는 `구매완료` capsule tag를 표시한다.
|
- 구매 완료 상태는 `구매완료` capsule tag를 표시한다.
|
||||||
@@ -212,6 +213,7 @@
|
|||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- section header는 `댓글 {commentCount}`를 표시한다.
|
- section header는 `댓글 {commentCount}`를 표시한다.
|
||||||
- 댓글 목록은 `LazyVStack`으로 표시하고 마지막 댓글 `onAppear`에서 다음 페이지 조회를 요청한다.
|
- 댓글 목록은 `LazyVStack`으로 표시하고 마지막 댓글 `onAppear`에서 다음 페이지 조회를 요청한다.
|
||||||
|
- 댓글 row 사이에는 Figma 기준 `gray800` 1pt separator를 표시한다.
|
||||||
- 댓글이 비어 있으면 header만 유지하거나 Figma 기준 빈 목록 영역을 유지한다.
|
- 댓글이 비어 있으면 header만 유지하거나 Figma 기준 빈 목록 영역을 유지한다.
|
||||||
- 선택된 댓글의 popup은 기존 `CreatorChannelActionPopup` 또는 `CreatorChannelReplyDetailActionPopup` 기반으로 수정/삭제 action을 표시한다.
|
- 선택된 댓글의 popup은 기존 `CreatorChannelActionPopup` 또는 `CreatorChannelReplyDetailActionPopup` 기반으로 수정/삭제 action을 표시한다.
|
||||||
- 검증 기준:
|
- 검증 기준:
|
||||||
@@ -238,6 +240,7 @@
|
|||||||
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift`
|
- 생성: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailView.swift`
|
||||||
- 작업 내용:
|
- 작업 내용:
|
||||||
- `CreatorChannelReplyDetailTitleBar`를 재사용해 back-only title bar를 표시한다.
|
- `CreatorChannelReplyDetailTitleBar`를 재사용해 back-only title bar를 표시한다.
|
||||||
|
- 댓글 가능 상세에서는 게시글 영역과 댓글 목록 사이에 Figma 기준 `gray800` 1pt separator를 표시한다.
|
||||||
- `ScrollView` 안에 게시글 콘텐츠와 댓글 목록을 배치한다.
|
- `ScrollView` 안에 게시글 콘텐츠와 댓글 목록을 배치한다.
|
||||||
- `isCommentAvailable == true`일 때만 하단 입력 바를 `safeAreaInset(edge: .bottom)`으로 고정한다.
|
- `isCommentAvailable == true`일 때만 하단 입력 바를 `safeAreaInset(edge: .bottom)`으로 고정한다.
|
||||||
- loading, toast, 삭제 confirm modal을 연결한다.
|
- loading, toast, 삭제 confirm modal을 연결한다.
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ data class CreatorChannelCommunityRepliesResponse(
|
|||||||
- 배경은 `Color.black`을 사용한다.
|
- 배경은 `Color.black`을 사용한다.
|
||||||
- 상단 title bar는 Figma처럼 title text 없이 back chevron만 표시한다.
|
- 상단 title bar는 Figma처럼 title text 없이 back chevron만 표시한다.
|
||||||
- 게시글 영역은 작성자 프로필, 닉네임, 상대 시간, 본문, 이미지/오디오/잠금 영역, 좋아요/댓글 count를 표시한다.
|
- 게시글 영역은 작성자 프로필, 닉네임, 상대 시간, 본문, 이미지/오디오/잠금 영역, 좋아요/댓글 count를 표시한다.
|
||||||
|
- 댓글 가능 상세에서는 게시글 영역과 댓글 목록 사이에 Figma 기준 `gray800` 1pt separator를 표시한다.
|
||||||
- `createdAtUtc`는 기존 `DateParser.relativeTimeText(fromUTC:fallback:now:)` 계열 helper로 상대 시간으로 표시한다.
|
- `createdAtUtc`는 기존 `DateParser.relativeTimeText(fromUTC:fallback:now:)` 계열 helper로 상대 시간으로 표시한다.
|
||||||
- `price > 0 && existOrdered == false && isOwnPost == false`이면 유료 미구매 잠금 상태로 표시한다.
|
- `price > 0 && existOrdered == false && isOwnPost == false`이면 유료 미구매 잠금 상태로 표시한다.
|
||||||
- 유료 미구매 잠금 상태는 Figma처럼 `gray800` 계열 이미지 영역, 잠금 아이콘, 캐시 가격 capsule을 표시한다.
|
- 유료 미구매 잠금 상태는 Figma처럼 `gray800` 계열 이미지 영역, 잠금 아이콘, 캐시 가격 capsule을 표시한다.
|
||||||
@@ -157,6 +158,7 @@ data class CreatorChannelCommunityRepliesResponse(
|
|||||||
- `isLiked == true`이면 `ic_feed_community_heart_fill`을 표시한다.
|
- `isLiked == true`이면 `ic_feed_community_heart_fill`을 표시한다.
|
||||||
- `isLiked == false`이면 `ic_feed_community_heart`를 표시한다.
|
- `isLiked == false`이면 `ic_feed_community_heart`를 표시한다.
|
||||||
- 하트 tap 시 기존 `CreatorCommunityRepository.communityPostLike(postId:)`를 호출한다.
|
- 하트 tap 시 기존 `CreatorCommunityRepository.communityPostLike(postId:)`를 호출한다.
|
||||||
|
- 반응 정보는 Figma 기준으로 댓글 icon/count를 먼저, 좋아요 icon/count를 뒤에 표시한다.
|
||||||
- 좋아요 tap 직후 UI는 기존 `CreatorChannelCommunityListItem`처럼 local optimistic state로 반영하고, API 실패 시 toast를 표시한 뒤 tap 직전의 `isLiked`, `likeCount` 값으로 local state를 복구한다.
|
- 좋아요 tap 직후 UI는 기존 `CreatorChannelCommunityListItem`처럼 local optimistic state로 반영하고, API 실패 시 toast를 표시한 뒤 tap 직전의 `isLiked`, `likeCount` 값으로 local state를 복구한다.
|
||||||
- `isCommentAvailable == true`이면 댓글 icon과 `commentCount`를 표시한다.
|
- `isCommentAvailable == true`이면 댓글 icon과 `commentCount`를 표시한다.
|
||||||
- `isCommentAvailable == false`이면 댓글 icon/count, 댓글 목록, 하단 입력 바를 모두 숨긴다.
|
- `isCommentAvailable == false`이면 댓글 icon/count, 댓글 목록, 하단 입력 바를 모두 숨긴다.
|
||||||
@@ -164,6 +166,7 @@ data class CreatorChannelCommunityRepliesResponse(
|
|||||||
### 7.5 Comments
|
### 7.5 Comments
|
||||||
- 상세 API 응답의 `comments.comments`를 초기 댓글 목록으로 표시한다.
|
- 상세 API 응답의 `comments.comments`를 초기 댓글 목록으로 표시한다.
|
||||||
- 댓글 section header는 Figma처럼 `댓글 {commentCount}` 형식으로 표시한다.
|
- 댓글 section header는 Figma처럼 `댓글 {commentCount}` 형식으로 표시한다.
|
||||||
|
- 댓글 row 사이에는 Figma 기준 `gray800` 1pt separator를 표시한다.
|
||||||
- 댓글 row는 작성자 프로필, 닉네임, 상대 시간, 본문, 우측 더보기 버튼을 표시한다.
|
- 댓글 row는 작성자 프로필, 닉네임, 상대 시간, 본문, 우측 더보기 버튼을 표시한다.
|
||||||
- `writerId`로 내 댓글/답글 여부를 판정하고, 게시글의 `creatorId`로 현재 사용자가 해당 커뮤니티 게시글을 쓴 크리에이터인지 판정한다.
|
- `writerId`로 내 댓글/답글 여부를 판정하고, 게시글의 `creatorId`로 현재 사용자가 해당 커뮤니티 게시글을 쓴 크리에이터인지 판정한다.
|
||||||
- 댓글/답글 popup menu는 기존 커뮤니티 댓글과 동일하게 내 댓글/답글이면 `수정하기`, `삭제하기`를 표시하고, 해당 게시글을 쓴 크리에이터이면 내가 쓴 댓글/답글이 아니어도 `삭제하기`를 표시한다.
|
- 댓글/답글 popup menu는 기존 커뮤니티 댓글과 동일하게 내 댓글/답글이면 `수정하기`, `삭제하기`를 표시하고, 해당 게시글을 쓴 크리에이터이면 내가 쓴 댓글/답글이 아니어도 `삭제하기`를 표시한다.
|
||||||
|
|||||||
469
docs/20260711_유저_크리에이터_1대1_채팅/plan-task.md
Normal file
469
docs/20260711_유저_크리에이터_1대1_채팅/plan-task.md
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
# 계획/TASK: 유저-크리에이터 1:1 채팅
|
||||||
|
|
||||||
|
관련 PRD: `docs/20260711_유저_크리에이터_1대1_채팅/prd.md`
|
||||||
|
|
||||||
|
## 작업 원칙
|
||||||
|
- REST는 Moya `TargetType`로만 구현한다.
|
||||||
|
- 실시간 텍스트 채팅은 `URLSessionWebSocketTask` raw WebSocket으로 구현한다.
|
||||||
|
- STOMP/SockJS 클라이언트 및 STOMP frame은 사용하지 않는다.
|
||||||
|
- `roomId`가 nil 또는 0이면 WebSocket outgoing 명령을 보내지 않는다.
|
||||||
|
- UI 연결 완료 기준은 WebSocket open이 아니라 서버 `JOINED` 수신이다.
|
||||||
|
|
||||||
|
## 재사용 vs 신규
|
||||||
|
- 재사용:
|
||||||
|
- `ApiResponse<T>`: `SodaLive/Sources/Common/ApiResponse.swift`
|
||||||
|
- Moya `TargetType` + Repository + Combine 패턴: `SodaLive/Sources/V2/Main/Chat/Repository/MainChatApi.swift`, `MainChatRepository.swift`
|
||||||
|
- AI 채팅방 UI 구조: `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift`
|
||||||
|
- Loading/Toast: `BaseView(isLoading:)`, `.sodaToast`
|
||||||
|
- 로그: `DEBUG_LOG`, `ERROR_LOG`
|
||||||
|
- 앱 라우팅: `AppStep`, `AppState`, `ContentView`
|
||||||
|
- 기존 음성 multipart 패턴: `SodaLive/Sources/Message/MessageApi.swift`, `SodaLive/Sources/Message/Voice/VoiceMessageViewModel.swift`
|
||||||
|
- 신규:
|
||||||
|
- 유저-크리에이터 채팅 REST API/Repository/DTO
|
||||||
|
- `WebSocketChatClient` raw WebSocket 객체
|
||||||
|
- DM 채팅방 View/ViewModel/전용 메시지 컴포넌트
|
||||||
|
- DM deep link action 및 route
|
||||||
|
- 세션 종료 알림 또는 훅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: REST DTO/API/Repository
|
||||||
|
|
||||||
|
- [x] **Task 1.1: REST/Message DTO 정의**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatModels.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `UserCreatorCreateRoomRequest(creatorId: Int)` 정의.
|
||||||
|
- `UserCreatorCreateRoomResponse(roomId: Int)` 정의.
|
||||||
|
- `UserCreatorOpenRoomResponse(roomId, opponentNickname, opponentProfileImageUrl, messages, hasMore, nextCursor)` 정의.
|
||||||
|
- `UserCreatorChatMessagesResponse(messages, hasMore, nextCursor)` 정의.
|
||||||
|
- `UserCreatorChatMessageItem: Codable` 정의.
|
||||||
|
- `UserCreatorVoiceMessageResponse(message, deliveredRealtime, pushSent)` 정의.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "UserCreatorChatMessageItem|UserCreatorOpenRoomResponse|UserCreatorVoiceMessageResponse" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models`
|
||||||
|
- 기대 결과: 서버 계약 필드가 Swift DTO에 빠짐없이 선언되어 있다.
|
||||||
|
|
||||||
|
- [x] **Task 1.2: Moya TargetType 추가**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatApi.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- case 추가:
|
||||||
|
- `createRoom(request:)`
|
||||||
|
- `openRoom(roomId:limit:)`
|
||||||
|
- `getMessages(roomId:cursor:limit:)`
|
||||||
|
- `sendVoiceMessage(roomId:parameters:)`
|
||||||
|
- path:
|
||||||
|
- `/api/v2/user-creator-chat/rooms/create`
|
||||||
|
- `/api/v2/user-creator-chat/rooms/{roomId}/open`
|
||||||
|
- `/api/v2/user-creator-chat/rooms/{roomId}/messages`
|
||||||
|
- `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
|
||||||
|
- `Authorization: Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))` 헤더 추가.
|
||||||
|
- 음성 메시지는 `.uploadMultipart` 사용.
|
||||||
|
- 제거된 event/text REST API는 추가하지 않는다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "/api/v2/user-creator-chat|uploadMultipart|Authorization" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatApi.swift`
|
||||||
|
- 기대 결과: 4개 REST API만 존재하고 제거 API path는 없다.
|
||||||
|
|
||||||
|
- [x] **Task 1.3: Repository 추가**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `createRoom(creatorId:)`, `openRoom(roomId:limit:)`, `getMessages(roomId:cursor:limit:)`, `sendVoiceMessage(roomId:voiceData:)` 추가.
|
||||||
|
- 음성 multipart의 `request` part는 JSON 문자열 `{"recipientId": null}` 형태로 전송한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "createRoom|openRoom|getMessages|sendVoiceMessage|voiceMessageFile|recipientId" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository`
|
||||||
|
- 기대 결과: Repository 메서드와 multipart part 이름이 서버 계약과 일치한다.
|
||||||
|
|
||||||
|
### Phase 2: Raw WebSocket Client
|
||||||
|
|
||||||
|
- [x] **Task 2.1: WebSocket envelope/상태 모델 정의**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/UserCreatorChatWebSocketModels.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `UserCreatorChatSocketState`: `disconnected`, `connecting`, `socketOpen`, `joining`, `joined`.
|
||||||
|
- outgoing/incoming envelope 정의: `type`, `requestId`, `roomId`, `payload`.
|
||||||
|
- `ERROR` payload는 `messageKey`를 파싱한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "socketOpen|joining|joined|messageKey|SEND_TEXT|JOIN_ROOM" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket`
|
||||||
|
- 기대 결과: 상태와 protocol type/payload 모델이 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 2.2: WebSocketChatClient 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `URLSessionWebSocketTask` 사용.
|
||||||
|
- `BASE_URL`의 scheme/host를 `ws`/`wss`로 변환해 `/ws/v2/user-creator-chat` URL 생성.
|
||||||
|
- `URLRequest`에 `Authorization` 헤더 추가.
|
||||||
|
- `connect(roomId:)`는 socket open 후 실제 `roomId > 0`일 때만 `JOIN_ROOM` 전송.
|
||||||
|
- outgoing JSON과 incoming raw text를 `DEBUG_LOG`로 출력.
|
||||||
|
- receive loop에서 `JOINED`, `SEND_ACK`, `MESSAGE`, `PONG`, `ERROR` event를 delegate/closure로 전달.
|
||||||
|
- `sendText`, `ping`, `leaveRoom`은 `joined` 상태와 `roomId > 0`을 모두 만족할 때만 전송.
|
||||||
|
- `close()`는 가능한 경우 `LEAVE_ROOM` 전송 후 cancel/close 처리.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "URLSessionWebSocketTask|URLRequest|Authorization|/ws/v2/user-creator-chat|DEBUG_LOG|JOIN_ROOM|LEAVE_ROOM" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
|
||||||
|
- 기대 결과: raw WebSocket 구현과 guard/logging이 확인된다.
|
||||||
|
- 실행 명령: `rg "Stomp|SockJS|CONNECT|SUBSCRIBE" SodaLive/Sources/V2/Main/Chat/UserCreatorChat`
|
||||||
|
- 기대 결과: 검색 결과가 없어야 한다.
|
||||||
|
|
||||||
|
- [x] **Task 2.3: heartbeat 및 ACK timeout 기반**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `joined` 후 30초 주기 `PING` timer 시작.
|
||||||
|
- `PONG` 수신 로그/상태 갱신.
|
||||||
|
- socket close/error를 상위 ViewModel에 전달해 재연결 판단은 ViewModel이 수행하게 한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "30|PING|PONG|Timer|joined" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
|
||||||
|
- 기대 결과: heartbeat가 `joined` 이후에만 동작한다.
|
||||||
|
|
||||||
|
### Phase 3: ViewModel 상태/메시지 처리
|
||||||
|
|
||||||
|
- [x] **Task 3.1: DM 채팅방 ViewModel 생성**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDisplayMessage.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 상태: `isLoading`, `isShowPopup`, `errorMessage`, `roomId`, `opponentNickname`, `opponentProfileImageUrl`, `messages`, `hasMore`, `nextCursor`, `socketState`.
|
||||||
|
- `enter(roomId:)`: `openRoom` 성공 후 실제 roomId로 WebSocket connect/join.
|
||||||
|
- `enter(creatorId:)`: `createRoom` 성공 후 `openRoom` + connect/join.
|
||||||
|
- `isLoading`은 REST 호출 및 최초 `JOINED` 대기 동안 true 유지.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "enter\\(roomId|enter\\(creatorId|openRoom|socketState|opponentNickname|isLoading" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 기대 결과: roomId/creatorId 진입 흐름과 Loading 상태가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.2: JOIN/ERROR 재시도 정책 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `JOINED` 전 `ERROR` 수신 시 최대 3회 재시도.
|
||||||
|
- 3회 실패 시 Loading 해제, 토스트 메시지 `대화방에 접속하지 못했습니다.`, socket close, `AppState.shared.back()` 호출.
|
||||||
|
- `payload.messageKey`를 로그/분기 기준으로 사용한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "joinRetry|messageKey|대화방에 접속하지 못했습니다|AppState.shared.back" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 기대 결과: JOIN 전 ERROR 3회 재시도와 실패 후 뒤로가기 처리가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.3: 메시지 pending/ACK/중복 제거 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Models/UserCreatorChatDisplayMessage.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `sendText()`는 `joined` 상태에서만 WebSocket `SEND_TEXT` 전송.
|
||||||
|
- requestId로 pending 메시지를 append.
|
||||||
|
- 15초 timeout 후 failed 상태 표시.
|
||||||
|
- `SEND_ACK`의 requestId로 pending 메시지를 서버 메시지로 교체.
|
||||||
|
- `MESSAGE`와 REST 응답은 `messageId` 기준 merge/dedupe.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "sendText|pending|SEND_ACK|requestId|15|messageId" SodaLive/Sources/V2/Main/Chat/UserCreatorChat`
|
||||||
|
- 기대 결과: pending/ACK/timeout/dedupe 흐름이 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.4: 과거 메시지/재동기화 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 스크롤 상단 도달 시 `getMessages(roomId:cursor:limit:)` 호출.
|
||||||
|
- `hasMore`/`nextCursor` 관리.
|
||||||
|
- 재연결 후 필요 시 `openRoom` 또는 `getMessages`로 최신 메시지 merge.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "loadMore|nextCursor|hasMore|merge|openRoom" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 기대 결과: pagination과 재동기화 로직이 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.5: lifecycle close/reconnect 구현**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 수정: `SodaLive/Sources/Settings/SettingsView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/Settings/SignOut/SignOutViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 화면 이탈 시 `leaveAndClose()`.
|
||||||
|
- `UIApplication.didEnterBackgroundNotification` 수신 시 `LEAVE_ROOM` 후 close.
|
||||||
|
- 로그아웃/전체 로그아웃/회원탈퇴 성공 직전에 세션 종료 notification을 post하고, ViewModel이 받아 close.
|
||||||
|
- socket 오류 발생 시 화면이 살아 있는 동안만 재연결한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "didEnterBackgroundNotification|leaveAndClose|userSession|reconnect" SodaLive/Sources/V2/Main/Chat/UserCreatorChat SodaLive/Sources/Settings`
|
||||||
|
- 기대 결과: 백그라운드/로그아웃/재연결 처리가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 3.6: 음성 메시지 통신 계층 연결**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인/수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Repository/UserCreatorChatRepository.swift`
|
||||||
|
- 확인/수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `sendVoiceMessage(soundData:)` REST 호출 경로를 통신 계층에 준비한다.
|
||||||
|
- 이번 범위에서는 음성 메시지 송신 UI와 수신 음성 메시지 표시 UI를 연결하지 않는다.
|
||||||
|
- REST 응답의 `message`, `deliveredRealtime`, `pushSent`는 디코딩 가능해야 하며 debug log로 확인할 수 있게 둔다.
|
||||||
|
- 음성 메시지는 채팅방 메시지 목록 UI에 append/merge하지 않는다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "sendVoiceMessage|deliveredRealtime|pushSent|voiceMessageFile" SodaLive/Sources/V2/Main/Chat/UserCreatorChat`
|
||||||
|
- 기대 결과: 음성 메시지 REST 통신 경로가 확인되고, 음성 UI/재생 컴포넌트 연결은 없어야 한다.
|
||||||
|
|
||||||
|
### Phase 4: UI 컴포넌트/화면
|
||||||
|
|
||||||
|
- [x] **Task 4.1: DM 메시지 컴포넌트 생성**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components/UserCreatorChatTextMessageItemView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- mine 여부에 따라 좌/우 정렬.
|
||||||
|
- `TEXT`는 `textMessage` 표시.
|
||||||
|
- `VOICE` 수신 메시지 표시와 재생 UI는 이번 범위에서 구현하지 않는다.
|
||||||
|
- pending/failed 상태를 표시한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "UserCreatorChatTextMessageItemView|pending|failed|textMessage" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components`
|
||||||
|
- 기대 결과: TEXT 렌더링과 pending/failed 상태 표시가 확인된다.
|
||||||
|
- 실행 명령: `rg "UserCreatorChatVoiceMessageItemView|voiceMessageUrl|AVAudioPlayer" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/Components`
|
||||||
|
- 기대 결과: 검색 결과가 없어야 한다.
|
||||||
|
|
||||||
|
- [x] **Task 4.2: DM 채팅방 화면 생성**
|
||||||
|
- 대상 파일:
|
||||||
|
- 생성: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `BaseView(isLoading:)`로 전체 화면 Loading 표시.
|
||||||
|
- AI `ChatRoomView` 구조를 기준으로 헤더/메시지 목록/입력창 구성.
|
||||||
|
- 헤더에서 `Character / Clone`, 보유 캔 UI, 우측 더보기 버튼 제거.
|
||||||
|
- 입력창은 `socketState == .joined`일 때만 활성화.
|
||||||
|
- `onAppear`에서 `enter(roomId:)` 또는 `enter(creatorId:)` 호출.
|
||||||
|
- `onDisappear`에서 `leaveAndClose()`.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "BaseView|opponentNickname|opponentProfileImageUrl|socketState|onDisappear|sendText" SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomView.swift`
|
||||||
|
- 기대 결과: Loading/헤더/입력/종료 처리가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 4.3: I18n 문구 추가**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- DM 입력 placeholder.
|
||||||
|
- 연결 실패 토스트: `대화방에 접속하지 못했습니다.`
|
||||||
|
- 메시지 전송 실패 문구.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "UserCreatorChat|대화방에 접속하지 못했습니다" SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- 기대 결과: 신규 문구가 다국어 `pick(ko:en:ja:)` 패턴으로 추가되어 있다.
|
||||||
|
|
||||||
|
### Phase 5: Routing/Deep Link/진입점
|
||||||
|
|
||||||
|
- [x] **Task 5.1: AppStep/ContentView 라우팅 추가**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/App/AppStep.swift`
|
||||||
|
- 수정: `SodaLive/Sources/ContentView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `case userCreatorChatRoom(roomId: Int)` 추가.
|
||||||
|
- `case userCreatorChatCreator(creatorId: Int)` 추가.
|
||||||
|
- 각 case를 `UserCreatorChatRoomView(roomId:)`, `UserCreatorChatRoomView(creatorId:)`에 연결.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "userCreatorChatRoom|userCreatorChatCreator|UserCreatorChatRoomView" SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift`
|
||||||
|
- 기대 결과: roomId/creatorId 진입 route가 모두 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 5.2: 홈 채팅 탭 DM 아이템 라우팅 연결**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/MainChatView.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `chatType == "DM"` 터치 시 `AppState.shared.setAppStep(step: .userCreatorChatRoom(roomId: roomId))`.
|
||||||
|
- `chatType == "AI"` 기존 `AppStep.chatRoom(id:)` 라우팅 유지.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "chatType == \"DM\"|userCreatorChatRoom|chatRoom\\(id:" SodaLive/Sources/V2/Main/Chat/MainChatView.swift`
|
||||||
|
- 기대 결과: AI/DM 라우팅 분기가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 5.3: 크리에이터 채널 DM 시작 진입점 연결**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Home/Repository/CreatorChannelHomeRepository.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
|
||||||
|
- 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가한다.
|
||||||
|
- `DM 보내기` 버튼 터치 시 로그인 여부만 확인한 뒤 `creatorId` 기반 route 또는 `rooms/create` 흐름으로 신규 DM 채팅방에 진입한다.
|
||||||
|
- AI 캐릭터 채팅 진입에 적용되는 본인인증/성인 콘텐츠 설정 guard는 DM 정책과 맞지 않으므로 적용하지 않는다.
|
||||||
|
- 기존 AI 채팅방 생성 API(`TalkApi.createChatRoom`)와 신규 DM 생성 API(`UserCreatorChatApi.createRoom`)가 섞이지 않게 분리한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "handleTalkTap|DM|userCreatorChatCreator|createRoom\\(creatorId" SodaLive/Sources/V2/CreatorChannel`
|
||||||
|
- 기대 결과: `handleTalkTap`은 AI 캐릭터 채팅 경로를 유지하고, 별도 `DM 보내기` 경로에서 `creatorId > 0` guard 후 DM 생성/조회 흐름으로 이동한다.
|
||||||
|
|
||||||
|
- [x] **Task 5.4: 푸시 deep link 파싱 추가**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/App/AppDeepLinkHandler.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `AppDeepLinkAction.userCreatorChat(roomId: Int)` 추가.
|
||||||
|
- `voiceon://chat/{roomId}`, `voiceon-test://chat/{roomId}` 파싱.
|
||||||
|
- scheme은 기존 `voiceon`/`voiceon-test` 허용을 재사용하고, host `chat`, path 첫 segment를 `roomId`로 읽는다.
|
||||||
|
- apply 시 `AppStep.userCreatorChatRoom(roomId:)`로 이동한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "userCreatorChat|case \"chat\"|voiceon-test" SodaLive/Sources/App/AppDeepLinkHandler.swift`
|
||||||
|
- 기대 결과: chat deep link가 live/content/message와 별도 action으로 파싱된다.
|
||||||
|
|
||||||
|
### Phase 6: 프로젝트 등록/검증
|
||||||
|
|
||||||
|
- [x] **Task 6.1: Xcode 프로젝트 신규 파일 등록**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
- 작업 내용:
|
||||||
|
- Phase 1~5에서 추가한 `.swift` 파일을 앱 타깃 소스에 등록한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "UserCreatorChatRoomView|WebSocketChatClient|UserCreatorChatApi" SodaLive.xcodeproj/project.pbxproj`
|
||||||
|
- 기대 결과: 신규 Swift 파일들이 프로젝트에 등록되어 있다.
|
||||||
|
|
||||||
|
- [x] **Task 6.2: 정적 검색 검증**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인: `SodaLive/Sources/**`
|
||||||
|
- 작업 내용:
|
||||||
|
- 제거 API 미사용 확인.
|
||||||
|
- STOMP/SockJS 미사용 확인.
|
||||||
|
- WebSocket outgoing guard 확인.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `rg "/events|events/disconnect|messages/text|Stomp|SockJS|SUBSCRIBE|CONNECT" SodaLive/Sources`
|
||||||
|
- 기대 결과: 신규 유저-크리에이터 채팅 코드에서 제거 API/STOMP 사용이 없어야 한다.
|
||||||
|
- 실행 명령: `rg "roomId > 0|guard .*roomId" SodaLive/Sources/V2/Main/Chat/UserCreatorChat`
|
||||||
|
- 기대 결과: outgoing 명령 전 roomId guard가 확인된다.
|
||||||
|
|
||||||
|
- [x] **Task 6.3: 빌드 검증**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인: 전체 프로젝트
|
||||||
|
- 작업 내용:
|
||||||
|
- `docs/agent-guides/build-test-verification.md` 기준으로 빌드한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
|
||||||
|
- 기대 결과: `** BUILD SUCCEEDED **`
|
||||||
|
- 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||||
|
- 기대 결과: `** BUILD SUCCEEDED **`
|
||||||
|
|
||||||
|
- [ ] **Task 6.4: 수동 QA**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인: 앱 런타임
|
||||||
|
- 확인 항목:
|
||||||
|
- 방 생성/조회 후 `roomId > 0`으로만 `JOIN_ROOM`이 전송된다.
|
||||||
|
- `JOINED` 전에는 입력이 비활성화되고 Loading이 표시된다.
|
||||||
|
- `SEND_TEXT` outgoing JSON과 incoming raw text가 debug log에 남는다.
|
||||||
|
- `SEND_ACK` 수신 시 pending 메시지가 확정된다.
|
||||||
|
- `SEND_ACK` timeout 시 실패 상태가 보인다.
|
||||||
|
- `MESSAGE` 중복 수신 시 한 번만 표시된다.
|
||||||
|
- 백그라운드/뒤로가기/로그아웃 시 `LEAVE_ROOM` 후 socket이 닫힌다.
|
||||||
|
- `JOINED` 전 `ERROR` 3회 실패 시 토스트 후 이전 페이지로 이동한다.
|
||||||
|
- `voiceon://chat/{roomId}` deep link로 동일한 open/join 흐름이 실행된다.
|
||||||
|
- `VOICE` 메시지 송신/수신 UI와 재생 UI가 노출되지 않는다.
|
||||||
|
|
||||||
|
### Phase 7: 코드 리뷰 후속 수정
|
||||||
|
|
||||||
|
- [x] **Task 7.1: 재연결 동기화 및 VOICE 전용 페이지 보완**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 재연결 후 최신 `hasMore`와 `nextCursor`를 반영해 20개 초과 누락 구간을 과거 메시지 조회로 복구할 수 있게 한다.
|
||||||
|
- VOICE 메시지만 반환된 페이지는 cursor가 전진하는 동안 다음 페이지를 자동 조회한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 재연결 동기화 결과의 pagination 상태가 갱신된다.
|
||||||
|
- VOICE 전용 페이지 뒤의 TEXT 메시지까지 조회할 수 있다.
|
||||||
|
|
||||||
|
- [x] **Task 7.2: WebSocket 종료 및 재연결 단일 실행 보장**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 동일 socket task의 `receive` 실패와 delegate close가 중복 종료 이벤트를 만들지 않게 한다.
|
||||||
|
- 예약된 재연결 작업은 항상 하나만 유지하고 화면 이탈 또는 연결 성공 시 취소한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- socket 종료 처리 후 현재 task가 해제된다.
|
||||||
|
- 중복 `.closed` 이벤트가 들어와도 재연결 예약은 하나만 남는다.
|
||||||
|
|
||||||
|
- [x] **Task 7.3: 후속 수정 검증**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인: 전체 프로젝트
|
||||||
|
- 검증 기준:
|
||||||
|
- `git diff --check`가 통과한다.
|
||||||
|
- `SodaLive`, `SodaLive-dev` Debug 빌드가 성공한다.
|
||||||
|
|
||||||
|
### Phase 8: 코드 리뷰 결과 반영
|
||||||
|
|
||||||
|
- [x] **Task 8.1: 백그라운드 재연결과 openRoom 성공 gate 보완**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 백그라운드 진입 시 예약된 재연결을 취소하고 앱 활성 상태를 별도로 관리한다.
|
||||||
|
- `openRoom` 성공 전에는 foreground 전환이나 재연결 경로에서 WebSocket을 연결하지 않는다.
|
||||||
|
- 백그라운드에서 `openRoom` 응답이 완료되면 방 정보만 반영하고 socket 연결은 foreground까지 미룬다.
|
||||||
|
- 검증 기준:
|
||||||
|
- `openRoom` 성공 전이거나 앱이 백그라운드인 동안 `socketClient.connect` 경로가 차단된다.
|
||||||
|
- foreground 복귀 시 성공적으로 열린 방에 한해 재연결한다.
|
||||||
|
|
||||||
|
- [x] **Task 8.2: ACK 유실 시 pending 메시지 재동기화**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- REST/실시간 서버 메시지 merge 시 동일한 내 텍스트 pending/failed 메시지를 1:1로 재동기화한다.
|
||||||
|
- ACK만 유실된 경우 서버 메시지와 failed pending이 중복 노출되지 않게 한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 서버 `messageId` dedupe와 로컬 pending 재동기화가 함께 적용된다.
|
||||||
|
- 동일 텍스트를 연속 전송해도 서버 메시지 하나가 pending 하나만 확정한다.
|
||||||
|
|
||||||
|
- [x] **Task 8.3: WebSocket mutable state 직렬화**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- public 명령과 `URLSessionWebSocketDelegate`/receive/send callback의 `task`, `session`, `state`, `isClosing` 접근을 main thread로 직렬화한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- socket open/close/receive가 겹쳐도 현재 task 판별과 종료 이벤트가 단일 실행된다.
|
||||||
|
|
||||||
|
- [x] **Task 8.4: 요청 범위 외 변경 원복**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/Community/Detail/CreatorChannelCommunityPostDetailViewModel.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/CreatorChannel/CreatorChannelViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- DM 채팅 구현과 관계없는 기존 `print(error)` 삭제 2건을 원복한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 두 파일이 DM 채팅 변경 diff에서 제외된다.
|
||||||
|
|
||||||
|
- [x] **Task 8.5: 리뷰 반영 검증**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인: 전체 프로젝트
|
||||||
|
- 검증 기준:
|
||||||
|
- `git diff --check HEAD`가 통과한다.
|
||||||
|
- `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과한다.
|
||||||
|
- `SodaLive`, `SodaLive-dev` Debug 빌드가 성공한다.
|
||||||
|
|
||||||
|
- [x] **Task 8.6: 최신 메시지 REST 재동기화 Loading/오류 처리**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `syncLatestMessages` REST 요청 동안 Loading을 유지한다.
|
||||||
|
- 요청 실패, API 실패 응답, 디코딩 오류를 기존 토스트 경로로 전달한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- `JOINED` 직후 Loading은 재동기화 완료 후 해제된다.
|
||||||
|
- 재동기화 실패 시 Loading이 해제되고 오류 토스트가 표시된다.
|
||||||
|
|
||||||
|
### Phase 9: 추가 코드 리뷰 결과 반영
|
||||||
|
|
||||||
|
- [x] **Task 9.1: JOIN 전송 실패 및 JOINED timeout 처리**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/UserCreatorChatWebSocketModels.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/WebSocket/WebSocketChatClient.swift`
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- `JOIN_ROOM` 전송 오류를 ViewModel의 JOIN 재시도 정책으로 전달한다.
|
||||||
|
- `JOIN_ROOM` 전송 후 15초 안에 `JOINED`가 없으면 JOIN 실패 event를 전달한다.
|
||||||
|
- 연결 성공, socket 종료, 재연결 시작 시 예약된 JOIN timeout을 취소한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- JOIN 전송 오류 또는 timeout이 발생하면 `.joining`에 고정되지 않고 최대 3회 재시도한다.
|
||||||
|
- 화면 이탈 또는 연결 성공 후 이전 JOIN timeout이 실행되지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 9.2: ACK 유실 재동기화 clock-skew 허용**
|
||||||
|
- 대상 파일:
|
||||||
|
- 수정: `SodaLive/Sources/V2/Main/Chat/UserCreatorChat/UserCreatorChatRoomViewModel.swift`
|
||||||
|
- 작업 내용:
|
||||||
|
- 동일한 내 텍스트 서버 메시지와 pending/failed 메시지를 매칭할 때 서버 시각이 로컬 생성 시각보다 최대 5분 이전인 경우까지 허용한다.
|
||||||
|
- 기존 1:1 매칭과 ACK timeout 취소 동작을 유지한다.
|
||||||
|
- 검증 기준:
|
||||||
|
- 서버 `createdAt`이 로컬 pending 생성 시각보다 5분 이내로 이전이어도 중복 pending/failed 메시지가 제거된다.
|
||||||
|
- 5분을 초과해 오래된 동일 텍스트 메시지는 pending과 매칭하지 않는다.
|
||||||
|
|
||||||
|
- [x] **Task 9.3: 추가 리뷰 반영 검증**
|
||||||
|
- 대상 파일:
|
||||||
|
- 확인: 전체 프로젝트
|
||||||
|
- 검증 기준:
|
||||||
|
- `git diff --check HEAD`가 통과한다.
|
||||||
|
- `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과한다.
|
||||||
|
- `SodaLive`, `SodaLive-dev` Debug 빌드가 성공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
- 2026-07-11 문서 작성: `docs/agent-guides/documentation-policy.md`, 기존 홈 채팅 탭 PRD/계획 문서, 기존 AI `ChatRoomView`/`ChatRoomViewModel`, V2 `MainChatView`, `AppDeepLinkHandler`, `BaseView`, 기존 multipart 음성 메시지 전송 패턴을 확인하고 PRD 및 구현 계획에 반영했다. 이 기록은 구현 전 문서 작성 시점의 상태다.
|
||||||
|
- 2026-07-11 문서 보완: 사용자 확정 사항을 반영해 기존 `handleTalkTap`은 AI 캐릭터 채팅 진입으로 유지하고, 별도 `DM 보내기` 버튼에서 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동하도록 PRD/계획을 수정했다. 또한 `VOICE`는 DTO/API/Repository 통신 작업만 범위에 남기고, 음성 메시지 송신 UI·수신 표시 UI·재생 UI 작업을 제외하도록 Phase 3/4/5와 QA 항목을 갱신했다.
|
||||||
|
- 2026-07-11 코드 리뷰/검증: 변경사항 기준으로 제거된 event/text REST API 및 STOMP/SockJS 미사용, WebSocket outgoing `roomId > 0` guard, 신규 파일 프로젝트 등록을 정적 검색으로 확인했다. `SodaLive`, `SodaLive-dev` Debug simulator 빌드가 성공했다.
|
||||||
|
- 2026-07-12 코드 리뷰 후속 수정: 재연결 후 `openRoom`의 최신 pagination 상태를 반영하고, VOICE 전용 페이지에서 cursor가 전진하는 동안 다음 페이지를 자동 조회하도록 수정했다. 동일 WebSocket task의 종료 이벤트와 ViewModel의 재연결 예약을 각각 한 번만 처리하도록 보완했다. `git diff --check`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, 제거 API/STOMP 정적 검색을 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveReviewDerivedData CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
|
||||||
|
- 2026-07-12 코드 리뷰 결과 반영: 백그라운드 상태와 `openRoom` 성공 여부를 별도 gate로 관리해 지연된 REST 응답과 예약된 재연결이 백그라운드에서 socket을 다시 열지 않게 했다. 새로 관측된 내 서버 텍스트 메시지는 로컬 pending/failed 메시지와 1:1로 재동기화하고, WebSocket의 public 명령과 delegate/receive/send callback에서 발생하는 mutable state 접근을 main thread로 직렬화했다. DM 채팅과 관계없는 기존 `print(error)` 삭제 2건은 원복했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`, 제거 API/STOMP 정적 검색을 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveCodeReviewDerivedData CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
|
||||||
|
- 2026-07-12 최신 메시지 REST 재동기화 보완: `JOINED` 후 `syncLatestMessages` 완료까지 Loading을 유지하고, 요청 실패·API 실패 응답·디코딩 오류를 `applyRestFailure` 토스트 경로로 전달하도록 수정했다. `git diff --check HEAD`가 통과했고, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 `** BUILD SUCCEEDED **`로 완료됐다.
|
||||||
|
- 2026-07-12 추가 코드 리뷰 결과 반영: `JOIN_ROOM` 전송 오류를 즉시 JOIN 실패 event로 전달하고, 15초 동안 `JOINED`가 없으면 현재 socket을 닫은 뒤 기존 최대 3회 재시도 정책을 적용하도록 보완했다. ACK 유실 재동기화 시 서버 `createdAt`이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용했다. `git diff --check HEAD`, `plutil -lint SodaLive.xcodeproj/project.pbxproj`가 통과했고, `xcodebuild -quiet -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug -derivedDataPath /tmp/SodaLiveReviewCurrent CODE_SIGNING_ALLOWED=NO build` 및 `SodaLive-dev` 동일 명령이 종료 코드 0으로 완료됐다. 실제 서버 연동 수동 QA는 Task 6.4에 남겨 두었다.
|
||||||
229
docs/20260711_유저_크리에이터_1대1_채팅/prd.md
Normal file
229
docs/20260711_유저_크리에이터_1대1_채팅/prd.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# PRD: 유저-크리에이터 1:1 채팅
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
유저와 크리에이터가 1:1로 대화할 수 있는 DM 채팅방을 iOS 앱에 추가한다. REST API는 기존 `BASE_URL` 기반 Moya `TargetType`로 호출하고, 실시간 텍스트 채팅은 STOMP/SockJS 없이 `URLSessionWebSocketTask` 기반 raw WebSocket으로 처리한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 기존 홈 채팅 탭의 `DM` 아이템은 목록에는 노출되지만 DM 채팅방 진입 화면이 연결되어 있지 않다.
|
||||||
|
- 기존 AI 채팅방(`ChatRoomView`)은 REST 기반 텍스트 전송 API를 사용하므로, raw WebSocket 기반 유저-크리에이터 채팅 계약과 다르다.
|
||||||
|
- 서버는 `/ws/v2/user-creator-chat` raw WebSocket만 지원하며, STOMP frame(`CONNECT`/`SUBSCRIBE`/`SEND`)을 보내면 안 된다.
|
||||||
|
- WebSocket 연결 완료와 채팅방 입장 완료가 다르므로, UI의 연결 완료 기준을 socket open이 아니라 `JOINED` 수신으로 분리해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 유저-크리에이터 채팅 REST API 4종을 Moya `TargetType`에 추가하고, 요청/응답 `Codable` DTO를 정의한다.
|
||||||
|
- WebSocket은 `URLSessionWebSocketTask`로 구현하고, 모든 요청에 `Authorization: Bearer {accessToken}` 헤더를 넣는다.
|
||||||
|
- `openRoom` 성공 후 실제 `roomId > 0`일 때만 WebSocket 연결 및 `JOIN_ROOM`을 수행한다.
|
||||||
|
- WebSocket 상태를 `disconnected`, `connecting`, `socketOpen`, `joining`, `joined` 등으로 분리하고, UI는 `JOINED` 수신 후에만 입력 가능 상태로 본다.
|
||||||
|
- `JOINED` 이후에만 `SEND_TEXT`, `PING`, `LEAVE_ROOM`을 허용한다.
|
||||||
|
- 텍스트 전송은 REST API가 아니라 WebSocket `SEND_TEXT`로만 처리한다.
|
||||||
|
- `SEND_TEXT` 전송 시 pending 메시지를 즉시 표시하고, `SEND_ACK.requestId`로 매칭해 서버 메시지로 확정한다.
|
||||||
|
- `SEND_ACK` timeout은 15초 기준으로 실패 상태를 표시한다.
|
||||||
|
- `MESSAGE` 수신 및 REST 재조회 결과는 `messageId` 기준으로 중복 제거/merge한다.
|
||||||
|
- joined 상태에서 30초 주기로 `PING`을 보내고 `PONG`을 처리한다.
|
||||||
|
- 화면 이탈, 앱 백그라운드, 로그아웃/회원탈퇴 시 `LEAVE_ROOM` 전송 후 WebSocket을 닫는다.
|
||||||
|
- 네트워크 오류 시 사용자가 채팅방 화면에 머무는 동안만 재연결하고, 재연결 후 `JOIN_ROOM`을 다시 보낸다.
|
||||||
|
- `JOINED` 전 `ERROR`가 내려오면 최대 3회 재시도하고, 계속 실패하면 Loading을 숨긴 뒤 토스트를 표시하고 이전 페이지로 이동한다.
|
||||||
|
- 푸시 deep link `voiceon://chat/{roomId}` / `voiceon-test://chat/{roomId}`를 파싱해 일반 진입과 동일하게 `openRoom` 후 WebSocket `JOIN_ROOM`을 수행한다.
|
||||||
|
- UI는 기존 AI 채팅방과 거의 동일하게 유지하되, 헤더에서 `Character / Clone` 표시, 보유 캔 UI, 우측 더보기 버튼을 제거한다.
|
||||||
|
- 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
|
||||||
|
- 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가하고, 이 버튼을 터치하면 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동한다.
|
||||||
|
- `VOICE`는 REST 통신 계층만 구현하고, 이번 범위에서는 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI를 구현하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- STOMP/SockJS 클라이언트 추가 또는 STOMP frame 전송은 하지 않는다.
|
||||||
|
- 텍스트 메시지 전송에 REST `POST /messages/text`를 사용하지 않는다.
|
||||||
|
- 제거된 event API를 사용하지 않는다.
|
||||||
|
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events`
|
||||||
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
|
||||||
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`
|
||||||
|
- 기존 AI 채팅방의 API/동작을 유저-크리에이터 채팅 방식으로 변경하지 않는다.
|
||||||
|
- 크리에이터 채널의 기존 AI 캐릭터 채팅 진입(`handleTalkTap`)을 DM 진입으로 바꾸지 않는다.
|
||||||
|
- 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI는 이번 범위에서 구현하지 않는다.
|
||||||
|
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
|
||||||
|
- 서버 API 계약 변경은 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 크리에이터와 1:1 DM 대화를 시작하거나 이어서 확인하려는 일반 사용자
|
||||||
|
- 푸시 알림을 통해 특정 DM 채팅방으로 바로 진입하려는 사용자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 크리에이터와의 DM 채팅방에 진입하면 최신 메시지와 과거 메시지를 확인할 수 있다.
|
||||||
|
- 사용자는 채팅방 연결이 완료되기 전까지 Loading 상태를 확인하고, `JOINED` 후 메시지를 입력할 수 있다.
|
||||||
|
- 사용자는 텍스트를 전송하면 즉시 pending 메시지를 보고, 서버 ACK 이후 확정된 메시지 상태를 확인할 수 있다.
|
||||||
|
- 사용자는 상대방이 보낸 실시간 메시지를 중복 없이 목록에서 확인할 수 있다.
|
||||||
|
- 사용자는 과거 메시지를 스크롤로 추가 조회할 수 있다.
|
||||||
|
- 사용자는 푸시 알림을 눌러 `voiceon://chat/{roomId}` 형식의 deep link로 채팅방에 진입할 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Requirements
|
||||||
|
|
||||||
|
### 7.1 REST API
|
||||||
|
신규 REST API는 Moya `TargetType`로 추가한다. 모든 요청은 기존 REST `BASE_URL`을 사용하고 `Authorization` 헤더를 포함한다.
|
||||||
|
|
||||||
|
1. 방 생성/조회
|
||||||
|
- `POST /api/v2/user-creator-chat/rooms/create`
|
||||||
|
- body: `{"creatorId": 123}`
|
||||||
|
- response: `ApiResponse<CreateRoomResponse>`
|
||||||
|
- data: `{"roomId": 10}`
|
||||||
|
|
||||||
|
2. 채팅방 열기
|
||||||
|
- `GET /api/v2/user-creator-chat/rooms/{roomId}/open?limit=20`
|
||||||
|
- data: `roomId`, `opponentNickname`, `opponentProfileImageUrl`, `messages`, `hasMore`, `nextCursor`
|
||||||
|
|
||||||
|
3. 과거 메시지 조회
|
||||||
|
- `GET /api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={nextCursor}&limit=20`
|
||||||
|
- data: `messages`, `hasMore`, `nextCursor`
|
||||||
|
|
||||||
|
4. 음성 메시지 전송
|
||||||
|
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
|
||||||
|
- `multipart/form-data`
|
||||||
|
- parts:
|
||||||
|
- `voiceMessageFile`: file data
|
||||||
|
- `request`: JSON 문자열 `{"recipientId": null}`
|
||||||
|
- data: `message`, `deliveredRealtime`, `pushSent`
|
||||||
|
|
||||||
|
### 7.2 DTO
|
||||||
|
공통 응답은 기존 `ApiResponse<T>`를 사용한다.
|
||||||
|
|
||||||
|
`MessageItem`은 아래 필드를 `Codable`로 정의한다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messageId": 1,
|
||||||
|
"messageType": "TEXT",
|
||||||
|
"mine": true,
|
||||||
|
"createdAt": 1710000000000,
|
||||||
|
"textMessage": "hello",
|
||||||
|
"voiceMessageUrl": null,
|
||||||
|
"senderId": 1,
|
||||||
|
"senderNickname": "...",
|
||||||
|
"senderProfileImageUrl": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
로컬 pending/failed 상태는 서버 DTO와 분리한 display model 또는 wrapper에서 관리한다.
|
||||||
|
|
||||||
|
### 7.3 WebSocket URL/Auth
|
||||||
|
- WebSocket URL은 REST `BASE_URL`의 scheme/host를 기준으로 만든다.
|
||||||
|
- REST가 `http://...`이면 `ws://{host}/ws/v2/user-creator-chat`
|
||||||
|
- REST가 `https://...`이면 `wss://{host}/ws/v2/user-creator-chat`
|
||||||
|
- WebSocket `URLRequest`에 `Authorization: Bearer {accessToken}` 헤더를 넣는다.
|
||||||
|
- STOMP 관련 라이브러리와 STOMP frame은 사용하지 않는다.
|
||||||
|
|
||||||
|
### 7.4 WebSocket Protocol
|
||||||
|
`roomId`가 nil 또는 0이면 `JOIN_ROOM`, `SEND_TEXT`, `PING`, `LEAVE_ROOM`을 절대 보내지 않는다.
|
||||||
|
|
||||||
|
Client -> Server:
|
||||||
|
- `JOIN_ROOM`: `{"type":"JOIN_ROOM","requestId":"uuid","roomId":10,"payload":{}}`
|
||||||
|
- `SEND_TEXT`: `{"type":"SEND_TEXT","requestId":"uuid","roomId":10,"payload":{"textMessage":"hello"}}`
|
||||||
|
- `LEAVE_ROOM`: `{"type":"LEAVE_ROOM","requestId":"uuid","roomId":10,"payload":{}}`
|
||||||
|
- `PING`: `{"type":"PING","requestId":"uuid","roomId":10,"payload":{}}`
|
||||||
|
|
||||||
|
Server -> Client:
|
||||||
|
- `JOINED`: 연결 완료 기준
|
||||||
|
- `SEND_ACK`: `requestId`로 pending 메시지 확정
|
||||||
|
- `MESSAGE`: `messageId` 기준 중복 제거 후 append
|
||||||
|
- `PONG`: ping 응답 처리
|
||||||
|
- `ERROR`: root code/message가 아니라 `payload.messageKey`를 읽는다.
|
||||||
|
|
||||||
|
모든 outgoing JSON과 incoming raw text는 `DEBUG_LOG`로 남긴다.
|
||||||
|
|
||||||
|
### 7.5 Connection State
|
||||||
|
- `disconnected`: task 없음 또는 닫힘
|
||||||
|
- `connecting`: WebSocket task 생성/resume 중
|
||||||
|
- `socketOpen`: WebSocket task가 열렸지만 room join 전
|
||||||
|
- `joining`: `JOIN_ROOM` 전송 후 `JOINED` 대기
|
||||||
|
- `joined`: 채팅 입력/핑/leave 허용
|
||||||
|
|
||||||
|
UI 입력 가능, heartbeat 시작, pending send 허용 기준은 `socketOpen`이 아니라 `joined`다.
|
||||||
|
- `JOIN_ROOM` 전송 실패는 즉시 JOIN 재시도 정책으로 전달한다.
|
||||||
|
- `JOIN_ROOM` 전송 후 15초 안에 `JOINED`가 없으면 JOIN 실패로 처리하고 동일한 재시도 정책을 적용한다.
|
||||||
|
|
||||||
|
### 7.6 Message Behavior
|
||||||
|
- `openRoom` 응답 메시지는 정렬 후 초기 목록으로 표시한다.
|
||||||
|
- 과거 메시지는 `messages` API로 조회하고, `messageId` 기준 merge한다.
|
||||||
|
- `SEND_TEXT` 시 `UUID` requestId를 만들고 임시 pending 메시지를 append한다.
|
||||||
|
- `SEND_ACK` 수신 시 pending 메시지를 서버 `MessageItem`으로 교체한다.
|
||||||
|
- 15초 안에 `SEND_ACK`가 없으면 해당 pending 메시지를 failed 상태로 전환한다.
|
||||||
|
- `MESSAGE` 수신 시 이미 동일 `messageId`가 있으면 무시한다.
|
||||||
|
- 재연결 후 최신 동기화가 필요하면 `openRoom` 또는 `messages` API 결과를 `messageId` 기준으로 merge한다.
|
||||||
|
- ACK 유실 시 동일한 내 텍스트의 서버 메시지와 pending/failed 메시지를 1:1로 재동기화한다.
|
||||||
|
- 서버/클라이언트 시각 차이로 중복 메시지가 남지 않도록 서버 `createdAt`이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용한다.
|
||||||
|
|
||||||
|
### 7.7 Loading/Error
|
||||||
|
- 네트워크 통신 중에는 항상 Loading을 표시한다.
|
||||||
|
- 최초 진입은 `openRoom` + WebSocket `JOINED`까지 Loading을 유지한다.
|
||||||
|
- `JOINED` 전 `ERROR` 수신 시 최대 3회 재시도한다.
|
||||||
|
- `JOIN_ROOM` 전송 실패 또는 15초 `JOINED` timeout도 동일하게 최대 3회 재시도한다.
|
||||||
|
- 3회 재시도 후에도 실패하면 Loading을 숨기고 `대화방에 접속하지 못했습니다.` 토스트를 표시한 뒤 socket을 닫고 이전 페이지로 이동한다.
|
||||||
|
- 일반 REST 실패도 Loading을 숨기고 토스트를 표시한다.
|
||||||
|
|
||||||
|
### 7.8 Lifecycle/Reconnection
|
||||||
|
- 화면 이탈 시 `LEAVE_ROOM`을 보내고 WebSocket을 닫는다.
|
||||||
|
- 앱 백그라운드 전환 시 `LEAVE_ROOM` 후 close/cancel한다.
|
||||||
|
- 로그아웃/전체 로그아웃/회원탈퇴 성공 흐름에서는 열린 DM WebSocket이 `LEAVE_ROOM`을 보낸 뒤 닫히도록 알림 또는 공통 세션 종료 훅을 둔다.
|
||||||
|
- 네트워크 오류로 socket이 끊기면 채팅방 화면이 살아 있는 동안만 재연결한다.
|
||||||
|
- 재연결 후에는 동일 `roomId`로 다시 `JOIN_ROOM`을 보낸다.
|
||||||
|
|
||||||
|
### 7.9 UI
|
||||||
|
- 신규 DM 채팅방은 AI `ChatRoomView`와 같은 전체 화면 채팅 UX를 따른다.
|
||||||
|
- 헤더에서는 아래 3가지를 제거한다.
|
||||||
|
- `Character / Clone` 표시
|
||||||
|
- 우측 더보기 버튼
|
||||||
|
- 더보기 버튼 왼쪽의 보유 캔 표시 UI
|
||||||
|
- 헤더에는 뒤로가기, 상대 프로필 이미지, 상대 닉네임을 표시한다.
|
||||||
|
- 메시지 목록 UI는 이번 범위에서 `TEXT`만 표시한다.
|
||||||
|
- `VOICE` 메시지는 DTO/API/Repository 통신 처리를 추가하되, 채팅방 화면에는 표시하지 않는다.
|
||||||
|
- 입력창은 `joined` 상태에서만 활성화한다.
|
||||||
|
- 기존 AI 채팅방의 quota, locked image, 배경 변경/초기화 설정은 DM 화면에 넣지 않는다.
|
||||||
|
|
||||||
|
### 7.10 Deep Link
|
||||||
|
- `voiceon://chat/{roomId}`와 `voiceon-test://chat/{roomId}`를 지원한다.
|
||||||
|
- URL 파싱 기준:
|
||||||
|
- scheme: `voiceon` 또는 `voiceon-test`
|
||||||
|
- host: `chat`
|
||||||
|
- path 첫 segment: `roomId`
|
||||||
|
- 푸시 터치로 roomId를 얻으면 일반 진입과 동일하게 `openRoom(roomId)` 성공 후 WebSocket connect 및 `JOIN_ROOM`을 수행한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UX / UI Expectations
|
||||||
|
- 화면 구조, spacing, 색상, 입력창은 기존 `ChatRoomView`의 사용자 경험을 우선 재사용한다.
|
||||||
|
- Loading은 기존 `BaseView(isLoading:)` 또는 동일한 `LoadingView` 패턴을 사용한다.
|
||||||
|
- 토스트는 기존 `.sodaToast` 패턴을 사용한다.
|
||||||
|
- pending 메시지는 사용자가 전송 즉시 인지할 수 있어야 하고, 실패 상태는 재전송 가능 여부를 후속 결정할 수 있도록 명확히 표시한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technical Constraints
|
||||||
|
- 신규 View/ViewModel/Repository/DTO는 새 기능이므로 `SodaLive/Sources/V2/**` 아래에 둔다.
|
||||||
|
- DM 채팅방 전용 컴포넌트는 해당 기능 폴더 하위 `Components`에 둔다.
|
||||||
|
- Moya는 REST API에만 사용한다.
|
||||||
|
- 실시간 채팅에는 `URLSessionWebSocketTask`를 사용한다. 현재 소스에서 별도 raw WebSocket 클라이언트는 확인되지 않았다.
|
||||||
|
- `BASE_URL`, `UserDefaultsKey.token`, `ApiResponse<T>`, `DEBUG_LOG`, `ERROR_LOG`, `BaseView`, `.sodaToast`, `AppState`/`AppStep`의 기존 패턴을 따른다.
|
||||||
|
- `roomId == 0`을 전송하면 서버가 `ERROR` 후 close code `1008`로 닫으므로 모든 outgoing 명령은 `roomId > 0` guard를 둔다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Assumptions
|
||||||
|
- 문서 작성 기준일은 2026-07-11이며 신규 문서 폴더도 이 날짜를 사용한다.
|
||||||
|
- 기존 대화 목록(`MainChatView`)의 `chatType == "DM"` 아이템은 신규 DM 채팅방으로 라우팅한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Confirmed Decisions
|
||||||
|
- 2026-07-11: 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다.
|
||||||
|
- 2026-07-11: 크리에이터 채널에 별도 `DM 보내기` 버튼을 추가하고, 해당 버튼 터치 시 `creatorId` 기반 DM 생성/조회 후 신규 DM 채팅방으로 이동한다.
|
||||||
|
- 2026-07-11: 이번 범위에서 `VOICE`는 통신 작업만 구현한다. 음성 메시지 송신 UI와 수신 음성 메시지 표시 UI는 구현하지 않는다.
|
||||||
|
- 2026-07-11: `DM 보내기` 진입은 로그인 여부만 확인한다. AI 캐릭터 채팅 진입에 적용되는 본인인증/성인 콘텐츠 설정 guard는 DM 정책과 맞지 않으므로 적용하지 않는다.
|
||||||
Reference in New Issue
Block a user