535 lines
19 KiB
Kotlin
535 lines
19 KiB
Kotlin
package jp.juggler.subwaytooter.action
|
||
|
||
import android.content.Context
|
||
import androidx.core.net.toUri
|
||
import jp.juggler.subwaytooter.ActMain
|
||
import jp.juggler.subwaytooter.R
|
||
import jp.juggler.subwaytooter.actmain.addColumn
|
||
import jp.juggler.subwaytooter.api.*
|
||
import jp.juggler.subwaytooter.api.entity.*
|
||
import jp.juggler.subwaytooter.column.ColumnType
|
||
import jp.juggler.subwaytooter.column.findStatus
|
||
import jp.juggler.subwaytooter.columnviewholder.ItemListAdapter
|
||
import jp.juggler.subwaytooter.dialog.actionsDialog
|
||
import jp.juggler.subwaytooter.table.SavedAccount
|
||
import jp.juggler.subwaytooter.table.daoAcctColor
|
||
import jp.juggler.subwaytooter.table.daoSavedAccount
|
||
import jp.juggler.subwaytooter.table.sortedByNickname
|
||
import jp.juggler.subwaytooter.util.matchHost
|
||
import jp.juggler.subwaytooter.util.openCustomTab
|
||
import jp.juggler.util.coroutine.launchAndShowError
|
||
import jp.juggler.util.coroutine.launchMain
|
||
import jp.juggler.util.data.notEmpty
|
||
import jp.juggler.util.log.LogCategory
|
||
import jp.juggler.util.log.showToast
|
||
import jp.juggler.util.network.toFormRequestBody
|
||
import jp.juggler.util.network.toPost
|
||
|
||
private val log = LogCategory("Action_Conversation")
|
||
|
||
fun ActMain.clickConversation(
|
||
pos: Int,
|
||
accessInfo: SavedAccount,
|
||
|
||
// optional. 未読表示のクリアに使う
|
||
listAdapter: ItemListAdapter? = null,
|
||
|
||
// どちらか非nullであること
|
||
status: TootStatus? = null,
|
||
summary: TootConversationSummary? = null,
|
||
) {
|
||
// 未読クリアと表示の更新
|
||
(summary ?: status?.conversationSummary)?.let {
|
||
if (conversationUnreadClear(accessInfo, it)) {
|
||
listAdapter?.notifyChange(
|
||
reason = "ConversationSummary reset unread",
|
||
reset = true
|
||
)
|
||
}
|
||
}
|
||
// 会話カラムを開く
|
||
(status ?: summary?.last_status)?.let {
|
||
conversation(pos, accessInfo, it)
|
||
}
|
||
}
|
||
|
||
// プレビューカードのイメージは返信かもしれない
|
||
fun ActMain.clickCardImage(
|
||
pos: Int,
|
||
accessInfo: SavedAccount,
|
||
card: TootCard?,
|
||
longClick: Boolean = false,
|
||
) {
|
||
card ?: return
|
||
card.originalStatus?.let {
|
||
if (longClick) {
|
||
conversationOtherInstance(pos, it)
|
||
} else {
|
||
conversation(pos, accessInfo, it)
|
||
}
|
||
return
|
||
}
|
||
card.url?.notEmpty()?.let {
|
||
openCustomTab(this, pos, it, accessInfo = accessInfo)
|
||
}
|
||
}
|
||
|
||
// 「~からの返信」の表記を押した
|
||
fun ActMain.clickReplyInfo(
|
||
pos: Int,
|
||
accessInfo: SavedAccount,
|
||
columnType: ColumnType,
|
||
statusReply: TootStatus?,
|
||
statusShowing: TootStatus?,
|
||
longClick: Boolean = false,
|
||
contextMenuOpener: ActMain.(status: TootStatus) -> Unit = {},
|
||
) {
|
||
when {
|
||
statusReply != null ->
|
||
if (longClick) {
|
||
contextMenuOpener(this, statusReply)
|
||
} else {
|
||
conversation(pos, accessInfo, statusReply)
|
||
}
|
||
|
||
// tootsearchは返信元のIDを取得するのにひと手間必要
|
||
columnType == ColumnType.SEARCH_TS ||
|
||
columnType == ColumnType.SEARCH_NOTESTOCK ->
|
||
conversationFromTootsearch(pos, statusShowing)
|
||
|
||
else ->
|
||
statusShowing?.in_reply_to_id
|
||
?.let { conversationLocal(pos, accessInfo, it) }
|
||
}
|
||
}
|
||
|
||
/////////////////////////////////////////////////////////////////////////////////////
|
||
// open conversation
|
||
|
||
// returns true if unread flag will be cleared.
|
||
internal fun ActMain.conversationUnreadClear(
|
||
accessInfo: SavedAccount,
|
||
conversationSummary: TootConversationSummary?,
|
||
): Boolean {
|
||
|
||
// サマリがない
|
||
conversationSummary ?: return false
|
||
|
||
// 更新の必要がない
|
||
if (!conversationSummary.unread) return false
|
||
|
||
// 変数を更新
|
||
conversationSummary.unread = false
|
||
|
||
// 未読フラグのクリアをサーバに送る
|
||
launchMain {
|
||
runApiTask(accessInfo, progressStyle = ApiTask.PROGRESS_NONE) { client ->
|
||
client.request(
|
||
"/api/v1/conversations/${conversationSummary.id}/read",
|
||
"".toFormRequestBody().toPost()
|
||
)
|
||
}
|
||
// 応答の内容は見ない
|
||
}
|
||
|
||
return true // 表示も更新されるべき
|
||
}
|
||
|
||
// ローカルかリモートか判断する
|
||
fun ActMain.conversation(
|
||
pos: Int,
|
||
accessInfo: SavedAccount,
|
||
status: TootStatus,
|
||
) {
|
||
if (accessInfo.isNA || !accessInfo.matchHost(status.readerApDomain)) {
|
||
conversationOtherInstance(pos, status)
|
||
} else {
|
||
|
||
conversationLocal(pos, accessInfo, status.id)
|
||
}
|
||
}
|
||
|
||
// ローカルから見える会話の流れを表示する
|
||
fun ActMain.conversationLocal(
|
||
pos: Int,
|
||
accessInfo: SavedAccount,
|
||
statusId: EntityId,
|
||
isReference: Boolean = false,
|
||
) = addColumn(
|
||
pos,
|
||
accessInfo,
|
||
when {
|
||
isReference && TootInstance.getCached(accessInfo)?.canUseReference == true ->
|
||
ColumnType.CONVERSATION_WITH_REFERENCE
|
||
else ->
|
||
ColumnType.CONVERSATION
|
||
},
|
||
params = arrayOf(statusId),
|
||
)
|
||
|
||
private val reDetailedStatusTime =
|
||
"""<a\b[^>]*?\bdetailed-status__datetime\b[^>]*href="https://[^/]+/@[^/]+/([^\s?#/"]+)"""
|
||
.toRegex()
|
||
|
||
private val reHeaderOgUrl =
|
||
"""<meta\s+content="https://[^/"]+/notice/([^/"]+)"\s+property="og:url"/?>"""
|
||
.toRegex()
|
||
|
||
// 疑似アカウントではURLからIDを取得するのにHTMLと正規表現を使う
|
||
suspend fun guessStatusIdFromPseudoAccount(
|
||
context: Context, // for string resource
|
||
client: TootApiClient, // for get HTML
|
||
remoteStatusUrl: String,
|
||
): Pair<TootApiResult?, EntityId?> {
|
||
|
||
val result = client.getHttp(remoteStatusUrl)
|
||
|
||
result?.string?.let { html ->
|
||
|
||
reDetailedStatusTime.find(html)
|
||
?.groupValues?.elementAtOrNull(1)
|
||
?.let { return Pair(result, EntityId(it)) }
|
||
|
||
reHeaderOgUrl.find(html)
|
||
?.groupValues?.elementAtOrNull(1)
|
||
?.let { return Pair(result, EntityId(it)) }
|
||
}
|
||
|
||
return Pair(
|
||
result?.setError(context.getString(R.string.status_id_conversion_failed)),
|
||
null
|
||
)
|
||
}
|
||
|
||
private fun ActMain.conversationRemote(
|
||
pos: Int,
|
||
accessInfo: SavedAccount,
|
||
remoteStatusUrl: String,
|
||
) {
|
||
|
||
launchMain {
|
||
var localStatusId: EntityId? = null
|
||
|
||
runApiTask(
|
||
accessInfo,
|
||
progressPrefix = getString(R.string.progress_synchronize_toot)
|
||
) { client ->
|
||
if (accessInfo.isPseudo) {
|
||
// 疑似アカウントではURLからIDを取得するのにHTMLと正規表現を使う
|
||
val pair =
|
||
guessStatusIdFromPseudoAccount(applicationContext, client, remoteStatusUrl)
|
||
localStatusId = pair.second
|
||
pair.first
|
||
} else {
|
||
// 実アカウントでは検索APIを使える
|
||
val (result, status) = client.syncStatus(accessInfo, remoteStatusUrl)
|
||
if (status != null) {
|
||
localStatusId = status.id
|
||
log.d("status id conversion $remoteStatusUrl=>${status.id}")
|
||
}
|
||
result
|
||
}
|
||
}?.let { result ->
|
||
when (val statusId = localStatusId) {
|
||
null -> showToast(true, result.error)
|
||
else -> conversationLocal(pos, accessInfo, statusId)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// アプリ外部からURLを渡された場合に呼ばれる
|
||
fun ActMain.conversationOtherInstance(
|
||
pos: Int,
|
||
urlArg: String,
|
||
statusIdOriginal: EntityId? = null,
|
||
hostAccess: Host? = null,
|
||
statusIdAccess: EntityId? = null,
|
||
isReference: Boolean = false,
|
||
) {
|
||
val activity = this
|
||
launchAndShowError {
|
||
actionsDialog(getString(R.string.open_status_from)) {
|
||
|
||
val hostOriginal = Host.parse(urlArg.toUri().authority ?: "")
|
||
|
||
// 選択肢:ブラウザで表示する
|
||
action(getString(R.string.open_web_on_host, hostOriginal.pretty)) {
|
||
openCustomTab(urlArg)
|
||
}
|
||
|
||
// トゥートの投稿元タンスにあるアカウント
|
||
val localAccountList = ArrayList<SavedAccount>()
|
||
|
||
// TLを読んだタンスにあるアカウント
|
||
val accessAccountList = ArrayList<SavedAccount>()
|
||
|
||
// その他のタンスにあるアカウント
|
||
val otherAccountList = ArrayList<SavedAccount>()
|
||
|
||
for (a in daoSavedAccount.loadAccountList()) {
|
||
|
||
// 疑似アカウントは後でまとめて処理する
|
||
if (a.isPseudo) continue
|
||
|
||
if (isReference && TootInstance.getCached(a)?.canUseReference != true) continue
|
||
|
||
if (statusIdOriginal != null && a.matchHost(hostOriginal)) {
|
||
// アクセス情報+ステータスID でアクセスできるなら
|
||
// 同タンスのアカウントならステータスIDの変換なしに表示できる
|
||
localAccountList.add(a)
|
||
} else if (statusIdAccess != null && a.matchHost(hostAccess)) {
|
||
// 既に変換済みのステータスIDがあるなら、そのアカウントでもステータスIDの変換は必要ない
|
||
accessAccountList.add(a)
|
||
} else {
|
||
// 別タンスでも実アカウントなら検索APIでステータスIDを変換できる
|
||
otherAccountList.add(a)
|
||
}
|
||
}
|
||
|
||
// 参照の場合、status URLから/references を除去しないとURLでの検索ができない
|
||
val url = when {
|
||
isReference -> """/references\z""".toRegex().replace(urlArg, "")
|
||
else -> urlArg
|
||
}
|
||
|
||
// 同タンスのアカウントがないなら、疑似アカウントで開く選択肢
|
||
if (localAccountList.isEmpty()) {
|
||
if (statusIdOriginal != null) {
|
||
action(
|
||
getString(R.string.open_in_pseudo_account, "?@${hostOriginal.pretty}")
|
||
) {
|
||
launchMain {
|
||
addPseudoAccount(hostOriginal)?.let { sa ->
|
||
conversationLocal(
|
||
pos,
|
||
sa,
|
||
statusIdOriginal,
|
||
isReference = isReference
|
||
)
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
action(
|
||
getString(R.string.open_in_pseudo_account, "?@${hostOriginal.pretty}")
|
||
) {
|
||
launchMain {
|
||
addPseudoAccount(hostOriginal)?.let { sa ->
|
||
conversationRemote(pos, sa, url)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ローカルアカウント
|
||
if (statusIdOriginal != null) {
|
||
for (a in localAccountList.sortedByNickname()) {
|
||
action(
|
||
daoAcctColor.getStringWithNickname(
|
||
activity,
|
||
R.string.open_in_account,
|
||
a.acct
|
||
)
|
||
) {
|
||
conversationLocal(pos, a, statusIdOriginal, isReference = isReference)
|
||
}
|
||
}
|
||
}
|
||
|
||
// アクセスしたアカウント
|
||
if (statusIdAccess != null) {
|
||
for (a in accessAccountList.sortedByNickname()) {
|
||
action(
|
||
daoAcctColor.getStringWithNickname(
|
||
activity,
|
||
R.string.open_in_account,
|
||
a.acct
|
||
)
|
||
) {
|
||
conversationLocal(pos, a, statusIdAccess, isReference = isReference)
|
||
}
|
||
}
|
||
}
|
||
|
||
// その他の実アカウント
|
||
for (a in otherAccountList.sortedByNickname()) {
|
||
action(
|
||
daoAcctColor.getStringWithNickname(
|
||
activity,
|
||
R.string.open_in_account,
|
||
a.acct
|
||
)
|
||
) {
|
||
conversationRemote(pos, a, url)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// リモートかもしれない会話の流れを表示する
|
||
fun ActMain.conversationOtherInstance(
|
||
pos: Int,
|
||
status: TootStatus?,
|
||
) {
|
||
// URLが不明なトゥートというのはreblogの外側のアレ
|
||
val url = status?.url?.notEmpty() ?: return
|
||
|
||
when {
|
||
|
||
// 検索サービスではステータスTLをどのタンスから読んだのか分からない
|
||
status.readerApDomain == null ->
|
||
conversationOtherInstance(
|
||
pos,
|
||
url,
|
||
TootStatus.validStatusId(status.id)
|
||
?: TootStatus.findStatusIdFromUri(
|
||
status.uri,
|
||
status.url
|
||
)
|
||
)
|
||
|
||
// TLアカウントのホストとトゥートのアカウントのホストが同じ
|
||
status.originalApDomain == status.readerApDomain ->
|
||
conversationOtherInstance(
|
||
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(
|
||
pos, url, TootStatus.findStatusIdFromUri(
|
||
status.uri,
|
||
status.url
|
||
), status.readerApDomain, TootStatus.validStatusId(status.id)
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
////////////////////////////////////////
|
||
|
||
fun ActMain.conversationMute(
|
||
accessInfo: SavedAccount,
|
||
status: TootStatus?,
|
||
) {
|
||
status ?: return
|
||
|
||
// toggle change
|
||
val bMute = !status.muted
|
||
|
||
launchMain {
|
||
var localStatus: TootStatus? = null
|
||
runApiTask(accessInfo) { client ->
|
||
client.request(
|
||
"/api/v1/statuses/${status.id}/${if (bMute) "mute" else "unmute"}",
|
||
"".toFormRequestBody().toPost()
|
||
)?.also { result ->
|
||
localStatus = TootParser(this, accessInfo).status(result.jsonObject)
|
||
}
|
||
}?.let { result ->
|
||
when (val ls = localStatus) {
|
||
null -> showToast(true, result.error)
|
||
|
||
else -> {
|
||
for (column in appState.columnList) {
|
||
if (accessInfo == column.accessInfo) {
|
||
column.findStatus(accessInfo.apDomain, ls.id) { _, status ->
|
||
status.muted = bMute
|
||
true
|
||
}
|
||
}
|
||
}
|
||
showToast(
|
||
true,
|
||
if (bMute) R.string.mute_succeeded else R.string.unmute_succeeded
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
//////////////////////////////////////////////////
|
||
|
||
// tootsearch APIは投稿の返信元を示すreplyの情報がない。
|
||
// in_reply_to_idを参照するしかない
|
||
// ところがtootsearchでは投稿をどのタンスから読んだか分からないので、IDは全面的に信用できない。
|
||
// 疑似ではないアカウントを選んだ後に表示中の投稿を検索APIで調べて、そのリプライのIDを取得しなおす
|
||
fun ActMain.conversationFromTootsearch(
|
||
pos: Int,
|
||
statusArg: TootStatus?,
|
||
) {
|
||
statusArg ?: return
|
||
|
||
// step 1: choose account
|
||
val host = statusArg.account.apDomain
|
||
val localAccountList = ArrayList<SavedAccount>()
|
||
val otherAccountList = ArrayList<SavedAccount>()
|
||
for (a in daoSavedAccount.loadAccountList()) {
|
||
when {
|
||
// 検索APIはログイン必須なので疑似アカウントは使えない
|
||
a.isPseudo -> continue
|
||
a.matchHost(host) -> localAccountList.add(a)
|
||
else -> otherAccountList.add(a)
|
||
}
|
||
}
|
||
|
||
val activity = this
|
||
launchAndShowError {
|
||
|
||
// step2: 選択したアカウントで投稿を検索して返信元の投稿のIDを調べる
|
||
suspend fun step2(a: SavedAccount) {
|
||
var tmp: TootStatus? = null
|
||
runApiTask(a) { client ->
|
||
val (result, status) = client.syncStatus(a, statusArg)
|
||
tmp = status
|
||
result
|
||
}?.let { result ->
|
||
val status = tmp
|
||
val replyId = status?.in_reply_to_id
|
||
when {
|
||
status == null -> showToast(true, result.error ?: "?")
|
||
replyId == null -> showToast(
|
||
true,
|
||
"showReplyTootsearch: in_reply_to_id is null"
|
||
)
|
||
else -> conversationLocal(pos, a, replyId)
|
||
}
|
||
}
|
||
}
|
||
actionsDialog(getString(R.string.open_status_from)) {
|
||
for (a in localAccountList.sortedByNickname()) {
|
||
action(
|
||
daoAcctColor.getStringWithNickname(
|
||
activity,
|
||
R.string.open_in_account,
|
||
a.acct
|
||
)
|
||
) { step2(a) }
|
||
}
|
||
|
||
for (a in otherAccountList.sortedByNickname()) {
|
||
action(
|
||
daoAcctColor.getStringWithNickname(
|
||
activity,
|
||
R.string.open_in_account,
|
||
a.acct
|
||
)
|
||
) { step2(a) }
|
||
}
|
||
}
|
||
}
|
||
}
|