package jp.juggler.subwaytooter.action import android.text.SpannableStringBuilder import jp.juggler.emoji.EmojiMap 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.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.* import kotlin.math.max object Action_Toot { private val log = LogCategory("Action_Toot") private val reDetailedStatusTime = """]*?\bdetailed-status__datetime\b[^>]*href="https://[^/]+/@[^/]+/([^\s?#/"]+)""" .asciiPattern() // アカウントを選んでお気に入り fun favouriteFromAnotherAccount( activity: ActMain, timeline_account: SavedAccount, status: TootStatus? ) { if (status == null) return AccountPicker.pick( activity, bAllowPseudo = false, bAuto = false, message = activity.getString(R.string.account_picker_favourite), accountListArg = makeAccountListNonPseudo(activity, timeline_account.apDomain) ) { action_account -> favourite( activity, action_account, status, calcCrossAccountMode(timeline_account, action_account), callback = activity.favourite_complete_callback ) } } // お気に入りの非同期処理 fun favourite( activity: ActMain, access_info: SavedAccount, arg_status: TootStatus, nCrossAccountMode: Int, callback: () -> Unit, bSet: Boolean = true, bConfirmed: Boolean = false ) { if (activity.app_state.isBusyFav(access_info, arg_status)) { activity.showToast(false, R.string.wait_previous_operation) return } // 必要なら確認を出す if (!bConfirmed && access_info.isMastodon) { DlgConfirm.open( activity, activity.getString( when (bSet) { true -> R.string.confirm_favourite_from else -> R.string.confirm_unfavourite_from }, AcctColor.getNickname(access_info) ), object : DlgConfirm.Callback { override fun onOK() { favourite( activity, access_info, arg_status, nCrossAccountMode, callback, bSet = bSet, bConfirmed = true ) } override var isConfirmEnabled: Boolean get() = when (bSet) { true -> access_info.confirm_favourite else -> access_info.confirm_unfavourite } set(value) { when (bSet) { true -> access_info.confirm_favourite = value else -> access_info.confirm_unfavourite = value } access_info.saveSetting() activity.reloadAccountSetting(access_info) } }) return } // activity.app_state.setBusyFav(access_info, arg_status) // TootTaskRunner(activity, TootTaskRunner.PROGRESS_NONE).run(access_info, object : TootTask { var new_status: TootStatus? = null override suspend fun background(client: TootApiClient): TootApiResult? { val target_status = if (nCrossAccountMode == CROSS_ACCOUNT_REMOTE_INSTANCE) { val (result, status) = client.syncStatus(access_info, arg_status) status ?: return result if (status.favourited) { return TootApiResult(activity.getString(R.string.already_favourited)) } status } else { arg_status } return if (access_info.isMisskey) { client.request( if (bSet) { "/api/notes/favorites/create" } else { "/api/notes/favorites/delete" }, access_info.putMisskeyApiToken().apply { put("noteId", target_status.id.toString()) } .toPostRequestBuilder() )?.also { result -> // 正常レスポンスは 204 no content // 既にお気に入り済みならエラー文字列に'already favorited' が返る if (result.response?.code == 204 || result.error?.contains("already favorited") == true || result.error?.contains("already not favorited") == true ) { // 成功した new_status = target_status.apply { favourited = bSet } } } } else { client.request( "/api/v1/statuses/${target_status.id}/${if (bSet) "favourite" else "unfavourite"}", "".toFormRequestBody().toPost() )?.also { result -> new_status = TootParser(activity, access_info).status(result.jsonObject) } } } override suspend fun handleResult(result: TootApiResult?) { activity.app_state.resetBusyFav(access_info, arg_status) val new_status = this.new_status when { result == null -> { } // cancelled. new_status != null -> { val old_count = arg_status.favourites_count val new_count = new_status.favourites_count if (old_count != null && new_count != null) { if (access_info.isMisskey) { new_status.favourited = bSet } if (bSet && new_status.favourited && new_count <= old_count) { // 星をつけたのにカウントが上がらないのは違和感あるので、表示をいじる new_status.favourites_count = old_count + 1L } else if (!bSet && !new_status.favourited && new_count >= old_count) { // 星を外したのにカウントが下がらないのは違和感あるので、表示をいじる // 0未満にはならない new_status.favourites_count = if (old_count < 1L) 0L else old_count - 1L } } for (column in activity.app_state.columnList) { column.findStatus( access_info.apDomain, new_status.id ) { account, status -> // 同タンス別アカウントでもカウントは変化する status.favourites_count = new_status.favourites_count // 同アカウントならfav状態を変化させる if (access_info == account) { status.favourited = new_status.favourited } true } } callback() } else -> activity.showToast(true, result.error) } // 結果に関わらず、更新中状態から復帰させる activity.showColumnMatchAccount(access_info) } }) // ファボ表示を更新中にする activity.showColumnMatchAccount(access_info) } // アカウントを選んでお気に入り fun bookmarkFromAnotherAccount( activity: ActMain, timeline_account: SavedAccount, status: TootStatus? ) { status ?: return AccountPicker.pick( activity, bAllowPseudo = false, bAuto = false, message = activity.getString(R.string.account_picker_bookmark), accountListArg = makeAccountListNonPseudo(activity, timeline_account.apDomain) ) { action_account -> bookmark( activity, action_account, status, calcCrossAccountMode(timeline_account, action_account), callback = activity.bookmark_complete_callback ) } } // お気に入りの非同期処理 fun bookmark( activity: ActMain, access_info: SavedAccount, arg_status: TootStatus, nCrossAccountMode: Int, callback: () -> Unit, bSet: Boolean = true, bConfirmed: Boolean = false ) { if (activity.app_state.isBusyFav(access_info, arg_status)) { activity.showToast(false, R.string.wait_previous_operation) return } if (access_info.isMisskey) { activity.showToast(false, R.string.misskey_account_not_supported) return } // 必要なら確認を出す // ブックマークは解除する時だけ確認する if (!bConfirmed && !bSet) { DlgConfirm.openSimple( activity, activity.getString( R.string.confirm_unbookmark_from, AcctColor.getNickname(access_info) ) ) { bookmark( activity, access_info, arg_status, nCrossAccountMode, callback, bSet = bSet, bConfirmed = true ) } return } // activity.app_state.setBusyBookmark(access_info, arg_status) // TootTaskRunner(activity, TootTaskRunner.PROGRESS_NONE).run(access_info, object : TootTask { var new_status: TootStatus? = null override suspend fun background(client: TootApiClient): TootApiResult? { val target_status = if (nCrossAccountMode == CROSS_ACCOUNT_REMOTE_INSTANCE) { val (result, status) = client.syncStatus(access_info, arg_status) status ?: return result if (status.bookmarked) { return TootApiResult(activity.getString(R.string.already_bookmarked)) } status } else { arg_status } return client.request( "/api/v1/statuses/${target_status.id}/${if (bSet) "bookmark" else "unbookmark"}", "".toFormRequestBody().toPost() )?.also { result -> new_status = TootParser(activity, access_info).status(result.jsonObject) } } override suspend fun handleResult(result: TootApiResult?) { activity.app_state.resetBusyBookmark(access_info, arg_status) val new_status = this.new_status when { result == null -> { } // cancelled. new_status != null -> { for (column in activity.app_state.columnList) { column.findStatus( access_info.apDomain, new_status.id ) { account, status -> // 同アカウントならブックマーク状態を伝播する if (access_info == account) { status.bookmarked = new_status.bookmarked } true } } callback() } else -> activity.showToast(true, result.error) } // 結果に関わらず、更新中状態から復帰させる activity.showColumnMatchAccount(access_info) } }) // ファボ表示を更新中にする activity.showColumnMatchAccount(access_info) } fun boostFromAnotherAccount( activity: ActMain, timeline_account: SavedAccount, status: TootStatus? ) { status ?: return val status_owner = timeline_account.getFullAcct(status.account) val isPrivateToot = timeline_account.isMastodon && status.visibility == TootVisibility.PrivateFollowers if (isPrivateToot) { val list = ArrayList() for (a in SavedAccount.loadAccountList(activity)) { if (a.acct == status_owner) list.add(a) } if (list.isEmpty()) { activity.showToast(false, R.string.boost_private_toot_not_allowed) return } AccountPicker.pick( activity, bAllowPseudo = false, bAuto = false, message = activity.getString(R.string.account_picker_boost), accountListArg = list ) { action_account -> boost( activity, action_account, status, status_owner, calcCrossAccountMode(timeline_account, action_account), callback = activity.boost_complete_callback ) } } else { AccountPicker.pick( activity, bAllowPseudo = false, bAuto = false, message = activity.getString(R.string.account_picker_boost), accountListArg = makeAccountListNonPseudo(activity, timeline_account.apDomain) ) { action_account -> boost( activity, action_account, status, status_owner, calcCrossAccountMode(timeline_account, action_account), callback = activity.boost_complete_callback ) } } } fun boost( activity: ActMain, access_info: SavedAccount, arg_status: TootStatus, status_owner: Acct, nCrossAccountMode: Int, bSet: Boolean = true, bConfirmed: Boolean = false, visibility: TootVisibility? = null, callback: () -> Unit ) { // アカウントからステータスにブースト操作を行っているなら、何もしない if (activity.app_state.isBusyBoost(access_info, arg_status)) { activity.showToast(false, R.string.wait_previous_operation) return } // Mastodonは非公開トゥートをブーストできるのは本人だけ val isPrivateToot = access_info.isMastodon && arg_status.visibility == TootVisibility.PrivateFollowers if( isPrivateToot && access_info.acct != status_owner) { activity.showToast(false, R.string.boost_private_toot_not_allowed) return } // DMとかのブーストはAPI側がエラーを出すだろう? // 必要なら確認を出す if (!bConfirmed) { DlgConfirm.open( activity, activity.getString( when { !bSet -> R.string.confirm_unboost_from isPrivateToot -> R.string.confirm_boost_private_from visibility == TootVisibility.PrivateFollowers -> R.string.confirm_private_boost_from else -> R.string.confirm_boost_from }, AcctColor.getNickname(access_info) ), object : DlgConfirm.Callback { override fun onOK() { boost( activity, access_info, arg_status, status_owner, nCrossAccountMode, bSet = bSet, bConfirmed = true, visibility = visibility, callback = callback, ) } override var isConfirmEnabled: Boolean get() = when (bSet) { true -> access_info.confirm_boost else -> access_info.confirm_unboost } set(value) { when (bSet) { true -> access_info.confirm_boost = value else -> access_info.confirm_unboost = value } access_info.saveSetting() activity.reloadAccountSetting(access_info) } }) return } activity.app_state.setBusyBoost(access_info, arg_status) TootTaskRunner(activity, TootTaskRunner.PROGRESS_NONE).run(access_info, object : TootTask { var new_status: TootStatus? = null var unrenoteId: EntityId? = null override suspend fun background(client: TootApiClient): TootApiResult? { val parser = TootParser(activity, access_info) val target_status = if (nCrossAccountMode == CROSS_ACCOUNT_REMOTE_INSTANCE) { val (result, status) = client.syncStatus(access_info, arg_status) if (status == null) return result if (status.reblogged) { return TootApiResult(activity.getString(R.string.already_boosted)) } status } else { // 既に自タンスのステータスがある arg_status } if (access_info.isMisskey) { return if (!bSet) { val myRenoteId = target_status.myRenoteId ?: return TootApiResult("missing renote id.") client.request( "/api/notes/delete", access_info.putMisskeyApiToken().apply { put("noteId", myRenoteId.toString()) put("renoteId", target_status.id.toString()) } .toPostRequestBuilder() ) ?.also { if (it.response?.code == 204) unrenoteId = myRenoteId } } else { client.request( "/api/notes/create", access_info.putMisskeyApiToken().apply { put("renoteId", target_status.id.toString()) } .toPostRequestBuilder() ) ?.also { result -> val jsonObject = result.jsonObject if (jsonObject != null) { val outerStatus = parser.status( jsonObject.jsonObject("createdNote") ?: jsonObject ) val innerStatus = outerStatus?.reblog ?: outerStatus if (outerStatus != null && innerStatus != null && outerStatus != innerStatus) { innerStatus.myRenoteId = outerStatus.id innerStatus.reblogged = true } // renoteそのものではなくrenoteされた元noteが欲しい this.new_status = innerStatus } } } } else { val b = JsonObject().apply { if (visibility != null) put("visibility", visibility.strMastodon) }.toPostRequestBuilder() val result = client.request( "/api/v1/statuses/${target_status.id}/${if (bSet) "reblog" else "unreblog"}", b ) // reblogはreblogを表すStatusを返す // unreblogはreblogしたStatusを返す val s = parser.status(result?.jsonObject) this.new_status = s?.reblog ?: s return result } } override suspend fun handleResult(result: TootApiResult?) { activity.app_state.resetBusyBoost(access_info, arg_status) val unrenoteId = this.unrenoteId val new_status = this.new_status when { // cancelled. result == null -> { } // Misskeyでunrenoteに成功した unrenoteId != null -> { // 星を外したのにカウントが下がらないのは違和感あるので、表示をいじる // 0未満にはならない val count = max(0, (arg_status.reblogs_count ?: 1) - 1) for (column in activity.app_state.columnList) { column.findStatus( access_info.apDomain, arg_status.id ) { account, status -> // 同タンス別アカウントでもカウントは変化する status.reblogs_count = count // 同アカウントならreblogged状態を変化させる if (access_info == account && status.myRenoteId == unrenoteId) { status.myRenoteId = null status.reblogged = false } true } } callback() } new_status != null -> { // カウント数は遅延があるみたいなので、恣意的に表示を変更する // ブーストカウント数を加工する val old_count = arg_status.reblogs_count val new_count = new_status.reblogs_count if (old_count != null && new_count != null) { if (bSet && new_status.reblogged && new_count <= old_count) { // 星をつけたのにカウントが上がらないのは違和感あるので、表示をいじる new_status.reblogs_count = old_count + 1 } else if (!bSet && !new_status.reblogged && new_count >= old_count) { // 星を外したのにカウントが下がらないのは違和感あるので、表示をいじる // 0未満にはならない new_status.reblogs_count = if (old_count < 1) 0 else old_count - 1 } } for (column in activity.app_state.columnList) { column.findStatus( access_info.apDomain, new_status.id ) { account, status -> // 同タンス別アカウントでもカウントは変化する status.reblogs_count = new_status.reblogs_count if (access_info == account) { // 同アカウントならreblog状態を変化させる when { access_info.isMastodon -> status.reblogged = new_status.reblogged bSet && status.myRenoteId == null -> { status.myRenoteId = new_status.myRenoteId status.reblogged = true } } // Misskey のunrenote時はここを通らない } true } } callback() } else -> activity.showToast(true, result.error) } // 結果に関わらず、更新中状態から復帰させる activity.showColumnMatchAccount(access_info) } }) // ブースト表示を更新中にする activity.showColumnMatchAccount(access_info) // misskeyは非公開トゥートをブーストできないっぽい } fun delete(activity: ActMain, access_info: SavedAccount, status_id: EntityId) { TootTaskRunner(activity).run(access_info, object : TootTask { override suspend fun background(client: TootApiClient): TootApiResult? { return if (access_info.isMisskey) { client.request( "/api/notes/delete", access_info.putMisskeyApiToken().apply { put("noteId", status_id) } .toPostRequestBuilder() ) // 204 no content } else { client.request( "/api/v1/statuses/$status_id", Request.Builder().delete() ) } } override suspend fun handleResult(result: TootApiResult?) { if (result == null) return // cancelled. if (result.jsonObject != null) { activity.showToast(false, R.string.delete_succeeded) for (column in activity.app_state.columnList) { column.onStatusRemoved(access_info.apDomain, status_id) } } else { activity.showToast(false, result.error) } } }) } ///////////////////////////////////////////////////////////////////////////////////// // 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() // TLを読んだタンスにあるアカウント val access_account_list = ArrayList() // その他のタンスにあるアカウント val other_account_list = ArrayList() 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() val other_account_list = ArrayList() 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 fun pin( activity: ActMain, access_info: SavedAccount, status: TootStatus, bSet: Boolean ) { TootTaskRunner(activity) .progressPrefix(activity.getString(R.string.profile_pin_progress)) .run(access_info, object : TootTask { var new_status: TootStatus? = null override suspend fun background(client: TootApiClient): TootApiResult? { val result = client.request( "/api/v1/statuses/${status.id}/${if (bSet) "pin" else "unpin"}", "".toFormRequestBody().toPost() ) new_status = TootParser(activity, access_info).status(result?.jsonObject) return result } override suspend fun handleResult(result: TootApiResult?) { val new_status = this.new_status when { result == null -> { // cancelled. } new_status != null -> { for (column in activity.app_state.columnList) { if (access_info == column.access_info) { column.findStatus( access_info.apDomain, new_status.id ) { _, status -> status.pinned = bSet true } } } } else -> activity.showToast(true, result.error) } // 結果に関わらず、更新中状態から復帰させる activity.showColumnMatchAccount(access_info) } }) } ///////////////////////////////////////////////////////////////////////////////// // reply fun reply( activity: ActMain, access_info: SavedAccount, status: TootStatus, quote: Boolean = false ) { ActPost.open( activity, ActMain.REQUEST_CODE_POST, 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, accessInfo: SavedAccount, status: TootStatus ) { activity.post_helper.closeAcctPopup() if (accessInfo.isMisskey) { ActPost.open( activity, ActMain.REQUEST_CODE_POST, accessInfo.db_id, redraft_status = status, reply_status = status.reply ) return } if (status.in_reply_to_id == null) { ActPost.open( activity, ActMain.REQUEST_CODE_POST, accessInfo.db_id, redraft_status = status ) return } TootTaskRunner(activity).run(accessInfo, object : TootTask { var reply_status: TootStatus? = null override suspend fun background(client: TootApiClient): TootApiResult? { val result = client.request("/api/v1/statuses/${status.in_reply_to_id}") reply_status = TootParser(activity, accessInfo).status(result?.jsonObject) return result } override suspend fun handleResult(result: TootApiResult?) { if (result == null) return // cancelled. val reply_status = this.reply_status if (reply_status != null) { ActPost.open( activity, ActMain.REQUEST_CODE_POST, accessInfo.db_id, redraft_status = status, reply_status = reply_status ) return } val error = result.error ?: "(no information)" activity.showToast(true, activity.getString(R.string.cant_sync_toot) + " : $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) } } }) } fun reaction( activity: ActMain, access_info: SavedAccount, arg_status: TootStatus, status_owner_acct: Acct, nCrossAccountMode: Int, callback: () -> Unit, bSet: Boolean = true, code: String? = null ) { if (access_info.isPseudo || !access_info.isMisskey) return // 自分の投稿にはリアクション出来ない if (access_info.acct == status_owner_acct) { activity.showToast(false, R.string.it_is_you) return } if (code == null) { if (!bSet) error("will not happen") EmojiPicker(activity, access_info, closeOnSelected = true) { name, instance, _, _, _ -> val item = EmojiMap.shortNameToEmojiInfo[name] val newCode = if (item == null || instance != null) { ":$name:" } else { item.unified } reaction( activity, access_info, arg_status, status_owner_acct, nCrossAccountMode, callback, bSet, newCode ) }.show() return } TootTaskRunner(activity, TootTaskRunner.PROGRESS_NONE).run(access_info, object : TootTask { override suspend fun background(client: TootApiClient): TootApiResult? { val target_status = if (nCrossAccountMode == CROSS_ACCOUNT_REMOTE_INSTANCE) { val (result, status) = client.syncStatus(access_info, arg_status) status ?: return result if (status.myReaction != null) { return TootApiResult(activity.getString(R.string.already_reactioned)) } status } else { // 既に自タンスのステータスがある arg_status } return if (!bSet) { client.request( "/api/notes/reactions/delete", access_info.putMisskeyApiToken().apply { put("noteId", target_status.id.toString()) } .toPostRequestBuilder() ) // 成功すると204 no content } else { client.request( "/api/notes/reactions/create", access_info.putMisskeyApiToken().apply { put("noteId", target_status.id.toString()) put("reaction", code) } .toPostRequestBuilder() ) // 成功すると204 no content } } override suspend fun handleResult(result: TootApiResult?) { result ?: return val error = result.error if (error != null) { activity.showToast(false, error) return } callback() } }) } fun reactionFromAnotherAccount( activity: ActMain, timeline_account: SavedAccount, status: TootStatus?, code: String? = null ) { status ?: return val status_owner = timeline_account.getFullAcct(status.account) AccountPicker.pick( activity, bAllowPseudo = false, bAllowMisskey = true, bAllowMastodon = false, bAuto = false, message = activity.getString(R.string.account_picker_reaction) ) { action_account -> reaction( activity, action_account, status, status_owner, calcCrossAccountMode(timeline_account, action_account), activity.reaction_complete_callback, code = code ) } } fun deleteScheduledPost( activity: ActMain, access_info: SavedAccount, item: TootScheduled, bConfirmed: Boolean = false, callback: () -> Unit ) { if (!bConfirmed) { DlgConfirm.openSimple( activity, activity.getString(R.string.scheduled_status_delete_confirm) ) { deleteScheduledPost( activity, access_info, item, bConfirmed = true, callback = callback ) } return } TootTaskRunner(activity).run(access_info, object : TootTask { override suspend fun background(client: TootApiClient): TootApiResult? { return client.request( "/api/v1/scheduled_statuses/${item.id}", Request.Builder().delete() ) } override suspend fun handleResult(result: TootApiResult?) { result ?: return val error = result.error if (error != null) { activity.showToast(false, error) return } callback() } }) } fun editScheduledPost( activity: ActMain, access_info: SavedAccount, item: TootScheduled ) { TootTaskRunner(activity).run(access_info, object : TootTask { var reply_status: TootStatus? = null override suspend fun background(client: TootApiClient): TootApiResult? { val reply_status_id = item.in_reply_to_id ?: return TootApiResult() return client.request("/api/v1/statuses/$reply_status_id")?.also { result -> reply_status = 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 } ActPost.open( activity, ActMain.REQUEST_CODE_POST, access_info.db_id, scheduledStatus = item, reply_status = reply_status ) } }) } }