Timeline refactor (#2175)

* Move Timeline files into their own package

* Introduce TimelineViewModel, add coroutines

* Simplify StatusViewData

* Handle timeilne fetch errors

* Rework filters, fix ViewThreadFragment

* Fix NotificationsFragment

* Simplify Notifications and Thread, handle pin

* Redo loading in TimelineViewModel

* Improve error handling in TimelineViewModel

* Rewrite actions in TimelineViewModel

* Apply feedback after timeline factoring review

* Handle initial failure in timeline correctly
This commit is contained in:
Ivan Kupalov 2021-06-11 20:15:40 +02:00 committed by GitHub
commit 44a5b42cac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 3956 additions and 3618 deletions

View file

@ -0,0 +1,56 @@
package com.keylesspalace.tusky.network
import android.text.TextUtils
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.entity.Status
import java.util.regex.Pattern
import javax.inject.Inject
/**
* One-stop for status filtering logic using Mastodon's filters.
*
* 1. You init with [initWithFilters], this compiles regex pattern.
* 2. You call [shouldFilterStatus] to figure out what to display when you load statuses.
*/
class FilterModel @Inject constructor() {
private var pattern: Pattern? = null
fun initWithFilters(filters: List<Filter>) {
this.pattern = makeFilter(filters)
}
fun shouldFilterStatus(status: Status): Boolean {
// Patterns are expensive and thread-safe, matchers are neither.
val matcher = pattern?.matcher("") ?: return false
if (status.poll != null) {
val pollMatches = status.poll.options.any { matcher.reset(it.title).find() }
if (pollMatches) return true
}
val spoilerText = status.actionableStatus.spoilerText
return (matcher.reset(status.actionableStatus.content).find() ||
spoilerText.isNotEmpty() && matcher.reset(spoilerText).find())
}
private fun filterToRegexToken(filter: Filter): String? {
val phrase = filter.phrase
val quotedPhrase = Pattern.quote(phrase)
return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) {
String.format("(^|\\W)%s($|\\W)", quotedPhrase)
} else {
quotedPhrase
}
}
private fun makeFilter(filters: List<Filter>): Pattern? {
if (filters.isEmpty()) return null
val tokens = filters.map { filterToRegexToken(it) }
return Pattern.compile(TextUtils.join("|", tokens), Pattern.CASE_INSENSITIVE);
}
companion object {
private val ALPHANUMERIC = Pattern.compile("^\\w+$")
}
}

View file

