編集履歴カラムの追加。投稿のヘッダに編集済表記を追加。

This commit is contained in:
tateisu 2022-03-16 13:04:04 +09:00
parent 8bba82b930
commit 0bbb3728bb
18 changed files with 233 additions and 97 deletions

View File

@ -3,14 +3,12 @@ package jp.juggler.subwaytooter.action
import android.content.Intent
import androidx.appcompat.app.AlertDialog
import jp.juggler.subwaytooter.*
import jp.juggler.subwaytooter.actmain.addColumn
import jp.juggler.subwaytooter.actmain.reloadAccountSetting
import jp.juggler.subwaytooter.actmain.showColumnMatchAccount
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.column.Column
import jp.juggler.subwaytooter.column.findStatus
import jp.juggler.subwaytooter.column.onScheduleDeleted
import jp.juggler.subwaytooter.column.onStatusRemoved
import jp.juggler.subwaytooter.column.*
import jp.juggler.subwaytooter.dialog.ActionsDialog
import jp.juggler.subwaytooter.dialog.DlgConfirm
import jp.juggler.subwaytooter.dialog.pickAccount
@ -642,3 +640,12 @@ fun ActMain.scheduledPostEdit(
}
}
}
// アカウントを選んでタイムラインカラムを追加
fun ActMain.openStatusHistory(
pos: Int,
accessInfo: SavedAccount,
status: TootStatus,
) {
addColumn(pos, accessInfo, ColumnType.STATUS_HISTORY, status.id, status.json)
}

View File

@ -2,6 +2,7 @@ package jp.juggler.subwaytooter.api
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.util.LogCategory
import jp.juggler.util.cast
import java.util.*
class DuplicateMap {
@ -67,4 +68,30 @@ class DuplicateMap {
}
return listNew
}
private fun isDuplicateByCreatedAt(o: TimelineItem): Boolean {
if (o is TootStatus) {
val createdAt = EntityId(o.time_created_at.toString())
if (createdAt.notDefaultOrConfirming) {
if (idSet.contains(createdAt)) return true
idSet.add(createdAt)
}
}
//編集履歴以外のカラムではここを通らない
return false
}
fun filterDuplicateByCreatedAt(src: Collection<TimelineItem>?): ArrayList<TimelineItem> {
val listNew = ArrayList<TimelineItem>()
if (src != null) {
for (o in src) {
if (isDuplicateByCreatedAt(o)) {
log.d("filterDuplicateByCreatedAt: filtered. ${o.cast<TootStatus>()?.time_created_at}")
continue
}
listNew.add(o)
}
}
return listNew
}
}

View File

@ -182,6 +182,9 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
var isPromoted = false
var isFeatured = false
// Mastodon 3.5.0
var time_edited_at = 0L
///////////////////////////////////////////////////////////////////
// 以下はentityから取得したデータではなく、アプリ内部で使う
@ -602,6 +605,8 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
this.favourites_count = src.long("favourites_count")
this.replies_count = src.long("replies_count")
this.time_edited_at = parseTime(src.string("edited_at"))
this.reactionSet = TootReactionSet.parseFedibird(
src.jsonArray("emoji_reactions")
?: src.jsonObject("pleroma")?.jsonArray("emoji_reactions")
@ -1267,7 +1272,7 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
// 時刻を解釈してエポック秒(ミリ単位)を返す
// 解釈に失敗すると0Lを返す
fun parseTime(strTime: String?): Long {
if (strTime?.isNotBlank() != true) return 0L
if (strTime.isNullOrBlank()) return 0L
// last_status_at などでは YYYY-MM-DD になることがある
reDate.find(strTime)?.groupValues?.let { gv ->
@ -1552,5 +1557,23 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
return null
}
private val supplyEditHistoryKeys = arrayOf(
"id",
"uri",
"url",
"visibility",
)
// 編集履歴のデータはTootStatusとしては不足があるので、srcを元に補う
fun supplyEditHistory(array: JsonArray?, src: JsonObject?) {
src ?: return
array?.objectList()?.forEach {
for (key in supplyEditHistoryKeys) {
if (it.containsKey(key)) continue
it[key] = src[key]
}
}
}
}
}

View File

