feat(chat): 채팅 쿼터 광고 충전을 추가한다
This commit is contained in:
@@ -55,8 +55,17 @@ class ChatRoomRepository {
|
|||||||
return talkApi.requestPublisher(.getChatQuotaStatus(roomId: roomId))
|
return talkApi.requestPublisher(.getChatQuotaStatus(roomId: roomId))
|
||||||
}
|
}
|
||||||
|
|
||||||
func purchaseChatQuota(roomId: Int) -> AnyPublisher<Response, MoyaError> {
|
func purchaseChatQuota(
|
||||||
return talkApi.requestPublisher(.purchaseChatQuota(roomId: roomId, request: ChatQuotaPurchaseRequest()))
|
roomId: Int,
|
||||||
|
chargeType: ChatRoomQuotaChargeType = .can,
|
||||||
|
canOption: ChatRoomQuotaCanOption? = nil
|
||||||
|
) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
return talkApi.requestPublisher(
|
||||||
|
.purchaseChatQuota(
|
||||||
|
roomId: roomId,
|
||||||
|
request: ChatQuotaPurchaseRequest(chargeType: chargeType, canOption: canOption)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetChatRoom(roomId: Int) -> AnyPublisher<Response, MoyaError> {
|
func resetChatRoom(roomId: Int) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
|||||||
@@ -148,9 +148,17 @@ struct ChatRoomView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.showQuotaNoticeView {
|
if viewModel.showQuotaNoticeView {
|
||||||
ChatQuotaNoticeItemView(remainingTime: viewModel.countdownText) {
|
ChatQuotaNoticeItemView(
|
||||||
viewModel.purchaseChatQuota()
|
onSelectAd: {
|
||||||
}
|
viewModel.showRewardedAdForChatQuota()
|
||||||
|
},
|
||||||
|
onSelectCan10: {
|
||||||
|
viewModel.purchaseChatQuota(canOption: .can10)
|
||||||
|
},
|
||||||
|
onSelectCan20: {
|
||||||
|
viewModel.purchaseChatQuota(canOption: .can20)
|
||||||
|
}
|
||||||
|
)
|
||||||
.id("quota_\(viewModel.messages.count)")
|
.id("quota_\(viewModel.messages.count)")
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -309,9 +317,6 @@ struct ChatRoomView: View {
|
|||||||
viewModel.getMemberInfo()
|
viewModel.getMemberInfo()
|
||||||
viewModel.enterRoom(roomId: roomId)
|
viewModel.enterRoom(roomId: roomId)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
|
||||||
viewModel.stopTimer()
|
|
||||||
}
|
|
||||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published private(set) var countdownText: String = "00:00:00"
|
@Published private(set) var totalRemaining: Int = 0
|
||||||
@Published private(set) var showQuotaNoticeView: Bool = false
|
@Published private(set) var showQuotaNoticeView: Bool = false
|
||||||
|
|
||||||
@Published private(set) var showSendingMessage: Bool = false
|
@Published private(set) var showSendingMessage: Bool = false
|
||||||
@@ -72,8 +72,6 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
private var hasMoreMessages: Bool = true
|
private var hasMoreMessages: Bool = true
|
||||||
private var nextCursor: Int64? = nil
|
private var nextCursor: Int64? = nil
|
||||||
|
|
||||||
private var timer: Timer?
|
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
func sendMessage() {
|
func sendMessage() {
|
||||||
guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
guard !messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
@@ -125,7 +123,7 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
let decoded = try jsonDecoder.decode(ApiResponse<SendChatMessageResponse>.self, from: responseData)
|
let decoded = try jsonDecoder.decode(ApiResponse<SendChatMessageResponse>.self, from: responseData)
|
||||||
if let data = decoded.data, decoded.success {
|
if let data = decoded.data, decoded.success {
|
||||||
self.messages.append(contentsOf: data.messages)
|
self.messages.append(contentsOf: data.messages)
|
||||||
self.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
self.updateQuota(totalRemaining: data.totalRemaining)
|
||||||
} else {
|
} else {
|
||||||
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
self.errorMessage = decoded.message ?? I18n.Common.commonError
|
||||||
self.isShowPopup = true
|
self.isShowPopup = true
|
||||||
@@ -177,7 +175,7 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
self?.hasMoreMessages = data.hasMoreMessages
|
self?.hasMoreMessages = data.hasMoreMessages
|
||||||
self?.nextCursor = data.messages.last?.messageId
|
self?.nextCursor = data.messages.last?.messageId
|
||||||
|
|
||||||
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
self?.updateQuota(totalRemaining: data.totalRemaining)
|
||||||
} else {
|
} else {
|
||||||
if let message = decoded.message {
|
if let message = decoded.message {
|
||||||
self?.errorMessage = message
|
self?.errorMessage = message
|
||||||
@@ -275,10 +273,25 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
.store(in: &subscription)
|
.store(in: &subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func purchaseChatQuota() {
|
func purchaseChatQuota(canOption: ChatRoomQuotaCanOption) {
|
||||||
|
purchaseChatQuota(chargeType: .can, canOption: canOption)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showRewardedAdForChatQuota() {
|
||||||
|
_Concurrency.Task {
|
||||||
|
await YandexRewardedAdManager.shared.showAdIfAvailable(for: .chatRoomQuota) { [weak self] in
|
||||||
|
self?.purchaseChatQuota(chargeType: .ad, canOption: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func purchaseChatQuota(
|
||||||
|
chargeType: ChatRoomQuotaChargeType,
|
||||||
|
canOption: ChatRoomQuotaCanOption?
|
||||||
|
) {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
||||||
repository.purchaseChatQuota(roomId: roomId)
|
repository.purchaseChatQuota(roomId: roomId, chargeType: chargeType, canOption: canOption)
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { result in
|
.sink { result in
|
||||||
switch result {
|
switch result {
|
||||||
@@ -295,10 +308,12 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
|
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
|
||||||
|
|
||||||
if let data = decoded.data, decoded.success {
|
if let data = decoded.data, decoded.success {
|
||||||
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
self?.updateQuota(totalRemaining: data.totalRemaining)
|
||||||
|
|
||||||
let can = UserDefaults.int(forKey: .can)
|
if let canOption {
|
||||||
UserDefaults.set(can - 30, forKey: .can)
|
let can = UserDefaults.int(forKey: .can)
|
||||||
|
UserDefaults.set(can - canOption.needCan, forKey: .can)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if let message = decoded.message {
|
if let message = decoded.message {
|
||||||
self?.errorMessage = message
|
self?.errorMessage = message
|
||||||
@@ -385,7 +400,7 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
chatRoomBgImageUrl = nil
|
chatRoomBgImageUrl = nil
|
||||||
roomId = 0
|
roomId = 0
|
||||||
|
|
||||||
countdownText = "00:00:00"
|
totalRemaining = 0
|
||||||
showQuotaNoticeView = false
|
showQuotaNoticeView = false
|
||||||
|
|
||||||
showSendingMessage = false
|
showSendingMessage = false
|
||||||
@@ -421,7 +436,7 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
|
let decoded = try jsonDecoder.decode(ApiResponse<ChatQuotaStatusResponse>.self, from: responseData)
|
||||||
|
|
||||||
if let data = decoded.data, decoded.success {
|
if let data = decoded.data, decoded.success {
|
||||||
self?.updateQuota(nextRechargeAtEpoch: data.nextRechargeAtEpoch)
|
self?.updateQuota(totalRemaining: data.totalRemaining)
|
||||||
} else {
|
} else {
|
||||||
if let message = decoded.message {
|
if let message = decoded.message {
|
||||||
self?.errorMessage = message
|
self?.errorMessage = message
|
||||||
@@ -442,78 +457,18 @@ final class ChatRoomViewModel: ObservableObject {
|
|||||||
.store(in: &subscription)
|
.store(in: &subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateQuota(nextRechargeAtEpoch: Int64?) {
|
private func updateQuota(totalRemaining: Int) {
|
||||||
isLoading = true
|
self.totalRemaining = totalRemaining
|
||||||
stopTimer()
|
showQuotaNoticeView = totalRemaining <= 0
|
||||||
|
prepareRewardedAdIfNeeded(totalRemaining: totalRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
// epoch 없음 → 카운트다운 비표시
|
private func prepareRewardedAdIfNeeded(totalRemaining: Int) {
|
||||||
guard let nextRechargeAtEpoch else {
|
guard totalRemaining <= 1 else { return }
|
||||||
countdownText = "00:00:00"
|
|
||||||
showQuotaNoticeView = false
|
_Concurrency.Task {
|
||||||
isLoading = false
|
await YandexRewardedAdManager.shared.preloadAd(for: .chatRoomQuota)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 즉시 1회 갱신
|
|
||||||
let remainMs = remainingMs(to: nextRechargeAtEpoch)
|
|
||||||
updateCountdownText(remainMs)
|
|
||||||
|
|
||||||
// 이미 0이면 종료 처리
|
|
||||||
guard remainMs > 0 else {
|
|
||||||
checkQuotaStatus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
|
||||||
showQuotaNoticeView = true
|
|
||||||
|
|
||||||
// 타이머 시작 (1초마다 갱신)
|
|
||||||
startTimer(targetEpoch: nextRechargeAtEpoch)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateCountdownText(_ remainMs: Int64) {
|
|
||||||
countdownText = remainMs > 0 ? formatMillisToHms(remainMs) : "00:00:00"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startTimer(targetEpoch: Int64) {
|
|
||||||
stopTimer()
|
|
||||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
let remain = self.remainingMs(to: targetEpoch)
|
|
||||||
self.updateCountdownText(remain)
|
|
||||||
if remain == 0 {
|
|
||||||
self.stopTimer()
|
|
||||||
self.checkQuotaStatus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let t = timer { RunLoop.main.add(t, forMode: .common) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopTimer() {
|
|
||||||
timer?.invalidate()
|
|
||||||
timer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func remainingMs(to epoch: Int64) -> Int64 {
|
|
||||||
let ms = normalizeToMs(epoch)
|
|
||||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
let fudgeMs: Int64 = 5000
|
|
||||||
|
|
||||||
// Kotlin 로직과 동일하게 표시 보정 적용
|
|
||||||
return max(ms - nowMs + fudgeMs, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 초 단위/밀리초 단위 혼용 대비
|
|
||||||
private func normalizeToMs(_ epoch: Int64) -> Int64 {
|
|
||||||
epoch < 1_000_000_000_000 ? epoch * 1000 : epoch
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatMillisToHms(_ ms: Int64) -> String {
|
|
||||||
let total = ms / 1000
|
|
||||||
let h = total / 3600
|
|
||||||
let m = (total % 3600) / 60
|
|
||||||
let s = total % 60
|
|
||||||
return String(format: "%02d:%02d:%02d", h, m, s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getSavedBackgroundImageId() -> Int? {
|
private func getSavedBackgroundImageId() -> Int? {
|
||||||
|
|||||||
@@ -9,57 +9,79 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ChatQuotaNoticeItemView: View {
|
struct ChatQuotaNoticeItemView: View {
|
||||||
|
|
||||||
let remainingTime: String
|
let onSelectAd: () -> Void
|
||||||
let purchase: () -> Void
|
let onSelectCan10: () -> Void
|
||||||
|
let onSelectCan20: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
VStack(spacing: 8) {
|
Button {
|
||||||
Image("ic_time")
|
onSelectAd()
|
||||||
.resizable()
|
} label: {
|
||||||
.frame(width: 30, height: 30)
|
Text(I18n.Chat.Room.quotaAdAction(chatCount: 5))
|
||||||
|
|
||||||
Text(remainingTime)
|
|
||||||
.appFont(size: 18, weight: .bold)
|
.appFont(size: 18, weight: .bold)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(Color(hex: "263238"))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
Text(I18n.Chat.Room.quotaWaitForFreeNotice)
|
.padding(.vertical, 14)
|
||||||
.appFont(size: 18, weight: .bold)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 15)
|
.background(Color(hex: "FEF8E3"))
|
||||||
.background(Color(hex: "EC8280"))
|
|
||||||
.cornerRadius(10)
|
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image("ic_can")
|
|
||||||
|
|
||||||
Text("10")
|
|
||||||
.appFont(size: 24, weight: .bold)
|
|
||||||
.foregroundColor(Color(hex: "263238"))
|
|
||||||
|
|
||||||
Text(I18n.Chat.Room.quotaPurchaseAction(chatCount: 12))
|
|
||||||
.appFont(size: 24, weight: .bold)
|
|
||||||
.foregroundColor(Color(hex: "263238"))
|
|
||||||
.padding(.leading, 4)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(hex: "B5E7FA"))
|
|
||||||
.cornerRadius(30)
|
.cornerRadius(30)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 30)
|
RoundedRectangle(cornerRadius: 30)
|
||||||
.stroke(lineWidth: 1)
|
.stroke(lineWidth: 1)
|
||||||
.foregroundColor(Color.button)
|
.foregroundColor(Color(hex: "F7CB50"))
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.buttonStyle(.plain)
|
||||||
purchase()
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
onSelectCan10()
|
||||||
|
} label: {
|
||||||
|
canButtonLabel(can: 10, chatCount: 15)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
onSelectCan20()
|
||||||
|
} label: {
|
||||||
|
canButtonLabel(can: 20, chatCount: 40)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func canButtonLabel(can: Int, chatCount: Int) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image("ic_can")
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("\(can)")
|
||||||
|
.appFont(size: 20, weight: .bold)
|
||||||
|
.foregroundColor(Color(hex: "263238"))
|
||||||
|
|
||||||
|
Text(" / \(I18n.Chat.Room.quotaChatCount(chatCount))")
|
||||||
|
.appFont(size: 20, weight: .medium)
|
||||||
|
.foregroundColor(Color(hex: "263238"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Color(hex: "B5E7FA"))
|
||||||
|
.cornerRadius(30)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 30)
|
||||||
|
.stroke(lineWidth: 1)
|
||||||
|
.foregroundColor(Color.button)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ChatQuotaNoticeItemView(remainingTime: "05:59:55") {}
|
ChatQuotaNoticeItemView(
|
||||||
|
onSelectAd: {},
|
||||||
|
onSelectCan10: {},
|
||||||
|
onSelectCan20: {}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,4 +7,42 @@
|
|||||||
|
|
||||||
struct ChatQuotaPurchaseRequest: Encodable {
|
struct ChatQuotaPurchaseRequest: Encodable {
|
||||||
let container: String = "ios"
|
let container: String = "ios"
|
||||||
|
let chargeType: ChatRoomQuotaChargeType
|
||||||
|
let canOption: ChatRoomQuotaCanOption?
|
||||||
|
|
||||||
|
init(
|
||||||
|
chargeType: ChatRoomQuotaChargeType = .can,
|
||||||
|
canOption: ChatRoomQuotaCanOption? = nil
|
||||||
|
) {
|
||||||
|
self.chargeType = chargeType
|
||||||
|
self.canOption = canOption
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatRoomQuotaChargeType: String, Encodable {
|
||||||
|
case can = "CAN"
|
||||||
|
case ad = "AD"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatRoomQuotaCanOption: String, Encodable {
|
||||||
|
case can10 = "CAN_10"
|
||||||
|
case can20 = "CAN_20"
|
||||||
|
|
||||||
|
var needCan: Int {
|
||||||
|
switch self {
|
||||||
|
case .can10:
|
||||||
|
return 10
|
||||||
|
case .can20:
|
||||||
|
return 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var quota: Int {
|
||||||
|
switch self {
|
||||||
|
case .can10:
|
||||||
|
return 15
|
||||||
|
case .can20:
|
||||||
|
return 40
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ enum YandexInterstitialPlacement {
|
|||||||
case contentDetail
|
case contentDetail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum YandexRewardedPlacement {
|
||||||
|
case chatRoomQuota
|
||||||
|
}
|
||||||
|
|
||||||
enum YandexAdUnitIdProvider {
|
enum YandexAdUnitIdProvider {
|
||||||
|
|
||||||
static func banner(for placement: YandexBannerPlacement) -> String {
|
static func banner(for placement: YandexBannerPlacement) -> String {
|
||||||
@@ -64,6 +68,13 @@ enum YandexAdUnitIdProvider {
|
|||||||
YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID
|
YANDEX_CONTENT_DETAIL_INTERSTITIAL_AD_UNIT_ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func rewarded(for placement: YandexRewardedPlacement) -> String {
|
||||||
|
switch placement {
|
||||||
|
case .chatRoomQuota:
|
||||||
|
YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct YandexInlineBannerView: View {
|
struct YandexInlineBannerView: View {
|
||||||
@@ -303,3 +314,128 @@ extension YandexInterstitialAdManager: InterstitialAdDelegate {
|
|||||||
completePendingAction()
|
completePendingAction()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class YandexRewardedAdManager: NSObject {
|
||||||
|
|
||||||
|
static let shared = YandexRewardedAdManager()
|
||||||
|
|
||||||
|
private var rewardedAd: RewardedAd?
|
||||||
|
private var rewardedAdLoader: RewardedAdLoader?
|
||||||
|
private var currentPlacement: YandexRewardedPlacement?
|
||||||
|
private var pendingRewardAction: (@MainActor () -> Void)?
|
||||||
|
private var rewardedPlacement: YandexRewardedPlacement?
|
||||||
|
private var isLoading = false
|
||||||
|
|
||||||
|
func preloadAd(for placement: YandexRewardedPlacement) {
|
||||||
|
guard !isLoading else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentPlacement == placement, rewardedAd != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let loader = RewardedAdLoader()
|
||||||
|
rewardedAdLoader = loader
|
||||||
|
rewardedAd = nil
|
||||||
|
currentPlacement = placement
|
||||||
|
isLoading = true
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let loadedAd = try await loader.loadAd(with: AdRequest(adUnitID: YandexAdUnitIdProvider.rewarded(for: placement)))
|
||||||
|
guard currentPlacement == placement else {
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedAd.delegate = self
|
||||||
|
rewardedAd = loadedAd
|
||||||
|
} catch {
|
||||||
|
if currentPlacement == placement {
|
||||||
|
rewardedAd = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentPlacement == placement {
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAdIfAvailable(for placement: YandexRewardedPlacement, onReward: @escaping @MainActor () -> Void) -> Bool {
|
||||||
|
guard let presenter = presentingViewController(), let rewardedAd, currentPlacement == placement else {
|
||||||
|
preloadAd(for: placement)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRewardAction = onReward
|
||||||
|
rewardedPlacement = placement
|
||||||
|
rewardedAd.show(from: presenter)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeRewardIfNeeded() {
|
||||||
|
let action = pendingRewardAction
|
||||||
|
pendingRewardAction = nil
|
||||||
|
action?()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetAndPreload() {
|
||||||
|
pendingRewardAction = nil
|
||||||
|
rewardedAd = nil
|
||||||
|
|
||||||
|
if let rewardedPlacement {
|
||||||
|
self.rewardedPlacement = nil
|
||||||
|
preloadAd(for: rewardedPlacement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentingViewController() -> UIViewController? {
|
||||||
|
guard
|
||||||
|
let rootViewController = UIApplication.shared.connectedScenes
|
||||||
|
.compactMap({ $0 as? UIWindowScene })
|
||||||
|
.flatMap({ $0.windows })
|
||||||
|
.first(where: { $0.isKeyWindow })?
|
||||||
|
.rootViewController
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var topViewController = rootViewController
|
||||||
|
|
||||||
|
while let presentedViewController = topViewController.presentedViewController {
|
||||||
|
topViewController = presentedViewController
|
||||||
|
}
|
||||||
|
|
||||||
|
return topViewController
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension YandexRewardedAdManager: RewardedAdDelegate {
|
||||||
|
|
||||||
|
func rewardedAd(_ rewardedAd: RewardedAd, didReward reward: Reward) {
|
||||||
|
DEBUG_LOG("리워드 광고 보상 받기 성공")
|
||||||
|
completeRewardIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewardedAd(_ rewardedAd: RewardedAd, didFailToShow error: Error) {
|
||||||
|
DEBUG_LOG("리워드 광고 에러")
|
||||||
|
resetAndPreload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewardedAdDidShow(_ rewardedAd: RewardedAd) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewardedAdDidDismiss(_ rewardedAd: RewardedAd) {
|
||||||
|
DEBUG_LOG("리워드 광고 닫기")
|
||||||
|
resetAndPreload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewardedAdDidClick(_ rewardedAd: RewardedAd) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewardedAd(_ rewardedAd: RewardedAd, didTrackImpression impressionData: ImpressionData?) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ let YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID = "R-M-19140297-12"
|
|||||||
let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19140297-13"
|
let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19140297-13"
|
||||||
let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-14"
|
let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-14"
|
||||||
let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-15"
|
let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19140297-15"
|
||||||
|
let YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID = "R-M-19140297-16"
|
||||||
|
|||||||
@@ -262,15 +262,19 @@ enum I18n {
|
|||||||
pick(ko: "입력 중", en: "Typing", ja: "入力中")
|
pick(ko: "입력 중", en: "Typing", ja: "入力中")
|
||||||
}
|
}
|
||||||
|
|
||||||
static var quotaWaitForFreeNotice: String {
|
static func quotaAdAction(chatCount: Int) -> String {
|
||||||
pick(ko: "기다리면 무료 이용이 가능합니다.", en: "Wait for free usage.", ja: "待てば無料で利用できます。")
|
pick(
|
||||||
|
ko: "광고 / \(chatCount)채팅",
|
||||||
|
en: "Ad / \(chatCount) chats",
|
||||||
|
ja: "広告 / \(chatCount)チャット"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func quotaPurchaseAction(chatCount: Int) -> String {
|
static func quotaChatCount(_ chatCount: Int) -> String {
|
||||||
pick(
|
pick(
|
||||||
ko: "(채팅 \(chatCount)개) 바로 대화 시작",
|
ko: "\(chatCount)채팅",
|
||||||
en: "(\(chatCount) chats) Start now",
|
en: "\(chatCount) chats",
|
||||||
ja: "(チャット\(chatCount)件) すぐに会話開始"
|
ja: "\(chatCount)チャット"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,3 +46,4 @@ let YANDEX_NOTIFICATION_RECEIVE_SETTINGS_BANNER_AD_UNIT_ID = "R-M-19157621-10"
|
|||||||
let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19157621-11"
|
let YANDEX_CHAT_CHARACTER_LIST_BANNER_AD_UNIT_ID = "R-M-19157621-11"
|
||||||
let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-12"
|
let YANDEX_CHAT_ORIGINAL_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-12"
|
||||||
let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-13"
|
let YANDEX_CHAT_TALK_TAB_TOP_BANNER_AD_UNIT_ID = "R-M-19157621-13"
|
||||||
|
let YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID = "R-M-19157621-14"
|
||||||
|
|||||||
119
docs/20260430_채팅쿼터충전확장.md
Normal file
119
docs/20260430_채팅쿼터충전확장.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# 20260430 채팅 쿼터 충전 확장
|
||||||
|
|
||||||
|
## 작업 체크리스트
|
||||||
|
- [x] `ChatRoomViewModel`의 쿼터 안내 표시 기준을 `nextRechargeAtEpoch` null 여부에서 `totalRemaining <= 0` 기준으로 전환한다.
|
||||||
|
- [x] 무료 충전이 없어진 정책에 맞춰 채팅방 쿼터 카운트다운/무료 대기 관련 상태와 타이머 로직을 제거한다.
|
||||||
|
- [x] `ChatQuotaNoticeItemView`를 2단 구성으로 재작성하고, 광고 버튼 1개 + 캔 구매 버튼 2개 UI를 요구사항 스펙에 맞게 반영한다.
|
||||||
|
- [x] 채팅 쿼터 구매 요청 DTO를 `CAN`/`AD` 충전 타입과 캔 옵션을 전달할 수 있도록 확장한다.
|
||||||
|
- [x] `ChatRoomQuotaChargeType`, `ChatRoomQuotaCanOption` 모델을 iOS 코드베이스 규칙에 맞게 추가한다.
|
||||||
|
- [x] 채팅방 쿼터 구매 흐름을 광고 보상 충전과 캔 옵션 충전으로 분기한다.
|
||||||
|
- [x] `totalRemaining <= 1`일 때 채팅 쿼터 전용 Yandex rewarded 광고를 준비하도록 채팅방 진입/상태 갱신 흐름을 확장한다.
|
||||||
|
- [x] 광고 버튼 탭 시 Yandex rewarded 광고를 표시하고, 리워드 지급 가능 시점에만 쿼터 충전 API를 호출하도록 연결한다.
|
||||||
|
- [x] 채팅방 쿼터 관련 문자열/I18n 사용 여부를 정리하고, 제거/추가가 필요한 문구를 반영한다.
|
||||||
|
- [x] 수정 파일 진단과 빌드를 실행하고 결과를 검증 기록에 남긴다.
|
||||||
|
|
||||||
|
## 작업 기준
|
||||||
|
|
||||||
|
- 사용자 요청 대상 화면:
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift`
|
||||||
|
- 현재 쿼터 상태/로직:
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaStatusResponse.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/Enter/ChatRoomEnterResponse.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/Message/SendChatMessageResponse.swift`
|
||||||
|
- 쿼터 구매 API/DTO:
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomRepository.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/TalkApi.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift`
|
||||||
|
- 광고 공용 지원:
|
||||||
|
- `SodaLive/Sources/Common/YandexAdSupport.swift`
|
||||||
|
- `SodaLive/Sources/App/AppDelegate.swift`
|
||||||
|
- 문자열 경로:
|
||||||
|
- `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- 공식 문서:
|
||||||
|
- `https://ads.yandex.com/helpcenter/en/dev/ios/rewarded`
|
||||||
|
|
||||||
|
## QA 기준
|
||||||
|
|
||||||
|
- `ChatRoomViewModel`은 `nextRechargeAtEpoch` null 여부와 무관하게 `totalRemaining <= 0`일 때만 `showQuotaNoticeView`를 `true`로 만든다.
|
||||||
|
- 채팅방 쿼터 안내 영역에는 더 이상 시간 아이콘, 카운트다운 텍스트, `기다리면 무료 이용이 가능합니다` 문구가 표시되지 않는다.
|
||||||
|
- 상단 광고 버튼은 `광고 / 5채팅` 라벨로 표시되고, 배경색은 hex `FEF8E3`(RGB `254, 248, 227`), 보더는 hex `F7CB50`(RGB `247, 203, 80`)로 적용된다.
|
||||||
|
- 하단에는 가로 2개 버튼이 표시되고, 왼쪽은 `ic_can + 10 / 15채팅`, 오른쪽은 `ic_can + 20 / 40채팅` 구성이 적용된다.
|
||||||
|
- 버튼 내 캔 숫자는 bold, 채팅 개수 텍스트는 medium 폰트로 구분된다.
|
||||||
|
- 채팅 쿼터 구매 요청은 광고 충전 시 `chargeType = AD`, 캔 충전 시 `chargeType = CAN`과 선택한 `canOption`을 함께 전송한다.
|
||||||
|
- `totalRemaining <= 1` 상태 진입 시 채팅 쿼터 전용 rewarded 광고가 준비되고, 광고 버튼 탭 시 로드된 광고가 있으면 표시된다.
|
||||||
|
- rewarded 광고 보상 가능 콜백에서만 쿼터 충전 API가 호출되고, 로드 실패/표시 실패/보상 미지급 시에는 API가 호출되지 않는다.
|
||||||
|
- 광고/캔 충전 성공 후 채팅 쿼터 UI와 사용자 can 잔액이 응답 기준으로 올바르게 갱신된다.
|
||||||
|
- 변경 파일 `lsp_diagnostics` 확인과 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`, `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`가 통과한다.
|
||||||
|
|
||||||
|
## 구현 메모
|
||||||
|
|
||||||
|
- 현재 `ChatRoomViewModel.updateQuota(nextRechargeAtEpoch:)`는 epoch 존재 여부와 타이머로 쿼터 안내 노출을 제어하므로, `totalRemaining`을 받는 형태로 시그니처와 호출부 전체를 함께 정리해야 한다.
|
||||||
|
- `ChatRoomEnterResponse`, `SendChatMessageResponse`, `ChatQuotaStatusResponse`에는 이미 `totalRemaining`이 있으므로, UI 분기와 광고 준비 판단은 이 값을 기준으로 통일한다.
|
||||||
|
- 현재 `ChatQuotaPurchaseRequest`는 `container`만 전송하므로, 요청 스펙 확장 시 기존 기본값(`ios`)은 유지하고 charge type / can option만 추가한다.
|
||||||
|
- `ChatQuotaNoticeItemView`의 기존 `I18n.Chat.Room.quotaWaitForFreeNotice`, `quotaPurchaseAction(chatCount:)`는 신규 UI에 맞지 않을 수 있으므로 재사용 여부를 점검하고 필요 시 신규 문자열을 추가한다.
|
||||||
|
- 기존 `YandexAdSupport.swift`에는 `YandexInlineBannerView`, `YandexInterstitialAdManager`만 있으므로, rewarded 광고는 동일 파일에 공용 매니저를 추가하는 방향을 우선 검토한다.
|
||||||
|
- Yandex 공식 rewarded 문서 기준으로 SDK 호출은 메인 스레드에서 수행하고, `RewardedAdLoader` 로드 성공 후 광고 객체를 유지한 뒤 `didReward` 시점에만 보상 API를 연결한다.
|
||||||
|
- 광고 unit id는 기존 배너/전면광고 unit id를 재사용하지 않고, 채팅 쿼터 rewarded 광고 전용 상수를 Constants 계층에 별도로 추가한다. 실제 상수 추가 위치는 구현 시 운영/디버그 Constants 파일 확인 후 확정한다.
|
||||||
|
- `totalRemaining <= 1`에서 “광고 준비”를 요구하므로, 채팅방 진입 직후/메시지 전송 응답/쿼터 상태 재조회/충전 완료 이후까지 동일 조건으로 preload 경로가 유지되어야 한다.
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
|
||||||
|
- 2026-04-30 / 계획 수립
|
||||||
|
- 무엇/왜/어떻게: 채팅 쿼터 충전 확장 구현 전에 현재 채팅방 쿼터 표시 로직, 구매 DTO, Yandex 광고 지원 범위를 확인하고 실제 수정 범위를 계획 문서로 정리했다.
|
||||||
|
- 확인 근거:
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift`
|
||||||
|
- `SodaLive/Sources/Common/YandexAdSupport.swift`
|
||||||
|
- `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- `https://ads.yandex.com/helpcenter/en/dev/ios/rewarded`
|
||||||
|
- 기존 계획 문서 패턴: `docs/20260320_채팅창얼림버튼및문구수정.md`, `docs/20260428_채팅탭Yandex배너추가.md`, `docs/20260428_Yandex광고화면배치구현.md`
|
||||||
|
- 결과:
|
||||||
|
- 현재 쿼터 안내는 `nextRechargeAtEpoch` 기반 카운트다운 구조임을 확인
|
||||||
|
- `totalRemaining`은 응답 모델에 존재하지만 UI 분기에는 아직 미사용임을 확인
|
||||||
|
- 공용 Yandex 지원은 배너/인터스티셜까지 구현되어 있고 rewarded 지원은 별도 추가가 필요함을 확인
|
||||||
|
- 위 범위를 기준으로 구현 체크리스트와 QA 기준을 확정
|
||||||
|
|
||||||
|
- 2026-04-30 / 구현 및 검증
|
||||||
|
- 무엇/왜/어떻게: 무료 충전 제거 정책에 맞춰 채팅 쿼터 안내 노출 기준을 `totalRemaining <= 0`으로 전환하고, 광고/캔 충전 UI와 `CAN`/`AD` 구매 요청 DTO, Yandex rewarded 광고 보상 콜백 기반 API 호출 흐름을 구현했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- `lsp_diagnostics`:
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/ChatRoomView.swift`
|
||||||
|
- `SodaLive/Sources/Common/YandexAdSupport.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaPurchaseRequest.swift`
|
||||||
|
- `SodaLive/Sources/I18n/I18n.swift`
|
||||||
|
- `SodaLive/Sources/Chat/Talk/Room/Quota/ChatQuotaNoticeItemView.swift`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
|
||||||
|
- `rg "countdownText|quotaWaitForFreeNotice|quotaPurchaseAction|remainingTime|stopTimer|startTimer\(" "SodaLive/Sources/Chat/Talk/Room" "SodaLive/Sources/I18n/I18n.swift"`
|
||||||
|
- `rg "totalRemaining <= 0|totalRemaining <= 1|ChatRoomQuotaChargeType|ChatRoomQuotaCanOption|YandexRewardedAdManager|YANDEX_CHAT_ROOM_QUOTA_REWARDED_AD_UNIT_ID|quotaAdAction|quotaChatCount" "SodaLive/Sources"`
|
||||||
|
- 결과:
|
||||||
|
- `ChatQuotaPurchaseRequest.swift`는 LSP 진단 없음.
|
||||||
|
- 나머지 SwiftUI/네트워크/광고 파일은 SourceKit 단독 해석 환경에서 `Kingfisher`, `Moya`, `YandexMobileAds`, 프로젝트 확장 심볼을 해석하지 못하는 환경성 오류가 있었지만, 실제 `xcodebuild` 실컴파일은 두 스킴 모두 통과했다.
|
||||||
|
- `SodaLive-dev` Debug 빌드 성공.
|
||||||
|
- `SodaLive` Debug 빌드 성공. Crashlytics dSYM 관련 기존 빌드 경고는 있었지만 빌드는 성공했다.
|
||||||
|
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가했다.
|
||||||
|
- 제거 대상 카운트다운/무료 대기 심볼 검색 결과는 없음.
|
||||||
|
- 신규 쿼터 기준/DTO/광고/I18n 심볼 검색 결과가 기대 위치에서 확인됨.
|
||||||
|
- 채팅 쿼터 rewarded ad unit id는 별도 상수로 추가했으며, 실제 운영 unit id가 제공되지 않아 현재 값은 Yandex 공식 demo rewarded unit id(`demo-rewarded-yandex`)로 둔다.
|
||||||
|
|
||||||
|
- 2026-04-30 / rewarded 콜백 미호출 수정
|
||||||
|
- 무엇/왜/어떻게: rewarded 광고가 표시된 뒤 `didReward`/`didDismiss` 콜백이 호출되지 않는 문제를 공식 문서 기준으로 점검하고, 로드 성공 직후 `RewardedAd.delegate`를 설정하며 광고 표시 중 `RewardedAd` 강한 참조를 유지하도록 수정했다.
|
||||||
|
- 실행 명령/도구:
|
||||||
|
- 공식 문서 확인: `https://ads.yandex.com/helpcenter/en/dev/ios/rewarded`, `https://ads.yandex.com/helpcenter/en/dev/ios/demo-blocks`
|
||||||
|
- `rg "loadedAd.delegate = self|rewardedAd.show|rewardedAd = nil|didReward|purchaseChatQuota\(chargeType: \.ad" "SodaLive/Sources/Common/YandexAdSupport.swift" "SodaLive/Sources/Chat/Talk/Room/ChatRoomViewModel.swift"`
|
||||||
|
- `lsp_diagnostics`: `SodaLive/Sources/Common/YandexAdSupport.swift`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
|
||||||
|
- 결과:
|
||||||
|
- 공식 문서에는 iOS Simulator에서 rewarded 콜백이 동작하지 않는다는 제한이 명시되어 있지 않다.
|
||||||
|
- 공식 문서는 `RewardedAd`와 `RewardedAdLoader`의 강한 참조 유지, 로드 성공 후 delegate 설정을 권장한다.
|
||||||
|
- `YandexRewardedAdManager.preloadAd`에서 로드 성공 직후 `loadedAd.delegate = self`를 설정하도록 변경했다.
|
||||||
|
- `showAdIfAvailable`에서 광고 표시 직전 `rewardedAd`를 nil로 만들지 않도록 변경해 표시 생명주기 동안 강한 참조를 유지했다.
|
||||||
|
- `SodaLive-dev` Debug 빌드 성공.
|
||||||
|
- SourceKit 단독 LSP는 기존과 동일하게 `YandexMobileAds` 모듈 미해결 환경성 오류를 보고했으나, 실제 `xcodebuild`는 통과했다.
|
||||||
Reference in New Issue
Block a user