[needs help] Support announcements (#1977)

* Implement announcements activity

* Update reactions without api access

* Add badge style

* Use emptyList() as default parameter

* Simplify newIntent

* Use List instead of Array

* Remove unneeded ConstraintLayout

* Add lineSpacingMultiplier

* Fix wording

* Apply material design's default chip style

* Dismiss announcements automatically
This commit is contained in:
kyori19 2020-11-19 05:12:27 +09:00 committed by GitHub
commit fef4b8b07f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 717 additions and 21 deletions

View file

@ -0,0 +1,115 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.announcements
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.view.size
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipGroup
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.util.emojify
import kotlinx.android.synthetic.main.item_announcement.view.*
interface AnnouncementActionListener {
fun openReactionPicker(announcementId: String, target: View)
fun addReaction(announcementId: String, name: String)
fun removeReaction(announcementId: String, name: String)
}
class AnnouncementAdapter(
private var items: List<Announcement> = emptyList(),
private val listener: AnnouncementActionListener
) : RecyclerView.Adapter<AnnouncementAdapter.AnnouncementViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AnnouncementViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_announcement, parent, false)
return AnnouncementViewHolder(view)
}
override fun onBindViewHolder(viewHolder: AnnouncementViewHolder, position: Int) {
viewHolder.bind(items[position])
}
override fun getItemCount() = items.size
fun updateList(items: List<Announcement>) {
this.items = items
notifyDataSetChanged()
}
inner class AnnouncementViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {
private val text: TextView = view.text
private val chips: ChipGroup = view.chipGroup
private val addReactionChip: Chip = view.addReactionChip
fun bind(item: Announcement) {
text.text = item.content
item.reactions.forEachIndexed { i, reaction ->
(chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip?
?: Chip(ContextThemeWrapper(view.context, R.style.Widget_MaterialComponents_Chip_Choice)).apply {
isCheckable = true
checkedIcon = null
chips.addView(this, i)
})
.apply {
val emojiText = if (reaction.url == null) {
reaction.name
} else {
view.context.getString(R.string.emoji_shortcode_format, reaction.name)
}
text = ("$emojiText ${reaction.count}")
.emojify(
listOf(Emoji(
reaction.name,
reaction.url ?: "",
reaction.staticUrl ?: "",
null
)),
this
)
isChecked = reaction.me
setOnClickListener {
if (reaction.me) {
listener.removeReaction(item.id, reaction.name)
} else {
listener.addReaction(item.id, reaction.name)
}
}
}
}
while (chips.size - 1 > item.reactions.size) {
chips.removeViewAt(item.reactions.size)
}
addReactionChip.setOnClickListener {
listener.openReactionPicker(item.id, it)
}
}
}
}

View file

@ -0,0 +1,153 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.announcements
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.widget.PopupWindow
import androidx.activity.viewModels
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.EmojiAdapter
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.view.EmojiPicker
import kotlinx.android.synthetic.main.activity_announcements.*
import kotlinx.android.synthetic.main.toolbar_basic.*
import javax.inject.Inject
class AnnouncementsActivity : BaseActivity(), AnnouncementActionListener, OnEmojiSelectedListener, Injectable {
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val viewModel: AnnouncementsViewModel by viewModels { viewModelFactory }
private val adapter = AnnouncementAdapter(emptyList(), this)
private val picker by lazy { EmojiPicker(this) }
private val pickerDialog by lazy {
PopupWindow(this)
.apply {
contentView = picker
isFocusable = true
setOnDismissListener {
currentAnnouncementId = null
}
}
}
private var currentAnnouncementId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_announcements)
setSupportActionBar(toolbar)
supportActionBar?.apply {
title = getString(R.string.title_announcements)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
swipeRefreshLayout.setOnRefreshListener(this::refreshAnnouncements)
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
announcementsList.setHasFixedSize(true)
announcementsList.layoutManager = LinearLayoutManager(this)
val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
announcementsList.addItemDecoration(divider)
announcementsList.adapter = adapter
viewModel.announcements.observe(this, Observer {
when (it) {
is Success -> {
progressBar.hide()
swipeRefreshLayout.isRefreshing = false
if (it.data.isNullOrEmpty()) {
errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_announcements)
errorMessageView.show()
} else {
errorMessageView.hide()
}
adapter.updateList(it.data ?: listOf())
}
is Loading -> {
errorMessageView.hide()
}
is Error -> {
progressBar.hide()
swipeRefreshLayout.isRefreshing = false
errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshAnnouncements()
}
errorMessageView.show()
}
}
})
viewModel.emojis.observe(this, Observer {
picker.adapter = EmojiAdapter(it, this)
})
viewModel.load()
progressBar.show()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
}
return super.onOptionsItemSelected(item)
}
private fun refreshAnnouncements() {
viewModel.load()
swipeRefreshLayout.isRefreshing = true
}
override fun openReactionPicker(announcementId: String, target: View) {
currentAnnouncementId = announcementId
pickerDialog.showAsDropDown(target)
}
override fun onEmojiSelected(shortcode: String) {
viewModel.addReaction(currentAnnouncementId!!, shortcode)
pickerDialog.dismiss()
}
override fun addReaction(announcementId: String, name: String) {
viewModel.addReaction(announcementId, name)
}
override fun removeReaction(announcementId: String, name: String) {
viewModel.removeReaction(announcementId, name)
}
companion object {
fun newIntent(context: Context) = Intent(context, AnnouncementsActivity::class.java)
}
}

