2017-04-19 08:50:47 +02:00
|
|
|
/*
|
|
|
|
* Twidere - Twitter client for Android
|
|
|
|
*
|
|
|
|
* Copyright (C) 2012-2017 Mariotaku Lee <mariotaku.lee@gmail.com>
|
|
|
|
*
|
|
|
|
* This program is free software: you can redistribute it and/or modify
|
|
|
|
* it under the terms of the GNU General Public License as published by
|
|
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
|
|
* (at your option) any later version.
|
|
|
|
*
|
|
|
|
* This program is distributed in the hope that it will be useful,
|
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
* GNU General Public License for more details.
|
|
|
|
*
|
|
|
|
* You should have received a copy of the GNU General Public License
|
|
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
*/
|
|
|
|
|
|
|
|
package org.mariotaku.twidere.extension.model.api
|
|
|
|
|
|
|
|
import android.text.Spanned
|
|
|
|
import android.text.style.URLSpan
|
2017-05-05 09:10:42 +02:00
|
|
|
import org.apache.commons.lang3.text.translate.EntityArrays
|
|
|
|
import org.apache.commons.lang3.text.translate.LookupTranslator
|
|
|
|
import org.mariotaku.commons.text.CodePointArray
|
2017-05-10 12:53:29 +02:00
|
|
|
import org.mariotaku.ktextension.isNotNullOrEmpty
|
2017-04-19 08:50:47 +02:00
|
|
|
import org.mariotaku.ktextension.mapToArray
|
2017-05-05 09:10:42 +02:00
|
|
|
import org.mariotaku.microblog.library.twitter.model.EntitySupport
|
|
|
|
import org.mariotaku.microblog.library.twitter.model.ExtendedEntitySupport
|
|
|
|
import org.mariotaku.microblog.library.twitter.model.MediaEntity
|
2017-04-19 08:50:47 +02:00
|
|
|
import org.mariotaku.microblog.library.twitter.model.Status
|
|
|
|
import org.mariotaku.twidere.extension.model.toParcelable
|
2017-04-26 17:38:59 +02:00
|
|
|
import org.mariotaku.twidere.extension.toSpanItem
|
2017-04-22 08:25:42 +02:00
|
|
|
import org.mariotaku.twidere.model.*
|
2017-04-19 08:50:47 +02:00
|
|
|
import org.mariotaku.twidere.model.util.ParcelableLocationUtils
|
|
|
|
import org.mariotaku.twidere.model.util.ParcelableMediaUtils
|
|
|
|
import org.mariotaku.twidere.model.util.ParcelableStatusUtils.addFilterFlag
|
2017-04-26 17:38:59 +02:00
|
|
|
import org.mariotaku.twidere.text.AcctMentionSpan
|
2017-05-05 09:10:42 +02:00
|
|
|
import org.mariotaku.twidere.util.HtmlBuilder
|
2017-04-19 08:50:47 +02:00
|
|
|
import org.mariotaku.twidere.util.HtmlSpanBuilder
|
2017-05-05 09:10:42 +02:00
|
|
|
import org.mariotaku.twidere.util.InternalTwitterContentUtils.getMediaUrl
|
|
|
|
import org.mariotaku.twidere.util.InternalTwitterContentUtils.getStartEndForEntity
|
2017-04-19 08:50:47 +02:00
|
|
|
|
2017-04-22 08:25:42 +02:00
|
|
|
fun Status.toParcelable(details: AccountDetails, profileImageSize: String = "normal"): ParcelableStatus {
|
|
|
|
return toParcelable(details.key, details.type, profileImageSize).apply {
|
|
|
|
account_color = details.color
|
|
|
|
}
|
|
|
|
}
|
2017-04-19 08:50:47 +02:00
|
|
|
|
|
|
|
fun Status.toParcelable(accountKey: UserKey, accountType: String, profileImageSize: String = "normal"): ParcelableStatus {
|
|
|
|
val result = ParcelableStatus()
|
2017-04-28 15:44:45 +02:00
|
|
|
applyTo(accountKey, accountType, profileImageSize, result)
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
|
|
|
fun Status.applyTo(accountKey: UserKey, accountType: String, profileImageSize: String = "normal", result: ParcelableStatus) {
|
2017-04-19 08:50:47 +02:00
|
|
|
val extras = ParcelableStatus.Extras()
|
|
|
|
result.account_key = accountKey
|
|
|
|
result.id = id
|
|
|
|
result.sort_id = sortId
|
|
|
|
result.timestamp = createdAt?.time ?: 0
|
|
|
|
|
|
|
|
extras.external_url = inferredExternalUrl
|
|
|
|
extras.support_entities = entities != null
|
|
|
|
extras.statusnet_conversation_id = statusnetConversationId
|
|
|
|
extras.conversation_id = conversationId
|
|
|
|
result.is_pinned_status = user.pinnedTweetIds?.contains(id) ?: false
|
|
|
|
|
|
|
|
val retweetedStatus = retweetedStatus
|
|
|
|
result.is_retweet = isRetweet
|
|
|
|
result.retweeted = wasRetweeted()
|
|
|
|
val status: Status
|
|
|
|
if (retweetedStatus != null) {
|
|
|
|
status = retweetedStatus
|
|
|
|
val retweetUser = user
|
|
|
|
result.retweet_id = retweetedStatus.id
|
|
|
|
result.retweet_timestamp = retweetedStatus.createdAt?.time ?: 0
|
2017-05-03 15:42:05 +02:00
|
|
|
result.retweeted_by_user_key = retweetUser.key
|
2017-04-19 08:50:47 +02:00
|
|
|
result.retweeted_by_user_name = retweetUser.name
|
|
|
|
result.retweeted_by_user_screen_name = retweetUser.screenName
|
|
|
|
result.retweeted_by_user_profile_image = retweetUser.getProfileImageOfSize(profileImageSize)
|
|
|
|
|
|
|
|
extras.retweeted_external_url = retweetedStatus.inferredExternalUrl
|
|
|
|
|
|
|
|
if (retweetUser.isBlocking == true) {
|
|
|
|
result.addFilterFlag(ParcelableStatus.FilterFlags.BLOCKING_USER)
|
|
|
|
}
|
|
|
|
if (retweetUser.isBlockedBy == true) {
|
|
|
|
result.addFilterFlag(ParcelableStatus.FilterFlags.BLOCKED_BY_USER)
|
|
|
|
}
|
|
|
|
if (retweetedStatus.isPossiblySensitive) {
|
2017-05-03 15:42:05 +02:00
|
|
|
result.addFilterFlag(ParcelableStatus.FilterFlags.POSSIBLY_SENSITIVE)
|
2017-04-19 08:50:47 +02:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
status = this
|
|
|
|
if (status.isPossiblySensitive) {
|
2017-05-03 15:42:05 +02:00
|
|
|
result.addFilterFlag(ParcelableStatus.FilterFlags.POSSIBLY_SENSITIVE)
|
2017-04-19 08:50:47 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
val quoted = status.quotedStatus
|
|
|
|
result.is_quote = status.isQuoteStatus
|
|
|
|
result.quoted_id = status.quotedStatusId
|
|
|
|
if (quoted != null) {
|
|
|
|
val quotedUser = quoted.user
|
|
|
|
result.quoted_id = quoted.id
|
|
|
|
extras.quoted_external_url = quoted.inferredExternalUrl
|
|
|
|
|
|
|
|
val quotedText = quoted.htmlText
|
|
|
|
// Twitter will escape <> to <>, so if a status contains those symbols unescaped
|
|
|
|
// We should treat this as an html
|
|
|
|
if (quotedText.isHtml) {
|
|
|
|
val html = HtmlSpanBuilder.fromHtml(quotedText, quoted.extendedText)
|
2017-04-20 19:23:11 +02:00
|
|
|
result.quoted_text_unescaped = html?.toString()
|
2017-04-19 08:50:47 +02:00
|
|
|
result.quoted_text_plain = result.quoted_text_unescaped
|
2017-04-20 19:23:11 +02:00
|
|
|
result.quoted_spans = html?.spanItems
|
2017-04-19 08:50:47 +02:00
|
|
|
} else {
|
2017-05-05 09:10:42 +02:00
|
|
|
val textWithIndices = quoted.formattedTextWithIndices()
|
|
|
|
result.quoted_text_plain = quotedText.twitterUnescaped()
|
2017-04-19 08:50:47 +02:00
|
|
|
result.quoted_text_unescaped = textWithIndices.text
|
|
|
|
result.quoted_spans = textWithIndices.spans
|
|
|
|
extras.quoted_display_text_range = textWithIndices.range
|
|
|
|
}
|
|
|
|
|
|
|
|
result.quoted_timestamp = quoted.createdAt.time
|
|
|
|
result.quoted_source = quoted.source
|
|
|
|
result.quoted_media = ParcelableMediaUtils.fromStatus(quoted, accountKey, accountType)
|
|
|
|
|
2017-05-03 15:42:05 +02:00
|
|
|
result.quoted_user_key = quotedUser.key
|
2017-04-19 08:50:47 +02:00
|
|
|
result.quoted_user_name = quotedUser.name
|
|
|
|
result.quoted_user_screen_name = quotedUser.screenName
|
|
|
|
result.quoted_user_profile_image = quotedUser.getProfileImageOfSize(profileImageSize)
|
|
|
|
result.quoted_user_is_protected = quotedUser.isProtected
|
|
|
|
result.quoted_user_is_verified = quotedUser.isVerified
|
|
|
|
|
|
|
|
if (quoted.isPossiblySensitive) {
|
2017-05-03 15:42:05 +02:00
|
|
|
result.addFilterFlag(ParcelableStatus.FilterFlags.POSSIBLY_SENSITIVE)
|
2017-04-19 08:50:47 +02:00
|
|
|
}
|
|
|
|
} else if (status.isQuoteStatus) {
|
|
|
|
result.addFilterFlag(ParcelableStatus.FilterFlags.QUOTE_NOT_AVAILABLE)
|
|
|
|
}
|
|
|
|
|
|
|
|
result.reply_count = status.replyCount
|
|
|
|
result.retweet_count = status.retweetCount
|
|
|
|
result.favorite_count = status.favoriteCount
|
|
|
|
|
|
|
|
result.in_reply_to_name = status.inReplyToName
|
|
|
|
result.in_reply_to_screen_name = status.inReplyToScreenName
|
|
|
|
result.in_reply_to_status_id = status.inReplyToStatusId
|
|
|
|
result.in_reply_to_user_key = status.getInReplyToUserKey(accountKey)
|
|
|
|
|
|
|
|
val user = status.user
|
2017-05-03 15:42:05 +02:00
|
|
|
result.user_key = user.key
|
2017-04-19 08:50:47 +02:00
|
|
|
result.user_name = user.name
|
|
|
|
result.user_screen_name = user.screenName
|
|
|
|
result.user_profile_image_url = user.getProfileImageOfSize(profileImageSize)
|
|
|
|
result.user_is_protected = user.isProtected
|
|
|
|
result.user_is_verified = user.isVerified
|
|
|
|
result.user_is_following = user.isFollowing == true
|
|
|
|
extras.user_statusnet_profile_url = user.statusnetProfileUrl
|
|
|
|
extras.user_profile_image_url_fallback = user.profileImageUrlHttps ?: user.profileImageUrl
|
|
|
|
val text = status.htmlText
|
|
|
|
// Twitter will escape <> to <>, so if a status contains those symbols unescaped
|
|
|
|
// We should treat this as an html
|
|
|
|
if (text.isHtml) {
|
|
|
|
val html = HtmlSpanBuilder.fromHtml(text, status.extendedText)
|
2017-04-20 19:23:11 +02:00
|
|
|
result.text_unescaped = html?.toString()
|
2017-04-19 08:50:47 +02:00
|
|
|
result.text_plain = result.text_unescaped
|
2017-04-20 19:23:11 +02:00
|
|
|
result.spans = html?.spanItems
|
2017-04-19 08:50:47 +02:00
|
|
|
} else {
|
2017-05-05 09:10:42 +02:00
|
|
|
val textWithIndices = status.formattedTextWithIndices()
|
2017-04-19 08:50:47 +02:00
|
|
|
result.text_unescaped = textWithIndices.text
|
2017-05-05 09:10:42 +02:00
|
|
|
result.text_plain = text.twitterUnescaped()
|
2017-04-19 08:50:47 +02:00
|
|
|
result.spans = textWithIndices.spans
|
|
|
|
extras.display_text_range = textWithIndices.range
|
|
|
|
}
|
|
|
|
|
|
|
|
result.media = ParcelableMediaUtils.fromStatus(status, accountKey, accountType)
|
|
|
|
result.source = status.source
|
|
|
|
result.location = status.parcelableLocation
|
|
|
|
result.is_favorite = status.isFavorited
|
|
|
|
if (result.account_key.maybeEquals(result.retweeted_by_user_key)) {
|
|
|
|
result.my_retweet_id = result.id
|
|
|
|
} else {
|
|
|
|
result.my_retweet_id = status.currentUserRetweet
|
|
|
|
}
|
|
|
|
result.is_possibly_sensitive = status.isPossiblySensitive
|
2017-05-03 15:42:05 +02:00
|
|
|
result.mentions = status.userMentionEntities?.mapToArray { it.toParcelable(user.host) }
|
2017-04-19 08:50:47 +02:00
|
|
|
result.card = status.card?.toParcelable(accountKey, accountType)
|
|
|
|
result.card_name = result.card?.name
|
|
|
|
result.place_full_name = status.placeFullName
|
|
|
|
result.lang = status.lang
|
|
|
|
result.extras = extras
|
2017-05-10 12:53:29 +02:00
|
|
|
|
|
|
|
if (result.media.isNotNullOrEmpty() || result.quoted_media.isNotNullOrEmpty()) {
|
|
|
|
result.addFilterFlag(ParcelableStatus.FilterFlags.HAS_MEDIA)
|
|
|
|
}
|
2017-04-19 08:50:47 +02:00
|
|
|
}
|
|
|
|
|
2017-05-05 09:10:42 +02:00
|
|
|
|
|
|
|
fun Status.formattedTextWithIndices(): StatusTextWithIndices {
|
|
|
|
val source = CodePointArray(this.fullText ?: this.text!!)
|
|
|
|
val builder = HtmlBuilder(source, false, true, false)
|
|
|
|
builder.addEntities(this)
|
|
|
|
val textWithIndices = StatusTextWithIndices()
|
|
|
|
val (text, spans) = builder.buildWithIndices()
|
|
|
|
textWithIndices.text = text
|
|
|
|
textWithIndices.spans = spans
|
|
|
|
|
|
|
|
// Display text range
|
|
|
|
val range = displayTextRange?.takeIf { it.size == 2 }
|
|
|
|
if (range != null) {
|
|
|
|
textWithIndices.range = intArrayOf(
|
|
|
|
source.findResultRangeLength(spans, 0, range[0]),
|
|
|
|
text.length - source.findResultRangeLength(spans, range[1], source.length())
|
|
|
|
)
|
|
|
|
}
|
|
|
|
return textWithIndices
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun CodePointArray.findResultRangeLength(spans: Array<SpanItem>, origStart: Int, origEnd: Int): Int {
|
|
|
|
val findResult = findByOrigRange(spans, origStart, origEnd)
|
|
|
|
if (findResult.isEmpty()) {
|
|
|
|
return charCount(origStart, origEnd)
|
|
|
|
}
|
|
|
|
val first = findResult.first()
|
|
|
|
val last = findResult.last()
|
|
|
|
if (first.orig_start == -1 || last.orig_end == -1)
|
|
|
|
return charCount(origStart, origEnd)
|
|
|
|
return charCount(origStart, first.orig_start) + (last.end - first.start) + charCount(first.orig_end, origEnd)
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fun HtmlBuilder.addEntities(entities: EntitySupport) {
|
|
|
|
// Format media.
|
|
|
|
var mediaEntities: Array<MediaEntity>? = null
|
|
|
|
if (entities is ExtendedEntitySupport) {
|
|
|
|
mediaEntities = entities.extendedMediaEntities
|
|
|
|
}
|
|
|
|
if (mediaEntities == null) {
|
|
|
|
mediaEntities = entities.mediaEntities
|
|
|
|
}
|
|
|
|
val startEnd = IntArray(2)
|
|
|
|
mediaEntities?.forEach { mediaEntity ->
|
|
|
|
val mediaUrl = getMediaUrl(mediaEntity)
|
|
|
|
if (mediaUrl != null && getStartEndForEntity(mediaEntity, startEnd)) {
|
|
|
|
addLink(mediaEntity.expandedUrl, mediaEntity.displayUrl,
|
|
|
|
startEnd[0], startEnd[1], false)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
entities.urlEntities?.forEach { urlEntity ->
|
|
|
|
val expandedUrl = urlEntity.expandedUrl
|
|
|
|
if (expandedUrl != null && getStartEndForEntity(urlEntity, startEnd)) {
|
|
|
|
addLink(expandedUrl, urlEntity.displayUrl, startEnd[0],
|
|
|
|
startEnd[1], false)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun String.twitterUnescaped(): String {
|
|
|
|
return twitterRawTextTranslator.translate(this)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param spans Ordered spans
|
|
|
|
* *
|
|
|
|
* @param start orig_start
|
|
|
|
* *
|
|
|
|
* @param end orig_end
|
|
|
|
*/
|
|
|
|
internal fun findByOrigRange(spans: Array<SpanItem>, start: Int, end: Int): List<SpanItem> {
|
|
|
|
return spans.filter { it.orig_start >= start && it.orig_end <= end }
|
|
|
|
}
|
|
|
|
|
2017-04-19 08:50:47 +02:00
|
|
|
internal inline val CharSequence.spanItems get() = (this as? Spanned)?.let { text ->
|
2017-04-26 17:38:59 +02:00
|
|
|
text.getSpans(0, length, URLSpan::class.java).mapToArray {
|
|
|
|
val item = it.toSpanItem(text)
|
|
|
|
if (it is AcctMentionSpan) {
|
|
|
|
item.type = SpanItem.SpanType.ACCT_MENTION
|
|
|
|
}
|
|
|
|
return@mapToArray item
|
|
|
|
}
|
2017-04-19 08:50:47 +02:00
|
|
|
}
|
|
|
|
|
2017-04-20 19:23:11 +02:00
|
|
|
internal inline val String.isHtml get() = contains('<') && contains('>')
|
2017-04-19 08:50:47 +02:00
|
|
|
|
|
|
|
private inline val Status.inReplyToName get() = userMentionEntities?.firstOrNull {
|
|
|
|
inReplyToUserId == it.id
|
|
|
|
}?.name ?: attentions?.firstOrNull {
|
|
|
|
inReplyToUserId == it.id
|
|
|
|
}?.fullName ?: inReplyToScreenName
|
|
|
|
|
|
|
|
|
|
|
|
private inline val Status.placeFullName get() = place?.fullName ?: location?.takeIf {
|
|
|
|
ParcelableLocation.valueOf(location) == null
|
|
|
|
}
|
|
|
|
|
|
|
|
private inline val Status.inferredExternalUrl get() = externalUrl ?: uri?.let { uri ->
|
|
|
|
noticeUriRegex.matchEntire(uri)?.let { result: MatchResult ->
|
|
|
|
"https://${result.groups[1]?.value}/notice/${result.groups[3]?.value}"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private val Status.parcelableLocation: ParcelableLocation?
|
|
|
|
get() {
|
|
|
|
val geoLocation = geoLocation
|
|
|
|
if (geoLocation != null) {
|
|
|
|
return ParcelableLocationUtils.fromGeoLocation(geoLocation)
|
|
|
|
}
|
|
|
|
val locationString = location ?: return null
|
|
|
|
val location = ParcelableLocation.valueOf(locationString)
|
|
|
|
if (location != null) {
|
|
|
|
return location
|
|
|
|
}
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun Status.getInReplyToUserKey(accountKey: UserKey): UserKey? {
|
|
|
|
val inReplyToUserId = inReplyToUserId ?: return null
|
|
|
|
val entities = userMentionEntities
|
|
|
|
if (entities != null) {
|
|
|
|
if (entities.any { inReplyToUserId == it.id }) {
|
|
|
|
return UserKey(inReplyToUserId, accountKey.host)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
val attentions = attentions
|
|
|
|
if (attentions != null) {
|
|
|
|
attentions.firstOrNull { inReplyToUserId == it.id }?.let {
|
2017-05-03 15:42:05 +02:00
|
|
|
val host = getUserHost(it.ostatusUri, accountKey.host)
|
2017-04-19 08:50:47 +02:00
|
|
|
return UserKey(inReplyToUserId, host)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return UserKey(inReplyToUserId, accountKey.host)
|
|
|
|
}
|
|
|
|
|
2017-05-05 09:10:42 +02:00
|
|
|
private val noticeUriRegex = Regex("tag:([\\w\\d.]+),(\\d{4}-\\d{2}-\\d{2}):noticeId=(\\d+):objectType=(\\w+)")
|
|
|
|
|
|
|
|
private object twitterRawTextTranslator : LookupTranslator(*EntityArrays.BASIC_UNESCAPE())
|
|
|
|
|
|
|
|
class StatusTextWithIndices {
|
|
|
|
var text: String? = null
|
|
|
|
var spans: Array<SpanItem>? = null
|
|
|
|
var range: IntArray? = null
|
|
|
|
}
|