668 lines
20 KiB
Kotlin
668 lines
20 KiB
Kotlin
package jp.juggler.subwaytooter.api.entity
|
||
|
||
import android.content.Context
|
||
import android.text.Spannable
|
||
import android.text.SpannableStringBuilder
|
||
import android.widget.TextView
|
||
import jp.juggler.subwaytooter.App1
|
||
import jp.juggler.subwaytooter.Pref
|
||
import jp.juggler.subwaytooter.R
|
||
import jp.juggler.subwaytooter.api.MisskeyAccountDetailMap
|
||
import jp.juggler.subwaytooter.api.TootParser
|
||
import jp.juggler.subwaytooter.table.SavedAccount
|
||
import jp.juggler.subwaytooter.table.UserRelation
|
||
import jp.juggler.subwaytooter.util.DecodeOptions
|
||
import jp.juggler.subwaytooter.util.NetworkEmojiInvalidator
|
||
import jp.juggler.subwaytooter.view.MyLinkMovementMethod
|
||
import jp.juggler.util.*
|
||
import java.net.IDN
|
||
import java.util.*
|
||
import java.util.regex.Pattern
|
||
|
||
open class TootAccount(parser : TootParser, src : JsonObject) {
|
||
|
||
//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
|
||
|
||
// Equals username for local users, includes @domain for remote ones
|
||
val acctAscii : String // punycode
|
||
val prettyAcct : String // unicode
|
||
|
||
// The username of the account /[A-Za-z0-9_]{1,30}/
|
||
val username : String
|
||
|
||
val hostAscii : String // punycode
|
||
|
||
// 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 : HashMap<String, NicoProfileEmoji>?
|
||
|
||
val movedRef : TootAccountRef?
|
||
|
||
val moved : TootAccount?
|
||
get() = movedRef?.get()
|
||
|
||
class Field(
|
||
val name : String,
|
||
val value : String,
|
||
val verified_at : Long // 0L if not verified
|
||
)
|
||
|
||
val fields : ArrayList<Field>?
|
||
|
||
val custom_emojis : HashMap<String, CustomEmoji>?
|
||
|
||
val bot : Boolean
|
||
val isCat : Boolean
|
||
val isAdmin : Boolean
|
||
val isPro : Boolean
|
||
|
||
val isLocal :Boolean
|
||
get() = !acctAscii.contains('@')
|
||
|
||
val isRemote :Boolean
|
||
get() = acctAscii.contains('@')
|
||
|
||
// 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
|
||
private var last_status_at = 0L
|
||
|
||
val json : JsonObject
|
||
|
||
init {
|
||
this.json = src
|
||
|
||
var sv : String?
|
||
|
||
if(parser.serviceType == ServiceType.MISSKEY) {
|
||
|
||
val remoteHost = src.string("host")
|
||
val tmpHost = (remoteHost ?: parser.accessHost ?: error("missing host")).toLowerCase(Locale.JAPAN)
|
||
this.hostAscii = IDN.toASCII(tmpHost,IDN.ALLOW_UNASSIGNED)
|
||
val prettyHost = IDN.toUnicode(tmpHost,IDN.ALLOW_UNASSIGNED)
|
||
|
||
this.custom_emojis =
|
||
parseMapOrNull(CustomEmoji.decodeMisskey, src.jsonArray("emojis"))
|
||
|
||
this.profile_emojis = null
|
||
|
||
this.username = src.notEmptyOrThrow("username")
|
||
this.url = "https://$hostAscii/@$username"
|
||
|
||
//
|
||
sv = src.string("name")
|
||
this.display_name = if(sv?.isNotEmpty() == true) sv.sanitizeBDI() else username
|
||
|
||
//
|
||
this.note = src.string("description")
|
||
|
||
this.source = null
|
||
this.movedRef = null
|
||
this.locked = src.optBoolean("isLocked")
|
||
|
||
|
||
|
||
this.bot = src.optBoolean("isBot", false)
|
||
this.isCat = src.optBoolean("isCat", false)
|
||
this.isAdmin = src.optBoolean("isAdmin", false)
|
||
this.isPro = src.optBoolean("isPro", false)
|
||
|
||
// this.user_hides_network = src.optBoolean("user_hides_network")
|
||
|
||
this.id = EntityId.mayDefault(src.string("id"))
|
||
|
||
this.acctAscii = when {
|
||
|
||
// アクセス元から見て内部ユーザなら short acct
|
||
remoteHost?.equals(
|
||
parser.accessHost,
|
||
ignoreCase = true
|
||
) != false -> username
|
||
|
||
// アクセス元から見て外部ユーザならfull acct
|
||
else -> "$username@$hostAscii"
|
||
}
|
||
|
||
this.prettyAcct = when {
|
||
// アクセス元から見て内部ユーザなら short acct
|
||
remoteHost?.equals(
|
||
parser.accessHost,
|
||
ignoreCase = true
|
||
) != false -> username
|
||
|
||
// アクセス元から見て外部ユーザならfull acct
|
||
else -> "$username@$prettyHost"
|
||
}
|
||
|
||
this.followers_count = src.long("followersCount") ?: - 1L
|
||
this.following_count = src.long("followingCount") ?: - 1L
|
||
this.statuses_count = src.long("notesCount") ?: - 1L
|
||
|
||
this.created_at = src.string("createdAt")
|
||
this.time_created_at = TootStatus.parseTime(this.created_at)
|
||
|
||
this.avatar = src.string("avatarUrl")
|
||
this.avatar_static = src.string("avatarUrl")
|
||
this.header = src.string("bannerUrl")
|
||
this.header_static = src.string("bannerUrl")
|
||
|
||
this.pinnedNoteIds = src.stringArrayList("pinnedNoteIds")
|
||
if(parser.misskeyDecodeProfilePin) {
|
||
val list = parseList(::TootStatus, parser, src.jsonArray("pinnedNotes"))
|
||
list.forEach { it.pinned = true }
|
||
this.pinnedNotes = if(list.isNotEmpty()) list else null
|
||
}
|
||
|
||
val profile = src.jsonObject("profile")
|
||
this.location = profile?.string("location")
|
||
this.birthday = profile?.string("birthday")
|
||
|
||
|
||
this.fields = parseMisskeyFields(src)
|
||
|
||
|
||
UserRelation.fromAccount(parser, src, id)
|
||
|
||
@Suppress("LeakingThis")
|
||
MisskeyAccountDetailMap.fromAccount(parser, this, id)
|
||
|
||
} else {
|
||
|
||
// 絵文字データは先に読んでおく
|
||
this.custom_emojis = parseMapOrNull(CustomEmoji.decode, src.jsonArray("emojis"))
|
||
|
||
this.profile_emojis = when(val o = src["profile_emojis"]) {
|
||
is JsonArray -> parseMapOrNull(::NicoProfileEmoji, o, TootStatus.log)
|
||
is JsonObject -> parseProfileEmoji2(::NicoProfileEmoji, o, TootStatus.log)
|
||
else -> null
|
||
}
|
||
|
||
// 疑似アカウントにacctとusernameだけ
|
||
this.url = src.string("url")
|
||
this.username = src.notEmptyOrThrow("username")
|
||
|
||
//
|
||
sv = src.string("display_name")
|
||
this.display_name = if(sv?.isNotEmpty() == true) sv.sanitizeBDI() else username
|
||
|
||
//
|
||
this.note = src.string("note")
|
||
|
||
this.source = parseSource(src.jsonObject("source"))
|
||
this.movedRef = TootAccountRef.mayNull(
|
||
parser,
|
||
src.jsonObject("moved")?.let {
|
||
TootAccount(parser, it)
|
||
}
|
||
)
|
||
this.locked = src.optBoolean("locked")
|
||
|
||
this.fields = parseFields(src.jsonArray("fields"))
|
||
|
||
this.bot = src.optBoolean("bot", false)
|
||
this.isAdmin = false
|
||
this.isCat = false
|
||
this.isPro = false
|
||
// this.user_hides_network = src.optBoolean("user_hides_network")
|
||
|
||
this.last_status_at = TootStatus.parseTime(src.string("last_status_at"))
|
||
|
||
when(parser.serviceType) {
|
||
ServiceType.MASTODON -> {
|
||
|
||
val hostAccess = parser.accessHost
|
||
|
||
this.id = EntityId.mayDefault(src.string("id"))
|
||
|
||
val tmpAcct = src.notEmptyOrThrow("acct")
|
||
val tmpHost = (
|
||
findHostFromUrl(tmpAcct, hostAccess, url)
|
||
?: throw RuntimeException("can't get host from acct or url")
|
||
).toLowerCase(Locale.JAPAN)
|
||
this.hostAscii = IDN.toASCII(tmpHost,IDN.ALLOW_UNASSIGNED)
|
||
val prettyHost = IDN.toUnicode(tmpHost,IDN.ALLOW_UNASSIGNED)
|
||
this.acctAscii = if( !tmpAcct.contains('@') ) tmpAcct else "$username@$hostAscii"
|
||
this.prettyAcct = if( !tmpAcct.contains('@') ) tmpAcct else "$username@$prettyHost"
|
||
|
||
this.followers_count = src.long("followers_count")
|
||
this.following_count = src.long("following_count")
|
||
this.statuses_count = src.long("statuses_count")
|
||
|
||
this.created_at = src.string("created_at")
|
||
this.time_created_at = TootStatus.parseTime(this.created_at)
|
||
|
||
this.avatar = src.string("avatar")
|
||
this.avatar_static = src.string("avatar_static")
|
||
this.header = src.string("header")
|
||
this.header_static = src.string("header_static")
|
||
|
||
}
|
||
|
||
ServiceType.TOOTSEARCH -> {
|
||
// tootsearch のアカウントのIDはどのタンス上のものか分からないので役に立たない
|
||
this.id = EntityId.DEFAULT
|
||
|
||
sv = src.notEmptyOrThrow("acct")
|
||
val tmpHost = (findHostFromUrl(sv, null, url)
|
||
?: throw RuntimeException("can't get host from acct or url")
|
||
).toLowerCase(Locale.JAPAN)
|
||
this.hostAscii = IDN.toASCII(tmpHost,IDN.ALLOW_UNASSIGNED)
|
||
val prettyHost=IDN.toUnicode(tmpHost,IDN.ALLOW_UNASSIGNED)
|
||
|
||
this.acctAscii = this.username + "@" + this.hostAscii
|
||
this.prettyAcct = this.username + "@" + prettyHost
|
||
|
||
this.followers_count = src.long("followers_count")
|
||
this.following_count = src.long("following_count")
|
||
this.statuses_count = src.long("statuses_count")
|
||
|
||
this.created_at = src.string("created_at")
|
||
this.time_created_at = TootStatus.parseTime(this.created_at)
|
||
|
||
this.avatar = src.string("avatar")
|
||
this.avatar_static = src.string("avatar_static")
|
||
this.header = src.string("header")
|
||
this.header_static = src.string("header_static")
|
||
}
|
||
|
||
ServiceType.MSP -> {
|
||
this.id = EntityId.mayDefault(src.string("id"))
|
||
|
||
// MSPはLTLの情報しか持ってないのでacctは常にホスト名部分を持たない
|
||
val tmpHost = (findHostFromUrl(null, null, url)
|
||
?: throw RuntimeException("can't get host from url")
|
||
).toLowerCase(Locale.JAPAN)
|
||
this.hostAscii = IDN.toASCII(tmpHost,IDN.ALLOW_UNASSIGNED)
|
||
val prettyHost = IDN.toUnicode(tmpHost,IDN.ALLOW_UNASSIGNED)
|
||
|
||
this.acctAscii = this.username + "@" + hostAscii
|
||
this.prettyAcct = this.username + "@" + prettyHost
|
||
|
||
this.followers_count = null
|
||
this.following_count = null
|
||
this.statuses_count = null
|
||
|
||
this.created_at = null
|
||
this.time_created_at = 0L
|
||
|
||
val avatar = src.string("avatar")
|
||
this.avatar = avatar
|
||
this.avatar_static = avatar
|
||
this.header = null
|
||
this.header_static = null
|
||
|
||
}
|
||
|
||
ServiceType.MISSKEY -> error("will not happen")
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
).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,
|
||
tv : TextView,
|
||
invalidator : NetworkEmojiInvalidator?,
|
||
fromProfileHeader : Boolean = false
|
||
) : SpannableStringBuilder? {
|
||
val pref = App1.pref
|
||
val context = tv.context
|
||
|
||
var sb : SpannableStringBuilder? = null
|
||
fun prepareSb() = sb?.apply { append('\n') } ?: SpannableStringBuilder().also { sb = it }
|
||
val delm = ": "
|
||
|
||
if(Pref.bpDirectoryLastActive(pref) && last_status_at > 0L)
|
||
prepareSb()
|
||
.append(context.getString(R.string.last_active))
|
||
.append(delm)
|
||
.append(TootStatus.formatTime(context, last_status_at, true))
|
||
|
||
if(! fromProfileHeader) {
|
||
if(Pref.bpDirectoryTootCount(pref)
|
||
&& (statuses_count ?: 0L) > 0L)
|
||
prepareSb()
|
||
.append(context.getString(R.string.toot_count))
|
||
.append(delm)
|
||
.append(statuses_count.toString())
|
||
|
||
if(Pref.bpDirectoryFollowers(pref)
|
||
&& !Pref.bpHideFollowCount(pref)
|
||
&& (followers_count ?: 0L) > 0L)
|
||
prepareSb()
|
||
.append(context.getString(R.string.followers))
|
||
.append(delm)
|
||
.append(followers_count.toString())
|
||
|
||
if(Pref.bpDirectoryNote(pref) && note?.isNotEmpty() == true) {
|
||
val decodedNote = DecodeOptions(
|
||
context,
|
||
accessInfo,
|
||
short = true,
|
||
decodeEmoji = true,
|
||
emojiMapProfile = profile_emojis,
|
||
emojiMapCustom = custom_emojis,
|
||
unwrapEmojiImageTag = true
|
||
).decodeHTML(note)
|
||
.replaceAllEx(reNoteLineFeed, " ")
|
||
.trimEx()
|
||
if(decodedNote.isNotBlank()) {
|
||
prepareSb().append(
|
||
if(decodedNote is SpannableStringBuilder && decodedNote.length > 200) {
|
||
decodedNote.replace(200, decodedNote.length, "…")
|
||
} else {
|
||
decodedNote
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
tv.vg(sb != null)
|
||
?.apply {
|
||
text = sb
|
||
movementMethod = MyLinkMovementMethod
|
||
invalidator?.register(sb)
|
||
}
|
||
?: invalidator?.clear()
|
||
|
||
return sb
|
||
}
|
||
|
||
companion object {
|
||
private val log = LogCategory("TootAccount")
|
||
|
||
internal val reWhitespace : Pattern = Pattern.compile("[\\s\\t\\x0d\\x0a]+")
|
||
|
||
// noteをディレクトリに表示する際、制御文字や空白を変換する
|
||
private val reNoteLineFeed : Pattern = Pattern.compile("""[\x00-\x20\x7f ]+""")
|
||
|
||
// MFMのメンション @username @username@host
|
||
// (Mastodonのカラムでは使われていない)
|
||
internal val reMention = Pattern.compile("""\A@(\w+(?:[\w-]*\w)?)(?:@(\w[\w.-]*\w))?""")
|
||
|
||
internal val reUrlHost : Pattern =
|
||
Pattern.compile("""\Ahttps://(\w[\w.-]*\w)/""")
|
||
|
||
// host, user ,(instance)
|
||
// Misskeyだけではないのでusernameの定義が違う
|
||
internal val reAccountUrl : Pattern =
|
||
Pattern.compile("""\Ahttps://(\w[\w.-]*\w)/@(\w+[\w-]*)(?:@(\w[\w.-]*\w))?(?=\z|[?#])""")
|
||
|
||
// host,user
|
||
internal val reAccountUrl2 : Pattern =
|
||
Pattern.compile("""\Ahttps://(\w[\w.-]*\w)/users/(\w|\w+[\w-]*\w)(?=\z|[?#])""")
|
||
|
||
fun getAcctFromUrl(url : String?) : String? {
|
||
|
||
url ?: return null
|
||
|
||
var m = reAccountUrl.matcher(url)
|
||
if(m.find()) {
|
||
val host = m.groupEx(1)
|
||
val user = m.groupEx(2)?.decodePercent()
|
||
val instance = m.groupEx(3)?.decodePercent()
|
||
return if(instance?.isNotEmpty() == true) {
|
||
"$user@$instance"
|
||
} else {
|
||
"$user@$host"
|
||
}
|
||
}
|
||
|
||
m = reAccountUrl2.matcher(url)
|
||
if(m.find()) {
|
||
val host = m.groupEx(1)
|
||
val user = m.groupEx(2)?.decodePercent()
|
||
|
||
return "$user@$host"
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
private fun parseSource(src : JsonObject?) : Source? {
|
||
src ?: return null
|
||
return try {
|
||
Source(src)
|
||
} catch(ex : Throwable) {
|
||
log.trace(ex)
|
||
log.e("parseSource failed.")
|
||
null
|
||
}
|
||
}
|
||
|
||
// Tootsearch用。URLやUriを使ってアカウントのインスタンス名を調べる
|
||
fun findHostFromUrl(acct : String?, accessHost : String?, url : String?) : String? {
|
||
|
||
// acctから調べる
|
||
if(acct != null) {
|
||
val pos = acct.indexOf('@')
|
||
if(pos != - 1) {
|
||
val host = acct.substring(pos + 1)
|
||
if(host.isNotEmpty()) return host.toLowerCase(Locale.JAPAN)
|
||
}
|
||
}
|
||
|
||
// accessHostから調べる
|
||
if(accessHost != null) {
|
||
return accessHost
|
||
}
|
||
|
||
// URLから調べる
|
||
// たぶんどんなURLでもauthorityの部分にホスト名が来るだろう(慢心)
|
||
url.mayUri()?.authority?.let { host ->
|
||
if(host.isNotEmpty()) {
|
||
return host.toLowerCase(Locale.JAPAN)
|
||
}
|
||
}
|
||
|
||
log.e("findHostFromUrl: can't parse host from URL $url")
|
||
return null
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
|
||
fun acctAndPrettyAcct(src : String) : Pair<String, String> {
|
||
val cols = src.split("@")
|
||
if(cols.size < 2 ) return Pair(src,src)
|
||
val username = cols[0]
|
||
return Pair(
|
||
"$username@${IDN.toASCII(cols[1], IDN.ALLOW_UNASSIGNED)}",
|
||
"$username@${IDN.toUnicode(cols[1], IDN.ALLOW_UNASSIGNED)}"
|
||
)
|
||
}
|
||
}
|
||
}
|