package jp.juggler.subwaytooter.api.entity import android.content.Context import android.text.Spannable import android.text.SpannableString import jp.juggler.subwaytooter.App1 import jp.juggler.subwaytooter.Pref import jp.juggler.subwaytooter.R import jp.juggler.subwaytooter.api.TootAccountMap import jp.juggler.subwaytooter.api.TootApiClient import org.json.JSONObject import java.lang.ref.WeakReference import java.util.regex.Pattern import jp.juggler.subwaytooter.api.TootParser import jp.juggler.subwaytooter.table.HighlightWord import jp.juggler.subwaytooter.table.SavedAccount import jp.juggler.subwaytooter.util.* import org.json.JSONArray import java.text.SimpleDateFormat import java.util.* import kotlin.collections.ArrayList @Suppress("MemberVisibilityCanPrivate") class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { val json : JSONObject // A Fediverse-unique resource ID // MSP から取得したデータだと uri は提供されずnullになる val uri : String? // URL to the status page (can be remote) // ブーストだとnullになる val url : String? // 投稿元タンスのホスト名 val host_original : String get() = account.host // 取得タンスのホスト名。トゥート検索サービスでは提供されずnullになる val host_access : String? // ステータスID。 // host_access が null の場合は投稿元タンスでのIDかもしれない。 // 取得に失敗するとINVALID_IDになる // Misskeyでは文字列のID。 val id : EntityId // misskey ではページネーションにIDではなくエポック秒を使う private val _orderId : EntityId override fun getOrderId() = _orderId // val isMisskey :Boolean // get()= id is EntityIdString // The TootAccount which posted the status val accountRef : TootAccountRef val account : TootAccount get() = TootAccountMap.find(accountRef.mapId) //The number of reblogs for the status var reblogs_count : Long? = null // アプリから変更する。検索サービスでは提供されない(null) //The number of favourites for the status var favourites_count : Long? = null // アプリから変更する。検索サービスでは提供されない(null) // Whether the authenticated user has reblogged the status var reblogged : Boolean = false // アプリから変更する // Whether the authenticated user has favourited the status var favourited : Boolean = false // アプリから変更する // Whether the authenticated user has muted the conversation this status from var muted : Boolean = false // アプリから変更する // 固定されたトゥート var pinned : Boolean = false // アプリから変更する //Whether media attachments should be hidden by default val sensitive : Boolean // The detected language for the status, if detected private val language : String? //If not empty, warning text that should be displayed before the actual content val spoiler_text : String? val decoded_spoiler_text : Spannable // Body of the status; this will contain HTML (remote HTML already sanitized) val content : String? val decoded_content : Spannable //Application from which the status was posted val application : TootApplication? val custom_emojis : HashMap? val profile_emojis : HashMap? // The time the status was created private val created_at : String? // null or the ID of the status it replies to val in_reply_to_id : EntityId? // null or the ID of the account it replies to private val in_reply_to_account_id : String? // null or the reblogged Status val reblog : TootStatus? //One of: public, unlisted, private, direct val visibility : TootVisibility val misskeyVisibleIds : ArrayList? // An array of Attachments val media_attachments : ArrayList? // An array of Mentions var mentions : ArrayList? = null //An array of Tags var tags : ArrayList? = null // public Spannable decoded_tags; var decoded_mentions : Spannable = EMPTY_SPANNABLE var conversation_main : Boolean = false var enquete : NicoEnquete? = null // var replies_count :Long? = null /////////////////////////////////////////////////////////////////// // 以下はentityから取得したデータではなく、アプリ内部で使う class AutoCW( var refActivity : WeakReference? = null, var cell_width : Int = 0, var decoded_spoiler_text : Spannable? = null, var originalLineCount : Int = 0 ) // アプリ内部で使うワークエリア var auto_cw : AutoCW? = null // 会話の流れビューで後から追加する var card : TootCard? = null var highlight_sound : HighlightWord? = null var hasHighlight : Boolean = false val time_created_at : Long //////////////////////////////////////////////////////// init { this.json = src if( parser.serviceType == ServiceType.MISSKEY) { val instance = parser.linkHelper.host val misskeyId = src.parseString("id") this.host_access = parser.linkHelper.host this.uri = "https://$instance/notes/$misskeyId" this.url = "https://$instance/notes/$misskeyId" this.created_at = src.parseString("createdAt") this.time_created_at = parseTime(this.created_at) this.id = EntityIdString( src.parseString("id") ?: error("missing id") ) // ページネーションには日時を使う this._orderId = EntityIdLong(time_created_at) // 絵文字マップはすぐ後で使うので、最初の方で読んでおく this.custom_emojis = null this.profile_emojis = null val who = parser.account(src.optJSONObject("user")) ?: throw RuntimeException("missing account") this.accountRef = TootAccountRef(parser, who) this.reblogs_count = src.parseLong("renoteCount") ?: 0L this.favourites_count = 0L this.replies_count = src.parseLong("repliesCount") ?: 0L this.reblogged = false this.favourited = false this.visibility = TootVisibility.parseMisskey(src.parseString("visibility")) ?: TootVisibility.Public this.misskeyVisibleIds = parseStringArray(src.optJSONArray("visibleUserIds")) this.media_attachments = parseListOrNull(::TootAttachment,parser,src.optJSONArray("media")) // Misskeyは画像毎にNSFWフラグがある。どれか1枚でもNSFWならトゥート全体がNSFWということにする var bv = src.optBoolean("sensitive") media_attachments?.forEach { if( (it as? TootAttachment)?.isSensitive == true){ bv =true } } this.sensitive = bv this.in_reply_to_id = null this.in_reply_to_account_id = null this.mentions = null this.tags = null this.application = parseItem(::TootApplication, parser,src.optJSONObject("app"), log) this.pinned = parser.pinned this.muted = false this.language = null this.decoded_mentions = HTMLDecoder.decodeMentions( parser.linkHelper, this.mentions, this ) ?: EMPTY_SPANNABLE // this.decoded_tags = HTMLDecoder.decodeTags( account,status.tags ); // content this.content = src.parseString("text") var options = DecodeOptions( parser.context, parser.linkHelper, short = true, decodeEmoji = true, emojiMapCustom = custom_emojis, emojiMapProfile = profile_emojis, attachmentList = media_attachments, highlightTrie = parser.highlightTrie ) this.decoded_content = options.decodeHTML(content) this.hasHighlight = this.hasHighlight || options.hasHighlight if(options.highlight_sound != null && this.highlight_sound == null) { this.highlight_sound = options.highlight_sound } // spoiler_text this.spoiler_text = reWhitespace .matcher(src.parseString("cw") ?: "") .replaceAll(" ") .sanitizeBDI() options = DecodeOptions( parser.context, emojiMapCustom = custom_emojis, emojiMapProfile = profile_emojis, highlightTrie = parser.highlightTrie ) this.decoded_spoiler_text = options.decodeEmoji(spoiler_text) this.hasHighlight = this.hasHighlight || options.hasHighlight if(options.highlight_sound != null && this.highlight_sound == null) { this.highlight_sound = options.highlight_sound } this.enquete = null this.reblog = parser.status(src.optJSONObject("renote")) }else{ misskeyVisibleIds = null this.uri = src.parseString("uri") // MSPだとuriは提供されない this.url = src.parseString("url") // 頻繁にnullになる this.created_at = src.parseString("created_at") // 絵文字マップはすぐ後で使うので、最初の方で読んでおく this.custom_emojis = parseMapOrNull(::CustomEmoji, src.optJSONArray("emojis"), log) this.profile_emojis = parseMapOrNull(::NicoProfileEmoji, src.optJSONArray("profile_emojis"), log) val who = parser.account(src.optJSONObject("account")) ?: throw RuntimeException("missing account") this.accountRef = TootAccountRef(parser, who) this.reblogs_count = src.parseLong("reblogs_count") this.favourites_count = src.parseLong("favourites_count") this.replies_count = src.parseLong("replies_count") when(parser.serviceType) { ServiceType.MASTODON -> { this.host_access = parser.linkHelper.host this.id = EntityIdLong(src.parseLong("id") ?: INVALID_ID) this.reblogged = src.optBoolean("reblogged") this.favourited = src.optBoolean("favourited") this.time_created_at = parseTime(this.created_at) this.media_attachments = parseListOrNull(::TootAttachment, parser,src.optJSONArray("media_attachments"), log) this.visibility = TootVisibility.parseMastodon(src.parseString("visibility")) ?: TootVisibility.Public this.sensitive = src.optBoolean("sensitive") } ServiceType.TOOTSEARCH -> { this.host_access = null // 投稿元タンスでのIDを調べる。失敗するかもしれない this.id = findStatusIdFromUri(uri, url) ?: error("missing id") this.time_created_at = TootStatus.parseTime(this.created_at) this.media_attachments = parseListOrNull(::TootAttachment, parser,src.optJSONArray("media_attachments"), log) this.visibility = TootVisibility.Public this.sensitive = src.optBoolean("sensitive") } ServiceType.MSP -> { this.host_access = null // MSPのデータはLTLから呼んだものなので、常に投稿元タンスでのidが得られる this.id = EntityIdLong(src.parseLong("id") ?: INVALID_ID) this.time_created_at = parseTimeMSP(created_at) this.media_attachments = TootAttachmentMSP.parseList(src.optJSONArray("media_attachments")) this.visibility = TootVisibility.Public this.sensitive = src.optInt("sensitive", 0) != 0 } ServiceType.MISSKEY-> error("will not happen") } this._orderId = this.id this.in_reply_to_id = EntityId.mayNull( src.parseLong("in_reply_to_id")) this.in_reply_to_account_id = src.parseString("in_reply_to_account_id") this.mentions = parseListOrNull(::TootMention, src.optJSONArray("mentions"), log) this.tags = parseListOrNull(::TootTag, src.optJSONArray("tags")) this.application = parseItem(::TootApplication,parser, src.optJSONObject("application"), log) this.pinned = parser.pinned || src.optBoolean("pinned") this.muted = src.optBoolean("muted") this.language = src.parseString("language") this.decoded_mentions = HTMLDecoder.decodeMentions( parser.linkHelper, this.mentions, this ) ?: EMPTY_SPANNABLE // this.decoded_tags = HTMLDecoder.decodeTags( account,status.tags ); // content this.content = src.parseString("content") var options = DecodeOptions( parser.context, parser.linkHelper, short = true, decodeEmoji = true, emojiMapCustom = custom_emojis, emojiMapProfile = profile_emojis, attachmentList = media_attachments, highlightTrie = parser.highlightTrie ) this.decoded_content = options.decodeHTML(content) this.hasHighlight = this.hasHighlight || options.hasHighlight if(options.highlight_sound != null && this.highlight_sound == null) { this.highlight_sound = options.highlight_sound } // spoiler_text this.spoiler_text = reWhitespace .matcher(src.parseString("spoiler_text") ?: "") .replaceAll(" ") .sanitizeBDI() options = DecodeOptions( parser.context, emojiMapCustom = custom_emojis, emojiMapProfile = profile_emojis, highlightTrie = parser.highlightTrie ) this.decoded_spoiler_text = options.decodeEmoji(spoiler_text) this.hasHighlight = this.hasHighlight || options.hasHighlight if(options.highlight_sound != null && this.highlight_sound == null) { this.highlight_sound = options.highlight_sound } this.enquete = NicoEnquete.parse( parser, this, media_attachments, src.parseString("enquete") ) // Pinned TL を取得した時にreblogが登場することはないので、reblogについてpinned 状態を気にする必要はない this.reblog = parser.status(src.optJSONObject("reblog")) } } /////////////////////////////////////////////////// // ユーティリティ val hostAccessOrOriginal : String get() = validHost(host_access) ?: validHost(host_original) ?: "(null)" val busyKey : String get() = "$hostAccessOrOriginal:$id" fun checkMuted( muted_app : HashSet?, muted_word : WordTrieTree? ) : Boolean { // app mute if(muted_app != null) { val name = application?.name if(name != null && muted_app.contains(name)) return true } // word mute if(muted_word != null) { if(muted_word.matchShort(decoded_content)) return true if(muted_word.matchShort(decoded_spoiler_text)) return true } // reblog return true == reblog?.checkMuted(muted_app, muted_word) } fun hasMedia() : Boolean { return (media_attachments?.size ?: 0) > 0 } fun canPin(access_info : SavedAccount) : Boolean { return reblog == null && access_info.isMe(account) && visibility.canPin( access_info.isMisskey) } // 内部で使う private var _filtered = false val filtered :Boolean get() = _filtered || reblog?._filtered == true fun updateFiltered(muted_words : WordTrieTree?) { _filtered = checkFiltered(muted_words) reblog?.updateFiltered(muted_words) } private fun checkFiltered(filter_tree : WordTrieTree?) : Boolean { filter_tree ?: return false // var t = decoded_spoiler_text if(t.isNotEmpty() && filter_tree.matchShort(t)) return true // t = decoded_content if(t.isNotEmpty() && filter_tree.matchShort(t)) return true // return false } companion object { internal val log = LogCategory("TootStatus") private val reWhitespace = Pattern.compile("[\\s\\t\\x0d\\x0a]+") val EMPTY_SPANNABLE = SpannableString("") // OStatus @Suppress("HasPlatformType") private val reTootUriOS = Pattern.compile( "tag:([^,]*),[^:]*:objectId=(\\d+):objectType=Status", Pattern.CASE_INSENSITIVE ) // ActivityPub 1 @Suppress("HasPlatformType") private val reTootUriAP1 = Pattern.compile("https?://([^/]+)/users/[A-Za-z0-9_]+/statuses/(\\d+)") // ActivityPub 2 @Suppress("HasPlatformType") private val reTootUriAP2 = Pattern.compile("https?://([^/]+)/@[A-Za-z0-9_]+/(\\d+)") const val INVALID_ID = - 1L fun parseListTootsearch( parser : TootParser, root : JSONObject ) : ArrayList { parser.serviceType = ServiceType.TOOTSEARCH val result = ArrayList() val array = TootApiClient.getTootsearchHits(root) if(array != null) { val array_size = array.length() result.ensureCapacity(array_size) for(i in 0 until array.length()) { try { val src = array.optJSONObject(i)?.optJSONObject("_source") ?: continue result.add(TootStatus(parser, src)) } catch(ex : Throwable) { log.trace(ex) } } } return result } private val tz_utc = TimeZone.getTimeZone("UTC") private val reTime = Pattern.compile("\\A(\\d+)\\D+(\\d+)\\D+(\\d+)\\D+(\\d+)\\D+(\\d+)\\D+(\\d+)\\D+(\\d+)") private val reMSPTime = Pattern.compile("\\A(\\d+)\\D+(\\d+)\\D+(\\d+)\\D+(\\d+)\\D+(\\d+)\\D+(\\d+)") fun parseTime(strTime : String?) : Long { if(strTime != null && strTime.isNotEmpty()) { try { val m = reTime.matcher(strTime) if(! m.find()) { log.d("invalid time format: %s", strTime) } else { val g = GregorianCalendar(tz_utc) g.set( m.group(1).optInt() ?: 1, (m.group(2).optInt() ?: 1) - 1, m.group(3).optInt() ?: 1, m.group(4).optInt() ?: 0, m.group(5).optInt() ?: 0, m.group(6).optInt() ?: 0 ) g.set(Calendar.MILLISECOND, m.group(7).optInt() ?: 0) return g.timeInMillis } } catch(ex : Throwable) { // ParseException, ArrayIndexOutOfBoundsException log.trace(ex) log.e(ex, "TootStatus.parseTime failed. src=%s", strTime) } } return 0L } private fun parseTimeMSP(strTime : String?) : Long { if(strTime != null && strTime.isNotEmpty()) { try { val m = reMSPTime.matcher(strTime) if(! m.find()) { log.d("invalid time format: %s", strTime) } else { val g = GregorianCalendar(tz_utc) g.set( m.group(1).optInt() ?: 1, (m.group(2).optInt() ?: 1) - 1, m.group(3).optInt() ?: 1, m.group(4).optInt() ?: 0, m.group(5).optInt() ?: 0, m.group(6).optInt() ?: 0 ) g.set(Calendar.MILLISECOND, 500) return g.timeInMillis } } catch(ex : Throwable) { // ParseException, ArrayIndexOutOfBoundsException log.trace(ex) log.e(ex, "parseTimeMSP failed. src=%s", strTime) } } return 0L } private val date_format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) fun formatTime(context : Context, t : Long, bAllowRelative : Boolean) : String { if(bAllowRelative && Pref.bpRelativeTimestamp(App1.pref)) { val now = System.currentTimeMillis() var delta = now - t val sign = context.getString(if(delta > 0) R.string.ago else R.string.later) delta = if(delta >= 0) delta else - delta when { delta < 1000L -> return context.getString(R.string.time_within_second) delta < 60000L -> { val v = (delta / 1000L).toInt() return context.getString( if(v > 1) R.string.relative_time_second_2 else R.string.relative_time_second_1, v, sign ) } delta < 3600000L -> { val v = (delta / 60000L).toInt() return context.getString( if(v > 1) R.string.relative_time_minute_2 else R.string.relative_time_minute_1, v, sign ) } delta < 86400000L -> { val v = (delta / 3600000L).toInt() return context.getString( if(v > 1) R.string.relative_time_hour_2 else R.string.relative_time_hour_1, v, sign ) } delta < 40 * 86400000L -> { val v = (delta / 86400000L).toInt() return context.getString( if(v > 1) R.string.relative_time_day_2 else R.string.relative_time_day_1, v, sign ) } else -> { } } } date_format.timeZone = TimeZone.getDefault() return date_format.format(Date(t)) } fun parseStringArray(src:JSONArray?):ArrayList?{ var rv : ArrayList? = null if( src != null ){ for( i in 0 until src.length()){ val s = src.optString(i,null) if( s?.isNotEmpty() == true){ if( rv == null) rv = ArrayList() rv.add(s) } } } return rv } private fun validHost(host : String?) : String? { return if(host != null && host.isNotEmpty() && host != "?") host else null } // 投稿元タンスでのステータスIDを調べる fun findStatusIdFromUri(uri : String?, url : String?) : EntityId? { // pleromaのuriやURL からはステータスIDは取れません // uri https://pleroma.miniwa.moe/objects/d6e83d3c-cf9e-46ac-8245-f91716088e17 // url https://pleroma.miniwa.moe/objects/d6e83d3c-cf9e-46ac-8245-f91716088e17 try { if(uri?.isNotEmpty() == true) { // https://friends.nico/users/(who)/statuses/(status_id) var m = reTootUriAP1.matcher(uri) if(m.find()) return EntityIdLong(m.group(2).toLong(10)) // tag:mstdn.osaka,2017-12-19:objectId=5672321:objectType=Status m = reTootUriOS.matcher(uri) if(m.find()) return EntityIdLong(m.group(2).toLong(10)) // m = reTootUriAP2.matcher(uri) if(m.find()) return EntityIdLong(m.group(2).toLong(10)) log.e("can't parse status uri: $uri") } if(url?.isNotEmpty() == true) { // https://friends.nico/users/(who)/statuses/(status_id) var m = reTootUriAP1.matcher(url) if(m.find()) return EntityIdLong(m.group(2).toLong(10)) // https://friends.nico/@(who)/(status_id) m = reTootUriAP2.matcher(url) if(m.find()) return EntityIdLong(m.group(2).toLong(10)) log.e("can't parse status URL: $url") } } catch(ex : Throwable) { log.e(ex, "can't parse status number: $uri,$url") } return null } } }