Skip to content
Open
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)")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Foundation
import UIKit

import ComposableArchitecture
import Entity
Expand Down Expand Up @@ -54,10 +55,12 @@ public struct BattleFeature {

public enum AsyncAction: Equatable {
case fetchRequested
case prepareShare(text: String, url: String, imageURL: String?)
}

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

nonisolated enum CancelID: Hashable {
Expand Down Expand Up @@ -126,10 +129,11 @@ extension BattleFeature {
]
.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
return .send(.async(.prepareShare(
text: text,
url: PickeShareURL.battle(id: battleId),
imageURL: battle.imageURL
)))

case let .optionTapped(battleId, optionId):
analyticsUseCase.track(.uiAction(action: .quickBattleOption, screen: .quickBattle))
Expand Down Expand Up @@ -165,6 +169,22 @@ extension BattleFeature {
return await send(.inner(.todayResponse(result)))
}
.cancellable(id: CancelID.fetchToday, cancelInFlight: true)

case let .prepareShare(text, url, imageURL):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이걸 좀더 더 나은 방식으로 할수 없을까여?

return .run { send in
var items: [Any] = [text]
if let linkURL = URL(string: url) {
items.append(linkURL)
}
if let imageURL,
let remoteURL = URL(string: imageURL),
let (data, _) = try? await URLSession.shared.data(from: remoteURL),
let image = UIImage(data: data)
{
items.append(image)
}
await send(.inner(.sharePrepared(ShareItem(items: items))))
}
}
}

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

case let .sharePrepared(item):
state.shareItem = item
return .none
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ extension PreVoteFeature {
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 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 }
Expand Down
106 changes: 81 additions & 25 deletions Projects/Presentation/Chat/Sources/Vote/View/PreVoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ extension PreVoteView {
Spacer()

Button {
send(.shareTapped(snapshot: captureCardSnapshot()))
shareWithSnapshot()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 tca action 으로 할수는 없나요 ??

} label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 24, weight: .regular))
Expand Down Expand Up @@ -225,11 +225,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)
}
Expand All @@ -247,10 +250,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)
Expand All @@ -274,26 +280,32 @@ 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()
}
}

@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)
Expand Down Expand Up @@ -321,17 +333,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
Expand Down Expand Up @@ -359,19 +381,53 @@ extension PreVoteView {
// MARK: - Share snapshot

extension PreVoteView {
/// 공유 트리거 — 옵션 아바타(철학자) 이미지를 먼저 비동기 로드한 뒤 카드 스냅샷을 렌더한다.
/// KFImage 는 ImageRenderer(동기 렌더) 에서 로드 전이라 빈 원으로 캡처되므로,
/// Kingfisher 로 미리 받아 `avatarOverrides` 로 주입해 동기 렌더한다. (RecapView 와 동일 패턴)
private func shareWithSnapshot() {
Task { @MainActor in
let avatars = await loadOptionAvatarImages()
send(.shareTapped(snapshot: captureCardSnapshot(avatarOverrides: avatars)))
}
}

/// 좌/우 옵션의 원격 아바타를 Kingfisher 로 선로드 (실패한 쪽은 제외 → KFImage 폴백).
@MainActor
private func captureCardSnapshot() -> Data? {
private func loadOptionAvatarImages() async -> [Int: UIImage] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 로직은 뷰에서 빼줘 야 할거 같습니다 ...

guard let battle = store.battle else { return [:] }
var images: [Int: UIImage] = [:]
for option in [battle.leftOption, battle.rightOption] {
guard let url = URL(string: option.imageURL) else { continue }
let image: UIImage? = await withCheckedContinuation { continuation in

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일것도 uscase 또는 내부에 서 해야 할거 같습니다

KingfisherManager.shared.retrieveImage(with: url) { result in
continuation.resume(returning: try? result.get().image)
}
}
if let image {
images[option.optionId] = image
}
}
return images
}

@MainActor
private func captureCardSnapshot(avatarOverrides: [Int: UIImage]) -> Data? {
guard let battle = store.battle else { return nil }
let renderer = ImageRenderer(content: shareSnapshotCard(battle))
let renderer = ImageRenderer(content: shareSnapshotCard(battle, avatarOverrides: avatarOverrides))
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)
Expand Down
Loading