refactor: Replace .to... with .from() in most cases (#82)
The previous code generally converted between a higher and a lower type by putting the type conversion functions on the lower type. This introduced cycles in the code dependency graph, and made it more difficult to follow the code flow. Refactor the code so that types generally have a `from(...)` static factory method that can create an instance from a lower type, and if appropriate a `to...()` method that can also create an instance of that lower type. Add `docs/code-style.md` which explains the rationale for this change in more detail so that future contributors can write code in the same style.
This commit is contained in:
parent
f45a3df83f
commit
3a274b0594
|
@ -53,6 +53,7 @@ import app.pachli.entity.Emoji;
|
|||
import app.pachli.entity.Filter;
|
||||
import app.pachli.entity.FilterResult;
|
||||
import app.pachli.entity.HashTag;
|
||||
import app.pachli.entity.Poll;
|
||||
import app.pachli.entity.Status;
|
||||
import app.pachli.interfaces.StatusActionListener;
|
||||
import app.pachli.util.AbsoluteTimeFormatter;
|
||||
|
@ -279,7 +280,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
List<Status.Mention> mentions = actionable.getMentions();
|
||||
List<HashTag> tags =actionable.getTags();
|
||||
List<Emoji> emojis = actionable.getEmojis();
|
||||
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll());
|
||||
Poll poll = actionable.getPoll();
|
||||
|
||||
if (expanded) {
|
||||
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
|
||||
|
@ -288,7 +289,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
updateMediaLabel(i, sensitive, true);
|
||||
}
|
||||
if (poll != null) {
|
||||
setupPoll(poll, emojis, statusDisplayOptions, listener);
|
||||
setupPoll(PollViewData.Companion.from(poll), emojis, statusDisplayOptions, listener);
|
||||
} else {
|
||||
hidePoll();
|
||||
}
|
||||
|
@ -962,24 +963,28 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
|||
private CharSequence getPollDescription(@NonNull StatusViewData status,
|
||||
@NonNull Context context,
|
||||
@NonNull StatusDisplayOptions statusDisplayOptions) {
|
||||
PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll());
|
||||
Poll poll = status.getActionable().getPoll();
|
||||
if (poll == null) {
|
||||
return "";
|
||||
} else {
|
||||
Object[] args = new CharSequence[5];
|
||||
List<PollOptionViewData> options = poll.getOptions();
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
if (i < options.size()) {
|
||||
int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount());
|
||||
args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context);
|
||||
} else {
|
||||
args[i] = "";
|
||||
}
|
||||
}
|
||||
args[4] = getPollInfoText(System.currentTimeMillis(), poll, statusDisplayOptions,
|
||||
context);
|
||||
return context.getString(R.string.description_poll, args);
|
||||
}
|
||||
|
||||
PollViewData pollViewData = PollViewData.Companion.from(poll);
|
||||
Object[] args = new CharSequence[5];
|
||||
List<PollOptionViewData> options = pollViewData.getOptions();
|
||||
int totalVotes = pollViewData.getVotesCount();
|
||||
Integer totalVoters = pollViewData.getVotersCount();
|
||||
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
if (i < options.size()) {
|
||||
int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), totalVoters, totalVotes);
|
||||
args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context);
|
||||
} else {
|
||||
args[i] = "";
|
||||
}
|
||||
}
|
||||
args[4] = getPollInfoText(System.currentTimeMillis(), pollViewData, statusDisplayOptions,
|
||||
context);
|
||||
return context.getString(R.string.description_poll, args);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
|
|
@ -26,7 +26,6 @@ import app.pachli.entity.HashTag
|
|||
import app.pachli.entity.Poll
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.TimelineAccount
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import java.util.Date
|
||||
|
||||
@Entity(primaryKeys = ["id", "accountId"])
|
||||
|
@ -39,13 +38,26 @@ data class ConversationEntity(
|
|||
val unread: Boolean,
|
||||
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity,
|
||||
) {
|
||||
fun toViewData(): ConversationViewData {
|
||||
return ConversationViewData(
|
||||
id = id,
|
||||
companion object {
|
||||
fun from(
|
||||
conversation: Conversation,
|
||||
accountId: Long,
|
||||
order: Int,
|
||||
expanded: Boolean,
|
||||
contentShowing: Boolean,
|
||||
contentCollapsed: Boolean,
|
||||
) = ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = conversation.id,
|
||||
order = order,
|
||||
accounts = accounts,
|
||||
unread = unread,
|
||||
lastStatus = lastStatus.toViewData(),
|
||||
accounts = conversation.accounts.map { ConversationAccountEntity.from(it) },
|
||||
unread = conversation.unread,
|
||||
lastStatus = ConversationStatusEntity.from(
|
||||
conversation.lastStatus!!,
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
contentCollapsed = contentCollapsed,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -70,6 +82,17 @@ data class ConversationAccountEntity(
|
|||
emojis = emojis,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(timelineAccount: TimelineAccount) = ConversationAccountEntity(
|
||||
id = timelineAccount.id,
|
||||
localUsername = timelineAccount.localUsername,
|
||||
username = timelineAccount.username,
|
||||
displayName = timelineAccount.name,
|
||||
avatar = timelineAccount.avatar,
|
||||
emojis = timelineAccount.emojis.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@TypeConverters(Converters::class)
|
||||
|
@ -100,104 +123,37 @@ data class ConversationStatusEntity(
|
|||
val language: String?,
|
||||
) {
|
||||
|
||||
fun toViewData(): StatusViewData {
|
||||
return StatusViewData(
|
||||
status = Status(
|
||||
id = id,
|
||||
url = url,
|
||||
account = account.toAccount(),
|
||||
inReplyToId = inReplyToId,
|
||||
inReplyToAccountId = inReplyToAccountId,
|
||||
content = content,
|
||||
reblog = null,
|
||||
createdAt = createdAt,
|
||||
editedAt = editedAt,
|
||||
emojis = emojis,
|
||||
reblogsCount = 0,
|
||||
favouritesCount = favouritesCount,
|
||||
repliesCount = repliesCount,
|
||||
reblogged = false,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = sensitive,
|
||||
spoilerText = spoilerText,
|
||||
visibility = Status.Visibility.DIRECT,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
tags = tags,
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = muted,
|
||||
poll = poll,
|
||||
card = null,
|
||||
language = language,
|
||||
filtered = null,
|
||||
),
|
||||
isExpanded = expanded,
|
||||
isShowingContent = showingHiddenContent,
|
||||
isCollapsed = collapsed,
|
||||
companion object {
|
||||
fun from(
|
||||
status: Status,
|
||||
expanded: Boolean,
|
||||
contentShowing: Boolean,
|
||||
contentCollapsed: Boolean,
|
||||
) = ConversationStatusEntity(
|
||||
id = status.id,
|
||||
url = status.url,
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
account = ConversationAccountEntity.from(status.account),
|
||||
content = status.content,
|
||||
createdAt = status.createdAt,
|
||||
editedAt = status.editedAt,
|
||||
emojis = status.emojis,
|
||||
favouritesCount = status.favouritesCount,
|
||||
repliesCount = status.repliesCount,
|
||||
favourited = status.favourited,
|
||||
bookmarked = status.bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText,
|
||||
attachments = status.attachments,
|
||||
mentions = status.mentions,
|
||||
tags = status.tags,
|
||||
showingHiddenContent = contentShowing,
|
||||
expanded = expanded,
|
||||
collapsed = contentCollapsed,
|
||||
muted = status.muted ?: false,
|
||||
poll = status.poll,
|
||||
language = status.language,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun TimelineAccount.toEntity() =
|
||||
ConversationAccountEntity(
|
||||
id = id,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
avatar = avatar,
|
||||
emojis = emojis.orEmpty(),
|
||||
)
|
||||
|
||||
fun Status.toEntity(
|
||||
expanded: Boolean,
|
||||
contentShowing: Boolean,
|
||||
contentCollapsed: Boolean,
|
||||
) =
|
||||
ConversationStatusEntity(
|
||||
id = id,
|
||||
url = url,
|
||||
inReplyToId = inReplyToId,
|
||||
inReplyToAccountId = inReplyToAccountId,
|
||||
account = account.toEntity(),
|
||||
content = content,
|
||||
createdAt = createdAt,
|
||||
editedAt = editedAt,
|
||||
emojis = emojis,
|
||||
favouritesCount = favouritesCount,
|
||||
repliesCount = repliesCount,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = sensitive,
|
||||
spoilerText = spoilerText,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
tags = tags,
|
||||
showingHiddenContent = contentShowing,
|
||||
expanded = expanded,
|
||||
collapsed = contentCollapsed,
|
||||
muted = muted ?: false,
|
||||
poll = poll,
|
||||
language = language,
|
||||
)
|
||||
|
||||
fun Conversation.toEntity(
|
||||
accountId: Long,
|
||||
order: Int,
|
||||
expanded: Boolean,
|
||||
contentShowing: Boolean,
|
||||
contentCollapsed: Boolean,
|
||||
) =
|
||||
ConversationEntity(
|
||||
accountId = accountId,
|
||||
id = id,
|
||||
order = order,
|
||||
accounts = accounts.map { it.toEntity() },
|
||||
unread = unread,
|
||||
lastStatus = lastStatus!!.toEntity(
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
contentCollapsed = contentCollapsed,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -25,7 +25,7 @@ data class ConversationViewData(
|
|||
val unread: Boolean,
|
||||
val lastStatus: StatusViewData,
|
||||
) {
|
||||
fun toEntity(
|
||||
fun toConversationEntity(
|
||||
accountId: Long,
|
||||
favourited: Boolean = lastStatus.status.favourited,
|
||||
bookmarked: Boolean = lastStatus.status.bookmarked,
|
||||
|
@ -52,41 +52,14 @@ data class ConversationViewData(
|
|||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun StatusViewData.toConversationStatusEntity(
|
||||
favourited: Boolean = status.favourited,
|
||||
bookmarked: Boolean = status.bookmarked,
|
||||
muted: Boolean = status.muted ?: false,
|
||||
poll: Poll? = status.poll,
|
||||
expanded: Boolean = isExpanded,
|
||||
collapsed: Boolean = isCollapsed,
|
||||
showingHiddenContent: Boolean = isShowingContent,
|
||||
): ConversationStatusEntity {
|
||||
return ConversationStatusEntity(
|
||||
id = id,
|
||||
url = status.url,
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
account = status.account.toEntity(),
|
||||
content = status.content,
|
||||
createdAt = status.createdAt,
|
||||
editedAt = status.editedAt,
|
||||
emojis = status.emojis,
|
||||
favouritesCount = status.favouritesCount,
|
||||
repliesCount = status.repliesCount,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText,
|
||||
attachments = status.attachments,
|
||||
mentions = status.mentions,
|
||||
tags = status.tags,
|
||||
showingHiddenContent = showingHiddenContent,
|
||||
expanded = expanded,
|
||||
collapsed = collapsed,
|
||||
muted = muted,
|
||||
poll = poll,
|
||||
language = status.language,
|
||||
)
|
||||
companion object {
|
||||
fun from(conversationEntity: ConversationEntity) = ConversationViewData(
|
||||
id = conversationEntity.id,
|
||||
order = conversationEntity.order,
|
||||
accounts = conversationEntity.accounts,
|
||||
unread = conversationEntity.unread,
|
||||
lastStatus = StatusViewData.from(conversationEntity.lastStatus),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,17 +58,13 @@ class ConversationsRemoteMediator(
|
|||
conversations
|
||||
.filterNot { it.lastStatus == null }
|
||||
.map { conversation ->
|
||||
|
||||
val expanded = activeAccount.alwaysOpenSpoiler
|
||||
val contentShowing = activeAccount.alwaysShowSensitiveMedia || !conversation.lastStatus!!.sensitive
|
||||
val contentCollapsed = true
|
||||
|
||||
conversation.toEntity(
|
||||
ConversationEntity.from(
|
||||
conversation,
|
||||
accountId = activeAccount.id,
|
||||
order = order++,
|
||||
expanded = expanded,
|
||||
contentShowing = contentShowing,
|
||||
contentCollapsed = contentCollapsed,
|
||||
expanded = activeAccount.alwaysOpenSpoiler,
|
||||
contentShowing = activeAccount.alwaysShowSensitiveMedia || !conversation.lastStatus!!.sensitive,
|
||||
contentCollapsed = true,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
@ -55,14 +55,14 @@ class ConversationsViewModel @Inject constructor(
|
|||
)
|
||||
.flow
|
||||
.map { pagingData ->
|
||||
pagingData.map { conversation -> conversation.toViewData() }
|
||||
pagingData.map { conversation -> ConversationViewData.from(conversation) }
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
fun favourite(favourite: Boolean, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
timelineCases.favourite(conversation.lastStatus.id, favourite).fold({
|
||||
val newConversation = conversation.toEntity(
|
||||
val newConversation = conversation.toConversationEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
favourited = favourite,
|
||||
)
|
||||
|
@ -77,7 +77,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({
|
||||
val newConversation = conversation.toEntity(
|
||||
val newConversation = conversation.toConversationEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
bookmarked = bookmark,
|
||||
)
|
||||
|
@ -93,7 +93,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices)
|
||||
.fold({ poll ->
|
||||
val newConversation = conversation.toEntity(
|
||||
val newConversation = conversation.toConversationEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
poll = poll,
|
||||
)
|
||||
|
@ -107,7 +107,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
|
||||
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
val newConversation = conversation.toEntity(
|
||||
val newConversation = conversation.toConversationEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
expanded = expanded,
|
||||
)
|
||||
|
@ -117,7 +117,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
|
||||
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
val newConversation = conversation.toEntity(
|
||||
val newConversation = conversation.toConversationEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
collapsed = collapsed,
|
||||
)
|
||||
|
@ -127,7 +127,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
|
||||
fun showContent(showing: Boolean, conversation: ConversationViewData) {
|
||||
viewModelScope.launch {
|
||||
val newConversation = conversation.toEntity(
|
||||
val newConversation = conversation.toConversationEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
showingHiddenContent = showing,
|
||||
)
|
||||
|
@ -158,7 +158,7 @@ class ConversationsViewModel @Inject constructor(
|
|||
!(conversation.lastStatus.status.muted ?: false),
|
||||
)
|
||||
|
||||
val newConversation = conversation.toEntity(
|
||||
val newConversation = conversation.toConversationEntity(
|
||||
accountId = accountManager.activeAccount!!.id,
|
||||
muted = !(conversation.lastStatus.status.muted ?: false),
|
||||
)
|
||||
|
|
|
@ -47,7 +47,6 @@ import app.pachli.util.StatusDisplayOptions
|
|||
import app.pachli.util.deserialize
|
||||
import app.pachli.util.serialize
|
||||
import app.pachli.util.throttleFirst
|
||||
import app.pachli.util.toViewData
|
||||
import app.pachli.viewdata.NotificationViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.getOrThrow
|
||||
|
@ -534,7 +533,8 @@ class NotificationsViewModel @Inject constructor(
|
|||
.map { pagingData ->
|
||||
pagingData.map { notification ->
|
||||
val filterAction = notification.status?.actionableStatus?.let { filterModel.shouldFilterStatus(it) } ?: Filter.Action.NONE
|
||||
notification.toViewData(
|
||||
NotificationViewData.from(
|
||||
notification,
|
||||
isShowingContent = statusDisplayOptions.value.showSensitiveMedia ||
|
||||
!(notification.status?.actionableStatus?.sensitive ?: false),
|
||||
isExpanded = statusDisplayOptions.value.openSpoiler,
|
||||
|
|
|
@ -35,7 +35,7 @@ import app.pachli.util.Error
|
|||
import app.pachli.util.Loading
|
||||
import app.pachli.util.Resource
|
||||
import app.pachli.util.Success
|
||||
import app.pachli.util.toViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
@ -79,7 +79,7 @@ class ReportViewModel @Inject constructor(
|
|||
.map { pagingData ->
|
||||
/* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData
|
||||
instead of StatusViewState */
|
||||
pagingData.map { status -> status.toViewData(false, false, false) }
|
||||
pagingData.map { status -> StatusViewData.from(status, false, false, false) }
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
|
|
|
@ -38,8 +38,8 @@ import app.pachli.util.setClickableMentions
|
|||
import app.pachli.util.setClickableText
|
||||
import app.pachli.util.shouldTrimStatus
|
||||
import app.pachli.util.show
|
||||
import app.pachli.viewdata.PollViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.toViewData
|
||||
import java.util.Date
|
||||
|
||||
class StatusViewHolder(
|
||||
|
@ -93,7 +93,10 @@ class StatusViewHolder(
|
|||
mediaViewHeight,
|
||||
)
|
||||
|
||||
statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions)
|
||||
viewData.status.poll?.let {
|
||||
statusViewHelper.setupPollReadonly(PollViewData.from(it), viewData.status.emojis, statusDisplayOptions)
|
||||
} ?: statusViewHelper.hidePoll()
|
||||
|
||||
setCreatedAt(viewData.status.createdAt)
|
||||
}
|
||||
|
||||
|
|
|
@ -28,7 +28,6 @@ import app.pachli.entity.DeletedStatus
|
|||
import app.pachli.entity.Status
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.toViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
|
@ -58,7 +57,8 @@ class SearchViewModel @Inject constructor(
|
|||
|
||||
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
|
||||
it.statuses.map { status ->
|
||||
status.toViewData(
|
||||
StatusViewData.from(
|
||||
status,
|
||||
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
|
||||
isExpanded = alwaysOpenSpoiler,
|
||||
isCollapsed = true,
|
||||
|
|
|
@ -1,235 +0,0 @@
|
|||
/* Copyright 2021 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 app.pachli.components.timeline
|
||||
|
||||
import app.pachli.db.TimelineAccountEntity
|
||||
import app.pachli.db.TimelineStatusEntity
|
||||
import app.pachli.db.TimelineStatusWithAccount
|
||||
import app.pachli.entity.Attachment
|
||||
import app.pachli.entity.Card
|
||||
import app.pachli.entity.Emoji
|
||||
import app.pachli.entity.HashTag
|
||||
import app.pachli.entity.Poll
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.TimelineAccount
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.util.Date
|
||||
|
||||
@Suppress("unused")
|
||||
private const val TAG = "TimelineTypeMappers"
|
||||
|
||||
private val attachmentArrayListType = object : TypeToken<ArrayList<Attachment>>() {}.type
|
||||
private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
|
||||
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
|
||||
private val tagListType = object : TypeToken<List<HashTag>>() {}.type
|
||||
|
||||
fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
|
||||
return TimelineAccountEntity(
|
||||
serverId = id,
|
||||
timelineUserId = accountId,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = name,
|
||||
url = url,
|
||||
avatar = avatar,
|
||||
emojis = gson.toJson(emojis),
|
||||
bot = bot,
|
||||
)
|
||||
}
|
||||
|
||||
fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount {
|
||||
return TimelineAccount(
|
||||
id = serverId,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
note = "",
|
||||
url = url,
|
||||
avatar = avatar,
|
||||
bot = bot,
|
||||
emojis = gson.fromJson(emojis, emojisListType),
|
||||
)
|
||||
}
|
||||
|
||||
fun Status.toEntity(
|
||||
timelineUserId: Long,
|
||||
gson: Gson,
|
||||
): TimelineStatusEntity {
|
||||
return TimelineStatusEntity(
|
||||
serverId = this.id,
|
||||
url = actionableStatus.url,
|
||||
timelineUserId = timelineUserId,
|
||||
authorServerId = actionableStatus.account.id,
|
||||
inReplyToId = actionableStatus.inReplyToId,
|
||||
inReplyToAccountId = actionableStatus.inReplyToAccountId,
|
||||
content = actionableStatus.content,
|
||||
createdAt = actionableStatus.createdAt.time,
|
||||
editedAt = actionableStatus.editedAt?.time,
|
||||
emojis = actionableStatus.emojis.let(gson::toJson),
|
||||
reblogsCount = actionableStatus.reblogsCount,
|
||||
favouritesCount = actionableStatus.favouritesCount,
|
||||
reblogged = actionableStatus.reblogged,
|
||||
favourited = actionableStatus.favourited,
|
||||
bookmarked = actionableStatus.bookmarked,
|
||||
sensitive = actionableStatus.sensitive,
|
||||
spoilerText = actionableStatus.spoilerText,
|
||||
visibility = actionableStatus.visibility,
|
||||
attachments = actionableStatus.attachments.let(gson::toJson),
|
||||
mentions = actionableStatus.mentions.let(gson::toJson),
|
||||
tags = actionableStatus.tags.let(gson::toJson),
|
||||
application = actionableStatus.application.let(gson::toJson),
|
||||
reblogServerId = reblog?.id,
|
||||
reblogAccountId = reblog?.let { this.account.id },
|
||||
poll = actionableStatus.poll.let(gson::toJson),
|
||||
muted = actionableStatus.muted,
|
||||
pinned = actionableStatus.pinned == true,
|
||||
card = actionableStatus.card?.let(gson::toJson),
|
||||
repliesCount = actionableStatus.repliesCount,
|
||||
language = actionableStatus.language,
|
||||
filtered = actionableStatus.filtered,
|
||||
)
|
||||
}
|
||||
|
||||
fun TimelineStatusWithAccount.toViewData(gson: Gson, alwaysOpenSpoiler: Boolean, alwaysShowSensitiveMedia: Boolean, isDetailed: Boolean = false): StatusViewData {
|
||||
val attachments: ArrayList<Attachment> = gson.fromJson(
|
||||
status.attachments,
|
||||
attachmentArrayListType,
|
||||
) ?: arrayListOf()
|
||||
val mentions: List<Status.Mention> = gson.fromJson(
|
||||
status.mentions,
|
||||
mentionListType,
|
||||
) ?: emptyList()
|
||||
val tags: List<HashTag>? = gson.fromJson(
|
||||
status.tags,
|
||||
tagListType,
|
||||
)
|
||||
val application = gson.fromJson(status.application, Status.Application::class.java)
|
||||
val emojis: List<Emoji> = gson.fromJson(
|
||||
status.emojis,
|
||||
emojisListType,
|
||||
) ?: emptyList()
|
||||
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
|
||||
val card: Card? = gson.fromJson(status.card, Card::class.java)
|
||||
|
||||
val reblog = status.reblogServerId?.let { id ->
|
||||
Status(
|
||||
id = id,
|
||||
url = status.url,
|
||||
account = account.toAccount(gson),
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = status.content.orEmpty(),
|
||||
createdAt = Date(status.createdAt),
|
||||
editedAt = status.editedAt?.let { Date(it) },
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
reblogged = status.reblogged,
|
||||
favourited = status.favourited,
|
||||
bookmarked = status.bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText,
|
||||
visibility = status.visibility,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
tags = tags,
|
||||
application = application,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = card,
|
||||
repliesCount = status.repliesCount,
|
||||
language = status.language,
|
||||
filtered = status.filtered,
|
||||
)
|
||||
}
|
||||
val status = if (reblog != null) {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = null, // no url for reblogs
|
||||
account = this.reblogAccount!!.toAccount(gson),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
reblog = reblog,
|
||||
content = "",
|
||||
createdAt = Date(status.createdAt), // lie but whatever?
|
||||
editedAt = null,
|
||||
emojis = listOf(),
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
reblogged = false,
|
||||
favourited = false,
|
||||
bookmarked = false,
|
||||
sensitive = false,
|
||||
spoilerText = "",
|
||||
visibility = status.visibility,
|
||||
attachments = ArrayList(),
|
||||
mentions = listOf(),
|
||||
tags = listOf(),
|
||||
application = null,
|
||||
pinned = status.pinned,
|
||||
muted = status.muted,
|
||||
poll = null,
|
||||
card = null,
|
||||
repliesCount = status.repliesCount,
|
||||
language = status.language,
|
||||
filtered = status.filtered,
|
||||
)
|
||||
} else {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = status.url,
|
||||
account = account.toAccount(gson),
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = status.content.orEmpty(),
|
||||
createdAt = Date(status.createdAt),
|
||||
editedAt = status.editedAt?.let { Date(it) },
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
reblogged = status.reblogged,
|
||||
favourited = status.favourited,
|
||||
bookmarked = status.bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText,
|
||||
visibility = status.visibility,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
tags = tags,
|
||||
application = application,
|
||||
pinned = status.pinned,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = card,
|
||||
repliesCount = status.repliesCount,
|
||||
language = status.language,
|
||||
filtered = status.filtered,
|
||||
)
|
||||
}
|
||||
|
||||
return StatusViewData(
|
||||
status = status,
|
||||
isExpanded = this.viewData?.expanded ?: alwaysOpenSpoiler,
|
||||
isShowingContent = this.viewData?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isCollapsed = this.viewData?.contentCollapsed ?: true,
|
||||
isDetailed = isDetailed,
|
||||
)
|
||||
}
|
|
@ -26,11 +26,12 @@ import androidx.paging.PagingState
|
|||
import androidx.paging.RemoteMediator
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.withTransaction
|
||||
import app.pachli.components.timeline.toEntity
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.db.AppDatabase
|
||||
import app.pachli.db.RemoteKeyEntity
|
||||
import app.pachli.db.RemoteKeyKind
|
||||
import app.pachli.db.TimelineAccountEntity
|
||||
import app.pachli.db.TimelineStatusEntity
|
||||
import app.pachli.db.TimelineStatusWithAccount
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.network.Links
|
||||
|
@ -258,13 +259,15 @@ class CachedTimelineRemoteMediator(
|
|||
@Transaction
|
||||
private suspend fun insertStatuses(statuses: List<Status>) {
|
||||
for (status in statuses) {
|
||||
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson))
|
||||
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount ->
|
||||
timelineDao.insertAccount(rebloggedAccount)
|
||||
timelineDao.insertAccount(TimelineAccountEntity.from(status.account, activeAccount.id, gson))
|
||||
status.reblog?.account?.let {
|
||||
val account = TimelineAccountEntity.from(it, activeAccount.id, gson)
|
||||
timelineDao.insertAccount(account)
|
||||
}
|
||||
|
||||
timelineDao.insertStatus(
|
||||
status.toEntity(
|
||||
TimelineStatusEntity.from(
|
||||
status,
|
||||
timelineUserId = activeAccount.id,
|
||||
gson = gson,
|
||||
),
|
||||
|
|
|
@ -32,7 +32,6 @@ import app.pachli.appstore.ReblogEvent
|
|||
import app.pachli.components.timeline.CachedTimelineRepository
|
||||
import app.pachli.components.timeline.FiltersRepository
|
||||
import app.pachli.components.timeline.TimelineKind
|
||||
import app.pachli.components.timeline.toViewData
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.entity.Filter
|
||||
import app.pachli.entity.Poll
|
||||
|
@ -97,10 +96,11 @@ class CachedTimelineViewModel @Inject constructor(
|
|||
.map { pagingData ->
|
||||
pagingData
|
||||
.map {
|
||||
it.toViewData(
|
||||
StatusViewData.from(
|
||||
it,
|
||||
gson,
|
||||
alwaysOpenSpoiler = activeAccount.alwaysOpenSpoiler,
|
||||
alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia,
|
||||
isExpanded = activeAccount.alwaysOpenSpoiler,
|
||||
isShowingContent = activeAccount.alwaysShowSensitiveMedia,
|
||||
)
|
||||
}
|
||||
.filter { shouldFilterStatus(it) != Filter.Action.HIDE }
|
||||
|
|
|
@ -38,7 +38,6 @@ import app.pachli.entity.Poll
|
|||
import app.pachli.network.FilterModel
|
||||
import app.pachli.settings.AccountPreferenceDataStore
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.toViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
@ -90,7 +89,8 @@ class NetworkTimelineViewModel @Inject constructor(
|
|||
return repository.getStatusStream(viewModelScope, kind = kind, initialKey = initialKey)
|
||||
.map { pagingData ->
|
||||
pagingData.map {
|
||||
modifiedViewData[it.id] ?: it.toViewData(
|
||||
modifiedViewData[it.id] ?: StatusViewData.from(
|
||||
it,
|
||||
isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive,
|
||||
isExpanded = statusDisplayOptions.value.openSpoiler,
|
||||
isCollapsed = true,
|
||||
|
|
|
@ -21,10 +21,10 @@ import androidx.lifecycle.viewModelScope
|
|||
import app.pachli.appstore.EventHub
|
||||
import app.pachli.appstore.PreferenceChangedEvent
|
||||
import app.pachli.entity.Filter
|
||||
import app.pachli.entity.TrendingTag
|
||||
import app.pachli.entity.end
|
||||
import app.pachli.entity.start
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.util.toViewData
|
||||
import app.pachli.viewdata.TrendingViewData
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import kotlinx.coroutines.async
|
||||
|
@ -97,7 +97,7 @@ class TrendingTagsViewModel @Inject constructor(
|
|||
} ?: false
|
||||
}
|
||||
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
|
||||
.toViewData()
|
||||
.toTrendingViewDataTag()
|
||||
|
||||
val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
|
||||
TrendingTagsUiState(listOf(header) + tags, LoadingState.LOADED)
|
||||
|
@ -114,6 +114,14 @@ class TrendingTagsViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private fun List<TrendingTag>.toTrendingViewDataTag(): List<TrendingViewData.Tag> {
|
||||
val maxTrendingValue = flatMap { tag -> tag.history }
|
||||
.mapNotNull { it.uses.toLongOrNull() }
|
||||
.maxOrNull() ?: 1
|
||||
|
||||
return map { TrendingViewData.Tag.from(it, maxTrendingValue) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TrendingViewModel"
|
||||
}
|
||||
|
|
|
@ -28,19 +28,16 @@ import app.pachli.appstore.StatusComposedEvent
|
|||
import app.pachli.appstore.StatusDeletedEvent
|
||||
import app.pachli.appstore.StatusEditedEvent
|
||||
import app.pachli.components.timeline.CachedTimelineRepository
|
||||
import app.pachli.components.timeline.toViewData
|
||||
import app.pachli.components.timeline.util.ifExpected
|
||||
import app.pachli.db.AccountEntity
|
||||
import app.pachli.db.AccountManager
|
||||
import app.pachli.db.AppDatabase
|
||||
import app.pachli.db.StatusViewDataEntity
|
||||
import app.pachli.entity.Filter
|
||||
import app.pachli.entity.FilterV1
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.network.FilterModel
|
||||
import app.pachli.network.MastodonApi
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.toViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import at.connyduck.calladapter.networkresult.getOrElse
|
||||
|
@ -113,25 +110,32 @@ class ViewThreadViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
Log.d(TAG, "Finding status with: $id")
|
||||
val contextCall = async { api.statusContext(id) }
|
||||
val timelineStatus = db.timelineDao().getStatus(id)
|
||||
val timelineStatusWithAccount = db.timelineDao().getStatus(id)
|
||||
|
||||
var detailedStatus = if (timelineStatus != null) {
|
||||
var detailedStatus = if (timelineStatusWithAccount != null) {
|
||||
Log.d(TAG, "Loaded status from local timeline")
|
||||
val viewData = timelineStatus.toViewData(
|
||||
gson,
|
||||
alwaysOpenSpoiler = alwaysOpenSpoiler,
|
||||
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
|
||||
isDetailed = true,
|
||||
)
|
||||
val status = timelineStatusWithAccount.toStatus(gson)
|
||||
|
||||
// Return the correct status, depending on which one matched. If you do not do
|
||||
// this the status IDs will be different between the status that's displayed with
|
||||
// ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent
|
||||
// status content is the same. Then the status flickers as it is drawn twice.
|
||||
if (viewData.actionableId == id) {
|
||||
viewData.actionable.toViewData(isDetailed = true, viewData)
|
||||
if (status.actionableId == id) {
|
||||
StatusViewData.from(
|
||||
status = status.actionableStatus,
|
||||
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: alwaysOpenSpoiler,
|
||||
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: alwaysShowSensitiveMedia,
|
||||
isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true,
|
||||
isDetailed = true,
|
||||
)
|
||||
} else {
|
||||
viewData
|
||||
StatusViewData.from(
|
||||
timelineStatusWithAccount,
|
||||
gson,
|
||||
isExpanded = alwaysOpenSpoiler,
|
||||
isShowingContent = alwaysShowSensitiveMedia,
|
||||
isDetailed = true,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Loaded status from network")
|
||||
|
@ -139,7 +143,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
_uiState.value = ThreadUiState.Error(exception)
|
||||
return@launch
|
||||
}
|
||||
result.toViewData(isDetailed = true)
|
||||
StatusViewData.fromStatusAndUiState(result, isDetailed = true)
|
||||
}
|
||||
|
||||
_uiState.value = ThreadUiState.LoadingThread(
|
||||
|
@ -151,9 +155,16 @@ class ViewThreadViewModel @Inject constructor(
|
|||
// compared to the remote one. Now the user has a working UI do a background fetch
|
||||
// for the status. Ignore errors, the user still has a functioning UI if the fetch
|
||||
// failed.
|
||||
if (timelineStatus != null) {
|
||||
val viewData = api.status(id).getOrNull()?.toViewData(isDetailed = true, detailedStatus)
|
||||
if (viewData != null) { detailedStatus = viewData }
|
||||
if (timelineStatusWithAccount != null) {
|
||||
api.status(id).getOrNull()?.let {
|
||||
detailedStatus = StatusViewData.from(
|
||||
it,
|
||||
isShowingContent = detailedStatus.isShowingContent,
|
||||
isExpanded = detailedStatus.isExpanded,
|
||||
isCollapsed = detailedStatus.isCollapsed,
|
||||
isDetailed = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val contextResult = contextCall.await()
|
||||
|
@ -163,12 +174,26 @@ class ViewThreadViewModel @Inject constructor(
|
|||
val cachedViewData = repository.getStatusViewData(ids)
|
||||
val ancestors = statusContext.ancestors.map {
|
||||
status ->
|
||||
status.toViewData(statusViewDataEntity = cachedViewData[status.id])
|
||||
}.filter()
|
||||
val svd = cachedViewData[status.id]
|
||||
StatusViewData.from(
|
||||
status,
|
||||
isShowingContent = svd?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = svd?.expanded ?: alwaysOpenSpoiler,
|
||||
isCollapsed = svd?.contentCollapsed ?: true,
|
||||
isDetailed = false,
|
||||
)
|
||||
}.filterByFilterAction()
|
||||
val descendants = statusContext.descendants.map {
|
||||
status ->
|
||||
status.toViewData(statusViewDataEntity = cachedViewData[status.id])
|
||||
}.filter()
|
||||
val svd = cachedViewData[status.id]
|
||||
StatusViewData.from(
|
||||
status,
|
||||
isShowingContent = svd?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = svd?.expanded ?: alwaysOpenSpoiler,
|
||||
isCollapsed = svd?.contentCollapsed ?: true,
|
||||
isDetailed = false,
|
||||
)
|
||||
}.filterByFilterAction()
|
||||
val statuses = ancestors + detailedStatus + descendants
|
||||
|
||||
_uiState.value = ThreadUiState.Success(
|
||||
|
@ -345,7 +370,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
|
||||
// there is a new reply to the detailed status or below -> display it
|
||||
val newStatuses = statuses.subList(0, repliedIndex + 1) +
|
||||
eventStatus.toViewData() +
|
||||
StatusViewData.fromStatusAndUiState(eventStatus) +
|
||||
statuses.subList(repliedIndex + 1, statuses.size)
|
||||
uiState.copy(statusViewData = newStatuses)
|
||||
} else {
|
||||
|
@ -359,7 +384,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
uiState.copy(
|
||||
statusViewData = uiState.statusViewData.map { status ->
|
||||
if (status.actionableId == event.originalId) {
|
||||
event.status.toViewData()
|
||||
StatusViewData.fromStatusAndUiState(event.status)
|
||||
} else {
|
||||
status
|
||||
}
|
||||
|
@ -465,7 +490,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
|
||||
private fun updateStatuses() {
|
||||
updateSuccess { uiState ->
|
||||
val statuses = uiState.statusViewData.filter()
|
||||
val statuses = uiState.statusViewData.filterByFilterAction()
|
||||
uiState.copy(
|
||||
statusViewData = statuses,
|
||||
revealButton = statuses.getRevealButtonState(),
|
||||
|
@ -473,7 +498,7 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun List<StatusViewData>.filter(): List<StatusViewData> {
|
||||
private fun List<StatusViewData>.filterByFilterAction(): List<StatusViewData> {
|
||||
return filter { status ->
|
||||
if (status.isDetailed) {
|
||||
true
|
||||
|
@ -485,35 +510,14 @@ class ViewThreadViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert the status to a [StatusViewData], copying the view data from [statusViewData]
|
||||
* Creates a [StatusViewData] from `status`, copying over the viewdata state from the same
|
||||
* status in _uiState (if that status exists).
|
||||
*/
|
||||
private fun Status.toViewData(isDetailed: Boolean = false, statusViewData: StatusViewData): StatusViewData {
|
||||
return toViewData(
|
||||
isShowingContent = statusViewData.isShowingContent,
|
||||
isExpanded = statusViewData.isExpanded,
|
||||
isCollapsed = statusViewData.isCollapsed,
|
||||
isDetailed = isDetailed,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the status to a [StatusViewData], copying the view data from [statusViewDataEntity]
|
||||
*/
|
||||
private fun Status.toViewData(isDetailed: Boolean = false, statusViewDataEntity: StatusViewDataEntity?): StatusViewData {
|
||||
return toViewData(
|
||||
isShowingContent = statusViewDataEntity?.contentShowing ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
|
||||
isExpanded = statusViewDataEntity?.expanded ?: alwaysOpenSpoiler,
|
||||
isCollapsed = statusViewDataEntity?.contentCollapsed ?: !isDetailed,
|
||||
isDetailed = isDetailed,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Status.toViewData(
|
||||
isDetailed: Boolean = false,
|
||||
): StatusViewData {
|
||||
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == this.id }
|
||||
return toViewData(
|
||||
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
|
||||
private fun StatusViewData.Companion.fromStatusAndUiState(status: Status, isDetailed: Boolean = false): StatusViewData {
|
||||
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == status.id }
|
||||
return from(
|
||||
status,
|
||||
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
|
||||
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
|
||||
isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
|
||||
isDetailed = oldStatus?.isDetailed ?: isDetailed,
|
||||
|
|
|
@ -35,7 +35,7 @@ import app.pachli.util.parseAsMastodonHtml
|
|||
import app.pachli.util.setClickableText
|
||||
import app.pachli.util.show
|
||||
import app.pachli.util.visible
|
||||
import app.pachli.viewdata.toViewData
|
||||
import app.pachli.viewdata.PollOptionViewData
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.xml.sax.XMLReader
|
||||
|
@ -138,7 +138,7 @@ class ViewEditsAdapter(
|
|||
binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context)
|
||||
|
||||
pollAdapter.setup(
|
||||
options = edit.poll.options.map { it.toViewData(false) },
|
||||
options = edit.poll.options.map { PollOptionViewData.from(it, false) },
|
||||
voteCount = 0,
|
||||
votersCount = null,
|
||||
emojis = edit.emojis,
|
||||
|
|
|
@ -20,8 +20,18 @@ import androidx.room.Entity
|
|||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.TypeConverters
|
||||
import app.pachli.entity.Attachment
|
||||
import app.pachli.entity.Card
|
||||
import app.pachli.entity.Emoji
|
||||
import app.pachli.entity.FilterResult
|
||||
import app.pachli.entity.HashTag
|
||||
import app.pachli.entity.Poll
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.TimelineAccount
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.lang.reflect.Type
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* We're trying to play smart here. Server sends us reblogs as two entities one embedded into
|
||||
|
@ -82,7 +92,43 @@ data class TimelineStatusEntity(
|
|||
val card: String?,
|
||||
val language: String?,
|
||||
val filtered: List<FilterResult>?,
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
fun from(status: Status, timelineUserId: Long, gson: Gson) = TimelineStatusEntity(
|
||||
serverId = status.id,
|
||||
url = status.actionableStatus.url,
|
||||
timelineUserId = timelineUserId,
|
||||
authorServerId = status.actionableStatus.account.id,
|
||||
inReplyToId = status.actionableStatus.inReplyToId,
|
||||
inReplyToAccountId = status.actionableStatus.inReplyToAccountId,
|
||||
content = status.actionableStatus.content,
|
||||
createdAt = status.actionableStatus.createdAt.time,
|
||||
editedAt = status.actionableStatus.editedAt?.time,
|
||||
emojis = status.actionableStatus.emojis.let(gson::toJson),
|
||||
reblogsCount = status.actionableStatus.reblogsCount,
|
||||
favouritesCount = status.actionableStatus.favouritesCount,
|
||||
reblogged = status.actionableStatus.reblogged,
|
||||
favourited = status.actionableStatus.favourited,
|
||||
bookmarked = status.actionableStatus.bookmarked,
|
||||
sensitive = status.actionableStatus.sensitive,
|
||||
spoilerText = status.actionableStatus.spoilerText,
|
||||
visibility = status.actionableStatus.visibility,
|
||||
attachments = status.actionableStatus.attachments.let(gson::toJson),
|
||||
mentions = status.actionableStatus.mentions.let(gson::toJson),
|
||||
tags = status.actionableStatus.tags.let(gson::toJson),
|
||||
application = status.actionableStatus.application.let(gson::toJson),
|
||||
reblogServerId = status.reblog?.id,
|
||||
reblogAccountId = status.reblog?.let { status.account.id },
|
||||
poll = status.actionableStatus.poll.let(gson::toJson),
|
||||
muted = status.actionableStatus.muted,
|
||||
pinned = status.actionableStatus.pinned == true,
|
||||
card = status.actionableStatus.card?.let(gson::toJson),
|
||||
repliesCount = status.actionableStatus.repliesCount,
|
||||
language = status.actionableStatus.language,
|
||||
filtered = status.actionableStatus.filtered,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Entity(
|
||||
primaryKeys = ["serverId", "timelineUserId"],
|
||||
|
@ -97,7 +143,35 @@ data class TimelineAccountEntity(
|
|||
val avatar: String,
|
||||
val emojis: String,
|
||||
val bot: Boolean,
|
||||
)
|
||||
) {
|
||||
fun toTimelineAccount(gson: Gson): TimelineAccount {
|
||||
return TimelineAccount(
|
||||
id = serverId,
|
||||
localUsername = localUsername,
|
||||
username = username,
|
||||
displayName = displayName,
|
||||
note = "",
|
||||
url = url,
|
||||
avatar = avatar,
|
||||
bot = bot,
|
||||
emojis = gson.fromJson(emojis, emojisListType),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(timelineAccount: TimelineAccount, accountId: Long, gson: Gson) = TimelineAccountEntity(
|
||||
serverId = timelineAccount.id,
|
||||
timelineUserId = accountId,
|
||||
localUsername = timelineAccount.localUsername,
|
||||
username = timelineAccount.username,
|
||||
displayName = timelineAccount.name,
|
||||
url = timelineAccount.url,
|
||||
avatar = timelineAccount.avatar,
|
||||
emojis = gson.toJson(timelineAccount.emojis),
|
||||
bot = timelineAccount.bot,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The local view data for a status.
|
||||
|
@ -120,6 +194,11 @@ data class StatusViewDataEntity(
|
|||
val contentCollapsed: Boolean,
|
||||
)
|
||||
|
||||
val attachmentArrayListType: Type = object : TypeToken<ArrayList<Attachment>>() {}.type
|
||||
val emojisListType: Type = object : TypeToken<List<Emoji>>() {}.type
|
||||
val mentionListType: Type = object : TypeToken<List<Status.Mention>>() {}.type
|
||||
val tagListType: Type = object : TypeToken<List<HashTag>>() {}.type
|
||||
|
||||
data class TimelineStatusWithAccount(
|
||||
@Embedded
|
||||
val status: TimelineStatusEntity,
|
||||
|
@ -129,4 +208,125 @@ data class TimelineStatusWithAccount(
|
|||
val reblogAccount: TimelineAccountEntity? = null, // null when no reblog
|
||||
@Embedded(prefix = "svd_")
|
||||
val viewData: StatusViewDataEntity? = null,
|
||||
)
|
||||
) {
|
||||
fun toStatus(gson: Gson): Status {
|
||||
val attachments: ArrayList<Attachment> = gson.fromJson(
|
||||
status.attachments,
|
||||
attachmentArrayListType,
|
||||
) ?: arrayListOf()
|
||||
val mentions: List<Status.Mention> = gson.fromJson(
|
||||
status.mentions,
|
||||
mentionListType,
|
||||
) ?: emptyList()
|
||||
val tags: List<HashTag>? = gson.fromJson(
|
||||
status.tags,
|
||||
tagListType,
|
||||
)
|
||||
val application = gson.fromJson(status.application, Status.Application::class.java)
|
||||
val emojis: List<Emoji> = gson.fromJson(
|
||||
status.emojis,
|
||||
emojisListType,
|
||||
) ?: emptyList()
|
||||
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
|
||||
val card: Card? = gson.fromJson(status.card, Card::class.java)
|
||||
|
||||
val reblog = status.reblogServerId?.let { id ->
|
||||
Status(
|
||||
id = id,
|
||||
url = status.url,
|
||||
account = account.toTimelineAccount(gson),
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = status.content.orEmpty(),
|
||||
createdAt = Date(status.createdAt),
|
||||
editedAt = status.editedAt?.let { Date(it) },
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
reblogged = status.reblogged,
|
||||
favourited = status.favourited,
|
||||
bookmarked = status.bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText,
|
||||
visibility = status.visibility,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
tags = tags,
|
||||
application = application,
|
||||
pinned = false,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = card,
|
||||
repliesCount = status.repliesCount,
|
||||
language = status.language,
|
||||
filtered = status.filtered,
|
||||
)
|
||||
}
|
||||
return if (reblog != null) {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = null, // no url for reblogs
|
||||
account = reblogAccount!!.toTimelineAccount(gson),
|
||||
inReplyToId = null,
|
||||
inReplyToAccountId = null,
|
||||
reblog = reblog,
|
||||
content = "",
|
||||
createdAt = Date(status.createdAt), // lie but whatever?
|
||||
editedAt = null,
|
||||
emojis = listOf(),
|
||||
reblogsCount = 0,
|
||||
favouritesCount = 0,
|
||||
reblogged = false,
|
||||
favourited = false,
|
||||
bookmarked = false,
|
||||
sensitive = false,
|
||||
spoilerText = "",
|
||||
visibility = status.visibility,
|
||||
attachments = ArrayList(),
|
||||
mentions = listOf(),
|
||||
tags = listOf(),
|
||||
application = null,
|
||||
pinned = status.pinned,
|
||||
muted = status.muted,
|
||||
poll = null,
|
||||
card = null,
|
||||
repliesCount = status.repliesCount,
|
||||
language = status.language,
|
||||
filtered = status.filtered,
|
||||
)
|
||||
} else {
|
||||
Status(
|
||||
id = status.serverId,
|
||||
url = status.url,
|
||||
account = account.toTimelineAccount(gson),
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
reblog = null,
|
||||
content = status.content.orEmpty(),
|
||||
createdAt = Date(status.createdAt),
|
||||
editedAt = status.editedAt?.let { Date(it) },
|
||||
emojis = emojis,
|
||||
reblogsCount = status.reblogsCount,
|
||||
favouritesCount = status.favouritesCount,
|
||||
reblogged = status.reblogged,
|
||||
favourited = status.favourited,
|
||||
bookmarked = status.bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText,
|
||||
visibility = status.visibility,
|
||||
attachments = attachments,
|
||||
mentions = mentions,
|
||||
tags = tags,
|
||||
application = application,
|
||||
pinned = status.pinned,
|
||||
muted = status.muted,
|
||||
poll = poll,
|
||||
card = card,
|
||||
repliesCount = status.repliesCount,
|
||||
language = status.language,
|
||||
filtered = status.filtered,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -261,7 +261,10 @@ class StatusViewHelper(private val itemView: View) {
|
|||
}
|
||||
}
|
||||
|
||||
fun setupPollReadonly(poll: PollViewData?, emojis: List<Emoji>, statusDisplayOptions: StatusDisplayOptions) {
|
||||
/**
|
||||
* Configures and shows poll views based on [poll].
|
||||
*/
|
||||
fun setupPollReadonly(poll: PollViewData, emojis: List<Emoji>, statusDisplayOptions: StatusDisplayOptions) {
|
||||
val pollResults = listOf<TextView>(
|
||||
itemView.findViewById(R.id.status_poll_option_result_0),
|
||||
itemView.findViewById(R.id.status_poll_option_result_1),
|
||||
|
@ -271,19 +274,29 @@ class StatusViewHelper(private val itemView: View) {
|
|||
|
||||
val pollDescription = itemView.findViewById<TextView>(R.id.status_poll_description)
|
||||
|
||||
if (poll == null) {
|
||||
for (pollResult in pollResults) {
|
||||
pollResult.visibility = View.GONE
|
||||
}
|
||||
pollDescription.visibility = View.GONE
|
||||
} else {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val timestamp = System.currentTimeMillis()
|
||||
|
||||
setupPollResult(poll, emojis, pollResults, statusDisplayOptions.animateEmojis)
|
||||
setupPollResult(poll, emojis, pollResults, statusDisplayOptions.animateEmojis)
|
||||
|
||||
pollDescription.visibility = View.VISIBLE
|
||||
pollDescription.text = getPollInfoText(timestamp, poll, pollDescription, statusDisplayOptions.useAbsoluteTime)
|
||||
pollDescription.show()
|
||||
pollDescription.text = getPollInfoText(timestamp, poll, pollDescription, statusDisplayOptions.useAbsoluteTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides views related to polls.
|
||||
*/
|
||||
fun hidePoll() {
|
||||
val pollResults = listOf<TextView>(
|
||||
itemView.findViewById(R.id.status_poll_option_result_0),
|
||||
itemView.findViewById(R.id.status_poll_option_result_1),
|
||||
itemView.findViewById(R.id.status_poll_option_result_2),
|
||||
itemView.findViewById(R.id.status_poll_option_result_3),
|
||||
)
|
||||
|
||||
for (pollResult in pollResults) {
|
||||
pollResult.hide()
|
||||
}
|
||||
itemView.findViewById<TextView>(R.id.status_poll_description).hide()
|
||||
}
|
||||
|
||||
private fun getPollInfoText(timestamp: Long, poll: PollViewData, pollDescription: TextView, useAbsoluteTime: Boolean): CharSequence {
|
||||
|
|
|
@ -1,97 +0,0 @@
|
|||
/*
|
||||
* Copyright 2023 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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>.
|
||||
*/
|
||||
|
||||
@file:JvmName("ViewDataUtils")
|
||||
|
||||
/* Copyright 2017 Andrew Dawson
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 app.pachli.util
|
||||
|
||||
import app.pachli.entity.Filter
|
||||
import app.pachli.entity.Notification
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.TrendingTag
|
||||
import app.pachli.viewdata.NotificationViewData
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import app.pachli.viewdata.TrendingViewData
|
||||
|
||||
fun Status.toViewData(
|
||||
isShowingContent: Boolean,
|
||||
isExpanded: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
isDetailed: Boolean = false,
|
||||
filterAction: Filter.Action = app.pachli.entity.Filter.Action.NONE,
|
||||
): StatusViewData {
|
||||
return StatusViewData(
|
||||
status = this,
|
||||
isShowingContent = isShowingContent,
|
||||
isCollapsed = isCollapsed,
|
||||
isExpanded = isExpanded,
|
||||
isDetailed = isDetailed,
|
||||
filterAction = filterAction,
|
||||
)
|
||||
}
|
||||
|
||||
fun Notification.toViewData(
|
||||
isShowingContent: Boolean,
|
||||
isExpanded: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
filterAction: Filter.Action,
|
||||
): NotificationViewData {
|
||||
return NotificationViewData(
|
||||
this.type,
|
||||
this.id,
|
||||
this.account,
|
||||
this.status?.toViewData(
|
||||
isShowingContent,
|
||||
isExpanded,
|
||||
isCollapsed,
|
||||
filterAction = filterAction,
|
||||
),
|
||||
this.report,
|
||||
)
|
||||
}
|
||||
|
||||
fun List<TrendingTag>.toViewData(): List<TrendingViewData.Tag> {
|
||||
val maxTrendingValue = flatMap { tag -> tag.history }
|
||||
.mapNotNull { it.uses.toLongOrNull() }
|
||||
.maxOrNull() ?: 1
|
||||
|
||||
return map { tag ->
|
||||
|
||||
val reversedHistory = tag.history.asReversed()
|
||||
|
||||
TrendingViewData.Tag(
|
||||
name = tag.name,
|
||||
usage = reversedHistory.mapNotNull { it.uses.toLongOrNull() },
|
||||
accounts = reversedHistory.mapNotNull { it.accounts.toLongOrNull() },
|
||||
maxTrendingValue = maxTrendingValue,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
package app.pachli.viewdata
|
||||
|
||||
import app.pachli.entity.Filter
|
||||
import app.pachli.entity.Notification
|
||||
import app.pachli.entity.Report
|
||||
import app.pachli.entity.TimelineAccount
|
||||
|
@ -27,4 +28,28 @@ data class NotificationViewData(
|
|||
val account: TimelineAccount,
|
||||
var statusViewData: StatusViewData?,
|
||||
val report: Report?,
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
fun from(
|
||||
notification: Notification,
|
||||
isShowingContent: Boolean,
|
||||
isExpanded: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
filterAction: Filter.Action,
|
||||
) = NotificationViewData(
|
||||
notification.type,
|
||||
notification.id,
|
||||
notification.account,
|
||||
notification.status?.let { status ->
|
||||
StatusViewData.from(
|
||||
status,
|
||||
isShowingContent,
|
||||
isExpanded,
|
||||
isCollapsed,
|
||||
filterAction = filterAction,
|
||||
)
|
||||
},
|
||||
notification.report,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,14 +34,36 @@ data class PollViewData(
|
|||
val votersCount: Int?,
|
||||
val options: List<PollOptionViewData>,
|
||||
var voted: Boolean,
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
fun from(poll: Poll) = PollViewData(
|
||||
id = poll.id,
|
||||
expiresAt = poll.expiresAt,
|
||||
expired = poll.expired,
|
||||
multiple = poll.multiple,
|
||||
votesCount = poll.votesCount,
|
||||
votersCount = poll.votersCount,
|
||||
options = poll.options.mapIndexed { index, option -> PollOptionViewData.from(option, poll.ownVotes?.contains(index) == true) },
|
||||
voted = poll.voted,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class PollOptionViewData(
|
||||
val title: String,
|
||||
var votesCount: Int,
|
||||
var selected: Boolean,
|
||||
var voted: Boolean,
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
fun from(pollOption: PollOption, voted: Boolean) = PollOptionViewData(
|
||||
title = pollOption.title,
|
||||
votesCount = pollOption.votesCount,
|
||||
selected = false,
|
||||
voted = voted,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int {
|
||||
return if (fraction == 0) {
|
||||
|
@ -61,26 +83,3 @@ fun buildDescription(title: String, percent: Int, voted: Boolean, context: Conte
|
|||
}
|
||||
return builder.append(title)
|
||||
}
|
||||
|
||||
fun Poll?.toViewData(): PollViewData? {
|
||||
if (this == null) return null
|
||||
return PollViewData(
|
||||
id = id,
|
||||
expiresAt = expiresAt,
|
||||
expired = expired,
|
||||
multiple = multiple,
|
||||
votesCount = votesCount,
|
||||
votersCount = votersCount,
|
||||
options = options.mapIndexed { index, option -> option.toViewData(ownVotes?.contains(index) == true) },
|
||||
voted = voted,
|
||||
)
|
||||
}
|
||||
|
||||
fun PollOption.toViewData(voted: Boolean): PollOptionViewData {
|
||||
return PollOptionViewData(
|
||||
title = title,
|
||||
votesCount = votesCount,
|
||||
selected = false,
|
||||
voted = voted,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -16,11 +16,16 @@ package app.pachli.viewdata
|
|||
|
||||
import android.os.Build
|
||||
import android.text.Spanned
|
||||
import app.pachli.components.conversation.ConversationAccountEntity
|
||||
import app.pachli.components.conversation.ConversationStatusEntity
|
||||
import app.pachli.db.TimelineStatusWithAccount
|
||||
import app.pachli.entity.Filter
|
||||
import app.pachli.entity.Poll
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.util.parseAsMastodonHtml
|
||||
import app.pachli.util.replaceCrashingCharacters
|
||||
import app.pachli.util.shouldTrimStatus
|
||||
import com.google.gson.Gson
|
||||
|
||||
/**
|
||||
* Data required to display a status.
|
||||
|
@ -113,4 +118,111 @@ data class StatusViewData(
|
|||
|
||||
/** Helper for Java */
|
||||
fun copyWithCollapsed(isCollapsed: Boolean) = copy(isCollapsed = isCollapsed)
|
||||
|
||||
fun toConversationStatusEntity(
|
||||
favourited: Boolean = status.favourited,
|
||||
bookmarked: Boolean = status.bookmarked,
|
||||
muted: Boolean = status.muted ?: false,
|
||||
poll: Poll? = status.poll,
|
||||
expanded: Boolean = isExpanded,
|
||||
collapsed: Boolean = isCollapsed,
|
||||
showingHiddenContent: Boolean = isShowingContent,
|
||||
) = ConversationStatusEntity(
|
||||
id = id,
|
||||
url = status.url,
|
||||
inReplyToId = status.inReplyToId,
|
||||
inReplyToAccountId = status.inReplyToAccountId,
|
||||
account = ConversationAccountEntity.from(status.account),
|
||||
content = status.content,
|
||||
createdAt = status.createdAt,
|
||||
editedAt = status.editedAt,
|
||||
emojis = status.emojis,
|
||||
favouritesCount = status.favouritesCount,
|
||||
repliesCount = status.repliesCount,
|
||||
favourited = favourited,
|
||||
bookmarked = bookmarked,
|
||||
sensitive = status.sensitive,
|
||||
spoilerText = status.spoilerText,
|
||||
attachments = status.attachments,
|
||||
mentions = status.mentions,
|
||||
tags = status.tags,
|
||||
showingHiddenContent = showingHiddenContent,
|
||||
expanded = expanded,
|
||||
collapsed = collapsed,
|
||||
muted = muted,
|
||||
poll = poll,
|
||||
language = status.language,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun from(
|
||||
status: Status,
|
||||
isShowingContent: Boolean,
|
||||
isExpanded: Boolean,
|
||||
isCollapsed: Boolean,
|
||||
isDetailed: Boolean = false,
|
||||
filterAction: Filter.Action = Filter.Action.NONE,
|
||||
) = StatusViewData(
|
||||
status = status,
|
||||
isShowingContent = isShowingContent,
|
||||
isCollapsed = isCollapsed,
|
||||
isExpanded = isExpanded,
|
||||
isDetailed = isDetailed,
|
||||
filterAction = filterAction,
|
||||
)
|
||||
|
||||
fun from(conversationStatusEntity: ConversationStatusEntity) = StatusViewData(
|
||||
status = Status(
|
||||
id = conversationStatusEntity.id,
|
||||
url = conversationStatusEntity.url,
|
||||
account = conversationStatusEntity.account.toAccount(),
|
||||
inReplyToId = conversationStatusEntity.inReplyToId,
|
||||
inReplyToAccountId = conversationStatusEntity.inReplyToAccountId,
|
||||
content = conversationStatusEntity.content,
|
||||
reblog = null,
|
||||
createdAt = conversationStatusEntity.createdAt,
|
||||
editedAt = conversationStatusEntity.editedAt,
|
||||
emojis = conversationStatusEntity.emojis,
|
||||
reblogsCount = 0,
|
||||
favouritesCount = conversationStatusEntity.favouritesCount,
|
||||
repliesCount = conversationStatusEntity.repliesCount,
|
||||
reblogged = false,
|
||||
favourited = conversationStatusEntity.favourited,
|
||||
bookmarked = conversationStatusEntity.bookmarked,
|
||||
sensitive = conversationStatusEntity.sensitive,
|
||||
spoilerText = conversationStatusEntity.spoilerText,
|
||||
visibility = Status.Visibility.DIRECT,
|
||||
attachments = conversationStatusEntity.attachments,
|
||||
mentions = conversationStatusEntity.mentions,
|
||||
tags = conversationStatusEntity.tags,
|
||||
application = null,
|
||||
pinned = false,
|
||||
muted = conversationStatusEntity.muted,
|
||||
poll = conversationStatusEntity.poll,
|
||||
card = null,
|
||||
language = conversationStatusEntity.language,
|
||||
filtered = null,
|
||||
),
|
||||
isExpanded = conversationStatusEntity.expanded,
|
||||
isShowingContent = conversationStatusEntity.showingHiddenContent,
|
||||
isCollapsed = conversationStatusEntity.collapsed,
|
||||
)
|
||||
|
||||
fun from(
|
||||
timelineStatusWithAccount: TimelineStatusWithAccount,
|
||||
gson: Gson,
|
||||
isExpanded: Boolean,
|
||||
isShowingContent: Boolean,
|
||||
isDetailed: Boolean = false,
|
||||
): StatusViewData {
|
||||
val status = timelineStatusWithAccount.toStatus(gson)
|
||||
return StatusViewData(
|
||||
status = status,
|
||||
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: isExpanded,
|
||||
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (isShowingContent || !status.actionableStatus.sensitive),
|
||||
isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true,
|
||||
isDetailed = isDetailed,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
package app.pachli.viewdata
|
||||
|
||||
import app.pachli.entity.TrendingTag
|
||||
import java.util.Date
|
||||
|
||||
sealed class TrendingViewData {
|
||||
|
@ -36,5 +37,19 @@ sealed class TrendingViewData {
|
|||
) : TrendingViewData() {
|
||||
override val id: String
|
||||
get() = name
|
||||
|
||||
companion object {
|
||||
fun from(trendingTag: TrendingTag, maxTrendingValue: Long): Tag {
|
||||
// Reverse the list to put oldest items first
|
||||
val reversedHistory = trendingTag.history.asReversed()
|
||||
|
||||
return Tag(
|
||||
name = trendingTag.name,
|
||||
usage = reversedHistory.map { it.uses.toLongOrNull() ?: 0 },
|
||||
accounts = reversedHistory.map { it.accounts.toLongOrNull() ?: 0 },
|
||||
maxTrendingValue = maxTrendingValue,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package app.pachli.components.timeline
|
||||
|
||||
import app.pachli.db.StatusViewDataEntity
|
||||
import app.pachli.db.TimelineAccountEntity
|
||||
import app.pachli.db.TimelineStatusEntity
|
||||
import app.pachli.db.TimelineStatusWithAccount
|
||||
import app.pachli.entity.Status
|
||||
import app.pachli.entity.TimelineAccount
|
||||
|
@ -95,11 +97,13 @@ fun mockStatusEntityWithAccount(
|
|||
val gson = Gson()
|
||||
|
||||
return TimelineStatusWithAccount(
|
||||
status = mockedStatus.toEntity(
|
||||
status = TimelineStatusEntity.from(
|
||||
mockedStatus,
|
||||
timelineUserId = userId,
|
||||
gson = gson,
|
||||
),
|
||||
account = mockedStatus.account.toEntity(
|
||||
account = TimelineAccountEntity.from(
|
||||
mockedStatus.account,
|
||||
accountId = userId,
|
||||
gson = gson,
|
||||
),
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
# Code style guide
|
||||
|
||||
## Synopsis
|
||||
|
||||
This document describes aspects of code style that are not enforced with linters or formatting tools but the project still tries to adhere to. Some of these are things that developers might reasonably disagree on, but the project has a specific stance.
|
||||
|
||||
## Topics
|
||||
|
||||
### On converting between types
|
||||
|
||||
Roughly speaking the code that handles data from the user's server deals with three variations of that data.
|
||||
|
||||
1. Data from the network
|
||||
2. Data cached locally (either to disk or memory)
|
||||
3. Data displayed to the user
|
||||
|
||||
There must be code to convert between those representations, and it's important to make sure there isn't a loop in the dependency graph between the types.
|
||||
|
||||
Consider two types, `N`, representing data received from the network, and `C`, representing data that will be cached.
|
||||
|
||||
The wrong way to do it is code like:
|
||||
|
||||
```kotlin
|
||||
/** In N.kt (the network data type) */
|
||||
import C
|
||||
|
||||
data class N() {
|
||||
fun toC(): C { /* return a C created from a N */ }
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
/** in C.kt (the cache data type) */
|
||||
import N
|
||||
|
||||
data class C() {
|
||||
fun toN(): N { /* return a N created from a C */ }
|
||||
}
|
||||
```
|
||||
|
||||
This creates a loop in their dependency graph as they import each other's types.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
direction RL
|
||||
class N{
|
||||
toC() C
|
||||
}
|
||||
|
||||
class C{
|
||||
toN() N
|
||||
}
|
||||
|
||||
C ..> N: Imports
|
||||
N ..> C: Imports
|
||||
```
|
||||
|
||||
This is a problem because:
|
||||
|
||||
- They can't be placed in separate modules
|
||||
- Modifying code in `N` can cause `C` to be recompiled, and vice-versa
|
||||
|
||||
To fix this:
|
||||
|
||||
1. Pick one type as being "higher" in the dependency tree than the other
|
||||
2. Remove the `to...()` method from the lower type, and implement it as a companion `from()` method on the higher type
|
||||
|
||||
In Pachli the dependency hierarchy is (higher types on the left):
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
ViewData --> ViewModel --> Cache --> Network --> Core
|
||||
```
|
||||
|
||||
so the previous example involving a network type and a cache type would instead be written as:
|
||||
|
||||
```kotlin
|
||||
/** In N.kt (the network data type) */
|
||||
data class N() {
|
||||
// No import, no toC() method
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
/** in C.kt (the cache data type) */
|
||||
import N
|
||||
|
||||
data class C() {
|
||||
fun toN(): N { /* return a N created from a C */ }
|
||||
companion object {
|
||||
fun from(n: N): C {
|
||||
// code from the N.toC() in the previous example
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now the dependency between the two types is:
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
direction RL
|
||||
class N{
|
||||
}
|
||||
|
||||
class C{
|
||||
toN() N
|
||||
from(n: N)$ C
|
||||
}
|
||||
|
||||
C <.. N: Imports
|
||||
```
|
||||
|
||||
The circular dependency is gone, the `N` type can easily be placed in a separate module to the `C` type, and changes to the `C` type will not require the `N` type to be recompiled.
|
||||
|
||||
In these examples the `from` method could also have been written as a secondary constructor instead of a static factory method in the companion object. We prefer static factory methods over secondary constructors because:
|
||||
|
||||
1. Functions have names that can more clearly indicate their intent
|
||||
2. Functions can return objects of any subtype. If the example class `C` had multiple subtypes the correct subtype could be returned based on properties of `N`
|
||||
3. Functions can return null or other values to signify an error. Perhaps the network type is expected to contain a particular property, but the server has a bug and returned data without that property.
|
||||
4. Functions can have more specific visibility modifiers
|
||||
5. Functions can be marked `inline`
|
|
@ -93,7 +93,7 @@ Pull requests (PRs) are the primary unit of collaboration for code.
|
|||
|
||||
### Work on branches in your own fork of the repository
|
||||
|
||||
Do not clone the `pachli-android` repository. Instead, create a fork, create a branch in your fork from the `main` branch, and commit your changes to that.
|
||||
Do not clone the `pachli-android` repository. Instead, create a fork, create a branch in your fork from the `main` branch, and commit your changes to that branch.
|
||||
|
||||
See the GitHub [Collaborating with pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/about-collaborative-development-models) documentation.
|
||||
|
||||
|
@ -114,6 +114,32 @@ Typically you would configure the build variant in Android Studio with Build > S
|
|||
|
||||
This is not mandatory, but may make developing easier for you.
|
||||
|
||||
### Code style
|
||||
|
||||
#### `ktlintCheck` and `ktlintFormat`
|
||||
|
||||
The project uses [ktlint](https://pinterest.github.io/ktlint/) to enforce common code and formatting standards.
|
||||
|
||||
You can check your code before creating the PR with the `ktlintCheck` task.
|
||||
|
||||
```shell
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
Most code formatting issues can be automatically resolved with the `ktlintFormat` task.
|
||||
|
||||
```shell
|
||||
./gradlew ktlintFormat
|
||||
```
|
||||
|
||||
The code in your PR will be checked for this every time it changes. If it is not lint-clean and automated fixes are possible they will be added as comments to the PR.
|
||||
|
||||
#### Questions of taste
|
||||
|
||||
Some code style issues are questions of taste, where developers might reasonably differ but the project has a specific stance.
|
||||
|
||||
Please read the [Code style guide](/docs/code-style.md).
|
||||
|
||||
### Individual commits
|
||||
|
||||
A PR is typically made up multiple commits.
|
||||
|
@ -148,7 +174,7 @@ This makes things needlessly difficult for your reviewers.
|
|||
The project uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard for commit messages. If you are not familiar with them [Conventional Commits: A better way](https://medium.com/neudesic-innovation/conventional-commits-a-better-way-78d6785c2e08) is also a good introduction.
|
||||
|
||||
> [!NOTE]
|
||||
> See [docs/decisions/0001-use-conventional-commits.md](https://github.com/pachli/pachli-android/docs/decisions/0001-use-conventional-commits.md)
|
||||
> See [docs/decisions/0001-use-conventional-commits.md](/docs/decisions/0001-use-conventional-commits.md)
|
||||
|
||||
The PR's title and description will become the first line and remaining body of the commit message when the PR is merged, so your PR title and description should also follow the conventional commits approach.
|
||||
|
||||
|
@ -175,7 +201,7 @@ The types are:
|
|||
- `test`, modify the test suite
|
||||
- `wip`, work-in-progress
|
||||
|
||||
More details on each is in [docs/decisions/conventional-commits.md](https://github.com/pachli/pachli-android/docs/decisions/conventional-commits.md).
|
||||
More details on each is in [docs/decisions/0001-use-conventional-commits.md](/docs/decisions/0001-use-conventional-commits.md).
|
||||
|
||||
`feat` for new features and `fix` for bug fixes are the most common.
|
||||
|
||||
|
@ -250,24 +276,6 @@ You should periodically merge changes from the `main` branch in to your PR branc
|
|||
|
||||
If your PR can not be cleanly merged in to `main` it is difficult to review effectively, because merging the changes from `main` in to your PR will invalidate the review. You've changed the code, so the reviewer needs to look at it again.
|
||||
|
||||
#### `ktlintCheck` and `ktlintFormat`
|
||||
|
||||
The project uses [ktlint](https://pinterest.github.io/ktlint/) to enforce common code and formatting standards.
|
||||
|
||||
You can check your code before creating the PR with the `ktlintCheck` task.
|
||||
|
||||
```shell
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
Most code formatting issues can be automatically resolved with the `ktlintFormat` task.
|
||||
|
||||
```shell
|
||||
./gradlew ktlintFormat
|
||||
```
|
||||
|
||||
The code in your PR will be checked for this every time it changes. If it is not lint-clean and automated fixes are possible they will be added as comments to the PR.
|
||||
|
||||
#### Tests
|
||||
|
||||
The project has a number of automated tests, they will automatically be run on your PR when it is submitted.
|
||||
|
|
Loading…
Reference in New Issue