@ -49,7 +49,7 @@ interface MastodonApi {
fun getInstance(): Single<Instance>
@GET("api/v1/filters")
fun getFilters(): Call<List<Filter>>
fun getFilters(): Single<List<Filter>>
@GET("api/v1/timelines/home")
fun homeTimeline(

View file

@ -30,20 +30,20 @@ import java.lang.IllegalStateException
*/
interface TimelineCases {
fun reblog(status: Status, reblog: Boolean): Single<Status>
fun favourite(status: Status, favourite: Boolean): Single<Status>
fun bookmark(status: Status, bookmark: Boolean): Single<Status>
fun mute(id: String, notifications: Boolean, duration: Int?)
fun block(id: String)
fun delete(id: String): Single<DeletedStatus>
fun pin(status: Status, pin: Boolean)
fun voteInPoll(status: Status, choices: List<Int>): Single<Poll>
fun muteConversation(status: Status, mute: Boolean): Single<Status>
fun reblog(statusId: String, reblog: Boolean): Single<Status>
fun favourite(statusId: String, favourite: Boolean): Single<Status>
fun bookmark(statusId: String, bookmark: Boolean): Single<Status>
fun mute(statusId: String, notifications: Boolean, duration: Int?)
fun block(statusId: String)
fun delete(statusId: String): Single<DeletedStatus>
fun pin(statusId: String, pin: Boolean): Single<Status>
fun voteInPoll(statusId: String, pollId: String, choices: List<Int>): Single<Poll>
fun muteConversation(statusId: String, mute: Boolean): Single<Status>
}
class TimelineCasesImpl(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : TimelineCases {
/**
@ -52,103 +52,92 @@ class TimelineCasesImpl(
*/
private val cancelDisposable = CompositeDisposable()
override fun reblog(status: Status, reblog: Boolean): Single<Status> {
val id = status.actionableId
override fun reblog(statusId: String, reblog: Boolean): Single<Status> {
val call = if (reblog) {
mastodonApi.reblogStatus(id)
mastodonApi.reblogStatus(statusId)
} else {
mastodonApi.unreblogStatus(id)
mastodonApi.unreblogStatus(statusId)
}
return call.doAfterSuccess {
eventHub.dispatch(ReblogEvent(status.id, reblog))
eventHub.dispatch(ReblogEvent(statusId, reblog))
}
}
override fun favourite(status: Status, favourite: Boolean): Single<Status> {
val id = status.actionableId
override fun favourite(statusId: String, favourite: Boolean): Single<Status> {
val call = if (favourite) {
mastodonApi.favouriteStatus(id)
mastodonApi.favouriteStatus(statusId)
} else {
mastodonApi.unfavouriteStatus(id)
mastodonApi.unfavouriteStatus(statusId)
}
return call.doAfterSuccess {
eventHub.dispatch(FavoriteEvent(status.id, favourite))
eventHub.dispatch(FavoriteEvent(statusId, favourite))
}
}
override fun bookmark(status: Status, bookmark: Boolean): Single<Status> {
val id = status.actionableId
override fun bookmark(statusId: String, bookmark: Boolean): Single<Status> {
val call = if (bookmark) {
mastodonApi.bookmarkStatus(id)
mastodonApi.bookmarkStatus(statusId)
} else {
mastodonApi.unbookmarkStatus(id)
mastodonApi.unbookmarkStatus(statusId)
}
return call.doAfterSuccess {
eventHub.dispatch(BookmarkEvent(status.id, bookmark))
eventHub.dispatch(BookmarkEvent(statusId, bookmark))
}
}
override fun muteConversation(status: Status, mute: Boolean): Single<Status> {
val id = status.actionableId
override fun muteConversation(statusId: String, mute: Boolean): Single<Status> {
val call = if (mute) {
mastodonApi.muteConversation(id)
mastodonApi.muteConversation(statusId)
} else {
mastodonApi.unmuteConversation(id)
mastodonApi.unmuteConversation(statusId)
}
return call.doAfterSuccess {
eventHub.dispatch(MuteConversationEvent(status.id, mute))
eventHub.dispatch(MuteConversationEvent(statusId, mute))
}
}
override fun mute(id: String, notifications: Boolean, duration: Int?) {
mastodonApi.muteAccount(id, notifications, duration)
.subscribe({
eventHub.dispatch(MuteEvent(id))
}, { t ->
Log.w("Failed to mute account", t)
})
.addTo(cancelDisposable)
override fun mute(statusId: String, notifications: Boolean, duration: Int?) {
mastodonApi.muteAccount(statusId, notifications, duration)
.subscribe({
eventHub.dispatch(MuteEvent(statusId))
}, { t ->
Log.w("Failed to mute account", t)
})
.addTo(cancelDisposable)
}
override fun block(id: String) {
mastodonApi.blockAccount(id)
.subscribe({
eventHub.dispatch(BlockEvent(id))
}, { t ->
Log.w("Failed to block account", t)
})
.addTo(cancelDisposable)
override fun block(statusId: String) {
mastodonApi.blockAccount(statusId)
.subscribe({
eventHub.dispatch(BlockEvent(statusId))
}, { t ->
Log.w("Failed to block account", t)
})
.addTo(cancelDisposable)
}
override fun delete(id: String): Single<DeletedStatus> {
return mastodonApi.deleteStatus(id)
.doAfterSuccess {
eventHub.dispatch(StatusDeletedEvent(id))
}
override fun delete(statusId: String): Single<DeletedStatus> {
return mastodonApi.deleteStatus(statusId)
.doAfterSuccess {
eventHub.dispatch(StatusDeletedEvent(statusId))
}
}
override fun pin(status: Status, pin: Boolean) {
override fun pin(statusId: String, pin: Boolean): Single<Status> {
// Replace with extension method if we use RxKotlin
(if (pin) mastodonApi.pinStatus(status.id) else mastodonApi.unpinStatus(status.id))
.subscribe({ updatedStatus ->
status.pinned = updatedStatus.pinned
}, {})
.addTo(this.cancelDisposable)
return (if (pin) mastodonApi.pinStatus(statusId) else mastodonApi.unpinStatus(statusId))
.doAfterSuccess {
eventHub.dispatch(PinEvent(statusId, pin))
}
}
override fun voteInPoll(status: Status, choices: List<Int>): Single<Poll> {
val pollId = status.actionableStatus.poll?.id
if(pollId == null || choices.isEmpty()) {
override fun voteInPoll(statusId: String, pollId: String, choices: List<Int>): Single<Poll> {
if (choices.isEmpty()) {
return Single.error(IllegalStateException())
}
return mastodonApi.voteInPoll(pollId, choices).doAfterSuccess {
eventHub.dispatch(PollVoteEvent(status.id, it))
eventHub.dispatch(PollVoteEvent(statusId, it))
}
}