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

920 lines
28 KiB
Kotlin

package jp.juggler.subwaytooter.action
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.view.Gravity
import android.view.View
import android.widget.*
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.ReportForm
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.UserRelation
import jp.juggler.subwaytooter.util.TootApiResultCallback
import jp.juggler.subwaytooter.util.matchHost
import jp.juggler.subwaytooter.util.openCustomTab
import jp.juggler.util.*
import kotlinx.coroutines.*
import okhttp3.Request
object Action_User {
// ユーザをミュート/ミュート解除する
private fun mute(
activity: ActMain,
access_info: SavedAccount,
whoArg: TootAccount,
whoAccessInfo: SavedAccount,
bMute: Boolean,
bMuteNotification: Boolean,
duration: Int?,
) {
val whoAcct = whoAccessInfo.getFullAcct(whoArg)
if (access_info.isMe(whoAcct)) {
activity.showToast(false, R.string.it_is_you)
return
}
TootTaskRunner(activity).run(access_info, object : TootTask {
var relationResult: UserRelation? = null
var whoIdResult: EntityId? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
return if (access_info.isPseudo) {
if (!whoAcct.isValidFull) {
TootApiResult("can't mute pseudo acct ${whoAcct.pretty}")
} else {
val relation = UserRelation.loadPseudo(whoAcct)
relation.muting = bMute
relation.savePseudo(whoAcct.ascii)
relationResult = relation
whoIdResult = whoArg.id
TootApiResult()
}
} else {
val whoId = if (access_info.matchHost(whoAccessInfo)) {
whoArg.id
} else {
val (result, accountRef) = client.syncAccountByAcct(access_info, whoAcct)
accountRef?.get()?.id ?: return result
}
whoIdResult = whoId
if (access_info.isMisskey) {
client.request(
when (bMute) {
true -> "/api/mute/create"
else -> "/api/mute/delete"
},
access_info.putMisskeyApiToken().apply {
put("userId", whoId.toString())
}.toPostRequestBuilder()
)?.apply {
if (jsonObject != null) {
// 204 no content
// update user relation
val ur = UserRelation.load(access_info.db_id, whoId)
ur.muting = bMute
saveUserRelationMisskey(
access_info,
whoId,
TootParser(activity, access_info)
)
relationResult = ur
}
}
} else {
client.request(
"/api/v1/accounts/${whoId}/${if (bMute) "mute" else "unmute"}",
when {
!bMute -> "".toFormRequestBody()
else ->
jsonObject {
put("notifications", bMuteNotification)
if (duration != null) put("duration", duration)
}
.toRequestBody()
}.toPost()
)?.apply {
val jsonObject = jsonObject
if (jsonObject != null) {
relationResult = saveUserRelation(
access_info,
parseItem(
::TootRelationShip,
TootParser(activity, access_info),
jsonObject
)
)
}
}
}
}
}
override suspend fun handleResult(result: TootApiResult?) {
if (result == null) return // cancelled.
val relation = relationResult
val whoId = whoIdResult
if (relation != null && whoId != null) {
// 未確認だが、自分をミュートしようとするとリクエストは成功するがレスポンス中のmutingはfalseになるはず
if (bMute && !relation.muting) {
activity.showToast(false, R.string.not_muted)
return
}
for (column in activity.app_state.columnList) {
if (column.access_info.isPseudo) {
if (relation.muting && column.type != ColumnType.PROFILE) {
// ミュートしたユーザの情報はTLから消える
column.removeAccountInTimelinePseudo(whoAcct)
}
// フォローアイコンの表示更新が走る
column.updateFollowIcons(access_info)
} else if (column.access_info == access_info) {
when {
!relation.muting -> {
if (column.type == ColumnType.MUTES) {
// ミュート解除したら「ミュートしたユーザ」カラムから消える
column.removeUser(access_info, ColumnType.MUTES, whoId)
} else {
// 他のカラムではフォローアイコンの表示更新が走る
column.updateFollowIcons(access_info)
}
}
column.type == ColumnType.PROFILE && column.profile_id == whoId -> {
// 該当ユーザのプロフページのトゥートはミュートしてても見れる
// しかしフォローアイコンの表示更新は必要
column.updateFollowIcons(access_info)
}
else -> {
// ミュートしたユーザの情報はTLから消える
column.removeAccountInTimeline(access_info, whoId)
}
}
}
}
activity.showToast(
false,
if (relation.muting) R.string.mute_succeeded else R.string.unmute_succeeded
)
} else {
activity.showToast(false, result.error)
}
}
})
}
fun unmute(
activity: ActMain,
access_info: SavedAccount,
whoArg: TootAccount,
whoAccessInfo: SavedAccount,
) = mute(
activity,
access_info,
whoArg,
whoAccessInfo,
bMute = false,
bMuteNotification = false,
duration = null,
)
fun muteConfirm(
activity: ActMain,
access_info: SavedAccount,
who: TootAccount,
whoAccessInfo: SavedAccount,
) = activity.launch {
try {
@SuppressLint("InflateParams")
val view = activity.layoutInflater.inflate(R.layout.dlg_confirm, null, false)
val tvMessage = view.findViewById<TextView>(R.id.tvMessage)
tvMessage.text = activity.getString(R.string.confirm_mute_user, who.username)
tvMessage.text = activity.getString(R.string.confirm_mute_user, who.username)
// 「次回以降スキップ」のチェックボックスは「このユーザからの通知もミュート」に再利用する
// このオプションはMisskeyや疑似アカウントにはない
val cbMuteNotification = view.findViewById<CheckBox>(R.id.cbSkipNext)
val hasMuteNotification = !access_info.isMisskey && !access_info.isPseudo
cbMuteNotification.isChecked = hasMuteNotification
cbMuteNotification.vg(hasMuteNotification)?.apply {
setText(R.string.confirm_mute_notification_for_user)
}
// Mastodon 3.3から時限ミュート設定ができる
val choiceList = arrayOf(
Pair(0, activity.getString(R.string.duration_indefinite)),
Pair(300, activity.getString(R.string.duration_minutes_5)),
Pair(1800, activity.getString(R.string.duration_minutes_30)),
Pair(3600, activity.getString(R.string.duration_hours_1)),
Pair(21600, activity.getString(R.string.duration_hours_6)),
Pair(86400, activity.getString(R.string.duration_days_1)),
Pair(259200, activity.getString(R.string.duration_days_3)),
Pair(604800, activity.getString(R.string.duration_days_7)),
)
val hasMuteDuration = when {
access_info.isMisskey || access_info.isPseudo -> false
else -> withContext(Dispatchers.IO) {
val client = TootApiClient(activity, callback = object : TootApiCallback {
override val isApiCancelled: Boolean
get() = true != coroutineContext[Job]?.isActive
})
.apply { account = access_info }
val (ti, ri) = TootInstance.get(client)
when {
ti != null -> ti.versionGE(TootInstance.VERSION_3_3_0_rc1)
ri == null -> throw CancellationException()
else -> throw RuntimeException(ri.error)
}
}
}
val spMuteDuration: Spinner = view.findViewById(R.id.spMuteDuration)
if (hasMuteDuration) {
view.findViewById<View>(R.id.llMuteDuration).vg(true)
spMuteDuration.apply {
adapter = ArrayAdapter(
activity,
android.R.layout.simple_spinner_item,
choiceList.map { it.second }.toTypedArray(),
).apply {
setDropDownViewResource(R.layout.lv_spinner_dropdown)
}
}
}
AlertDialog.Builder(activity)
.setView(view)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok)
{ _, _ ->
mute(
activity,
access_info,
who,
whoAccessInfo,
bMute = true,
bMuteNotification = cbMuteNotification.isChecked,
duration = spMuteDuration.selectedItemPosition
.takeIf { hasMuteDuration && it in choiceList.indices }
?.let { choiceList[it].first }
)
}
.show()
} catch (ex: CancellationException) {
// not show error
} catch (ex: RuntimeException) {
activity.showToast(true, ex.message)
}
}
fun muteFromAnotherAccount(
activity: ActMain,
who: TootAccount,
whoAccessInfo: SavedAccount
) {
AccountPicker.pick(
activity,
bAllowPseudo = false,
bAuto = false,
message = activity.getString(R.string.account_picker_mute, who.acct.pretty),
accountListArg = makeAccountListNonPseudo(activity, who.apDomain)
) { muteConfirm(activity, it, who, whoAccessInfo) }
}
// ユーザをブロック/ブロック解除する
fun block(
activity: ActMain,
access_info: SavedAccount,
whoArg: TootAccount,
whoAccessInfo: SavedAccount,
bBlock: Boolean
) {
val whoAcct = whoArg.acct
if (access_info.isMe(whoAcct)) {
activity.showToast(false, R.string.it_is_you)
return
}
TootTaskRunner(activity).run(access_info, object : TootTask {
var relationResult: UserRelation? = null
var whoIdResult: EntityId? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
if (access_info.isPseudo)
return if (whoAcct.ascii.contains('?')) {
TootApiResult("can't block pseudo account ${whoAcct.pretty}")
} else {
val relation = UserRelation.loadPseudo(whoAcct)
relation.blocking = bBlock
relation.savePseudo(whoAcct.ascii)
relationResult = relation
TootApiResult()
}
val whoId = if (access_info.matchHost(whoAccessInfo)) {
whoArg.id
} else {
val (result, accountRef) = client.syncAccountByAcct(access_info, whoAcct)
accountRef?.get()?.id ?: return result
}
whoIdResult = whoId
return if (access_info.isMisskey) {
fun saveBlock(v: Boolean) {
val ur = UserRelation.load(access_info.db_id, whoId)
ur.blocking = v
UserRelation.save1Misskey(
System.currentTimeMillis(),
access_info.db_id,
whoId.toString(),
ur
)
relationResult = ur
}
client.request(
"/api/blocking/${if (bBlock) "create" else "delete"}",
access_info.putMisskeyApiToken().apply {
put("userId", whoId.toString())
}.toPostRequestBuilder()
)?.apply {
val error = this.error
when {
// success
error == null -> saveBlock(bBlock)
// already
error.contains("already blocking") -> saveBlock(bBlock)
error.contains("already not blocking") -> saveBlock(bBlock)
// else something error
}
}
} else {
client.request(
"/api/v1/accounts/${whoId}/${if (bBlock) "block" else "unblock"}",
"".toFormRequestBody().toPost()
)?.apply {
val jsonObject = this.jsonObject
if (jsonObject != null) {
relationResult = saveUserRelation(
access_info,
parseItem(
::TootRelationShip,
TootParser(activity, access_info),
jsonObject
)
)
}
}
}
}
override suspend fun handleResult(result: TootApiResult?) {
if (result == null) return // cancelled.
val relation = relationResult
val whoId = whoIdResult
if (relation != null && whoId != null) {
// 自分をブロックしようとすると、blocking==falseで帰ってくる
if (bBlock && !relation.blocking) {
activity.showToast(false, R.string.not_blocked)
return
}
for (column in activity.app_state.columnList) {
if (column.access_info.isPseudo) {
if (relation.blocking) {
// ミュートしたユーザの情報はTLから消える
column.removeAccountInTimelinePseudo(whoAcct)
}
// フォローアイコンの表示更新が走る
column.updateFollowIcons(access_info)
} else if (column.access_info == access_info) {
when {
!relation.blocking -> {
if (column.type == ColumnType.BLOCKS) {
// ブロック解除したら「ブロックしたユーザ」カラムのリストから消える
column.removeUser(access_info, ColumnType.BLOCKS, whoId)
} else {
// 他のカラムではフォローアイコンの更新を行う
column.updateFollowIcons(access_info)
}
}
access_info.isMisskey -> {
// Misskeyのブロックはフォロー解除とフォロー拒否だけなので
// カラム中の投稿を消すなどの効果はない
// しかしカラム中のフォローアイコン表示の更新は必要
column.updateFollowIcons(access_info)
}
// 該当ユーザのプロフカラムではブロックしててもトゥートを見れる
// しかしカラム中のフォローアイコン表示の更新は必要
column.type == ColumnType.PROFILE && whoId == column.profile_id -> {
column.updateFollowIcons(access_info)
}
// MastodonではブロックしたらTLからそのアカウントの投稿が消える
else -> column.removeAccountInTimeline(access_info, whoId)
}
}
}
activity.showToast(
false,
if (relation.blocking)
R.string.block_succeeded
else
R.string.unblock_succeeded
)
} else {
activity.showToast(false, result.error)
}
}
})
}
fun blockConfirm(
activity: ActMain,
access_info: SavedAccount,
who: TootAccount,
whoAccessInfo: SavedAccount
) {
AlertDialog.Builder(activity)
.setMessage(
activity.getString(
R.string.confirm_block_user,
who.username
)
)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
block(
activity,
access_info,
who,
whoAccessInfo,
true
)
}
.show()
}
fun blockFromAnotherAccount(
activity: ActMain,
who: TootAccount,
whoAccessInfo: SavedAccount
) {
AccountPicker.pick(
activity,
bAllowPseudo = false,
bAuto = false,
message = activity.getString(R.string.account_picker_block, who.acct.pretty),
accountListArg = makeAccountListNonPseudo(activity, who.apDomain)
) { ai ->
blockConfirm(activity, ai, who, whoAccessInfo)
}
}
//////////////////////////////////////////////////////////////////////////////////////
// URLからユーザを検索してプロフを開く
private fun profileFromUrlOrAcct(
activity: ActMain,
pos: Int,
access_info: SavedAccount,
who_url: String,
acct: Acct
) {
TootTaskRunner(activity).run(access_info, object : TootTask {
var who: TootAccount? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val (result, ar) = client.syncAccountByUrl(access_info, who_url)
if (result == null) return null
who = ar?.get()
if (who != null) return result
val (r2, ar2) = client.syncAccountByAcct(access_info, acct)
who = ar2?.get()
return r2
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return // cancelled.
when (val who = this.who) {
null -> {
activity.showToast(true, result.error)
// 仕方ないのでchrome tab で開く
activity.openCustomTab(who_url)
}
else -> activity.addColumn(pos, access_info, ColumnType.PROFILE, who.id)
}
}
})
}
// アカウントを選んでユーザプロフを開く
fun profileFromAnotherAccount(
activity: ActMain,
pos: Int,
access_info: SavedAccount,
who: TootAccount?
) {
if (who?.url == null) return
AccountPicker.pick(
activity,
bAllowPseudo = false,
bAuto = false,
message = activity.getString(
R.string.account_picker_open_user_who,
AcctColor.getNickname(access_info, who)
),
accountListArg = makeAccountListNonPseudo(activity, who.apDomain)
) { ai ->
if (ai.matchHost(access_info)) {
activity.addColumn(pos, ai, ColumnType.PROFILE, who.id)
} else {
profileFromUrlOrAcct(activity, pos, ai, who.url, access_info.getFullAcct(who))
}
}
}
// 今のアカウントでユーザプロフを開く
fun profileLocal(
activity: ActMain,
pos: Int,
access_info: SavedAccount,
who: TootAccount
) {
when {
access_info.isNA -> profileFromAnotherAccount(activity, pos, access_info, who)
else -> activity.addColumn(pos, access_info, ColumnType.PROFILE, who.id)
}
}
// User URL で指定されたユーザのプロフを開く
// Intent-Filter や openChromeTabから 呼ばれる
fun profile(
activity: ActMain,
pos: Int,
access_info: SavedAccount?,
url: String,
host: Host,
user: String,
original_url: String = url
) {
val acct = Acct.parse(user, host)
if (access_info?.isPseudo == false) {
// 文脈のアカウントがあり、疑似アカウントではない
if (access_info.matchHost(host)) {
// 文脈のアカウントと同じインスタンスなら、アカウントIDを探して開いてしまう
TootTaskRunner(activity).run(access_info, object : TootTask {
var who: TootAccount? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val (result, ar) = client.syncAccountByAcct(access_info, acct)
who = ar?.get()
return result
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return // cancelled
when (val who = this.who) {
null -> {
// ダメならchromeで開く
activity.openCustomTab(url)
}
else -> profileLocal(activity, pos, access_info, who)
}
}
})
} else {
// 文脈のアカウントと異なるインスタンスなら、別アカウントで開く
profileFromUrlOrAcct(activity, pos, access_info, url, acct)
}
return
}
// 文脈がない、もしくは疑似アカウントだった
// 疑似アカウントでは検索APIを使えないため、IDが分からない
if (!SavedAccount.hasRealAccount()) {
// 疑似アカウントしか登録されていない
// chrome tab で開く
activity.openCustomTab(original_url)
} else {
AccountPicker.pick(
activity,
bAllowPseudo = false,
bAuto = false,
message = activity.getString(
R.string.account_picker_open_user_who,
AcctColor.getNickname(acct)
),
accountListArg = makeAccountListNonPseudo(activity, host),
extra_callback = { ll, pad_se, pad_tb ->
// chrome tab で開くアクションを追加
val lp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
val b = Button(activity)
b.setPaddingRelative(pad_se, pad_tb, pad_se, pad_tb)
b.gravity = Gravity.START or Gravity.CENTER_VERTICAL
b.isAllCaps = false
b.layoutParams = lp
b.minHeight = (0.5f + 32f * activity.density).toInt()
b.text = activity.getString(R.string.open_in_browser)
b.setBackgroundResource(R.drawable.btn_bg_transparent_round6dp)
b.setOnClickListener {
activity.openCustomTab(original_url)
}
ll.addView(b, 0)
}
) {
profileFromUrlOrAcct(activity, pos, it, url, acct)
}
}
}
//////////////////////////////////////////////////////////////////////////////////////
// 通報フォームを開く
fun reportForm(
activity: ActMain,
access_info: SavedAccount,
who: TootAccount,
status: TootStatus? = null
) {
ReportForm.showReportForm(activity, access_info, who, status) { dialog, comment, forward ->
report(activity, access_info, who, status, comment, forward) {
dialog.dismissSafe()
}
}
}
// 通報する
private fun report(
activity: ActMain,
access_info: SavedAccount,
who: TootAccount,
status: TootStatus?,
comment: String,
forward: Boolean,
onReportComplete: TootApiResultCallback
) {
if (access_info.isMe(who)) {
activity.showToast(false, R.string.it_is_you)
return
}
TootTaskRunner(activity).run(access_info, object : TootTask {
override suspend fun background(client: TootApiClient): TootApiResult? {
return client.request(
"/api/v1/reports",
JsonObject().apply {
put("account_id", who.id.toString())
put("comment", comment)
put("forward", forward)
if (status != null) {
put("status_ids", jsonArray {
add(status.id.toString())
})
}
}.toPostRequestBuilder()
)
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return // cancelled.
if (result.jsonObject != null) {
onReportComplete(result)
activity.showToast(false, R.string.report_completed)
} else {
activity.showToast(true, result.error)
}
}
})
}
// show/hide boosts from (following) user
fun showBoosts(
activity: ActMain, access_info: SavedAccount, who: TootAccount, bShow: Boolean
) {
if (access_info.isMe(who)) {
activity.showToast(false, R.string.it_is_you)
return
}
TootTaskRunner(activity).run(access_info, object : TootTask {
var relation: UserRelation? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val result = client.request(
"/api/v1/accounts/${who.id}/follow",
jsonObject {
try {
put("reblogs", bShow)
} catch (ex: Throwable) {
return TootApiResult(ex.withCaption("json encoding error"))
}
}.toPostRequestBuilder()
)
val jsonObject = result?.jsonObject
if (jsonObject != null) {
relation =
saveUserRelation(
access_info,
parseItem(
::TootRelationShip,
TootParser(activity, access_info),
jsonObject
)
)
}
return result
}
override suspend fun handleResult(result: TootApiResult?) {
if (result == null) return // cancelled.
if (relation != null) {
activity.showToast(true, R.string.operation_succeeded)
} else {
activity.showToast(true, result.error)
}
}
})
}
// メンションを含むトゥートを作る
private fun mention(
activity: ActMain,
account: SavedAccount,
initial_text: String
) {
ActPost.open(
activity,
ActMain.REQUEST_CODE_POST,
account.db_id,
initial_text = initial_text
)
}
// メンションを含むトゥートを作る
fun mention(
activity: ActMain, account: SavedAccount, who: TootAccount
) {
mention(activity, account, "@${account.getFullAcct(who).ascii} ")
}
// メンションを含むトゥートを作る
fun mentionFromAnotherAccount(
activity: ActMain, access_info: SavedAccount, who: TootAccount?
) {
if (who == null) return
val initial_text = "@${access_info.getFullAcct(who).ascii} "
AccountPicker.pick(
activity,
bAllowPseudo = false,
bAuto = false,
message = activity.getString(R.string.account_picker_toot),
accountListArg = makeAccountListNonPseudo(activity, who.apDomain)
) { ai ->
mention(activity, ai, initial_text)
}
}
fun deleteSuggestion(
activity: ActMain,
access_info: SavedAccount,
who: TootAccount,
bConfirmed: Boolean = false
) {
if (!bConfirmed) {
val name = who.decodeDisplayName(activity)
AlertDialog.Builder(activity)
.setMessage(name.intoStringResource(activity, R.string.delete_succeeded_confirm))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
deleteSuggestion(activity, access_info, who, bConfirmed = true)
}
.show()
return
}
TootTaskRunner(activity).run(access_info, object : TootTask {
override suspend fun background(client: TootApiClient): TootApiResult? {
return client.request("/api/v1/suggestions/${who.id}", Request.Builder().delete())
}
override suspend fun handleResult(result: TootApiResult?) {
// cancelled
result ?: return
// error
val error = result.error
if (error != null) {
activity.showToast(true, result.error)
return
}
activity.showToast(false, R.string.delete_succeeded)
// update suggestion column
for (column in activity.app_state.columnList) {
column.removeUser(access_info, ColumnType.FOLLOW_SUGGESTION, who.id)
}
}
})
}
fun statusNotification(
activity: ActMain,
accessInfo: SavedAccount,
whoId: EntityId,
enabled: Boolean
) {
TootTaskRunner(activity).run(accessInfo, object : TootTask {
override suspend fun background(client: TootApiClient): TootApiResult? {
return client.request(
"/api/v1/accounts/$whoId/follow",
jsonObject {
put("notify", enabled)
}.toPostRequestBuilder()
)?.also { result ->
val relation = parseItem(
::TootRelationShip,
TootParser(activity, accessInfo),
result.jsonObject
)
if (relation != null) {
UserRelation.save1Mastodon(
System.currentTimeMillis(),
accessInfo.db_id,
relation
)
}
}
}
override suspend fun handleResult(result: TootApiResult?) {
// cancelled
result ?: return
// error
val error = result.error
if (error != null) {
activity.showToast(true, result.error)
return
}
activity.showToast(false, R.string.operation_succeeded)
}
})
}
}