package jp.juggler.subwaytooter.action import jp.juggler.subwaytooter.ActMain import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.ApiTask import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.entity.Host import jp.juggler.subwaytooter.api.entity.InstanceCapability import jp.juggler.subwaytooter.api.entity.TootReaction import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.api.runApiTask import jp.juggler.subwaytooter.api.syncStatus import jp.juggler.subwaytooter.column.Column import jp.juggler.subwaytooter.column.fireShowContent import jp.juggler.subwaytooter.column.updateEmojiReactionByApiResponse import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm import jp.juggler.subwaytooter.dialog.launchEmojiPicker import jp.juggler.subwaytooter.dialog.pickAccount import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.emoji.UnicodeEmoji import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.accountListCanReaction import jp.juggler.subwaytooter.table.daoAcctColor import jp.juggler.subwaytooter.table.daoSavedAccount import jp.juggler.subwaytooter.util.DecodeOptions import jp.juggler.util.coroutine.launchAndShowError import jp.juggler.util.coroutine.launchMain import jp.juggler.util.data.encodePercent import jp.juggler.util.log.showToast import jp.juggler.util.network.* private val rePleromaStatusUrl = """/objects/""".toRegex() private fun String.likePleromaStatusUrl(): Boolean { return rePleromaStatusUrl.find(this) != null } // 長押しでない普通のリアクション操作 fun ActMain.reactionAdd( column: Column, status: TootStatus, // Unicode絵文字、 :name: :name@.: :name@domain: name name@domain 等 codeArg: String? = null, urlArg: String? = null, // 確認済みなら真 isConfirmed: Boolean = false, ) { val activity = this@reactionAdd val accessInfo = column.accessInfo val canMultipleReaction = InstanceCapability.canMultipleReaction(accessInfo) val hasMyReaction = status.reactionSet?.hasMyReaction() == true if (hasMyReaction && !canMultipleReaction) { showToast(false, R.string.already_reactioned) return } if (codeArg == null) { launchEmojiPicker( activity, accessInfo, closeOnSelected = true ) { emoji, _ -> var newUrl: String? = null val newCode: String = when (emoji) { is UnicodeEmoji -> emoji.unifiedCode is CustomEmoji -> { newUrl = emoji.staticUrl if (accessInfo.isMisskey) { ":${emoji.shortcode}:" } else { emoji.shortcode } } } reactionAdd(column, status, newCode, newUrl) } return } var code = codeArg if (accessInfo.isMisskey) { val (name, domain) = TootReaction.splitEmojiDomain(code) if (name == null) { // unicode emoji } else when (domain) { null, "", ".", accessInfo.apDomain.ascii -> { // normalize to local custom emoji code = ":$name:" } else -> { /* #misskey のリアクションAPIはリモートのカスタム絵文字のコードをフォールバック絵文字に変更して、 何の追加情報もなしに204 no contentを返す。 よってクライアントはAPI応答からフォールバックが発生したことを認識できず、 後から投稿をリロードするまで気が付かない。 この挙動はこの挙動は多くのユーザにとって受け入れられないと判断するので、 クライアント側で事前にエラー扱いにする方が良い。 */ showToast(true, R.string.cant_reaction_remote_custom_emoji, code) return } } } if (canMultipleReaction && TootReaction.isCustomEmoji(code)) { showToast(false, "can't reaction with custom emoji from this account") return } activity.launchAndShowError { if (!isConfirmed) { val options = DecodeOptions( activity, accessInfo, decodeEmoji = true, enlargeEmoji = 1.5f, enlargeCustomEmoji = 1.5f ) val emojiSpan = TootReaction.toSpannableStringBuilder(options, code, urlArg) confirm( getString( R.string.confirm_reaction, emojiSpan, daoAcctColor.getNickname(accessInfo) ), accessInfo.confirmReaction, ) { newConfirmEnabled -> accessInfo.confirmReaction = newConfirmEnabled daoSavedAccount.save(accessInfo) } } var resultStatus: TootStatus? = null runApiTask(accessInfo) { client -> when { accessInfo.isMisskey -> client.request( "/api/notes/reactions/create", accessInfo.putMisskeyApiToken().apply { put("noteId", status.id.toString()) put("reaction", code) }.toPostRequestBuilder() ) // 成功すると204 no content canMultipleReaction -> client.request( "/api/v1/pleroma/statuses/${status.id}/reactions/${code.encodePercent("@")}", "".toFormRequestBody().toPut() )?.also { result -> // 成功すると新しいステータス resultStatus = TootParser(activity, accessInfo).status(result.jsonObject) } else -> client.request( "/api/v1/statuses/${status.id}/emoji_reactions/${code.encodePercent("@")}", "".toFormRequestBody().toPut() )?.also { result -> // 成功すると新しいステータス resultStatus = TootParser(activity, accessInfo).status(result.jsonObject) } } }?.let { result -> result.error?.let { error(it) } when (val resCode = result.response?.code) { in 200 until 300 -> when (val newStatus = resultStatus) { null -> if (status.increaseReactionMisskey(code, true, caller = "addReaction")) { // 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する column.fireShowContent( reason = "addReaction complete", reset = true ) } else -> activity.appState.columnList.forEach { column -> if (column.accessInfo.acct == accessInfo.acct) { column.updateEmojiReactionByApiResponse(newStatus) } } } else -> showToast(false, "HTTP error $resCode") } } } } // 長押しでない普通のリアクション操作 fun ActMain.reactionRemove( column: Column, status: TootStatus, reactionArg: TootReaction? = null, confirmed: Boolean = false, ) { val activity = this val accessInfo = column.accessInfo val canMultipleReaction = InstanceCapability.canMultipleReaction(accessInfo) // 指定されたリアクションまたは自分がリアクションした最初のもの val reaction = reactionArg ?: status.reactionSet?.find { it.count > 0 && it.me } if (reaction == null) { showToast(false, R.string.not_reactioned) return } launchAndShowError { if (!confirmed) { val options = DecodeOptions( activity, accessInfo, decodeEmoji = true, enlargeEmoji = 1.5f, enlargeCustomEmoji = 1.5f ) val emojiSpan = reaction.toSpannableStringBuilder(options, status) confirm(R.string.reaction_remove_confirm, emojiSpan) } var resultStatus: TootStatus? = null runApiTask(accessInfo) { client -> when { accessInfo.isMisskey -> client.request( "/api/notes/reactions/delete", accessInfo.putMisskeyApiToken().apply { put("noteId", status.id.toString()) }.toPostRequestBuilder() ) // 成功すると204 no content canMultipleReaction -> client.request( "/api/v1/pleroma/statuses/${status.id}/reactions/${reaction.name.encodePercent("@")}", "".toFormRequestBody().toDelete() )?.also { result -> // 成功すると新しいステータス resultStatus = TootParser(activity, accessInfo).status(result.jsonObject) } else -> client.request( "/api/v1/statuses/${status.id}/emoji_unreaction", "".toFormRequestBody().toPost() )?.also { result -> // 成功すると新しいステータス resultStatus = TootParser(activity, accessInfo).status(result.jsonObject) } } }?.let { result -> val resCode = result.response?.code when { result.error != null -> activity.showToast(false, result.error) resCode !in 200 until 300 -> showToast(false, "HTTP error $resCode") else -> { when (val newStatus = resultStatus) { null -> if (status.decreaseReactionMisskey( reaction.name, true, "removeReaction" ) ) { // 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する column.fireShowContent( reason = "removeReaction complete", reset = true ) } else -> activity.appState.columnList.forEach { column -> if (column.accessInfo.acct == accessInfo.acct) { column.updateEmojiReactionByApiResponse(newStatus) } } } } } } } } // リアクションの別アカ操作で使う // 選択済みのアカウントと同期済みのステータスにリアクションを行う private fun ActMain.reactionWithoutUi( accessInfo: SavedAccount, resolvedStatus: TootStatus, reactionCode: String? = null, reactionImage: String? = null, callback: () -> Unit, ) { val activity = this if (reactionCode == null) { launchEmojiPicker(activity, accessInfo, closeOnSelected = true) { emoji, _ -> var newUrl: String? = null val newCode = when (emoji) { is UnicodeEmoji -> emoji.unifiedCode is CustomEmoji -> { newUrl = emoji.staticUrl if (accessInfo.isMisskey) { ":${emoji.shortcode}:" } else { emoji.shortcode } } } reactionWithoutUi( accessInfo = accessInfo, resolvedStatus = resolvedStatus, reactionCode = newCode, reactionImage = newUrl, callback = callback ) } return } val canMultipleReaction = InstanceCapability.canMultipleReaction(accessInfo) val options = DecodeOptions( activity, accessInfo, decodeEmoji = true, enlargeEmoji = 1.5f, enlargeCustomEmoji = 1.5f ) val emojiSpan = TootReaction.toSpannableStringBuilder(options, reactionCode, reactionImage) val isCustomEmoji = TootReaction.isCustomEmoji(reactionCode) val url = resolvedStatus.url launchAndShowError { when { isCustomEmoji && canMultipleReaction -> error("can't reaction with custom emoji from this account") isCustomEmoji && url?.likePleromaStatusUrl() == true -> confirm( R.string.confirm_reaction_to_pleroma, emojiSpan, daoAcctColor.getNickname(accessInfo), resolvedStatus.account.acct.host?.pretty ?: "(null)" ) else -> confirm( getString( R.string.confirm_reaction, emojiSpan, daoAcctColor.getNickname(accessInfo) ), accessInfo.confirmReaction, ) { newConfirmEnabled -> accessInfo.confirmReaction = newConfirmEnabled daoSavedAccount.save(accessInfo) } } // var resultStatus: TootStatus? = null runApiTask(accessInfo) { client -> when { accessInfo.isMisskey -> client.request( "/api/notes/reactions/create", accessInfo.putMisskeyApiToken().apply { put("noteId", resolvedStatus.id.toString()) put("reaction", reactionCode) }.toPostRequestBuilder() ) // 成功すると204 no content canMultipleReaction -> client.request( "/api/v1/pleroma/statuses/${resolvedStatus.id}/reactions/${ reactionCode.encodePercent("@") }", "".toFormRequestBody().toPut() ) // 成功すると更新された投稿 else -> client.request( "/api/v1/statuses/${resolvedStatus.id}/emoji_reactions/${reactionCode.encodePercent()}", "".toFormRequestBody().toPut() ) // 成功すると更新された投稿 } }?.let { result -> when (val error = result.error) { null -> callback() else -> showToast(true, error) } } } } // リアクションの別アカ操作で使う // 選択済みのアカウントと同期済みのステータスと同期まえのリアクションから、同期後のリアクションコードを計算する // 解決できなかった場合はnullを返す private fun ActMain.reactionFixCode( timelineAccount: SavedAccount, actionAccount: SavedAccount, reaction: TootReaction, ): String? { val pair = reaction.splitEmojiDomain() pair.first ?: return reaction.name // null または Unicode絵文字 val srcDomain = when (val d = pair.second) { null, ".", "" -> timelineAccount.apDomain else -> Host.parse(d) } // リアクション者から見てローカルな絵文字 if (srcDomain == actionAccount.apDomain) { return when { actionAccount.isMisskey -> ":${pair.first}:" else -> pair.first } } // リアクション者からみてリモートの絵文字 val newName = "${pair.first}@$srcDomain" if (actionAccount.isMisskey) { /* Misskey のリアクションAPIはリモートのカスタム絵文字のコードをフォールバック絵文字に変更して、 何の追加情報もなしに204 no contentを返す。 よってクライアントはAPI応答からフォールバックが発生したことを認識できず、 後から投稿をリロードするまで気が付かない。 この挙動はこの挙動は多くのユーザにとって受け入れられないと判断するので、 クライアント側で事前にエラー扱いにする方が良い。 */ } else { // Fedibirdの場合、ステータスを同期した時点で絵文字も同期されてると期待できるのだろうか? // 実際に試してみると // nightly.fedibird.comの投稿にローカルな絵文字を付けた後、 // その投稿のURLをfedibird.comの検索欄にいれてローカルに同期すると、 // すでにインポート済みの投稿だとリアクション集合は古いままなのだった。 // // if (resolvedStatus.reactionSet?.any { it.name == newName } == true) return newName } // エラー showToast(true, R.string.cant_reaction_remote_custom_emoji, newName) return null } fun ActMain.reactionFromAnotherAccount( timelineAccount: SavedAccount, statusArg: TootStatus?, reaction: TootReaction? = null, ) { statusArg ?: return val activity = this launchMain { fun afterResolveStatus( actionAccount: SavedAccount, resolvedStatus: TootStatus, ) { val code = if (reaction == null) { null // あとで選択する } else { reactionFixCode( timelineAccount = timelineAccount, actionAccount = actionAccount, reaction = reaction, ) ?: return // エラー終了の場合がある } reactionWithoutUi( accessInfo = actionAccount, resolvedStatus = resolvedStatus, reactionCode = code, callback = reactionCompleteCallback, ) } val list = accountListCanReaction() ?: return@launchMain if (list.isEmpty()) { showToast(false, R.string.not_available_for_current_accounts) return@launchMain } pickAccount( accountListArg = list.toMutableList(), bAuto = false, message = activity.getString(R.string.account_picker_reaction) )?.let { action_account -> if (calcCrossAccountMode(timelineAccount, action_account).isNotRemote) { afterResolveStatus(action_account, statusArg) } else { var newStatus: TootStatus? = null runApiTask(action_account, progressStyle = ApiTask.PROGRESS_NONE) { client -> val (result, status) = client.syncStatus(action_account, statusArg) newStatus = status result }?.let { result -> result.error?.let { activity.showToast(true, it) return@launchMain } newStatus?.let { afterResolveStatus(action_account, it) } } } } } } fun ActMain.clickReaction(accessInfo: SavedAccount, column: Column, status: TootStatus) { val canMultipleReaction = InstanceCapability.canMultipleReaction(accessInfo) val hasMyReaction = status.reactionSet?.hasMyReaction() == true val bRemoveButton = hasMyReaction && !canMultipleReaction when { !TootReaction.canReaction(accessInfo) -> reactionFromAnotherAccount( accessInfo, status ) bRemoveButton -> reactionRemove(column, status) else -> reactionAdd(column, status) } }