View file

@ -0,0 +1,187 @@
/* Copyright 2020 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.announcements
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.InstanceEntity
import com.keylesspalace.tusky.entity.Announcement
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Instance
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import io.reactivex.rxkotlin.Singles
import javax.inject.Inject
class AnnouncementsViewModel @Inject constructor(
accountManager: AccountManager,
private val appDatabase: AppDatabase,
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : RxAwareViewModel() {
private val announcementsMutable = MutableLiveData<Resource<List<Announcement>>>()
val announcements: LiveData<Resource<List<Announcement>>> = announcementsMutable
private val emojisMutable = MutableLiveData<List<Emoji>>()
val emojis: LiveData<List<Emoji>> = emojisMutable
init {
Singles.zip(
mastodonApi.getCustomEmojis(),
appDatabase.instanceDao().loadMetadataForInstance(accountManager.activeAccount?.domain!!)
.map<Either<InstanceEntity, Instance>> { Either.Left(it) }
.onErrorResumeNext(
mastodonApi.getInstance()
.map { Either.Right<InstanceEntity, Instance>(it) }
)
) { emojis, either ->
either.asLeftOrNull()?.copy(emojiList = emojis)
?: InstanceEntity(
accountManager.activeAccount?.domain!!,
emojis,
either.asRight().maxTootChars,
either.asRight().pollLimits?.maxOptions,
either.asRight().pollLimits?.maxOptionChars,
either.asRight().version
)
}
.doOnSuccess {
appDatabase.instanceDao().insertOrReplace(it)
}
.subscribe({
emojisMutable.postValue(it.emojiList)
}, {
Log.w(TAG, "Failed to get custom emojis.", it)
})
.autoDispose()
}
fun load() {
announcementsMutable.postValue(Loading())
mastodonApi.listAnnouncements()
.subscribe({
announcementsMutable.postValue(Success(it))
it.filter { announcement -> !announcement.read }
.forEach { announcement ->
mastodonApi.dismissAnnouncement(announcement.id)
.subscribe(
{
eventHub.dispatch(AnnouncementReadEvent(announcement.id))
},
{ throwable ->
Log.d(TAG, "Failed to mark announcement as read.", throwable)
}
)
.autoDispose()
}
}, {
announcementsMutable.postValue(Error(cause = it))
})
.autoDispose()
}
fun addReaction(announcementId: String, name: String) {
mastodonApi.addAnnouncementReaction(announcementId, name)
.subscribe({
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) {
announcement.reactions.map { reaction ->
if (reaction.name == name) {
reaction.copy(
count = reaction.count + 1,
me = true
)
} else {
reaction
}
}
} else {
listOf(
*announcement.reactions.toTypedArray(),
emojis.value!!.find { emoji -> emoji.shortcode == name }
!!.run {
Announcement.Reaction(
name,
1,
true,
url,
staticUrl
)
}
)
}
)
} else {
announcement
}
}
)
)
}, {
Log.w(TAG, "Failed to add reaction to the announcement.", it)
})
.autoDispose()
}
fun removeReaction(announcementId: String, name: String) {
mastodonApi.removeAnnouncementReaction(announcementId, name)
.subscribe({
announcementsMutable.postValue(
Success(
announcements.value!!.data!!.map { announcement ->
if (announcement.id == announcementId) {
announcement.copy(
reactions = announcement.reactions.mapNotNull { reaction ->
if (reaction.name == name) {
if (reaction.count > 1) {
reaction.copy(
count = reaction.count - 1,
me = false
)
} else {
null
}
} else {
reaction
}
}
)
} else {
announcement
}
}
)
)
}, {
Log.w(TAG, "Failed to remove reaction from the announcement.", it)
})
.autoDispose()
}
companion object {
private const val TAG = "AnnouncementsViewModel"
}
}