Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ public data class ActivityData(
val filterTags: List<String>,
val id: String,
val interestTags: List<String>,
val isWatched: Boolean?,
val latestReactions: List<FeedsReactionData>,
val location: ActivityLocation?,
val mentionedUsers: List<UserData>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ internal fun ActivityResponse.toModel(): ActivityData =
filterTags = filterTags,
id = id,
interestTags = interestTags,
isWatched = isWatched,
latestReactions = latestReactions.map { it.toModel() },
location = location,
mentionedUsers = mentionedUsers.map { it.toModel() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ internal object TestData {
filterTags = emptyList(),
id = id,
interestTags = emptyList(),
isWatched = null,
latestReactions = emptyList(),
location = null,
mentionedUsers = emptyList(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.getstream.android.core.api.filter.doesNotExist
import io.getstream.android.core.api.filter.exists
import io.getstream.feeds.android.client.api.FeedsClient
import io.getstream.feeds.android.client.api.file.FeedUploadPayload
import io.getstream.feeds.android.client.api.file.FileType
Expand All @@ -33,13 +31,12 @@ import io.getstream.feeds.android.client.api.model.FeedInputData
import io.getstream.feeds.android.client.api.model.FeedMemberRequestData
import io.getstream.feeds.android.client.api.model.FeedVisibility
import io.getstream.feeds.android.client.api.state.Feed
import io.getstream.feeds.android.client.api.state.query.ActivitiesFilter
import io.getstream.feeds.android.client.api.state.query.ActivitiesFilterField
import io.getstream.feeds.android.client.api.state.query.FeedQuery
import io.getstream.feeds.android.network.models.AddReactionRequest
import io.getstream.feeds.android.network.models.CreatePollRequest
import io.getstream.feeds.android.network.models.CreatePollRequest.VotingVisibility.Anonymous
import io.getstream.feeds.android.network.models.CreatePollRequest.VotingVisibility.Public
import io.getstream.feeds.android.network.models.MarkActivityRequest
import io.getstream.feeds.android.network.models.PollOptionInput
import io.getstream.feeds.android.network.models.UpdateActivityRequest
import io.getstream.feeds.android.sample.login.LoginManager
Expand Down Expand Up @@ -71,12 +68,9 @@ import kotlinx.coroutines.flow.stateIn
class FeedViewModel
@Inject
constructor(private val application: Application, loginManager: LoginManager) : ViewModel() {
private val client =
flow { emit(AsyncResource.notNull(loginManager.currentClient())) }
.stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading)

val viewState =
client
flow { emit(AsyncResource.notNull(loginManager.currentClient())) }
.map { it.map(::toState) }
.stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading)

Expand All @@ -91,10 +85,12 @@ constructor(private val application: Application, loginManager: LoginManager) :
init {
viewState.withFirstContent(viewModelScope) {
timeline.getOrCreate().notifyOnFailure { "Error getting the timeline" }
timeline.followSelfIfNeeded(ownFeed.fid)
timeline.followSelfIfNeeded(ownTimeline.fid)
}
viewState.withFirstContent(viewModelScope) {
stories.getOrCreate().notifyOnFailure { "Error getting the stories" }
ownStories.getOrCreate()
stories.followSelfIfNeeded(ownStories.fid)
}
viewState.withFirstContent(viewModelScope) { notifications.getOrCreate() }
}
Expand Down Expand Up @@ -140,7 +136,9 @@ constructor(private val application: Application, loginManager: LoginManager) :

fun onRepostClick(activity: ActivityData, text: String?) {
viewState.withFirstContent(viewModelScope) {
ownFeed.repost(activity.id, text = text).notifyOnFailure { "Failed to repost activity" }
ownTimeline.repost(activity.id, text = text).notifyOnFailure {
"Failed to repost activity"
}
}
}

Expand Down Expand Up @@ -198,16 +196,26 @@ constructor(private val application: Application, loginManager: LoginManager) :
return@withFirstContent
}

val postingFeed = if (isStory) ownStories else ownTimeline

val result =
ownFeed
postingFeed
.addActivity(
request = addActivityRequest(ownFeed.fid, text, isStory, attachmentFiles),
request =
addActivityRequest(postingFeed.fid, text, isStory, attachmentFiles),
attachmentUploadProgress = { file, progress ->
Log.d(TAG, "Uploading attachment: ${file.type}, progress: $progress")
},
)
.logResult(TAG, "Creating activity with text: $text")
.notifyOnFailure { "Failed to create post" }
.onSuccess {
// Creating a story doesn't trigger an update to aggregated activities
// (stories are aggregated by user), so we refetch after posting
if (isStory) {
stories.getOrCreate()
}
}

deleteFiles(attachmentFiles)

Expand All @@ -219,6 +227,14 @@ constructor(private val application: Application, loginManager: LoginManager) :
}
}

