「会話と参照」カラムを表示する

This commit is contained in:
tateisu 2022-08-02 11:41:05 +09:00
parent eeedf742a6
commit 406ce8e5c5
21 changed files with 337 additions and 216 deletions

View File

@ -1,7 +1,8 @@
package jp.juggler.subwaytooter.action package jp.juggler.subwaytooter.action
import android.content.Context import android.content.Context
import jp.juggler.subwaytooter.* import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.actmain.addColumn import jp.juggler.subwaytooter.actmain.addColumn
import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
@ -14,7 +15,6 @@ import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.matchHost import jp.juggler.subwaytooter.util.matchHost
import jp.juggler.subwaytooter.util.openCustomTab import jp.juggler.subwaytooter.util.openCustomTab
import jp.juggler.util.* import jp.juggler.util.*
import java.util.*
private val log = LogCategory("Action_Conversation") private val log = LogCategory("Action_Conversation")
@ -45,7 +45,12 @@ fun ActMain.clickConversation(
} }
// プレビューカードのイメージは返信かもしれない // プレビューカードのイメージは返信かもしれない
fun ActMain.clickCardImage(pos: Int, accessInfo: SavedAccount, card: TootCard?, longClick: Boolean = false) { fun ActMain.clickCardImage(
pos: Int,
accessInfo: SavedAccount,
card: TootCard?,
longClick: Boolean = false,
) {
card ?: return card ?: return
card.originalStatus?.let { card.originalStatus?.let {
if (longClick) { if (longClick) {
@ -80,7 +85,7 @@ fun ActMain.clickReplyInfo(
// tootsearchは返信元のIDを取得するのにひと手間必要 // tootsearchは返信元のIDを取得するのにひと手間必要
columnType == ColumnType.SEARCH_TS || columnType == ColumnType.SEARCH_TS ||
columnType == ColumnType.SEARCH_NOTESTOCK -> columnType == ColumnType.SEARCH_NOTESTOCK ->
conversationFromTootsearch(pos, statusShowing) conversationFromTootsearch(pos, statusShowing)
else -> else ->
@ -140,14 +145,26 @@ fun ActMain.conversationLocal(
pos: Int, pos: Int,
accessInfo: SavedAccount, accessInfo: SavedAccount,
statusId: EntityId, statusId: EntityId,
) = addColumn(pos, accessInfo, ColumnType.CONVERSATION, statusId) isReference: Boolean = false,
) = addColumn(
pos,
accessInfo,
when {
isReference && TootInstance.getCached(accessInfo)?.canUseReference == true ->
ColumnType.CONVERSATION_WITH_REFERENCE
else ->
ColumnType.CONVERSATION
},
statusId,
)
private val reDetailedStatusTime = private val reDetailedStatusTime =
"""<a\b[^>]*?\bdetailed-status__datetime\b[^>]*href="https://[^/]+/@[^/]+/([^\s?#/"]+)""" """<a\b[^>]*?\bdetailed-status__datetime\b[^>]*href="https://[^/]+/@[^/]+/([^\s?#/"]+)"""
.toRegex() .toRegex()
private val reHeaderOgUrl = """<meta\s+content="https://[^/"]+/notice/([^/"]+)"\s+property="og:url"/?>""" private val reHeaderOgUrl =
.toRegex() """<meta\s+content="https://[^/"]+/notice/([^/"]+)"\s+property="og:url"/?>"""
.toRegex()
// 疑似アカウントではURLからIDを取得するのにHTMLと正規表現を使う // 疑似アカウントではURLからIDを取得するのにHTMLと正規表現を使う
suspend fun guessStatusIdFromPseudoAccount( suspend fun guessStatusIdFromPseudoAccount(
@ -190,7 +207,8 @@ private fun ActMain.conversationRemote(
) { client -> ) { client ->
if (accessInfo.isPseudo) { if (accessInfo.isPseudo) {
// 疑似アカウントではURLからIDを取得するのにHTMLと正規表現を使う // 疑似アカウントではURLからIDを取得するのにHTMLと正規表現を使う
val pair = guessStatusIdFromPseudoAccount(applicationContext, client, remoteStatusUrl) val pair =
guessStatusIdFromPseudoAccount(applicationContext, client, remoteStatusUrl)
localStatusId = pair.second localStatusId = pair.second
pair.first pair.first
} else { } else {
@ -218,6 +236,7 @@ fun ActMain.conversationOtherInstance(
statusIdOriginal: EntityId? = null, statusIdOriginal: EntityId? = null,
hostAccess: Host? = null, hostAccess: Host? = null,
statusIdAccess: EntityId? = null, statusIdAccess: EntityId? = null,
isReference: Boolean = false,
) { ) {
val activity = this val activity = this
@ -226,7 +245,8 @@ fun ActMain.conversationOtherInstance(
val hostOriginal = Host.parse(url.toUri().authority ?: "") val hostOriginal = Host.parse(url.toUri().authority ?: "")
// 選択肢:ブラウザで表示する // 選択肢:ブラウザで表示する
dialog.addAction(getString(R.string.open_web_on_host, hostOriginal.pretty)) { openCustomTab(url) } dialog.addAction(getString(R.string.open_web_on_host,
hostOriginal.pretty)) { openCustomTab(url) }
// トゥートの投稿元タンスにあるアカウント // トゥートの投稿元タンスにあるアカウント
val localAccountList = ArrayList<SavedAccount>() val localAccountList = ArrayList<SavedAccount>()
@ -263,7 +283,7 @@ fun ActMain.conversationOtherInstance(
) { ) {
launchMain { launchMain {
addPseudoAccount(hostOriginal)?.let { sa -> addPseudoAccount(hostOriginal)?.let { sa ->
conversationLocal(pos, sa, statusIdOriginal) conversationLocal(pos, sa, statusIdOriginal, isReference = isReference)
} }
} }
} }
@ -290,7 +310,7 @@ fun ActMain.conversationOtherInstance(
R.string.open_in_account, R.string.open_in_account,
a.acct a.acct
) )
) { conversationLocal(pos, a, statusIdOriginal) } ) { conversationLocal(pos, a, statusIdOriginal, isReference = isReference) }
} }
} }
@ -304,7 +324,7 @@ fun ActMain.conversationOtherInstance(
R.string.open_in_account, R.string.open_in_account,
a.acct a.acct
) )
) { conversationLocal(pos, a, statusIdAccess) } ) { conversationLocal(pos, a, statusIdAccess, isReference = isReference) }
} }
} }

