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

543 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 androidx.appcompat.app.AlertDialog
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.dialog.DlgConfirm
import jp.juggler.subwaytooter.dialog.EmojiPicker
import jp.juggler.subwaytooter.dialog.pickAccount
import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.emoji.UnicodeEmoji
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.util.*
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) {
EmojiPicker(activity, accessInfo, closeOnSelected = true) { result ->
var newUrl: String? = null
val newCode: String = when (val emoji = result.emoji) {
is UnicodeEmoji -> emoji.unifiedCode
is CustomEmoji -> {
newUrl = emoji.staticUrl
if (accessInfo.isMisskey) {
":${emoji.shortcode}:"
} else {
emoji.shortcode
}
}
}
reactionAdd(column, status, newCode, newUrl)
}.show()
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
}
if (!isConfirmed) {
val options = DecodeOptions(
activity,
accessInfo,
decodeEmoji = true,
enlargeEmoji = 1.5f,
enlargeCustomEmoji = 1.5f
)
val emojiSpan = TootReaction.toSpannableStringBuilder(options, code, urlArg)
DlgConfirm.open(
activity,
getString(R.string.confirm_reaction, emojiSpan, AcctColor.getNickname(accessInfo)),
object : DlgConfirm.Callback {
override var isConfirmEnabled: Boolean
get() = accessInfo.confirm_reaction
set(bv) {
accessInfo.confirm_reaction = bv
accessInfo.saveSetting()
}
override fun onOK() {
reactionAdd(
column,
status,
codeArg = code,
urlArg = urlArg,
isConfirmed = true
)
}
})
return
}
launchMain {
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 ->
val error = result.error
if (error != null) {
activity.showToast(false, error)
return@launchMain
}
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
}
if (!confirmed) {
val options = DecodeOptions(
activity,
accessInfo,
decodeEmoji = true,
enlargeEmoji = 1.5f,
enlargeCustomEmoji = 1.5f
)
val emojiSpan = reaction.toSpannableStringBuilder(options, status)
AlertDialog.Builder(activity)
.setMessage(getString(R.string.reaction_remove_confirm, emojiSpan))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
reactionRemove(column, status, reaction, confirmed = true)
}
.show()
return
}
launchMain {
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,
isConfirmed: Boolean = false,
callback: () -> Unit,
) {
val activity = this
if (reactionCode == null) {
EmojiPicker(activity, accessInfo, closeOnSelected = true) { result ->
var newUrl: String? = null
val newCode = when (val emoji = result.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,
isConfirmed = isConfirmed,
callback = callback
)
}.show()
return
}
val canMultipleReaction = InstanceCapability.canMultipleReaction(accessInfo)
if (!isConfirmed) {
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
when {
isCustomEmoji && canMultipleReaction -> {
showToast(false, "can't reaction with custom emoji from this account")
return
}
isCustomEmoji && url?.likePleromaStatusUrl() == true -> DlgConfirm.openSimple(
activity,
getString(
R.string.confirm_reaction_to_pleroma,
emojiSpan,
AcctColor.getNickname(accessInfo),
resolvedStatus.account.acct.host?.pretty ?: "(null)"
),
) {
reactionWithoutUi(
accessInfo = accessInfo,
resolvedStatus = resolvedStatus,
reactionCode = reactionCode,
reactionImage = reactionImage,
isConfirmed = true,
callback = callback
)
}
else -> DlgConfirm.open(
activity,
getString(R.string.confirm_reaction, emojiSpan, AcctColor.getNickname(accessInfo)),
object : DlgConfirm.Callback {
override var isConfirmEnabled: Boolean
get() = accessInfo.confirm_reaction
set(bv) {
accessInfo.confirm_reaction = bv
accessInfo.saveSetting()
}
override fun onOK() {
reactionWithoutUi(
accessInfo = accessInfo,
resolvedStatus = resolvedStatus,
reactionCode = reactionCode,
reactionImage = reactionImage,
isConfirmed = true,
callback = callback
)
}
})
}
return
}
launchMain {
// 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
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,
)
}
launchMain {
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)
}
}