fun onStoryWatched(storyId: String) {
viewState.withFirstContent(viewModelScope) {
stories
.markActivity(MarkActivityRequest(markWatched = listOf(storyId)))
.notifyOnFailure { "Failed to mark story as watched" }
}
}

@OptIn(ExperimentalTime::class)
private fun addActivityRequest(
feedId: FeedId,
Expand Down Expand Up @@ -250,7 +266,7 @@ constructor(private val application: Application, loginManager: LoginManager) :
votingVisibility = if (poll.anonymousPoll) Anonymous else Public,
)

ownFeed
ownTimeline
.createPoll(request = request, activityType = "activity")
.logResult(TAG, "Creating poll with question: ${poll.question}")
.notifyOnFailure { "Failed to create poll" }
Expand All @@ -259,23 +275,24 @@ constructor(private val application: Application, loginManager: LoginManager) :

private fun toState(client: FeedsClient): ViewState {
val userId = client.user.id
val timelineQuery = feedQuery(userId, ActivitiesFilterField.expiresAt.doesNotExist())
val storiesQuery = feedQuery(userId, ActivitiesFilterField.expiresAt.exists())
val timelineQuery = feedQuery(Feeds.timeline(userId), userId)
val storiesQuery = feedQuery(Feeds.stories(userId), userId)
val ownStoriesQuery = feedQuery(Feeds.story(userId), userId)

return ViewState(
userId = userId,
userImage = client.user.imageURL,
ownFeed = client.feed(Feeds.user(userId)),
timeline = client.feed(timelineQuery),
ownTimeline = client.feed(Feeds.user(userId)),
stories = client.feed(storiesQuery),
ownStories = client.feed(ownStoriesQuery),
notifications = client.feed(Feeds.notifications(userId)),
)
}

