diff --git a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt index 6a42a35d..32f61f74 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ActPost.kt @@ -165,6 +165,11 @@ class ActPost : AppCompatActivity(), internal const val DRAFT_REPLY_URL = "reply_url" internal const val DRAFT_IS_ENQUETE = "is_enquete" internal const val DRAFT_POLL_TYPE = "poll_type" + internal const val DRAFT_POLL_MULTIPLE = "poll_multiple" + internal const val DRAFT_POLL_HIDE_TOTALS = "poll_hide_totals" + internal const val DRAFT_POLL_EXPIRE_DAY = "poll_expire_day" + internal const val DRAFT_POLL_EXPIRE_HOUR = "poll_expire_hour" + internal const val DRAFT_POLL_EXPIRE_MINUTE = "poll_expire_minute" internal const val DRAFT_ENQUETE_ITEMS = "enquete_items" internal const val DRAFT_QUOTED_RENOTE = "quotedRenote" @@ -718,11 +723,11 @@ class ActPost : AppCompatActivity(), val src_enquete = base_status.enquete val src_items = src_enquete?.items if(src_items != null) { - if(src_enquete.poll_type == NicoEnquete.PollType.FriendsNico && src_enquete.type != NicoEnquete.TYPE_ENQUETE) { + if(src_enquete.pollType == PollType.FriendsNico && src_enquete.type != NicoEnquete.TYPE_ENQUETE) { // フレニコAPIのアンケート結果は再編集の対象外 } else { spEnquete.setSelection( - if(src_enquete.poll_type == NicoEnquete.PollType.FriendsNico) { + if(src_enquete.pollType == PollType.FriendsNico) { 2 } else { 1 @@ -1957,7 +1962,7 @@ class ActPost : AppCompatActivity(), opener.open().use { inData -> val tmp = ByteArray(4096) while(true) { - val r = inData.read(tmp, 0, tmp.size) + val r = inData.read(tmp, 0, tmp.size) if(r <= 0) break sink.write(tmp, 0, r) } @@ -2236,7 +2241,7 @@ class ActPost : AppCompatActivity(), when(spEnquete.selectedItemPosition) { 1 -> { copyEnqueteText() - post_helper.poll_type = NicoEnquete.PollType.Mastodon + post_helper.poll_type = PollType.Mastodon post_helper.poll_expire_seconds = getExpireSeconds() post_helper.poll_hide_totals = cbHideTotals.isChecked post_helper.poll_multiple_choice = cbMultipleChoice.isChecked @@ -2244,7 +2249,7 @@ class ActPost : AppCompatActivity(), 2 -> { copyEnqueteText() - post_helper.poll_type = NicoEnquete.PollType.FriendsNico + post_helper.poll_type = PollType.FriendsNico } @@ -2388,11 +2393,17 @@ class ActPost : AppCompatActivity(), json.put(DRAFT_POLL_TYPE, spEnquete.selectedItemPosition.toPollTypeString()) - val array = JSONArray() - for(s in str_choice) { - array.put(s) - } - json.put(DRAFT_ENQUETE_ITEMS, array) + json.put(DRAFT_POLL_MULTIPLE, cbMultipleChoice.isChecked) + json.put(DRAFT_POLL_HIDE_TOTALS, cbHideTotals.isChecked ) + json.put(DRAFT_POLL_EXPIRE_DAY,etExpireDays.text.toString()) + json.put(DRAFT_POLL_EXPIRE_HOUR,etExpireHours.text.toString()) + json.put(DRAFT_POLL_EXPIRE_MINUTE,etExpireMinutes.text.toString()) + + json.put(DRAFT_ENQUETE_ITEMS, JSONArray().apply{ + for(s in str_choice) { + put(s) + } + }) PostDraft.save(System.currentTimeMillis(), json) @@ -2563,6 +2574,13 @@ class ActPost : AppCompatActivity(), spEnquete.setSelection( if(bv) 2 else 0) } + cbMultipleChoice.isChecked = draft.optBoolean(DRAFT_POLL_MULTIPLE) + cbHideTotals.isChecked = draft.optBoolean(DRAFT_POLL_HIDE_TOTALS) + etExpireDays.setText( draft.optString(DRAFT_POLL_EXPIRE_DAY,"1")) + etExpireHours.setText( draft.optString(DRAFT_POLL_EXPIRE_HOUR,"")) + etExpireMinutes.setText( draft.optString(DRAFT_POLL_EXPIRE_MINUTE,"")) + + val array = draft.optJSONArray(DRAFT_ENQUETE_ITEMS) if(array != null) { var src_index = 0 diff --git a/app/src/main/java/jp/juggler/subwaytooter/ItemViewHolder.kt b/app/src/main/java/jp/juggler/subwaytooter/ItemViewHolder.kt index f853dfc1..fe3b9140 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/ItemViewHolder.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/ItemViewHolder.kt @@ -6,16 +6,17 @@ import android.graphics.Typeface import android.os.SystemClock import androidx.core.content.ContextCompat import androidx.appcompat.app.AlertDialog -import androidx.recyclerview.widget.RecyclerView import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.TextUtils +import android.util.LayoutDirection import android.util.TypedValue import android.view.Gravity import android.view.View import android.view.ViewGroup import android.widget.* +import androidx.core.view.ViewCompat import com.google.android.flexbox.FlexWrap import com.google.android.flexbox.FlexboxLayout import com.google.android.flexbox.JustifyContent @@ -25,6 +26,7 @@ import jp.juggler.subwaytooter.api.* import jp.juggler.subwaytooter.api.entity.* import jp.juggler.subwaytooter.dialog.ActionsDialog import jp.juggler.subwaytooter.dialog.DlgConfirm +import jp.juggler.subwaytooter.drawable.PollPlotDrawable import jp.juggler.subwaytooter.drawable.PreviewCardBorder import jp.juggler.subwaytooter.span.MyClickableSpan import jp.juggler.subwaytooter.table.* @@ -32,6 +34,7 @@ import jp.juggler.subwaytooter.util.* import jp.juggler.subwaytooter.view.* import jp.juggler.util.* import org.jetbrains.anko.* +import org.json.JSONArray import org.json.JSONObject import kotlin.math.max @@ -707,13 +710,13 @@ internal class ItemViewHolder( val in_reply_to_id = item.in_reply_to_id val in_reply_to_account_id = item.in_reply_to_account_id when { - reply != null ->{ + reply != null -> { showReply( R.drawable.ic_reply, R.string.reply_to, reply ) - if( colorBgArg == 0) colorBg = Pref.ipEventBgColorMention(activity.pref) + if(colorBgArg == 0) colorBg = Pref.ipEventBgColorMention(activity.pref) } in_reply_to_id != null && in_reply_to_account_id != null -> { @@ -722,7 +725,7 @@ internal class ItemViewHolder( in_reply_to_account_id, item ) - if( colorBgArg == 0) colorBg = Pref.ipEventBgColorMention(activity.pref) + if(colorBgArg == 0) colorBg = Pref.ipEventBgColorMention(activity.pref) } } showStatus(item, colorBg) @@ -1190,20 +1193,16 @@ internal class ItemViewHolder( // ニコフレのアンケートの表示 val enquete = status.enquete if(enquete != null) { - if(access_info.isMisskey || NicoEnquete.TYPE_ENQUETE == enquete.type) { + if(enquete.pollType == PollType.FriendsNico && enquete.type != NicoEnquete.TYPE_ENQUETE) { + // フレニコの投票の結果表示は普通にテキストを表示するだけでよい + } else { + + // アンケートの本文を上書きする val question = enquete.decoded_question - val items = enquete.items - if(question.isNotBlank()) content = question - if(items != null) { - val now = System.currentTimeMillis() - var n = 0 - for(item in items) { - makeEnqueteChoiceView(enquete, now, n ++, item) - } - } - if(! access_info.isMisskey) makeEnqueteTimerView(enquete) + showEnqueteItems(status, enquete) + } } @@ -2455,59 +2454,176 @@ internal class ItemViewHolder( }) } + private fun showEnqueteItems(status : TootStatus, enquete : NicoEnquete) { + val items = enquete.items ?: return + + val now = System.currentTimeMillis() + + val canVote = when(enquete.pollType) { + PollType.Mastodon -> when { + enquete.expired -> false + now >= enquete.expired_at -> false + enquete.myVoted != null -> false + else -> true + } + + PollType.FriendsNico -> { + val remain = enquete.time_start + NicoEnquete.ENQUETE_EXPIRE - now + enquete.myVoted == null && remain > 0L + } + + PollType.Misskey -> enquete.myVoted == null + } + + items.forEachIndexed { index, choice -> + makeEnqueteChoiceView(status, enquete, canVote, index, choice) + } + + when(enquete.pollType) { + PollType.Mastodon -> makeEnqueteFooterMastodon(status, enquete, canVote) + + PollType.FriendsNico -> makeEnqueteFooterFriendsNico(enquete) + + PollType.Misskey -> { + } + } + } + private fun makeEnqueteChoiceView( + status : TootStatus, enquete : NicoEnquete, - now : Long, + canVote : Boolean, i : Int, item : NicoEnquete.Choice ) { - val canVote = if(access_info.isMisskey) { - enquete.myVoted == null - } else { - val remain = enquete.time_start + NicoEnquete.ENQUETE_EXPIRE - now - enquete.myVoted == null && remain > 0L + + val text = when(enquete.pollType) { + PollType.Misskey -> { + val sb = SpannableStringBuilder() + .append(item.decoded_text) + + if(enquete.myVoted != null) { + sb.append(" / ") + sb.append(activity.getString(R.string.vote_count_text, item.votes)) + if(i == enquete.myVoted) sb.append(' ').append(0x2713.toChar()) + } + sb + } + + PollType.FriendsNico -> { + item.decoded_text + } + + PollType.Mastodon -> if(canVote) { + item.decoded_text + } else { + val sb = SpannableStringBuilder() + .append(item.decoded_text) + if(! canVote) { + sb.append(" / ") + sb.append( + when(val v = item.votes) { + null -> activity.getString(R.string.vote_count_unavailable) + else -> activity.getString(R.string.vote_count_text, v) + } + ) + } + sb + } } + // 投票ボタンの表示 val lp = LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT - ) - if(i == 0) - lp.topMargin = (0.5f + activity.density * 3f).toInt() - val b = Button(activity) - b.layoutParams = lp - b.isAllCaps = false + ).apply { + if(i == 0) topMargin = (0.5f + activity.density * 3f).toInt() + } - val text = if(access_info.isMisskey) { - val sb = SpannableStringBuilder() - .append(item.decoded_text) - - if(enquete.myVoted != null) { - sb.append(" / ") - sb.append(activity.getString(R.string.vote_count_text, item.votes)) - if(i == enquete.myVoted) sb.append(' ').append(0x2713.toChar()) - } - sb - } else { - item.decoded_text - } - b.text = text - val invalidator = NetworkEmojiInvalidator(activity.handler, b) - extra_invalidator_list.add(invalidator) - invalidator.register(text) if(! canVote) { - b.isEnabled = false - } else { - val accessInfo = this@ItemViewHolder.access_info - b.setOnClickListener { view -> - val context = view.context ?: return@setOnClickListener - onClickEnqueteChoice(enquete, context, accessInfo, i) + + val b = TextView(activity) + b.layoutParams = lp + + b.text = text + val invalidator = NetworkEmojiInvalidator(activity.handler, b) + extra_invalidator_list.add(invalidator) + invalidator.register(text) + + b.padding = (activity.density * 3f + 0.5f).toInt() + + val ratio = when(enquete.pollType){ + PollType.Mastodon ->{ + val votesCount = enquete.votes_count ?:0 + val max = enquete.maxVotesCount ?:0 + if( max > 0 && votesCount > 0 ){ + (item.votes?:0).toFloat() / votesCount.toFloat() + }else{ + null + } + } + else->{ + val ratios = enquete.ratios + if( ratios !=null && i <= ratios.size ){ + ratios[i] + }else{ + null + } + } } + + if( ratio != null){ + b.backgroundDrawable = PollPlotDrawable( + color = (content_color and 0xFFFFFF) or 0x20000000, + ratio = ratio, + isRtl = b.layoutDirection == View.LAYOUT_DIRECTION_RTL, + startWidth = (activity.density * 2f + 0.5f).toInt() + ) + } + + llExtra.addView(b) + + } else if(enquete.multiple) { + // 複数選択なのでチェックボックス + val b = CheckBox(activity) + b.layoutParams = lp + b.isAllCaps = false + b.text = text + val invalidator = NetworkEmojiInvalidator(activity.handler, b) + extra_invalidator_list.add(invalidator) + invalidator.register(text) + if(! canVote) { + b.isEnabled = false + } else { + b.isChecked = item.checked + b.setOnCheckedChangeListener { _, checked -> + item.checked = checked + } + } + llExtra.addView(b) + + } else { + val b = Button(activity) + b.layoutParams = lp + b.isAllCaps = false + b.text = text + val invalidator = NetworkEmojiInvalidator(activity.handler, b) + extra_invalidator_list.add(invalidator) + invalidator.register(text) + if(! canVote) { + b.isEnabled = false + } else { + val accessInfo = this@ItemViewHolder.access_info + b.setOnClickListener { view -> + val context = view.context ?: return@setOnClickListener + onClickEnqueteChoice(status, enquete, context, accessInfo, i) + } + } + llExtra.addView(b) } - llExtra.addView(b) } - private fun makeEnqueteTimerView(enquete : NicoEnquete) { + private fun makeEnqueteFooterFriendsNico(enquete : NicoEnquete) { val density = activity.density val height = (0.5f + 6 * density).toInt() val view = EnqueteTimerView(activity) @@ -2517,43 +2633,133 @@ internal class ItemViewHolder( llExtra.addView(view) } + private fun makeEnqueteFooterMastodon( + status : TootStatus, + enquete : NicoEnquete, + canVote : Boolean + ) { + + val density = activity.density + + if(canVote && enquete.multiple) { + // 複数選択の投票ボタン + val lp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = (0.5f + density * 3f).toInt() + } + + val b = Button(activity) + b.layoutParams = lp + b.isAllCaps = false + b.text = activity.getString(R.string.vote_button) + val accessInfo = this@ItemViewHolder.access_info + b.setOnClickListener { view -> + val context = view.context ?: return@setOnClickListener + sendMultiple(status, enquete, context, accessInfo) + } + llExtra.addView(b) + } + + val tv = TextView(activity) + val lp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + lp.topMargin = (0.5f + 3 * density).toInt() + tv.layoutParams = lp + + val sb = StringBuilder() + + val votes_count = enquete.votes_count ?: 0 + when { + votes_count == 1 -> sb.append(activity.getString(R.string.vote_1)) + votes_count > 1 -> sb.append(activity.getString(R.string.vote_2, votes_count)) + } + + when(val t = enquete.expired_at) { + + Long.MAX_VALUE -> { + } + + else -> { + if(sb.isNotEmpty()) sb.append(" ") + sb.append( + activity.getString( + R.string.vote_expire_at, + TootStatus.formatTime(activity, t, false) + ) + ) + } + } + + tv.text = sb.toString() + + llExtra.addView(tv) + } + private fun onClickEnqueteChoice( + status : TootStatus, enquete : NicoEnquete, context : Context, accessInfo : SavedAccount, idx : Int ) { - val now = System.currentTimeMillis() if(enquete.myVoted != null) { showToast(context, false, R.string.already_voted) return } - if(! accessInfo.isMisskey) { - val remain = enquete.time_start + NicoEnquete.ENQUETE_EXPIRE - now - if(remain <= 0L) { - showToast(context, false, R.string.enquete_was_end) - return + + val now = System.currentTimeMillis() + + when(enquete.pollType) { + PollType.Misskey -> { + // Misskeyのアンケートには期限がない? + } + + PollType.FriendsNico -> { + val remain = enquete.time_start + NicoEnquete.ENQUETE_EXPIRE - now + if(remain <= 0L) { + showToast(context, false, R.string.enquete_was_end) + return + } + } + + PollType.Mastodon -> { + if(enquete.expired || now >= enquete.expired_at) { + showToast(context, false, R.string.enquete_was_end) + return + } } } TootTaskRunner(context).run(accessInfo, object : TootTask { - override fun background(client : TootApiClient) : TootApiResult? { - return if(accessInfo.isMisskey) { - client.request( - "/api/notes/polls/vote", - accessInfo.putMisskeyApiToken(JSONObject()) - .put("noteId", enquete.status_id.toString()) - .put("choice", idx) - .toPostRequestBuilder() - ) - } else { - client.request( - "/api/v1/votes/${enquete.status_id}", - JSONObject() - .put("item_index", idx.toString()) - .toPostRequestBuilder() - ) - } + override fun background(client : TootApiClient) = when(enquete.pollType) { + PollType.Misskey -> client.request( + "/api/notes/polls/vote", + accessInfo.putMisskeyApiToken(JSONObject()) + .put("noteId", enquete.status_id.toString()) + .put("choice", idx) + .toPostRequestBuilder() + ) + PollType.Mastodon -> client.request( + "/api/v1/polls/${enquete.pollId}/votes", + JSONObject() + .put( + "choices", + JSONArray().apply { + put(idx) + } + ) + .toPostRequestBuilder() + ) + PollType.FriendsNico -> client.request( + "/api/v1/votes/${enquete.status_id}", + JSONObject() + .put("item_index", idx.toString()) + .toPostRequestBuilder() + ) } override fun handleResult(result : TootApiResult?) { @@ -2561,23 +2767,43 @@ internal class ItemViewHolder( val data = result.jsonObject if(data != null) { - if(accessInfo.isMisskey) { - if(enquete.increaseVote(activity, idx, true)) { + when(enquete.pollType) { + PollType.Misskey -> if(enquete.increaseVote(activity, idx, true)) { showToast(context, false, R.string.enquete_voted) // 1個だけ開閉するのではなく、例えば通知TLにある複数の要素をまとめて開閉するなどある list_adapter.notifyChange(reason = "onClickEnqueteChoice", reset = true) } - } else { - val message = data.parseString("message") ?: "?" - val valid = data.optBoolean("valid") - if(valid) { - showToast(context, false, R.string.enquete_voted) - } else { - showToast(context, true, R.string.enquete_vote_failed, message) + PollType.Mastodon -> { + val newPoll = NicoEnquete.parse( + TootParser(activity, accessInfo), + status, + status.media_attachments, + data, + PollType.Mastodon + ) + if(newPoll != null) { + status.enquete = newPoll + // 1個だけ開閉するのではなく、例えば通知TLにある複数の要素をまとめて開閉するなどある + list_adapter.notifyChange( + reason = "onClickEnqueteChoice", + reset = true + ) + } else if(result.error != null) { + showToast(context, true, "response parse error") + } } + PollType.FriendsNico -> { + val message = data.parseString("message") ?: "?" + val valid = data.optBoolean("valid") + if(valid) { + showToast(context, false, R.string.enquete_voted) + } else { + showToast(context, true, R.string.enquete_vote_failed, message) + } + } } } else { showToast(context, true, result.error) @@ -2587,6 +2813,62 @@ internal class ItemViewHolder( }) } + private fun sendMultiple( + status : TootStatus, + enquete : NicoEnquete, + context : Context, + accessInfo : SavedAccount + ) { + val now = System.currentTimeMillis() + if(now >= enquete.expired_at) { + showToast(context, false, R.string.enquete_was_end) + return + } + + TootTaskRunner(context).run(accessInfo, object : TootTask { + + var newPoll : NicoEnquete? = null + + override fun background(client : TootApiClient) : TootApiResult? { + return client.request( + "/api/v1/polls/${enquete.pollId}/votes", + JSONObject() + .put("choices", JSONArray().apply { + enquete.items?.forEachIndexed { index, choice -> + if(choice.checked) put(index) + } + }) + .toPostRequestBuilder() + )?.also { result -> + val data = result.jsonObject + if(data != null) { + newPoll = NicoEnquete.parse( + TootParser(activity, accessInfo), + status, + status.media_attachments, + data, + PollType.Mastodon + ) + if(newPoll == null) result.setError("response parse error") + } + } + } + + override fun handleResult(result : TootApiResult?) { + result ?: return // cancelled. + + val newPoll = this.newPoll + if(newPoll != null) { + status.enquete = newPoll + // 1個だけ開閉するのではなく、例えば通知TLにある複数の要素をまとめて開閉するなどある + list_adapter.notifyChange(reason = "onClickEnqueteChoice", reset = true) + } else if(result.error != null) { + showToast(context, true, result.error) + } + } + }) + } + private fun openFilterMenu(item : TootFilter) { val ad = ActionsDialog() ad.addAction(activity.getString(R.string.edit)) { @@ -2604,12 +2886,14 @@ internal class ItemViewHolder( val b = Benchmark(log, "Item-Inflate", 40L) val rv = verticalLayout { // トップレベルのViewGroupのlparamsはイニシャライザ内部に置くしかないみたい - layoutParams = androidx.recyclerview.widget.RecyclerView.LayoutParams(matchParent, wrapContent).apply { - marginStart = dip(8) - marginEnd = dip(8) - topMargin = dip(2f) - bottomMargin = dip(1f) - } + layoutParams = + androidx.recyclerview.widget.RecyclerView.LayoutParams(matchParent, wrapContent) + .apply { + marginStart = dip(8) + marginEnd = dip(8) + topMargin = dip(2f) + bottomMargin = dip(1f) + } setPaddingRelative(dip(4), dip(1f), dip(4), dip(2f)) diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/NicoEnquete.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/NicoEnquete.kt index edd2c2cf..eca49c8f 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/NicoEnquete.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/NicoEnquete.kt @@ -11,20 +11,19 @@ import org.json.JSONArray import org.json.JSONObject import java.util.regex.Pattern -@Suppress("MemberVisibilityCanPrivate") +enum class PollType { + Mastodon, // Mastodon 2.8's poll + Misskey, // Misskey's poll + FriendsNico, // friends.nico +} + class NicoEnquete( parser : TootParser, status : TootStatus, list_attachment : ArrayList?, - src : JSONObject + src : JSONObject, + val pollType : PollType ) { - enum class PollType{ - Mastodon, // Mastodon 2.8's poll - Misskey, // Misskey's poll - FriendsNico, // friends.nico - } - - val poll_type : PollType // one of enquete,enquete_result val type : String? @@ -37,10 +36,10 @@ class NicoEnquete( val items : ArrayList? // 結果の数値 // null or array of number - private var ratios : MutableList? + var ratios : MutableList? = null // 結果の数値のテキスト // null or array of string - private var ratios_text : MutableList? + var ratios_text : MutableList? = null var myVoted : Int? = null @@ -48,13 +47,20 @@ class NicoEnquete( val time_start : Long val status_id : EntityId + // Mastodon poll API + var expired_at = Long.MAX_VALUE + var expired = false + var multiple = false + var votes_count : Int? = null + var maxVotesCount : Int? = null + var pollId : EntityId? = null + init { this.time_start = status.time_created_at this.status_id = status.id if(parser.serviceType == ServiceType.MISSKEY) { - this.poll_type = PollType.Misskey this.items = parseChoiceListMisskey( @@ -65,7 +71,7 @@ class NicoEnquete( var votesMax = 1 items?.forEachIndexed { index, choice -> if(choice.isVoted) this.myVoted = index - val votes = choice.votes + val votes = choice.votes ?: 0 votesList.add(votes) if(votes > votesMax) votesMax = votes } @@ -94,9 +100,54 @@ class NicoEnquete( emojiMapProfile = status.profile_emojis ).decodeHTML(this.question ?: "?") + } else if(pollType == PollType.Mastodon) { + this.type = "enquete" + + this.question = status.content + this.decoded_question = DecodeOptions( + parser.context, + parser.linkHelper, + short = true, + decodeEmoji = true, + attachmentList = list_attachment, + linkTag = status, + emojiMapCustom = status.custom_emojis, + emojiMapProfile = status.profile_emojis + ).decodeHTML(this.question ?: "?") + + this.items = parseChoiceListMastodon( + parser.context, + status, + src.optJSONArray("options")?.toObjectList() + ) + + this.pollId = EntityId.mayNull(src.parseString("id")) + this.expired_at = TootStatus.parseTime(src.parseString("expires_at")).notZero() ?: Long.MAX_VALUE + this.expired = src.optBoolean("expired", false) + this.multiple = src.optBoolean("multiple", false) + this.votes_count = src.parseInt("votes_count") + this.myVoted = if(src.optBoolean("voted", false)) 1 else null + + if(this.items == null) { + maxVotesCount = null + } else if(this.multiple){ + var max :Int? = null + for( item in items){ + val v = item.votes + if( v != null && (max == null || v > max) ) max =v + + } + maxVotesCount = max + } else { + var sum :Int?= null + for( item in items){ + val v = item.votes + if( v != null ) sum = (sum?:0) + v + } + maxVotesCount = sum + } + } else { - // TODO Mastodonのpollとfriends.nicoのアンケートを区別する - this.poll_type = PollType.FriendsNico this.type = src.parseString("type") this.question = src.parseString("question") @@ -111,14 +162,14 @@ class NicoEnquete( emojiMapProfile = status.profile_emojis ).decodeHTML(this.question ?: "?") - this.items = parseChoiceList( + this.items = parseChoiceListFriendsNico( parser.context, status, src.parseStringArrayList("items") ) this.ratios = src.parseFloatArrayList("ratios") - this.ratios_text = src.parseStringArrayList( "ratios_text") + this.ratios_text = src.parseStringArrayList("ratios_text") } } @@ -127,7 +178,8 @@ class NicoEnquete( val text : String, val decoded_text : Spannable, var isVoted : Boolean = false, // misskey - var votes : Int = 0 // misskey + var votes : Int? = 0, // misskey + var checked : Boolean = false // Mastodon ) companion object { @@ -147,27 +199,8 @@ class NicoEnquete( parser : TootParser, status : TootStatus, list_attachment : ArrayList?, - jsonString : String? - ) : NicoEnquete? { - jsonString ?: return null - return try { - NicoEnquete( - parser, - status, - list_attachment, - jsonString.toJsonObject() - ) - } catch(ex : Throwable) { - log.trace(ex) - null - } - } - - fun parse( - parser : TootParser, - status : TootStatus, - list_attachment : ArrayList?, - src : JSONObject? + src : JSONObject?, + pollType : PollType ) : NicoEnquete? { src ?: return null return try { @@ -175,7 +208,8 @@ class NicoEnquete( parser, status, list_attachment, - src + src, + pollType ) } catch(ex : Throwable) { log.trace(ex) @@ -183,7 +217,40 @@ class NicoEnquete( } } - private fun parseChoiceList( + private fun parseChoiceListMastodon( + context : Context, + status : TootStatus, + objectArray : ArrayList? + ) : ArrayList? { + if(objectArray != null) { + val size = objectArray.size + val items = ArrayList(size) + val options = DecodeOptions( + context, + emojiMapCustom = status.custom_emojis, + emojiMapProfile = status.profile_emojis, + decodeEmoji = true + ) + for(o in objectArray) { + val text = reWhitespace + .matcher((o.parseString("title") ?: "?").sanitizeBDI()) + .replaceAll(" ") + val decoded_text = options.decodeHTML(text) + + items.add( + Choice( + text, + decoded_text, + votes = o.parseInt("votes_count") // may null + ) + ) + } + if(items.isNotEmpty()) return items + } + return null + } + + private fun parseChoiceListFriendsNico( context : Context, status : TootStatus, stringArray : ArrayList? @@ -243,13 +310,13 @@ class NicoEnquete( fun increaseVote(context : Context, argChoice : Int?, isMyVoted : Boolean) : Boolean { argChoice ?: return false - synchronized(this){ + synchronized(this) { try { // 既に投票済み状態なら何もしない if(myVoted != null) return false val item = this.items?.get(argChoice) ?: return false - item.votes += 1 + item.votes = (item.votes ?: 0) + 1 if(isMyVoted) item.isVoted = true // update ratios @@ -257,7 +324,7 @@ class NicoEnquete( var votesMax = 1 items.forEachIndexed { index, choice -> if(choice.isVoted) this.myVoted = index - val votes = choice.votes + val votes = choice.votes ?: 0 votesList.add(votes) if(votes > votesMax) votesMax = votes } diff --git a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt index fa9c3f15..066964ab 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/api/entity/TootStatus.kt @@ -207,7 +207,7 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { // ページネーションには日時を使う this._orderId = EntityId(time_created_at.toString()) - + // お気に入りカラムなどではパース直後に変更することがある // 絵文字マップはすぐ後で使うので、最初の方で読んでおく @@ -323,7 +323,8 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { parser, this, media_attachments, - src.optJSONObject("poll") + src.optJSONObject("poll"), + PollType.Misskey ) this.reactionCounts = parseReactionCounts(src.optJSONObject("reactionCounts")) @@ -385,8 +386,8 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { src.optJSONArray("media_attachments"), log ) - this.visibility = TootVisibility.parseMastodon(src.parseString("visibility")) ?: - TootVisibility.Public + this.visibility = TootVisibility.parseMastodon(src.parseString("visibility")) + ?: TootVisibility.Public this.sensitive = src.optBoolean("sensitive") } @@ -432,7 +433,8 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { this._orderId = this.id this.in_reply_to_id = EntityId.mayNull(src.parseString("in_reply_to_id")) - this.in_reply_to_account_id = EntityId.mayNull(src.parseString("in_reply_to_account_id")) + this.in_reply_to_account_id = + EntityId.mayNull(src.parseString("in_reply_to_account_id")) this.mentions = parseListOrNull(::TootMention, src.optJSONArray("mentions"), log) this.tags = parseListOrNull(::TootTag, src.optJSONArray("tags")) this.application = @@ -467,7 +469,7 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { this.highlight_sound = options.highlight_sound } - val sv = (src.parseString("spoiler_text") ?: "").cleanCW() + var sv = (src.parseString("spoiler_text") ?: "").cleanCW() this.spoiler_text = when { sv.isEmpty() -> "" // CWなし sv.isBlank() -> parser.context.getString(R.string.blank_cw) @@ -488,12 +490,30 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { this.highlight_sound = options.highlight_sound } - this.enquete = NicoEnquete.parse( - parser, - this, - media_attachments, - src.parseString("enquete") - ) + this.enquete = try { + sv = src.parseString("enquete") ?: "" + if(sv.isNotEmpty()) { + NicoEnquete.parse( + parser, + this, + media_attachments, + sv.toJsonObject(), + PollType.FriendsNico + ) + } else { + val ov = src.optJSONObject("poll") + NicoEnquete.parse( + parser, + this, + media_attachments, + ov, + PollType.Mastodon + ) + } + } catch(ex : Throwable) { + log.trace(ex) + null + } // Pinned TL を取得した時にreblogが登場することはないので、reblogについてpinned 状態を気にする必要はない this.reblog = parser.status(src.optJSONObject("reblog")) @@ -664,7 +684,7 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { fun markDeleted(context : Context, deletedAt : Long?) : Boolean? { - if( Pref.bpDontRemoveDeletedToot( App1.getAppState(context).pref)) return false + if(Pref.bpDontRemoveDeletedToot(App1.getAppState(context).pref)) return false var sv = if(deletedAt != null) { context.getString(R.string.status_deleted_at, formatTime(context, deletedAt, false)) @@ -717,7 +737,7 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { """\Ahttps://([^/]+)/notes/([0-9a-f]{24})\b""", Pattern.CASE_INSENSITIVE ) - + // PleromaのStatusのUri internal val reStatusPageObjects = Pattern.compile("""\Ahttps://([^/]+)/objects/([^?#/\s]+)(?:\z|[?#])""") @@ -919,10 +939,8 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { return if(host != null && host.isNotEmpty() && host != "?") host else null } - - fun validStatusId(src : EntityId?) : EntityId? = - when{ + when { src == null -> null src == EntityId.DEFAULT -> null src.toString().startsWith("-") -> null @@ -951,7 +969,7 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { // https://misskey.xyz/notes/5b802367744b650030a13640 m = reStatusPageMisskey.matcher(uri) - if( m.find()) return EntityId(m.group(2)) + if(m.find()) return EntityId(m.group(2)) // https://pl.at7s.me/objects/feeb4399-cd7a-48c8-8999-b58868daaf43 // tootsearch中の投稿からIDを読めるようにしたい @@ -982,8 +1000,8 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() { // https://misskey.xyz/notes/5b802367744b650030a13640 m = reStatusPageMisskey.matcher(url) - if( m.find()) return EntityId(m.group(2)) - + if(m.find()) return EntityId(m.group(2)) + // https://pl.at7s.me/objects/feeb4399-cd7a-48c8-8999-b58868daaf43 // tootsearch中の投稿からIDを読めるようにしたい // しかしこのURL中のuuidはステータスIDではないので、無意味 diff --git a/app/src/main/java/jp/juggler/subwaytooter/drawable/PollPlotDrawable.kt b/app/src/main/java/jp/juggler/subwaytooter/drawable/PollPlotDrawable.kt new file mode 100644 index 00000000..fc2113b7 --- /dev/null +++ b/app/src/main/java/jp/juggler/subwaytooter/drawable/PollPlotDrawable.kt @@ -0,0 +1,42 @@ +package jp.juggler.subwaytooter.drawable + +import android.graphics.* +import android.graphics.drawable.Drawable + +class PollPlotDrawable( + private val color :Int, + private val startWidth: Int, // pixel width for minimum gauge + private val ratio: Float, // gauge ratio in 0..1 + private val isRtl :Boolean = false // false for LTR, true for RTL layout +) : Drawable(){ + + override fun setAlpha(alpha : Int) { + } + + override fun getOpacity() : Int = PixelFormat.TRANSLUCENT + + override fun setColorFilter(colorFilter : ColorFilter?) { + } + + private val rect = Rect() + private val paint = Paint() + + override fun draw(canvas : Canvas) { + + val bounds = bounds + val w = bounds.width() + val ratioWidth = ( (w - startWidth) * ratio + 0.5f ).toInt() + + val remainWidth = w - ratioWidth - startWidth + + if( isRtl){ + rect.set(bounds.left+remainWidth,bounds.top,bounds.right,bounds.bottom) + }else{ + rect.set(bounds.left,bounds.top,bounds.right-remainWidth,bounds.bottom) + } + paint.color = color + canvas.drawRect(rect,paint) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/jp/juggler/subwaytooter/util/PostHelper.kt b/app/src/main/java/jp/juggler/subwaytooter/util/PostHelper.kt index 1b93069f..dc7a4b6d 100644 --- a/app/src/main/java/jp/juggler/subwaytooter/util/PostHelper.kt +++ b/app/src/main/java/jp/juggler/subwaytooter/util/PostHelper.kt @@ -71,7 +71,7 @@ class PostHelper( var in_reply_to_id : EntityId? = null var attachment_list : ArrayList? = null var enquete_items : ArrayList? = null - var poll_type : NicoEnquete.PollType? = null + var poll_type : PollType? = null var poll_expire_seconds = 0 var poll_hide_totals = false var poll_multiple_choice = false @@ -126,7 +126,7 @@ class PostHelper( val choice_max_chars = if(isMisskey) { 15 } else when(poll_type) { - NicoEnquete.PollType.Mastodon -> 25 + PollType.Mastodon -> 25 else -> 15 } @@ -496,7 +496,7 @@ class PostHelper( } if(enquete_items?.isNotEmpty() == true) { - if(poll_type == NicoEnquete.PollType.Mastodon) { + if(poll_type == PollType.Mastodon) { json.put("poll", JSONObject().apply { put("multiple", poll_multiple_choice) put("hide_totals", poll_hide_totals) diff --git a/app/src/main/res/layout/act_account_setting.xml b/app/src/main/res/layout/act_account_setting.xml index 58d31beb..03385acb 100644 --- a/app/src/main/res/layout/act_account_setting.xml +++ b/app/src/main/res/layout/act_account_setting.xml @@ -610,7 +610,7 @@ diff --git a/app/src/main/res/layout/act_app_setting_color.xml b/app/src/main/res/layout/act_app_setting_color.xml index 69a2a684..ad01ce3c 100644 --- a/app/src/main/res/layout/act_app_setting_color.xml +++ b/app/src/main/res/layout/act_app_setting_color.xml @@ -415,7 +415,7 @@ diff --git a/app/src/main/res/layout/page_column.xml b/app/src/main/res/layout/page_column.xml index 1d4c1817..c757b94f 100644 --- a/app/src/main/res/layout/page_column.xml +++ b/app/src/main/res/layout/page_column.xml @@ -565,7 +565,7 @@ android:layout_height="match_parent" android:layout_margin="0dp" android:background="@drawable/btn_bg_transparent" - android:contentDescription="@string/vote" + android:contentDescription="@string/vote_misskey" /> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index abdee379..08a2a98c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -748,7 +748,7 @@ Follow request was cancelled. La demande de suivi de %2$s à %1$s sera rejetée. Vous êtes sûr ? Réaction (Misskey) - Vote (Misskey) + Vote (Misskey) Timeout for embed media viewer (unit:seconds, app restart(delete from app history) required) Taille maximale en octets des médias vidéo (unité : méga octets. par défaut : 40) Couleur des liens (redémarrage requis) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 44926d04..3ce825ba 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -749,7 +749,7 @@ フォロワー (ローカル) 未収載 (ローカル) - 投票 (Misskey) + 投票 (Misskey) %1$d票 直前の操作が完了するまでお待ちください 添付データあり @@ -879,5 +879,10 @@ 時間 + 1 vote + %1$d votes + 投票期限 %1$s + \?\?\?票 + 投票 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index e127034a..2fdad564 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -769,7 +769,7 @@ 팔로우 요청 취소됨. %2$s로부터 %1$s로의 팔로우 요청을 취소할까요\? 반응 (Misskey) - 투표 (Misskey) + 투표 (Misskey) 내장 미디어 뷰어 시간제한 (단위:초, 앱 재시작(앱 사용이력에서 삭제) 필요) 링크 색 (앱 재시작 필요) 닫을 칼럼이 표시 범위에 없음. diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 292b0732..17989fa8 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -719,7 +719,7 @@ Følgingsforespørsel forkastet. Følgingsforespørsel fra %1$s til %2$s vil forkastes. Er du sikker\? Reaksjon (Misskey) - Stem (Misskey) + Stem (Misskey) Tidsavbrudd for innebygd mediaviser (enhet:sekunder, programomstart (sletting fra programhistorikk) kreves) Lenkefarge (programomstart kreves) mangler lukkbar kolonne i synlig område. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ed54a0f..9fccabed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -778,7 +778,7 @@ Follow request cancelled. Cancel follow request from %2$s to %1$s? Reaction (Misskey) - Vote (Misskey) + Vote (Misskey) Timeout for embedded media viewer (Unit:seconds, app restart(delete from app history) required) Link color (app restart required) Missing closeable column in visible range. @@ -902,5 +902,10 @@ days hours minutes + 1 vote + %1$d votes + time limit: %1$s + \?\?\? votes + Vote \ No newline at end of file