(Misskey)投稿をキャプチャしてリアクション,投票,削除のイベントを受け取る

This commit is contained in:
tateisu 2018-11-03 23:21:42 +09:00
parent ae27ae3d67
commit c013daf99a
9 changed files with 324 additions and 28 deletions

View File

@ -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

View File

@ -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
}
// カラム破棄やリロードのタイミングで呼ばれる

View File

@ -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)

View File

@ -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()
}

View File

@ -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")
}
}
}

View File

@ -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
}
}
}

View File

@ -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")

View File

@ -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>

View File

@ -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>