private fun feedQuery(userId: String, filter: ActivitiesFilter) =
private fun feedQuery(feedId: FeedId, userId: String) =
FeedQuery(
fid = Feeds.timeline(userId),
activityFilter = filter,
fid = feedId,
followingLimit = 10,
data =
FeedInputData(
Expand All @@ -291,9 +308,10 @@ constructor(private val application: Application, loginManager: LoginManager) :
data class ViewState(
val userId: String,
val userImage: String?,
val ownFeed: Feed,
val timeline: Feed,
val ownTimeline: Feed,
val stories: Feed,
val ownStories: Feed,
val notifications: Feed,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ import com.ramcosta.composedestinations.generated.destinations.NotificationsScre
import com.ramcosta.composedestinations.generated.destinations.ProfileScreenDestination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import io.getstream.feeds.android.client.api.model.ActivityData
import io.getstream.feeds.android.client.api.model.AggregatedActivityData
import io.getstream.feeds.android.client.api.model.PollData
import io.getstream.feeds.android.client.api.model.UserData
import io.getstream.feeds.android.client.api.state.FeedState
import io.getstream.feeds.android.network.models.Attachment
import io.getstream.feeds.android.network.models.NotificationStatusResponse
import io.getstream.feeds.android.sample.R
Expand Down Expand Up @@ -148,15 +148,17 @@ private fun FeedsScreenContent(
Box(modifier = Modifier.fillMaxSize()) {
// Feed content
val activities by viewState.timeline.state.activities.collectAsStateWithLifecycle()
val storyGroups by
viewState.stories.state.aggregatedActivities.collectAsStateWithLifecycle()
val listState = rememberLazyListState()

if (activities.isEmpty()) {
if (activities.isEmpty() && storyGroups.isEmpty()) {
EmptyContent()
} else {
ScrolledToBottomEffect(listState, action = viewModel::onLoadMore)

LazyColumn(state = listState) {
item { Stories(viewState.stories.state) }
item { Stories(storyGroups, viewModel::onStoryWatched) }

items(activities) { activity ->
if (activity.parent != null) {
Expand Down Expand Up @@ -239,24 +241,35 @@ private fun FeedsScreenContent(
}

@Composable
fun Stories(state: FeedState) {
val stories by state.activities.collectAsStateWithLifecycle()
var selectedStory by remember { mutableStateOf<ActivityData?>(null) }
fun Stories(storyGroups: List<AggregatedActivityData>, onStoryWatched: (String) -> Unit) {
var selectedStories by remember { mutableStateOf<List<ActivityData>>(emptyList()) }

LazyRow {
items(stories) { story ->
items(storyGroups) { storyGroup ->
val highlightColor =
if (storyGroup.activities.all { it.isWatched == true }) {
MaterialTheme.colorScheme.surfaceDim
} else {
MaterialTheme.colorScheme.secondary
}
UserAvatar(
story.user.image,
storyGroup.activities.first().user.image,
Modifier.padding(8.dp)
.size(72.dp)
.border(3.dp, MaterialTheme.colorScheme.secondary, CircleShape)
.border(3.dp, highlightColor, CircleShape)
.border(6.dp, MaterialTheme.colorScheme.surface, CircleShape)
.clickable { selectedStory = story },
.clickable { selectedStories = storyGroup.activities },
)
}
}

selectedStory?.let { StoryScreen(activity = it, onDismiss = { selectedStory = null }) }
if (selectedStories.isNotEmpty()) {
StoryScreen(
activities = selectedStories,
onWatched = onStoryWatched,
onDismiss = { selectedStories = emptyList() },
)
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import kotlinx.coroutines.flow.StateFlow
fun ProfileScreen(navigator: DestinationsNavigator) {
val viewModel = hiltViewModel<ProfileViewModel>()

val feed by viewModel.feed.collectAsStateWithLifecycle()
val feed by viewModel.state.collectAsStateWithLifecycle()

Surface {
when (val feed = feed) {
Expand All @@ -77,7 +77,7 @@ fun ProfileScreen(navigator: DestinationsNavigator) {

is AsyncResource.Content ->
ProfileScreen(
state = feed.data.state,
state = feed.data.feed.state,
followSuggestions = viewModel.followSuggestions,
onFollowClick = viewModel::follow,
onUnfollowClick = viewModel::unfollow,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.getstream.android.core.api.utils.flatMap
import io.getstream.feeds.android.client.api.FeedsClient
import io.getstream.feeds.android.client.api.model.FeedData
import io.getstream.feeds.android.client.api.model.FeedId
Expand All @@ -44,65 +45,66 @@ import kotlinx.coroutines.flow.update
@HiltViewModel
class ProfileViewModel @Inject constructor(loginManager: LoginManager) : ViewModel() {

private val client =
val state =
flow { emit(AsyncResource.notNull(loginManager.currentClient())) }
.stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading)

val feed =
client
.map { loadingState -> loadingState.map(::getFeed) }
.map { loadingState -> loadingState.map(::toState) }
.stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading)

private val _followSuggestions: MutableStateFlow<List<FeedData>> = MutableStateFlow(emptyList())
val followSuggestions: StateFlow<List<FeedData>> = _followSuggestions.asStateFlow()

init {
feed.withFirstContent(viewModelScope) {
getOrCreate().logResult(TAG, "Error getting the profile feed")
}
client.withFirstContent(viewModelScope) {
state.withFirstContent(viewModelScope) {
feed.getOrCreate().logResult(TAG, "Getting the profile feed")
_followSuggestions.value =
// We query suggestions from a user feed because we want to follow those, not other
// timelines.
feed(Feeds.user(user.id))
client
.feed(Feeds.user(client.user.id))
.queryFollowSuggestions(10)
.logResult(TAG, "Error getting follow suggestions")
.logResult(TAG, "Getting follow suggestions")
.getOrDefault(emptyList())
}
}

fun follow(feedId: FeedId) {
feed.withFirstContent(viewModelScope) {
follow(feedId, createNotificationActivity = true)
state.withFirstContent(viewModelScope) {
feed
.follow(feedId, createNotificationActivity = true)
.onSuccess {
// Update the follow suggestions after following a feed
_followSuggestions.update {
it.filter { suggestion -> suggestion.fid != feedId }
}
}
.onFailure { Log.e(TAG, "Failed to follow feed: $feedId", it) }
.flatMap {
// Also make `stories:user_id` follow `story:their_id` to follow stories.
client.feed(Feeds.stories(client.user.id)).follow(Feeds.story(feedId.id))
}
.onFailure { Log.e(TAG, "Failed to follow stories feed for: ${feedId.id}", it) }
}
}

fun unfollow(feedId: FeedId) {
feed.withFirstContent(viewModelScope) {
unfollow(feedId)
.onSuccess { Log.d(TAG, "Successfully unfollowed feed: $it") }
.onFailure { Log.e(TAG, "Failed to unfollow feed: $feedId", it) }
state.withFirstContent(viewModelScope) {
feed.unfollow(feedId).logResult(TAG, "Unfollowing feed: $feedId")
}
}

private fun getFeed(client: FeedsClient): Feed {
private fun toState(client: FeedsClient): State {
val profileFeedQuery =
FeedQuery(
fid = Feeds.timeline(client.user.id),
activityLimit = 0, // We don't need activities for the profile feed
followerLimit = 10, // Load first 10 followers
followingLimit = 10, // Load first 10 followings
)
return client.feed(profileFeedQuery)
return State(client, client.feed(profileFeedQuery))
}

data class State(val client: FeedsClient, val feed: Feed)

companion object {
private const val TAG = "ProfileViewModel"
}
Expand Down
Loading