(WIP)(Mastodon 3.1.0)ホームカラムに告知を表示する

This commit is contained in:
tateisu 2020-01-27 14:45:16 +09:00
parent 7f4b93dc38
commit 99e2efb9bb
22 changed files with 1007 additions and 71 deletions

View File

@ -487,6 +487,18 @@ class Column(
internal var language_filter : JsonObject? = null
// 告知のリスト
internal var announcements : MutableList<TootAnnouncement>? = null
// 表示中の告知
internal var announcementId : EntityId? = null
// 告知を閉じた時刻, 0なら閉じていない
internal var announcementHideTime = 0L
// 告知データを更新したタイミング
internal var announcementUpdated = 0L
// プロフカラムでのアカウント情報
@Volatile
internal var who_account : TootAccountRef? = null
@ -2572,6 +2584,65 @@ class Column(
}
}
}
override fun onAnnouncementUpdate(item : TootAnnouncement) {
val list = announcements
if(list == null) {
announcements = mutableListOf(item)
}else{
val index = list.indexOfFirst{ it.id == item.id}
if( index != -1 ){
list[index] = TootAnnouncement.merge(list[index],item)
}else{
list.add(0, item)
}
announcements?.sortWith(TootAnnouncement.comparator)
}
announcementUpdated = SystemClock.elapsedRealtime()
fireShowColumnHeader()
}
override fun onAnnouncementDelete(id : EntityId) {
val it = announcements?.iterator() ?: return
while(it.hasNext()){
val item = it.next()
if( item.id == id){
it.remove()
announcementUpdated = SystemClock.elapsedRealtime()
fireShowColumnHeader()
return
}
}
}
override fun onAnnouncementReaction(reaction : TootAnnouncement.Reaction) {
// find announcement
val announcement_id = reaction.announcement_id ?: return
val announcement = announcements?.find { it.id == announcement_id } ?: return
// find reaction
val index = announcement.reactions?.indexOfFirst { it.name == reaction.name }
when {
reaction.count <= 0L -> {
if(index != null && index != - 1) announcement.reactions?.removeAt(index)
}
index == null -> {
announcement.reactions = ArrayList<TootAnnouncement.Reaction>().apply {
add(reaction)
}
}
index == - 1 -> announcement.reactions?.add(reaction)
else -> announcement.reactions?.get(index)?.let{ old ->
old.count = reaction.count
// ストリーミングイベントにはmeが含まれないので、oldにあるmeは変更されない
}
}
announcementUpdated = SystemClock.elapsedRealtime()
fireShowColumnHeader()
}
}
internal fun resumeStreaming(bPutGap : Boolean) {

View File

@ -43,9 +43,16 @@ abstract class ColumnTask(
val highlight_trie : WordTrieTree?
get() = column.highlight_trie
val isPseudo :Boolean
get() = access_info.isPseudo
val isMastodon : Boolean
get() = access_info.isMastodon
val isMisskey : Boolean
get() = access_info.isMisskey
val misskeyVersion : Int
get() = access_info.misskeyVersion

View File

@ -1039,5 +1039,31 @@ class ColumnTask_Loading(
// fallback to old api
return getStatusList(client, Column.PATH_DIRECT_MESSAGES)
}
internal fun getAnnouncements(client : TootApiClient) : TootApiResult? {
if( isMastodon && !isPseudo ){
column.announcements = null
column.announcementUpdated =1L
column.announcementId = null
client.publishApiProgress("loading announcements")
val (instance, _) = TootInstance.get(client)
if( instance?.versionGE(TootInstance.VERSION_3_1_0_rc1) == true){
val result = client.request("/api/v1/announcements")
?: return null // cancelled.
val code = result.response?.code ?: 0
if(code !in 400 until 500) {
val list = parseList(::TootAnnouncement,parser,result.jsonArray)
if(list.isNotEmpty()){
column.announcements = list
column.announcementUpdated = SystemClock.elapsedRealtime()
client.publishApiProgress("announcements loaded")
}
// other errors such as network or server fails will stop column loading.
return result
}
// just skip load announcements for 4xx error if server does not support announcements.
}
}
return TootApiResult()
}
}

View File

@ -398,7 +398,10 @@ enum class ColumnType(
iconId = { R.drawable.ic_home },
name1 = { it.getString(R.string.home) },
loading = { client -> getStatusList(client, column.makeHomeTlUrl()) },
loading = { client ->
getAnnouncements(client)
getStatusList(client, column.makeHomeTlUrl())
},
refresh = { client -> getStatusList(client, column.makeHomeTlUrl()) },
gap = { client -> getStatusList(client, column.makeHomeTlUrl()) },
bAllowPseudo = false

View File

@ -6,7 +6,9 @@ import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.graphics.Color
import android.os.AsyncTask
import android.os.SystemClock
import android.text.InputType
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.TextUtils
import android.view.*
@ -17,13 +19,26 @@ import android.widget.*
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.flexbox.FlexWrap
import com.google.android.flexbox.FlexboxLayout
import com.google.android.flexbox.JustifyContent
import com.omadahealth.github.swipyrefreshlayout.library.SwipyRefreshLayout
import com.omadahealth.github.swipyrefreshlayout.library.SwipyRefreshLayoutDirection
import jp.juggler.subwaytooter.action.Action_List
import jp.juggler.subwaytooter.action.Action_Notification
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.TootTask
import jp.juggler.subwaytooter.api.TootTaskRunner
import jp.juggler.subwaytooter.api.entity.TootAnnouncement
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.dialog.EmojiPicker
import jp.juggler.subwaytooter.span.NetworkEmojiSpan
import jp.juggler.subwaytooter.table.AcctColor
import jp.juggler.subwaytooter.util.*
import jp.juggler.subwaytooter.view.ListDivider
import jp.juggler.subwaytooter.view.MyLinkMovementMethod
import jp.juggler.subwaytooter.view.MyTextView
import jp.juggler.util.*
import org.jetbrains.anko.*
import java.io.Closeable
@ -137,6 +152,22 @@ class ColumnViewHolder(
private lateinit var etHashtagExtraAll : EditText
private lateinit var etHashtagExtraNone : EditText
private lateinit var llAnnouncementsBox : View
private lateinit var tvAnnouncementsIndex : TextView
private lateinit var btnAnnouncementsPrev : ImageButton
private lateinit var btnAnnouncementsNext : ImageButton
private lateinit var btnAnnouncementsShowHide : ImageButton
private lateinit var llAnnouncements : View
private lateinit var tvAnnouncementPeriod : TextView
private lateinit var tvAnnouncementContent : MyTextView
private lateinit var llAnnouncementExtra : LinearLayout
private val announcementContentInvalidator : NetworkEmojiInvalidator
private var lastAnnouncementShown = 0L
private val extra_invalidator_list = ArrayList<NetworkEmojiInvalidator>()
private val isPageDestroyed : Boolean
get() = column == null || activity.isFinishing
@ -291,8 +322,6 @@ class ColumnViewHolder(
handled
}
btnQuickFilterAll.setOnClickListener(this)
btnQuickFilterMention.setOnClickListener(this)
btnQuickFilterFavourite.setOnClickListener(this)
@ -301,7 +330,6 @@ class ColumnViewHolder(
btnQuickFilterReaction.setOnClickListener(this)
btnQuickFilterVote.setOnClickListener(this)
llColumnHeader.setOnClickListener(this)
btnColumnSetting.setOnClickListener(this)
btnColumnReload.setOnClickListener(this)
@ -317,6 +345,10 @@ class ColumnViewHolder(
llRefreshError.setOnClickListener(this)
btnAnnouncementsShowHide.setOnClickListener(this)
btnAnnouncementsPrev.setOnClickListener(this)
btnAnnouncementsNext.setOnClickListener(this)
cbDontCloseColumn.setOnCheckedChangeListener(this)
cbWithAttachment.setOnCheckedChangeListener(this)
@ -426,6 +458,10 @@ class ColumnViewHolder(
activity.handler.removeCallbacks(proc_start_filter)
activity.handler.postDelayed(proc_start_filter, 666L)
})
announcementContentInvalidator =
NetworkEmojiInvalidator(activity.handler, tvAnnouncementContent)
tvAnnouncementContent.movementMethod = MyLinkMovementMethod
}
private val proc_start_filter : Runnable = Runnable {
@ -611,7 +647,7 @@ class ColumnViewHolder(
btnDeleteNotification.vg(column.isNotificationColumn)
llSearch.vg(column.isSearchColumn)?.let{
llSearch.vg(column.isSearchColumn)?.let {
btnSearchClear.vg(Pref.bpShowSearchClear(activity.pref))
}
@ -652,6 +688,8 @@ class ColumnViewHolder(
column.addColumnViewHolder(this)
lastAnnouncementShown = - 1L
showColumnColor()
showContent(reason = "onPageCreate", reset = true)
@ -1060,8 +1098,30 @@ class ColumnViewHolder(
btnQuickFilterReaction -> clickQuickFilter(Column.QUICK_FILTER_REACTION)
btnQuickFilterVote -> clickQuickFilter(Column.QUICK_FILTER_VOTE)
btnAnnouncementsShowHide -> {
if(llAnnouncements.visibility == View.VISIBLE) {
column.announcementHideTime = System.currentTimeMillis()
} else {
column.announcementHideTime = 0L
}
activity.app_state.saveColumnList()
showAnnouncements()
}
btnAnnouncementsPrev -> {
column.announcementId =
TootAnnouncement.move(column.announcements, column.announcementId, - 1)
activity.app_state.saveColumnList()
showAnnouncements()
}
btnAnnouncementsNext -> {
column.announcementId =
TootAnnouncement.move(column.announcements, column.announcementId, + 1)
activity.app_state.saveColumnList()
showAnnouncements()
}
}
}
override fun onLongClick(v : View) : Boolean {
@ -1130,12 +1190,14 @@ class ColumnViewHolder(
showColumnCloseButton()
showAnnouncements(force = false)
}
// カラムヘッダなど、負荷が低い部分の表示更新
fun showColumnHeader() {
activity.handler.removeCallbacks(procShowColumnHeader)
activity.handler.postDelayed(procShowColumnHeader, 50L)
}
internal fun showContent(
@ -1198,6 +1260,7 @@ class ColumnViewHolder(
showRefreshError()
}
proc_restoreScrollPosition.run()
}
private var bRefreshErrorWillShown = false
@ -1887,6 +1950,121 @@ class ColumnViewHolder(
} // end of column setting scroll view
llAnnouncementsBox = verticalLayout {
lparams(matchParent, wrapContent) {
startMargin = dip(6)
endMargin = dip(6)
topMargin = dip(2)
bottomMargin = dip(2)
}
background = createRoundDrawable(
dip(6).toFloat(),
getAttributeColor(context, R.attr.colorThumbnailBackground)
)
var pad_tb = dip(2)
setPadding(0, pad_tb, 0, pad_tb)
linearLayout {
lparams(matchParent, wrapContent) {
startMargin = dip(6)
endMargin = dip(6)
}
gravity = Gravity.CENTER_VERTICAL
textView {
gravity = Gravity.END
text = context.getString(R.string.announcements)
}.lparams(0, wrapContent) {
weight = 1f
}
btnAnnouncementsPrev = imageButton {
background = ContextCompat.getDrawable(
context,
R.drawable.btn_bg_transparent
)
contentDescription = context.getString(R.string.previous)
imageResource = R.drawable.ic_arrow_start
}.lparams(dip(32), dip(32)) {
gravity = Gravity.END
marginStart = dip(4)
}
tvAnnouncementsIndex = textView {
}.lparams(wrapContent, wrapContent) {
marginStart = dip(4)
}
btnAnnouncementsNext = imageButton {
background = ContextCompat.getDrawable(
context,
R.drawable.btn_bg_transparent
)
contentDescription = context.getString(R.string.next)
imageResource = R.drawable.ic_arrow_end
}.lparams(dip(32), dip(32)) {
gravity = Gravity.END
marginStart = dip(4)
}
btnAnnouncementsShowHide = imageButton {
background = ContextCompat.getDrawable(
context,
R.drawable.btn_bg_transparent
)
contentDescription = context.getString(R.string.hide)
imageResource = R.drawable.ic_close
}.lparams(dip(32), dip(32)) {
gravity = Gravity.END
marginStart = dip(4)
}
}
llAnnouncements = maxHeightScrollView {
lparams(matchParent, wrapContent) {
topMargin = dip(1)
}
val pad_lr = dip(6)
pad_tb = dip(2)
setPadding(pad_lr, pad_tb, pad_lr, pad_tb)
scrollBarStyle = View.SCROLLBARS_OUTSIDE_OVERLAY
isScrollbarFadingEnabled = false
maxHeight = dip(240)
verticalLayout {
lparams(matchParent, wrapContent)
// 期間があれば表示する
tvAnnouncementPeriod = textView {
gravity = Gravity.END
}.lparams(matchParent, wrapContent) {
bottomMargin = dip(3)
}
tvAnnouncementContent = myTextView {
setLineSpacing(lineSpacingExtra, 1.1f)
// tools:text="Contents\nContents"
}.lparams(matchParent, wrapContent) {
topMargin = dip(3)
}
llAnnouncementExtra = verticalLayout {
lparams(matchParent, wrapContent) {
topMargin = dip(3)
}
}
}
}
}
llSearch = verticalLayout {
lparams(matchParent, wrapContent)
backgroundColor = getAttributeColor(context, R.attr.colorSearchFormBackground)
@ -2112,4 +2290,367 @@ class ColumnViewHolder(
b.report()
rv
}
private fun showAnnouncements(force : Boolean = true) {
val column = column ?: return
if(! force && lastAnnouncementShown >= column.announcementUpdated) {
return
}
lastAnnouncementShown = SystemClock.elapsedRealtime()
fun clearExtras() {
for(invalidator in extra_invalidator_list) {
invalidator.register(null)
}
extra_invalidator_list.clear()
}
llAnnouncementExtra.removeAllViews()
clearExtras()
val listShown = TootAnnouncement.filterShown(column.announcements)
if(llAnnouncementsBox.vg(listShown?.isNotEmpty() == true) == null) {
return
}
val content_color = column.getContentColor()
val item = listShown !!.find { it.id == column.announcementId }
?: listShown[0]
val itemIndex = listShown.indexOf(item)
val enablePaging = listShown.size > 1
val expand = column.announcementHideTime <= 0L
btnAnnouncementsPrev.vg(expand)?.run {
isEnabled = enablePaging
alpha = if(enablePaging) 1f else 0.3f
}
btnAnnouncementsNext.vg(expand)?.run {
isEnabled = enablePaging
alpha = if(enablePaging) 1f else 0.3f
}
tvAnnouncementsIndex.vg(expand)?.text =
activity.getString(R.string.announcements_index, itemIndex + 1, listShown.size)
llAnnouncements.vg(expand)
if(! expand) {
val newer = listShown.find { it.updated_at > column.announcementHideTime }
if(newer != null) {
column.announcementId = newer.id
setIconDrawableId(
activity,
btnAnnouncementsShowHide,
R.drawable.ic_error,
color = getAttributeColor(activity, R.attr.colorRegexFilterError),
alphaMultiplier = Styler.boost_alpha
)
} else {
setIconDrawableId(
activity,
btnAnnouncementsShowHide,
R.drawable.ic_arrow_drop_down,
color = content_color,
alphaMultiplier = Styler.boost_alpha
)
}
return
}
setIconDrawableId(
activity,
btnAnnouncementsShowHide,
R.drawable.ic_arrow_drop_up,
color = content_color,
alphaMultiplier = Styler.boost_alpha
)
var periods : StringBuilder? = null
fun String.appendPeriod() {
val sb = periods
if(sb == null) {
periods = StringBuilder(this)
} else {
sb.append("\n")
sb.append(this)
}
}
val (strStart, strEnd) = TootStatus.formatTimeRange(
item.starts_at,
item.ends_at,
item.all_day
)
when {
// no periods.
strStart == "" && strEnd == "" -> {
}
// single date
strStart == strEnd -> {
activity.getString(R.string.announcements_period1, strStart)
.appendPeriod()
}
else -> {
activity.getString(R.string.announcements_period2, strStart, strEnd)
.appendPeriod()
}
}
if(item.updated_at > item.published_at) {
val strUpdateAt = TootStatus.formatTime(activity, item.updated_at, false)
activity.getString(R.string.edited_at, strUpdateAt).appendPeriod()
}
val sb = periods
tvAnnouncementPeriod.vg(sb != null)?.text = sb
tvAnnouncementContent.text = item.decoded_content
announcementContentInvalidator.register(item.decoded_content)
// リアクションの表示
val density = activity.density
val buttonHeight = ActMain.boostButtonSize
val marginBetween = (buttonHeight.toFloat() * 0.2f + 0.5f).toInt()
val paddingH = (buttonHeight.toFloat() * 0.1f + 0.5f).toInt()
val paddingV = (buttonHeight.toFloat() * 0.1f + 0.5f).toInt()
val box = FlexboxLayout(activity).apply {
flexWrap = FlexWrap.WRAP
justifyContent = JustifyContent.FLEX_START
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
topMargin = (0.5f + density * 3f).toInt()
}
}
// +ボタン
run {
val b = ImageButton(activity)
val blp = FlexboxLayout.LayoutParams(
buttonHeight,
buttonHeight
)
blp.endMargin = marginBetween
b.layoutParams = blp
b.background = ContextCompat.getDrawable(
activity,
R.drawable.btn_bg_transparent
)
b.contentDescription = activity.getString(R.string.reaction_add)
b.scaleType = ImageView.ScaleType.FIT_CENTER
b.padding = paddingV
b.setOnClickListener {
addReaction(item, null)
}
setIconDrawableId(
activity,
b,
R.drawable.ic_add,
color = content_color,
alphaMultiplier = Styler.boost_alpha
)
box.addView(b)
}
val reactions = item.reactions?.filter { it.count > 0L }?.notEmpty()
if(reactions != null) {
var lastButton : View? = null
val options = DecodeOptions(
activity,
column.access_info,
decodeEmoji = true,
enlargeEmoji = 1.5f
)
val actMain = activity
val emojiAnimation = Pref.bpDisableEmojiAnimation(actMain.pref)
for(reaction in reactions) {
val url = if(emojiAnimation) {
reaction.url.notEmpty() ?: reaction.static_url.notEmpty()
} else {
reaction.static_url.notEmpty() ?: reaction.url.notEmpty()
}
val b = Button(activity).apply {
layoutParams = FlexboxLayout.LayoutParams(
FlexboxLayout.LayoutParams.WRAP_CONTENT,
buttonHeight
).apply {
endMargin = marginBetween
}
minWidthCompat = buttonHeight
allCaps = false
tag = reaction
background = ContextCompat.getDrawable(
actMain,
if(reaction.me == true) {
R.drawable.bg_button_cw
} else {
R.drawable.btn_bg_transparent
}
)
setTextColor(content_color)
setPadding(paddingH, paddingV, paddingH, paddingV)
text = if(url == null) {
EmojiDecoder.decodeEmoji(options, "${reaction.name} ${reaction.count}")
} else {
SpannableStringBuilder("${reaction.name} ${reaction.count}").also { sb ->
sb.setSpan(
NetworkEmojiSpan(url, scale = 1.5f),
0,
reaction.name.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
val invalidator =
NetworkEmojiInvalidator(actMain.handler, this)
invalidator.register(sb)
extra_invalidator_list.add(invalidator)
}
}
setOnClickListener {
if(reaction.me == true) {
removeReaction(item, reaction.name)
} else {
addReaction(item, TootAnnouncement.Reaction(jsonObject {
put("name", reaction.name)
put("count", 1)
put("me", true)
putNotNull("url", reaction.url)
putNotNull("static_url", reaction.static_url)
}))
}
}
}
box.addView(b)
lastButton = b
}
lastButton
?.layoutParams
?.cast<ViewGroup.MarginLayoutParams>()
?.endMargin = 0
}
llAnnouncementExtra.addView(box)
}
private fun addReaction(item : TootAnnouncement, sample : TootAnnouncement.Reaction?) {
val column = column ?: return
val host = column.access_info.host
val isMisskey = column.isMisskey
if(sample == null) {
EmojiPicker(activity, host, isMisskey) { name, _, _ ,unicode->
log.d("addReaction: $name")
addReaction(item, TootAnnouncement.Reaction(jsonObject {
put("name", unicode ?: name )
put("count", 1)
put("me", true)
// 以下はカスタム絵文字のみ
if(unicode == null){
val map = App1.custom_emoji_lister.getMap(host, isMisskey)
if(map == null) {
showToast(activity, false, "emoji map is null")
return@EmojiPicker
}
val ce = map[name]
if(ce == null) {
showToast(activity, false, "emoji '$name' not found.")
return@EmojiPicker
}
putNotNull("url", ce.url)
putNotNull("static_url", ce.static_url)
}
}))
}.show()
return
}
TootTaskRunner(activity).run(column.access_info, object : TootTask {
override fun background(client : TootApiClient) : TootApiResult? {
return client.request(
"/api/v1/announcements/${item.id}/reactions/${sample.name.encodePercent()}",
JsonObject().toPutRequestBuilder()
)
// 200 {}
}
override fun handleResult(result : TootApiResult?) {
result ?: return
if(result.jsonObject == null) {
showToast(activity, true, result.error)
} else {
val list = item.reactions
if(list == null) {
item.reactions = mutableListOf(sample)
} else {
val reaction = list.find { it.name == sample.name }
if(reaction == null) {
list.add(sample)
} else {
reaction.me = true
++reaction.count
}
}
column.announcementUpdated = SystemClock.elapsedRealtime()
showAnnouncements()
}
}
})
}
private fun removeReaction(item : TootAnnouncement, name : String) {
val column = column ?: return
TootTaskRunner(activity).run(column.access_info, object : TootTask {
override fun background(client : TootApiClient) : TootApiResult? {
return client.request(
"/api/v1/announcements/${item.id}/reactions/${name.encodePercent()}",
JsonObject().toDeleteRequestBuilder()
)
// 200 {}
}
override fun handleResult(result : TootApiResult?) {
result ?: return
if(result.jsonObject == null) {
showToast(activity, true, result.error)
} else {
val it = item.reactions?.iterator() ?: return
while(it.hasNext()) {
val reaction = it.next()
if(reaction.name == name) {
reaction.me = false
if(-- reaction.count <= 0) it.remove()
break
}
}
column.announcementUpdated = SystemClock.elapsedRealtime()
showAnnouncements()
}
}
})
}
}

View File

@ -3365,7 +3365,7 @@ internal class ItemViewHolder(
context,
R.drawable.btn_bg_transparent
)
contentDescription = "@string/hide"
contentDescription = context.getString(R.string.hide)
imageResource = R.drawable.ic_close
}.lparams(dip(32), dip(32)) {
gravity = Gravity.END

View File

@ -5,6 +5,7 @@ import android.content.SharedPreferences
import android.os.Handler
import jp.juggler.subwaytooter.api.*
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.entity.TootAnnouncement.Reaction
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.util.*
import okhttp3.Response
@ -24,11 +25,14 @@ internal class StreamReader(
) {
internal interface StreamCallback {
fun channelId() : String?
fun onTimelineItem(item : TimelineItem)
fun onListeningStateChanged(bListen : Boolean)
fun onNoteUpdated(ev : MisskeyNoteUpdate)
fun channelId() : String?
fun onAnnouncementUpdate( item: TootAnnouncement)
fun onAnnouncementDelete( id: EntityId)
fun onAnnouncementReaction(reaction : Reaction)
}
companion object {
@ -131,14 +135,12 @@ internal class StreamReader(
}
}
private fun fireTimelineItem(item : TimelineItem?, channelId : String? = null) {
item ?: return
private inline fun eachCallback(block:(callback:StreamCallback)->Unit){
synchronized(this) {
if(bDisposed.get()) return@synchronized
for(callback in callback_list) {
try {
if(channelId != null && channelId != callback.channelId()) continue
callback.onTimelineItem(item)
block(callback)
} catch(ex : Throwable) {
log.trace(ex)
}
@ -146,12 +148,21 @@ internal class StreamReader(
}
}
private fun fireTimelineItem(item : TimelineItem?, channelId : String? = null) {
item ?: return
eachCallback{ callback->
if(channelId != null && channelId != callback.channelId()) return@eachCallback
callback.onTimelineItem(item)
}
}
private fun fireDeleteId(id : EntityId) {
val tl_host = access_info.host
runOnMainLooper {
synchronized(this) {
if(bDisposed.get()) return@runOnMainLooper
if(Pref.bpDontRemoveDeletedToot(App1.getAppState(context).pref)) return@runOnMainLooper
if(bDisposed.get()) return@synchronized
if(Pref.bpDontRemoveDeletedToot(App1.getAppState(context).pref)) return@synchronized
for(column in App1.getAppState(context).column_list) {
try {
column.onStatusRemoved(tl_host, id)
@ -165,16 +176,9 @@ internal class StreamReader(
private fun fireNoteUpdated(ev : MisskeyNoteUpdate, channelId : String? = null) {
runOnMainLooper {
synchronized(this) {
if(bDisposed.get()) return@runOnMainLooper
for(callback in callback_list) {
try {
if(channelId != null && channelId != callback.channelId()) continue
callback.onNoteUpdated(ev)
} catch(ex : Throwable) {
log.trace(ex)
}
}
eachCallback { callback->
if(channelId != null && channelId != callback.channelId()) return@eachCallback
callback.onNoteUpdated(ev)
}
}
}
@ -252,6 +256,61 @@ internal class StreamReader(
}
private fun handleMastodonMessage(obj:JsonObject,text:String){
when(val event = obj.string("event")) {
null,"" -> log.d("onMessage: missing event parameter")
"filters_changed" ->
Column.onFiltersChanged(context, access_info)
else -> {
val payload = TootPayload.parsePayload(parser, event, obj, text)
when(event) {
"delete" -> when(payload) {
is Long -> fireDeleteId(EntityId(payload.toString()))
is String -> fireDeleteId(EntityId(payload.toString()))
else -> log.d("unsupported payload type. $payload")
}
// {"event":"announcement","payload":"{\"id\":\"3\",\"content\":\"<p>追加</p>\",\"starts_at\":null,\"ends_at\":null,\"all_day\":false,\"mentions\":[],\"tags\":[],\"emojis\":[],\"reactions\":[]}"}
"announcement"->{
if( payload is TootAnnouncement) {
runOnMainLooper {
eachCallback { it.onAnnouncementUpdate(payload) }
}
}
}
// {"event":"announcement.delete","payload":"2"}
"announcement.delete"->{
val id = EntityId.mayNull(payload?.toString())
if( id != null){
runOnMainLooper {
eachCallback { it.onAnnouncementDelete(id) }
}
}
}
// {"event":"announcement.reaction","payload":"{\"name\":\"hourglass_gif\",\"count\":1,\"url\":\"https://m2j.zzz.ac/...\",\"static_url\":\"https://m2j.zzz.ac/...\",\"announcement_id\":\"9\"}"}
"announcement.reaction"->{
if( payload is Reaction) {
runOnMainLooper {
eachCallback { it.onAnnouncementReaction(payload) }
}
}
}
else -> when(payload) {
is TimelineItem -> fireTimelineItem(payload)
else -> log.d("unsupported payload type. $payload")
}
}
}
}
}
/**
* Invoked when a text (type `0x1`) message has been received.
*/
@ -263,34 +322,8 @@ internal class StreamReader(
if(access_info.isMisskey) {
handleMisskeyMessage(obj)
} else {
val event = obj.string("event")
if(event == null || event.isEmpty()) {
log.d("onMessage: missing event parameter")
return
}
if(event == "filters_changed") {
Column.onFiltersChanged(context, access_info)
return
}
val payload = TootPayload.parsePayload(parser, event, obj, text)
when(event) {
"delete" -> when(payload) {
is Long -> fireDeleteId(EntityId(payload.toString()))
is String -> fireDeleteId(EntityId(payload.toString()))
else -> log.d("unsupported payload type. $payload")
}
else -> when(payload) {
is TimelineItem -> fireTimelineItem(payload)
else -> log.d("unsupported payload type. $payload")
}
}
handleMastodonMessage(obj,text)
}
} catch(ex : Throwable) {

View File

@ -0,0 +1,162 @@
package jp.juggler.subwaytooter.api.entity
import android.text.Spannable
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.util.JsonObject
import jp.juggler.util.LogCategory
import jp.juggler.util.notEmpty
import jp.juggler.util.notZero
import java.util.HashMap
import kotlin.Comparator
import kotlin.Int
import kotlin.String
class TootAnnouncement(parser : TootParser, src : JsonObject) {
class Reaction(val src : JsonObject) {
val name = src.string("name") ?: "?"
var count = src.long("count") ?: 0
var me = src.boolean("me") // ストリーミングイベントではmeは定義されない
// 以下はカスタム絵文字のみ
val url = src.string("url")
val static_url = src.string("static_url")
// ストリーミングイベントでは告知IDが含まれる
val announcement_id = EntityId.mayNull(src.string("announcement_id"))
}
// {"id":"1",
// "content":"\u003cp\u003e日本語\u003cbr /\u003eURL \u003ca href=\"https://www.youtube.com/watch?v=2n1fM2ItdL8\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e\u003cspan class=\"invisible\"\u003ehttps://www.\u003c/span\u003e\u003cspan class=\"ellipsis\"\u003eyoutube.com/watch?v=2n1fM2ItdL\u003c/span\u003e\u003cspan class=\"invisible\"\u003e8\u003c/span\u003e\u003c/a\u003e\u003cbr /\u003eカスタム絵文字 :ct013: \u003cbr /\u003e普通の絵文字 🤹 \u003c/p\u003e\u003cp\u003e改行2つ\u003c/p\u003e",
// "starts_at":"2020-01-23T00:00:00.000Z",
// "ends_at":"2020-01-28T23:59:00.000Z",
// "all_day":true,
// "mentions":[],
// "tags":[],
// "emojis":[{"shortcode":"ct013","url":"https://m2j.zzz.ac/custom_emojis/images/000/004/116/original/ct013.png","static_url":"https://m2j.zzz.ac/custom_emojis/images/000/004/116/static/ct013.png","visible_in_picker":true}],
// "reactions":[]}]
val id = EntityId.mayDefault(src.string("id"))
val starts_at = TootStatus.parseTime(src.string("starts_at"))
val ends_at = TootStatus.parseTime(src.string("ends_at"))
val all_day = src.boolean("all_day") ?: false
val published_at = TootStatus.parseTime(src.string("published_at"))
val updated_at = TootStatus.parseTime(src.string("updated_at"))
private val custom_emojis : HashMap<String, CustomEmoji>?
// Body of the status; this will contain HTML (remote HTML already sanitized)
val content : String
val decoded_content : Spannable
//An array of Tags
val tags : ArrayList<TootTag>?
// An array of Mentions
val mentions : ArrayList<TootMention>?
var reactions : MutableList<Reaction>? = null
init {
// 絵文字マップはすぐ後で使うので、最初の方で読んでおく
this.custom_emojis =
parseMapOrNull(CustomEmoji.decode, src.jsonArray("emojis"), log)
this.tags = parseListOrNull(::TootTag, src.jsonArray("tags"))
this.mentions = parseListOrNull(::TootMention, src.jsonArray("mentions"), log)
val options = DecodeOptions(
parser.context,
parser.linkHelper,
short = true,
decodeEmoji = true,
emojiMapCustom = custom_emojis,
// emojiMapProfile = profile_emojis,
// attachmentList = media_attachments,
highlightTrie = parser.highlightTrie,
mentions = mentions
)
this.content = src.string("content") ?: ""
this.decoded_content = options.decodeHTML(content)
this.reactions = parseListOrNull(::Reaction, src.jsonArray("reactions"))
}
companion object {
private val log = LogCategory("TootAnnouncement")
val comparator = Comparator<TootAnnouncement> { a, b ->
val at = a.starts_at.notZero() ?: a.published_at.notZero() ?: 0L
val bt = b.starts_at.notZero() ?: b.published_at.notZero() ?: 0L
if(at < bt) - 1 else if(at > bt) 1 else 0
}
// return null if list is empty
fun filterShown(src : List<TootAnnouncement>?) : List<TootAnnouncement>? {
val now = System.currentTimeMillis()
return src
?.filter {
when {
// 期間の大小が入れ替わってる場合はフィルタしない
it.starts_at > it.ends_at -> true
// まだ開始していない
it.starts_at > 0L && now < it.starts_at -> false
// 終了した後
it.ends_at > 0L && now > it.ends_at -> false
// フィルタしない
else -> true
}
}
?.notEmpty()
?.sortedWith(comparator)
}
// return previous/next item in announcement list.
fun move(src : List<TootAnnouncement>?, currentId : EntityId?, delta : Int) : EntityId? {
val listShown = filterShown(src)
?: return null
val size = listShown.size
if(size <= 0) return null
val idx = delta + when(val v = listShown.indexOfFirst { it.id == currentId }) {
- 1 -> 0
else -> v
}
return listShown[(idx + size) % size].id
}
// https://github.com/tootsuite/mastodon/blob/b9d74d407673a6dbdc87c3310618b22c85358c85/app/javascript/mastodon/reducers/announcements.js#L64
// reactionsのmeを残したまま他の項目を更新したい
fun merge(old : TootAnnouncement, dst : TootAnnouncement) : TootAnnouncement {
val oldReactions = old.reactions
val dstReactions = dst.reactions
if(dstReactions == null) {
dst.reactions = oldReactions
} else if(oldReactions != null) {
val reactions = mutableListOf<Reaction>()
reactions.addAll(oldReactions)
for(newItem in dstReactions) {
val oldItem = reactions.find { it.name == newItem.name }
if(oldItem == null) {
reactions.add(newItem)
} else {
oldItem.count = newItem.count
}
}
dst.reactions = reactions
}
return dst
}
}
}

View File

@ -180,6 +180,7 @@ class TootInstance(parser : TootParser, src : JsonObject) {
val VERSION_2_6_0 = VersionString("2.6.0")
val VERSION_2_7_0_rc1 = VersionString("2.7.0rc1")
val VERSION_3_0_0_rc1 = VersionString("3.0.0rc1")
val VERSION_3_1_0_rc1 = VersionString("3.1.0rc1")
val MISSKEY_VERSION_11 = VersionString("11.0")

View File

@ -1,6 +1,7 @@
package jp.juggler.subwaytooter.api.entity
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.TootAnnouncement.Reaction
import jp.juggler.util.*
import java.util.regex.Pattern
@ -61,10 +62,13 @@ object TootPayload {
"conversation" -> parseItem(::TootConversationSummary, parser, src)
// ここを通るケースはまだ確認できていない
"announcement" -> parseItem(::TootAnnouncement, parser, src)
"announcement.reaction" -> parseItem(::Reaction, src)
else -> {
log.e("unknown payload(2). message=%s", parent_text)
null
// ここを通るケースはまだ確認できていない
}
}
} else if(payload[0] == '[') {

View File

@ -22,6 +22,7 @@ import java.util.regex.Pattern
import kotlin.collections.ArrayList
import kotlin.collections.LinkedHashMap
import kotlin.math.abs
import kotlin.math.min
class FilterTrees(
val treeIrreversible : WordTrieTree = WordTrieTree(),
@ -995,6 +996,9 @@ class TootStatus(parser : TootParser, src : JsonObject) : TimelineItem() {
@SuppressLint("SimpleDateFormat")
internal val date_format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
@SuppressLint("SimpleDateFormat")
internal val date_format2 = SimpleDateFormat("yyyy-MM-dd")
fun formatTime(context : Context, t : Long, bAllowRelative : Boolean) : String {
if(bAllowRelative && Pref.bpRelativeTimestamp(App1.pref)) {
@ -1050,7 +1054,53 @@ class TootStatus(parser : TootParser, src : JsonObject) : TimelineItem() {
}
}
return date_format.format(Date(t))
return formatDate(t,date_format,omitZeroSecond = false)
}
// 告知の開始/終了日付
private fun formatDate(
t : Long,
format:SimpleDateFormat ,
omitZeroSecond:Boolean
) : String {
var dateTarget = format.format(Date(t))
// 秒の部分を省略する
if( omitZeroSecond && dateTarget.endsWith(":00")){
dateTarget = dateTarget.substring(0,dateTarget.length -3)
}
// 年の部分が現在と同じなら省略する
val dateNow = format.format(Date(t))
val delm = dateNow.indexOf('-')
if(delm!=-1 && dateNow.substring(0,delm+1) == dateTarget.substring(0,delm+1)){
dateTarget = dateTarget.substring(delm+1)
}
return dateTarget
}
fun formatTimeRange(start : Long, end : Long, allDay : Boolean):Pair<String,String>{
val strStart = when {
start <= 0L -> ""
allDay-> formatDate(start,date_format2,omitZeroSecond = false)
else -> formatDate(start, date_format,omitZeroSecond = true)
}
val strEnd = when {
end <= 0L -> ""
allDay-> formatDate(end,date_format2,omitZeroSecond = false)
else -> formatDate(end, date_format,omitZeroSecond = true)
}
// 終了日は先頭と同じ部分を省略する
var skip = 0
for(i in 0 until min(strStart.length,strEnd.length)){
val c =strStart[i]
if(c != strEnd[i] ) break
if( c.isDigit() ) continue
skip= i+1
if( c == ' ') break // 時間以降は省略しない
}
return Pair( strStart,strEnd.substring(skip,strEnd.length))
}
fun parseStringArray(src : JsonArray?) : ArrayList<String>? {

View File

@ -30,7 +30,12 @@ class EmojiPicker(
private val activity : Activity,
private val instance : String?,
@Suppress("CanBeParameter") private val isMisskey : Boolean,
private val onEmojiPicked : (name : String, instance : String?, bInstanceHasCustomEmoji : Boolean) -> Unit
private val onEmojiPicked : (
name : String,
instance : String?,
bInstanceHasCustomEmoji : Boolean,
unicode:String?
) -> Unit
// onEmojiPickedのinstance引数は通常の絵文字ならnull、カスタム絵文字なら非null、
) : View.OnClickListener, ViewPager.OnPageChangeListener {
@ -501,26 +506,28 @@ class EmojiPicker(
var name = item.name
if(item.instance != null && item.instance.isNotEmpty()) {
// カスタム絵文字
selected(name, item.instance)
selected(name, item.instance,null)
} else {
// 普通の絵文字
EmojiMap.sShortNameToEmojiInfo[name] ?: return
var ei = EmojiMap.sShortNameToEmojiInfo[name] ?: return
if(page.hasSkinTone) {
val sv = applySkinTone(name)
if(EmojiMap.sShortNameToEmojiInfo[sv] != null) {
val tmp = EmojiMap.sShortNameToEmojiInfo[sv]
if( tmp!=null){
ei = tmp
name = sv
}
}
selected(name, null)
selected(name, null,ei.unified)
}
}
}
}
// name はスキントーン適用済みであること
internal fun selected(name : String, instance : String?) {
internal fun selected(name : String, instance : String?,unicode:String?) {
dialog.dismissSafe()
@ -564,7 +571,7 @@ class EmojiPicker(
}
onEmojiPicked(name, instance, bInstanceHasCustomEmoji)
onEmojiPicked(name, instance, bInstanceHasCustomEmoji,unicode)
}
internal inner class EmojiPickerPagerAdapter : androidx.viewpager.widget.PagerAdapter() {

View File

@ -35,7 +35,7 @@ var View.endPadding : Int
}
// paddingStart,paddingEndにはsetterが提供されてない問題の対策
fun View.setPaddingStartEnd(start : Int, end : Int) {
fun View.setPaddingStartEnd(start : Int, end : Int =start) {
setPaddingRelative(start, paddingTop, end, paddingBottom)
}

View File

@ -1014,7 +1014,7 @@ class PostHelper(
}
private val open_picker_emoji : Runnable = Runnable {
EmojiPicker(activity, instance, isMisskey) { name, instance, bInstanceHasCustomEmoji ->
EmojiPicker(activity, instance, isMisskey) { name, instance, bInstanceHasCustomEmoji,_ ->
val et = this.et ?: return@EmojiPicker
val src = et.text ?: ""
@ -1042,7 +1042,7 @@ class PostHelper(
}
fun openEmojiPickerFromMore() {
EmojiPicker(activity, instance, isMisskey) { name, instance, bInstanceHasCustomEmoji ->
EmojiPicker(activity, instance, isMisskey) { name, instance, bInstanceHasCustomEmoji,_ ->
val et = this.et ?: return@EmojiPicker
val src = et.text ?: ""

View File

@ -23,8 +23,8 @@ fun RequestBody.toPost() : Request.Builder =
fun RequestBody.toPut() :Request.Builder =
Request.Builder().put(this)
// fun RequestBody.toDelete():Request.Builder =
// Request.Builder().delete(this)
fun RequestBody.toDelete():Request.Builder =
Request.Builder().delete(this)
fun RequestBody.toPatch() :Request.Builder =
Request.Builder().patch(this)
@ -34,3 +34,4 @@ fun RequestBody.toRequest(methodArg : String) :Request.Builder =
fun JsonObject.toPostRequestBuilder() : Request.Builder = toRequestBody( ).toPost()
fun JsonObject.toPutRequestBuilder() : Request.Builder = toRequestBody( ).toPut()
fun JsonObject.toDeleteRequestBuilder() : Request.Builder = toRequestBody( ).toDelete()

View File

@ -3,6 +3,7 @@ package jp.juggler.util
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.drawable.GradientDrawable
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
@ -86,3 +87,11 @@ var CompoundButton.isCheckedNoAnime : Boolean
isChecked = value
jumpDrawablesToCurrentState()
}
fun createRoundDrawable(radius:Float,fillColor:Int?=null, strokeColor:Int?=null, strokeWidth:Int = 4) =
GradientDrawable().apply{
setCornerRadius(radius)
if(fillColor!=null) setColor(fillColor)
if( strokeColor!=null) setStroke(strokeWidth,strokeColor)
}

View File

@ -1,6 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path

View File

@ -1,6 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
</vector>

View File

@ -83,7 +83,7 @@
android:contentDescription="@string/previous"
android:minWidth="48dp"
android:src="@drawable/ic_left"
android:src="@drawable/ic_arrow_start"
android:tint="?attr/colorVectorDrawable" />
<ImageButton
@ -92,7 +92,7 @@
android:layout_height="48dp"
android:contentDescription="@string/next"
android:minWidth="48dp"
android:src="@drawable/ic_right"
android:src="@drawable/ic_arrow_end"
android:tint="?attr/colorVectorDrawable" />
<ImageButton

View File

@ -1001,5 +1001,10 @@
<string name="push_notification_server_key_missing">サーバ側の設定ミスによりプッシュ通知は動作しません。サーバ公開鍵がありません。</string>
<string name="push_notification_server_key_empty">サーバ側の設定ミスによりプッシュ通知は動作しません。サーバ公開鍵がカラです。</string>
<string name="divide_notification">通知を分割する (Android 6+)</string>
<string name="announcements">告知</string>
<string name="announcements_index">%1$d/%2$d</string>
<string name="announcements_period1">イベント期間: %1$s</string>
<string name="announcements_period2">イベント期間: %1$s%2$s</string>
<string name="edited_at">更新: %1$s</string>
</resources>

View File

@ -996,4 +996,9 @@
<string name="push_notification_server_key_missing">Push notification will not work because server misconfiguration. Server public key is missing.</string>
<string name="push_notification_server_key_empty">Push notification will not work because server misconfiguration. Server public key is empty.</string>
<string name="divide_notification">Divide notifications (Android 6+)</string>
<string name="announcements">Announcements</string>
<string name="announcements_index">%1$d/%2$d</string>
<string name="announcements_period1">Event Period: %1$s</string>
<string name="announcements_period2">Event Period: %1$s…%2$s</string>
<string name="edited_at">Edited: %1$s</string>
</resources>