show/vote to polls

This commit is contained in:
tateisu 2019-03-06 18:49:02 +09:00
parent e1be78dab5
commit 0eb8294e03
14 changed files with 614 additions and 175 deletions

View File

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

View File

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

View File

@ -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<TootAttachmentLike>?,
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<Choice>?
// 結果の数値 // null or array of number
private var ratios : MutableList<Float>?
var ratios : MutableList<Float>? = null
// 結果の数値のテキスト // null or array of string
private var ratios_text : MutableList<String>?
var ratios_text : MutableList<String>? = 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<TootAttachmentLike>?,
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<TootAttachmentLike>?,
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<JSONObject>?
) : ArrayList<Choice>? {
if(objectArray != null) {
val size = objectArray.size
val items = ArrayList<Choice>(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<String>?
@ -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
}

View File

@ -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ではないので、無意味

View File

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

View File

@ -71,7 +71,7 @@ class PostHelper(
var in_reply_to_id : EntityId? = null
var attachment_list : ArrayList<PostAttachment>? = null
var enquete_items : ArrayList<String>? = 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)

View File

@ -610,7 +610,7 @@
<CheckBox
android:id="@+id/cbNotificationVote"
style="@style/setting_horizontal_stretch"
android:text="@string/vote"
android:text="@string/vote_misskey"
/>
</LinearLayout>
<LinearLayout style="@style/setting_row_form">

View File

@ -415,7 +415,7 @@
<TextView
style="@style/setting_row_label_indent1"
android:text="@string/vote"
android:text="@string/vote_misskey"
/>
<LinearLayout style="@style/setting_row_form">

View File

@ -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"
/>
</LinearLayout>
</HorizontalScrollView>

View File

@ -748,7 +748,7 @@
<string name="follow_request_cancelled">Follow request was cancelled.</string>
<string name="confirm_cancel_follow_request_who_from">La demande de suivi de %2$s à %1$s sera rejetée. Vous êtes sûr ?</string>
<string name="reaction">Réaction (Misskey)</string>
<string name="vote">Vote (Misskey)</string>
<string name="vote_misskey">Vote (Misskey)</string>
<string name="timeout_for_embed_media_viewer">Timeout for embed media viewer (unit:seconds, app restart(delete from app history) required)</string>
<string name="media_attachment_max_byte_size_movie">Taille maximale en octets des médias vidéo (unité : méga octets. par défaut : 40)</string>
<string name="link_color">Couleur des liens (redémarrage requis)</string>

View File

@ -749,7 +749,7 @@
<string name="visibility_local_followers">フォロワー (ローカル)</string>
<string name="visibility_local_unlisted">未収載 (ローカル)</string>
<string name="vote">投票 (Misskey)</string>
<string name="vote_misskey">投票 (Misskey)</string>
<string name="vote_count_text">%1$d票</string>
<string name="wait_previous_operation">直前の操作が完了するまでお待ちください</string>
<string name="with_attachment">添付データあり</string>
@ -879,5 +879,10 @@
<string name="poll_expire_days"></string>
<string name="poll_expire_hours">時間</string>
<string name="poll_expire_minutes"></string>
<string name="vote_1">1 vote</string>
<string name="vote_2">%1$d votes</string>
<string name="vote_expire_at">投票期限 %1$s</string>
<string name="vote_count_unavailable">\?\?\?票</string>
<string name="vote_button">投票</string>
</resources>

View File

@ -769,7 +769,7 @@
<string name="follow_request_cancelled">팔로우 요청 취소됨.</string>
<string name="confirm_cancel_follow_request_who_from">%2$s로부터 %1$s로의 팔로우 요청을 취소할까요\?</string>
<string name="reaction">반응 (Misskey)</string>
<string name="vote">투표 (Misskey)</string>
<string name="vote_misskey">투표 (Misskey)</string>
<string name="timeout_for_embed_media_viewer">내장 미디어 뷰어 시간제한 (단위:초, 앱 재시작(앱 사용이력에서 삭제) 필요)</string>
<string name="link_color">링크 색 (앱 재시작 필요)</string>
<string name="missing_closeable_column">닫을 칼럼이 표시 범위에 없음.</string>

View File

@ -719,7 +719,7 @@
<string name="follow_request_cancelled">Følgingsforespørsel forkastet.</string>
<string name="confirm_cancel_follow_request_who_from">Følgingsforespørsel fra %1$s til %2$s vil forkastes. Er du sikker\?</string>
<string name="reaction">Reaksjon (Misskey)</string>
<string name="vote">Stem (Misskey)</string>
<string name="vote_misskey">Stem (Misskey)</string>
<string name="timeout_for_embed_media_viewer">Tidsavbrudd for innebygd mediaviser (enhet:sekunder, programomstart (sletting fra programhistorikk) kreves)</string>
<string name="link_color">Lenkefarge (programomstart kreves)</string>
<string name="missing_closeable_column">mangler lukkbar kolonne i synlig område.</string>

View File

@ -778,7 +778,7 @@
<string name="follow_request_cancelled">Follow request cancelled.</string>
<string name="confirm_cancel_follow_request_who_from">Cancel follow request from %2$s to %1$s?</string>
<string name="reaction">Reaction (Misskey)</string>
<string name="vote">Vote (Misskey)</string>
<string name="vote_misskey">Vote (Misskey)</string>
<string name="timeout_for_embed_media_viewer">Timeout for embedded media viewer (Unit:seconds, app restart(delete from app history) required)</string>
<string name="link_color">Link color (app restart required)</string>
<string name="missing_closeable_column">Missing closeable column in visible range.</string>
@ -902,5 +902,10 @@
<string name="poll_expire_days">days</string>
<string name="poll_expire_hours">hours</string>
<string name="poll_expire_minutes">minutes</string>
<string name="vote_1">1 vote</string>
<string name="vote_2">%1$d votes</string>
<string name="vote_expire_at">time limit: %1$s</string>
<string name="vote_count_unavailable">\?\?\? votes</string>
<string name="vote_button">Vote</string>
</resources>