@ -160,6 +160,7 @@ class Column(
internal var profileTab = ProfileTab.Status
internal var statusId: EntityId? = null
internal var originalStatus: JsonObject? = null
// プロフカラムではアカウントのID。リストカラムではリストのID
internal var profileId: EntityId? = null

View File

@ -242,6 +242,7 @@ fun Column.onMuteUpdated() {
}
fun Column.replaceStatus(statusId: EntityId, statusJson: JsonObject) {
if (type == ColumnType.STATUS_HISTORY) return
fun createStatus() =
TootParser(context, accessInfo).status(statusJson)

View File

@ -56,6 +56,7 @@ object ColumnEncoder {
private const val KEY_PROFILE_ID = "profile_id"
private const val KEY_PROFILE_TAB = "tab"
private const val KEY_STATUS_ID = "status_id"
private const val KEY_ORIGINAL_STATUS = "original_status"
private const val KEY_HASHTAG = "hashtag"
private const val KEY_HASHTAG_ANY = "hashtag_any"
@ -170,6 +171,11 @@ object ColumnEncoder {
->
dst[KEY_STATUS_ID] = statusId.toString()
ColumnType.STATUS_HISTORY -> {
dst[KEY_STATUS_ID] = statusId.toString()
dst[KEY_ORIGINAL_STATUS] = originalStatus
}
ColumnType.FEDERATED_AROUND -> {
dst[KEY_STATUS_ID] = statusId.toString()
dst[KEY_REMOTE_ONLY] = remoteOnly
@ -299,6 +305,11 @@ object ColumnEncoder {
ColumnType.ACCOUNT_AROUND,
-> statusId = EntityId.mayNull(src.string(KEY_STATUS_ID))
ColumnType.STATUS_HISTORY -> {
statusId = EntityId.mayNull(src.string(KEY_STATUS_ID))
originalStatus = src.jsonObject(KEY_ORIGINAL_STATUS)
}
ColumnType.FEDERATED_AROUND,
-> {
statusId = EntityId.mayNull(src.string(KEY_STATUS_ID))

View File

@ -36,6 +36,7 @@ fun Column.canReloadWhenRefreshTop(): Boolean = when (type) {
ColumnType.TREND_TAG,
ColumnType.FOLLOW_SUGGESTION,
ColumnType.PROFILE_DIRECTORY,
ColumnType.STATUS_HISTORY,
-> true
ColumnType.LIST_MEMBER,
@ -56,7 +57,7 @@ fun Column.canRefreshTopBySwipe(): Boolean =
else -> true
}
// カラム操作的にリフレッシュを許容するかどうか
// カラム操作的に下端リフレッシュを許容するかどうか
fun Column.canRefreshBottomBySwipe(): Boolean = when (type) {
ColumnType.LIST_LIST,
ColumnType.CONVERSATION,
@ -65,6 +66,7 @@ fun Column.canRefreshBottomBySwipe(): Boolean = when (type) {
ColumnType.SEARCH,
ColumnType.TREND_TAG,
ColumnType.FOLLOW_SUGGESTION,
ColumnType.STATUS_HISTORY,
-> false
ColumnType.FOLLOW_REQUESTS -> isMisskey

View File

@ -44,20 +44,26 @@ fun Column.getFilterContext() = when (type) {
ColumnType.PROFILE -> TootFilter.CONTEXT_PROFILE
ColumnType.STATUS_HISTORY -> TootFilter.CONTEXT_NONE
else -> TootFilter.CONTEXT_PUBLIC
// ColumnType.MISSKEY_HYBRID や ColumnType.MISSKEY_ANTENNA_TL はHOMEでもPUBLICでもある…
// Misskeyだし関係ないが、NONEにするとアプリ内で完結するフィルタも働かなくなる
}
// カラム設定に正規表現フィルタを含めるなら真
fun Column.canStatusFilter(): Boolean {
if (getFilterContext() != TootFilter.CONTEXT_NONE) return true
return when (type) {
ColumnType.SEARCH_MSP, ColumnType.SEARCH_TS, ColumnType.SEARCH_NOTESTOCK -> true
else -> false
fun Column.canStatusFilter() =
when (type) {
ColumnType.SEARCH_MSP,
ColumnType.SEARCH_TS,
ColumnType.SEARCH_NOTESTOCK,
ColumnType.STATUS_HISTORY,
-> true
else -> when {
getFilterContext() == TootFilter.CONTEXT_NONE -> false
else -> true
}
}
}
// カラム設定に「すべての画像を隠す」ボタンを含めるなら真
fun Column.canNSFWDefault(): Boolean = canStatusFilter()

View File

@ -4,6 +4,7 @@ import jp.juggler.subwaytooter.api.entity.Acct
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.JsonObject
import jp.juggler.util.LogCategory
private val log = LogCategory("ColumnSpec")
@ -34,6 +35,12 @@ object ColumnSpec {
else -> error("getParamString [$idx] bad type. $o")
}
private fun getParamJsonObject(params: Array<out Any>, idx: Int): JsonObject =
when (val o = params[idx]) {
is JsonObject -> o
else -> error("getParamJsonObject [$idx] bad type. $o")
}
@Suppress("UNCHECKED_CAST")
private inline fun <reified T> getParamAtNullable(params: Array<out Any>, idx: Int): T? {
if (idx >= params.size) return null
@ -53,6 +60,11 @@ object ColumnSpec {
->
statusId = getParamEntityId(params, 0)
ColumnType.STATUS_HISTORY -> {
statusId = getParamEntityId(params, 0)
originalStatus = getParamJsonObject(params, 1)
}
ColumnType.PROFILE, ColumnType.LIST_TL, ColumnType.LIST_MEMBER,
ColumnType.MISSKEY_ANTENNA_TL,
->
@ -121,19 +133,20 @@ object ColumnSpec {
ColumnType.LOCAL_AROUND,
ColumnType.FEDERATED_AROUND,
ColumnType.ACCOUNT_AROUND,
ColumnType.STATUS_HISTORY,
->
column.statusId == getParamEntityId(params, 0)
ColumnType.HASHTAG -> {
(getParamString(params, 0) == column.hashtag) &&
((getParamAtNullable<String>(params, 1) ?: "") == column.hashtagAny) &&
((getParamAtNullable<String>(params, 2) ?: "") == column.hashtagAll) &&
((getParamAtNullable<String>(params, 3) ?: "") == column.hashtagNone)
((getParamAtNullable<String>(params, 1) ?: "") == column.hashtagAny) &&
((getParamAtNullable<String>(params, 2) ?: "") == column.hashtagAll) &&
((getParamAtNullable<String>(params, 3) ?: "") == column.hashtagNone)
}
ColumnType.HASHTAG_FROM_ACCT -> {
(getParamString(params, 0) == column.hashtag) &&
((getParamAtNullable<String>(params, 1) ?: "") == column.hashtagAcct)
((getParamAtNullable<String>(params, 1) ?: "") == column.hashtagAcct)
}
ColumnType.NOTIFICATION_FROM_ACCT -> {
@ -142,7 +155,7 @@ object ColumnSpec {
ColumnType.SEARCH ->
getParamString(params, 0) == column.searchQuery &&
getParamAtNullable<Boolean>(params, 1) == column.searchResolve
getParamAtNullable<Boolean>(params, 1) == column.searchResolve
ColumnType.SEARCH_MSP,
ColumnType.SEARCH_TS,
@ -155,8 +168,8 @@ object ColumnSpec {
ColumnType.PROFILE_DIRECTORY ->
getParamString(params, 0) == column.instanceUri &&
getParamAtNullable<String>(params, 1) == column.searchQuery &&
getParamAtNullable<Boolean>(params, 2) == column.searchResolve
getParamAtNullable<String>(params, 1) == column.searchQuery &&
getParamAtNullable<Boolean>(params, 2) == column.searchResolve
ColumnType.DOMAIN_TIMELINE ->
getParamString(params, 0) == column.instanceUri

View File

@ -104,6 +104,9 @@ class ColumnTask_Loading(
// 検索カラムはIDによる重複排除が不可能
ColumnType.SEARCH -> listTmp
// 編集履歴は投稿日時で重複排除する
ColumnType.STATUS_HISTORY -> column.duplicateMap.filterDuplicateByCreatedAt(listTmp)
// 他のカラムは重複排除してから追加
else -> column.duplicateMap.filterDuplicate(listTmp)
}
@ -847,6 +850,19 @@ class ColumnTask_Loading(
return result
}
suspend fun getEditHistory(client: TootApiClient): TootApiResult? {
// ページングなし
val result = client.request("/api/v1/statuses/${column.statusId}/history")
// TootStatusとしては不足している情報があるのを補う
TootStatus.supplyEditHistory(result?.jsonArray, column.originalStatus)
val src = parser.statusList(result?.jsonArray).reversed()
listTmp = addAll(listTmp, src)
column.saveRange(bBottom = true, bTop = true, result = result, list = src)
return result
}
suspend fun getListList(
client: TootApiClient,
pathBase: String,

View File

@ -92,7 +92,12 @@ class ColumnTask_Refresh(
return
}
val listNew = column.duplicateMap.filterDuplicate(listTmp)
val listNew = when (column.type) {
// 編集履歴は投稿日時で重複排除する
ColumnType.STATUS_HISTORY -> column.duplicateMap.filterDuplicateByCreatedAt(listTmp)
else -> column.duplicateMap.filterDuplicate(listTmp)
}
if (listNew.isEmpty()) {
column.fireShowContent(
reason = "refresh list_new is empty",
@ -1153,4 +1158,17 @@ class ColumnTask_Refresh(
column.saveRange(bBottom, !bBottom, result, src)
return result
}
suspend fun getEditHistory(client: TootApiClient): TootApiResult? {
// ページングなし
val result = client.request("/api/v1/statuses/${column.statusId}/history")
// TootStatusとしては不足している情報があるのを補う
TootStatus.supplyEditHistory(result?.jsonArray, column.originalStatus)
val src = parser.statusList(result?.jsonArray).reversed()
listTmp = addAll(listTmp, src)
column.saveRange(bBottom, !bBottom, result, src)
return result
}
}

View File

@ -663,7 +663,11 @@ enum class ColumnType(
},
loading = { client -> getPublicTlAroundTime(client, column.makePublicFederateUrl()) },
refresh = { client -> getStatusList(client, column.makePublicFederateUrl(), useMinId = true) },
refresh = { client ->
getStatusList(client,
column.makePublicFederateUrl(),
useMinId = true)
},
canStreamingMastodon = streamingTypeNo,
canStreamingMisskey = streamingTypeNo,
@ -1965,6 +1969,24 @@ enum class ColumnType(
// Misskey10 にアンテナはない
),
STATUS_HISTORY(
43,
iconId = { R.drawable.ic_history },
name1 = { it.getString(R.string.edit_history) },
bAllowPseudo = true,
bAllowMisskey = false,
loading = { client ->
getEditHistory(client)
},
refresh = { client ->
getEditHistory(client)
},
canStreamingMastodon = streamingTypeNo,
canStreamingMisskey = streamingTypeNo,
),
;
init {
@ -1985,7 +2007,7 @@ enum class ColumnType(
min = min(min, id)
max = max(max, id)
}
log.d("dump: ColumnType range=$min..$max")
log.i("dump: ColumnType range=$min..$max")
}
fun parse(id: Int) = Column.typeMap[id] ?: HOME
@ -2015,7 +2037,11 @@ fun Column.streamKeyHashtagTl() =
"hashtag"
.appendIf(":local", instanceLocal)
private fun unmatchMastodonStream(stream: JsonArray, name: String, expectArg: String? = null): Boolean {
private fun unmatchMastodonStream(
stream: JsonArray,
name: String,
expectArg: String? = null,
): Boolean {
val key = stream.string(0)

View File

@ -88,71 +88,11 @@ internal class DlgContextMenu(
dialog.setCancelable(true)
dialog.setCanceledOnTouchOutside(true)
arrayOf(
views.btnAccountWebPage,
views.btnAroundAccountTL,
views.btnAroundFTL,
views.btnAroundLTL,
views.btnAvatarImage,
views.btnBlock,
views.btnBookmarkAnotherAccount,
views.btnBoostAnotherAccount,
views.btnBoostedBy,
views.btnBoostWithVisibility,
views.btnConversationAnotherAccount,
views.btnConversationMute,
views.btnCopyAccountId,
views.btnDelete,
views.btnDeleteSuggestion,
views.btnDomainBlock,
views.btnDomainTimeline,
views.btnEndorse,
views.btnFavouriteAnotherAccount,
views.btnFavouritedBy,
views.btnFollow,
views.btnFollowFromAnotherAccount,
views.btnFollowRequestNG,
views.btnFollowRequestOK,
views.btnHideBoost,
views.btnHideFavourite,
views.btnInstanceInformation,
views.btnListMemberAddRemove,
views.btnMute,
views.btnMuteApp,
views.btnNotificationDelete,
views.btnNotificationFrom,
views.btnOpenAccountInAdminWebUi,
views.btnOpenInstanceInAdminWebUi,
views.btnOpenProfileFromAnotherAccount,
views.btnOpenTimeline,
views.btnProfile,
views.btnProfileDirectory,
views.btnProfilePin,
views.btnProfileUnpin,
views.btnQuoteAnotherAccount,
views.btnQuoteTootBT,
views.btnReactionAnotherAccount,
views.btnRedraft,
views.btnStatusEdit,
views.btnReplyAnotherAccount,
views.btnReportStatus,
views.btnReportUser,
views.btnSendMessage,
views.btnSendMessageFromAnotherAccount,
views.btnShowBoost,
views.btnShowFavourite,
views.btnStatusNotification,
views.btnStatusWebPage,
views.btnText,
views.btnQuoteUrlStatus,
views.btnTranslate,
views.btnQuoteUrlAccount,
views.btnShareUrlStatus,
views.btnShareUrlAccount,
views.btnQuoteName
).forEach { it.setOnClickListener(this) }
views.root.scan { v ->
when (v) {
is Button -> v.setOnClickListener(this)
}
}
arrayOf(
views.btnBlock,
@ -219,6 +159,11 @@ internal class DlgContextMenu(
}
}
}
views.btnStatusHistory.vg(status.time_edited_at > 0L && columnType != ColumnType.STATUS_HISTORY)
?.text = activity.getString(R.string.edit_history) + "\n" +
TootStatus.formatTime(activity, status.time_edited_at, bAllowRelative = false)
views.llLinks.vg(views.llLinks.childCount > 1)
views.btnGroupStatusByMe.vg(statusByMe)
@ -372,8 +317,6 @@ internal class DlgContextMenu(
}
}
views.btnAccountText.setOnClickListener(this)
if (accessInfo.isPseudo) {
views.btnProfile.visibility = View.GONE
views.btnSendMessage.visibility = View.GONE
@ -399,10 +342,6 @@ internal class DlgContextMenu(
views.btnSendMessageFromAnotherAccount.visibility = View.GONE
}
views.btnNickname.setOnClickListener(this)
views.btnCancel.setOnClickListener(this)
views.btnAccountQrCode.setOnClickListener(this)
if (accessInfo.isPseudo ||
who == null ||
!relation.getFollowing(who) ||
@ -641,6 +580,7 @@ internal class DlgContextMenu(
R.id.btnConversationMute -> conversationMute(accessInfo, status)
R.id.btnProfilePin -> statusPin(accessInfo, status, true)
R.id.btnProfileUnpin -> statusPin(accessInfo, status, false)
R.id.btnStatusHistory -> openStatusHistory(pos, accessInfo, status)
else -> return false
}
return true

View File

@ -2,10 +2,12 @@ package jp.juggler.subwaytooter.itemviewholder
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.Typeface
import android.os.SystemClock
import android.text.Spannable
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.style.StyleSpan
import android.view.View
import android.widget.Button
import android.widget.TextView
@ -602,6 +604,14 @@ fun ItemViewHolder.showStatusTime(
if (sb.isNotEmpty()) sb.append(' ')
sb.append(activity.getString(R.string.featured))
}
if (status.time_edited_at > 0L) {
if (sb.isNotEmpty()) sb.append(' ')
val start = sb.length
sb.append(activity.getString(R.string.edited))
val end = sb.length
sb.setSpan(StyleSpan(Typeface.BOLD), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
} else {
reblogVisibility?.takeIf { it != TootVisibility.Unknown }?.let { visibility ->
val visIconId = Styler.getVisibilityIconId(accessInfo.isMisskey, visibility)
@ -626,12 +636,18 @@ fun ItemViewHolder.showStatusTime(
time != null -> TootStatus.formatTime(
activity,
time,
column.type != ColumnType.CONVERSATION
when (column.type) {
ColumnType.CONVERSATION, ColumnType.STATUS_HISTORY -> false
else -> true
}
)
status != null -> TootStatus.formatTime(
activity,
status.time_created_at,
column.type != ColumnType.CONVERSATION
when (column.type) {
ColumnType.CONVERSATION, ColumnType.STATUS_HISTORY -> false
else -> true
}
)
else -> "?"
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z"/>
</vector>

View File

@ -50,6 +50,21 @@
android:textColor="?attr/colorTimeSmall"
android:textSize="12sp" />
<Button
android:id="@+id/btnStatusHistory"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/btn_bg_transparent_round6dp"
android:gravity="start|center_vertical"
android:minHeight="32dp"
android:paddingStart="8dp"
android:paddingTop="4dp"
android:paddingEnd="8dp"
android:paddingBottom="4dp"
android:text="@string/edit_history"
android:textAllCaps="false" />
<Button
android:id="@+id/btnStatusWebPage"
android:layout_width="match_parent"

View File

@ -1127,4 +1127,6 @@
<string name="always">常に</string>
<string name="auto">自動</string>
<string name="status_edit">編集(Mastodon 3.5.0+)</string>
<string name="edited">編集済</string>
<string name="edit_history">編集履歴</string>
</resources>

View File

@ -1138,4 +1138,6 @@
<string name="always">Always</string>
<string name="auto">Auto</string>
<string name="status_edit">Edit (Mastodon 3.5.0+)</string>
<string name="edited">edited</string>
<string name="edit_history">Edit history</string>
</resources>