diff --git a/Projects/Domain/Entity/Sources/Share/PickeShareURL.swift b/Projects/Domain/Entity/Sources/Share/PickeShareURL.swift new file mode 100644 index 0000000..147142d --- /dev/null +++ b/Projects/Domain/Entity/Sources/Share/PickeShareURL.swift @@ -0,0 +1,27 @@ +// +// PickeShareURL.swift +// Entity +// + +import Foundation + +/// 공유용 웹 랜딩 링크 빌더. +/// 서버 `shareUrl` 이 빈 값이거나 무관한 도메인(pique.app 등)으로 내려오는 사고가 있어, +/// picke.store 링크만 신뢰하고 그 외에는 검증된 랜딩 경로로 대체한다. +public enum PickeShareURL { + private static let trustedHost = "picke.store" + + /// 배틀 공유 랜딩 링크 — 웹 랜딩이 실존하는 경로는 단수형 `/battle/{id}` 다 (`/battles/{id}` 는 403). + public static func battle(id: Int, serverShareUrl: String? = nil) -> String { + if let serverShareUrl, isTrusted(serverShareUrl) { + return serverShareUrl + } + return "https://\(trustedHost)/battle/\(id)" + } + + /// picke.store(서브도메인 포함) 링크만 신뢰. + private static func isTrusted(_ urlString: String) -> Bool { + guard let host = URLComponents(string: urlString)?.host?.lowercased() else { return false } + return host == trustedHost || host.hasSuffix(".\(trustedHost)") + } +} diff --git a/Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift b/Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift new file mode 100644 index 0000000..531f82f --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift @@ -0,0 +1,74 @@ +// +// ShareUseCase.swift +// UseCase +// + +import Foundation +import UIKit + +import ComposableArchitecture +import Entity + +/// 공유 시트 아이템(본문·링크·이미지)을 조립하는 클라이언트. +public struct ShareUseCase: Sendable { + /// 원격 이미지를 받음 + public var loadImageData: @Sendable (_ urlString: String) async -> Data? + /// `ShareContent` 를 공유 시트에 넘길 아이템 배열로 조립한다. + public var makeShareItem: @Sendable (_ content: ShareContent) async -> ShareItem + + public init( + loadImageData: @escaping @Sendable (_ urlString: String) async -> Data?, + makeShareItem: @escaping @Sendable (_ content: ShareContent) async -> ShareItem + ) { + self.loadImageData = loadImageData + self.makeShareItem = makeShareItem + } +} + +extension ShareUseCase: DependencyKey { + public static let liveValue = ShareUseCase( + loadImageData: loadRemoteImageData, + makeShareItem: { content in + var items: [Any] = [content.displayText] + + if let url = URL(string: content.url) { + items.append(url) + } else { + items.append(content.url) + } + + // 인스타 스토리/게시물 공유를 위해 카드 스냅샷을 우선 포함하고, 없을 때만 썸네일로 폴백. + if let snapshotData = content.snapshotData, let image = UIImage(data: snapshotData) { + items.append(image) + } else if let thumbnailURL = content.thumbnailURL, + let data = await loadRemoteImageData(thumbnailURL), + let image = UIImage(data: data) + { + items.append(image) + } + + return ShareItem(items: items) + } + ) + + public static let testValue = ShareUseCase( + loadImageData: { _ in nil }, + makeShareItem: { ShareItem(items: [$0.displayText]) } + ) + + public static let previewValue = testValue + + private static let loadRemoteImageData: @Sendable (String) async -> Data? = { urlString in + guard let url = URL(string: urlString), + let (data, _) = try? await URLSession.shared.data(from: url) + else { return nil } + return data + } +} + +public extension DependencyValues { + var shareUseCase: ShareUseCase { + get { self[ShareUseCase.self] } + set { self[ShareUseCase.self] = newValue } + } +} diff --git a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift index 3426c33..34e9f13 100644 --- a/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift +++ b/Projects/Presentation/Battle/Sources/Main/Reducer/BattleFeature.swift @@ -54,10 +54,12 @@ public struct BattleFeature { public enum AsyncAction: Equatable { case fetchRequested + case prepareShare(ShareContent) } public enum InnerAction: Equatable { case todayResponse(Result) + case sharePrepared(ShareItem) } nonisolated enum CancelID: Hashable { @@ -66,6 +68,7 @@ public struct BattleFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.analyticsUseCase) private var analyticsUseCase + @Dependency(\.shareUseCase) private var shareUseCase public enum DelegateAction: Equatable { /// 배틀 입장 → 채팅방 진입 @@ -119,17 +122,16 @@ extension BattleFeature { case let .shareTapped(battleId): analyticsUseCase.track(.shareAction(ShareActionData(target: .battle))) guard let battle = state.battles.first(where: { $0.battleId == battleId }) else { return .none } - let text = [ - battle.title, - battle.question, - battle.tags.map { "#\($0)" }.joined(separator: " "), - ] - .filter { !$0.isEmpty } - .joined(separator: "\n\n") - var items: [Any] = [text] - if let urlString = battle.imageURL, let url = URL(string: urlString) { items.append(url) } - state.shareItem = ShareItem(items: items) - return .none + let content = ShareContent( + title: battle.title, + summary: battle.question, + hashtags: battle.tags.map { "#\($0)" }, + optionLine: nil, + url: PickeShareURL.battle(id: battleId), + thumbnailURL: battle.imageURL, + snapshotData: nil + ) + return .send(.async(.prepareShare(content))) case let .optionTapped(battleId, optionId): analyticsUseCase.track(.uiAction(action: .quickBattleOption, screen: .quickBattle)) @@ -165,6 +167,11 @@ extension BattleFeature { return await send(.inner(.todayResponse(result))) } .cancellable(id: CancelID.fetchToday, cancelInFlight: true) + + case let .prepareShare(content): + return .run { [useCase = shareUseCase] send in + await send(.inner(.sharePrepared(useCase.makeShareItem(content)))) + } } } @@ -183,6 +190,10 @@ extension BattleFeature { Log.error("[BattleFeature] fetchTodayBattles failed: \(error.localizedDescription)") } return .none + + case let .sharePrepared(item): + state.shareItem = item + return .none } } } diff --git a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift index 198298b..8462bdd 100644 --- a/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift +++ b/Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift @@ -6,7 +6,6 @@ // import Foundation -import UIKit import BattleDomainInterface import CommonDomainInterface @@ -27,6 +26,20 @@ public struct PreVoteFeature { case post } + /// 공유 카드 렌더 요청 — 아바타 선로드가 끝나면 채워지고, 뷰가 이 값으로 카드를 그린다. + public struct ShareSnapshotRequest: Equatable, Identifiable { + public let id: UUID + public let avatarImageData: [Int: Data] + + public init( + id: UUID = UUID(), + avatarImageData: [Int: Data] + ) { + self.id = id + self.avatarImageData = avatarImageData + } + } + @ObservableState public struct State: Equatable { public var battle: PreVoteBattle? @@ -39,6 +52,7 @@ public struct PreVoteFeature { public var detailLoadFailed: Bool = false public var isSubmitting: Bool = false public var shareItem: ShareItem? + public var shareSnapshotRequest: ShareSnapshotRequest? public var battleId: Int = 0 public var voteMode: VoteMode = .pre public var myPerspective: BattlePerspective? @@ -78,7 +92,8 @@ public struct PreVoteFeature { case onAppear case backButtonTapped case retryTapped - case shareTapped(snapshot: Data?) + case shareTapped + case shareSnapshotRendered(Data?) case optionTapped(optionId: Int) case primaryButtonTapped } @@ -87,6 +102,7 @@ public struct PreVoteFeature { case fetchBattleDetail case fetchMyPerspective case deleteMyPerspective(perspectiveId: Int) + case loadShareAvatars case prepareShare(ShareContent) case submitPreVote(battleId: Int, optionId: Int) case submitPostVote(battleId: Int, optionId: Int) @@ -97,6 +113,7 @@ public struct PreVoteFeature { case myPerspectiveResponse(Result) case deleteMyPerspectiveResponse(Result) case preVoteResponse(Result) + case shareAvatarsLoaded([Int: Data]) case sharePrepared(ShareItem) case postVoteResponse(Result) } @@ -121,6 +138,7 @@ public struct PreVoteFeature { case fetchBattleDetail case fetchMyPerspective case deleteMyPerspective + case loadShareAvatars case submitPreVote case submitPostVote } @@ -128,6 +146,7 @@ public struct PreVoteFeature { @Dependency(\.battleUseCase) private var battleUseCase @Dependency(\.perspectiveUseCase) private var perspectiveUseCase @Dependency(\.analyticsUseCase) private var analyticsUseCase + @Dependency(\.shareUseCase) private var shareUseCase public var body: some Reducer { BindingReducer() @@ -187,39 +206,12 @@ extension PreVoteFeature { case .retryTapped: return .send(.async(.fetchBattleDetail)) - case let .shareTapped(snapshot): - let detail = state.battleDetail - let battle = state.battle - let title = detail?.battleInfo.title ?? battle?.titleLine1 ?? "" - let url = detail?.shareUrl ?? "https://picke.store/battles/\(state.battleId)" - let thumbnailURL = detail?.battleInfo.thumbnailUrl ?? battle?.backgroundImageURL - let summary = { - if let description = detail?.description, !description.isEmpty { return description } - if let infoSummary = detail?.battleInfo.summary, !infoSummary.isEmpty { return infoSummary } - return battle?.summary ?? "" - }() - let hashtags: [String] = { - if let tags = detail?.categoryTags, !tags.isEmpty { - return tags.map { "#\($0.name)" } - } - return battle?.tags ?? [] - }() - let optionLine: String? = { - guard let left = battle?.leftOption.stance, - let right = battle?.rightOption.stance - else { return nil } - return "🆚 A: \(left) vs B: \(right)" - }() - let content = ShareContent( - title: title, - summary: summary, - hashtags: hashtags, - optionLine: optionLine, - url: url, - thumbnailURL: thumbnailURL, - snapshotData: snapshot - ) - return .send(.async(.prepareShare(content))) + case .shareTapped: + return .send(.async(.loadShareAvatars)) + + case let .shareSnapshotRendered(snapshot): + state.shareSnapshotRequest = nil + return .send(.async(.prepareShare(shareContent(state: state, snapshot: snapshot)))) case let .optionTapped(optionId): state.selectedOptionId = (state.selectedOptionId == optionId) ? nil : optionId @@ -238,6 +230,41 @@ extension PreVoteFeature { } } + /// 배틀 상세를 우선하고 프리뷰(battle) 값으로 폴백해 공유 본문을 조립. + private func shareContent(state: State, snapshot: Data?) -> ShareContent { + let detail = state.battleDetail + let battle = state.battle + let title = detail?.battleInfo.title ?? battle?.titleLine1 ?? "" + let url = PickeShareURL.battle(id: state.battleId, serverShareUrl: detail?.shareUrl) + let thumbnailURL = detail?.battleInfo.thumbnailUrl ?? battle?.backgroundImageURL + let summary = { + if let description = detail?.description, !description.isEmpty { return description } + if let infoSummary = detail?.battleInfo.summary, !infoSummary.isEmpty { return infoSummary } + return battle?.summary ?? "" + }() + let hashtags: [String] = { + if let tags = detail?.categoryTags, !tags.isEmpty { + return tags.map { "#\($0.name)" } + } + return battle?.tags ?? [] + }() + let optionLine: String? = { + guard let left = battle?.leftOption.stance, + let right = battle?.rightOption.stance + else { return nil } + return "🆚 A: \(left) vs B: \(right)" + }() + return ShareContent( + title: title, + summary: summary, + hashtags: hashtags, + optionLine: optionLine, + url: url, + thumbnailURL: thumbnailURL, + snapshotData: snapshot + ) + } + private func handleAsyncAction( state: inout State, action: AsyncAction @@ -278,27 +305,26 @@ extension PreVoteFeature { } .cancellable(id: CancelID.deleteMyPerspective, cancelInFlight: true) - case let .prepareShare(content): - return .run { send in - var items: [Any] = [content.displayText] - - if let url = URL(string: content.url) { - items.append(url) - } else { - items.append(content.url) - } - - if let data = content.snapshotData, let image = UIImage(data: data) { - items.append(image) - } else if let thumbnailURL = content.thumbnailURL, - let remoteURL = URL(string: thumbnailURL), - let (data, _) = try? await URLSession.shared.data(from: remoteURL), - let image = UIImage(data: data) - { - items.append(image) + case .loadShareAvatars: + guard let battle = state.battle else { + return .send(.async(.prepareShare(shareContent(state: state, snapshot: nil)))) + } + + let avatars = [battle.leftOption, battle.rightOption].map { ($0.optionId, $0.imageURL) } + + return .run { [useCase = shareUseCase] send in + var imageData: [Int: Data] = [:] + + for (optionId, imageURL) in avatars { + imageData[optionId] = await useCase.loadImageData(imageURL) } + await send(.inner(.shareAvatarsLoaded(imageData))) + } + .cancellable(id: CancelID.loadShareAvatars, cancelInFlight: true) - await send(.inner(.sharePrepared(ShareItem(items: items)))) + case let .prepareShare(content): + return .run { [useCase = shareUseCase] send in + await send(.inner(.sharePrepared(useCase.makeShareItem(content)))) } case let .submitPreVote(battleId, optionId): @@ -382,6 +408,10 @@ extension PreVoteFeature { return .none } + case let .shareAvatarsLoaded(imageData): + state.shareSnapshotRequest = ShareSnapshotRequest(avatarImageData: imageData) + return .none + case let .sharePrepared(item): state.shareItem = item return .none diff --git a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift index a5ad926..791e880 100644 --- a/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift +++ b/Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift @@ -51,6 +51,10 @@ public struct PreVoteView: View { } } .onAppear { send(.onAppear) } + .onChange(of: store.shareSnapshotRequest) { _, request in + guard let request else { return } + send(.shareSnapshotRendered(captureCardSnapshot(request))) + } .sheet(item: $store.shareItem) { item in ShareSheet(items: item.items) .presentationDetents([.fraction(0.5)]) @@ -178,7 +182,7 @@ extension PreVoteView { Spacer() Button { - send(.shareTapped(snapshot: captureCardSnapshot())) + send(.shareTapped) } label: { Image(systemName: "square.and.arrow.up") .font(.system(size: 24, weight: .regular)) @@ -225,11 +229,14 @@ extension PreVoteView { } @ViewBuilder - private func contentSection(_ battle: PreVoteBattle) -> some View { + private func contentSection( + _ battle: PreVoteBattle, + titleColorOverride: Color? = nil + ) -> some View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 20) { tagsRow(battle) - titleText(battle) + titleText(battle, colorOverride: titleColorOverride) } summaryText(battle) } @@ -247,10 +254,13 @@ extension PreVoteView { } @ViewBuilder - private func titleText(_ battle: PreVoteBattle) -> some View { + private func titleText( + _ battle: PreVoteBattle, + colorOverride: Color? = nil + ) -> some View { Text([battle.titleLine1, battle.titleLine2].filter { !$0.isEmpty }.joined(separator: "\n")) .pretendardFont(.bold24) - .foregroundStyle(titleColor) + .foregroundStyle(colorOverride ?? titleColor) .kerning(-0.6) .lineSpacing(24 * 0.4) .multilineTextAlignment(.leading) @@ -274,11 +284,14 @@ extension PreVoteView { extension PreVoteView { @ViewBuilder - private func optionSection(_ battle: PreVoteBattle) -> some View { + private func optionSection( + _ battle: PreVoteBattle, + avatarOverrides: [Int: UIImage] = [:] + ) -> some View { ZStack { HStack(spacing: 8) { - optionCard(battle.leftOption) - optionCard(battle.rightOption) + optionCard(battle.leftOption, avatarOverride: avatarOverrides[battle.leftOption.optionId]) + optionCard(battle.rightOption, avatarOverride: avatarOverrides[battle.rightOption.optionId]) } .frame(maxWidth: .infinity) vsBadge() @@ -286,14 +299,17 @@ extension PreVoteView { } @ViewBuilder - private func optionCard(_ option: PreVoteOption) -> some View { + private func optionCard( + _ option: PreVoteOption, + avatarOverride: UIImage? = nil + ) -> some View { let isSelected = store.selectedOptionId == option.optionId return Button { send(.optionTapped(optionId: option.optionId)) } label: { VStack(spacing: 12) { - avatarView(imageURL: option.imageURL) + avatarView(imageURL: option.imageURL, override: avatarOverride) VStack(spacing: 2) { Text(option.stance) @@ -321,17 +337,27 @@ extension PreVoteView { .buttonStyle(.plain) } - private func avatarView(imageURL: String) -> some View { - KFImage(URL(string: imageURL)) - .placeholder { - SkeletonView() - .frame(width: 28, height: 20) + @ViewBuilder + private func avatarView(imageURL: String, override: UIImage? = nil) -> some View { + Group { + if let override { + // 공유 스냅샷: 사전 로드된 이미지를 동기 렌더. + Image(uiImage: override) + .resizable() + .scaledToFit() + } else { + KFImage(URL(string: imageURL)) + .placeholder { + SkeletonView() + .frame(width: 28, height: 20) + } + .resizable() + .scaledToFit() } - .resizable() - .scaledToFit() - .frame(width: 28, height: 20) - .frame(width: 40, height: 40) - .background(.beige600, in: Circle()) + } + .frame(width: 28, height: 20) + .frame(width: 40, height: 40) + .background(.beige600, in: Circle()) } @ViewBuilder @@ -360,18 +386,24 @@ extension PreVoteView { extension PreVoteView { @MainActor - private func captureCardSnapshot() -> Data? { + private func captureCardSnapshot(_ request: PreVoteFeature.ShareSnapshotRequest) -> Data? { guard let battle = store.battle else { return nil } - let renderer = ImageRenderer(content: shareSnapshotCard(battle)) + let avatars = request.avatarImageData.compactMapValues(UIImage.init(data:)) + let renderer = ImageRenderer(content: shareSnapshotCard(battle, avatarOverrides: avatars)) renderer.scale = UIScreen.main.scale return renderer.uiImage?.pngData() } @ViewBuilder - private func shareSnapshotCard(_ battle: PreVoteBattle) -> some View { + private func shareSnapshotCard( + _ battle: PreVoteBattle, + avatarOverrides: [Int: UIImage] + ) -> some View { VStack(spacing: PreVoteLayout.contentToOptionSpacing) { - contentSection(battle) - optionSection(battle) + // 스냅샷 배경은 모드와 무관하게 밝은색이라, 사후(post) 화면의 밝은 제목색을 + // 그대로 쓰면 베이지 위 베이지로 묻힌다 → 항상 어두운 제목색으로 고정. + contentSection(battle, titleColorOverride: .neutral500) + optionSection(battle, avatarOverrides: avatarOverrides) } .padding(16) .frame(width: PreVoteLayout.snapshotWidth)