SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/action/Action_Reaction.kt

528 lines
21 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.util.emojiSizeMode
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 = DecodeOptions.emojiScaleReaction,
enlargeCustomEmoji = DecodeOptions.emojiScaleReaction,
emojiSizeMode = accessInfo.emojiSizeMode(),
)
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 = DecodeOptions.emojiScaleReaction,
enlargeCustomEmoji = DecodeOptions.emojiScaleReaction,
emojiSizeMode = accessInfo.emojiSizeMode(),
)
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 = DecodeOptions.emojiScaleReaction,
enlargeCustomEmoji = DecodeOptions.emojiScaleReaction,
emojiSizeMode = accessInfo.emojiSizeMode(),
)
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)
}
}