リアクション時に確認ダイアログを表示する

This commit is contained in:
tateisu 2021-05-23 21:54:52 +09:00
parent fd452c0974
commit 99fe09f1bf
24 changed files with 1255 additions and 1146 deletions

View File

@ -129,6 +129,7 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
private lateinit var cbConfirmUnboost: CheckBox
private lateinit var cbConfirmUnfavourite: CheckBox
private lateinit var cbConfirmToot: CheckBox
private lateinit var cbConfirmReaction: CheckBox
private lateinit var tvUserCustom: TextView
private lateinit var btnUserCustom: View
@ -236,7 +237,7 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
}
} else {
// 失敗したら DBからデータを削除
state.uriCameraImage?.let{
state.uriCameraImage?.let {
contentResolver.delete(it, null, null)
}
state.uriCameraImage = null
@ -283,9 +284,9 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
val encodedState = Json.encodeToString(state)
log.d("encodedState=$encodedState")
val decodedState :State = Json.decodeFromString(encodedState)
val decodedState: State = Json.decodeFromString(encodedState)
log.d("encodedState.uriCameraImage=${decodedState.uriCameraImage}")
outState.putString(ACTIVITY_STATE,encodedState )
outState.putString(ACTIVITY_STATE, encodedState)
}
override fun onStop() {
@ -342,6 +343,7 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
cbConfirmUnboost = findViewById(R.id.cbConfirmUnboost)
cbConfirmUnfavourite = findViewById(R.id.cbConfirmUnfavourite)
cbConfirmToot = findViewById(R.id.cbConfirmToot)
cbConfirmReaction = findViewById(R.id.cbConfirmReaction)
tvUserCustom = findViewById(R.id.tvUserCustom)
btnUserCustom = findViewById(R.id.btnUserCustom)
@ -423,66 +425,10 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
).map { findViewById(it) }
btnFields = findViewById(R.id.btnFields)
btnOpenBrowser.setOnClickListener(this)
btnPushSubscription.setOnClickListener(this)
btnPushSubscriptionNotForce.setOnClickListener(this)
btnResetNotificationTracking.setOnClickListener(this)
btnAccessToken.setOnClickListener(this)
btnInputAccessToken.setOnClickListener(this)
btnAccountRemove.setOnClickListener(this)
btnLoadPreference.setOnClickListener(this)
btnVisibility.setOnClickListener(this)
btnUserCustom.setOnClickListener(this)
btnProfileAvatar.setOnClickListener(this)
btnProfileHeader.setOnClickListener(this)
btnDisplayName.setOnClickListener(this)
btnNote.setOnClickListener(this)
btnFields.setOnClickListener(this)
swNSFWOpen.setOnCheckedChangeListener(this)
swDontShowTimeout.setOnCheckedChangeListener(this)
swExpandCW.setOnCheckedChangeListener(this)
swMarkSensitive.setOnCheckedChangeListener(this)
cbNotificationMention.setOnCheckedChangeListener(this)
cbNotificationBoost.setOnCheckedChangeListener(this)
cbNotificationFavourite.setOnCheckedChangeListener(this)
cbNotificationFollow.setOnCheckedChangeListener(this)
cbNotificationFollowRequest.setOnCheckedChangeListener(this)
cbNotificationReaction.setOnCheckedChangeListener(this)
cbNotificationVote.setOnCheckedChangeListener(this)
cbNotificationPost.setOnCheckedChangeListener(this)
cbLocked.setOnCheckedChangeListener(this)
cbConfirmFollow.setOnCheckedChangeListener(this)
cbConfirmFollowLockedUser.setOnCheckedChangeListener(this)
cbConfirmUnfollow.setOnCheckedChangeListener(this)
cbConfirmBoost.setOnCheckedChangeListener(this)
cbConfirmFavourite.setOnCheckedChangeListener(this)
cbConfirmUnboost.setOnCheckedChangeListener(this)
cbConfirmUnfavourite.setOnCheckedChangeListener(this)
cbConfirmToot.setOnCheckedChangeListener(this)
btnNotificationSoundEdit = findViewById(R.id.btnNotificationSoundEdit)
btnNotificationSoundReset = findViewById(R.id.btnNotificationSoundReset)
btnNotificationSoundEdit.setOnClickListener(this)
btnNotificationSoundReset.setOnClickListener(this)
btnNotificationStyleEdit = findViewById(R.id.btnNotificationStyleEdit)
btnNotificationStyleEditReply = findViewById(R.id.btnNotificationStyleEditReply)
btnNotificationStyleEdit.setOnClickListener(this)
btnNotificationStyleEditReply.setOnClickListener(this)
spResizeImage.onItemSelectedListener = this
spPushPolicy.onItemSelectedListener = this
btnNotificationStyleEditReply.vg(Pref.bpSeparateReplyNotificationGroup(pref))
name_invalidator = NetworkEmojiInvalidator(handler, etDisplayName)
@ -544,6 +490,57 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
}
})
arrayOf(
btnOpenBrowser,
btnPushSubscription,
btnPushSubscriptionNotForce,
btnResetNotificationTracking,
btnAccessToken,
btnInputAccessToken,
btnAccountRemove,
btnLoadPreference,
btnVisibility,
btnUserCustom,
btnProfileAvatar,
btnProfileHeader,
btnDisplayName,
btnNote,
btnFields,
btnNotificationSoundEdit,
btnNotificationSoundReset,
btnNotificationStyleEdit,
btnNotificationStyleEditReply,
).forEach { it.setOnClickListener(this) }
arrayOf(
swNSFWOpen,
swDontShowTimeout,
swExpandCW,
swMarkSensitive,
cbNotificationMention,
cbNotificationBoost,
cbNotificationFavourite,
cbNotificationFollow,
cbNotificationFollowRequest,
cbNotificationReaction,
cbNotificationVote,
cbNotificationPost,
cbLocked,
cbConfirmFollow,
cbConfirmFollowLockedUser,
cbConfirmUnfollow,
cbConfirmBoost,
cbConfirmFavourite,
cbConfirmUnboost,
cbConfirmUnfavourite,
cbConfirmToot,
cbConfirmReaction,
).forEach { it.setOnCheckedChangeListener(this) }
arrayOf(
spResizeImage,
spPushPolicy,
).forEach { it.onItemSelectedListener = this }
}
private fun EditText.parseInt(): Int? {
@ -588,6 +585,7 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
cbConfirmToot.isChecked = a.confirm_post
cbConfirmReaction.isChecked = a.confirm_reaction
notification_sound_uri = a.sound_uri
@ -597,34 +595,44 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
loading = false
val enabled = !a.isPseudo
btnAccessToken.isEnabledAlpha = enabled
btnInputAccessToken.isEnabledAlpha = enabled
btnVisibility.isEnabledAlpha = enabled
btnPushSubscription.isEnabledAlpha = enabled
btnPushSubscriptionNotForce.isEnabledAlpha = enabled
btnResetNotificationTracking.isEnabledAlpha = enabled
btnNotificationSoundEdit.isEnabledAlpha = Build.VERSION.SDK_INT < 26 && enabled
btnNotificationSoundReset.isEnabledAlpha = Build.VERSION.SDK_INT < 26 && enabled
btnNotificationStyleEdit.isEnabledAlpha = Build.VERSION.SDK_INT >= 26 && enabled
btnNotificationStyleEditReply.isEnabledAlpha = Build.VERSION.SDK_INT >= 26 && enabled
cbNotificationMention.isEnabledAlpha = enabled
cbNotificationBoost.isEnabledAlpha = enabled
cbNotificationFavourite.isEnabledAlpha = enabled
cbNotificationFollow.isEnabledAlpha = enabled
cbNotificationFollowRequest.isEnabledAlpha = enabled
cbNotificationReaction.isEnabledAlpha = enabled
cbNotificationVote.isEnabledAlpha = enabled
cbNotificationPost.isEnabledAlpha = enabled
arrayOf(
btnAccessToken,
btnInputAccessToken,
btnVisibility,
btnPushSubscription,
btnPushSubscriptionNotForce,
btnResetNotificationTracking,
cbNotificationMention,
cbNotificationBoost,
cbNotificationFavourite,
cbNotificationFollow,
cbNotificationFollowRequest,
cbNotificationReaction,
cbNotificationVote,
cbNotificationPost,
cbConfirmFollow,
cbConfirmFollowLockedUser,
cbConfirmUnfollow,
cbConfirmBoost,
cbConfirmFavourite,
cbConfirmUnboost,
cbConfirmUnfavourite,
cbConfirmToot,
cbConfirmReaction,
).forEach { it.isEnabledAlpha = enabled }
cbConfirmFollow.isEnabledAlpha = enabled
cbConfirmFollowLockedUser.isEnabledAlpha = enabled
cbConfirmUnfollow.isEnabledAlpha = enabled
cbConfirmBoost.isEnabledAlpha = enabled
cbConfirmFavourite.isEnabledAlpha = enabled
cbConfirmUnboost.isEnabledAlpha = enabled
cbConfirmUnfavourite.isEnabledAlpha = enabled
cbConfirmToot.isEnabledAlpha = enabled
val enabledOldNotification = enabled && Build.VERSION.SDK_INT < 26
arrayOf(
btnNotificationSoundEdit,
btnNotificationSoundReset,
).forEach { it.isEnabledAlpha = enabledOldNotification }
val enabledNewNotification = enabled && Build.VERSION.SDK_INT >= 26
arrayOf(
btnNotificationStyleEdit,
btnNotificationStyleEditReply,
).forEach { it.isEnabledAlpha = enabledNewNotification }
val ti = TootInstance.getCached(a.apiHost)
if (ti == null) {
@ -695,6 +703,7 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
account.confirm_unboost = cbConfirmUnboost.isChecked
account.confirm_unfavourite = cbConfirmUnfavourite.isChecked
account.confirm_post = cbConfirmToot.isChecked
account.confirm_reaction = cbConfirmReaction.isChecked
account.default_text = etDefaultText.text.toString()
val num = etMaxTootChars.parseInt()
@ -1010,13 +1019,15 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
etNote.setText(loadingText)
// 初期状態では編集不可能
btnProfileAvatar.isEnabledAlpha = false
btnProfileHeader.isEnabledAlpha = false
etDisplayName.isEnabledAlpha = false
btnDisplayName.isEnabledAlpha = false
etNote.isEnabledAlpha = false
btnNote.isEnabledAlpha = false
cbLocked.isEnabledAlpha = false
arrayOf(
btnProfileAvatar,
btnProfileHeader,
etDisplayName,
btnDisplayName,
etNote,
btnNote,
cbLocked,
).forEach { it.isEnabledAlpha = false }
for (et in listEtFieldName) {
et.setText(loadingText)
@ -1132,13 +1143,15 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
cbLocked.isChecked = src.locked
// 編集可能にする
btnProfileAvatar.isEnabledAlpha = true
btnProfileHeader.isEnabledAlpha = true
etDisplayName.isEnabledAlpha = true
btnDisplayName.isEnabledAlpha = true
etNote.isEnabledAlpha = true
btnNote.isEnabledAlpha = true
cbLocked.isEnabledAlpha = true
arrayOf(
btnProfileAvatar,
btnProfileHeader,
etDisplayName,
btnDisplayName,
etNote,
btnNote,
cbLocked,
).forEach { it.isEnabledAlpha = true }
if (src.source?.fields != null) {
val fields = src.source.fields

View File

@ -18,7 +18,6 @@ import android.view.*
import android.view.inputmethod.EditorInfo
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.recyclerview.widget.LinearLayoutManager
@ -1542,7 +1541,7 @@ class ActMain : AsyncActivity(), View.OnClickListener,
}
)
fun updateColumnStrip() {
private fun updateColumnStrip() {
llEmpty.vg(app_state.columnCount == 0)
val iconSize = stripIconSize
@ -1695,7 +1694,7 @@ class ActMain : AsyncActivity(), View.OnClickListener,
val statusInfo = url.findStatusIdFromUrl()
if (statusInfo != null) {
// ステータスをアプリ内で開く
Action_Toot.conversationOtherInstance(
Action_Conversation.conversationOtherInstance(
this@ActMain,
defaultInsertPosition,
statusInfo.url,
@ -2175,7 +2174,7 @@ class ActMain : AsyncActivity(), View.OnClickListener,
}
// アクセストークンの手動入力(更新)
fun checkAccessToken2(db_id: Long) {
private fun checkAccessToken2(db_id: Long) {
val sa = SavedAccount.loadAccount(this, db_id) ?: return

View File

@ -125,8 +125,9 @@ class App1 : Application() {
// 2020/9/20 57=>58 UserRelationテーブルに項目追加
// 2021/2/10 58=>59 SavedAccountテーブルに項目追加
// 2021/5/11 59=>60 SavedAccountテーブルに項目追加
// 2021/5/23 60=>61 SavedAccountテーブルに項目追加
internal const val DB_VERSION = 60
internal const val DB_VERSION = 61
private val tableList = arrayOf(
LogData,

View File

@ -21,7 +21,6 @@ import jp.juggler.subwaytooter.util.NetworkStateTracker
import jp.juggler.subwaytooter.util.PostAttachment
import jp.juggler.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.apache.commons.io.IOUtils
import java.io.ByteArrayOutputStream

View File

@ -1,7 +1,6 @@
package jp.juggler.subwaytooter
import android.annotation.SuppressLint
import android.app.Activity
import android.app.Dialog
import android.content.res.ColorStateList
import android.view.Gravity
@ -993,24 +992,24 @@ internal class DlgContextMenu(
status
)
R.id.btnReactionAnotherAccount -> Action_Toot.reactionFromAnotherAccount(
R.id.btnReactionAnotherAccount -> Action_Reaction.reactionFromAnotherAccount(
activity,
access_info,
status
)
R.id.btnReplyAnotherAccount -> Action_Toot.replyFromAnotherAccount(
R.id.btnReplyAnotherAccount -> Action_Reply.replyFromAnotherAccount(
activity,
access_info,
status
)
R.id.btnQuoteToot -> Action_Toot.replyFromAnotherAccount(
R.id.btnQuoteToot -> Action_Reply.replyFromAnotherAccount(
activity,
access_info,
status,
quote = true
)
R.id.btnQuoteTootBT -> Action_Toot.replyFromAnotherAccount(
R.id.btnQuoteTootBT -> Action_Reply.replyFromAnotherAccount(
activity,
access_info,
status?.reblogParent,
@ -1018,7 +1017,7 @@ internal class DlgContextMenu(
)
R.id.btnConversationAnotherAccount -> status?.let { status ->
Action_Toot.conversationOtherInstance(activity, pos, status)
Action_Conversation.conversationOtherInstance(activity, pos, status)
}
R.id.btnDelete -> status?.let { status ->
@ -1078,7 +1077,7 @@ internal class DlgContextMenu(
}
R.id.btnConversationMute -> status?.let { status ->
Action_Toot.muteConversation(activity, access_info, status)
Action_Conversation.muteConversation(activity, access_info, status)
}
R.id.btnProfilePin -> status?.let { status ->

View File

@ -37,10 +37,10 @@ fun ItemViewHolder.openConversationSummary() {
reset = true
)
// 未読フラグのクリアをサーバに送る
Action_Toot.clearConversationUnread(activity, access_info, cs)
Action_Conversation.clearConversationUnread(activity, access_info, cs)
}
Action_Toot.conversation(
Action_Conversation.conversation(
activity,
activity.nextPosition(column),
access_info,
@ -152,17 +152,17 @@ fun ItemViewHolder.onClickImpl(v: View?) {
val s = status_reply
when {
s != null -> Action_Toot.conversation(activity, pos, access_info, s)
s != null -> Action_Conversation.conversation(activity, pos, access_info, s)
// tootsearchは返信元のIDを取得するのにひと手間必要
column.type == ColumnType.SEARCH_TS ||
column.type == ColumnType.SEARCH_NOTESTOCK ->
Action_Toot.showReplyTootsearch(activity, pos, status_showing)
Action_Reply.showReplyTootsearch(activity, pos, status_showing)
else -> {
val id = status_showing?.in_reply_to_id
if (id != null) {
Action_Toot.conversationLocal(activity, pos, access_info, id)
Action_Conversation.conversationLocal(activity, pos, access_info, id)
}
}
}
@ -318,7 +318,7 @@ fun ItemViewHolder.onClickImpl(v: View?) {
ivCardImage -> status_showing?.card?.let { card ->
val originalStatus = card.originalStatus
if (originalStatus != null) {
Action_Toot.conversation(
Action_Conversation.conversation(
activity,
activity.nextPosition(column),
access_info,
@ -395,7 +395,7 @@ fun ItemViewHolder.onLongClickImpl(v: View?): Boolean {
// tootsearchは返信元のIDを取得するのにひと手間必要
column.type == ColumnType.SEARCH_TS ||
column.type == ColumnType.SEARCH_NOTESTOCK ->
Action_Toot.showReplyTootsearch(
Action_Reply.showReplyTootsearch(
activity,
activity.nextPosition(column),
status_showing
@ -404,7 +404,7 @@ fun ItemViewHolder.onLongClickImpl(v: View?): Boolean {
else -> {
val id = status_showing?.in_reply_to_id
if (id != null) {
Action_Toot.conversationLocal(
Action_Conversation.conversationLocal(
activity,
activity.nextPosition(column),
access_info,
@ -440,7 +440,7 @@ fun ItemViewHolder.onLongClickImpl(v: View?): Boolean {
return true
}
ivCardImage -> Action_Toot.conversationOtherInstance(
ivCardImage -> Action_Conversation.conversationOtherInstance(
activity,
activity.nextPosition(column),
status_showing?.card?.originalStatus
@ -490,7 +490,7 @@ fun ItemViewHolder.clickMedia(i: Int) {
is TootAttachmentMSP -> {
// マストドン検索ポータルのデータではmedia_attachmentsが簡略化されている
// 会話の流れを表示する
Action_Toot.conversationOtherInstance(
Action_Conversation.conversationOtherInstance(
activity,
activity.nextPosition(column),
status_showing

View File

@ -1,14 +1,13 @@
package jp.juggler.subwaytooter
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import androidx.core.content.ContextCompat
import com.google.android.flexbox.FlexWrap
import com.google.android.flexbox.FlexboxLayout
import com.google.android.flexbox.JustifyContent
import jp.juggler.subwaytooter.action.Action_Toot
import jp.juggler.subwaytooter.action.Action_Reaction
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.TootReaction
import jp.juggler.subwaytooter.api.entity.TootStatus
@ -20,8 +19,8 @@ fun ItemViewHolder.makeReactionsView(status: TootStatus) {
val myReaction = status.reactionSet?.myReaction
val reactionSet = status.reactionSet?.filter { it.count > 0 }
if (reactionSet?.isEmpty() != false){
if( !TootReaction.canReaction(access_info) || !Pref.bpKeepReactionSpace(activity.pref) ) return
if (reactionSet?.isEmpty() != false) {
if (!TootReaction.canReaction(access_info) || !Pref.bpKeepReactionSpace(activity.pref)) return
}
val density = activity.density
@ -45,67 +44,7 @@ fun ItemViewHolder.makeReactionsView(status: TootStatus) {
}
}
// // +/- ボタン
// box.addView(ImageButton(act).also { b ->
// b.layoutParams = FlexboxLayout.LayoutParams(
// buttonHeight,
// buttonHeight
// ).apply {
// endMargin = marginBetween
// }
//
// b.background = ContextCompat.getDrawable(
// activity,
// R.drawable.btn_bg_transparent_round6dp
// )
//
// val myReaction = status.reactionSet?.myReaction
//
// b.contentDescription = activity.getString(
// if (myReaction != null)
// R.string.reaction_remove
// else
// R.string.reaction_add
// )
// b.scaleType = ImageView.ScaleType.FIT_CENTER
// b.padding = paddingV
//
// b.setOnClickListener {
// if (!TootReaction.canReaction(access_info)) {
// Action_Toot.reactionFromAnotherAccount(
// activity,
// access_info,
// status_showing
// )
// } else if (myReaction != null) {
// Action_Toot.removeReaction(activity, column, status)
// } else {
// Action_Toot.addReaction(activity, column, status)
// }
// }
//
// b.setOnLongClickListener {
// Action_Toot.reactionFromAnotherAccount(
// activity,
// access_info,
// status_showing
// )
// true
// }
//
// setIconDrawableId(
// act,
// b,
// if (myReaction != null)
// R.drawable.ic_remove
// else
// R.drawable.ic_add,
// color = content_color,
// alphaMultiplier = Styler.boost_alpha
// )
// })
if( reactionSet?.isEmpty() != false){
if (reactionSet?.isEmpty() != false) {
val v = View(act).apply {
layoutParams = FlexboxLayout.LayoutParams(
buttonHeight,
@ -133,7 +72,7 @@ fun ItemViewHolder.makeReactionsView(status: TootStatus) {
FlexboxLayout.LayoutParams.WRAP_CONTENT,
buttonHeight
).apply {
if(index >0 ) startMargin = marginBetween
if (index > 0) startMargin = marginBetween
}
minWidthCompat = buttonHeight
@ -162,15 +101,15 @@ fun ItemViewHolder.makeReactionsView(status: TootStatus) {
setOnClickListener {
val taggedReaction = it.tag as? TootReaction
if (taggedReaction == status.reactionSet?.myReaction) {
Action_Toot. removeReaction(act, column, status)
Action_Reaction.removeReaction(act, column, status)
} else {
Action_Toot. addReaction(act, column, status, taggedReaction?.name)
Action_Reaction.addReaction(act, column, status, taggedReaction?.name, taggedReaction?.static_url)
}
}
setOnLongClickListener {
val taggedReaction = it.tag as? TootReaction
Action_Toot.reactionFromAnotherAccount(
Action_Reaction.reactionFromAnotherAccount(
this@makeReactionsView.activity,
access_info,
status_showing,

View File

@ -466,11 +466,11 @@ class StatusButtons(
reset = true
)
// 未読フラグのクリアをサーバに送る
Action_Toot.clearConversationUnread(activity, access_info, cs)
Action_Conversation.clearConversationUnread(activity, access_info, cs)
}
}
Action_Toot.conversation(
Action_Conversation.conversation(
activity,
activity.nextPosition(column),
access_info,
@ -480,15 +480,15 @@ class StatusButtons(
}
btnReply -> if (!access_info.isPseudo) {
Action_Toot.reply(activity, access_info, status)
Action_Reply.reply(activity, access_info, status)
} else {
Action_Toot.replyFromAnotherAccount(activity, access_info, status)
Action_Reply.replyFromAnotherAccount(activity, access_info, status)
}
btnQuote -> if (!access_info.isPseudo) {
Action_Toot.reply(activity, access_info, status, quote = true)
Action_Reply.reply(activity, access_info, status, quote = true)
} else {
Action_Toot.replyFromAnotherAccount(activity, access_info, status, quote = true)
Action_Reply.replyFromAnotherAccount(activity, access_info, status, quote = true)
}
btnBoost -> {
@ -564,20 +564,20 @@ class StatusButtons(
}
}
btnReaction -> {
if (!TootReaction.canReaction(access_info)) {
Action_Toot.reactionFromAnotherAccount(
btnReaction -> when {
!TootReaction.canReaction(access_info) ->
Action_Reaction.reactionFromAnotherAccount(
activity,
access_info,
status
)
} else if (status.reactionSet?.myReaction != null) {
Action_Toot.removeReaction(activity, column, status)
} else {
Action_Toot.addReaction(activity, column, status)
}
status.reactionSet?.myReaction != null ->
Action_Reaction.removeReaction(activity, column, status)
else ->
Action_Reaction.addReaction(activity, column, status)
}
btnFollow2 -> {
val accountRef = status.accountRef
val account = accountRef.get()
@ -682,54 +682,26 @@ class StatusButtons(
val status = this.status ?: return true
when (v) {
btnConversation -> Action_Toot.conversationOtherInstance(
activity, activity.nextPosition(column), status
)
btnBoost -> Action_Toot.boostFromAnotherAccount(activity, access_info, status)
btnFavourite -> Action_Toot.favouriteFromAnotherAccount(activity, access_info, status)
btnBookmark -> Action_Toot.bookmarkFromAnotherAccount(activity, access_info, status)
btnBoost -> Action_Toot.boostFromAnotherAccount(
activity, access_info, status
)
btnReply -> Action_Reply.replyFromAnotherAccount(activity, access_info, status)
btnQuote -> Action_Reply.replyFromAnotherAccount(activity, access_info, status, quote = true)
btnFavourite -> Action_Toot.favouriteFromAnotherAccount(
activity, access_info, status
)
btnReaction -> Action_Reaction.reactionFromAnotherAccount(activity, access_info, status)
btnBookmark -> Action_Toot.bookmarkFromAnotherAccount(
activity, access_info, status
)
btnConversation -> Action_Conversation.conversationOtherInstance(activity, activity.nextPosition(column), status)
btnReply -> Action_Toot.replyFromAnotherAccount(
activity, access_info, status
)
btnQuote -> Action_Toot.replyFromAnotherAccount(activity, access_info, status, quote = true)
btnReaction -> Action_Toot.reactionFromAnotherAccount(activity, access_info, status)
btnFollow2 -> Action_Follow.followFromAnotherAccount(
activity, activity.nextPosition(column), access_info, status.account
)
btnTranslate -> shareUrl(
status,
CustomShareTarget.Translate
)
btnCustomShare1 -> shareUrl(
status,
CustomShareTarget.CustomShare1
)
btnCustomShare2 -> shareUrl(
status,
CustomShareTarget.CustomShare2
)
btnCustomShare3 -> shareUrl(
status,
CustomShareTarget.CustomShare3
)
btnFollow2 ->
Action_Follow.followFromAnotherAccount(
activity, activity.nextPosition(column), access_info, status.account
)
btnTranslate -> shareUrl(status, CustomShareTarget.Translate)
btnCustomShare1 -> shareUrl(status, CustomShareTarget.CustomShare1)
btnCustomShare2 -> shareUrl(status, CustomShareTarget.CustomShare2)
btnCustomShare3 -> shareUrl(status, CustomShareTarget.CustomShare3)
}
return true
}

View File

@ -0,0 +1,334 @@
package jp.juggler.subwaytooter.action
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.ColumnType
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TootConversationSummary
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.dialog.ActionsDialog
import jp.juggler.subwaytooter.findStatus
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.matchHost
import jp.juggler.subwaytooter.util.openCustomTab
import jp.juggler.util.*
import java.util.ArrayList
object Action_Conversation {
private val log= LogCategory("Action_Conversation")
private val reDetailedStatusTime =
"""<a\b[^>]*?\bdetailed-status__datetime\b[^>]*href="https://[^/]+/@[^/]+/([^\s?#/"]+)"""
.asciiPattern()
/////////////////////////////////////////////////////////////////////////////////////
// open conversation
internal fun clearConversationUnread(
activity: ActMain,
access_info: SavedAccount,
conversationSummary: TootConversationSummary?
) {
conversationSummary ?: return
TootTaskRunner(activity, progress_style = TootTaskRunner.PROGRESS_NONE)
.run(access_info, object : TootTask {
override suspend fun background(client: TootApiClient): TootApiResult? {
return client.request(
"/api/v1/conversations/${conversationSummary.id}/read",
"".toFormRequestBody().toPost()
)
}
override suspend fun handleResult(result: TootApiResult?) {
// 何もしない
}
})
}
// ローカルかリモートか判断する
fun conversation(
activity: ActMain,
pos: Int,
access_info: SavedAccount,
status: TootStatus
) {
if (access_info.isNA || !access_info.matchHost(status.readerApDomain)) {
conversationOtherInstance(activity, pos, status)
} else {
conversationLocal(activity, pos, access_info, status.id)
}
}
// ローカルから見える会話の流れを表示する
fun conversationLocal(
activity: ActMain,
pos: Int,
access_info: SavedAccount,
status_id: EntityId
) {
activity.addColumn(pos, access_info, ColumnType.CONVERSATION, status_id)
}
// リモートかもしれない会話の流れを表示する
fun conversationOtherInstance(
activity: ActMain, pos: Int, status: TootStatus?
) {
if (status == null) return
val url = status.url
if (url == null || url.isEmpty()) {
// URLが不明なトゥートというのはreblogの外側のアレ
return
}
when {
// 検索サービスではステータスTLをどのタンスから読んだのか分からない
status.readerApDomain == null ->
conversationOtherInstance(
activity, pos, url, TootStatus.validStatusId(status.id)
?: TootStatus.findStatusIdFromUri(
status.uri,
status.url
)
)
// TLアカウントのホストとトゥートのアカウントのホストが同じ
status.originalApDomain == status.readerApDomain ->
conversationOtherInstance(
activity, pos, url, TootStatus.validStatusId(status.id)
?: TootStatus.findStatusIdFromUri(
status.uri,
status.url
)
)
else -> {
// トゥートを取得したタンスと投稿元タンスが異なる場合
// status.id はトゥートを取得したタンスでのIDである
// 投稿元タンスでのIDはuriやURLから調べる
// pleromaではIDがuuidなので失敗する(その時はURLを検索してIDを見つける)
conversationOtherInstance(
activity, pos, url, TootStatus.findStatusIdFromUri(
status.uri,
status.url
), status.readerApDomain, TootStatus.validStatusId(status.id)
)
}
}
}
// アプリ外部からURLを渡された場合に呼ばれる
fun conversationOtherInstance(
activity: ActMain,
pos: Int,
url: String,
status_id_original: EntityId? = null,
host_access: Host? = null,
status_id_access: EntityId? = null
) {
val dialog = ActionsDialog()
val host_original = Host.parse(url.toUri().authority ?: "")
// 選択肢:ブラウザで表示する
dialog.addAction(activity.getString(R.string.open_web_on_host, host_original.pretty))
{ activity.openCustomTab(url) }
// トゥートの投稿元タンスにあるアカウント
val local_account_list = ArrayList<SavedAccount>()
// TLを読んだタンスにあるアカウント
val access_account_list = ArrayList<SavedAccount>()
// その他のタンスにあるアカウント
val other_account_list = ArrayList<SavedAccount>()
for (a in SavedAccount.loadAccountList(activity)) {
// 疑似アカウントは後でまとめて処理する
if (a.isPseudo) continue
if (status_id_original != null && a.matchHost(host_original)) {
// アクセス情報ステータスID でアクセスできるなら
// 同タンスのアカウントならステータスIDの変換なしに表示できる
local_account_list.add(a)
} else if (status_id_access != null && a.matchHost(host_access)) {
// 既に変換済みのステータスIDがあるなら、そのアカウントでもステータスIDの変換は必要ない
access_account_list.add(a)
} else {
// 別タンスでも実アカウントなら検索APIでステータスIDを変換できる
other_account_list.add(a)
}
}
// 同タンスのアカウントがないなら、疑似アカウントで開く選択肢
if (local_account_list.isEmpty()) {
if (status_id_original != null) {
dialog.addAction(
activity.getString(R.string.open_in_pseudo_account, "?@${host_original.pretty}")
) {
addPseudoAccount(activity, host_original) { sa ->
conversationLocal(activity, pos, sa, status_id_original)
}
}
} else {
dialog.addAction(
activity.getString(R.string.open_in_pseudo_account, "?@${host_original.pretty}")
) {
addPseudoAccount(activity, host_original) { sa ->
conversationRemote(activity, pos, sa, url)
}
}
}
}
// ローカルアカウント
if (status_id_original != null) {
SavedAccount.sort(local_account_list)
for (a in local_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { conversationLocal(activity, pos, a, status_id_original) }
}
}
// アクセスしたアカウント
if (status_id_access != null) {
SavedAccount.sort(access_account_list)
for (a in access_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { conversationLocal(activity, pos, a, status_id_access) }
}
}
// その他の実アカウント
SavedAccount.sort(other_account_list)
for (a in other_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { conversationRemote(activity, pos, a, url) }
}
dialog.show(activity, activity.getString(R.string.open_status_from))
}
private fun conversationRemote(
activity: ActMain, pos: Int, access_info: SavedAccount, remote_status_url: String
) {
TootTaskRunner(activity)
.progressPrefix(activity.getString(R.string.progress_synchronize_toot))
.run(access_info, object : TootTask {
var local_status_id: EntityId? = null
override suspend fun background(client: TootApiClient): TootApiResult? =
if (access_info.isPseudo) {
// 疑似アカウントではURLからIDを取得するのにHTMLと正規表現を使う
val result = client.getHttp(remote_status_url)
val string = result?.string
if (string != null) {
try {
val m = reDetailedStatusTime.matcher(string)
if (m.find()) {
local_status_id = EntityId(m.groupEx(1)!!)
}
} catch (ex: Throwable) {
log.e(ex, "openStatusRemote: can't parse status id from HTML data.")
}
if (result.error == null && local_status_id == null) {
result.setError(activity.getString(R.string.status_id_conversion_failed))
}
}
result
} else {
val (result, status) = client.syncStatus(access_info, remote_status_url)
if (status != null) {
local_status_id = status.id
log.d("status id conversion %s => %s", remote_status_url, status.id)
}
result
}
override suspend fun handleResult(result: TootApiResult?) {
if (result == null) return // cancelled.
val local_status_id = this.local_status_id
if (local_status_id != null) {
conversationLocal(activity, pos, access_info, local_status_id)
} else {
activity.showToast(true, result.error)
}
}
})
}
////////////////////////////////////////
fun muteConversation(
activity: ActMain, access_info: SavedAccount, status: TootStatus
) {
// toggle change
val bMute = !status.muted
TootTaskRunner(activity).run(access_info, object : TootTask {
var local_status: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val result = client.request(
"/api/v1/statuses/${status.id}/${if (bMute) "mute" else "unmute"}",
"".toFormRequestBody().toPost()
)
local_status = TootParser(activity, access_info).status(result?.jsonObject)
return result
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return // cancelled.
val ls = local_status
if (ls != null) {
for (column in activity.app_state.columnList) {
if (access_info == column.access_info) {
column.findStatus(access_info.apDomain, ls.id) { _, status ->
status.muted = bMute
true
}
}
}
activity.showToast(
true,
if (bMute) R.string.mute_succeeded else R.string.unmute_succeeded
)
} else {
activity.showToast(true, result.error)
}
}
})
}
}

View File

@ -0,0 +1,491 @@
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.Host
import jp.juggler.subwaytooter.api.entity.TootReaction
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.dialog.AccountPicker
import jp.juggler.subwaytooter.dialog.DlgConfirm
import jp.juggler.subwaytooter.dialog.EmojiPicker
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.*
object Action_Reaction {
// 長押しでない普通のリアクション操作
fun addReaction(
activity: ActMain,
column: Column,
status: TootStatus,
// Unicode絵文字、 :name: :name@.: :name@domain: name name@domain 等
codeArg: String? = null,
urlArg: String? = null,
// 確認済みなら真
isConfirmed: Boolean = false
) {
if (status.reactionSet?.myReaction != null) {
activity.showToast(false, R.string.already_reactioned)
return
}
val access_info = column.access_info
var code = codeArg
if (code == null) {
EmojiPicker(activity, access_info, closeOnSelected = true) { result ->
var newUrl: String? = null
val newCode: String = when (val emoji = result.emoji) {
is UnicodeEmoji -> emoji.unifiedCode
is CustomEmoji -> {
newUrl = emoji.static_url
if (access_info.isMisskey) {
":${emoji.shortcode}:"
} else {
emoji.shortcode
}
}
}
addReaction(activity, column, status, newCode, newUrl)
}.show()
return
}
if (access_info.isMisskey) {
val pair = TootReaction.splitEmojiDomain(code)
when (/* val domain = */ pair.second) {
null, "", ".", access_info.apDomain.ascii -> {
// normalize to local custom emoji
code = ":${pair.first}:"
}
else -> {
/*
#misskey のリアクションAPIはリモートのカスタム絵文字のコードをフォールバック絵文字に変更して
何の追加情報もなしに204 no contentを返す
よってクライアントはAPI応答からフォールバックが発生したことを認識できず
後から投稿をリロードするまで気が付かない
この挙動はこの挙動は多くのユーザにとって受け入れられないと判断するので
クライアント側で事前にエラー扱いにする方が良い
*/
activity.showToast(true, R.string.cant_reaction_remote_custom_emoji, code)
return
}
}
}
if (!isConfirmed) {
val options = DecodeOptions(
activity,
access_info,
decodeEmoji = true,
enlargeEmoji = 1.5f,
enlargeCustomEmoji = 1.5f
)
val emojiSpan = TootReaction.toSpannableStringBuilder(options, code, urlArg)
DlgConfirm.open(
activity,
activity.getString(R.string.confirm_reaction, emojiSpan, AcctColor.getNickname(access_info)),
object : DlgConfirm.Callback {
override var isConfirmEnabled: Boolean
get() = access_info.confirm_reaction
set(bv) {
access_info.confirm_reaction = bv
access_info.saveSetting()
}
override fun onOK() {
addReaction(
activity,
column,
status,
codeArg = code,
urlArg = urlArg,
isConfirmed = true
)
}
})
return
}
TootTaskRunner(activity, progress_style = TootTaskRunner.PROGRESS_NONE).run(access_info,
object : TootTask {
var newStatus: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
return if (access_info.isMisskey) {
client.request("/api/notes/reactions/create", access_info.putMisskeyApiToken().apply {
put("noteId", status.id.toString())
put("reaction", code)
}.toPostRequestBuilder())
// 成功すると204 no content
} else {
client.request(
"/api/v1/statuses/${status.id}/emoji_reactions/${code.encodePercent("@")}",
"".toFormRequestBody().toPut()
)
// 成功すると新しいステータス
?.also { result ->
newStatus = TootParser(activity, access_info).status(result.jsonObject)
}
}
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return
val error = result.error
if (error != null) {
activity.showToast(false, error)
return
}
when (val resCode = result.response?.code) {
in 200 until 300 -> {
if (newStatus != null) {
activity.app_state.columnList.forEach { column ->
if (column.access_info.acct == access_info.acct)
column.updateEmojiReactionByApiResponse(newStatus)
}
} else {
if (status.increaseReactionMisskey(code, true, caller = "addReaction")) {
// 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する
column.fireShowContent(
reason = "addReaction complete",
reset = true
)
}
}
}
else -> activity.showToast(false, "HTTP error $resCode")
}
}
})
}
// 長押しでない普通のリアクション操作
fun removeReaction(
activity: ActMain,
column: Column,
status: TootStatus,
confirmed: Boolean = false
) {
val access_info = column.access_info
val myReaction = status.reactionSet?.myReaction
if (myReaction == null) {
activity.showToast(false, R.string.not_reactioned)
return
}
if (!confirmed) {
AlertDialog.Builder(activity)
.setMessage(activity.getString(R.string.reaction_remove_confirm, myReaction.name))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
removeReaction(activity, column, status, confirmed = true)
}
.show()
return
}
TootTaskRunner(activity, progress_style = TootTaskRunner.PROGRESS_NONE).run(access_info,
object : TootTask {
var newStatus: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? =
if (access_info.isMisskey) {
client.request(
"/api/notes/reactions/delete",
access_info.putMisskeyApiToken().apply {
put("noteId", status.id.toString())
}
.toPostRequestBuilder()
)
// 成功すると204 no content
} else {
client.request(
"/api/v1/statuses/${status.id}/emoji_unreaction",
"".toFormRequestBody().toPost()
)
// 成功すると新しいステータス
?.also { result ->
newStatus = TootParser(activity, access_info).status(result.jsonObject)
}
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return
result.error?.let {
activity.showToast(false, it)
return
}
val resCode = result.response?.code ?: -1
if (resCode !in 200 until 300) {
activity.showToast(false, "HTTP error $resCode")
return
}
if (newStatus != null) {
activity.app_state.columnList.forEach { column ->
if (column.access_info.acct == access_info.acct)
column.updateEmojiReactionByApiResponse(newStatus)
}
} else if (status.decreaseReactionMisskey(myReaction.name, true, "removeReaction")) {
// 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する
column.fireShowContent(
reason = "removeReaction complete",
reset = true
)
}
}
})
}
// リアクションの別アカ操作で使う
// 選択済みのアカウントと同期済みのステータスにリアクションを行う
private fun reactionWithoutUi(
activity: ActMain,
access_info: SavedAccount,
resolvedStatus: TootStatus,
reactionCode: String? = null,
reactionImage: String? = null,
isConfirmed: Boolean = false,
callback: () -> Unit,
) {
if (reactionCode == null) {
EmojiPicker(activity, access_info, closeOnSelected = true) { result ->
var newUrl :String? = null
val newCode = when (val emoji = result.emoji) {
is UnicodeEmoji -> emoji.unifiedCode
is CustomEmoji ->{
newUrl = emoji.static_url
if (access_info.isMisskey) {
":${emoji.shortcode}:"
} else {
emoji.shortcode
}
}
}
reactionWithoutUi(
activity = activity,
access_info = access_info,
resolvedStatus = resolvedStatus,
reactionCode = newCode,
reactionImage = newUrl,
isConfirmed = isConfirmed,
callback = callback
)
}.show()
return
}
if (!isConfirmed) {
val options = DecodeOptions(
activity,
access_info,
decodeEmoji = true,
enlargeEmoji = 1.5f,
enlargeCustomEmoji = 1.5f
)
val emojiSpan = TootReaction.toSpannableStringBuilder(options, reactionCode, reactionImage)
DlgConfirm.open(
activity,
activity.getString(R.string.confirm_reaction, emojiSpan, AcctColor.getNickname(access_info)),
object : DlgConfirm.Callback {
override var isConfirmEnabled: Boolean
get() = access_info.confirm_reaction
set(bv) {
access_info.confirm_reaction = bv
access_info.saveSetting()
}
override fun onOK() {
reactionWithoutUi(
activity = activity,
access_info = access_info,
resolvedStatus = resolvedStatus,
reactionCode = reactionCode,
reactionImage = reactionImage,
isConfirmed = true,
callback = callback
)
}
})
return
}
TootTaskRunner(activity, TootTaskRunner.PROGRESS_NONE).run(access_info,
object : TootTask {
var newStatus: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
return if (access_info.isMisskey) {
client.request(
"/api/notes/reactions/create",
access_info.putMisskeyApiToken().apply {
put("noteId", resolvedStatus.id.toString())
put("reaction", reactionCode)
}
.toPostRequestBuilder()
)
// 成功すると204 no content
} else {
client.request(
"/api/v1/statuses/${resolvedStatus.id}/emoji_reactions/${reactionCode.encodePercent()}",
"".toFormRequestBody().toPut()
)?.also { result ->
// 成功すると更新された投稿
newStatus = TootParser(activity, access_info).status(result.jsonObject)
}
}
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return
result.error?.let {
activity.showToast(true, it)
return
}
callback()
}
})
}
// リアクションの別アカ操作で使う
// 選択済みのアカウントと同期済みのステータスと同期まえのリアクションから、同期後のリアクションコードを計算する
// 解決できなかった場合はnullを返す
private fun fixReactionCode(
activity: ActMain,
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
}
// エラー
activity.showToast(true, R.string.cant_reaction_remote_custom_emoji, newName)
return null
}
fun reactionFromAnotherAccount(
activity: ActMain,
timeline_account: SavedAccount,
statusArg: TootStatus?,
reaction: TootReaction? = null,
) {
statusArg ?: return
fun afterResolveStatus(actionAccount: SavedAccount, resolvedStatus: TootStatus) {
val code = if (reaction == null) {
null // あとで選択する
} else {
fixReactionCode(
activity = activity,
timelineAccount = timeline_account,
actionAccount = actionAccount,
reaction = reaction,
) ?: return // エラー終了の場合がある
}
reactionWithoutUi(
activity = activity,
access_info = actionAccount,
resolvedStatus = resolvedStatus,
reactionCode = code,
callback = activity.reaction_complete_callback,
)
}
Action_Account.listAccountsReactionable(activity) { list ->
if (list.isEmpty()) {
activity.showToast(false, R.string.not_available_for_current_accounts)
return@listAccountsReactionable
}
AccountPicker.pick(
activity,
accountListArg = list,
bAuto = false,
message = activity.getString(R.string.account_picker_reaction)
) { action_account ->
if (calcCrossAccountMode(timeline_account, action_account).isNotRemote) {
afterResolveStatus(action_account, statusArg)
} else {
TootTaskRunner(activity, TootTaskRunner.PROGRESS_NONE).run(action_account,
object : TootTask {
var newStatus: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val (result, status) = client.syncStatus(action_account, statusArg)
if (status?.reactionSet?.myReaction != null) {
return TootApiResult(activity.getString(R.string.already_reactioned))
}
newStatus = status
return result
}
override suspend fun handleResult(result: TootApiResult?) {
result?.error?.let {
activity.showToast(true, it)
return
}
newStatus?.let { afterResolveStatus(action_account, it) }
}
})
}
}
}
}
}

View File

@ -0,0 +1,195 @@
package jp.juggler.subwaytooter.action
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.ActPost
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.dialog.AccountPicker
import jp.juggler.subwaytooter.dialog.ActionsDialog
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.SavedAccountCallback
import jp.juggler.subwaytooter.util.matchHost
import jp.juggler.util.showToast
import java.util.*
object Action_Reply {
/////////////////////////////////////////////////////////////////////////////////
// reply
fun reply(
activity: ActMain,
access_info: SavedAccount,
status: TootStatus,
quote: Boolean = false
) {
activity.launchActPost(
ActPost.createIntent(
activity,
access_info.db_id,
reply_status = status,
quote = quote
)
)
}
private fun replyRemote(
activity: ActMain,
access_info: SavedAccount,
remote_status_url: String?,
quote: Boolean = false
) {
if (remote_status_url == null || remote_status_url.isEmpty()) return
TootTaskRunner(activity)
.progressPrefix(activity.getString(R.string.progress_synchronize_toot))
.run(access_info, object : TootTask {
var local_status: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val (result, status) = client.syncStatus(access_info, remote_status_url)
local_status = status
return result
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return // cancelled.
val ls = local_status
if (ls != null) {
reply(activity, access_info, ls, quote = quote)
} else {
activity.showToast(true, result.error)
}
}
})
}
fun replyFromAnotherAccount(
activity: ActMain,
timeline_account: SavedAccount,
status: TootStatus?,
quote: Boolean = false
) {
status ?: return
val accountCallback: SavedAccountCallback = { ai ->
if (ai.matchHost(status.readerApDomain)) {
// アクセス元ホストが同じならステータスIDを使って返信できる
reply(activity, ai, status, quote = quote)
} else {
// それ以外の場合、ステータスのURLを検索APIに投げることで返信できる
replyRemote(activity, ai, status.url, quote = quote)
}
}
if (quote) {
AccountPicker.pick(
activity,
bAllowPseudo = false,
bAllowMisskey = true,
bAllowMastodon = true,
bAuto = true,
message = activity.getString(R.string.account_picker_quote_toot),
callback = accountCallback
)
} else {
AccountPicker.pick(
activity,
bAllowPseudo = false,
bAuto = false,
message = activity.getString(R.string.account_picker_reply),
accountListArg = makeAccountListNonPseudo(activity, timeline_account.apDomain),
callback = accountCallback
)
}
}
//////////////////////////////////////////////////
// tootsearch APIは投稿の返信元を示すreplyの情報がない。
// in_reply_to_idを参照するしかない
// ところがtootsearchでは投稿をどのタンスから読んだか分からないので、IDは全面的に信用できない。
// 疑似ではないアカウントを選んだ後に表示中の投稿を検索APIで調べて、そのリプライのIDを取得しなおす
fun showReplyTootsearch(
activity: ActMain,
pos: Int,
statusArg: TootStatus?
) {
statusArg ?: return
// step2: 選択したアカウントで投稿を検索して返信元の投稿のIDを調べる
fun step2(a: SavedAccount) = TootTaskRunner(activity).run(a, object : TootTask {
var tmp: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val (result, status) = client.syncStatus(a, statusArg)
this.tmp = status
return result
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return
val status = tmp
val replyId = status?.in_reply_to_id
when {
status == null -> activity.showToast(true, result.error ?: "?")
replyId == null -> activity.showToast(
true,
"showReplyTootsearch: in_reply_to_id is null"
)
else -> Action_Conversation.conversationLocal(activity, pos, a, replyId)
}
}
})
// step 1: choose account
val host = statusArg.account.apDomain
val local_account_list = ArrayList<SavedAccount>()
val other_account_list = ArrayList<SavedAccount>()
for (a in SavedAccount.loadAccountList(activity)) {
// 検索APIはログイン必須なので疑似アカウントは使えない
if (a.isPseudo) continue
if (a.matchHost(host)) {
local_account_list.add(a)
} else {
other_account_list.add(a)
}
}
val dialog = ActionsDialog()
SavedAccount.sort(local_account_list)
for (a in local_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { step2(a) }
}
SavedAccount.sort(other_account_list)
for (a in other_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { step2(a) }
}
dialog.show(activity, activity.getString(R.string.open_status_from))
}
}

View File

@ -1,20 +1,14 @@
package jp.juggler.subwaytooter.action
import androidx.appcompat.app.AlertDialog
import jp.juggler.subwaytooter.emoji.UnicodeEmoji
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.dialog.AccountPicker
import jp.juggler.subwaytooter.dialog.ActionsDialog
import jp.juggler.subwaytooter.dialog.DlgConfirm
import jp.juggler.subwaytooter.dialog.EmojiPicker
import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.SavedAccountCallback
import jp.juggler.subwaytooter.util.matchHost
import jp.juggler.subwaytooter.util.openCustomTab
import jp.juggler.util.*
import okhttp3.Request
import java.util.*
@ -22,12 +16,6 @@ import kotlin.math.max
object Action_Toot {
private val log = LogCategory("Action_Toot")
private val reDetailedStatusTime =
"""<a\b[^>]*?\bdetailed-status__datetime\b[^>]*href="https://[^/]+/@[^/]+/([^\s?#/"]+)"""
.asciiPattern()
// アカウントを選んでお気に入り
fun favouriteFromAnotherAccount(
activity: ActMain,
@ -710,344 +698,6 @@ object Action_Toot {
})
}
/////////////////////////////////////////////////////////////////////////////////////
// open conversation
internal fun clearConversationUnread(
activity: ActMain,
access_info: SavedAccount,
conversationSummary: TootConversationSummary?
) {
conversationSummary ?: return
TootTaskRunner(activity, progress_style = TootTaskRunner.PROGRESS_NONE)
.run(access_info, object : TootTask {
override suspend fun background(client: TootApiClient): TootApiResult? {
return client.request(
"/api/v1/conversations/${conversationSummary.id}/read",
"".toFormRequestBody().toPost()
)
}
override suspend fun handleResult(result: TootApiResult?) {
// 何もしない
}
})
}
// ローカルかリモートか判断する
fun conversation(
activity: ActMain,
pos: Int,
access_info: SavedAccount,
status: TootStatus
) {
if (access_info.isNA || !access_info.matchHost(status.readerApDomain)) {
conversationOtherInstance(activity, pos, status)
} else {
conversationLocal(activity, pos, access_info, status.id)
}
}
// ローカルから見える会話の流れを表示する
fun conversationLocal(
activity: ActMain,
pos: Int,
access_info: SavedAccount,
status_id: EntityId
) {
activity.addColumn(pos, access_info, ColumnType.CONVERSATION, status_id)
}
// リモートかもしれない会話の流れを表示する
fun conversationOtherInstance(
activity: ActMain, pos: Int, status: TootStatus?
) {
if (status == null) return
val url = status.url
if (url == null || url.isEmpty()) {
// URLが不明なトゥートというのはreblogの外側のアレ
return
}
when {
// 検索サービスではステータスTLをどのタンスから読んだのか分からない
status.readerApDomain == null ->
conversationOtherInstance(
activity, pos, url, TootStatus.validStatusId(status.id)
?: TootStatus.findStatusIdFromUri(
status.uri,
status.url
)
)
// TLアカウントのホストとトゥートのアカウントのホストが同じ
status.originalApDomain == status.readerApDomain ->
conversationOtherInstance(
activity, pos, url, TootStatus.validStatusId(status.id)
?: TootStatus.findStatusIdFromUri(
status.uri,
status.url
)
)
else -> {
// トゥートを取得したタンスと投稿元タンスが異なる場合
// status.id はトゥートを取得したタンスでのIDである
// 投稿元タンスでのIDはuriやURLから調べる
// pleromaではIDがuuidなので失敗する(その時はURLを検索してIDを見つける)
conversationOtherInstance(
activity, pos, url, TootStatus.findStatusIdFromUri(
status.uri,
status.url
), status.readerApDomain, TootStatus.validStatusId(status.id)
)
}
}
}
// アプリ外部からURLを渡された場合に呼ばれる
fun conversationOtherInstance(
activity: ActMain,
pos: Int,
url: String,
status_id_original: EntityId? = null,
host_access: Host? = null,
status_id_access: EntityId? = null
) {
val dialog = ActionsDialog()
val host_original = Host.parse(url.toUri().authority ?: "")
// 選択肢:ブラウザで表示する
dialog.addAction(activity.getString(R.string.open_web_on_host, host_original.pretty))
{ activity.openCustomTab(url) }
// トゥートの投稿元タンスにあるアカウント
val local_account_list = ArrayList<SavedAccount>()
// TLを読んだタンスにあるアカウント
val access_account_list = ArrayList<SavedAccount>()
// その他のタンスにあるアカウント
val other_account_list = ArrayList<SavedAccount>()
for (a in SavedAccount.loadAccountList(activity)) {
// 疑似アカウントは後でまとめて処理する
if (a.isPseudo) continue
if (status_id_original != null && a.matchHost(host_original)) {
// アクセス情報ステータスID でアクセスできるなら
// 同タンスのアカウントならステータスIDの変換なしに表示できる
local_account_list.add(a)
} else if (status_id_access != null && a.matchHost(host_access)) {
// 既に変換済みのステータスIDがあるなら、そのアカウントでもステータスIDの変換は必要ない
access_account_list.add(a)
} else {
// 別タンスでも実アカウントなら検索APIでステータスIDを変換できる
other_account_list.add(a)
}
}
// 同タンスのアカウントがないなら、疑似アカウントで開く選択肢
if (local_account_list.isEmpty()) {
if (status_id_original != null) {
dialog.addAction(
activity.getString(R.string.open_in_pseudo_account, "?@${host_original.pretty}")
) {
addPseudoAccount(activity, host_original) { sa ->
conversationLocal(activity, pos, sa, status_id_original)
}
}
} else {
dialog.addAction(
activity.getString(R.string.open_in_pseudo_account, "?@${host_original.pretty}")
) {
addPseudoAccount(activity, host_original) { sa ->
conversationRemote(activity, pos, sa, url)
}
}
}
}
// ローカルアカウント
if (status_id_original != null) {
SavedAccount.sort(local_account_list)
for (a in local_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { conversationLocal(activity, pos, a, status_id_original) }
}
}
// アクセスしたアカウント
if (status_id_access != null) {
SavedAccount.sort(access_account_list)
for (a in access_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { conversationLocal(activity, pos, a, status_id_access) }
}
}
// その他の実アカウント
SavedAccount.sort(other_account_list)
for (a in other_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { conversationRemote(activity, pos, a, url) }
}
dialog.show(activity, activity.getString(R.string.open_status_from))
}
private fun conversationRemote(
activity: ActMain, pos: Int, access_info: SavedAccount, remote_status_url: String
) {
TootTaskRunner(activity)
.progressPrefix(activity.getString(R.string.progress_synchronize_toot))
.run(access_info, object : TootTask {
var local_status_id: EntityId? = null
override suspend fun background(client: TootApiClient): TootApiResult? =
if (access_info.isPseudo) {
// 疑似アカウントではURLからIDを取得するのにHTMLと正規表現を使う
val result = client.getHttp(remote_status_url)
val string = result?.string
if (string != null) {
try {
val m = reDetailedStatusTime.matcher(string)
if (m.find()) {
local_status_id = EntityId(m.groupEx(1)!!)
}
} catch (ex: Throwable) {
log.e(ex, "openStatusRemote: can't parse status id from HTML data.")
}
if (result.error == null && local_status_id == null) {
result.setError(activity.getString(R.string.status_id_conversion_failed))
}
}
result
} else {
val (result, status) = client.syncStatus(access_info, remote_status_url)
if (status != null) {
local_status_id = status.id
log.d("status id conversion %s => %s", remote_status_url, status.id)
}
result
}
override suspend fun handleResult(result: TootApiResult?) {
if (result == null) return // cancelled.
val local_status_id = this.local_status_id
if (local_status_id != null) {
conversationLocal(activity, pos, access_info, local_status_id)
} else {
activity.showToast(true, result.error)
}
}
})
}
// tootsearch APIは投稿の返信元を示すreplyの情報がない。
// in_reply_to_idを参照するしかない
// ところがtootsearchでは投稿をどのタンスから読んだか分からないので、IDは全面的に信用できない。
// 疑似ではないアカウントを選んだ後に表示中の投稿を検索APIで調べて、そのリプライのIDを取得しなおす
fun showReplyTootsearch(
activity: ActMain,
pos: Int,
statusArg: TootStatus?
) {
statusArg ?: return
// step2: 選択したアカウントで投稿を検索して返信元の投稿のIDを調べる
fun step2(a: SavedAccount) = TootTaskRunner(activity).run(a, object : TootTask {
var tmp: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val (result, status) = client.syncStatus(a, statusArg)
this.tmp = status
return result
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return
val status = tmp
val replyId = status?.in_reply_to_id
when {
status == null -> activity.showToast(true, result.error ?: "?")
replyId == null -> activity.showToast(
true,
"showReplyTootsearch: in_reply_to_id is null"
)
else -> conversationLocal(activity, pos, a, replyId)
}
}
})
// step 1: choose account
val host = statusArg.account.apDomain
val local_account_list = ArrayList<SavedAccount>()
val other_account_list = ArrayList<SavedAccount>()
for (a in SavedAccount.loadAccountList(activity)) {
// 検索APIはログイン必須なので疑似アカウントは使えない
if (a.isPseudo) continue
if (a.matchHost(host)) {
local_account_list.add(a)
} else {
other_account_list.add(a)
}
}
val dialog = ActionsDialog()
SavedAccount.sort(local_account_list)
for (a in local_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { step2(a) }
}
SavedAccount.sort(other_account_list)
for (a in other_account_list) {
dialog.addAction(
AcctColor.getStringWithNickname(
activity,
R.string.open_in_account,
a.acct
)
) { step2(a) }
}
dialog.show(activity, activity.getString(R.string.open_status_from))
}
////////////////////////////////////////
// profile pin
@ -1107,99 +757,6 @@ object Action_Toot {
}
/////////////////////////////////////////////////////////////////////////////////
// reply
fun reply(
activity: ActMain,
access_info: SavedAccount,
status: TootStatus,
quote: Boolean = false
) {
activity.launchActPost(
ActPost.createIntent(
activity,
access_info.db_id,
reply_status = status,
quote = quote
)
)
}
private fun replyRemote(
activity: ActMain,
access_info: SavedAccount,
remote_status_url: String?,
quote: Boolean = false
) {
if (remote_status_url == null || remote_status_url.isEmpty()) return
TootTaskRunner(activity)
.progressPrefix(activity.getString(R.string.progress_synchronize_toot))
.run(access_info, object : TootTask {
var local_status: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val (result, status) = client.syncStatus(access_info, remote_status_url)
local_status = status
return result
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return // cancelled.
val ls = local_status
if (ls != null) {
reply(activity, access_info, ls, quote = quote)
} else {
activity.showToast(true, result.error)
}
}
})
}
fun replyFromAnotherAccount(
activity: ActMain,
timeline_account: SavedAccount,
status: TootStatus?,
quote: Boolean = false
) {
status ?: return
val accountCallback: SavedAccountCallback = { ai ->
if (ai.matchHost(status.readerApDomain)) {
// アクセス元ホストが同じならステータスIDを使って返信できる
reply(activity, ai, status, quote = quote)
} else {
// それ以外の場合、ステータスのURLを検索APIに投げることで返信できる
replyRemote(activity, ai, status.url, quote = quote)
}
}
if (quote) {
AccountPicker.pick(
activity,
bAllowPseudo = false,
bAllowMisskey = true,
bAllowMastodon = true,
bAuto = true,
message = activity.getString(R.string.account_picker_quote_toot),
callback = accountCallback
)
} else {
AccountPicker.pick(
activity,
bAllowPseudo = false,
bAuto = false,
message = activity.getString(R.string.account_picker_reply),
accountListArg = makeAccountListNonPseudo(activity, timeline_account.apDomain),
callback = accountCallback
)
}
}
// 投稿画面を開く。初期テキストを指定する
fun redraft(
activity: ActMain,
@ -1260,437 +817,6 @@ object Action_Toot {
}
})
}
////////////////////////////////////////
fun muteConversation(
activity: ActMain, access_info: SavedAccount, status: TootStatus
) {
// toggle change
val bMute = !status.muted
TootTaskRunner(activity).run(access_info, object : TootTask {
var local_status: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val result = client.request(
"/api/v1/statuses/${status.id}/${if (bMute) "mute" else "unmute"}",
"".toFormRequestBody().toPost()
)
local_status = TootParser(activity, access_info).status(result?.jsonObject)
return result
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return // cancelled.
val ls = local_status
if (ls != null) {
for (column in activity.app_state.columnList) {
if (access_info == column.access_info) {
column.findStatus(access_info.apDomain, ls.id) { _, status ->
status.muted = bMute
true
}
}
}
activity.showToast(
true,
if (bMute) R.string.mute_succeeded else R.string.unmute_succeeded
)
} else {
activity.showToast(true, result.error)
}
}
})
}
// 長押しでない普通のリアクション操作
fun addReaction(
activity: ActMain,
column: Column,
status: TootStatus,
codeArg: String? = null // Unicode絵文字、 :name: :name@.: :name@domain: name name@domain 等
) {
if (status.reactionSet?.myReaction != null) {
activity.showToast(false, R.string.already_reactioned)
return
}
val access_info = column.access_info
var code = codeArg
if (code == null) {
EmojiPicker(activity, access_info, closeOnSelected = true) { result ->
val newCode = when (val emoji = result.emoji) {
is UnicodeEmoji -> emoji.unifiedCode
is CustomEmoji -> if (access_info.isMisskey) {
":${emoji.shortcode}:"
} else {
emoji.shortcode
}
}
addReaction(activity, column, status, newCode)
}.show()
return
}
if (access_info.isMisskey) {
val pair = TootReaction.splitEmojiDomain(code)
when (/* val domain = */ pair.second) {
null, "", ".", access_info.apDomain.ascii -> {
// normalize to local custom emoji
code = ":${pair.first}:"
}
else -> {
/*
#misskey のリアクションAPIはリモートのカスタム絵文字のコードをフォールバック絵文字に変更して
何の追加情報もなしに204 no contentを返す
よってクライアントはAPI応答からフォールバックが発生したことを認識できず
後から投稿をリロードするまで気が付かない
この挙動はこの挙動は多くのユーザにとって受け入れられないと判断するので
クライアント側で事前にエラー扱いにする方が良い
*/
activity.showToast(true, R.string.cant_reaction_remote_custom_emoji, code)
return
}
}
}
TootTaskRunner(activity, progress_style = TootTaskRunner.PROGRESS_NONE).run(access_info,
object : TootTask {
var newStatus: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
return if (access_info.isMisskey) {
client.request("/api/notes/reactions/create", access_info.putMisskeyApiToken().apply {
put("noteId", status.id.toString())
put("reaction", code)
}.toPostRequestBuilder())
// 成功すると204 no content
} else {
client.request(
"/api/v1/statuses/${status.id}/emoji_reactions/${code.encodePercent("@")}",
"".toFormRequestBody().toPut()
)
// 成功すると新しいステータス
?.also { result ->
newStatus = TootParser(activity, access_info).status(result.jsonObject)
}
}
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return
val error = result.error
if (error != null) {
activity.showToast(false, error)
return
}
when (val resCode = result.response?.code) {
in 200 until 300 -> {
if (newStatus != null) {
activity.app_state.columnList.forEach { column ->
if (column.access_info.acct == access_info.acct)
column.updateEmojiReactionByApiResponse(newStatus)
}
} else {
if (status.increaseReactionMisskey(code, true, caller = "addReaction")) {
// 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する
column.fireShowContent(
reason = "addReaction complete",
reset = true
)
}
}
}
else -> activity.showToast(false, "HTTP error $resCode")
}
}
})
}
// 長押しでない普通のリアクション操作
fun removeReaction(
activity: ActMain,
column: Column,
status: TootStatus,
confirmed: Boolean = false
) {
val access_info = column.access_info
val myReaction = status.reactionSet?.myReaction
if (myReaction == null) {
activity.showToast(false, R.string.not_reactioned)
return
}
if (!confirmed) {
AlertDialog.Builder(activity)
.setMessage(activity.getString(R.string.reaction_remove_confirm, myReaction.name))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
removeReaction(activity, column, status, confirmed = true)
}
.show()
return
}
TootTaskRunner(activity, progress_style = TootTaskRunner.PROGRESS_NONE).run(access_info,
object : TootTask {
var newStatus: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? =
if (access_info.isMisskey) {
client.request(
"/api/notes/reactions/delete",
access_info.putMisskeyApiToken().apply {
put("noteId", status.id.toString())
}
.toPostRequestBuilder()
)
// 成功すると204 no content
} else {
client.request(
"/api/v1/statuses/${status.id}/emoji_unreaction",
"".toFormRequestBody().toPost()
)
// 成功すると新しいステータス
?.also { result ->
newStatus = TootParser(activity, access_info).status(result.jsonObject)
}
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return
result.error?.let {
activity.showToast(false, it)
return
}
val resCode = result.response?.code ?: -1
if (resCode !in 200 until 300) {
activity.showToast(false, "HTTP error $resCode")
return
}
if (newStatus != null) {
activity.app_state.columnList.forEach { column ->
if (column.access_info.acct == access_info.acct)
column.updateEmojiReactionByApiResponse(newStatus)
}
} else if (status.decreaseReactionMisskey(myReaction.name, true, "removeReaction")) {
// 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する
column.fireShowContent(
reason = "removeReaction complete",
reset = true
)
}
}
})
}
// リアクションの別アカ操作で使う
// 選択済みのアカウントと同期済みのステータスにリアクションを行う
private fun reactionWithoutUi(
activity: ActMain,
access_info: SavedAccount,
resolvedStatus: TootStatus,
reactionCode: String? = null,
callback: () -> Unit,
) {
if (reactionCode == null) {
EmojiPicker(activity, access_info, closeOnSelected = true) { result ->
val code = when (val emoji = result.emoji) {
is UnicodeEmoji -> emoji.unifiedCode
is CustomEmoji -> if (access_info.isMisskey) {
":${emoji.shortcode}:"
} else {
emoji.shortcode
}
}
reactionWithoutUi(
activity = activity,
access_info = access_info,
resolvedStatus = resolvedStatus,
reactionCode = code,
callback = callback
)
}.show()
return
}
TootTaskRunner(activity, TootTaskRunner.PROGRESS_NONE).run(access_info,
object : TootTask {
var newStatus: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
return if (access_info.isMisskey) {
client.request(
"/api/notes/reactions/create",
access_info.putMisskeyApiToken().apply {
put("noteId", resolvedStatus.id.toString())
put("reaction", reactionCode)
}
.toPostRequestBuilder()
)
// 成功すると204 no content
} else {
client.request(
"/api/v1/statuses/${resolvedStatus.id}/emoji_reactions/${reactionCode.encodePercent()}",
"".toFormRequestBody().toPut()
)?.also { result ->
// 成功すると更新された投稿
newStatus = TootParser(activity, access_info).status(result.jsonObject)
}
}
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return
result.error?.let {
activity.showToast(true, it)
return
}
callback()
}
})
}
// リアクションの別アカ操作で使う
// 選択済みのアカウントと同期済みのステータスと同期まえのリアクションから、同期後のリアクションコードを計算する
// 解決できなかった場合はnullを返す
private fun fixReactionCode(
activity: ActMain,
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
}
// エラー
activity.showToast(true, R.string.cant_reaction_remote_custom_emoji, newName)
return null
}
fun reactionFromAnotherAccount(
activity: ActMain,
timeline_account: SavedAccount,
statusArg: TootStatus?,
reaction: TootReaction? = null,
) {
statusArg ?: return
fun afterResolveStatus(actionAccount: SavedAccount, resolvedStatus: TootStatus) {
val code = if (reaction == null) {
null // あとで選択する
} else {
fixReactionCode(
activity = activity,
timelineAccount = timeline_account,
actionAccount = actionAccount,
reaction = reaction,
) ?: return // エラー終了の場合がある
}
reactionWithoutUi(
activity = activity,
access_info = actionAccount,
resolvedStatus = resolvedStatus,
reactionCode = code,
callback = activity.reaction_complete_callback,
)
}
Action_Account.listAccountsReactionable(activity) { list ->
if (list.isEmpty()) {
activity.showToast(false, R.string.not_available_for_current_accounts)
return@listAccountsReactionable
}
AccountPicker.pick(
activity,
accountListArg = list,
bAuto = false,
message = activity.getString(R.string.account_picker_reaction)
) { action_account ->
if (calcCrossAccountMode(timeline_account, action_account).isNotRemote) {
afterResolveStatus(action_account, statusArg)
} else {
TootTaskRunner(activity, TootTaskRunner.PROGRESS_NONE).run(action_account,
object : TootTask {
var newStatus: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val (result, status) = client.syncStatus(action_account, statusArg)
if (status?.reactionSet?.myReaction != null) {
return TootApiResult(activity.getString(R.string.already_reactioned))
}
newStatus = status
return result
}
override suspend fun handleResult(result: TootApiResult?) {
result?.error?.let {
activity.showToast(true, it)
return
}
newStatus?.let { afterResolveStatus(action_account, it) }
}
})
}
}
}
}
fun deleteScheduledPost(
activity: ActMain,

View File

@ -111,7 +111,7 @@ class TootReaction(
fun canReaction(
access_info: SavedAccount,
ti: TootInstance? = TootInstance.getCached(access_info.apiHost)
) = InstanceCapability.emojiReaction(access_info,ti)
) = InstanceCapability.emojiReaction(access_info, ti)
fun decodeEmojiQuery(jsonText: String?): List<TootReaction> =
jsonText.notEmpty()?.let { src ->
@ -130,7 +130,33 @@ class TootReaction(
}
}.toString()
fun urlToSpan(options: DecodeOptions, code: String, url: String) =
SpannableStringBuilder(code).apply {
setSpan(
NetworkEmojiSpan(url, scale = options.enlargeCustomEmoji),
0,
length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
fun toSpannableStringBuilder(
options: DecodeOptions,
code: String,
url: String?
): SpannableStringBuilder {
url?.let { return urlToSpan(options, code, url) }
if (options.linkHelper?.isMisskey == true) {
// 古い形式の絵文字はUnicode絵文字にする
misskeyOldReactions[code]?.let {
return EmojiDecoder.decodeEmoji(options, it)
}
}
return EmojiDecoder.decodeEmoji(options, code)
}
}
fun splitEmojiDomain() =
@ -160,15 +186,7 @@ class TootReaction(
url
}
fun urlToSpan(options: DecodeOptions, code: String, url: String) =
SpannableStringBuilder(code).apply {
setSpan(
NetworkEmojiSpan(url, scale = options.enlargeCustomEmoji),
0,
length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
if (options.linkHelper?.isMisskey == true) {

View File

@ -61,6 +61,7 @@ class SavedAccount(
var confirm_follow_locked : Boolean = false
var confirm_unfollow : Boolean = false
var confirm_post : Boolean = false
var confirm_reaction : Boolean = true
var notification_tag : String? = null
private var register_key : String? = null
@ -131,6 +132,7 @@ class SavedAccount(
confirm_follow_locked = cursor.getBoolean(COL_CONFIRM_FOLLOW_LOCKED)
confirm_unfollow = cursor.getBoolean(COL_CONFIRM_UNFOLLOW)
confirm_post = cursor.getBoolean(COL_CONFIRM_POST)
confirm_reaction = cursor.getBoolean(COL_CONFIRM_REACTION)
notification_mention = cursor.getBoolean(COL_NOTIFICATION_MENTION)
notification_boost = cursor.getBoolean(COL_NOTIFICATION_BOOST)
@ -224,7 +226,8 @@ class SavedAccount(
cv.put(COL_CONFIRM_FOLLOW_LOCKED, confirm_follow_locked.b2i())
cv.put(COL_CONFIRM_UNFOLLOW, confirm_unfollow.b2i())
cv.put(COL_CONFIRM_POST, confirm_post.b2i())
cv.put(COL_CONFIRM_REACTION, confirm_reaction.b2i())
cv.put(COL_SOUND_URI, sound_uri)
cv.put(COL_DEFAULT_TEXT, default_text)
@ -279,6 +282,8 @@ class SavedAccount(
this.confirm_favourite = b.confirm_favourite
this.confirm_unboost = b.confirm_unboost
this.confirm_unfavourite = b.confirm_unfavourite
this.confirm_post = b.confirm_post
this.confirm_reaction = b.confirm_reaction
this.dont_hide_nsfw = b.dont_hide_nsfw
this.dont_show_timeout = b.dont_show_timeout
@ -370,7 +375,8 @@ class SavedAccount(
private const val COL_CONFIRM_FAVOURITE = "confirm_favourite" // スキーマ23
private const val COL_CONFIRM_UNBOOST = "confirm_unboost" // スキーマ24
private const val COL_CONFIRM_UNFAVOURITE = "confirm_unfavourite" // スキーマ24
private const val COL_CONFIRM_REACTION = "confirm_reaction" // スキーマ61
// スキーマ13から
const val COL_NOTIFICATION_TAG = "notification_server"
@ -507,6 +513,9 @@ class SavedAccount(
// スキーマ60から
+ ",$COL_PUSH_POLICY text default null"
// スキーマ61から
+ ",$COL_CONFIRM_REACTION integer default 1"
+ ")"
)
db.execSQL("create index if not exists ${table}_user on ${table}(u)")
@ -749,6 +758,13 @@ class SavedAccount(
log.trace(ex)
}
}
isUpgraded(61){
try {
db.execSQL("alter table $table add column $COL_CONFIRM_REACTION integer default 1")
} catch(ex : Throwable) {
log.trace(ex)
}
}
}
val defaultResizeConfig = ResizeConfig(ResizeType.LongSide, 1280)

View File

@ -14,6 +14,7 @@ import androidx.browser.customtabs.CustomTabsIntent
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.Pref
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.action.Action_Conversation
import jp.juggler.subwaytooter.action.Action_HashTag
import jp.juggler.subwaytooter.action.Action_Toot
import jp.juggler.subwaytooter.action.Action_User
@ -235,7 +236,7 @@ fun openCustomTab(
statusInfo.statusId == null ||
!accessInfo.matchHost(statusInfo.host)
) {
Action_Toot.conversationOtherInstance(
Action_Conversation.conversationOtherInstance(
activity,
pos,
statusInfo.url,
@ -244,7 +245,7 @@ fun openCustomTab(
statusInfo.statusId
)
} else {
Action_Toot.conversationLocal(
Action_Conversation.conversationLocal(
activity,
pos,
accessInfo,

View File

@ -498,6 +498,11 @@
style="@style/setting_row_form"
android:text="@string/act_post" />
<CheckBox
android:id="@+id/cbConfirmReaction"
style="@style/setting_row_form"
android:text="@string/reaction" />
<View style="@style/setting_divider" />
<TextView

View File

@ -791,7 +791,7 @@
<string name="remove_endorse_succeeded">Recomanació eliminada.</string>
<string name="follow_request_cancelled">Sol·licitud de seguiment cancel·lada.</string>
<string name="confirm_cancel_follow_request_who_from">Cancel·lar la sol·licitud de seguiment de %2$s a %1$s\?</string>
<string name="reaction">Reacció (Misskey)</string>
<string name="reaction">Reacció</string>
<string name="vote_polls">votació o el seu resultat</string>
<string name="timeout_for_embed_media_viewer">Temps d\'espera per al visor de mèdia incorporat (Unitat:segons, cal reiniciar(eliminar de l\'historial) l\'aplicació)</string>
<string name="link_color">Color dels enllaços (cal reiniciar aplicació)</string>

View File

@ -704,7 +704,7 @@
<string name="link_color">链接颜色(应用需要重启)</string>
<string name="timeout_for_embed_media_viewer">嵌入式媒体查看器超时(单位: 秒,应用重启(从应用历史记录删除))</string>
<string name="vote_polls">投票或结果</string>
<string name="reaction">动作 (Misskey)</string>
<string name="reaction">动作</string>
<string name="confirm_cancel_follow_request_who_from">从%2$s到%1$s取消请求吗</string>
<string name="follow_request_cancelled">请求取消.</string>
<string name="remove_endorse_succeeded">未签署.</string>

View File

@ -560,7 +560,7 @@
<string name="link_color">Linkfarbe (App-Neustart erforderlich)</string>
<string name="timeout_for_embed_media_viewer">Timeout für den integrierten Medienbetrachter (Einheit: Sekunden, App-Neustart erforderlich)</string>
<string name="vote_polls">Abstimmung oder Ergebnis</string>
<string name="reaction">Reaktion (Misskey)</string>
<string name="reaction">Reaktion</string>
<string name="confirm_cancel_follow_request_who_from">Followanfrage von %2$s bis %1$s abbrechen\?</string>
<string name="follow_request_cancelled">Follow-Anfrage abgebrochen.</string>
<string name="remove_endorse_succeeded">Promotion entfernt.</string>

View File

@ -753,7 +753,7 @@
<string name="remove_endorse_succeeded">Nest plus recommandé.</string>
<string name="follow_request_cancelled">Demande dabonnement annulée.</string>
<string name="confirm_cancel_follow_request_who_from">Annuler la demande dabonnement de %2$s à %1$s \?</string>
<string name="reaction">Réaction (Misskey)</string>
<string name="reaction">Réaction</string>
<string name="timeout_for_embed_media_viewer">Délai dexpiration du visualiseur multimédia intégré (unité: secondes, redémarrage de lapplication (suppression de lhistorique de application) requis)</string>
<string name="media_attachment_max_byte_size_movie">Taille limite des octets de la pièce jointe (vidéo) (Unité : MB. La valeur par défaut est 40)</string>
<string name="link_color">Couleur des liens (redémarrage requis)</string>

View File

@ -569,7 +569,7 @@
<string name="quote_name">名前を引用…</string>
<string name="quote_url">URLを引用…</string>
<string name="rate_on_store">ストアで評価</string>
<string name="reaction">リアクション (Misskey)</string>
<string name="reaction">リアクション</string>
<string name="reaction_add">リアクションの追加</string>
<string name="reaction_remove">リアクションの削除</string>
<string name="read_gap">ギャップを読む</string>
@ -1091,4 +1091,5 @@
<string name="discard_changes">変更を破棄しますか?</string>
<string name="saved">保存しました</string>
<string name="not_available_for_current_accounts">この機能を利用できるアカウントがありません</string>
<string name="confirm_reaction">%2$s から %1$s でリアクションしますか?</string>
</resources>

View File

@ -767,7 +767,7 @@
<string name="remove_endorse_succeeded">소개 삭제됨.</string>
<string name="follow_request_cancelled">팔로우 요청 취소됨.</string>
<string name="confirm_cancel_follow_request_who_from">%2$s로부터 %1$s로의 팔로우 요청을 취소할까요\?</string>
<string name="reaction">반응 (Misskey)</string>
<string name="reaction">반응</string>
<string name="timeout_for_embed_media_viewer">내장 미디어 뷰어 시간제한 (단위:초, 앱 재시작(앱 사용이력에서 삭제) 필요)</string>
<string name="link_color">링크 색 (앱 재시작 필요)</string>
<string name="missing_closeable_column">닫을 칼럼이 표시 범위에 없음.</string>

View File

@ -698,7 +698,7 @@
<string name="already_voted">Du har allerede stemt.</string>
<string name="follow_request_cancelled">Følgingsforespørsel forkastet.</string>
<string name="confirm_cancel_follow_request_who_from">Følgingsforespørsel fra %1$s til %2$s vil forkastes. Er du sikker\?</string>
<string name="reaction">Reaksjon (Misskey)</string>
<string name="reaction">Reaksjon</string>
<string name="timeout_for_embed_media_viewer">Tidsavbrudd for innebygd mediaviser (enhet:sekunder, programomstart (sletting fra programhistorikk) kreves)</string>
<string name="link_color">Lenkefarge (programomstart kreves)</string>
<string name="missing_closeable_column">mangler lukkbar kolonne i synlig område.</string>

View File

@ -788,7 +788,7 @@
<string name="remove_endorse_succeeded">Unendorsemed.</string>
<string name="follow_request_cancelled">Follow request cancelled.</string>
<string name="confirm_cancel_follow_request_who_from">Cancel follow request from %2$s to %1$s?</string>
<string name="reaction">Reaction (Misskey)</string>
<string name="reaction">Reaction</string>
<string name="vote_polls">voting or its result</string>
<string name="timeout_for_embed_media_viewer">Timeout for embedded media viewer (Unit:seconds, app restart(delete from app history) required)</string>
<string name="link_color">Link color (app restart required)</string>
@ -1104,5 +1104,5 @@
<string name="discard_changes">Discard changes?</string>
<string name="saved">saved.</string>
<string name="not_available_for_current_accounts">Unavailable for current accounts</string>
<string name="confirm_reaction">Reaction %1$s from %2$s?</string>
</resources>