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_REPLY_URL = "reply_url"
internal const val DRAFT_IS_ENQUETE = "is_enquete" internal const val DRAFT_IS_ENQUETE = "is_enquete"
internal const val DRAFT_POLL_TYPE = "poll_type" 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_ENQUETE_ITEMS = "enquete_items"
internal const val DRAFT_QUOTED_RENOTE = "quotedRenote" internal const val DRAFT_QUOTED_RENOTE = "quotedRenote"
@ -718,11 +723,11 @@ class ActPost : AppCompatActivity(),
val src_enquete = base_status.enquete val src_enquete = base_status.enquete
val src_items = src_enquete?.items val src_items = src_enquete?.items
if(src_items != null) { 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のアンケート結果は再編集の対象外 // フレニコAPIのアンケート結果は再編集の対象外
} else { } else {
spEnquete.setSelection( spEnquete.setSelection(
if(src_enquete.poll_type == NicoEnquete.PollType.FriendsNico) { if(src_enquete.pollType == PollType.FriendsNico) {
2 2
} else { } else {
1 1
@ -1957,7 +1962,7 @@ class ActPost : AppCompatActivity(),
opener.open().use { inData -> opener.open().use { inData ->
val tmp = ByteArray(4096) val tmp = ByteArray(4096)
while(true) { while(true) {
val r = inData.read(tmp, 0, tmp.size) val r = inData.read(tmp, 0, tmp.size)
if(r <= 0) break if(r <= 0) break
sink.write(tmp, 0, r) sink.write(tmp, 0, r)
} }
@ -2236,7 +2241,7 @@ class ActPost : AppCompatActivity(),
when(spEnquete.selectedItemPosition) { when(spEnquete.selectedItemPosition) {
1 -> { 1 -> {
copyEnqueteText() copyEnqueteText()
post_helper.poll_type = NicoEnquete.PollType.Mastodon post_helper.poll_type = PollType.Mastodon
post_helper.poll_expire_seconds = getExpireSeconds() post_helper.poll_expire_seconds = getExpireSeconds()
post_helper.poll_hide_totals = cbHideTotals.isChecked post_helper.poll_hide_totals = cbHideTotals.isChecked
post_helper.poll_multiple_choice = cbMultipleChoice.isChecked post_helper.poll_multiple_choice = cbMultipleChoice.isChecked
@ -2244,7 +2249,7 @@ class ActPost : AppCompatActivity(),
2 -> { 2 -> {
copyEnqueteText() 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()) json.put(DRAFT_POLL_TYPE, spEnquete.selectedItemPosition.toPollTypeString())
val array = JSONArray() json.put(DRAFT_POLL_MULTIPLE, cbMultipleChoice.isChecked)
for(s in str_choice) { json.put(DRAFT_POLL_HIDE_TOTALS, cbHideTotals.isChecked )
array.put(s) json.put(DRAFT_POLL_EXPIRE_DAY,etExpireDays.text.toString())
} json.put(DRAFT_POLL_EXPIRE_HOUR,etExpireHours.text.toString())
json.put(DRAFT_ENQUETE_ITEMS, array) 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) PostDraft.save(System.currentTimeMillis(), json)
@ -2563,6 +2574,13 @@ class ActPost : AppCompatActivity(),
spEnquete.setSelection( if(bv) 2 else 0) 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) val array = draft.optJSONArray(DRAFT_ENQUETE_ITEMS)
if(array != null) { if(array != null) {
var src_index = 0 var src_index = 0

View File

@ -6,16 +6,17 @@ import android.graphics.Typeface
import android.os.SystemClock import android.os.SystemClock
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.TextUtils import android.text.TextUtils
import android.util.LayoutDirection
import android.util.TypedValue import android.util.TypedValue
import android.view.Gravity import android.view.Gravity
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.core.view.ViewCompat
import com.google.android.flexbox.FlexWrap import com.google.android.flexbox.FlexWrap
import com.google.android.flexbox.FlexboxLayout import com.google.android.flexbox.FlexboxLayout
import com.google.android.flexbox.JustifyContent 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.api.entity.*
import jp.juggler.subwaytooter.dialog.ActionsDialog import jp.juggler.subwaytooter.dialog.ActionsDialog
import jp.juggler.subwaytooter.dialog.DlgConfirm import jp.juggler.subwaytooter.dialog.DlgConfirm
import jp.juggler.subwaytooter.drawable.PollPlotDrawable
import jp.juggler.subwaytooter.drawable.PreviewCardBorder import jp.juggler.subwaytooter.drawable.PreviewCardBorder
import jp.juggler.subwaytooter.span.MyClickableSpan import jp.juggler.subwaytooter.span.MyClickableSpan
import jp.juggler.subwaytooter.table.* import jp.juggler.subwaytooter.table.*
@ -32,6 +34,7 @@ import jp.juggler.subwaytooter.util.*
import jp.juggler.subwaytooter.view.* import jp.juggler.subwaytooter.view.*
import jp.juggler.util.* import jp.juggler.util.*
import org.jetbrains.anko.* import org.jetbrains.anko.*
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import kotlin.math.max 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_id = item.in_reply_to_id
val in_reply_to_account_id = item.in_reply_to_account_id val in_reply_to_account_id = item.in_reply_to_account_id
when { when {
reply != null ->{ reply != null -> {
showReply( showReply(
R.drawable.ic_reply, R.drawable.ic_reply,
R.string.reply_to, R.string.reply_to,
reply 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 -> { in_reply_to_id != null && in_reply_to_account_id != null -> {
@ -722,7 +725,7 @@ internal class ItemViewHolder(
in_reply_to_account_id, in_reply_to_account_id,
item item
) )
if( colorBgArg == 0) colorBg = Pref.ipEventBgColorMention(activity.pref) if(colorBgArg == 0) colorBg = Pref.ipEventBgColorMention(activity.pref)
} }
} }
showStatus(item, colorBg) showStatus(item, colorBg)
@ -1190,20 +1193,16 @@ internal class ItemViewHolder(
// ニコフレのアンケートの表示 // ニコフレのアンケートの表示
val enquete = status.enquete val enquete = status.enquete
if(enquete != null) { 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 question = enquete.decoded_question
val items = enquete.items
if(question.isNotBlank()) content = question 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( private fun makeEnqueteChoiceView(
status : TootStatus,
enquete : NicoEnquete, enquete : NicoEnquete,
now : Long, canVote : Boolean,
i : Int, i : Int,
item : NicoEnquete.Choice item : NicoEnquete.Choice
) { ) {
val canVote = if(access_info.isMisskey) {
enquete.myVoted == null val text = when(enquete.pollType) {
} else { PollType.Misskey -> {
val remain = enquete.time_start + NicoEnquete.ENQUETE_EXPIRE - now val sb = SpannableStringBuilder()
enquete.myVoted == null && remain > 0L .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( val lp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT LinearLayout.LayoutParams.WRAP_CONTENT
) ).apply {
if(i == 0) if(i == 0) topMargin = (0.5f + activity.density * 3f).toInt()
lp.topMargin = (0.5f + activity.density * 3f).toInt() }
val b = Button(activity)
b.layoutParams = lp
b.isAllCaps = false
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) { if(! canVote) {
b.isEnabled = false
} else { val b = TextView(activity)
val accessInfo = this@ItemViewHolder.access_info b.layoutParams = lp
b.setOnClickListener { view ->
val context = view.context ?: return@setOnClickListener b.text = text
onClickEnqueteChoice(enquete, context, accessInfo, i) 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 density = activity.density
val height = (0.5f + 6 * density).toInt() val height = (0.5f + 6 * density).toInt()
val view = EnqueteTimerView(activity) val view = EnqueteTimerView(activity)
@ -2517,43 +2633,133 @@ internal class ItemViewHolder(
llExtra.addView(view) 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( private fun onClickEnqueteChoice(
status : TootStatus,
enquete : NicoEnquete, enquete : NicoEnquete,
context : Context, context : Context,
accessInfo : SavedAccount, accessInfo : SavedAccount,
idx : Int idx : Int
) { ) {
val now = System.currentTimeMillis()
if(enquete.myVoted != null) { if(enquete.myVoted != null) {
showToast(context, false, R.string.already_voted) showToast(context, false, R.string.already_voted)
return return
} }
if(! accessInfo.isMisskey) {
val remain = enquete.time_start + NicoEnquete.ENQUETE_EXPIRE - now val now = System.currentTimeMillis()
if(remain <= 0L) {
showToast(context, false, R.string.enquete_was_end) when(enquete.pollType) {
return 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 { TootTaskRunner(context).run(accessInfo, object : TootTask {
override fun background(client : TootApiClient) : TootApiResult? { override fun background(client : TootApiClient) = when(enquete.pollType) {
return if(accessInfo.isMisskey) { PollType.Misskey -> client.request(
client.request( "/api/notes/polls/vote",
"/api/notes/polls/vote", accessInfo.putMisskeyApiToken(JSONObject())
accessInfo.putMisskeyApiToken(JSONObject()) .put("noteId", enquete.status_id.toString())
.put("noteId", enquete.status_id.toString()) .put("choice", idx)
.put("choice", idx) .toPostRequestBuilder()
.toPostRequestBuilder() )
) PollType.Mastodon -> client.request(
} else { "/api/v1/polls/${enquete.pollId}/votes",
client.request( JSONObject()
"/api/v1/votes/${enquete.status_id}", .put(
JSONObject() "choices",
.put("item_index", idx.toString()) JSONArray().apply {
.toPostRequestBuilder() 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?) { override fun handleResult(result : TootApiResult?) {
@ -2561,23 +2767,43 @@ internal class ItemViewHolder(
val data = result.jsonObject val data = result.jsonObject
if(data != null) { if(data != null) {
if(accessInfo.isMisskey) { when(enquete.pollType) {
if(enquete.increaseVote(activity, idx, true)) { PollType.Misskey -> if(enquete.increaseVote(activity, idx, true)) {
showToast(context, false, R.string.enquete_voted) showToast(context, false, R.string.enquete_voted)
// 1個だけ開閉するのではなく、例えば通知TLにある複数の要素をまとめて開閉するなどある // 1個だけ開閉するのではなく、例えば通知TLにある複数の要素をまとめて開閉するなどある
list_adapter.notifyChange(reason = "onClickEnqueteChoice", reset = true) list_adapter.notifyChange(reason = "onClickEnqueteChoice", reset = true)
} }
} else { PollType.Mastodon -> {
val message = data.parseString("message") ?: "?" val newPoll = NicoEnquete.parse(
val valid = data.optBoolean("valid") TootParser(activity, accessInfo),
if(valid) { status,
showToast(context, false, R.string.enquete_voted) status.media_attachments,
} else { data,
showToast(context, true, R.string.enquete_vote_failed, message) 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 { } else {
showToast(context, true, result.error) 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) { private fun openFilterMenu(item : TootFilter) {
val ad = ActionsDialog() val ad = ActionsDialog()
ad.addAction(activity.getString(R.string.edit)) { ad.addAction(activity.getString(R.string.edit)) {
@ -2604,12 +2886,14 @@ internal class ItemViewHolder(
val b = Benchmark(log, "Item-Inflate", 40L) val b = Benchmark(log, "Item-Inflate", 40L)
val rv = verticalLayout { val rv = verticalLayout {
// トップレベルのViewGroupのlparamsはイニシャライザ内部に置くしかないみたい // トップレベルのViewGroupのlparamsはイニシャライザ内部に置くしかないみたい
layoutParams = androidx.recyclerview.widget.RecyclerView.LayoutParams(matchParent, wrapContent).apply { layoutParams =
marginStart = dip(8) androidx.recyclerview.widget.RecyclerView.LayoutParams(matchParent, wrapContent)
marginEnd = dip(8) .apply {
topMargin = dip(2f) marginStart = dip(8)
bottomMargin = dip(1f) marginEnd = dip(8)
} topMargin = dip(2f)
bottomMargin = dip(1f)
}
setPaddingRelative(dip(4), dip(1f), dip(4), dip(2f)) setPaddingRelative(dip(4), dip(1f), dip(4), dip(2f))

View File

@ -11,20 +11,19 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.regex.Pattern 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( class NicoEnquete(
parser : TootParser, parser : TootParser,
status : TootStatus, status : TootStatus,
list_attachment : ArrayList<TootAttachmentLike>?, 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 // one of enquete,enquete_result
val type : String? val type : String?
@ -37,10 +36,10 @@ class NicoEnquete(
val items : ArrayList<Choice>? val items : ArrayList<Choice>?
// 結果の数値 // null or array of number // 結果の数値 // null or array of number
private var ratios : MutableList<Float>? var ratios : MutableList<Float>? = null
// 結果の数値のテキスト // null or array of string // 結果の数値のテキスト // null or array of string
private var ratios_text : MutableList<String>? var ratios_text : MutableList<String>? = null
var myVoted : Int? = null var myVoted : Int? = null
@ -48,13 +47,20 @@ class NicoEnquete(
val time_start : Long val time_start : Long
val status_id : EntityId 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 { init {
this.time_start = status.time_created_at this.time_start = status.time_created_at
this.status_id = status.id this.status_id = status.id
if(parser.serviceType == ServiceType.MISSKEY) { if(parser.serviceType == ServiceType.MISSKEY) {
this.poll_type = PollType.Misskey
this.items = parseChoiceListMisskey( this.items = parseChoiceListMisskey(
@ -65,7 +71,7 @@ class NicoEnquete(
var votesMax = 1 var votesMax = 1
items?.forEachIndexed { index, choice -> items?.forEachIndexed { index, choice ->
if(choice.isVoted) this.myVoted = index if(choice.isVoted) this.myVoted = index
val votes = choice.votes val votes = choice.votes ?: 0
votesList.add(votes) votesList.add(votes)
if(votes > votesMax) votesMax = votes if(votes > votesMax) votesMax = votes
} }
@ -94,9 +100,54 @@ class NicoEnquete(
emojiMapProfile = status.profile_emojis emojiMapProfile = status.profile_emojis
).decodeHTML(this.question ?: "?") ).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 { } else {
// TODO Mastodonのpollとfriends.nicoのアンケートを区別する
this.poll_type = PollType.FriendsNico
this.type = src.parseString("type") this.type = src.parseString("type")
this.question = src.parseString("question") this.question = src.parseString("question")
@ -111,14 +162,14 @@ class NicoEnquete(
emojiMapProfile = status.profile_emojis emojiMapProfile = status.profile_emojis
).decodeHTML(this.question ?: "?") ).decodeHTML(this.question ?: "?")
this.items = parseChoiceList( this.items = parseChoiceListFriendsNico(
parser.context, parser.context,
status, status,
src.parseStringArrayList("items") src.parseStringArrayList("items")
) )
this.ratios = src.parseFloatArrayList("ratios") 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 text : String,
val decoded_text : Spannable, val decoded_text : Spannable,
var isVoted : Boolean = false, // misskey var isVoted : Boolean = false, // misskey
var votes : Int = 0 // misskey var votes : Int? = 0, // misskey
var checked : Boolean = false // Mastodon
) )
companion object { companion object {
@ -147,27 +199,8 @@ class NicoEnquete(
parser : TootParser, parser : TootParser,
status : TootStatus, status : TootStatus,
list_attachment : ArrayList<TootAttachmentLike>?, list_attachment : ArrayList<TootAttachmentLike>?,
jsonString : String? src : JSONObject?,
) : NicoEnquete? { pollType : PollType
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?
) : NicoEnquete? { ) : NicoEnquete? {
src ?: return null src ?: return null
return try { return try {
@ -175,7 +208,8 @@ class NicoEnquete(
parser, parser,
status, status,
list_attachment, list_attachment,
src src,
pollType
) )
} catch(ex : Throwable) { } catch(ex : Throwable) {
log.trace(ex) 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, context : Context,
status : TootStatus, status : TootStatus,
stringArray : ArrayList<String>? stringArray : ArrayList<String>?
@ -243,13 +310,13 @@ class NicoEnquete(
fun increaseVote(context : Context, argChoice : Int?, isMyVoted : Boolean) : Boolean { fun increaseVote(context : Context, argChoice : Int?, isMyVoted : Boolean) : Boolean {
argChoice ?: return false argChoice ?: return false
synchronized(this){ synchronized(this) {
try { try {
// 既に投票済み状態なら何もしない // 既に投票済み状態なら何もしない
if(myVoted != null) return false if(myVoted != null) return false
val item = this.items?.get(argChoice) ?: return false val item = this.items?.get(argChoice) ?: return false
item.votes += 1 item.votes = (item.votes ?: 0) + 1
if(isMyVoted) item.isVoted = true if(isMyVoted) item.isVoted = true
// update ratios // update ratios
@ -257,7 +324,7 @@ class NicoEnquete(
var votesMax = 1 var votesMax = 1
items.forEachIndexed { index, choice -> items.forEachIndexed { index, choice ->
if(choice.isVoted) this.myVoted = index if(choice.isVoted) this.myVoted = index
val votes = choice.votes val votes = choice.votes ?: 0
votesList.add(votes) votesList.add(votes)
if(votes > votesMax) votesMax = 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()) this._orderId = EntityId(time_created_at.toString())
// お気に入りカラムなどではパース直後に変更することがある // お気に入りカラムなどではパース直後に変更することがある
// 絵文字マップはすぐ後で使うので、最初の方で読んでおく // 絵文字マップはすぐ後で使うので、最初の方で読んでおく
@ -323,7 +323,8 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() {
parser, parser,
this, this,
media_attachments, media_attachments,
src.optJSONObject("poll") src.optJSONObject("poll"),
PollType.Misskey
) )
this.reactionCounts = parseReactionCounts(src.optJSONObject("reactionCounts")) this.reactionCounts = parseReactionCounts(src.optJSONObject("reactionCounts"))
@ -385,8 +386,8 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() {
src.optJSONArray("media_attachments"), src.optJSONArray("media_attachments"),
log log
) )
this.visibility = TootVisibility.parseMastodon(src.parseString("visibility")) ?: this.visibility = TootVisibility.parseMastodon(src.parseString("visibility"))
TootVisibility.Public ?: TootVisibility.Public
this.sensitive = src.optBoolean("sensitive") this.sensitive = src.optBoolean("sensitive")
} }
@ -432,7 +433,8 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() {
this._orderId = this.id this._orderId = this.id
this.in_reply_to_id = EntityId.mayNull(src.parseString("in_reply_to_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.mentions = parseListOrNull(::TootMention, src.optJSONArray("mentions"), log)
this.tags = parseListOrNull(::TootTag, src.optJSONArray("tags")) this.tags = parseListOrNull(::TootTag, src.optJSONArray("tags"))
this.application = this.application =
@ -467,7 +469,7 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() {
this.highlight_sound = options.highlight_sound this.highlight_sound = options.highlight_sound
} }
val sv = (src.parseString("spoiler_text") ?: "").cleanCW() var sv = (src.parseString("spoiler_text") ?: "").cleanCW()
this.spoiler_text = when { this.spoiler_text = when {
sv.isEmpty() -> "" // CWなし sv.isEmpty() -> "" // CWなし
sv.isBlank() -> parser.context.getString(R.string.blank_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.highlight_sound = options.highlight_sound
} }
this.enquete = NicoEnquete.parse( this.enquete = try {
parser, sv = src.parseString("enquete") ?: ""
this, if(sv.isNotEmpty()) {
media_attachments, NicoEnquete.parse(
src.parseString("enquete") 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 状態を気にする必要はない // Pinned TL を取得した時にreblogが登場することはないので、reblogについてpinned 状態を気にする必要はない
this.reblog = parser.status(src.optJSONObject("reblog")) 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? { 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) { var sv = if(deletedAt != null) {
context.getString(R.string.status_deleted_at, formatTime(context, deletedAt, false)) 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""", """\Ahttps://([^/]+)/notes/([0-9a-f]{24})\b""",
Pattern.CASE_INSENSITIVE Pattern.CASE_INSENSITIVE
) )
// PleromaのStatusのUri // PleromaのStatusのUri
internal val reStatusPageObjects = internal val reStatusPageObjects =
Pattern.compile("""\Ahttps://([^/]+)/objects/([^?#/\s]+)(?:\z|[?#])""") 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 return if(host != null && host.isNotEmpty() && host != "?") host else null
} }
fun validStatusId(src : EntityId?) : EntityId? = fun validStatusId(src : EntityId?) : EntityId? =
when{ when {
src == null -> null src == null -> null
src == EntityId.DEFAULT -> null src == EntityId.DEFAULT -> null
src.toString().startsWith("-") -> null src.toString().startsWith("-") -> null
@ -951,7 +969,7 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() {
// https://misskey.xyz/notes/5b802367744b650030a13640 // https://misskey.xyz/notes/5b802367744b650030a13640
m = reStatusPageMisskey.matcher(uri) 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 // https://pl.at7s.me/objects/feeb4399-cd7a-48c8-8999-b58868daaf43
// tootsearch中の投稿からIDを読めるようにしたい // tootsearch中の投稿からIDを読めるようにしたい
@ -982,8 +1000,8 @@ class TootStatus(parser : TootParser, src : JSONObject) : TimelineItem() {
// https://misskey.xyz/notes/5b802367744b650030a13640 // https://misskey.xyz/notes/5b802367744b650030a13640
m = reStatusPageMisskey.matcher(url) 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 // https://pl.at7s.me/objects/feeb4399-cd7a-48c8-8999-b58868daaf43
// tootsearch中の投稿からIDを読めるようにしたい // tootsearch中の投稿からIDを読めるようにしたい
// しかしこのURL中のuuidはステータス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 in_reply_to_id : EntityId? = null
var attachment_list : ArrayList<PostAttachment>? = null var attachment_list : ArrayList<PostAttachment>? = null
var enquete_items : ArrayList<String>? = 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_expire_seconds = 0
var poll_hide_totals = false var poll_hide_totals = false
var poll_multiple_choice = false var poll_multiple_choice = false
@ -126,7 +126,7 @@ class PostHelper(
val choice_max_chars = if(isMisskey) { val choice_max_chars = if(isMisskey) {
15 15
} else when(poll_type) { } else when(poll_type) {
NicoEnquete.PollType.Mastodon -> 25 PollType.Mastodon -> 25
else -> 15 else -> 15
} }
@ -496,7 +496,7 @@ class PostHelper(
} }
if(enquete_items?.isNotEmpty() == true) { if(enquete_items?.isNotEmpty() == true) {
if(poll_type == NicoEnquete.PollType.Mastodon) { if(poll_type == PollType.Mastodon) {
json.put("poll", JSONObject().apply { json.put("poll", JSONObject().apply {
put("multiple", poll_multiple_choice) put("multiple", poll_multiple_choice)
put("hide_totals", poll_hide_totals) put("hide_totals", poll_hide_totals)

View File

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

View File

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

View File

@ -565,7 +565,7 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_margin="0dp" android:layout_margin="0dp"
android:background="@drawable/btn_bg_transparent" android:background="@drawable/btn_bg_transparent"
android:contentDescription="@string/vote" android:contentDescription="@string/vote_misskey"
/> />
</LinearLayout> </LinearLayout>
</HorizontalScrollView> </HorizontalScrollView>

View File

@ -748,7 +748,7 @@
<string name="follow_request_cancelled">Follow request was cancelled.</string> <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="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="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="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="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> <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_followers">フォロワー (ローカル)</string>
<string name="visibility_local_unlisted">未収載 (ローカル)</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="vote_count_text">%1$d票</string>
<string name="wait_previous_operation">直前の操作が完了するまでお待ちください</string> <string name="wait_previous_operation">直前の操作が完了するまでお待ちください</string>
<string name="with_attachment">添付データあり</string> <string name="with_attachment">添付データあり</string>
@ -879,5 +879,10 @@
<string name="poll_expire_days"></string> <string name="poll_expire_days"></string>
<string name="poll_expire_hours">時間</string> <string name="poll_expire_hours">時間</string>
<string name="poll_expire_minutes"></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> </resources>

View File

@ -769,7 +769,7 @@
<string name="follow_request_cancelled">팔로우 요청 취소됨.</string> <string name="follow_request_cancelled">팔로우 요청 취소됨.</string>
<string name="confirm_cancel_follow_request_who_from">%2$s로부터 %1$s로의 팔로우 요청을 취소할까요\?</string> <string name="confirm_cancel_follow_request_who_from">%2$s로부터 %1$s로의 팔로우 요청을 취소할까요\?</string>
<string name="reaction">반응 (Misskey)</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="timeout_for_embed_media_viewer">내장 미디어 뷰어 시간제한 (단위:초, 앱 재시작(앱 사용이력에서 삭제) 필요)</string>
<string name="link_color">링크 색 (앱 재시작 필요)</string> <string name="link_color">링크 색 (앱 재시작 필요)</string>
<string name="missing_closeable_column">닫을 칼럼이 표시 범위에 없음.</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="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="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="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="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="link_color">Lenkefarge (programomstart kreves)</string>
<string name="missing_closeable_column">mangler lukkbar kolonne i synlig område.</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="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="confirm_cancel_follow_request_who_from">Cancel follow request from %2$s to %1$s?</string>
<string name="reaction">Reaction (Misskey)</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="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="link_color">Link color (app restart required)</string>
<string name="missing_closeable_column">Missing closeable column in visible range.</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_days">days</string>
<string name="poll_expire_hours">hours</string> <string name="poll_expire_hours">hours</string>
<string name="poll_expire_minutes">minutes</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> </resources>