(Nightly fedibird)絵文字リアクション機能

This commit is contained in:
tateisu 2021-05-17 14:03:18 +09:00
parent f93d2c4a32
commit 97276495b8
19 changed files with 2117 additions and 1787 deletions

View File

@ -606,7 +606,7 @@ class ActAccountSetting : AsyncActivity(), View.OnClickListener,
cbConfirmUnfavourite.isEnabled = enabled
cbConfirmToot.isEnabled = enabled
val ti = TootInstance.getCached(a.apiHost.ascii)
val ti = TootInstance.getCached(a.apiHost)
if (ti == null) {
etMediaSizeMax.setText(a.image_max_megabytes ?: "")
etMovieSizeMax.setText(a.movie_max_megabytes ?: "")

View File

@ -1323,7 +1323,7 @@ class ActPost : AsyncActivity(),
else -> {
// インスタンス情報を確認する
val info = TootInstance.getCached(account.apiHost.ascii)
val info = TootInstance.getCached(account.apiHost)
if (info == null || info.isExpire) {
// 情報がないか古いなら再取得
@ -2222,7 +2222,7 @@ class ActPost : AsyncActivity(),
return
}
val instance = TootInstance.getCached(account.apiHost.ascii)
val instance = TootInstance.getCached(account.apiHost)
if (instance?.instanceType == InstanceType.Pixelfed) {
if (in_reply_to_id != null) {
showToast(true, R.string.pixelfed_does_not_allow_reply_with_media)
@ -2660,7 +2660,7 @@ class ActPost : AsyncActivity(),
}
private fun performVisibility() {
val ti = account?.let { TootInstance.getCached(it.apiHost.ascii) }
val ti = account?.let { TootInstance.getCached(it.apiHost) }
val list = when {
account?.isMisskey == true ->

View File

@ -578,20 +578,26 @@ class Column(
val streamCallback = object : StreamCallback {
override fun onStreamStatusChanged(status: StreamStatus) {
log.d("onStreamStatusChanged status=${status}, bFirstInitialized=$bFirstInitialized, bInitialLoading=$bInitialLoading, column=${access_info.acct}/${getColumnName(true)}")
log.d(
"onStreamStatusChanged status=${status}, bFirstInitialized=$bFirstInitialized, bInitialLoading=$bInitialLoading, column=${access_info.acct}/${
getColumnName(
true
)
}"
)
if (status == StreamStatus.Subscribed) {
updateMisskeyCapture()
}
runOnMainLooperForStreamingEvent {
if(is_dispose.get()) return@runOnMainLooperForStreamingEvent
if (is_dispose.get()) return@runOnMainLooperForStreamingEvent
fireShowColumnStatus()
}
}
override fun onTimelineItem(item: TimelineItem, channelId: String?, stream: JsonArray?) {
if(StreamManager.traceDelivery) log.v("${access_info.acct} onTimelineItem")
if (StreamManager.traceDelivery) log.v("${access_info.acct} onTimelineItem")
if (!canHandleStreamingMessage()) return
when (item) {
@ -628,6 +634,14 @@ class Column(
app_state.handler.post(mergeStreamingMessage)
}
override fun onEmojiReaction(item: TootNotification) {
// 自分によるリアクションは通知されない
// リアクション削除は通知されない
runOnMainLooperForStreamingEvent {
updateEmojiReaction(item.status)
}
}
override fun onNoteUpdated(ev: MisskeyNoteUpdate, channelId: String?) {
runOnMainLooperForStreamingEvent {
@ -668,13 +682,24 @@ class Column(
when (ev.type) {
MisskeyNoteUpdate.Type.REACTION -> {
scanStatusAll { s ->
s.increaseReaction(ev.reaction, byMe, ev.emoji, "onNoteUpdated ${ev.userId}")
s.increaseReaction(
true,
ev.reaction,
byMe,
ev.emoji,
"onNoteUpdated ${ev.userId}"
)
}
}
MisskeyNoteUpdate.Type.UNREACTION -> {
scanStatusAll { s ->
s.decreaseReaction(ev.reaction, byMe, "onNoteUpdated ${ev.userId}")
s.decreaseReaction(
true,
ev.reaction,
byMe,
"onNoteUpdated ${ev.userId}"
)
}
}
@ -736,7 +761,7 @@ class Column(
}
}
override fun onAnnouncementReaction(reaction: TootAnnouncement.Reaction) {
override fun onAnnouncementReaction(reaction: TootReaction) {
runOnMainLooperForStreamingEvent {
// find announcement
val announcement_id = reaction.announcement_id
@ -752,7 +777,7 @@ class Column(
}
index == null -> {
announcement.reactions = ArrayList<TootAnnouncement.Reaction>().apply {
announcement.reactions = ArrayList<TootReaction>().apply {
add(reaction)
}
}
@ -775,7 +800,7 @@ class Column(
val handler = app_state.handler
// 未初期化や初期ロード中ならキューをクリアして何もしない
if (!canHandleStreamingMessage() ) {
if (!canHandleStreamingMessage()) {
stream_data_queue.clear()
handler.removeCallbacks(this)
return
@ -1923,6 +1948,7 @@ class Column(
TootNotification.TYPE_MENTION,
TootNotification.TYPE_REPLY -> dont_show_reply
TootNotification.TYPE_EMOJI_REACTION,
TootNotification.TYPE_REACTION -> dont_show_reaction
TootNotification.TYPE_VOTE,
@ -1947,6 +1973,8 @@ class Column(
TootNotification.TYPE_MENTION,
TootNotification.TYPE_REPLY -> quick_filter != QUICK_FILTER_MENTION
TootNotification.TYPE_EMOJI_REACTION,
TootNotification.TYPE_REACTION -> quick_filter != QUICK_FILTER_REACTION
TootNotification.TYPE_VOTE,
@ -1956,7 +1984,8 @@ class Column(
TootNotification.TYPE_STATUS -> quick_filter != QUICK_FILTER_POST
else -> true
}
}) {
}
) {
log.d("isFiltered: ${item.type} notification filtered.")
return true
}
@ -1985,6 +2014,7 @@ class Column(
TootNotification.TYPE_RENOTE,
TootNotification.TYPE_QUOTE,
TootNotification.TYPE_FAVOURITE,
TootNotification.TYPE_EMOJI_REACTION,
TootNotification.TYPE_REACTION,
TootNotification.TYPE_FOLLOW,
TootNotification.TYPE_FOLLOW_REQUEST,
@ -2042,10 +2072,12 @@ class Column(
this.who_account = a
this.who_featured_tags = null
client.request("/api/v1/accounts/${profile_id}/featured_tags")?.also { result2 ->
client.request("/api/v1/accounts/${profile_id}/featured_tags")
?.also { result2 ->
this.who_featured_tags = TootTag.parseListOrNull(parser, result2.jsonArray)
}
this.who_featured_tags =
TootTag.parseListOrNull(parser, result2.jsonArray)
}
client.publishApiProgress("") // カラムヘッダの再表示
}
@ -2782,13 +2814,13 @@ class Column(
fun canStartStreaming() = when {
// 未初期化なら何もしない
!bFirstInitialized -> {
if(StreamManager.traceDelivery) log.v("canStartStreaming: column is not initialized.")
if (StreamManager.traceDelivery) log.v("canStartStreaming: column is not initialized.")
false
}
// 初期ロード中なら何もしない
bInitialLoading -> {
if(StreamManager.traceDelivery) log.v("canStartStreaming: is in initial loading.")
if (StreamManager.traceDelivery) log.v("canStartStreaming: is in initial loading.")
false
}
@ -3030,6 +3062,46 @@ class Column(
)
}
// Fedibird 絵文字リアクション機能
// APIの戻り値や通知データに新しいステータス情報が含まれるので、カラム中の該当する投稿のリアクション情報を更新する
fun updateEmojiReaction(newStatus: TootStatus?) {
newStatus ?: return
val statusId = newStatus.id
val newReactionSet = newStatus.reactionSet ?: TootReactionSet(isMisskey = false)
val changeList = ArrayList<AdapterChange>()
fun scanStatus1(s: TootStatus?, idx: Int, block: (s: TootStatus) -> Boolean) {
s ?: return
if (s.id == statusId) {
if (block(s)) {
changeList.add(AdapterChange(AdapterChangeType.RangeChange, idx, 1))
}
}
scanStatus1(s.reblog, idx, block)
scanStatus1(s.reply, idx, block)
}
fun scanStatusAll(block: (s: TootStatus) -> Boolean) {
for (i in 0 until list_data.size) {
val o = list_data[i]
if (o is TootStatus) {
scanStatus1(o, i, block)
} else if (o is TootNotification) {
scanStatus1(o.status, i, block)
}
}
}
scanStatusAll { s ->
s.updateReactionMastodon(newReactionSet)
true
}
if (changeList.isNotEmpty()) {
fireShowContent(reason = "onEmojiReaction", changeList = changeList)
}
}
// fun findListIndexByTimelineId(orderId : EntityId) : Int? {
// list_data.forEachIndexed { i, v ->
@ -3041,4 +3113,6 @@ class Column(
init {
registerColumnId(column_id, this)
}
}

View File

@ -35,6 +35,7 @@ import jp.juggler.subwaytooter.api.TootTask
import jp.juggler.subwaytooter.api.TootTaskRunner
import jp.juggler.subwaytooter.api.entity.CustomEmoji
import jp.juggler.subwaytooter.api.entity.TootAnnouncement
import jp.juggler.subwaytooter.api.entity.TootReaction
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.dialog.EmojiPicker
import jp.juggler.subwaytooter.span.NetworkEmojiSpan
@ -284,10 +285,10 @@ class ColumnViewHolder(
log.d("fling? not vertical view. $vx $vy")
} else {
val vydp = vy / density
val vyDp = vy / density
val limit = 1024f
log.d("fling? $vydp/$limit")
if (vydp >= limit) {
log.d("fling? $vyDp/$limit")
if (vyDp >= limit) {
val column = column
if (column != null && column.lastTask == null) {
@ -2714,7 +2715,7 @@ class ColumnViewHolder(
btn.allCaps = false
btn.tag = reaction
btn.background = if (reaction.me == true) {
btn.background = if (reaction.me) {
getAdaptiveRippleDrawableRound(
actMain,
actMain.attrColor(R.attr.colorButtonBgCw),
@ -2747,10 +2748,10 @@ class ColumnViewHolder(
}
btn.setOnClickListener {
if (reaction.me == true) {
if (reaction.me) {
removeReaction(item, reaction.name)
} else {
addReaction(item, TootAnnouncement.Reaction(jsonObject {
addReaction(item, TootReaction.parseFedibird(jsonObject {
put("name", reaction.name)
put("count", 1)
put("me", true)
@ -2774,7 +2775,7 @@ class ColumnViewHolder(
llAnnouncementExtra.addView(box)
}
private fun addReaction(item: TootAnnouncement, sample: TootAnnouncement.Reaction?) {
private fun addReaction(item: TootAnnouncement, sample: TootReaction?) {
val column = column ?: return
if (sample == null) {
EmojiPicker(activity, column.access_info, closeOnSelected = true) { result ->
@ -2785,7 +2786,7 @@ class ColumnViewHolder(
else -> error("unknown emoji type")
}
log.d("addReaction: $code ${result.emoji.javaClass.simpleName}")
addReaction(item, TootAnnouncement.Reaction(jsonObject {
addReaction(item, TootReaction.parseFedibird(jsonObject {
put("name", code)
put("count", 1)
put("me", true)

View File

@ -908,6 +908,7 @@ internal class ItemViewHolder(
}
}
TootNotification.TYPE_EMOJI_REACTION,
TootNotification.TYPE_REACTION -> {
val colorBg = Pref.ipEventBgColorReaction(activity.pref)
if (n_account != null) showBoost(
@ -915,7 +916,7 @@ internal class ItemViewHolder(
n.time_created_at,
R.drawable.ic_face,
R.string.display_name_reaction_by,
misskeyReaction = n.reaction ?: "?",
reaction = n.reaction ?: TootReaction.UNKNOWN,
boost_status = n_status
)
if (n_status != null) {
@ -1168,7 +1169,7 @@ internal class ItemViewHolder(
time: Long,
iconId: Int,
string_id: Int,
misskeyReaction: String? = null,
reaction: TootReaction? = null,
boost_status: TootStatus? = null
) {
boost_account = whoRef
@ -1184,7 +1185,7 @@ internal class ItemViewHolder(
val who = whoRef.get()
// フォローの場合 decoded_display_name が2箇所で表示に使われるのを避ける必要がある
val text: Spannable = if (misskeyReaction != null) {
val text: Spannable = if (reaction != null) {
val options = DecodeOptions(
activity,
access_info,
@ -1192,7 +1193,7 @@ internal class ItemViewHolder(
enlargeEmoji = 1.5f,
enlargeCustomEmoji = 1.5f
)
val ssb = MisskeyReaction.toSpannableStringBuilder(misskeyReaction, options, boost_status)
val ssb = reaction.toSpannableStringBuilder( options, boost_status)
ssb.append(" ")
ssb.append(who.decodeDisplayName(activity)
.intoStringResource(activity, string_id))
@ -2462,9 +2463,15 @@ internal class ItemViewHolder(
}
private fun makeReactionsView(status: TootStatus) {
if (!access_info.isMisskey) return
private fun canReaction() = when{
access_info.isPseudo -> false
access_info.isMisskey -> true
TootInstance.getCached(access_info.apiHost)?.fedibird_capabilities?.contains("emoji_reaction") == true -> true
else->false
}
private fun makeReactionsView(status: TootStatus) {
if( !canReaction() && status.reactionSet == null ) return
val density = activity.density
@ -2501,13 +2508,19 @@ internal class ItemViewHolder(
R.drawable.btn_bg_transparent_round6dp
)
val hasMyReaction = status.myReaction?.isNotEmpty() == true
val hasMyReaction = status.reactionSet?.myReaction?.isNotEmpty() ?: false
b.contentDescription =
activity.getString(if (hasMyReaction) R.string.reaction_remove else R.string.reaction_add)
b.scaleType = ImageView.ScaleType.FIT_CENTER
b.padding = paddingV
b.setOnClickListener {
if (hasMyReaction) {
if(!canReaction()){
Action_Toot.reactionFromAnotherAccount(
activity,
access_info,
status_showing
)
}else if (hasMyReaction) {
removeReaction(status, false)
} else {
addReaction(status, null)
@ -2532,8 +2545,8 @@ internal class ItemViewHolder(
)
})
val reactionCounts = status.reactionCounts
if (reactionCounts != null) {
val reactionSet = status.reactionSet
if (reactionSet != null) {
var lastButton: View? = null
@ -2545,12 +2558,12 @@ internal class ItemViewHolder(
enlargeCustomEmoji = 1.5f
)
for (entry in reactionCounts.entries) {
val key = entry.key
val count = entry.value
if (count <= 0) continue
val ssb = MisskeyReaction.toSpannableStringBuilder(key, options, status)
.also { it.append(" $count") }
for (entry in reactionSet.entries) {
val code = entry.key
val reaction = entry.value
if (reaction.count <= 0L) continue
val ssb = reaction.toSpannableStringBuilder( options, status)
.also { it.append(" ${reaction.count}") }
val b = Button(act).apply {
layoutParams = FlexboxLayout.LayoutParams(
@ -2561,7 +2574,7 @@ internal class ItemViewHolder(
}
minWidthCompat = buttonHeight
background = if (MisskeyReaction.equals(status.myReaction, key)) {
background = if (TootReaction.equals(reactionSet.myReaction, code)) {
// 自分がリアクションしたやつは背景を変える
getAdaptiveRippleDrawableRound(
act,
@ -2582,13 +2595,13 @@ internal class ItemViewHolder(
text = ssb
allCaps = false
tag = key
tag = code
setOnClickListener {
val code = it.tag as? String
if( MisskeyReaction.equals(status.myReaction, code)){
val tagStr = it.tag as? String
if( TootReaction.equals(status.reactionSet?.myReaction, tagStr)){
removeReaction(status, false)
}else{
addReaction(status,code)
addReaction(status,tagStr)
}
}
@ -2619,20 +2632,24 @@ internal class ItemViewHolder(
llExtra.addView(box)
}
private fun addReaction(status: TootStatus, code: String?) {
if (status.myReaction?.isNotEmpty() == true) {
// code は code@dmain のような形式かもしれない
private fun addReaction(status: TootStatus, code: String? ) {
if (status.reactionSet?.myReaction?.notEmpty() != null ) {
activity.showToast(false, R.string.already_reactioned)
return
}
if (access_info.isPseudo || !access_info.isMisskey) return
if(!canReaction()) return
if (code == null) {
EmojiPicker(activity, access_info, closeOnSelected = true) { result ->
addReaction(status, when(val emoji = result.emoji){
is UnicodeEmoji -> emoji.unifiedCode
is CustomEmoji -> ":${emoji.shortcode}:"
is CustomEmoji -> if(access_info.isMisskey) {
":${emoji.shortcode}:"
}else{
emoji.shortcode
}
else->error("unknown emoji type")
})
}.show()
@ -2642,15 +2659,23 @@ internal class ItemViewHolder(
TootTaskRunner(activity, progress_style = TootTaskRunner.PROGRESS_NONE).run(access_info,
object : TootTask {
var newStatus : TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? {
val params = access_info.putMisskeyApiToken().apply {
put("noteId", status.id.toString())
put("reaction", code)
}
// 成功すると204 no content
return client.request("/api/notes/reactions/create", params.toPostRequestBuilder())
return if(access_info.isMisskey){
client.request("/api/notes/reactions/create", access_info.putMisskeyApiToken().apply {
put("noteId", status.id.toString())
put("reaction", code)
}.toPostRequestBuilder())
// 成功すると204 no content
}else{
client.request("/api/v1/statuses/${status.id}/emoji_reactions/${code.encodePercent("@")}",
"".toFormRequestBody().toPut())
// 成功すると新しいステータス
?.also{ result->
newStatus = TootParser(activity,access_info).status(result.jsonObject)
}
}
}
override suspend fun handleResult(result: TootApiResult?) {
@ -2663,32 +2688,38 @@ internal class ItemViewHolder(
}
when (val resCode = result.response?.code) {
in 200 until 300 -> {
if (status.increaseReaction(code, true, caller="addReaction")) {
// 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する
list_adapter.notifyChange(reason = "addReaction complete", reset = true)
}
if(newStatus != null){
activity.app_state.columnList.forEach { column->
if( column.access_info.acct == access_info.acct)
column.updateEmojiReaction( newStatus)
}
}else{
if (status.increaseReaction(access_info.isMisskey,code, true, caller="addReaction")) {
// 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する
list_adapter.notifyChange(reason = "addReaction complete", reset = true)
}
}
}
else -> activity.showToast(false, "HTTP error $resCode")
}
}
})
}
private fun removeReaction(status: TootStatus, confirmed: Boolean = false) {
val reaction = status.myReaction
val code = status.reactionSet?.myReaction?.notEmpty()
if (reaction?.isNotEmpty() != true) {
if ( code ==null ) {
activity.showToast(false, R.string.not_reactioned)
return
}
if (access_info.isPseudo || !access_info.isMisskey) return
if(!canReaction()) return
if (!confirmed) {
AlertDialog.Builder(activity)
.setMessage(activity.getString(R.string.reaction_remove_confirm, reaction))
.setMessage(activity.getString(R.string.reaction_remove_confirm, code))
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
removeReaction(status, confirmed = true)
@ -2699,15 +2730,27 @@ internal class ItemViewHolder(
TootTaskRunner(activity, progress_style = TootTaskRunner.PROGRESS_NONE).run(access_info,
object : TootTask {
var newStatus: TootStatus? = null
override suspend fun background(client: TootApiClient): TootApiResult? =
// 成功すると204 no content
client.request(
"/api/notes/reactions/delete",
access_info.putMisskeyApiToken().apply {
put("noteId", status.id.toString())
}
.toPostRequestBuilder()
)
if(access_info.isMisskey) {
client.request(
"/api/notes/reactions/delete",
access_info.putMisskeyApiToken().apply {
put("noteId", status.id.toString())
}
.toPostRequestBuilder()
)
// 成功すると204 no content
}else{
client.request("/api/v1/statuses/${status.id}/emoji_unreaction",
"".toFormRequestBody().toPost())
// 成功すると新しいステータス
?.also{ result->
newStatus = TootParser(activity,access_info).status(result.jsonObject)
}
}
override suspend fun handleResult(result: TootApiResult?) {
result ?: return
@ -2719,13 +2762,20 @@ internal class ItemViewHolder(
}
if ((result.response?.code ?: -1) in 200 until 300) {
if (status.decreaseReaction(reaction, true, "removeReaction")) {
// 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する
list_adapter.notifyChange(
reason = "removeReaction complete",
reset = true
)
}
if(newStatus != null){
activity.app_state.columnList.forEach { column->
if( column.access_info.acct == access_info.acct)
column.updateEmojiReaction( newStatus)
}
}else{
if (status.decreaseReaction(access_info.isMisskey,code, true,"removeReaction")) {
// 1個だけ描画更新するのではなく、TLにある複数の要素をまとめて更新する
list_adapter.notifyChange(
reason = "removeReaction complete",
reset = true
)
}
}
}
}
})

View File

@ -192,7 +192,7 @@ internal class StatusButtons(
)
}
val ti = TootInstance.getCached(access_info.apiHost.ascii)
val ti = TootInstance.getCached(access_info.apiHost)
btnQuote.vg(ti?.feature_quote == true)?.let{
setButton(
btnQuote,

File diff suppressed because it is too large Load Diff

View File

@ -1,111 +0,0 @@
package jp.juggler.subwaytooter.api.entity
import android.text.Spannable
import android.text.SpannableStringBuilder
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.Pref
import jp.juggler.subwaytooter.span.NetworkEmojiSpan
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.EmojiDecoder
//private fun findSvgFile(utf16: String) =
// EmojiMap.sUTF16ToEmojiResource[utf16]
//
//fun EmojiMap.EmojiResource.loadToImageView(activity: ActMain, view: ImageView) {
// if (isSvg) {
// Glide.with(activity)
// .`as`(PictureDrawable::class.java)
// .load("file:///android_asset/${assetsName}")
// .into(view)
// } else {
// Glide.with(activity)
// .load(drawableId)
// .into(view)
// }
//}
object MisskeyReaction {
private val oldReactions = mapOf(
"like" to "\ud83d\udc4d",
"love" to "\u2665",
"laugh" to "\ud83d\ude06",
"hmm" to "\ud83e\udd14",
"surprise" to "\ud83d\ude2e",
"congrats" to "\ud83c\udf89",
"angry" to "\ud83d\udca2",
"confused" to "\ud83d\ude25",
"rip" to "\ud83d\ude07",
"pudding" to "\ud83c\udf6e",
"star" to "\u2B50", // リモートからのFavを示す代替リアクション。ピッカーには表示しない
)
private val reCustomEmoji = """\A:([^:]+):\z""".toRegex()
fun getAnotherExpression(reaction: String): String? {
val customCode = reCustomEmoji.find(reaction)?.groupValues?.elementAtOrNull(1) ?: return null
val cols = customCode.split("@")
val name = cols.elementAtOrNull(0)
val domain = cols.elementAtOrNull(1)
return if (domain == null) ":$name@.:" else if (domain == ".") ":$name:" else null
}
fun equals(a:String?,b:String?) = when {
a==null -> b==null
b==null -> false
else -> a ==b || getAnotherExpression(a) == b || a == getAnotherExpression(b)
}
fun toSpannableStringBuilder(
code: String,
options: DecodeOptions,
status:TootStatus?
): SpannableStringBuilder {
// 古い形式の絵文字はUnicode絵文字にする
oldReactions[code]?.let {
return EmojiDecoder.decodeEmoji(options, it)
}
fun CustomEmoji.toSpannableStringBuilder():SpannableStringBuilder?{
return if (Pref.bpDisableEmojiAnimation(App1.pref)) {
static_url
} else {
url
}?.let{
SpannableStringBuilder(code).apply {
setSpan(
NetworkEmojiSpan(it, scale = options.enlargeCustomEmoji),
0,
length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
// カスタム絵文字
val customCode = reCustomEmoji.find(code)?.groupValues?.elementAtOrNull(1)
if(customCode != null){
var ce = status?.custom_emojis?.get( customCode)
if(ce != null) return ce.toSpannableStringBuilder()?: EmojiDecoder.decodeEmoji(options, code)
val accessInfo = options.linkHelper as? SavedAccount
val cols = customCode.split("@",limit = 2)
val key = cols.elementAtOrNull(0)
val domain = cols.elementAtOrNull(1)
if( domain == null || domain=="" || domain=="." || domain == accessInfo?.apiHost?.ascii ){
if( accessInfo != null){
ce = App1.custom_emoji_lister
.getMap(accessInfo)
?.get(key)
if(ce != null) return ce.toSpannableStringBuilder()?: EmojiDecoder.decodeEmoji(options, code)
}
}
}
// unicode絵文字、もしくは :xxx: などのshortcode表現
return EmojiDecoder.decodeEmoji(options, code)
}
}

View File

@ -10,18 +10,7 @@ import java.util.*
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",
@ -51,7 +40,7 @@ class TootAnnouncement(parser : TootParser, src : JsonObject) {
// An array of Mentions
val mentions : ArrayList<TootMention>?
var reactions : MutableList<Reaction>? = null
var reactions : MutableList<TootReaction>? = null
init {
// 絵文字マップはすぐ後で使うので、最初の方で読んでおく
@ -79,7 +68,7 @@ class TootAnnouncement(parser : TootParser, src : JsonObject) {
this.content = src.string("content") ?: ""
this.decoded_content = options.decodeHTML(content)
this.reactions = parseListOrNull(::Reaction, src.jsonArray("reactions"))
this.reactions = parseListOrNull(TootReaction::parseFedibird, src.jsonArray("reactions"))
}
companion object {
@ -132,7 +121,7 @@ class TootAnnouncement(parser : TootParser, src : JsonObject) {
if(dstReactions == null) {
dst.reactions = oldReactions
} else if(oldReactions != null) {
val reactions = mutableListOf<Reaction>()
val reactions = mutableListOf<TootReaction>()
reactions.addAll(oldReactions)
for(newItem in dstReactions) {
val oldItem = reactions.find { it.name == newItem.name }

View File

@ -398,7 +398,8 @@ class TootInstance(parser: TootParser, src: JsonObject) {
// get from cache
// no request, no expiration check
fun getCached(host: String) = Host.parse(host).getCacheEntry().cacheData
fun getCached(apiHost: String) = Host.parse(apiHost).getCacheEntry().cacheData
fun getCached(apiHost: Host) = apiHost.getCacheEntry().cacheData
suspend fun get(client: TootApiClient): Pair<TootInstance?, TootApiResult?> = getEx(client)

View File

@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.api.entity
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.util.JsonObject
import jp.juggler.util.LogCategory
import jp.juggler.util.notEmpty
class TootNotification(parser : TootParser, src : JsonObject) : TimelineItem() {
@ -24,8 +25,9 @@ class TootNotification(parser : TootParser, src : JsonObject) : TimelineItem() {
const val TYPE_UNFOLLOW = "unfollow" // Mastodon,Misskey
const val TYPE_FAVOURITE = "favourite"
const val TYPE_REACTION = "reaction"
const val TYPE_REACTION = "reaction" // misskey
const val TYPE_EMOJI_REACTION = "emoji_reaction" // fedibird
const val TYPE_FOLLOW_REQUEST = "follow_request"
const val TYPE_FOLLOW_REQUEST_MISSKEY = "receiveFollowRequest"
@ -46,7 +48,7 @@ class TootNotification(parser : TootParser, src : JsonObject) : TimelineItem() {
val type : String // One of: "mention", "reblog", "favourite", "follow"
val accountRef : TootAccountRef? // The Account sending the notification to the user
val status : TootStatus? // The Status associated with the notification, if applicable
var reaction : String? = null
var reaction : TootReaction? = null
private val created_at : String? // The time the notification was created
val time_created_at : Long
@ -78,6 +80,8 @@ class TootNotification(parser : TootParser, src : JsonObject) : TimelineItem() {
)
reaction = src.string("reaction")
?.notEmpty()
?.let{ TootReaction.parseMisskey(it)}
// Misskeyの通知APIはページネーションをIDでしか行えない
// これは改善される予定 https://github.com/syuilo/misskey/issues/2275
@ -92,8 +96,10 @@ class TootNotification(parser : TootParser, src : JsonObject) : TimelineItem() {
accountRef =
TootAccountRef.mayNull(parser, parser.account(src.jsonObject("account")))
status = parser.status(src.jsonObject("status"))
reaction = src.jsonObject("emoji_reaction")
?.notEmpty()
?.let{ TootReaction.parseFedibird(it)}
}
}
}

View File

@ -1,7 +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.subwaytooter.api.entity.TootReaction
import jp.juggler.util.*
object TootPayload {
@ -62,7 +62,7 @@ object TootPayload {
"announcement" -> parseItem(::TootAnnouncement, parser, src)
"announcement.reaction" -> parseItem(::Reaction, src)
"announcement.reaction" -> parseItem(TootReaction::parseFedibird, src)
else -> {
log.e("unknown payload(2). message=%s", parent_text)

View File

@ -0,0 +1,204 @@
package jp.juggler.subwaytooter.api.entity
import android.text.Spannable
import android.text.SpannableStringBuilder
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.Pref
import jp.juggler.subwaytooter.span.NetworkEmojiSpan
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.EmojiDecoder
import jp.juggler.util.JsonArray
import jp.juggler.util.JsonObject
import jp.juggler.util.notEmpty
import jp.juggler.util.notZero
class TootReaction(
// (fedibird絵文字リアクション) unicode絵文字はunicodeそのまま、 カスタム絵文字はコロンなしのshortcode
// (Misskey)カスタム絵文字は前後にコロンがつく
val name: String,
// カスタム絵文字の場合は定義される
val url: String? = null,
val static_url: String? = null,
// (fedibird絵文字リアクション) 通知オブジェクト直下ではcountは常に0)
var count: Long = 0,
// (fedibird絵文字リアクション) 通知オブジェクト直下ではmeは常にfalse
// 告知のストリーミングイベントではmeは定義されない
var me: Boolean = false,
// 告知のストリーミングイベントでは告知IDが定義される
val announcement_id: EntityId? = null,
) {
companion object {
fun appendDomain(name: String, domain: String?) =
if (domain?.isNotEmpty() == true) {
"$name@$domain"
} else {
name
}
// Fedibirdの投稿や通知に含まれる
fun parseFedibird(src: JsonObject) = TootReaction(
// (fedibird絵文字リアクション) リモートのカスタム絵文字の場合はdomainが指定される
name = appendDomain(src.string("name") ?: "?", src.string("domain")),
url = src.string("url"),
static_url = src.string("static_url"),
count = src.long("count") ?: 0,
me = src.boolean("me") ?: false,
announcement_id = EntityId.mayNull(src.string("announcement_id")),
)
// Misskeyの通知にあるreaction文字列
fun parseMisskey(name: String?, count: Long = 0L) =
name?.let {
TootReaction(name = it, count = count)
}
private val misskeyOldReactions = mapOf(
"like" to "\ud83d\udc4d",
"love" to "\u2665",
"laugh" to "\ud83d\ude06",
"hmm" to "\ud83e\udd14",
"surprise" to "\ud83d\ude2e",
"congrats" to "\ud83c\udf89",
"angry" to "\ud83d\udca2",
"confused" to "\ud83d\ude25",
"rip" to "\ud83d\ude07",
"pudding" to "\ud83c\udf6e",
"star" to "\u2B50", // リモートからのFavを示す代替リアクション。ピッカーには表示しない
)
private val reCustomEmoji = """\A:([^:]+):\z""".toRegex()
fun getAnotherExpression(reaction: String): String? {
val customCode =
reCustomEmoji.find(reaction)?.groupValues?.elementAtOrNull(1) ?: return null
val cols = customCode.split("@")
val name = cols.elementAtOrNull(0)
val domain = cols.elementAtOrNull(1)
return if (domain == null) ":$name@.:" else if (domain == ".") ":$name:" else null
}
fun equals(a: String?, b: String?) = when {
a == null -> b == null
b == null -> false
else -> a == b || getAnotherExpression(a) == b || a == getAnotherExpression(b)
}
val UNKNOWN = TootReaction(name = "?")
}
fun toSpannableStringBuilder(
options: DecodeOptions,
status: TootStatus?
): SpannableStringBuilder {
val code = this.name
fun CustomEmoji.chooseUrl() =
if (Pref.bpDisableEmojiAnimation(App1.pref)) {
static_url
} else {
url
}
fun urlToSpan(options: DecodeOptions, code: String, url: String) =
SpannableStringBuilder(code).apply {
setSpan(
NetworkEmojiSpan(url, scale = options.enlargeCustomEmoji),
0,
length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
if (options.linkHelper?.isMisskey == true) {
// 古い形式の絵文字はUnicode絵文字にする
misskeyOldReactions[code]?.let {
return EmojiDecoder.decodeEmoji(options, it)
}
// カスタム絵文字
val customCode = reCustomEmoji.find(code)?.groupValues?.elementAtOrNull(1)
if (customCode != null) {
status?.custom_emojis?.get(customCode)
?.chooseUrl()
?.notEmpty()
?.let { return urlToSpan(options, code, it) }
val accessInfo = options.linkHelper as? SavedAccount
val cols = customCode.split("@", limit = 2)
val key = cols.elementAtOrNull(0)
val domain = cols.elementAtOrNull(1)
if (domain == null || domain == "" || domain == "." || domain == accessInfo?.apiHost?.ascii) {
if (accessInfo != null) {
App1.custom_emoji_lister
.getMap(accessInfo)
?.get(key)
?.chooseUrl()
?.notEmpty()
?.let { return urlToSpan(options, code, it) }
}
}
}
} else {
val url = if (Pref.bpDisableEmojiAnimation(App1.pref)) {
static_url
} else {
url
}
url?.notEmpty()?.let { return urlToSpan(options, code, url) }
}
// unicode絵文字、もしくは :xxx: などのshortcode表現
return EmojiDecoder.decodeEmoji(options, code)
}
}
class TootReactionSet(val isMisskey: Boolean) : LinkedHashMap<String, TootReaction>() {
var myReaction: String? = null
companion object {
fun parseMisskey(
src: JsonObject?,
myReaction: String? = null
): TootReactionSet? {
src ?: return null
val dst = TootReactionSet(isMisskey = true)
for (entry in src.entries) {
val key = entry.key.notEmpty() ?: continue
val v = src.long(key)?.notZero() ?: continue
dst[key] = TootReaction(name = key, count = v)
}
if (myReaction != null) {
dst[myReaction]?.let {
it.me = true
dst.myReaction = myReaction
}
}
return dst.notEmpty()
}
fun parseFedibird(
src: JsonArray? = null,
emoji_reactioned: Boolean? = null
): TootReactionSet? {
src ?: return null
val dst = TootReactionSet(isMisskey = false)
src.objectList().forEach {
val tr = TootReaction.parseFedibird(it)
if (tr.count > 0) dst[tr.name] = tr
}
dst.values.find { it.me }?.let {
dst.myReaction = it.name
}
return dst.notEmpty()
}
}
}

View File

@ -22,7 +22,7 @@ import jp.juggler.util.vg
class DlgCreateAccount(
val activity : AppCompatActivity,
val instance : Host,
val apiHost : Host,
val onClickOk : (
dialog : Dialog,
username : String,
@ -52,7 +52,7 @@ class DlgCreateAccount(
private val dialog = Dialog(activity)
init {
viewRoot.findViewById<TextView>(R.id.tvInstance).text = instance.pretty
viewRoot.findViewById<TextView>(R.id.tvInstance).text = apiHost.pretty
arrayOf(
R.id.btnRules,
@ -63,13 +63,13 @@ class DlgCreateAccount(
viewRoot.findViewById<Button>(it)?.setOnClickListener(this)
}
val instanceInfo = TootInstance.getCached(instance.ascii)
val instanceInfo = TootInstance.getCached(apiHost)
tvDescription.text =
DecodeOptions(
activity,
linkHelper = LinkHelper.create(
instance,
apiHost,
misskeyVersion = instanceInfo?.misskeyVersion ?: 0
)
).decodeHTML(
@ -97,10 +97,10 @@ class DlgCreateAccount(
override fun onClick(v : View?) {
when(v?.id) {
R.id.btnRules ->
activity.openCustomTab("https://$instance/about/more")
activity.openCustomTab("https://$apiHost/about/more")
R.id.btnTerms ->
activity.openCustomTab("https://$instance/terms")
activity.openCustomTab("https://$apiHost/terms")
R.id.btnCancel ->
dialog.cancel()

View File

@ -881,6 +881,7 @@ class TaskRunner(
TootNotification.TYPE_FAVOURITE ->
context.getString(R.string.display_name_favourited_by, name)
TootNotification.TYPE_EMOJI_REACTION,
TootNotification.TYPE_REACTION ->
context.getString(R.string.display_name_reaction_by, name)

View File

@ -1,17 +1,15 @@
package jp.juggler.subwaytooter.streaming
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.MisskeyNoteUpdate
import jp.juggler.subwaytooter.api.entity.TimelineItem
import jp.juggler.subwaytooter.api.entity.TootAnnouncement
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.util.JsonArray
interface StreamCallback {
fun onStreamStatusChanged(status: StreamStatus)
fun onTimelineItem(item: TimelineItem, channelId: String?,stream: JsonArray?)
fun onEmojiReaction(item:TootNotification)
fun onNoteUpdated(ev: MisskeyNoteUpdate, channelId: String?)
fun onAnnouncementUpdate(item: TootAnnouncement)
fun onAnnouncementDelete(id: EntityId)
fun onAnnouncementReaction(reaction: TootAnnouncement.Reaction)
fun onAnnouncementReaction(reaction: TootReaction)
}

View File

@ -24,10 +24,10 @@ class StreamConnection(
private val manager: StreamManager,
private val acctGroup: StreamGroupAcct,
val spec: StreamSpec? = null, // null if merged connection
val name :String
) : WebSocketListener() ,TootApiCallback {
val name: String
) : WebSocketListener(), TootApiCallback {
companion object{
companion object {
private val log = LogCategory("StreamConnection")
private const val misskeyAliveInterval = 60000L
@ -65,26 +65,36 @@ class StreamConnection(
// methods
private fun eachCallbackForSpec(
spec:StreamSpec,
spec: StreamSpec,
channelId: String? = null,
stream:JsonArray? = null,
item:TimelineItem?=null,
stream: JsonArray? = null,
item: TimelineItem? = null,
block: (callback: StreamCallback) -> Unit
) {
if (isDisposed.get()) return
acctGroup.keyGroups[spec]?.eachCallback(channelId,stream,item,block)
acctGroup.keyGroups[spec]?.eachCallback(channelId, stream, item, block)
}
private fun eachCallbackForAcct(
item: TimelineItem? = null,
block: (callback: StreamCallback) -> Unit
) {
if (isDisposed.get()) return
acctGroup.keyGroups.values.forEach {
it.eachCallback(null, null, item, block)
}
}
private fun eachCallback(
channelId: String? = null,
stream:JsonArray? = null,
item:TimelineItem?=null,
stream: JsonArray? = null,
item: TimelineItem? = null,
block: (callback: StreamCallback) -> Unit
) {
if(StreamManager.traceDelivery) log.v("$name eachCallback spec=${spec?.name}")
if (StreamManager.traceDelivery) log.v("$name eachCallback spec=${spec?.name}")
if (spec != null) {
eachCallbackForSpec(spec,channelId,stream,item,block)
}else {
eachCallbackForSpec(spec, channelId, stream, item, block)
} else {
if (isDisposed.get()) return
acctGroup.keyGroups.values.forEach { it.eachCallback(channelId, stream, item, block) }
}
@ -97,15 +107,26 @@ class StreamConnection(
socket.set(null)
}
fun getStreamStatus(streamSpec: StreamSpec) :StreamStatus = when {
fun getStreamStatus(streamSpec: StreamSpec): StreamStatus = when {
subscriptions[streamSpec] != null -> StreamStatus.Subscribed
else -> status
}
private fun fireTimelineItem(item: TimelineItem?, channelId: String? = null,stream:JsonArray?=null) {
item?:return
if(StreamManager.traceDelivery) log.v("$name fireTimelineItem")
eachCallback(channelId,stream,item=item) { it.onTimelineItem(item, channelId,stream) }
private fun fireTimelineItem(
item: TimelineItem?,
channelId: String? = null,
stream: JsonArray? = null
) {
item ?: return
if (StreamManager.traceDelivery) log.v("$name fireTimelineItem")
eachCallback(channelId, stream, item = item) { it.onTimelineItem(item, channelId, stream) }
}
// fedibird emoji reaction noti
private fun fireEmojiReaction(item: TootNotification) {
item ?: return
if (StreamManager.traceDelivery) log.v("$name fireTimelineItem")
eachCallbackForAcct(){ it.onEmojiReaction(item)}
}
private fun fireNoteUpdated(ev: MisskeyNoteUpdate, channelId: String? = null) {
@ -118,8 +139,8 @@ class StreamConnection(
manager.appState.columnList.forEach {
runOnMainLooper {
try {
if(!it.is_dispose.get()) it.onStatusRemoved(tl_host, id)
}catch(ex:Throwable) {
if (!it.is_dispose.get()) it.onStatusRemoved(tl_host, id)
} catch (ex: Throwable) {
log.trace(ex)
}
}
@ -173,7 +194,7 @@ class StreamConnection(
log.e("$name handleMisskeyMessage: noteUpdated body is null")
return
}
fireNoteUpdated(MisskeyNoteUpdate(acctGroup.account.apDomain,body), channelId)
fireNoteUpdated(MisskeyNoteUpdate(acctGroup.account.apDomain, body), channelId)
}
"notification" -> {
@ -229,13 +250,20 @@ class StreamConnection(
// {"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 TootAnnouncement.Reaction) {
if (payload is TootReaction) {
eachCallback { it.onAnnouncementReaction(payload) }
}
}
else -> when (payload) {
is TimelineItem -> fireTimelineItem(payload,stream=stream)
is TimelineItem -> {
if (payload is TootNotification && payload.type == TootNotification.TYPE_EMOJI_REACTION) {
fireEmojiReaction(payload)
}
fireTimelineItem(payload, stream = stream)
}
else -> log.d("$name unsupported payload type. $payload")
}
}
@ -256,7 +284,7 @@ class StreamConnection(
override fun onMessage(webSocket: WebSocket, text: String) {
manager.enqueue {
if(StreamManager.traceDelivery) log.v("$name WebSocket onMessage.")
if (StreamManager.traceDelivery) log.v("$name WebSocket onMessage.")
try {
val obj = text.decodeJsonObject()
when {
@ -287,7 +315,7 @@ class StreamConnection(
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
manager.enqueue {
if (t is SocketException && t.message?.contains("closed") ==true) {
if (t is SocketException && t.message?.contains("closed") == true) {
log.w("$name socket closed.")
} else {
log.e(t, "$name WebSocket onFailure.")
@ -319,10 +347,10 @@ class StreamConnection(
}
}
private fun unsubscribe(spec:StreamSpec) {
private fun unsubscribe(spec: StreamSpec) {
try {
subscriptions.remove(spec)
eachCallbackForSpec(spec) { it.onStreamStatusChanged(getStreamStatus(spec) ) }
eachCallbackForSpec(spec) { it.onStreamStatusChanged(getStreamStatus(spec)) }
val jsonObject = if (acctGroup.account.isMastodon) {
/*
@ -330,7 +358,7 @@ class StreamConnection(
{ "stream": "hashtag:local", "tag": "foo" }
等に後から "type": "unsubscribe" を足す
*/
spec.paramsClone().apply { put("type","unsubscribe") }
spec.paramsClone().apply { put("type", "unsubscribe") }
} else {
/*
Misskeyの場合
@ -357,7 +385,7 @@ class StreamConnection(
{ "stream": "hashtag:local", "tag": "foo" }
等に後から "type": "subscribe" を足す
*/
spec.paramsClone().apply { put("type","subscribe") }
spec.paramsClone().apply { put("type", "subscribe") }
} else {
/*
Misskeyの場合
@ -367,7 +395,7 @@ class StreamConnection(
*/
jsonObjectOf(
"type" to "connect",
"body" to spec.paramsClone().apply{ put("id",spec.channelId) }
"body" to spec.paramsClone().apply { put("id", spec.channelId) }
)
}
socket.get()?.send(jsonObject.toString())
@ -411,7 +439,7 @@ class StreamConnection(
return
}
val group = spec?.let{ acctGroup.keyGroups[it] }
val group = spec?.let { acctGroup.keyGroups[it] }
if (group != null) {
// 準備できたカラムがまったくないなら接続開始しない
if (!group.destinations.values.any { it.canStartStreaming() }) return
@ -475,7 +503,7 @@ class StreamConnection(
val socket = socket.get()
if (isDisposed.get() || socket == null) return
val type = when{
val type = when {
acctGroup.ti.versionGE(TootInstance.MISSKEY_VERSION_12_75_0) -> "sr"
else -> "subNote"
}

View File

@ -1105,7 +1105,8 @@ class SavedAccount(
TootNotification.TYPE_FOLLOW_REQUEST,
TootNotification.TYPE_FOLLOW_REQUEST_MISSKEY,
TootNotification.TYPE_FOLLOW_REQUEST_ACCEPTED_MISSKEY -> notification_follow_request
TootNotification.TYPE_EMOJI_REACTION,
TootNotification.TYPE_REACTION -> notification_reaction
TootNotification.TYPE_VOTE,