Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Projects/Domain/Entity/Sources/Share/PickeShareURL.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
74 changes: 74 additions & 0 deletions Projects/Domain/UseCase/Sources/Share/ShareUseCase.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ public struct BattleFeature {

public enum AsyncAction: Equatable {
case fetchRequested
case prepareShare(ShareContent)
}

public enum InnerAction: Equatable {
case todayResponse(Result<TodayBattlePage, BattleError>)
case sharePrepared(ShareItem)
}

nonisolated enum CancelID: Hashable {
Expand All @@ -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 {
/// 배틀 입장 → 채팅방 진입
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))))
}
}
}

Expand All @@ -183,6 +190,10 @@ extension BattleFeature {
Log.error("[BattleFeature] fetchTodayBattles failed: \(error.localizedDescription)")
}
return .none

case let .sharePrepared(item):
state.shareItem = item
return .none
}
}
}
138 changes: 84 additions & 54 deletions Projects/Presentation/Chat/Sources/Vote/Reducer/PreVoteFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
//

import Foundation
import UIKit

import BattleDomainInterface
import CommonDomainInterface
Expand All @@ -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?
Expand All @@ -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?
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand All @@ -97,6 +113,7 @@ public struct PreVoteFeature {
case myPerspectiveResponse(Result<BattlePerspective?, BattleError>)
case deleteMyPerspectiveResponse(Result<EmptyResult, PerspectiveError>)
case preVoteResponse(Result<PreVoteResult, BattleError>)
case shareAvatarsLoaded([Int: Data])
case sharePrepared(ShareItem)
case postVoteResponse(Result<PreVoteResult, BattleError>)
}
Expand All @@ -121,13 +138,15 @@ public struct PreVoteFeature {
case fetchBattleDetail
case fetchMyPerspective
case deleteMyPerspective
case loadShareAvatars
case submitPreVote
case submitPostVote
}

@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<State, Action> {
BindingReducer()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading