1
0
mirror of https://github.com/tateisu/SubwayTooter synced 2025-02-05 21:23:26 +01:00

日本語のハッシュタグの入力時にキーボードを切り替えるのが面倒なので投稿画面の#ボタンを常に表示して、押した先のメニュー項目で#だけを入力できるようにする。ついでにMisskeyでもハッシュタグトレンドをこのメニューに表示する。

This commit is contained in:
tateisu 2020-09-19 07:23:01 +09:00
parent 57fc721ec2
commit e47f96aa64
19 changed files with 117 additions and 95 deletions

View File

@ -18,13 +18,13 @@ class TestEntityUtils {
class TestEntity(val s : String, val l : Long) : Mappable<String> {
constructor(src : JsonObject) : this(
s = src.notEmptyOrThrow("s"),
s = src.stringOrThrow("s"),
l = src.long("l") ?: 0L
)
@Suppress("UNUSED_PARAMETER")
constructor(parser : TootParser, src : JsonObject) : this(
s = src.notEmptyOrThrow("s"),
s = src.stringOrThrow("s"),
l = src.long("l") ?: 0L
)
@ -254,16 +254,16 @@ class TestEntityUtils {
@Test(expected = RuntimeException::class)
fun testNotEmptyOrThrow4() {
println("""{"param1":null}""".decodeJsonObject().notEmptyOrThrow("param1"))
println("""{"param1":null}""".decodeJsonObject().stringOrThrow("param1"))
}
@Test(expected = RuntimeException::class)
fun testNotEmptyOrThrow5() {
println("""{"param1":""}""".decodeJsonObject().notEmptyOrThrow("param1"))
println("""{"param1":""}""".decodeJsonObject().stringOrThrow("param1"))
}
@Test
fun testNotEmptyOrThrow6() {
assertEquals("A", """{"param1":"A"}""".decodeJsonObject().notEmptyOrThrow("param1"))
assertEquals("A", """{"param1":"A"}""".decodeJsonObject().stringOrThrow("param1"))
}
}

View File

@ -1428,14 +1428,11 @@ class ActPost : AsyncActivity(),
val account = account
if(account == null || account.isPseudo) {
btnFeaturedTag.vg(false)
return
}
val cache = featuredTagCache[account.acct.ascii]
btnFeaturedTag.vg(cache?.list?.isNotEmpty() == true)
val now = SystemClock.elapsedRealtime()
if(cache != null && now - cache.time <= 300000L) return
@ -1448,12 +1445,18 @@ class ActPost : AsyncActivity(),
override fun background(client : TootApiClient) : TootApiResult? {
return if(account.isMisskey) {
featuredTagCache[account.acct.ascii] =
FeaturedTagCache(emptyList(), SystemClock.elapsedRealtime())
TootApiResult()
client.request(
"/api/hashtags/trend",
jsonObject { }
.toPostRequestBuilder()
)?.also { result ->
val list = TootTag.parseList( TootParser(this@ActPost,account), result.jsonArray)
featuredTagCache[account.acct.ascii] =
FeaturedTagCache(list, SystemClock.elapsedRealtime())
}
} else {
client.request("/api/v1/featured_tags")?.also { result ->
val list = parseList(::TootTag, result.jsonArray)
val list = TootTag.parseList( TootParser(this@ActPost,account), result.jsonArray)
featuredTagCache[account.acct.ascii] =
FeaturedTagCache(list, SystemClock.elapsedRealtime())
}

View File

@ -1502,9 +1502,10 @@ class Column(
val re = Pattern.compile(regex_text)
column_regex_filter =
{ text : CharSequence? ->
if(text?.isEmpty() != false) false else re.matcher(
text
).find()
if(text?.isEmpty() != false)
false
else
re.matcher(text).find()
}
} catch(ex : Throwable) {
log.trace(ex)
@ -1967,8 +1968,6 @@ class Column(
task.start()
}
private var bMinIdMatched : Boolean = false
internal fun parseRange(
result : TootApiResult?,
list : List<TimelineItem>?
@ -1978,30 +1977,29 @@ class Column(
if(isMisskey && list != null) {
// MisskeyはLinkヘッダがないので、常にデータからIDを読む
for(item in list) {
// injectされたデータをデータ範囲に追加しない
if( item .isInjected() ) continue
if(item.isInjected()) continue
val id = item.getOrderId()
if( id.notDefaultOrConfirming){
if(id.notDefaultOrConfirming) {
if(idMin == null || id < idMin) idMin = id
if(idMax == null || id > idMax) idMax = id
}
}
} else if(result != null) {
} else {
// Linkヘッダを読む
idMin = reMaxId.matcher(result?.link_older?:"").findOrNull()
?.let{
idMin = reMaxId.matcher(result?.link_older ?: "").findOrNull()
?.let {
EntityId(it.groupEx(1) !!)
}
idMax =reMinId.matcher(result.link_newer ?: "").findOrNull()
idMax = reMinId.matcher(result?.link_newer ?: "").findOrNull()
?.let {
bMinIdMatched = it.groupEx(1)=="min_id"
// min_idとsince_idの読み分けは現在利用してない it.groupEx(1)=="min_id"
EntityId(it.groupEx(2) !!)
}
}
return Pair(idMin, idMax)

View File

@ -46,7 +46,7 @@ class TootParser(
fun notification(src : JsonObject?) = parseItem(::TootNotification, this, src)
fun notificationList(src : JsonArray?) = parseList(::TootNotification, this, src)
fun tagList(array : JsonArray?) = parseList(::TootTag, array)
fun tagList(array : JsonArray?) = TootTag.parseList(this, array)
fun results(src : JsonObject?) = parseItem(::TootResults, this, src)
fun instance(src : JsonObject?) = parseItem(::TootInstance, this, src)

View File

@ -28,8 +28,8 @@ class CustomEmoji(
val decode : (JsonObject) -> CustomEmoji = { src ->
CustomEmoji(
shortcode = src.notEmptyOrThrow("shortcode"),
url = src.notEmptyOrThrow("url"),
shortcode = src.stringOrThrow("shortcode"),
url = src.stringOrThrow("url"),
static_url = src.string("static_url"),
visible_in_picker = src.optBoolean("visible_in_picker", true),
category =src.string("category")

View File

@ -44,7 +44,7 @@ class MisskeyAntenna(src : JsonObject) :TimelineItem(){
init{
timeCreatedAt = TootStatus.parseTime(src.string("createdAt"))
id = EntityId(src.notEmptyOrThrow("id"))
id = EntityId(src.stringOrThrow("id"))
name = src.string("name") ?: "?"
this.src = src.string("src") ?: "?"

View File

@ -10,8 +10,8 @@ class NicoProfileEmoji(
) : Mappable<String> {
constructor(src : JsonObject, shortcode : String? = null) : this(
url = src.notEmptyOrThrow("url"),
shortcode = shortcode ?: src.notEmptyOrThrow("shortcode"),
url = src.stringOrThrow("url"),
shortcode = shortcode ?: src.stringOrThrow("shortcode"),
account_url = src.string("account_url"),
account_id = EntityId.mayDefault(src.string("account_id"))
)

View File

@ -134,7 +134,7 @@ open class TootAccount(parser : TootParser, src : JsonObject) {
parseMapOrNull(CustomEmoji.decodeMisskey, src.jsonArray("emojis"))
this.profile_emojis = null
this.username = src.notEmptyOrThrow("username")
this.username = src.stringOrThrow("username")
this.apiHost = src.string("host")?.let { Host.parse(it) }
?: parser.apiHost
@ -229,7 +229,7 @@ open class TootAccount(parser : TootParser, src : JsonObject) {
// 疑似アカウントにacctとusernameだけ
this.url = src.string("url")
this.username = src.notEmptyOrThrow("username")
this.username = src.stringOrThrow("username")
//
val sv = src.string("display_name")
@ -262,7 +262,7 @@ open class TootAccount(parser : TootParser, src : JsonObject) {
this.id = EntityId.mayDefault(src.string("id"))
val tmpAcct = src.notEmptyOrThrow("acct")
val tmpAcct = src.stringOrThrow("acct")
val(apiHost,apDomain) = findHostFromUrl(tmpAcct, parser.linkHelper, url)
apiHost ?: error("can't get apiHost from acct or url")
@ -290,7 +290,7 @@ open class TootAccount(parser : TootParser, src : JsonObject) {
// tootsearch のアカウントのIDはどのタンス上のものか分からないので役に立たない
this.id = EntityId.DEFAULT
val tmpAcct = src.notEmptyOrThrow("acct")
val tmpAcct = src.stringOrThrow("acct")
val(apiHost,apDomain) = findHostFromUrl(tmpAcct, null, url)
apiHost ?: error("can't get apiHost from acct or url")

View File

@ -50,12 +50,11 @@ class TootAnnouncement(parser : TootParser, src : JsonObject) {
val decoded_content : Spannable
//An array of Tags
val tags : ArrayList<TootTag>?
val tags : List<TootTag>?
// An array of Mentions
val mentions : ArrayList<TootMention>?
var reactions : MutableList<Reaction>? = null
init {
@ -63,7 +62,7 @@ class TootAnnouncement(parser : TootParser, src : JsonObject) {
this.custom_emojis =
parseMapOrNull(CustomEmoji.decode, src.jsonArray("emojis"), log)
this.tags = parseListOrNull(::TootTag, src.jsonArray("tags"))
this.tags = TootTag.parseListOrNull(parser,src.jsonArray("tags"))
this.mentions = parseListOrNull(::TootMention, src.jsonArray("mentions"), log)

View File

@ -18,8 +18,8 @@ class TootMention(
constructor(src : JsonObject) : this(
id = EntityId.mayDefault(src.string("id")),
url = src.notEmptyOrThrow("url"),
acct = Acct.parse(src.notEmptyOrThrow("acct")),
username = src.notEmptyOrThrow("username")
url = src.stringOrThrow("url"),
acct = Acct.parse(src.stringOrThrow("acct")),
username = src.stringOrThrow("username")
)
}

View File

@ -60,7 +60,7 @@ class TootNotification(parser : TootParser, src : JsonObject) : TimelineItem() {
if(parser.serviceType == ServiceType.MISSKEY) {
id = EntityId.mayDefault(src.string("id"))
type = src.notEmptyOrThrow("type")
type = src.stringOrThrow("type")
created_at = src.string("createdAt")
time_created_at = TootStatus.parseTime(created_at)
@ -83,7 +83,7 @@ class TootNotification(parser : TootParser, src : JsonObject) : TimelineItem() {
} else {
id = EntityId.mayDefault(src.string("id"))
type = src.notEmptyOrThrow("type")
type = src.stringOrThrow("type")
created_at = src.string("created_at")
time_created_at = TootStatus.parseTime(created_at)

View File

@ -10,7 +10,7 @@ class TootResults private constructor(
// An array of matched Statuses
val statuses : ArrayList<TootStatus>,
// An array of matched hashtags
val hashtags : ArrayList<TootTag>
val hashtags : List<TootTag>
) {
var searchApiVersion = 0 // 0 means not from search API. such as trend tags.

View File

@ -138,7 +138,7 @@ class TootStatus(parser : TootParser, src : JsonObject) : TimelineItem() {
var mentions : ArrayList<TootMention>? = null
//An array of Tags
var tags : ArrayList<TootTag>? = null
var tags : List<TootTag>? = null
// public Spannable decoded_tags;
var decoded_mentions : Spannable = EMPTY_SPANNABLE
@ -513,7 +513,7 @@ class TootStatus(parser : TootParser, src : JsonObject) : TimelineItem() {
this.in_reply_to_account_id =
EntityId.mayNull(src.string("in_reply_to_account_id"))
this.mentions = parseListOrNull(::TootMention, src.jsonArray("mentions"), log)
this.tags = parseListOrNull(::TootTag, src.jsonArray("tags"))
this.tags = TootTag.parseListOrNull(parser,src.jsonArray("tags"))
this.application =
parseItem(::TootApplication, parser, src.jsonObject("application"), log)
this.pinned = parser.pinned || src.optBoolean("pinned")

View File

@ -1,5 +1,6 @@
package jp.juggler.subwaytooter.api.entity
import android.net.Uri
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.util.MisskeyMarkdownDecoder
import jp.juggler.util.*
@ -32,6 +33,7 @@ open class TootTag constructor(
}
class History(src : JsonObject) {
private val day : Long
val uses : Int
val accounts : Int
@ -47,17 +49,27 @@ open class TootTag constructor(
}
// for TREND_TAG column
constructor(src : JsonObject) : this(
name = src.notEmptyOrThrow("name"),
url = src.string("url"),
history = parseHistories(src.jsonArray("history"))
)
companion object {
val log = LogCategory("TootTag")
fun parse(parser : TootParser, src : JsonObject) =
if(parser.linkHelper.isMisskey) {
val name = src.stringOrThrow("tag")
val url = "https://${parser.apiHost}/tags/${Uri.encode(name)}"
TootTag(
name = name,
url = url,
history = null
)
} else {
TootTag(
name = src.stringOrThrow("name"),
url = src.string("url"),
history = parseHistories(src.jsonArray("history"))
)
}
private fun parseHistories(src : JsonArray?) : ArrayList<History>? {
src ?: return null
@ -72,45 +84,32 @@ open class TootTag constructor(
return dst
}
fun parseList(parser : TootParser, array : JsonArray?) : ArrayList<TootTag> {
val result = ArrayList<TootTag>()
if(array != null) {
if(parser.serviceType == ServiceType.MISSKEY) {
array.stringArrayList().forEach { sv ->
if(sv.isNotEmpty()) {
result.add(TootTag(name = sv))
}
}
} else {
array.forEach { item ->
val tag = try {
when(item) {
is String -> if(item.isNotEmpty()) {
TootTag(name = item)
} else {
null
}
is JsonObject -> TootTag(item)
else -> null
}
} catch(ex : Throwable) {
log.w(ex, "parseList: parse error")
null
}
if(tag != null) result.add(tag)
fun parseListOrNull(parser : TootParser, array : JsonArray?) =
array?.mapNotNull { src->
try {
when(src) {
null -> null
"" -> null
is String -> TootTag(name = src)
is JsonObject -> parse(parser, src)
else->null
}
}catch(ex:Throwable){
log.e(ex,"parseListOrNull failed.")
null
}
}
return result
}
}?.notEmpty()
fun parseList(parser : TootParser, array : JsonArray?) =
parseListOrNull(parser,array) ?: emptyList()
private const val w = TootAccount.reRubyWord
private const val a = TootAccount.reRubyAlpha
private const val s = "_\\u00B7\\u200c" // separators
private fun generateMastodonTagPattern():Pattern{
private fun generateMastodonTagPattern() : Pattern {
val reMastodonTagName = """([_$w][$s$w]*[$s$a][$s$w]*[_$w])|([_$w]*[$a][_$w]*)"""
return """(?:^|[^\w/)])#($reMastodonTagName)""".asciiPattern()
return """(?:^|[^\w/)])#($reMastodonTagName)""".asciiPattern()
}
private val reMastodonTag = generateMastodonTagPattern()
@ -157,12 +156,13 @@ open class TootTag constructor(
// https://mastodon.juggler.jp/tags/%E3%83%8F%E3%83%83%E3%82%B7%E3%83%A5%E3%82%BF%E3%82%B0
// あるサービスは /tags/... でなく /tag/... を使う
private val reUrlHashTag ="""\Ahttps://([^/]+)/tags?/([^?#・\s\-+.,:;/]+)(?:\z|[?#])"""
private val reUrlHashTag = """\Ahttps://([^/]+)/tags?/([^?#・\s\-+.,:;/]+)(?:\z|[?#])"""
.asciiPattern()
// https://pixelfed.tokyo/discover/tags/SubwayTooter?src=hash
private val reUrlHashTagPixelfed ="""\Ahttps://([^/]+)/discover/tags/([^?#・\s\-+.,:;/]+)(?:\z|[?#])"""
.asciiPattern()
private val reUrlHashTagPixelfed =
"""\Ahttps://([^/]+)/discover/tags/([^?#・\s\-+.,:;/]+)(?:\z|[?#])"""
.asciiPattern()
// returns null or pair of ( decoded tag without sharp, host)
fun String.findHashtagFromUrl() : Pair<String, String>? {

View File

@ -160,7 +160,7 @@ class HighlightWord {
constructor(src : JsonObject) {
this.id = src.long(COL_ID) ?: - 1L
this.name = src.notEmptyOrThrow(COL_NAME)
this.name = src.stringOrThrow(COL_NAME)
this.color_bg = src.optInt(COL_COLOR_BG)
this.color_fg = src.optInt(COL_COLOR_FG)
this.sound_type = src.optInt(COL_SOUND_TYPE)

View File

@ -1090,9 +1090,8 @@ class PostHelper(
}
fun openFeaturedTagList(list : List<TootTag>?) {
if(list?.isEmpty() != false) return
val ad = ActionsDialog()
for(tag in list) {
list?.forEach {tag->
ad.addAction("#${tag.name}") {
val et = this.et ?: return@addAction
@ -1113,6 +1112,27 @@ class PostHelper(
proc_text_changed.run()
}
}
ad.addAction( activity.getString(R.string.input_sharp_itself)){
val et = this.et ?: return@addAction
val src = et.text ?: ""
val src_length = src.length
val start = min(src_length, et.selectionStart)
val end = min(src_length, et.selectionEnd)
val sb = SpannableStringBuilder()
sb.append(src.subSequence(0, start))
if(! EmojiDecoder.canStartHashtag(sb, sb.length)) sb.append(' ')
sb.append('#')
val newSelection = sb.length
if(end < src_length) sb.append(src.subSequence(end, src_length))
et.text = sb
et.setSelection(newSelection)
proc_text_changed.run()
}
ad.show(activity, activity.getString(R.string.featured_hashtags))
}

View File

@ -270,7 +270,7 @@ class JsonObject : LinkedHashMap<String, Any?>() {
@Suppress("unused")
fun optDouble(name : String, defVal : Double = 0.0) = double(name) ?: defVal
fun notEmptyOrThrow(name : String) = notEmptyOrThrow(name, string(name))
fun stringOrThrow(name : String) = notEmptyOrThrow(name, string(name))
fun isNull(name : String) = this[name] == null
fun putNotNull(name : String, value : Any?) {
if(value != null) put(name, value)

View File

@ -1044,4 +1044,5 @@
<string name="gap_head">ギャップの始端</string>
<string name="gap_tail">ギャップの終端</string>
<string name="ignore_text_in_shared_media">外部アプリから共有されたメディアに付属する余計なテキストを追加しない</string>
<string name="input_sharp_itself">\'#\'だけを入力</string>
</resources>

View File

@ -1052,4 +1052,5 @@
<string name="gap_head">Head of gap</string>
<string name="gap_tail">Tail of gap</string>
<string name="ignore_text_in_shared_media">Don\'t append extra text that come with shared media from external apps</string>
<string name="input_sharp_itself">Input \'#\' itself</string>
</resources>