SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootAccount.kt

800 lines
31 KiB
Kotlin
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter.api.entity
import android.content.Context
import android.text.Spannable
import android.text.SpannableStringBuilder
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.MisskeyAccountDetailMap
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRefOrNull
import jp.juggler.subwaytooter.api.entity.TootStatus.Companion.tootStatus
import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoUserRelation
import jp.juggler.subwaytooter.util.*
import jp.juggler.subwaytooter.util.DecodeOptions.Companion.emojiScaleUserName
import jp.juggler.subwaytooter.view.MyLinkMovementMethod
import jp.juggler.util.*
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.ui.vg
import java.util.*
import java.util.regex.Pattern
@Suppress("LongParameterList")
open class TootAccount(
//URL of the user's profile page (can be remote)
// https://mastodon.juggler.jp/@tateisu
// 疑似アカウントではnullになります
val url: String?,
// The ID of the account
val id: EntityId,
// The username of the account /[A-Za-z0-9_]{1,30}/
val username: String,
final override val apiHost: Host,
final override val apDomain: Host,
// Equals username for local users, includes @domain for remote ones
val acct: Acct,
// The account's display name
val display_name: String,
//Boolean for when the account cannot be followed without waiting for approval first
val locked: Boolean,
// The time the account was created
// ex: "2017-04-13T11:06:08.289Z"
val created_at: String?,
val time_created_at: Long,
// The number of followers for the account
var followers_count: Long? = null,
//The number of accounts the given account is following
var following_count: Long? = null,
// The number of statuses the account has made
var statuses_count: Long? = null,
// Biography of user
// 説明文。改行は\r\n。リンクなどはHTMLタグで書かれている
val note: String?,
// URL to the avatar image
val avatar: String?,
// URL to the avatar static image (gif)
val avatar_static: String?,
//URL to the header image
val header: String?,
// URL to the header static image (gif)
val header_static: String?,
val source: Source?,
val profile_emojis: MutableMap<String, NicoProfileEmoji>?,
val movedRef: TootAccountRef?,
val fields: ArrayList<Field>?,
val custom_emojis: MutableMap<String, CustomEmoji>?,
val bot: Boolean,
val isCat: Boolean,
val isAdmin: Boolean,
val isPro: Boolean,
// user_hides_network is preference, not exposed in API
// val user_hides_network : Boolean
var pinnedNotes: ArrayList<TootStatus>? = null,
private var pinnedNoteIds: ArrayList<String>? = null,
// misskey (only /api/users/show)
var location: String? = null,
var birthday: String? = null,
// mastodon 3.0.0-dev // last_status_at : "2019-08-29T12:42:08.838Z" or null
// mastodon 3.1 // last_status_at : "2019-08-29" or null
private var last_status_at: Long = 0L,
// mastodon 3.3.0
var suspended: Boolean = false,
val json: JsonObject,
) : HostAndDomain {
class Field(
val name: String,
val value: String,
val verified_at: Long, // 0L if not verified
)
val moved: TootAccount?
get() = movedRef?.get()
@Suppress("unused")
val isLocal: Boolean
get() = acct.host == null
val isRemote: Boolean
get() = acct.host != null
fun getUserUrl() = url ?: "https://${apDomain.pretty}/@$username"
class Source(src: JsonObject) {
// デフォルト公開範囲
val privacy: String?
// 添付画像をデフォルトでNSFWにする設定
private val sensitive: Boolean
// HTMLエンコードされていない、生のnote
val note: String?
// 2.4.0 から?
val fields: ArrayList<Field>?
init {
this.privacy = src.string("privacy")
this.note = src.string("note")
// nullになることがあるが、falseと同じ扱いでよい
this.sensitive = src.optBoolean("sensitive", false)
//
this.fields = parseFields(src.jsonArray("fields"))
}
}
// リストメンバーダイアログや引っ越し先ユーザなど、TL以外の部分に名前を表示する場合は
// Invalidator の都合でSpannableを別途生成する必要がある
fun decodeDisplayName(context: Context): Spannable {
// remove white spaces
val sv = reWhitespace.matcher(display_name).replaceAll(" ")
// decode emoji code
return DecodeOptions(
context,
emojiMapProfile = profile_emojis,
emojiMapCustom = custom_emojis,
authorDomain = this,
enlargeCustomEmoji = emojiScaleUserName,
enlargeEmoji = emojiScaleUserName,
).decodeEmoji(sv)
}
// リストメンバーダイアログや引っ越し先ユーザなど、TL以外の部分に名前を表示する場合は
// Invalidator の都合でSpannableを別途生成する必要がある
fun decodeDisplayNameCached(context: Context): Spannable {
// remove white spaces
val sv = reWhitespace.matcher(display_name).replaceAll(" ")
// decode emoji code
return DecodeOptions(
context,
emojiMapProfile = profile_emojis,
emojiMapCustom = custom_emojis,
authorDomain = this,
enlargeCustomEmoji = emojiScaleUserName,
enlargeEmoji = emojiScaleUserName,
).decodeEmoji(sv)
}
private fun SpannableStringBuilder.replaceAllEx(
pattern: Pattern,
replacement: String,
): SpannableStringBuilder {
val m = pattern.matcher(this)
var buffer: SpannableStringBuilder? = null
var lastEnd = 0
while (m.find()) {
val dst = buffer ?: SpannableStringBuilder().also { buffer = it }
dst
.append(this.subSequence(lastEnd, m.start()))
.append(replacement) // 変数展開には未対応
lastEnd = m.end()
}
return buffer
?.also { if (lastEnd < length) it.append(subSequence(lastEnd, length)) }
?: this
}
private fun SpannableStringBuilder.trimEx(isSpace: (c: Char) -> Boolean = { it <= ' ' }): CharSequence {
var start = 0
var end = length
while (start < end && isSpace(this[start])) ++start
while (end > start && isSpace(this[end - 1])) --end
return when {
start >= end -> ""
start == 0 && end == length -> this
else -> subSequence(start, end)
}
}
fun setAccountExtra(
accessInfo: SavedAccount,
invalidator: NetworkEmojiInvalidator,
fromProfileHeader: Boolean = false,
suggestionSource: String? = null,
): SpannableStringBuilder? {
val context = invalidator.view.context
var sb: SpannableStringBuilder? = null
fun prepareSb() = sb?.apply { append('\n') } ?: SpannableStringBuilder().also { sb = it }
val delm = ": "
if (suggestionSource?.isNotEmpty() == true) {
prepareSb()
.append(context.getString(R.string.suggestion_source))
.append(delm)
.append(suggestionSource)
}
if (PrefB.bpDirectoryLastActive.value && last_status_at > 0L) {
prepareSb()
.append(context.getString(R.string.last_active))
.append(delm)
.append(
TootStatus.formatTime(
context,
last_status_at,
bAllowRelative = true,
onlyDate = true
)
)
}
if (!fromProfileHeader) {
if (PrefB.bpDirectoryTootCount.value &&
(statuses_count ?: 0L) > 0L
) {
prepareSb()
.append(context.getString(R.string.toot_count))
.append(delm)
.append(statuses_count.toString())
}
if (PrefB.bpDirectoryFollowers.value &&
!PrefB.bpHideFollowCount.value &&
(followers_count ?: 0L) > 0L
) {
prepareSb()
.append(context.getString(R.string.followers))
.append(delm)
.append(followers_count.toString())
}
if (PrefB.bpDirectoryNote.value && note?.isNotEmpty() == true) {
val decodedNote = DecodeOptions(
context,
accessInfo,
short = true,
decodeEmoji = true,
emojiMapProfile = profile_emojis,
emojiMapCustom = custom_emojis,
unwrapEmojiImageTag = true,
authorDomain = this,
emojiSizeMode = accessInfo.emojiSizeMode(),
enlargeCustomEmoji = emojiScaleUserName,
enlargeEmoji = emojiScaleUserName,
).decodeHTML(note)
.replaceAllEx(reNoteLineFeed, " ")
.trimEx()
if (decodedNote.isNotBlank()) {
prepareSb().append(
if (decodedNote is SpannableStringBuilder && decodedNote.length > 200) {
decodedNote.replace(200, decodedNote.length, "")
} else {
decodedNote
}
)
}
}
}
invalidator.view.vg(sb != null)?.apply {
invalidator.text = sb!!
movementMethod = MyLinkMovementMethod
} ?: invalidator.clear()
return sb
}
companion object {
private val log = LogCategory("TootAccount")
internal val reWhitespace = "[\\s\\t\\x0d\\x0a]+".asciiPattern()
// noteをディレクトリに表示する際、制御文字や空白を変換する
private val reNoteLineFeed: Pattern = """[\x00-\x20\x7f ]+""".asciiPattern()
// IDNドメインを含むホスト名の正規表現
private const val reHostIdn = """(?:(?:[\p{L}\p{N}][\p{L}\p{N}-_]*\.)+[\p{L}\p{N}]{2,})"""
internal val reHostInUrl: Pattern = """\Ahttps://($reHostIdn)/"""
.asciiPattern()
// 文字数カウントに使う正規表現
private val reCountLink = """(https?://$reHostIdn[\w/:%#@${'$'}&?!()\[\]~.=+\-]*)"""
.asciiPattern()
// 投稿中のURLは23文字として扱う
private val strUrlReplacement = (1..23).joinToString(transform = { " " })
// \p{L} : アルファベット (Letter)。
//   Ll(小文字)、Lm(擬似文字)、Lo(その他の文字)、Lt(タイトル文字)、Lu(大文字アルファベット)を含む
// \p{M} : 記号 (Mark)
// \p{Nd} : 10 進数字 (Decimal number)
// \p{Pc} : 連結用句読記号 (Connector punctuation)
// rubyの [:word:] 単語構成文字 (Letter | Mark | Decimal_Number | Connector_Punctuation)
const val reRubyWord = """\p{L}\p{M}\p{Nd}\p{Pc}"""
// rubyの [:alpha:] : 英字 (Letter | Mark)
const val reRubyAlpha = """\p{L}\p{M}"""
private const val reMastodonUserName = """[A-Za-z0-9_]+(?:[A-Za-z0-9_.-]+[A-Za-z0-9_]+)?"""
private const val reMastodonMention =
"""(?<=^|[^/$reRubyWord])@(($reMastodonUserName)(?:@[$reRubyWord.-]+[A-Za-z0-9]+)?)"""
val reCountMention = reMastodonMention.asciiPattern()
fun countText(s: String): Int {
return s
.replaceAll(reCountLink, strUrlReplacement)
.replaceAll(reCountMention, "@$2")
.codePointCount()
}
// MisskeyのMFMのメンションのドメイン部分はIDN非対応
private const val reMisskeyHost = """\w[\w.-]*\w"""
// https://misskey.io/@tateisu@%E3%83%9E%E3%82%B9%E3%83%88%E3%83%89%E3%83%B33.juggler.jp
// のようなURLがMisskeyのメンションから生成されることがある
// %エンコーディングのデコードが必要
private const val reMisskeyHostEncoded = """[\w%][\w.%-]*[\w%]"""
// MFMのメンション @username @username@host
// (Mastodonのカラムでは使われていない)
// MisskeyのMFMはIDNをサポートしていない
private val reMisskeyMentionBase = """@(\w+(?:[\w-]*\w)?)(?:@($reMisskeyHost))?"""
.asciiPattern()
// MFMパース時に使う
internal val reMisskeyMentionMFM = """\A$reMisskeyMentionBase"""
.asciiPattern()
// 投稿送信時にメンションを見つけてuserIdを調べるために使う
internal val reMisskeyMentionPost = """(?:\A|\s)$reMisskeyMentionBase"""
.asciiPattern()
// host, user ,(instance)
// Misskeyだけではないのでusernameの定義が違う
internal val reAccountUrl =
"""\Ahttps://($reHostIdn)/@(\w+[\w-]*)(?:@($reMisskeyHostEncoded))?(?=\z|[?#])"""
.asciiPattern()
// host,user
internal val reAccountUrl2 =
"""\Ahttps://($reHostIdn)/users/(\w|\w+[\w-]*\w)(?=\z|[?#])"""
.asciiPattern()
private fun tootAccountMisskey(parser: TootParser, src: JsonObject): TootAccount {
val custom_emojis: MutableMap<String, CustomEmoji>? =
when (src.jsonObject("emojis")?.values?.firstOrNull()) {
// Misskey13 は ショートコード→URLの単純なマップ
is String -> CustomEmoji.decodeMisskey12ReactionEmojis(src.jsonObject("emojis"))
// もっと古い形式
else -> parseMapOrNull(src.jsonArray("emojis"), CustomEmoji::decodeMisskey)
}
val username = src.stringOrThrow("username")
// FIXME apiHostとapDomainが異なる場合はMisskeyだとどうなの…
val apiHost = src.string("host")?.let { Host.parse(it) } ?: parser.apiHost
@Suppress("UnnecessaryVariable")
val apDomain = apiHost
@Suppress("LeakingThis")
val acct: Acct = when {
// アクセス元から見て内部ユーザなら short acct
parser.linkHelper.matchHost(apiHost, apDomain) -> Acct.parse(username)
// アクセス元から見て外部ユーザならfull acct
else -> Acct.parse(username, apDomain)
}
val id = EntityId.mayDefault(src.string("id"))
val created_at: String? = src.string("createdAt")
val pinnedNotes: ArrayList<TootStatus>? = if (parser.misskeyDecodeProfilePin) {
val list =
parseList(src.jsonArray("pinnedNotes")) { tootStatus(parser, it) }
list.forEach { it.pinned = true }
list.notEmpty()
} else {
null
}
// this.user_hides_network = src.optBoolean("user_hides_network")
val profile = src.jsonObject("profile")
daoUserRelation.fromAccount(parser, src, id)
return TootAccount(
acct = acct,
apDomain = apDomain,
apiHost = apiHost,
avatar = src.string("avatarUrl"),
avatar_static = src.string("avatarUrl"),
birthday = profile?.string("birthday"),
bot = src.optBoolean("isBot", false),
created_at = created_at,
custom_emojis = custom_emojis,
display_name = src.string("name")?.notEmpty()?.sanitizeBDI() ?: username,
fields = parseMisskeyFields(src),
followers_count = src.long("followersCount") ?: -1L,
following_count = src.long("followingCount") ?: -1L,
header = src.string("bannerUrl"),
header_static = src.string("bannerUrl"),
id = id,
isAdmin = src.optBoolean("isAdmin", false),
isCat = src.optBoolean("isCat", false),
isPro = src.optBoolean("isPro", false),
json = src,
location = profile?.string("location"),
locked = src.optBoolean("isLocked"),
movedRef = null,
note = src.string("description"),
pinnedNoteIds = src.stringArrayList("pinnedNoteIds"),
pinnedNotes = pinnedNotes,
profile_emojis = null,
source = null,
statuses_count = src.long("notesCount") ?: -1L,
time_created_at = TootStatus.parseTime(created_at),
url = "https://${apiHost.ascii}/@$username",
username = username,
suspended = false,
last_status_at = 0L,
).apply {
MisskeyAccountDetailMap.fromAccount(parser, this, id)
}
}
private fun tootAccountNoteStock(parser: TootParser, src: JsonObject): TootAccount {
// notestock はActivityPub 準拠のサービスなので、サーバ内IDというのは特にない
val id: EntityId = EntityId.DEFAULT
val tmpDisplayName = src.string("display_name")
val tmpUserName = src.string("username")
// notestockはdisplay_nameとusernameが入れ替わってる
val username = tmpDisplayName ?: tmpUserName ?: error("missing username,displayname")
val display_name =
tmpUserName ?: tmpDisplayName ?: error("missing username,displayname")
val tmpAcct = src.string("subject")?.let { Acct.parse(it) }
val url: String? = src.string("url")
val apDomain: Host = tmpAcct?.takeIf { it.isValidFull }?.host
?: Host.parse(
src.string("id").mayUri()?.authority?.notEmpty()
?: error("can't get apDomain from account's AP id.")
)
val apiHost: Host = Host.parse(
url.mayUri()?.authority?.notEmpty()
?: error("can't get apiHost from account's AP url.")
)
val apTag = APTag(parser, src.jsonArray("tag"))
return TootAccount(
acct = Acct.parse(username, apDomain),
apDomain = apDomain,
apiHost = apiHost,
avatar = src.string("avatar"),
avatar_static = src.string("avatar_static"),
birthday = null,
bot = false,
created_at = null,
custom_emojis = apTag.emojiList.notEmpty(),
display_name = display_name,
fields = null,
followers_count = null,
following_count = null,
header = src.string("header"),
header_static = src.string("header_static"),
id = id,
isAdmin = false,
isCat = false,
isPro = false,
json = src,
location = null,
locked = src.boolean("manuallyApprovesFollowers") ?: false,
movedRef = null,
note = src.string("note"),
pinnedNoteIds = null,
pinnedNotes = null,
profile_emojis = apTag.profileEmojiList.notEmpty(),
source = null,
statuses_count = null,
time_created_at = 0L,
url = url,
username = username,
suspended = false,
last_status_at = 0L,
)
}
private fun tootAccountMastodon(parser: TootParser, src: JsonObject): TootAccount {
// 絵文字データは先に読んでおく
val custom_emojis: HashMap<String, CustomEmoji>? =
parseMapOrNull(src.jsonArray("emojis"), CustomEmoji::decodeMastodon)
val profile_emojis: HashMap<String, NicoProfileEmoji>? =
when (val o = src["profile_emojis"]) {
is JsonArray -> parseMapOrNull(o) { NicoProfileEmoji(it) }
is JsonObject -> parseProfileEmoji2(o) { j, k -> NicoProfileEmoji(j, k) }
else -> null
}
val acct: Acct
val apDomain: Host
val apiHost: Host
val id: EntityId
// 疑似アカウントにacctとusernameだけ
val url: String? = src.string("url")
val username: String = src.stringOrThrow("username")
val movedRef: TootAccountRef? = tootAccountRefOrNull(
parser,
src.jsonObject("moved")?.let {
tootAccount(parser, it)
}
)
val created_at: String? = src.string("created_at")
// this.user_hides_network = src.optBoolean("user_hides_network")
when (parser.serviceType) {
ServiceType.MASTODON -> {
id = EntityId.mayDefault(src.string("id"))
val tmpAcct = src.stringOrThrow("acct")
val pair = findHostFromUrl(
tmpAcct,
parser.linkHelper,
url
)
apiHost = pair.first ?: error("can't get apiHost from acct or url")
apDomain = pair.second ?: error("can't get apDomain from acct or url")
acct = Acct.parse(username, if (tmpAcct.contains('@')) apDomain else null)
}
ServiceType.TOOTSEARCH -> {
// tootsearch のアカウントのIDはどのタンス上のものか分からないので役に立たない
id = EntityId.DEFAULT
val tmpAcct = src.stringOrThrow("acct")
val pair = findHostFromUrl(tmpAcct, null, url)
apiHost = pair.first ?: error("can't get apiHost from acct or url")
apDomain = pair.second ?: error("can't get apDomain from acct or url")
acct = Acct.parse(username, apDomain)
}
ServiceType.MSP -> {
id = EntityId.mayDefault(src.string("id"))
// MSPはLTLの情報しか持ってないのでacctは常にホスト名部分を持たない
val pair = findHostFromUrl(null, null, url)
apiHost = pair.first ?: error("can't get apiHost from acct or url")
apDomain = pair.second ?: error("can't get apDomain from acct or url")
acct = Acct.parse(username, apDomain)
}
else -> error("serverType missmatch: ${parser.serviceType}")
}
return TootAccount(
acct = acct,
apDomain = apDomain,
apiHost = apiHost,
avatar = src.string("avatar"),
avatar_static = src.string("avatar_static") ?: src.string("avatar"),
birthday = null,
bot = src.optBoolean("bot", false),
created_at = created_at,
custom_emojis = custom_emojis,
display_name = src.string("display_name")?.notEmpty()?.sanitizeBDI() ?: username,
fields = parseFields(src.jsonArray("fields")),
followers_count = src.long("followers_count"),
following_count = src.long("following_count"),
header = src.string("header"),
header_static = src.string("header_static"),
id = id,
isAdmin = false,
isCat = false,
isPro = false,
json = src,
location = null,
locked = src.optBoolean("locked"),
movedRef = movedRef,
note = src.string("note"),
pinnedNoteIds = null,
pinnedNotes = null,
profile_emojis = profile_emojis,
source = parseSource(src.jsonObject("source")),
statuses_count = src.long("statuses_count"),
time_created_at = TootStatus.parseTime(created_at),
url = url,
username = username,
suspended = src.optBoolean("suspended", false),
last_status_at = TootStatus.parseTime(src.string("last_status_at")),
)
}
fun tootAccount(parser: TootParser, src: JsonObject): TootAccount {
src["_fromStream"] = parser.fromStream
return when (parser.serviceType) {
ServiceType.MISSKEY -> tootAccountMisskey(parser, src)
ServiceType.NOTESTOCK -> tootAccountNoteStock(parser, src)
else -> tootAccountMastodon(parser, src)
}
}
// notestockはaccountのnotag先頭に
fun getAcctFromUrl(url: String?): Acct? {
url ?: return null
var m = reAccountUrl.matcher(url)
if (m.find()) {
val host = m.groupEx(1)
val user = m.groupEx(2)!!
val instance = m.groupEx(3)?.decodePercent()
return Acct.parse(user, instance?.notEmpty() ?: host)
}
m = reAccountUrl2.matcher(url)
if (m.find()) {
val host = m.groupEx(1)
val user = m.groupEx(2)!!
return Acct.parse(user, host)
}
return null
}
private fun parseSource(src: JsonObject?): Source? =
try {
src?.let { Source(it) }
} catch (ex: Throwable) {
log.e(ex, "parseSource failed.")
null
}
private fun findApDomain(
acctArg: String?,
linkHelper: LinkHelper?,
): Host? {
// acctから調べる
if (acctArg != null) {
val acct = Acct.parse(acctArg)
if (acct.host != null) return acct.host
}
// Acctはnullか、hostを含まない
if (linkHelper != null) return linkHelper.apDomain
return null
}
private fun findApiHost(url: String?): Host? {
// URLから調べる
// たぶんどんなURLでもauthorityの部分にホスト名が来るだろう(慢心)
url.mayUri()?.authority?.notEmpty()?.let { return Host.parse(it) }
log.e("findApiHost: can't parse host from URL $url")
return null
}
// Tootsearch用。URLやUriを使ってアカウントのインスタンス名を調べる
fun findHostFromUrl(
acctArg: String?,
linkHelper: LinkHelper?,
url: String?,
): Pair<Host?, Host?> {
val apDomain = findApDomain(acctArg, linkHelper)
val apiHost = findApiHost(url)
return Pair(apiHost ?: apDomain, apDomain ?: apiHost)
}
fun parseFields(src: JsonArray?): ArrayList<Field>? {
src ?: return null
val dst = ArrayList<Field>()
for (item in src) {
if (item !is JsonObject) continue
val name = item.string("name") ?: continue
val value = item.string("value") ?: continue
val verifiedAt = when (val svVerifiedAt = item.string("verified_at")) {
null -> 0L
else -> TootStatus.parseTime(svVerifiedAt)
}
dst.add(Field(name, value, verifiedAt))
}
return dst.notEmpty()
}
private fun parseMisskeyFields(src: JsonObject): ArrayList<Field>? {
var dst: ArrayList<Field>? = null
// リモートユーザーはAP経由のフィールドが表示される
// https://github.com/syuilo/misskey/pull/3590
// https://github.com/syuilo/misskey/pull/3596
src.jsonArray("fields")?.forEach { o ->
if (o !is JsonObject) return@forEach
//plain text
val n = o.string("name") ?: return@forEach
// mfm
val v = o.string("value") ?: ""
dst = (dst ?: ArrayList()).apply { add(Field(n, v, 0L)) }
}
// misskeyローカルユーザーはTwitter等の連携をフィールドに表示する
// https://github.com/syuilo/misskey/pull/3499
// https://github.com/syuilo/misskey/pull/3586
fun appendField(name: String, caption: String, url: String) {
val value = """[$caption]($url)"""
dst = (dst ?: ArrayList()).apply { add(Field(name, value, 0L)) }
}
runCatching {
src.jsonObject("twitter")?.let {
appendField(
"Twitter",
"@${it.string("screenName")}",
"https://twitter.com/intent/user?user_id=${it.string("userId")}"
)
}
}
runCatching {
src.jsonObject("github")?.string("login")?.let {
appendField(
"GitHub",
it,
"https://github.com/$it"
)
}
}
runCatching {
src.jsonObject("discord")?.let {
appendField(
"Discord",
"${it.string("username")}#${it.string("discriminator")}",
"https://discordapp.com/users/${it.string("id")}"
)
}
}
return if (dst?.isNotEmpty() == true) dst else null
}
}
}