Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PBE-3749] Implement ThreadList state and UI #5441

Open
wants to merge 35 commits into
base: develop
Choose a base branch
from

Conversation

VelikovPetar
Copy link
Contributor

🎯 Goal

This PR is the first request related to the Threads V2 feature. It covers the ThreadList component, and the logic fetching the data behind it.

🛠 Implementation details

  1. HTTP API changes:
  • Update the QueryThreadsRequest to accept additional params as per the documentation
  • Update the QueryThreadsResponseDto to include the prev and next cursors
  • Update the ChatApi.queryThreads method to return QueryThreadsResult instead of List<Thread> (now includes the prev and next cursors)
  • Update the DownstreamThreadDto to include some additional fields delivered from back-end, and make some of the fields optional (as per the endpoint documentation)
  • Update the Thread model based on the changes of DownstreamThreadDto
  1. ChatEvent changes:
  • Create new NotificationThreadMessageNewEventDto event of type: notification.thread_message_new. This event is now delivered when a new reply is added to a thread.
  • Create new domain NotificationThreadMessageNew event based on NotificationThreadMessageNewEventDto
  • Add handling for this event in ChannelLogic
  1. StatePlugin changes:
  • Introduce new QueryThreadsListener for applying side effects to the ChatClient.queryThreads operation.
  • Implement new QueryThreadsListenerState - Implementation of the QueryThreadsListener applying side effects as part of the StatePlugin
  • Implement QueryThreadsLogic -> single point of entry for the new threads-related logic. Handles the logic of updating the threads-related state caused by the ChatClient.queryThreads and by ChatEvents. (Accessible via the LogicRegistry)
  • Implement QueryThreadsState/QueryThreadsMutableState -> state holder for the new threads-related data (Accessible via the StateRegistry).
  • Implement QueryThreadsStateLogic -> interactor between the QueryThreadsLogic and the QueryThreadsMutableState
  • Expose ChatClient.queryThreadsAsState -> extension which initializes and exposes the 'global' QueryThreadsState
  1. Ui-common changes:
  • Implement ThreadListController -> a controller to be shared by the XML and Compose SDKs which handles the load and loadMore requests for the thread list, and exposes a reactive render-able ThreadListState.
  1. Compose changes:
  • Implement ThreadListViewModel backed by the ThreadListController
  • Implement high-level composable ThreadList backed by ThreadListViewModel which shows the list of threads, and the optional unread threads banner (with default thread item, unread threads banner and default loading and empty states)

🎨 UI Changes

threads-list

🧪 Testing

This is currently not testable with the sample app (I will commit the updates to the sample app once this PR is approved)

Provide a patch below if it is necessary for testing

Provide the patch summary here
Provide the patch code here

☑️Contributor Checklist

General

  • I have signed the Stream CLA (required)
  • Assigned a person / code owner group (required)
  • Thread with the PR link started in a respective Slack channel (#android-chat-core or #android-chat-ui) (required)
  • PR targets the develop branch
  • PR is linked to the GitHub issue it resolves

Code & documentation

  • Changelog is updated with client-facing changes
  • New code is covered by unit tests
  • Comparison screenshots added for visual changes
  • Affected documentation updated (KDocs, docusaurus, tutorial)

☑️Reviewer Checklist

  • UI Components sample runs & works
  • Compose sample runs & works
  • UI Changes correct (before & after images)
  • Bugs validated (bugfixes)
  • New feature tested and works
  • Release notes and docs clearly describe changes
  • All code we touched has new or updated KDocs

🎉 GIF

Please provide a suitable gif that describes your work on this pull request

@VelikovPetar VelikovPetar requested a review from a team October 15, 2024 16:00
@CheckResult
@InternalStreamChatApi
public fun queryThreadsResult(query: QueryThreadsRequest): Call<QueryThreadsResult> {
return queryThreadsInternal(query)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this extra method, we can call the API directly from here

Suggested change
return queryThreadsInternal(query)
return api.queryThreads(query)

val cid: String,
val channelInfo: ChannelInfo,
val channel: Channel?,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we keep ChannelInfo instance here?
Instead of having a nullable Channel?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I was able to see from the API responses, we are getting a full Channel object, but again, it is not marked as mandatory in the API specs. Should we still keep this as a mandatory ChannelInfo? (The Channel object is later used to resolve the channel name in ThreadItem via the channelNameFormatter)

val parentMessageId: String,
val parentMessage: Message,
val parentMessage: Message?,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A thread should always start with a parentMessage, it shouldn't be null

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I followed the swagger api spec where the parent_message field was not marked as mandatory. But you are right, this will probably never be null, I will update this.

val createdBy: User?,
val replyCount: Int?,
val participantCount: Int?,
val threadParticipants: List<User>?,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our domain model should avoid nullability as far as possible.
On the case of collections, we should fallback to emptyCollections instead.

Comment on lines 31 to 32
val replyCount: Int?,
val participantCount: Int?,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is backend sending us those properties as null??
They shouldn't

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the 'parent_message' param, but I will revert this change, as it doesn't make sense to get nulls here.

Comment on lines 106 to 121
val oldThreads = getThreads()
val oldThread = oldThreads.find { it.parentMessageId == parent.id }
oldThread ?: return false // no matching parent message was found
val newThread = oldThread.copy(
parentMessage = parent,
deletedAt = parent.deletedAt,
updatedAt = parent.updatedAt,
replyCount = parent.replyCount,
)
val newThreads = oldThreads.map {
if (it.parentMessageId == newThread.parentMessageId) {
newThread
} else {
it
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of performing a search and after mapping all elements, what about only mapping elements?
The copy() method can be invoke within the map lambda

Comment on lines 133 to 159
val thread = oldThreads.find { it.parentMessageId == reply.parentId }
thread ?: return
val oldReplies = thread.latestReplies
val newReplies = if (oldReplies.any { it.id == reply.id }) {
// update
oldReplies.map {
if (it.id == reply.id) {
reply
} else {
it
}
}
} else {
// insert
oldReplies + listOf(reply)
}
val sortedNewReplies = newReplies.sortedBy {
it.createdAt ?: it.createdLocallyAt
}
val newThread = thread.copy(latestReplies = sortedNewReplies)
val newThreads = oldThreads.map {
if (it.parentMessageId == newThread.parentMessageId) {
newThread
} else {
it
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Comment on lines 48 to 58
private companion object {
private val INITIAL_STATE = ThreadListState(
threads = emptyList(),
isLoading = true,
isLoadingMore = false,
unseenThreadsCount = 0,
)
private const val QUERY_LIMIT = 25
private const val QUERY_REPLY_LIMIT = 10
private const val QUERY_PARTICIPANT_LIMIT = 10
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move companion object to the end of the class?

Comment on lines 55 to 57
private const val QUERY_LIMIT = 25
private const val QUERY_REPLY_LIMIT = 10
private const val QUERY_PARTICIPANT_LIMIT = 10
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those constants should be the default ones, but allowing our customers to inject their own one by the constructor

public val state: StateFlow<ThreadListState>
get() = _state

private val scope = CoroutineScope(DispatcherProvider.Main + SupervisorJob())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't create scopes internally in our classes.
It should be injected.
On that case I think we should use the VM scope

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants