Skip to content

Commit 5303880

Browse files
authored
Use new built-in story feeds in the sample (#118)
* Use new built-in story feeds in the sample * Add event handling for the feed created event (#117) * Handle FeedCreatedEvent * Move Feed filter check to FeedEventHandler * Rename filter parameter to activityFilter * Remove FeedAdded handling for Feed * Add missing doc for isWatched * Unfollow story feed on unfollowing user
1 parent 7611de5 commit 5303880

File tree

9 files changed

+166
-58
lines changed

9 files changed

+166
-58
lines changed

stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/api/model/ActivityData.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import java.util.Date
6161
* @property id The unique identifier of the activity.
6262
* @property interestTags Tags indicating user interests or content categories for recommendation
6363
* purposes.
64+
* @property isWatched Whether the activity was watched by the current user. Relevant for stories.
6465
* @property latestReactions The most recent reactions added to the activity. This property contains
6566
* the latest reactions from users, typically limited to the most recent ones.
6667
* @property location Geographic location data associated with the activity, if any.
@@ -106,6 +107,7 @@ public data class ActivityData(
106107
val filterTags: List<String>,
107108
val id: String,
108109
val interestTags: List<String>,
110+
val isWatched: Boolean?,
109111
val latestReactions: List<FeedsReactionData>,
110112
val location: ActivityLocation?,
111113
val mentionedUsers: List<UserData>,

stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/model/ActivityOperations.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ internal fun ActivityResponse.toModel(): ActivityData =
4343
filterTags = filterTags,
4444
id = id,
4545
interestTags = interestTags,
46+
isWatched = isWatched,
4647
latestReactions = latestReactions.map { it.toModel() },
4748
location = location,
4849
mentionedUsers = mentionedUsers.map { it.toModel() },

stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ internal object TestData {
221221
filterTags = emptyList(),
222222
id = id,
223223
interestTags = emptyList(),
224+
isWatched = null,
224225
latestReactions = emptyList(),
225226
location = null,
226227
mentionedUsers = emptyList(),

stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedViewModel.kt

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,6 @@ import android.util.Log
2121
import androidx.lifecycle.ViewModel
2222
import androidx.lifecycle.viewModelScope
2323
import dagger.hilt.android.lifecycle.HiltViewModel
24-
import io.getstream.android.core.api.filter.doesNotExist
25-
import io.getstream.android.core.api.filter.exists
2624
import io.getstream.feeds.android.client.api.FeedsClient
2725
import io.getstream.feeds.android.client.api.file.FeedUploadPayload
2826
import io.getstream.feeds.android.client.api.file.FileType
@@ -33,13 +31,12 @@ import io.getstream.feeds.android.client.api.model.FeedInputData
3331
import io.getstream.feeds.android.client.api.model.FeedMemberRequestData
3432
import io.getstream.feeds.android.client.api.model.FeedVisibility
3533
import io.getstream.feeds.android.client.api.state.Feed
36-
import io.getstream.feeds.android.client.api.state.query.ActivitiesFilter
37-
import io.getstream.feeds.android.client.api.state.query.ActivitiesFilterField
3834
import io.getstream.feeds.android.client.api.state.query.FeedQuery
3935
import io.getstream.feeds.android.network.models.AddReactionRequest
4036
import io.getstream.feeds.android.network.models.CreatePollRequest
4137
import io.getstream.feeds.android.network.models.CreatePollRequest.VotingVisibility.Anonymous
4238
import io.getstream.feeds.android.network.models.CreatePollRequest.VotingVisibility.Public
39+
import io.getstream.feeds.android.network.models.MarkActivityRequest
4340
import io.getstream.feeds.android.network.models.PollOptionInput
4441
import io.getstream.feeds.android.network.models.UpdateActivityRequest
4542
import io.getstream.feeds.android.sample.login.LoginManager
@@ -71,12 +68,9 @@ import kotlinx.coroutines.flow.stateIn
7168
class FeedViewModel
7269
@Inject
7370
constructor(private val application: Application, loginManager: LoginManager) : ViewModel() {
74-
private val client =
75-
flow { emit(AsyncResource.notNull(loginManager.currentClient())) }
76-
.stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading)
7771

7872
val viewState =
79-
client
73+
flow { emit(AsyncResource.notNull(loginManager.currentClient())) }
8074
.map { it.map(::toState) }
8175
.stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading)
8276

@@ -91,10 +85,12 @@ constructor(private val application: Application, loginManager: LoginManager) :
9185
init {
9286
viewState.withFirstContent(viewModelScope) {
9387
timeline.getOrCreate().notifyOnFailure { "Error getting the timeline" }
94-
timeline.followSelfIfNeeded(ownFeed.fid)
88+
timeline.followSelfIfNeeded(ownTimeline.fid)
9589
}
9690
viewState.withFirstContent(viewModelScope) {
9791
stories.getOrCreate().notifyOnFailure { "Error getting the stories" }
92+
ownStories.getOrCreate()
93+
stories.followSelfIfNeeded(ownStories.fid)
9894
}
9995
viewState.withFirstContent(viewModelScope) { notifications.getOrCreate() }
10096
}
@@ -140,7 +136,9 @@ constructor(private val application: Application, loginManager: LoginManager) :
140136

141137
fun onRepostClick(activity: ActivityData, text: String?) {
142138
viewState.withFirstContent(viewModelScope) {
143-
ownFeed.repost(activity.id, text = text).notifyOnFailure { "Failed to repost activity" }
139+
ownTimeline.repost(activity.id, text = text).notifyOnFailure {
140+
"Failed to repost activity"
141+
}
144142
}
145143
}
146144

@@ -198,16 +196,26 @@ constructor(private val application: Application, loginManager: LoginManager) :
198196
return@withFirstContent
199197
}
200198

199+
val postingFeed = if (isStory) ownStories else ownTimeline
200+
201201
val result =
202-
ownFeed
202+
postingFeed
203203
.addActivity(
204-
request = addActivityRequest(ownFeed.fid, text, isStory, attachmentFiles),
204+
request =
205+
addActivityRequest(postingFeed.fid, text, isStory, attachmentFiles),
205206
attachmentUploadProgress = { file, progress ->
206207
Log.d(TAG, "Uploading attachment: ${file.type}, progress: $progress")
207208
},
208209
)
209210
.logResult(TAG, "Creating activity with text: $text")
210211
.notifyOnFailure { "Failed to create post" }
212+
.onSuccess {
213+
// Creating a story doesn't trigger an update to aggregated activities
214+
// (stories are aggregated by user), so we refetch after posting
215+
if (isStory) {
216+
stories.getOrCreate()
217+
}
218+
}
211219

212220
deleteFiles(attachmentFiles)
213221

@@ -219,6 +227,14 @@ constructor(private val application: Application, loginManager: LoginManager) :
219227
}
220228
}
221229

230+
fun onStoryWatched(storyId: String) {
231+
viewState.withFirstContent(viewModelScope) {
232+
stories
233+
.markActivity(MarkActivityRequest(markWatched = listOf(storyId)))
234+
.notifyOnFailure { "Failed to mark story as watched" }
235+
}
236+
}
237+
222238
@OptIn(ExperimentalTime::class)
223239
private fun addActivityRequest(
224240
feedId: FeedId,
@@ -250,7 +266,7 @@ constructor(private val application: Application, loginManager: LoginManager) :
250266
votingVisibility = if (poll.anonymousPoll) Anonymous else Public,
251267
)
252268

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

260276
private fun toState(client: FeedsClient): ViewState {
261277
val userId = client.user.id
262-
val timelineQuery = feedQuery(userId, ActivitiesFilterField.expiresAt.doesNotExist())
263-
val storiesQuery = feedQuery(userId, ActivitiesFilterField.expiresAt.exists())
278+
val timelineQuery = feedQuery(Feeds.timeline(userId), userId)
279+
val storiesQuery = feedQuery(Feeds.stories(userId), userId)
280+
val ownStoriesQuery = feedQuery(Feeds.story(userId), userId)
264281

265282
return ViewState(
266283
userId = userId,
267284
userImage = client.user.imageURL,
268-
ownFeed = client.feed(Feeds.user(userId)),
269285
timeline = client.feed(timelineQuery),
286+
ownTimeline = client.feed(Feeds.user(userId)),
270287
stories = client.feed(storiesQuery),
288+
ownStories = client.feed(ownStoriesQuery),
271289
notifications = client.feed(Feeds.notifications(userId)),
272290
)
273291
}
274292

275-
private fun feedQuery(userId: String, filter: ActivitiesFilter) =
293+
private fun feedQuery(feedId: FeedId, userId: String) =
276294
FeedQuery(
277-
fid = Feeds.timeline(userId),
278-
activityFilter = filter,
295+
fid = feedId,
279296
followingLimit = 10,
280297
data =
281298
FeedInputData(
@@ -291,9 +308,10 @@ constructor(private val application: Application, loginManager: LoginManager) :
291308
data class ViewState(
292309
val userId: String,
293310
val userImage: String?,
294-
val ownFeed: Feed,
295311
val timeline: Feed,
312+
val ownTimeline: Feed,
296313
val stories: Feed,
314+
val ownStories: Feed,
297315
val notifications: Feed,
298316
)
299317

stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/feed/FeedsScreen.kt

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ import com.ramcosta.composedestinations.generated.destinations.NotificationsScre
7676
import com.ramcosta.composedestinations.generated.destinations.ProfileScreenDestination
7777
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
7878
import io.getstream.feeds.android.client.api.model.ActivityData
79+
import io.getstream.feeds.android.client.api.model.AggregatedActivityData
7980
import io.getstream.feeds.android.client.api.model.PollData
8081
import io.getstream.feeds.android.client.api.model.UserData
81-
import io.getstream.feeds.android.client.api.state.FeedState
8282
import io.getstream.feeds.android.network.models.Attachment
8383
import io.getstream.feeds.android.network.models.NotificationStatusResponse
8484
import io.getstream.feeds.android.sample.R
@@ -148,15 +148,17 @@ private fun FeedsScreenContent(
148148
Box(modifier = Modifier.fillMaxSize()) {
149149
// Feed content
150150
val activities by viewState.timeline.state.activities.collectAsStateWithLifecycle()
151+
val storyGroups by
152+
viewState.stories.state.aggregatedActivities.collectAsStateWithLifecycle()
151153
val listState = rememberLazyListState()
152154

153-
if (activities.isEmpty()) {
155+
if (activities.isEmpty() && storyGroups.isEmpty()) {
154156
EmptyContent()
155157
} else {
156158
ScrolledToBottomEffect(listState, action = viewModel::onLoadMore)
157159

158160
LazyColumn(state = listState) {
159-
item { Stories(viewState.stories.state) }
161+
item { Stories(storyGroups, viewModel::onStoryWatched) }
160162

161163
items(activities) { activity ->
162164
if (activity.parent != null) {
@@ -239,24 +241,35 @@ private fun FeedsScreenContent(
239241
}
240242

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

246247
LazyRow {
247-
items(stories) { story ->
248+
items(storyGroups) { storyGroup ->
249+
val highlightColor =
250+
if (storyGroup.activities.all { it.isWatched == true }) {
251+
MaterialTheme.colorScheme.surfaceDim
252+
} else {
253+
MaterialTheme.colorScheme.secondary
254+
}
248255
UserAvatar(
249-
story.user.image,
256+
storyGroup.activities.first().user.image,
250257
Modifier.padding(8.dp)
251258
.size(72.dp)
252-
.border(3.dp, MaterialTheme.colorScheme.secondary, CircleShape)
259+
.border(3.dp, highlightColor, CircleShape)
253260
.border(6.dp, MaterialTheme.colorScheme.surface, CircleShape)
254-
.clickable { selectedStory = story },
261+
.clickable { selectedStories = storyGroup.activities },
255262
)
256263
}
257264
}
258265

259-
selectedStory?.let { StoryScreen(activity = it, onDismiss = { selectedStory = null }) }
266+
if (selectedStories.isNotEmpty()) {
267+
StoryScreen(
268+
activities = selectedStories,
269+
onWatched = onStoryWatched,
270+
onDismiss = { selectedStories = emptyList() },
271+
)
272+
}
260273
}
261274

262275
@Composable

stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/profile/ProfileScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import kotlinx.coroutines.flow.StateFlow
6464
fun ProfileScreen(navigator: DestinationsNavigator) {
6565
val viewModel = hiltViewModel<ProfileViewModel>()
6666

67-
val feed by viewModel.feed.collectAsStateWithLifecycle()
67+
val feed by viewModel.state.collectAsStateWithLifecycle()
6868

6969
Surface {
7070
when (val feed = feed) {
@@ -77,7 +77,7 @@ fun ProfileScreen(navigator: DestinationsNavigator) {
7777

7878
is AsyncResource.Content ->
7979
ProfileScreen(
80-
state = feed.data.state,
80+
state = feed.data.feed.state,
8181
followSuggestions = viewModel.followSuggestions,
8282
onFollowClick = viewModel::follow,
8383
onUnfollowClick = viewModel::unfollow,

stream-feeds-android-sample/src/main/java/io/getstream/feeds/android/sample/profile/ProfileViewModel.kt

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import android.util.Log
1919
import androidx.lifecycle.ViewModel
2020
import androidx.lifecycle.viewModelScope
2121
import dagger.hilt.android.lifecycle.HiltViewModel
22+
import io.getstream.android.core.api.utils.flatMap
2223
import io.getstream.feeds.android.client.api.FeedsClient
2324
import io.getstream.feeds.android.client.api.model.FeedData
2425
import io.getstream.feeds.android.client.api.model.FeedId
@@ -44,65 +45,72 @@ import kotlinx.coroutines.flow.update
4445
@HiltViewModel
4546
class ProfileViewModel @Inject constructor(loginManager: LoginManager) : ViewModel() {
4647

47-
private val client =
48+
val state =
4849
flow { emit(AsyncResource.notNull(loginManager.currentClient())) }
49-
.stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading)
50-
51-
val feed =
52-
client
53-
.map { loadingState -> loadingState.map(::getFeed) }
50+
.map { loadingState -> loadingState.map(::toState) }
5451
.stateIn(viewModelScope, SharingStarted.Eagerly, AsyncResource.Loading)
5552

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

5956
init {
60-
feed.withFirstContent(viewModelScope) {
61-
getOrCreate().logResult(TAG, "Error getting the profile feed")
62-
}
63-
client.withFirstContent(viewModelScope) {
57+
state.withFirstContent(viewModelScope) {
58+
feed.getOrCreate().logResult(TAG, "Getting the profile feed")
6459
_followSuggestions.value =
6560
// We query suggestions from a user feed because we want to follow those, not other
6661
// timelines.
67-
feed(Feeds.user(user.id))
62+
client
63+
.feed(Feeds.user(client.user.id))
6864
.queryFollowSuggestions(10)
69-
.logResult(TAG, "Error getting follow suggestions")
65+
.logResult(TAG, "Getting follow suggestions")
7066
.getOrDefault(emptyList())
7167
}
7268
}
7369

7470
fun follow(feedId: FeedId) {
75-
feed.withFirstContent(viewModelScope) {
76-
follow(feedId, createNotificationActivity = true)
71+
state.withFirstContent(viewModelScope) {
72+
feed
73+
.follow(feedId, createNotificationActivity = true)
7774
.onSuccess {
7875
// Update the follow suggestions after following a feed
7976
_followSuggestions.update {
8077
it.filter { suggestion -> suggestion.fid != feedId }
8178
}
8279
}
8380
.onFailure { Log.e(TAG, "Failed to follow feed: $feedId", it) }
81+
.flatMap {
82+
// Also make `stories:user_id` follow `story:their_id` to follow stories.
83+
client.feed(Feeds.stories(client.user.id)).follow(Feeds.story(feedId.id))
84+
}
85+
.onFailure { Log.e(TAG, "Failed to follow stories feed for: ${feedId.id}", it) }
8486
}
8587
}
8688

8789
fun unfollow(feedId: FeedId) {
88-
feed.withFirstContent(viewModelScope) {
89-
unfollow(feedId)
90-
.onSuccess { Log.d(TAG, "Successfully unfollowed feed: $it") }
91-
.onFailure { Log.e(TAG, "Failed to unfollow feed: $feedId", it) }
90+
state.withFirstContent(viewModelScope) {
91+
feed
92+
.unfollow(feedId)
93+
.logResult(TAG, "Unfollowing feed: $feedId")
94+
.flatMap {
95+
client.feed(Feeds.stories(client.user.id)).unfollow(Feeds.story(feedId.id))
96+
}
97+
.logResult(TAG, "Unfollowing stories feed for: ${feedId.id}")
9298
}
9399
}
94100

95-
private fun getFeed(client: FeedsClient): Feed {
101+
private fun toState(client: FeedsClient): State {
96102
val profileFeedQuery =
97103
FeedQuery(
98104
fid = Feeds.timeline(client.user.id),
99105
activityLimit = 0, // We don't need activities for the profile feed
100106
followerLimit = 10, // Load first 10 followers
101107
followingLimit = 10, // Load first 10 followings
102108
)
103-
return client.feed(profileFeedQuery)
109+
return State(client, client.feed(profileFeedQuery))
104110
}
105111

112+
data class State(val client: FeedsClient, val feed: Feed)
113+
106114
companion object {
107115
private const val TAG = "ProfileViewModel"
108116
}

0 commit comments

Comments
 (0)