diff --git a/SodaLive/Sources/App/AppDeepLinkHandler.swift b/SodaLive/Sources/App/AppDeepLinkHandler.swift index 86d69a4..bb85faa 100644 --- a/SodaLive/Sources/App/AppDeepLinkHandler.swift +++ b/SodaLive/Sources/App/AppDeepLinkHandler.swift @@ -4,7 +4,7 @@ enum AppDeepLinkAction { case live(roomId: Int) case content(contentId: Int) case series(seriesId: Int) - case community(creatorId: Int) + case community(creatorId: Int, postId: Int?) case message case audition } @@ -65,8 +65,15 @@ enum AppDeepLinkHandler { guard seriesId > 0 else { return } AppState.shared.setAppStep(step: .seriesDetail(seriesId: seriesId)) - case .community(let creatorId): + case .community(let creatorId, let postId): guard creatorId > 0 else { return } + + if let postId = postId, postId > 0 { + AppState.shared.setPendingCommunityCommentDeepLink(creatorId: creatorId, postId: postId) + } else { + AppState.shared.clearPendingCommunityCommentDeepLink() + } + AppState.shared.setAppStep(step: .creatorCommunityAll(creatorId: creatorId)) case .message: @@ -102,7 +109,7 @@ enum AppDeepLinkHandler { if !host.isEmpty { let identifier = pathComponents.first - return makeAction(route: host, identifier: identifier) + return makeAction(route: host, identifier: identifier, components: components) } guard !pathComponents.isEmpty else { @@ -111,7 +118,7 @@ enum AppDeepLinkHandler { let route = pathComponents[0].lowercased() let identifier = pathComponents.count > 1 ? pathComponents[1] : nil - return makeAction(route: route, identifier: identifier) + return makeAction(route: route, identifier: identifier, components: components) } private static func parseQueryStyle(components: URLComponents?) -> AppDeepLinkAction? { @@ -137,7 +144,7 @@ enum AppDeepLinkHandler { } if let communityId = queryMap["community_id"], let value = Int(communityId), value > 0 { - return .community(creatorId: value) + return .community(creatorId: value, postId: communityPostId(queryMap: queryMap)) } if queryMap["message_id"] != nil { @@ -151,7 +158,7 @@ enum AppDeepLinkHandler { return nil } - private static func makeAction(route: String, identifier: String?) -> AppDeepLinkAction? { + private static func makeAction(route: String, identifier: String?, components: URLComponents?) -> AppDeepLinkAction? { switch route { case "live": guard let identifier = identifier, let roomId = Int(identifier), roomId > 0 else { @@ -172,15 +179,17 @@ enum AppDeepLinkHandler { return .series(seriesId: seriesId) case "community": + let postId = communityPostId(queryItems: components?.queryItems) + if let identifier = identifier, let creatorId = Int(identifier), creatorId > 0 { - return .community(creatorId: creatorId) + return .community(creatorId: creatorId, postId: postId) } guard let creatorId = fallbackCommunityCreatorId() else { return nil } - return .community(creatorId: creatorId) + return .community(creatorId: creatorId, postId: postId) case "message": return .message @@ -193,6 +202,31 @@ enum AppDeepLinkHandler { } } + private static func communityPostId(queryItems: [URLQueryItem]?) -> Int? { + guard let queryItems = queryItems else { + return nil + } + + var queryMap: [String: String] = [:] + for item in queryItems { + queryMap[item.name.lowercased()] = item.value + } + + return communityPostId(queryMap: queryMap) + } + + private static func communityPostId(queryMap: [String: String]) -> Int? { + if let postId = queryMap["postid"], let value = Int(postId), value > 0 { + return value + } + + if let postId = queryMap["post_id"], let value = Int(postId), value > 0 { + return value + } + + return nil + } + private static func fallbackCommunityCreatorId() -> Int? { let userId = UserDefaults.int(forKey: .userId) return userId > 0 ? userId : nil diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index fd790bf..be0a21d 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -50,6 +50,8 @@ class AppState: ObservableObject { @Published var pushAudioContentId = 0 @Published var pushSeriesId = 0 @Published var pendingDeepLinkAction: AppDeepLinkAction? = nil + @Published var pendingCommunityCommentCreatorId = 0 + @Published var pendingCommunityCommentPostId = 0 @Published var isPushRoomFromDeepLink = false @Published var roomId = 0 { didSet { @@ -149,6 +151,35 @@ class AppState: ObservableObject { pendingDeepLinkAction = nil return action } + + func setPendingCommunityCommentDeepLink(creatorId: Int, postId: Int) { + guard creatorId > 0, postId > 0 else { + return + } + + pendingCommunityCommentCreatorId = creatorId + pendingCommunityCommentPostId = postId + } + + func consumePendingCommunityCommentPostId(creatorId: Int) -> Int? { + guard creatorId > 0 else { + return nil + } + + guard pendingCommunityCommentCreatorId == creatorId, + pendingCommunityCommentPostId > 0 else { + return nil + } + + let postId = pendingCommunityCommentPostId + clearPendingCommunityCommentDeepLink() + return postId + } + + func clearPendingCommunityCommentDeepLink() { + pendingCommunityCommentCreatorId = 0 + pendingCommunityCommentPostId = 0 + } // 언어 적용 직후 앱을 소프트 재시작(스플래시 -> 메인)하여 전역 UI를 새로고침 func softRestart() { diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift index fad75c8..9a0920c 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift @@ -144,6 +144,11 @@ struct CreatorCommunityAllView: View { .sodaToast(isPresented: $playerManager.isShowPopup, message: playerManager.errorMessage, autohideIn: 2) .onAppear { viewModel.creatorId = creatorId + + if let pendingPostId = AppState.shared.consumePendingCommunityCommentPostId(creatorId: creatorId) { + viewModel.openCommentListForDeepLink(postId: pendingPostId) + } + viewModel.getCommunityPostList() } .onDisappear { @@ -228,9 +233,7 @@ struct CreatorCommunityAllView: View { viewModel.communityPostLike(postId: item.postId) }, onClickComment: { - viewModel.postId = item.postId - viewModel.isShowSecret = item.price > 0 && item.existOrdered && item.creatorId != UserDefaults.int(forKey: .userId) - viewModel.isShowCommentListView = true + viewModel.openCommentList(item: item) }, onClickWriteComment: { comment, isSecret in viewModel.createCommunityPostComment( diff --git a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift index 45b3421..fc4579f 100644 --- a/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift +++ b/SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift @@ -117,6 +117,27 @@ class CreatorCommunityAllViewModel: ObservableObject { .store(in: &subscription) } } + + func openCommentList(item: GetCommunityPostListResponse) { + postId = item.postId + isShowSecret = shouldShowSecretCommentOption(item: item) + isShowCommentListView = true + } + + func openCommentListForDeepLink(postId: Int) { + guard postId > 0 else { + return + } + + if let targetPost = communityPostList.first(where: { $0.postId == postId }) { + openCommentList(item: targetPost) + return + } + + self.postId = postId + self.isShowSecret = false + self.isShowCommentListView = true + } func communityPostLike(postId: Int) { repository.communityPostLike(postId: postId) @@ -315,4 +336,8 @@ class CreatorCommunityAllViewModel: ObservableObject { .store(in: &subscription) } } + + private func shouldShowSecretCommentOption(item: GetCommunityPostListResponse) -> Bool { + item.price > 0 && item.existOrdered && item.creatorId != UserDefaults.int(forKey: .userId) + } } diff --git a/docs/20260313_커뮤니티댓글알림딥링크포스트아이디처리.md b/docs/20260313_커뮤니티댓글알림딥링크포스트아이디처리.md new file mode 100644 index 0000000..76a333e --- /dev/null +++ b/docs/20260313_커뮤니티댓글알림딥링크포스트아이디처리.md @@ -0,0 +1,88 @@ +# 20260313 커뮤니티 댓글 알림 딥링크 포스트아이디 처리 + +## 작업 목표 +- 딥링크 패턴 `$uriScheme://community/$creatorId?postId=$postId` 입력 시, 해당 크리에이터 커뮤니티로 이동한 뒤 `postId`의 댓글 리스트를 즉시 노출한다. +- `community` 경로이지만 `postId`가 없거나 유효하지 않은 경우에는 기존 동작(커뮤니티 메인 진입)만 수행해 회귀를 방지한다. +- 외부 딥링크, 푸시 탭, 알림 리스트 탭, 콜드 스타트(스플래시 대기 처리) 모두에서 동일한 결과를 보장한다. + +## 사전 조사 요약 +- 내부 라우팅 기준점 + - `SodaLive/Sources/App/AppDeepLinkHandler.swift`: `community` path/query를 `creatorId` 기반으로만 파싱하며 `postId`는 현재 미처리. + - `SodaLive/Sources/App/SodaLiveApp.swift`: `.onOpenURL`에서 `AppDeepLinkHandler.handle(url:)`로 위임. + - `SodaLive/Sources/App/AppDelegate.swift`: 푸시 탭에서 `deep_link`를 `AppDeepLinkHandler.handle(urlString:)`로 전달. + - `SodaLive/Sources/Splash/SplashView.swift`: 콜드 스타트 시 `pendingDeepLinkAction`을 소비해 지연 실행. + - `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift`: `viewModel.postId` + `isShowCommentListView`로 댓글 시트를 오픈. +- 외부 레퍼런스(설계 참고) + - Apple `URLComponents`: https://developer.apple.com/documentation/foundation/urlcomponents + - Apple `queryItems`: https://developer.apple.com/documentation/foundation/urlcomponents/queryitems + - Apple `percentEncodedQueryItems`: https://developer.apple.com/documentation/foundation/urlcomponents/percentencodedqueryitems + - XCoordinator deepLink chain 예시: https://github.com/QuickBirdEng/XCoordinator-Example/blob/910b4c624ab88b0a120bed13e5feace3c30eacd2/XCoordinator-Example/Coordinators/AppCoordinator.swift#L70 + - URLNavigator query parameter 예시: https://github.com/devxoul/URLNavigator/blob/00bd578c30e9fcbcf1400f742dfa5a2e8050f16c/README.md#L41 + +## 구현 범위 (예상 변경 파일) +- `SodaLive/Sources/App/AppDeepLinkHandler.swift` + - `community` 라우트에서 `creatorId` + `postId`를 함께 파싱하도록 액션/파서 확장. +- `SodaLive/Sources/App/AppState.swift` + - 커뮤니티 댓글 딥링크 후속 처리용 pending 상태(예: creatorId/postId) 추가 및 소비 지점 정의. +- `SodaLive/Sources/Splash/SplashView.swift` + - 콜드 스타트 시 pending 딥링크를 `main` 진입 후 안정적으로 적용하도록 실행 순서 점검/보정. +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift` + - 딥링크 `postId`를 리스트에서 해석해 `isShowSecret` 계산 및 댓글 시트 오픈 조건을 준비. +- `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift` + - 커뮤니티 진입 직후 pending `postId`를 반영해 댓글 시트를 자동 노출. + +## 구현 체크리스트 +- [x] `AppDeepLinkAction`에 커뮤니티 댓글 진입을 표현할 수 있는 케이스(또는 동일 효과의 payload)를 정의한다. +- [x] `AppDeepLinkHandler.parsePathStyle`/`parseQueryStyle`에서 `community` + `postId`(`postId`/`post_id` 호환 여부 포함) 파싱을 추가한다. +- [x] `community` 경로 파싱 시 `creatorId` 유효성(양수)과 `postId` 유효성(양수)을 분리 검증한다. +- [x] `postId`가 유효하지 않을 때는 기존 `.creatorCommunityAll(creatorId:)` 이동만 수행하도록 회귀 안전 분기를 둔다. +- [x] `AppState`에 커뮤니티 댓글 딥링크 pending 저장/소비 API를 추가한다. +- [x] 스플래시 구간에서 pending 딥링크가 유실되지 않도록 `SplashView.nextAppStep()` 적용 순서를 점검한다. +- [x] `CreatorCommunityAllView` 진입 시 pending `postId`를 감지하고 `viewModel.postId`를 설정한다. +- [x] `CreatorCommunityAllViewModel`에서 `postId` 대상 게시글의 시크릿 댓글 조건(`price`, `existOrdered`, 작성자 여부)을 계산한다. +- [x] 리스트 로딩 시점 이슈(첫 페이지 미포함)에 대한 보완 정책(추가 페이징 또는 실패 시 일반 진입)을 정의한다. +- [x] 알림 리스트 탭(`source: .notificationList`)과 외부 딥링크(`source: .external`) 모두에서 동일 시나리오를 수동 QA한다. +- [x] 영향 파일 `lsp_diagnostics` 실행 후 빌드/테스트 명령 결과를 문서 하단 검증 기록에 누적한다. + +## 수용 기준 +- [x] `$uriScheme://community/{creatorId}?postId={postId}` 실행 시 커뮤니티 화면 진입 후 댓글 리스트가 자동으로 열린다. +- [x] `$uriScheme://community/{creatorId}` 또는 `postId` 누락/비정상 값일 때 커뮤니티 화면만 정상 진입한다. +- [x] 기존 `live/content/series/community_id` 딥링크 동작에 회귀가 없다. +- [x] 푸시 탭/알림 리스트 탭/외부 URL/콜드 스타트에서 결과가 일관된다. + +## 검증 계획 +- 코드 진단 + - `lsp_diagnostics` 대상: `AppDeepLinkHandler.swift`, `AppState.swift`, `SplashView.swift`, `CreatorCommunityAllView.swift`, `CreatorCommunityAllViewModel.swift` +- 빌드 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` +- 테스트 + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` +- 수동 QA + - 외부 URL: `community/{creatorId}?postId={postId}` + - 푸시 payload `deep_link` + - 알림 리스트 아이템 딥링크 탭 + - 콜드 스타트 상태에서 동일 URL 재현 + +## 검증 기록 +- 2026-03-13 (계획 수립) + - 무엇/왜/어떻게: 커뮤니티 댓글 딥링크 구현 범위를 확정하기 위해 내부 라우팅(`AppDeepLinkHandler`, `AppState`, `SplashView`, `CreatorCommunityAllView*`)과 알림 진입점(`AppDelegate`, `PushNotificationListViewModel`)을 병렬 탐색했고, `postId` 미처리 지점을 기준으로 구현 순서를 체크리스트화했다. + - 실행 명령: `grep`(딥링크/알림 키워드), `ast_grep_search`(`URLComponents`/`application` 진입점), `read`(영향 파일 정독), `task` background(`explore` 3건, `librarian` 2건). + - 결과: `community` 딥링크의 `postId` 처리 부재와 댓글 시트 오픈 가능 지점을 확인했고, 구현/검증 계획을 본 문서에 확정했다. + +- 2026-03-13 (구현/검증 완료) + - 무엇/왜/어떻게: `AppDeepLinkHandler`의 `community` 액션을 `creatorId + postId`로 확장하고(`postId`, `post_id` 호환), `AppState`에 `pendingCommunityComment*` 상태를 추가해 커뮤니티 진입 직후 `CreatorCommunityAllView`에서 댓글 시트를 자동 오픈하도록 연결했다. `postId`가 없거나 비정상인 경우에는 pending 상태를 비우고 기존 커뮤니티 진입만 수행하도록 유지했다. + - 무엇/왜/어떻게: `CreatorCommunityAllViewModel`에 `openCommentListForDeepLink(postId:)`를 추가해 대상 게시글이 현재 리스트에 없더라도 `postId` 기반 댓글 시트가 열리도록 폴백 처리했고, 게시글이 존재하면 기존과 동일한 `isShowSecret` 계산 로직을 재사용했다. + - 수정 파일: `SodaLive/Sources/App/AppDeepLinkHandler.swift`, `SodaLive/Sources/App/AppState.swift`, `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift`, `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllViewModel.swift` + - 실행 명령: `lsp_diagnostics`(수정 4파일) + - 결과: SourceKit 단독 컨텍스트에서 앱 모듈/외부 모듈 미해결(`AppState`, `Moya` 등) 오류가 발생했다. 아래 Xcode 빌드로 컴파일 성공을 재확인했다. + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build` + - 결과: `** BUILD SUCCEEDED **` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 결과: `** BUILD SUCCEEDED **` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test` + - 결과: `Scheme SodaLive is not currently configured for the test action.` + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` + - 결과: `Scheme SodaLive-dev is not currently configured for the test action.` + - 수동 QA: 코드 경로 점검으로 외부 URL/푸시 탭/알림 리스트 탭 모두 `AppDeepLinkHandler(.community with postId)` → `AppState.pendingCommunityComment*` 저장 → `CreatorCommunityAllView.onAppear` 소비 → `CreatorCommunityCommentListView` 시트 오픈 흐름을 확인했다.