View File

@ -55,7 +55,8 @@ fun ActMain.handleOtherUri(uri: Uri): Boolean {
statusInfo.url, statusInfo.url,
statusInfo.statusId, statusInfo.statusId,
statusInfo.host, statusInfo.host,
statusInfo.statusId statusInfo.statusId,
isReference = statusInfo.isReference,
) )
return true return true
} }

View File

@ -40,8 +40,6 @@ object ApiPath {
// リストではなくオブジェクトを返すAPI // リストではなくオブジェクトを返すAPI
const val PATH_STATUSES = "/api/v1/statuses/%s" // 1:status_id const val PATH_STATUSES = "/api/v1/statuses/%s" // 1:status_id
const val PATH_STATUSES_CONTEXT = "/api/v1/statuses/%s/context" // 1:status_id
// search args 1: query(urlencoded) , also, append "&resolve=1" if resolve non-local accounts
const val PATH_FILTERS = "/api/v1/filters" const val PATH_FILTERS = "/api/v1/filters"

View File

@ -4,14 +4,16 @@ import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.util.JsonObject import jp.juggler.util.JsonObject
class TootContext( class TootContext(
// The ancestors of the status in the conversation, as a list of Statuses // The ancestors of the status in the conversation, as a list of Statuses
val ancestors: ArrayList<TootStatus>?, val ancestors: ArrayList<TootStatus>?,
// descendants The descendants of the status in the conversation, as a list of Statuses // descendants The descendants of the status in the conversation, as a list of Statuses
val descendants: ArrayList<TootStatus>?, val descendants: ArrayList<TootStatus>?,
// fedibird: 参照
val references: ArrayList<TootStatus>?,
) { ) {
constructor(parser: TootParser, src: JsonObject) : this( constructor(parser: TootParser, src: JsonObject) : this(
ancestors = parseListOrNull(::TootStatus, parser, src.jsonArray("ancestors")), ancestors = parseListOrNull(::TootStatus, parser, src.jsonArray("ancestors")),
descendants = parseListOrNull(::TootStatus, parser, src.jsonArray("descendants")) descendants = parseListOrNull(::TootStatus, parser, src.jsonArray("descendants")),
references = parseListOrNull(::TootStatus, parser, src.jsonArray("references")),
) )
} }

View File

@ -10,8 +10,12 @@ import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.subwaytooter.util.VersionString import jp.juggler.subwaytooter.util.VersionString
import jp.juggler.util.* import jp.juggler.util.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import okhttp3.Request import okhttp3.Request
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.coroutines.Continuation
import kotlin.coroutines.suspendCoroutine
import kotlin.math.max import kotlin.math.max
// インスタンスの種別 // インスタンスの種別
@ -220,16 +224,9 @@ class TootInstance(parser: TootParser, src: JsonObject) {
} }
class Stats(src: JsonObject) { class Stats(src: JsonObject) {
val user_count = src.long("user_count") ?: -1L
val user_count: Long val status_count = src.long("status_count") ?: -1L
val status_count: Long val domain_count = src.long("domain_count") ?: -1L
val domain_count: Long
init {
this.user_count = src.long("user_count") ?: -1L
this.status_count = src.long("status_count") ?: -1L
this.domain_count = src.long("domain_count") ?: -1L
}
} }
val misskeyVersion: Int val misskeyVersion: Int
@ -239,6 +236,9 @@ class TootInstance(parser: TootParser, src: JsonObject) {
else -> 10 else -> 10
} }
val canUseReference: Boolean?
get() = fedibird_capabilities?.contains("status_reference")
fun versionGE(check: VersionString): Boolean { fun versionGE(check: VersionString): Boolean {
if (decoded_version.isEmpty || check.isEmpty) return false if (decoded_version.isEmpty || check.isEmpty) return false
val i = VersionString.compare(decoded_version, check) val i = VersionString.compare(decoded_version, check)
@ -246,6 +246,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
} }
companion object { companion object {
private val log = LogCategory("TootInstance")
private val rePleroma = """\bpleroma\b""".asciiPattern(Pattern.CASE_INSENSITIVE) private val rePleroma = """\bpleroma\b""".asciiPattern(Pattern.CASE_INSENSITIVE)
private val rePixelfed = """\bpixelfed\b""".asciiPattern(Pattern.CASE_INSENSITIVE) private val rePixelfed = """\bpixelfed\b""".asciiPattern(Pattern.CASE_INSENSITIVE)
@ -375,26 +376,33 @@ class TootInstance(parser: TootParser, src: JsonObject) {
} }
// マストドンのインスタンス情報を読めたら、それはマストドンのインスタンス // マストドンのインスタンス情報を読めたら、それはマストドンのインスタンス
val r1 = getInstanceInformationMastodon(forceAccessToken) ?: return null // インスタンス情報を読めない場合もホワイトリストモードの問題があるので
if (r1.jsonObject != null) return r1 // マストドン側のエラーを返す
return getInstanceInformationMastodon(forceAccessToken)
return r1 // ホワイトリストモードの問題があるのでマストドン側のエラーを返す
} }
/**
* TootInstance.get() のエラー戻り値を作る
*/
private fun tiError(errMsg: String) =
Pair<TootInstance?, TootApiResult?>(null, TootApiResult(errMsg))
/**
* サーバ情報リクエスト
* - ホスト別のキューで実行する
*/
class QueuedRequest( class QueuedRequest(
var cont: Continuation<Pair<TootInstance?, TootApiResult?>>,
val allowPixelfed: Boolean, val allowPixelfed: Boolean,
val get: suspend (cached: TootInstance?) -> Pair<TootInstance?, TootApiResult?>, val get: suspend (cached: TootInstance?) -> Pair<TootInstance?, TootApiResult?>,
)
/**
* ホスト別のインスタンス情報キャッシュと処理キュー
*/
class CacheEntry(
val hostLower: String,
) { ) {
val result = Channel<Pair<TootInstance?, TootApiResult?>>()
}
private fun queuedRequest(
allowPixelfed: Boolean,
get: suspend (cached: TootInstance?) -> Pair<TootInstance?, TootApiResult?>,
) = QueuedRequest(allowPixelfed, get)
// インスタンス情報のキャッシュ。同期オブジェクトを兼ねる
class CacheEntry {
// インスタンス情報のキャッシュ // インスタンス情報のキャッシュ
var cacheData: TootInstance? = null var cacheData: TootInstance? = null
@ -402,32 +410,38 @@ class TootInstance(parser: TootParser, src: JsonObject) {
val requestQueue = Channel<QueuedRequest>(capacity = Channel.UNLIMITED) val requestQueue = Channel<QueuedRequest>(capacity = Channel.UNLIMITED)
private suspend fun handleRequest(req: QueuedRequest) = try { private suspend fun handleRequest(req: QueuedRequest) = try {
val pair = req.get(cacheData) val qrr = req.get(cacheData)
qrr.first?.let { cacheData = it }
pair.first?.let { cacheData = it }
when { when {
pair.first?.instanceType == InstanceType.Pixelfed && qrr.first?.instanceType == InstanceType.Pixelfed &&
!PrefB.bpEnablePixelfed() && !PrefB.bpEnablePixelfed() &&
!req.allowPixelfed -> !req.allowPixelfed ->
Pair( tiError("currently Pixelfed instance is not supported.")
null, TootApiResult("currently Pixelfed instance is not supported.") else -> qrr
)
else -> pair
} }
} catch (ex: Throwable) { } catch (ex: Throwable) {
Pair( log.e(ex, "handleRequest failed.")
null, tiError(ex.withCaption("can't get server information."))
TootApiResult(ex.withCaption("can't get server information."))
)
} }
init { init {
launchDefault { launchDefault {
while (true) { while (true) {
for (req in requestQueue) { try {
req.result.send(handleRequest(req)) val req = requestQueue.receive()
val r = try {
withTimeout(30000L) {
handleRequest(req)
}
} catch (ex: Throwable) {
log.trace(ex, "handleRequest failed.")
tiError(ex.withCaption("handleRequest failed."))
}
runCatching { req.cont.resumeWith(Result.success(r)) }
} catch (ex: Throwable) {
log.trace(ex, "requestQueue.take failed.")
delay(3000L)
} }
} }
} }
@ -441,7 +455,7 @@ class TootInstance(parser: TootParser, src: JsonObject) {
val hostLower = ascii.lowercase() val hostLower = ascii.lowercase()
var item = _hostCache[hostLower] var item = _hostCache[hostLower]
if (item == null) { if (item == null) {
item = CacheEntry() item = CacheEntry(hostLower)
_hostCache[hostLower] = item _hostCache[hostLower] = item
} }
item item
@ -463,86 +477,99 @@ class TootInstance(parser: TootParser, src: JsonObject) {
forceUpdate: Boolean = false, forceUpdate: Boolean = false,
forceAccessToken: String? = null, // マストドンのwhitelist modeでアカウント追加時に必要 forceAccessToken: String? = null, // マストドンのwhitelist modeでアカウント追加時に必要
): Pair<TootInstance?, TootApiResult?> { ): Pair<TootInstance?, TootApiResult?> {
try {
val cacheEntry = (hostArg ?: account?.apiHost ?: client.apiHost)?.getCacheEntry()
?: return tiError("missing host.")
val cacheEntry = (hostArg ?: account?.apiHost ?: client.apiHost)?.getCacheEntry() return withTimeout(30000L) {
?: return Pair(null, TootApiResult("missing host.")) suspendCoroutine { cont ->
QueuedRequest(cont, allowPixelfed) { cached ->
// ホスト名ごとに用意したオブジェクトで同期する // may use cached item.
return queuedRequest(allowPixelfed) { cached -> if (!forceUpdate && forceAccessToken == null && cached != null) {
val now = SystemClock.elapsedRealtime()
if (now - cached.time_parse <= EXPIRE) {
return@QueuedRequest Pair(cached, TootApiResult())
}
}
// may use cached item. val tmpInstance = client.apiHost
if (!forceUpdate && forceAccessToken == null && cached != null) { val tmpAccount = client.account
val now = SystemClock.elapsedRealtime()
if (now - cached.time_parse <= EXPIRE) {
return@queuedRequest Pair(cached, TootApiResult())
}
}
val tmpInstance = client.apiHost val linkHelper: LinkHelper?
val tmpAccount = client.account
val linkHelper: LinkHelper? // get new information
val result = when {
// get new information // ストリームマネジャから呼ばれる
val result = when { account != null -> try {
linkHelper = account
client.account = account // this may change client.apiHost
if (account.isMisskey) {
client.getInstanceInformationMisskey()
} else {
client.getInstanceInformationMastodon()
}
} finally {
client.account = tmpAccount
client.apiHost = tmpInstance // must be last.
}
// ストリームマネジャから呼ばれる // サーバ情報カラムやProfileDirectoryを開く場合
account != null -> try { hostArg != null && hostArg != tmpInstance -> try {
linkHelper = account linkHelper = null
client.account = account // this may change client.apiHost client.account = null // don't use access token.
if (account.isMisskey) { client.apiHost = hostArg
client.getInstanceInformationMisskey() client.getInstanceInformation()
} else { } finally {
client.getInstanceInformationMastodon() client.account = tmpAccount
client.apiHost = tmpInstance // must be last.
}
// client にすでにあるアクセス情報でサーバ情報を取得する
// マストドンのホワイトリストモード用にアクセストークンを指定できる
else -> {
linkHelper = client.account // may null
client.getInstanceInformation(
forceAccessToken = forceAccessToken
)
}
}
val json = result?.jsonObject
?: return@QueuedRequest Pair(null, result)
val item = parseItem(
::TootInstance,
TootParser(
client.context,
linkHelper = linkHelper ?: LinkHelper.create(
(hostArg ?: client.apiHost)!!,
misskeyVersion = parseMisskeyVersion(json)
)
),
json
) ?: return@QueuedRequest Pair(
null,
result.setError("instance information parse error.")
)
Pair(item, result)
}.let {
val result = cacheEntry.requestQueue.trySend(it)
when {
// 誰も閉じないので発生しない
result.isClosed -> error("cacheEntry.requestQueue closed")
// capacity=UNLIMITEDなので発生しない
result.isFailure -> error("cacheEntry.requestQueue failed")
}
} }
} finally {
client.account = tmpAccount
client.apiHost = tmpInstance // must be last.
}
// サーバ情報カラムやProfileDirectoryを開く場合
hostArg != null && hostArg != tmpInstance -> try {
linkHelper = null
client.account = null // don't use access token.
client.apiHost = hostArg
client.getInstanceInformation()
} finally {
client.account = tmpAccount
client.apiHost = tmpInstance // must be last.
}
// client にすでにあるアクセス情報でサーバ情報を取得する
// マストドンのホワイトリストモード用にアクセストークンを指定できる
else -> {
linkHelper = client.account // may null
client.getInstanceInformation(
forceAccessToken = forceAccessToken
)
} }
} }
} catch (ex: Throwable) {
val json = result?.jsonObject log.w(ex, "getEx failed.")
?: return@queuedRequest Pair(null, result) return tiError(ex.withCaption("can't get instance information"))
val item = parseItem(
::TootInstance,
TootParser(
client.context,
linkHelper = linkHelper ?: LinkHelper.create(
(hostArg ?: client.apiHost)!!,
misskeyVersion = parseMisskeyVersion(json)
)
),
json
) ?: return@queuedRequest Pair(
null,
result.setError("instance information parse error.")
)
Pair(item, result)
} }
.also { cacheEntry.requestQueue.send(it) }
.result.receive()
} }
} }
} }

View File

@ -2,26 +2,25 @@ package jp.juggler.subwaytooter.api.entity
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import androidx.annotation.StringRes
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import androidx.annotation.StringRes
import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.TootAccountMap
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.emoji.CustomEmoji import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.mfm.SpannableStringBuilderEx import jp.juggler.subwaytooter.mfm.SpannableStringBuilderEx
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.table.HighlightWord import jp.juggler.subwaytooter.table.HighlightWord
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.HTMLDecoder
import jp.juggler.util.* import jp.juggler.util.*
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
import kotlin.jvm.Throws
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@ -1079,8 +1078,8 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
val statusId: EntityId?, // may null val statusId: EntityId?, // may null
hostArg: String, hostArg: String,
val url: String, val url: String,
val isReference: Boolean = false,
) { ) {
val host = Host.parse(hostArg) val host = Host.parse(hostArg)
} }
@ -1117,6 +1116,11 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
private val reStatusPage = """\Ahttps://([^/]+)/@(\w+)/([^?#/\s]+)(?:\z|[?#])""" private val reStatusPage = """\Ahttps://([^/]+)/@(\w+)/([^?#/\s]+)(?:\z|[?#])"""
.asciiPattern() .asciiPattern()
// fedibird ステータスの参照のURL
private val reStatusWithReference =
"""\Ahttps://([^/]+)/@(\w+)/([^?#/\s]+)/references(?:\z|[?#])"""
.asciiPattern()
// 公開ステータスページのURL Misskey // 公開ステータスページのURL Misskey
internal val reStatusPageMisskey = internal val reStatusPageMisskey =
"""\Ahttps://([^/]+)/notes/([0-9a-f]{24}|[0-9a-z]{10})\b""" """\Ahttps://([^/]+)/notes/([0-9a-f]{24}|[0-9a-z]{10})\b"""
@ -1138,8 +1142,20 @@ class TootStatus(parser: TootParser, src: JsonObject) : TimelineItem() {
// returns null or pair( status_id, host ,url ) // returns null or pair( status_id, host ,url )
fun String.findStatusIdFromUrl(): FindStatusIdFromUrlResult? { fun String.findStatusIdFromUrl(): FindStatusIdFromUrlResult? {
// https://fedibird.com/@noellabo/108730353756004469/references
var m = reStatusWithReference.matcher(this)
if (m.find()) {
return FindStatusIdFromUrlResult(
EntityId(m.groupEx(3)!!),
m.groupEx(1)!!,
this,
isReference = true,
)
}
// https://mastodon.juggler.jp/@SubwayTooter/(status_id) // https://mastodon.juggler.jp/@SubwayTooter/(status_id)
var m = reStatusPage.matcher(this) m = reStatusPage.matcher(this)
if (m.find()) { if (m.find()) {
return FindStatusIdFromUrlResult(EntityId(m.groupEx(3)!!), m.groupEx(1)!!, this) return FindStatusIdFromUrlResult(EntityId(m.groupEx(3)!!), m.groupEx(1)!!, this)
} }

View File

@ -7,7 +7,6 @@ import jp.juggler.util.JsonObject
import jp.juggler.util.encodeBase64Url import jp.juggler.util.encodeBase64Url
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.util.*
// カラムデータのJSONエンコーダ、デコーダ // カラムデータのJSONエンコーダ、デコーダ
@ -164,6 +163,7 @@ object ColumnEncoder {
when (type) { when (type) {
ColumnType.CONVERSATION, ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
ColumnType.BOOSTED_BY, ColumnType.BOOSTED_BY,
ColumnType.FAVOURITED_BY, ColumnType.FAVOURITED_BY,
ColumnType.LOCAL_AROUND, ColumnType.LOCAL_AROUND,
@ -299,6 +299,7 @@ object ColumnEncoder {
when (type) { when (type) {
ColumnType.CONVERSATION, ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
ColumnType.BOOSTED_BY, ColumnType.BOOSTED_BY,
ColumnType.FAVOURITED_BY, ColumnType.FAVOURITED_BY,
ColumnType.LOCAL_AROUND, ColumnType.LOCAL_AROUND,

View File

@ -2,7 +2,7 @@ package jp.juggler.subwaytooter.column
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.View import android.view.View
import jp.juggler.subwaytooter.* import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.EntityId import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.TimelineItem import jp.juggler.subwaytooter.api.entity.TimelineItem
import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.api.entity.TootStatus
@ -32,6 +32,7 @@ fun Column.canReloadWhenRefreshTop(): Boolean = when (type) {
ColumnType.SEARCH_TS, ColumnType.SEARCH_TS,
ColumnType.SEARCH_NOTESTOCK, ColumnType.SEARCH_NOTESTOCK,
ColumnType.CONVERSATION, ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
ColumnType.LIST_LIST, ColumnType.LIST_LIST,
ColumnType.TREND_TAG, ColumnType.TREND_TAG,
ColumnType.FOLLOW_SUGGESTION, ColumnType.FOLLOW_SUGGESTION,
@ -52,6 +53,7 @@ fun Column.canRefreshTopBySwipe(): Boolean =
canReloadWhenRefreshTop() || canReloadWhenRefreshTop() ||
when (type) { when (type) {
ColumnType.CONVERSATION, ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
ColumnType.INSTANCE_INFORMATION, ColumnType.INSTANCE_INFORMATION,
-> false -> false
else -> true else -> true
@ -61,6 +63,7 @@ fun Column.canRefreshTopBySwipe(): Boolean =
fun Column.canRefreshBottomBySwipe(): Boolean = when (type) { fun Column.canRefreshBottomBySwipe(): Boolean = when (type) {
ColumnType.LIST_LIST, ColumnType.LIST_LIST,
ColumnType.CONVERSATION, ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
ColumnType.INSTANCE_INFORMATION, ColumnType.INSTANCE_INFORMATION,
ColumnType.KEYWORD_FILTER, ColumnType.KEYWORD_FILTER,
ColumnType.SEARCH, ColumnType.SEARCH,
@ -335,7 +338,9 @@ fun Column.startRefreshForPost(
} }
} }
ColumnType.CONVERSATION -> { ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
-> {
// 会話への返信が行われたなら会話を更新する // 会話への返信が行われたなら会話を更新する
try { try {
if (postedReplyId != null) { if (postedReplyId != null) {

View File

@ -3,7 +3,7 @@ package jp.juggler.subwaytooter.column
import android.content.Context import android.content.Context
import android.os.Environment import android.os.Environment
import androidx.annotation.RawRes import androidx.annotation.RawRes
import jp.juggler.subwaytooter.* import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiClient import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.api.TootParser
@ -11,7 +11,6 @@ import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.columnviewholder.saveScrollPosition import jp.juggler.subwaytooter.columnviewholder.saveScrollPosition
import jp.juggler.util.* import jp.juggler.util.*
import java.io.File import java.io.File
import java.util.*
private val log = LogCategory("ColumnExtra2") private val log = LogCategory("ColumnExtra2")
@ -61,6 +60,14 @@ val Column.isPublicStream: Boolean
fun Column.canAutoRefresh() = fun Column.canAutoRefresh() =
!accessInfo.isNA && type.canAutoRefresh !accessInfo.isNA && type.canAutoRefresh
val Column.isConversation
get() = when (type) {
ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
-> true
else -> false
}
///////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////
// 読み込み処理の内部で使うメソッド // 読み込み処理の内部で使うメソッド
@ -116,7 +123,11 @@ fun Column.getNotificationTypeString(): String {
return sb.toString() return sb.toString()
} }
suspend fun Column.loadProfileAccount(client: TootApiClient, parser: TootParser, bForceReload: Boolean): TootApiResult? = suspend fun Column.loadProfileAccount(
client: TootApiClient,
parser: TootParser,
bForceReload: Boolean,
): TootApiResult? =
when { when {
// リロード不要なら何もしない // リロード不要なら何もしない
this.whoAccount != null && !bForceReload -> null this.whoAccount != null && !bForceReload -> null

View File

@ -34,11 +34,18 @@ val Column.isFilterEnabled: Boolean
// マストドン2.4.3rcのキーワードフィルタのコンテキスト // マストドン2.4.3rcのキーワードフィルタのコンテキスト
fun Column.getFilterContext() = when (type) { fun Column.getFilterContext() = when (type) {
ColumnType.HOME, ColumnType.LIST_TL, ColumnType.MISSKEY_HYBRID -> TootFilter.CONTEXT_HOME ColumnType.HOME,
ColumnType.LIST_TL,
ColumnType.MISSKEY_HYBRID,
-> TootFilter.CONTEXT_HOME
ColumnType.NOTIFICATIONS, ColumnType.NOTIFICATION_FROM_ACCT -> TootFilter.CONTEXT_NOTIFICATIONS ColumnType.NOTIFICATIONS,
ColumnType.NOTIFICATION_FROM_ACCT,
-> TootFilter.CONTEXT_NOTIFICATIONS
ColumnType.CONVERSATION -> TootFilter.CONTEXT_THREAD ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
-> TootFilter.CONTEXT_THREAD
ColumnType.DIRECT_MESSAGES -> TootFilter.CONTEXT_THREAD ColumnType.DIRECT_MESSAGES -> TootFilter.CONTEXT_THREAD
@ -76,7 +83,10 @@ fun Column.canFilterBoost(): Boolean = when (type) {
-> true -> true
ColumnType.LOCAL, ColumnType.FEDERATE, ColumnType.HASHTAG, ColumnType.SEARCH -> isMisskey ColumnType.LOCAL, ColumnType.FEDERATE, ColumnType.HASHTAG, ColumnType.SEARCH -> isMisskey
ColumnType.HASHTAG_FROM_ACCT -> false ColumnType.HASHTAG_FROM_ACCT -> false
ColumnType.CONVERSATION, ColumnType.DIRECT_MESSAGES -> isMisskey ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
ColumnType.DIRECT_MESSAGES,
-> isMisskey
else -> false else -> false
} }

View File

@ -52,13 +52,13 @@ object ColumnSpec {
when (type) { when (type) {
ColumnType.CONVERSATION, ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
ColumnType.BOOSTED_BY, ColumnType.BOOSTED_BY,
ColumnType.FAVOURITED_BY, ColumnType.FAVOURITED_BY,
ColumnType.LOCAL_AROUND, ColumnType.LOCAL_AROUND,
ColumnType.FEDERATED_AROUND, ColumnType.FEDERATED_AROUND,
ColumnType.ACCOUNT_AROUND, ColumnType.ACCOUNT_AROUND,
-> -> statusId = getParamEntityId(params, 0)
statusId = getParamEntityId(params, 0)
ColumnType.STATUS_HISTORY -> { ColumnType.STATUS_HISTORY -> {
statusId = getParamEntityId(params, 0) statusId = getParamEntityId(params, 0)
@ -67,8 +67,7 @@ object ColumnSpec {
ColumnType.PROFILE, ColumnType.LIST_TL, ColumnType.LIST_MEMBER, ColumnType.PROFILE, ColumnType.LIST_TL, ColumnType.LIST_MEMBER,
ColumnType.MISSKEY_ANTENNA_TL, ColumnType.MISSKEY_ANTENNA_TL,
-> -> profileId = getParamEntityId(params, 0)
profileId = getParamEntityId(params, 0)
ColumnType.HASHTAG -> ColumnType.HASHTAG ->
hashtag = getParamString(params, 0) hashtag = getParamString(params, 0)
@ -124,18 +123,17 @@ object ColumnSpec {
ColumnType.LIST_TL, ColumnType.LIST_TL,
ColumnType.LIST_MEMBER, ColumnType.LIST_MEMBER,
ColumnType.MISSKEY_ANTENNA_TL, ColumnType.MISSKEY_ANTENNA_TL,
-> -> column.profileId == getParamEntityId(params, 0)
column.profileId == getParamEntityId(params, 0)
ColumnType.CONVERSATION, ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
ColumnType.BOOSTED_BY, ColumnType.BOOSTED_BY,
ColumnType.FAVOURITED_BY, ColumnType.FAVOURITED_BY,
ColumnType.LOCAL_AROUND, ColumnType.LOCAL_AROUND,
ColumnType.FEDERATED_AROUND, ColumnType.FEDERATED_AROUND,
ColumnType.ACCOUNT_AROUND, ColumnType.ACCOUNT_AROUND,
ColumnType.STATUS_HISTORY, ColumnType.STATUS_HISTORY,
-> -> column.statusId == getParamEntityId(params, 0)
column.statusId == getParamEntityId(params, 0)
ColumnType.HASHTAG -> { ColumnType.HASHTAG -> {
(getParamString(params, 0) == column.hashtag) && (getParamString(params, 0) == column.hashtag) &&

View File

@ -1009,7 +1009,10 @@ class ColumnTask_Loading(
return result return result
} }
suspend fun getConversation(client: TootApiClient): TootApiResult? { suspend fun getConversation(
client: TootApiClient,
withReference: Boolean = false,
): TootApiResult? {
return if (isMisskey) { return if (isMisskey) {
// 指定された発言そのもの // 指定された発言そのもの
val queryParams = column.makeMisskeyBaseParameter(parser).apply { val queryParams = column.makeMisskeyBaseParameter(parser).apply {
@ -1097,10 +1100,12 @@ class ColumnTask_Loading(
// 前後の会話 // 前後の会話
result = client.request( result = client.request(
String.format( "/api/v1/statuses/${column.statusId}/context${
Locale.JAPAN, when (withReference) {
ApiPath.PATH_STATUSES_CONTEXT, column.statusId true -> "?with_reference=true"
) else -> ""
}
}"
) )
jsonObject = result?.jsonObject ?: return result jsonObject = result?.jsonObject ?: return result
val conversationContext = val conversationContext =
@ -1116,6 +1121,10 @@ class ColumnTask_Loading(
1 1
) )
if (conversationContext.references != null) {
addWithFilterStatus(this.listTmp, conversationContext.references)
}
if (conversationContext.ancestors != null) { if (conversationContext.ancestors != null) {
addWithFilterStatus(this.listTmp, conversationContext.ancestors) addWithFilterStatus(this.listTmp, conversationContext.ancestors)
} }

View File

@ -926,8 +926,18 @@ enum class ColumnType(
canStreamingMastodon = streamingTypeNo, canStreamingMastodon = streamingTypeNo,
canStreamingMisskey = streamingTypeNo, canStreamingMisskey = streamingTypeNo,
),
), CONVERSATION_WITH_REFERENCE(
47,
iconId = { R.drawable.ic_link },
name1 = { it.getString(R.string.conversation_with_reference) },
name2 = { context.getString(R.string.conversation_with_reference) },
loading = { client -> getConversation(client, withReference = true) },
canStreamingMastodon = streamingTypeNo,
canStreamingMisskey = streamingTypeNo,
),
HASHTAG( HASHTAG(
9, 9,
@ -2059,9 +2069,6 @@ enum class ColumnType(
; ;
private fun getFollowedHashtags(client: TootApiClient) {
}
init { init {
val old = Column.typeMap[id] val old = Column.typeMap[id]
if (id > 0 && old != null) error("ColumnType: duplicate id $id. name=$name, ${old.name}") if (id > 0 && old != null) error("ColumnType: duplicate id $id. name=$name, ${old.name}")

View File

@ -118,8 +118,7 @@ fun ColumnViewHolder.onPageCreate(column: Column, pageIdx: Int, pageCount: Int)
ColumnViewHolder.log.d("onPageCreate [$pageIdx] ${column.getColumnName(true)}") ColumnViewHolder.log.d("onPageCreate [$pageIdx] ${column.getColumnName(true)}")
val bSimpleList = val bSimpleList = !column.isConversation && PrefB.bpSimpleList(activity.pref)
column.type != ColumnType.CONVERSATION && PrefB.bpSimpleList(activity.pref)
tvColumnIndex.text = activity.getString(R.string.column_index, pageIdx + 1, pageCount) tvColumnIndex.text = activity.getString(R.string.column_index, pageIdx + 1, pageCount)
tvColumnStatus.text = "?" tvColumnStatus.text = "?"

View File

@ -3,7 +3,7 @@ package jp.juggler.subwaytooter.itemviewholder
import android.view.View import android.view.View
import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.TootStatus import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.isConversation
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.PrefS import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.table.MediaShown import jp.juggler.subwaytooter.table.MediaShown
@ -53,7 +53,7 @@ fun ItemViewHolder.showPreviewCard(status: TootStatus) {
val card = status.card ?: return val card = status.card ?: return
// 会話カラムで返信ステータスなら捏造したカードを表示しない // 会話カラムで返信ステータスなら捏造したカードを表示しない
if (column.type == ColumnType.CONVERSATION && if (column.isConversation &&
card.originalStatus != null && card.originalStatus != null &&
status.reply != null status.reply != null
) { ) {

View File

@ -648,26 +648,11 @@ fun ItemViewHolder.showStatusTime(
} }
if (sb.isNotEmpty()) sb.append(' ') if (sb.isNotEmpty()) sb.append(' ')
sb.append( sb.append(
when { (time ?: status?.time_created_at)?.let {
time != null -> TootStatus.formatTime( TootStatus.formatTime(activity, it, column.canRelativeTime)
activity, } ?: "?"
time,
when (column.type) {
ColumnType.CONVERSATION, ColumnType.STATUS_HISTORY -> false
else -> true
}
)
status != null -> TootStatus.formatTime(
activity,
status.time_created_at,
when (column.type) {
ColumnType.CONVERSATION, ColumnType.STATUS_HISTORY -> false
else -> true
}
)
else -> "?"
}
) )
tv.text = sb tv.text = sb
@ -703,16 +688,20 @@ fun ItemViewHolder.showStatusTimeScheduled(
} }
if (sb.isNotEmpty()) sb.append(' ') if (sb.isNotEmpty()) sb.append(' ')
sb.append( sb.append(TootStatus.formatTime(activity, item.timeScheduledAt, column.canRelativeTime))
TootStatus.formatTime(
activity,
item.timeScheduledAt,
column.type != ColumnType.CONVERSATION
)
)
tv.text = sb tv.text = sb
} }
val Column.canRelativeTime
get() = when (type) {
ColumnType.CONVERSATION,
ColumnType.CONVERSATION_WITH_REFERENCE,
ColumnType.STATUS_HISTORY,
-> false
else -> true
}
// fun updateRelativeTime() { // fun updateRelativeTime() {
// val boost_time = this.boost_time // val boost_time = this.boost_time
// if(boost_time != 0L) { // if(boost_time != 0L) {

View File

@ -11,6 +11,7 @@ import jp.juggler.subwaytooter.actmain.checkAutoCW
import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.column.Column import jp.juggler.subwaytooter.column.Column
import jp.juggler.subwaytooter.column.ColumnType import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.column.isConversation
import jp.juggler.subwaytooter.pref.PrefB import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.pref.PrefI import jp.juggler.subwaytooter.pref.PrefI
import jp.juggler.subwaytooter.table.ContentWarning import jp.juggler.subwaytooter.table.ContentWarning
@ -222,14 +223,14 @@ private fun ItemViewHolder.showApplicationAndLanguage(status: TootStatus) {
val application = status.application val application = status.application
if (application != null && if (application != null &&
(column.type == ColumnType.CONVERSATION || PrefB.bpShowAppName(activity.pref)) (column.isConversation || PrefB.bpShowAppName(activity.pref))
) { ) {
prepareSb().append(activity.getString(R.string.application_is, application.name ?: "")) prepareSb().append(activity.getString(R.string.application_is, application.name ?: ""))
} }
val language = status.language val language = status.language
if (language != null && if (language != null &&
(column.type == ColumnType.CONVERSATION || PrefB.bpShowLanguage(activity.pref)) (column.isConversation || PrefB.bpShowLanguage(activity.pref))
) { ) {
prepareSb().append(activity.getString(R.string.language_is, language)) prepareSb().append(activity.getString(R.string.language_is, language))
} }

View File

@ -7,7 +7,9 @@ import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabColorSchemeParams
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import jp.juggler.subwaytooter.* import jp.juggler.subwaytooter.ActCallback
import jp.juggler.subwaytooter.ActMain
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.action.conversationLocal import jp.juggler.subwaytooter.action.conversationLocal
import jp.juggler.subwaytooter.action.conversationOtherInstance import jp.juggler.subwaytooter.action.conversationOtherInstance
import jp.juggler.subwaytooter.action.tagDialog import jp.juggler.subwaytooter.action.tagDialog
@ -20,7 +22,6 @@ import jp.juggler.subwaytooter.pref.pref
import jp.juggler.subwaytooter.span.LinkInfo import jp.juggler.subwaytooter.span.LinkInfo
import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.* import jp.juggler.util.*
import java.util.*
// Subway Tooterの「アプリ設定/挙動/リンクを開く際にCustom Tabsを使わない」をONにして // Subway Tooterの「アプリ設定/挙動/リンクを開く際にCustom Tabsを使わない」をONにして
// 投稿のコンテキストメニューの「トゥートへのアクション/Webページを開く」「ユーザへのアクション/Webページを開く」を使うと // 投稿のコンテキストメニューの「トゥートへのアクション/Webページを開く」「ユーザへのアクション/Webページを開く」を使うと
@ -189,7 +190,7 @@ fun openCustomTab(
tagList: ArrayList<String>? = null, tagList: ArrayList<String>? = null,
allowIntercept: Boolean = true, allowIntercept: Boolean = true,
whoRef: TootAccountRef? = null, whoRef: TootAccountRef? = null,
linkInfo: LinkInfo? = null linkInfo: LinkInfo? = null,
) { ) {
try { try {
log.d("openCustomTab: $url") log.d("openCustomTab: $url")
@ -219,23 +220,41 @@ fun openCustomTab(
val statusInfo = url.findStatusIdFromUrl() val statusInfo = url.findStatusIdFromUrl()
if (statusInfo != null) { if (statusInfo != null) {
if (accessInfo.isNA || when {
statusInfo.statusId == null || // fedibirdの参照のURLだった && 閲覧アカウントが参照を扱える
!accessInfo.matchHost(statusInfo.host) // 参照カラムを開く
) { statusInfo.statusId != null &&
activity.conversationOtherInstance( statusInfo.isReference &&
pos, TootInstance.getCached(accessInfo)?.canUseReference == true ->
statusInfo.url, activity.conversationLocal(
statusInfo.statusId, pos,
statusInfo.host, accessInfo,
statusInfo.statusId statusInfo.statusId,
) isReference = statusInfo.isReference,
} else { )
activity.conversationLocal(
pos, // 疑似アカウント?
accessInfo, // 別サーバ?
statusInfo.statusId // ステータスIDがない?(Pleroma)
) accessInfo.isNA ||
!accessInfo.matchHost(statusInfo.host) ||
statusInfo.statusId == null ->
activity.conversationOtherInstance(
pos,
statusInfo.url,
statusInfo.statusId,
statusInfo.host,
statusInfo.statusId,
isReference = statusInfo.isReference,
)
else ->
activity.conversationLocal(
pos,
accessInfo,
statusInfo.statusId,
isReference = statusInfo.isReference,
)
} }
return return
} }
@ -248,7 +267,8 @@ fun openCustomTab(
if (fullAcct.host != null) { if (fullAcct.host != null) {
when (fullAcct.host.ascii) { when (fullAcct.host.ascii) {
"github.com", "github.com",
"twitter.com" -> "twitter.com",
->
activity.openCustomTab(mention.url) activity.openCustomTab(mention.url)
"gmail.com" -> "gmail.com" ->
activity.openBrowser("mailto:${fullAcct.pretty}") activity.openBrowser("mailto:${fullAcct.pretty}")

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M17,7h-4v2h4c1.65,0 3,1.35 3,3s-1.35,3 -3,3h-4v2h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5zM11,15L7,15c-1.65,0 -3,-1.35 -3,-3s1.35,-3 3,-3h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-2zM8,11h8v2L8,13z"/>
</vector>

View File

@ -1154,4 +1154,5 @@
<string name="followed_tags">フォロー中のハッシュタグ</string> <string name="followed_tags">フォロー中のハッシュタグ</string>
<string name="follow_hashtag_of">\"%1$s\"のフォロー</string> <string name="follow_hashtag_of">\"%1$s\"のフォロー</string>
<string name="unfollow_hashtag_of">\"%1$s\"のフォロー解除</string> <string name="unfollow_hashtag_of">\"%1$s\"のフォロー解除</string>
<string name="conversation_with_reference">会話と参照</string>
</resources> </resources>

View File

@ -1163,4 +1163,5 @@
<string name="followed_tags">Followed hashtags</string> <string name="followed_tags">Followed hashtags</string>
<string name="follow_hashtag_of">Follow %1$s</string> <string name="follow_hashtag_of">Follow %1$s</string>
<string name="unfollow_hashtag_of">Unfollow %1$s</string> <string name="unfollow_hashtag_of">Unfollow %1$s</string>
<string name="conversation_with_reference">conversation + reference</string>
</resources> </resources>