(Misskey)投稿をキャプチャしてリアクション,投票,削除のイベントを受け取る
This commit is contained in:
parent
ae27ae3d67
commit
c013daf99a
|
@ -1139,6 +1139,62 @@ class Column(
|
|||
|
||||
}
|
||||
|
||||
fun onNoteUpdated( ev: MisskeyNoteUpdate ) {
|
||||
runOnMainLooper {
|
||||
if(is_dispose.get() ) return@runOnMainLooper
|
||||
|
||||
val changeList = ArrayList<AdapterChange>()
|
||||
|
||||
// TODO userId が自分かどうか調べる
|
||||
val myId = EntityId.from(access_info.token_info,TootApiClient.KEY_USER_ID)
|
||||
val byMe = myId != null && myId == ev.userId
|
||||
|
||||
fun scanStatus1(s:TootStatus?,idx:Int,block:(s:TootStatus,byMe:Boolean)->Boolean){
|
||||
s ?: return
|
||||
if(s.id == ev.noteId){
|
||||
if( block(s,byMe) ){
|
||||
changeList.add(AdapterChange(AdapterChangeType.RangeChange, idx, 1))
|
||||
}
|
||||
}
|
||||
scanStatus1(s.reblog,idx,block)
|
||||
scanStatus1(s.reply,idx,block)
|
||||
}
|
||||
|
||||
fun scanStatusAll(block:(s:TootStatus,byMe:Boolean)->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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when(ev.type){
|
||||
MisskeyNoteUpdate.Type.REACTION ->{
|
||||
scanStatusAll{ s,byMe->
|
||||
s.increaseReaction(ev.reaction,byMe)
|
||||
}
|
||||
}
|
||||
MisskeyNoteUpdate.Type.VOTED ->{
|
||||
scanStatusAll{ s,byMe->
|
||||
s.enquete?.increaseVote(context,ev.choice,byMe) ?: false
|
||||
}
|
||||
}
|
||||
MisskeyNoteUpdate.Type.DELETED ->{
|
||||
scanStatusAll{ s,_->
|
||||
s.markDeleted(context,ev.deletedAt) ?: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if( changeList.isNotEmpty()){
|
||||
fireShowContent(reason="onNoteUpdated",changeList = changeList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNotifications() {
|
||||
cancelLastTask()
|
||||
|
||||
|
@ -3219,6 +3275,8 @@ class Column(
|
|||
|
||||
// 初期ロードの直後は先頭に移動する
|
||||
viewHolder?.scrollToTop()
|
||||
|
||||
updateMisskeyCapture()
|
||||
}
|
||||
}
|
||||
this.lastTask = task
|
||||
|
@ -4982,6 +5040,9 @@ class Column(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateMisskeyCapture()
|
||||
|
||||
} finally {
|
||||
fireShowColumnStatus()
|
||||
|
||||
|
@ -5009,6 +5070,7 @@ class Column(
|
|||
return
|
||||
}
|
||||
|
||||
@Suppress("UNNECESSARY_SAFE_CALL")
|
||||
viewHolder?.refreshLayout?.isRefreshing = true
|
||||
|
||||
bRefreshLoading = true
|
||||
|
@ -5904,6 +5966,8 @@ class Column(
|
|||
scroll_save.adapterIndex += added - 1
|
||||
}
|
||||
}
|
||||
|
||||
updateMisskeyCapture()
|
||||
} finally {
|
||||
fireShowColumnStatus()
|
||||
}
|
||||
|
@ -6160,7 +6224,15 @@ class Column(
|
|||
override fun onListeningStateChanged() {
|
||||
if(is_dispose.get()) return
|
||||
runOnMainLooper {
|
||||
if(! is_dispose.get()) fireShowColumnStatus()
|
||||
when{
|
||||
is_dispose.get() ->{
|
||||
|
||||
}
|
||||
else-> {
|
||||
fireShowColumnStatus()
|
||||
updateMisskeyCapture()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6245,13 +6317,16 @@ class Column(
|
|||
|
||||
stream_data_queue.clear()
|
||||
|
||||
app_state.stream_reader.register(access_info, stream_path, highlight_trie, streamCallback)
|
||||
streamReader =app_state.stream_reader.register(access_info, stream_path, highlight_trie, streamCallback)
|
||||
fireShowColumnStatus()
|
||||
}
|
||||
|
||||
private var streamReader : StreamReader.Reader? = null
|
||||
|
||||
// onPauseの時はまとめて止められるが
|
||||
// カラム破棄やリロード開始時は個別にストリーミングを止める必要がある
|
||||
internal fun stopStreaming() {
|
||||
streamReader = null
|
||||
val stream_path = streamPath
|
||||
if(stream_path != null) {
|
||||
app_state.stream_reader.unregister(access_info, stream_path, streamCallback)
|
||||
|
@ -6435,9 +6510,39 @@ class Column(
|
|||
scroll_save.adapterIndex += added
|
||||
}
|
||||
}
|
||||
|
||||
updateMisskeyCapture()
|
||||
}
|
||||
}
|
||||
|
||||
private fun min(a:Int,b:Int):Int = if( a<b) a else b
|
||||
|
||||
private fun updateMisskeyCapture(){
|
||||
if(!isMisskey) return
|
||||
streamReader?: return
|
||||
|
||||
val max = 40
|
||||
val list = ArrayList<EntityId>(max*2) // リブログなどで膨れる場合がある
|
||||
|
||||
fun add(s:TootStatus?){
|
||||
s?:return
|
||||
list.add( s.id )
|
||||
add( s.reblog)
|
||||
add( s.reply)
|
||||
}
|
||||
|
||||
for(i in 0 until min( max, list_data.size)){
|
||||
val o = list_data[i]
|
||||
if( o is TootStatus ){
|
||||
add(o)
|
||||
}else if( o is TootNotification){
|
||||
add(o.status)
|
||||
}
|
||||
}
|
||||
|
||||
if( list.isNotEmpty() ) streamReader?.capture(list)
|
||||
}
|
||||
|
||||
private fun replaceConversationSummary(
|
||||
changeList : ArrayList<AdapterChange>,
|
||||
list_new : ArrayList<TimelineItem>,
|
||||
|
@ -6673,6 +6778,8 @@ class Column(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// fun findListIndexByTimelineId(orderId : EntityId) : Int? {
|
||||
// list_data.forEachIndexed { i, v ->
|
||||
// if(v.getOrderId() == orderId) return i
|
||||
|
|
|
@ -4,14 +4,13 @@ import android.content.Context
|
|||
import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import jp.juggler.subwaytooter.api.*
|
||||
import jp.juggler.subwaytooter.api.entity.EntityIdLong
|
||||
import jp.juggler.subwaytooter.api.entity.TimelineItem
|
||||
import jp.juggler.subwaytooter.api.entity.TootPayload
|
||||
import jp.juggler.subwaytooter.api.entity.*
|
||||
import jp.juggler.subwaytooter.table.SavedAccount
|
||||
import jp.juggler.subwaytooter.util.*
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.json.JSONObject
|
||||
import java.net.ProtocolException
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
@ -40,7 +39,7 @@ internal class StreamReader(
|
|||
|
||||
private val reader_list = LinkedList<Reader>()
|
||||
|
||||
private inner class Reader(
|
||||
internal inner class Reader(
|
||||
internal val access_info : SavedAccount,
|
||||
internal val end_point : String,
|
||||
highlight_trie : WordTrieTree?
|
||||
|
@ -143,15 +142,30 @@ internal class StreamReader(
|
|||
}
|
||||
}
|
||||
|
||||
private fun fireDeleteId(id : Long) {
|
||||
private fun fireDeleteId(id : EntityId) {
|
||||
val tl_host = access_info.host
|
||||
val eid = EntityIdLong(id)
|
||||
runOnMainLooper {
|
||||
synchronized(this) {
|
||||
if(bDisposed.get()) return@runOnMainLooper
|
||||
for(column in App1.getAppState(context).column_list) {
|
||||
try {
|
||||
column.onStatusRemoved(tl_host, eid)
|
||||
column.onStatusRemoved(tl_host, id)
|
||||
} catch(ex : Throwable) {
|
||||
log.trace(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fireNoteUpdated(ev:MisskeyNoteUpdate) {
|
||||
runOnMainLooper {
|
||||
synchronized(this) {
|
||||
if(bDisposed.get()) return@runOnMainLooper
|
||||
val acct = access_info.acct
|
||||
for(column in App1.getAppState(context).column_list) {
|
||||
try {
|
||||
if( column.access_info.acct == acct) column.onNoteUpdated(ev)
|
||||
} catch(ex : Throwable) {
|
||||
log.trace(ex)
|
||||
}
|
||||
|
@ -186,7 +200,12 @@ internal class StreamReader(
|
|||
val body = obj.optJSONObject("body")
|
||||
fireTimelineItem(parser.status(body))
|
||||
}
|
||||
|
||||
|
||||
"noteUpdated" -> {
|
||||
val body = obj.optJSONObject("body")
|
||||
fireNoteUpdated( MisskeyNoteUpdate(body))
|
||||
}
|
||||
|
||||
"notification" -> {
|
||||
val body = obj.optJSONObject("body")
|
||||
fireTimelineItem(parser.notification(body))
|
||||
|
@ -217,7 +236,7 @@ internal class StreamReader(
|
|||
|
||||
"delete" -> {
|
||||
if(payload is Long) {
|
||||
fireDeleteId(payload)
|
||||
fireDeleteId(EntityId.mayDefault(payload))
|
||||
|
||||
} else {
|
||||
log.d("payload is not long. $payload")
|
||||
|
@ -311,6 +330,10 @@ internal class StreamReader(
|
|||
|
||||
socket.set(null)
|
||||
bListening.set(true)
|
||||
synchronized(capturedId){
|
||||
capturedId.clear()
|
||||
log.d("capture cleared")
|
||||
}
|
||||
fireListeningChanged()
|
||||
|
||||
TootTaskRunner(context).run(access_info, object : TootTask {
|
||||
|
@ -344,6 +367,34 @@ internal class StreamReader(
|
|||
})
|
||||
}
|
||||
|
||||
// Misskeyの投稿キャプチャ
|
||||
private val capturedId = HashSet<EntityId>()
|
||||
|
||||
fun capture(list : ArrayList<EntityId>) {
|
||||
val socket = socket.get()
|
||||
when{
|
||||
bDisposed.get() -> return
|
||||
socket == null -> return
|
||||
else->{
|
||||
for( id in list ){
|
||||
if(id.isDefault) continue
|
||||
synchronized(capturedId){
|
||||
if( capturedId.contains(id) ) return
|
||||
try {
|
||||
if(socket.send("""{"type":"subNote","body": {"id":"$id"}}""")) {
|
||||
capturedId.add(id)
|
||||
log.d("capture: $id")
|
||||
}else{
|
||||
log.w("capture: $id failed")
|
||||
}
|
||||
} catch(ex : Throwable) {
|
||||
log.d(ex.withCaption("fireAlive failed."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareReader(
|
||||
|
@ -372,14 +423,14 @@ internal class StreamReader(
|
|||
endPoint : String,
|
||||
highlightTrie : WordTrieTree?,
|
||||
streamCallback : StreamCallback
|
||||
) {
|
||||
) :Reader {
|
||||
|
||||
val reader = prepareReader(accessInfo, endPoint, highlightTrie)
|
||||
|
||||
reader.addCallback(streamCallback)
|
||||
|
||||
if(! reader.bListening.get()) {
|
||||
reader.startRead()
|
||||
}
|
||||
return reader
|
||||
}
|
||||
|
||||
// カラム破棄やリロードのタイミングで呼ばれる
|
||||
|
|
|
@ -3,6 +3,7 @@ package jp.juggler.subwaytooter.api
|
|||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import jp.juggler.subwaytooter.*
|
||||
import jp.juggler.subwaytooter.api.entity.EntityId
|
||||
import jp.juggler.subwaytooter.api.entity.TootAccount
|
||||
import jp.juggler.subwaytooter.api.entity.TootInstance
|
||||
import jp.juggler.subwaytooter.api.entity.TootStatus
|
||||
|
@ -65,6 +66,7 @@ class TootApiClient(
|
|||
const val KEY_IS_MISSKEY = "isMisskey"
|
||||
const val KEY_MISSKEY_APP_SECRET = "secret"
|
||||
const val KEY_API_KEY_MISSKEY = "apiKeyMisskey"
|
||||
const val KEY_USER_ID = "userId"
|
||||
|
||||
// // APIからsecretを得られないバグがあるので定数を渡す
|
||||
// const val appSecretError =
|
||||
|
@ -797,13 +799,15 @@ class TootApiClient(
|
|||
return result.setError("missing accessToken in the response.")
|
||||
}
|
||||
|
||||
val user = token_info.optJSONObject("user")
|
||||
?: result.setError("missing user in the response.")
|
||||
val user : JSONObject = token_info.optJSONObject("user")
|
||||
?: return result.setError("missing user in the response.")
|
||||
|
||||
token_info.remove("user")
|
||||
|
||||
val apiKey = "$access_token$appSecret".encodeUTF8().digestSHA256().encodeHexLower()
|
||||
|
||||
// ユーザ情報を読めたならtokenInfoを保存する
|
||||
EntityId.mayNull( user.parseString("id") )?.putTo(token_info,KEY_USER_ID)
|
||||
token_info.put(KEY_IS_MISSKEY, true)
|
||||
token_info.put(KEY_AUTH_VERSION, AUTH_VERSION)
|
||||
token_info.put(KEY_API_KEY_MISSKEY, apiKey)
|
||||
|
|
|
@ -45,14 +45,15 @@ abstract class EntityId : Comparable<EntityId> {
|
|||
return null
|
||||
}
|
||||
|
||||
fun from(intent : Intent, key : String) =
|
||||
intent.getStringExtra(key)?.decode()
|
||||
fun from(intent : Intent?, key : String) =
|
||||
intent?.getStringExtra(key)?.decode()
|
||||
|
||||
fun from(bundle : Bundle, key : String) =
|
||||
bundle.getString(key)?.decode()
|
||||
fun from(bundle : Bundle?, key : String) =
|
||||
bundle?.getString(key)?.decode()
|
||||
|
||||
fun from(data : JSONObject, key : String) : EntityId? {
|
||||
val o = data.opt(key)
|
||||
// 内部保存データのデコード用。APIレスポンスのパースに使ってはいけない
|
||||
fun from(data : JSONObject?, key : String) : EntityId? {
|
||||
val o = data?.opt(key)
|
||||
if(o is Long) return EntityIdLong(o)
|
||||
return (o as? String)?.decode()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
package jp.juggler.subwaytooter.api.entity
|
||||
|
||||
import jp.juggler.subwaytooter.util.parseInt
|
||||
import jp.juggler.subwaytooter.util.parseString
|
||||
import org.json.JSONObject
|
||||
|
||||
class MisskeyNoteUpdate(src:JSONObject){
|
||||
|
||||
enum class Type {
|
||||
REACTION,
|
||||
DELETED,
|
||||
VOTED
|
||||
}
|
||||
|
||||
|
||||
val noteId : EntityId
|
||||
val type: Type
|
||||
var reaction: String? = null
|
||||
var userId: EntityId? = null
|
||||
var deletedAt : Long? = null
|
||||
var choice : Int? = null
|
||||
|
||||
init {
|
||||
noteId = EntityId.mayNull(src.parseString("id")) ?: error("MisskeyNoteUpdate: missing note id")
|
||||
|
||||
val src2 = src.optJSONObject("body") ?: error("MisskeyNoteUpdate: missing body")
|
||||
|
||||
val strType = src.parseString("type")
|
||||
when(strType) {
|
||||
"reacted" -> {
|
||||
type = Type.REACTION
|
||||
reaction = src2.parseString("reaction")
|
||||
userId = EntityId.mayDefault(src2.parseString("userId"))
|
||||
}
|
||||
|
||||
"deleted" -> {
|
||||
type = Type.DELETED
|
||||
deletedAt = TootStatus.parseTime(src2.optString("deletedAt"))
|
||||
}
|
||||
|
||||
"pollVoted" -> {
|
||||
type = Type.VOTED
|
||||
choice = src2.parseInt("choice")
|
||||
userId = EntityId.mayDefault(src2.parseString("userId"))
|
||||
}
|
||||
|
||||
else -> error("MisskeyNoteUpdate: unknown type $strType")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -29,10 +29,10 @@ class NicoEnquete(
|
|||
val items : ArrayList<Choice>?
|
||||
|
||||
// 結果の数値 // null or array of number
|
||||
private val ratios : MutableList<Float>?
|
||||
private var ratios : MutableList<Float>?
|
||||
|
||||
// 結果の数値のテキスト // null or array of string
|
||||
private val ratios_text : MutableList<String>?
|
||||
private var ratios_text : MutableList<String>?
|
||||
|
||||
var myVoted : Int? = null
|
||||
|
||||
|
@ -249,4 +249,47 @@ class NicoEnquete(
|
|||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// misskey用
|
||||
fun increaseVote(context:Context,argChoice : Int?,isMyVoted :Boolean) : Boolean {
|
||||
argChoice ?: return false
|
||||
try {
|
||||
val item = this.items?.get(argChoice) ?: return false
|
||||
item.votes += 1
|
||||
if( isMyVoted) item.isVoted = true
|
||||
|
||||
// update ratios
|
||||
val votesList = ArrayList<Int>()
|
||||
var votesMax = 1
|
||||
items.forEachIndexed { index, choice ->
|
||||
if(choice.isVoted) this.myVoted = index
|
||||
val votes = choice.votes
|
||||
votesList.add(votes)
|
||||
if(votes > votesMax) votesMax = votes
|
||||
}
|
||||
|
||||
if(votesList.isNotEmpty()) {
|
||||
|
||||
this.ratios = votesList.asSequence()
|
||||
.map { (it.toFloat() / votesMax.toFloat()) }
|
||||
.toMutableList()
|
||||
|
||||
this.ratios_text = votesList.asSequence()
|
||||
.map { context.getString(R.string.vote_count_text, it) }
|
||||
.toMutableList()
|
||||
|
||||
} else {
|
||||
this.ratios = null
|
||||
this.ratios_text = null
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
}catch(ex:Throwable){
|
||||
log.e(ex,"increaseVote failed")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -85,12 +85,12 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() {
|
|||
private val language : String?
|
||||
|
||||
//If not empty, warning text that should be displayed before the actual content
|
||||
val spoiler_text : String?
|
||||
val decoded_spoiler_text : Spannable
|
||||
var spoiler_text : String?
|
||||
var decoded_spoiler_text : Spannable
|
||||
|
||||
// Body of the status; this will contain HTML (remote HTML already sanitized)
|
||||
val content : String?
|
||||
val decoded_content : Spannable
|
||||
var content : String?
|
||||
var decoded_content : Spannable
|
||||
|
||||
//Application from which the status was posted
|
||||
val application : TootApplication?
|
||||
|
@ -571,6 +571,42 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() {
|
|||
else-> false
|
||||
}
|
||||
|
||||
// return true if updated
|
||||
fun increaseReaction(reaction : String?,byMe:Boolean):Boolean {
|
||||
reaction?: return false
|
||||
MisskeyReaction.shortcodeMap[reaction] ?: return false
|
||||
var map = this.reactionCounts
|
||||
if(map==null) {
|
||||
map = HashMap()
|
||||
this.reactionCounts = map
|
||||
}
|
||||
val v = map[ reaction ]
|
||||
map[ reaction ] = if( v==null ){
|
||||
1
|
||||
}else{
|
||||
v+1
|
||||
}
|
||||
if( byMe) myReaction = reaction
|
||||
return true
|
||||
}
|
||||
|
||||
fun markDeleted(context : Context, deletedAt: Long? ) : Boolean? {
|
||||
|
||||
var sv = if(deletedAt != null) {
|
||||
context.getString(R.string.status_deleted_at, formatTime(context,deletedAt,false))
|
||||
}else{
|
||||
context.getString(R.string.status_deleted)
|
||||
}
|
||||
this.content = sv
|
||||
this.decoded_content = SpannableString(sv)
|
||||
|
||||
sv = ""
|
||||
this.spoiler_text = sv
|
||||
this.decoded_spoiler_text = SpannableString(sv)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
internal val log = LogCategory("TootStatus")
|
||||
|
|
|
@ -777,5 +777,7 @@
|
|||
<string name="use_old_api">古いAPIを使う</string>
|
||||
<string name="around_toot_limitation_warning">バージョン2.6.0より古いインスタンスでは「指定時刻の周辺のTL」機能に制限があります。指定時間から新しい側のTLを取得することができません。</string>
|
||||
<string name="card_description_length">プレビューカード説明文の最大文字数</string>
|
||||
<string name="status_deleted_at">この投稿は %1$s に削除されました</string>
|
||||
<string name="status_deleted">この投稿は削除されました</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -796,5 +796,7 @@
|
|||
<string name="use_old_api">Use old API</string>
|
||||
<string name="around_toot_limitation_warning">\"timeline around the specified time\" feature has limitation for the instance older than version 2.6.0. It is impossible to get a little newer timeline from the specified time.</string>
|
||||
<string name="card_description_length">Preview card description max character count</string>
|
||||
<string name="status_deleted_at">This note has been deleted at %1$s</string>
|
||||
<string name="status_deleted">This note has been deleted</string>
|
||||
|
||||
</resources>
|
||||
|
|
Loading…
Reference in New Issue