Skip to content

Commit

Permalink
Saving place in For You feed (#469)
Browse files Browse the repository at this point in the history
Saving place in for you feed when so that when the user returns to the
app after closing it, they are returned to the last post they were
looking at.

- Saving place in for you feed
- Updating home remote mediator so the first load will take into account
your last position in the list
- Adding a scroll up button that will show if there are new posts, but
only if you haven't reached the top yet.

---------

Co-authored-by: John Oberhauser <j.git-global@obez.io>
  • Loading branch information
JohnOberhauser and John Oberhauser committed Apr 17, 2024
1 parent fcb196a commit 83dd24f
Show file tree
Hide file tree
Showing 21 changed files with 577 additions and 156 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ interface HomeTimelineStatusDao : BaseDao<HomeTimelineStatus> {
@Query("DELETE FROM homeTimeline")
suspend fun deleteHomeTimeline()

@Transaction
@Query(
"DELETE FROM homeTimeline " +
"WHERE statusId > :statusId"
)
suspend fun deleteStatusesBeforeId(statusId: String)

@Query(
"DELETE FROM homeTimeline " +
"WHERE statusId IN " +
Expand All @@ -41,4 +48,12 @@ interface HomeTimelineStatusDao : BaseDao<HomeTimelineStatus> {
")",
)
suspend fun getPostsFromAccount(accountId: String): List<HomeTimelineStatusWrapper>

@Transaction
@Query(
"SELECT * FROM homeTimeline " +
"ORDER BY statusId DESC " +
"LIMIT 1"
)
suspend fun getFirstStatus(): HomeTimelineStatusWrapper
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ class UserPreferencesDatastore(context: Context) {
it.serializedPushKeys
}.distinctUntilChanged()

val lastSeenHomeStatusId: Flow<String> =
dataStore.data.mapLatest {
it.lastSeenHomeStatusId
}.distinctUntilChanged()

/**
* Preload the data so that it's available in the cache
*/
Expand Down Expand Up @@ -83,6 +88,14 @@ class UserPreferencesDatastore(context: Context) {
}
}

suspend fun saveLastSeenHomeStatusId(statusId: String) {
dataStore.updateData {
it.toBuilder()
.setLastSeenHomeStatusId(statusId)
.build()
}
}

suspend fun clearData() {
dataStore.updateData {
it.toBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ message UserPreferences {
string account_id = 2;
string domain = 3;
string serialized_push_keys = 6;
string last_seen_home_status_id = 7;
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import social.firefly.core.designsystem.R
* TODO@release update material icons with phosphor icons
*/
object FfIcons {
@Composable
fun arrowLineUp() = painterResource(id = R.drawable.arrow_line_up)

@Composable
fun at() = painterResource(id = R.drawable.at)

Expand Down
9 changes: 9 additions & 0 deletions core/designsystem/src/main/res/drawable/arrow_line_up.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M205.66,138.34a8,8 0,0 1,-11.32 11.32L136,91.31V224a8,8 0,0 1,-16 0V91.31L61.66,149.66a8,8 0,0 1,-11.32 -11.32l72,-72a8,8 0,0 1,11.32 0ZM216,32H40a8,8 0,0 0,0 16H216a8,8 0,0 0,0 -16Z"
android:fillColor="#000000"/>
</vector>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package social.firefly.core.network.mastodon

import retrofit2.http.GET
import retrofit2.http.Query
import social.firefly.core.network.mastodon.model.NetworkMarker

interface MarkersApi {

@GET("/api/v1/markers")
suspend fun getMarkers(
@Query("timeline[]") timelines: Array<String>?,
): NetworkMarker
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package social.firefly.core.network.mastodon.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Marks the user's current position in their timelines,
* to synchronize and restore it across devices.
*/
@Serializable
data class NetworkMarker(
/**
* Home timeline marker.
*/
@SerialName("home")
val home: NetworkMarkerProperties,
/**
* Notifications timeline marker.
*/
@SerialName("notifications")
val notifications: NetworkMarkerProperties,
)
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
package social.firefly.core.network.mastodon.model

import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Marks the current reading position on a specific timeline.
*/
@Serializable
data class NetworkMarkerProperties(
/**
* ID of the last read item.
*/
@SerialName("last_read_id")
val lastReadId: String,
/**
* Date at which this marker was updated.
*/
@SerialName("updated_at")
val updatedAt: Instant,
/**
* Used for locking to prevent write conflicts.
*/
@SerialName("version")
val version: Long,
)
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ class TimelineRepository internal constructor(

suspend fun deleteHomeTimeline() = homeTimelineStatusDao.deleteHomeTimeline()

suspend fun deleteHomeStatusesBeforeId(statusId: String) =
homeTimelineStatusDao.deleteStatusesBeforeId(statusId)

suspend fun getFirstStatusFromHomeTimeline() =
homeTimelineStatusDao.getFirstStatus().status.toExternalModel()

suspend fun removePostInHomeTimelineForAccount(accountId: String) =
homeTimelineStatusDao.removePostsFromAccount(accountId)
//endregion
Expand Down
2 changes: 2 additions & 0 deletions core/repository/paging/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ dependencies {
implementation(project(":core:usecase:mastodon"))
implementation(project(":core:common"))
implementation(project(":core:model"))
implementation(project(":core:datastore"))

implementation(libs.androidx.paging.runtime)
implementation(libs.jakewharton.timber)
implementation(libs.koin.core)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,178 @@ import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import social.firefly.common.Rel
import social.firefly.core.database.model.entities.statusCollections.HomeTimelineStatusWrapper
import social.firefly.core.datastore.UserPreferencesDatastore
import social.firefly.core.model.paging.StatusPagingWrapper
import social.firefly.core.repository.mastodon.DatabaseDelegate
import social.firefly.core.repository.mastodon.TimelineRepository
import social.firefly.core.usecase.mastodon.status.GetInReplyToAccountNames
import social.firefly.core.usecase.mastodon.status.SaveStatusToDatabase
import timber.log.Timber
import kotlin.coroutines.coroutineContext

@OptIn(ExperimentalPagingApi::class)
class HomeTimelineRemoteMediator(
private val refreshHomeTimeline: RefreshHomeTimeline,
private val timelineRepository: TimelineRepository,
private val saveStatusToDatabase: SaveStatusToDatabase,
private val databaseDelegate: DatabaseDelegate,
private val getInReplyToAccountNames: GetInReplyToAccountNames,
private val userPreferencesDatastore: UserPreferencesDatastore,
) : RemoteMediator<Int, HomeTimelineStatusWrapper>() {

private var firstRefreshHasHappened = false

@Suppress("ReturnCount", "MagicNumber")
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, HomeTimelineStatusWrapper>,
): MediatorResult {
return refreshHomeTimeline(loadType = loadType, state = state)
return try {
val pageSize: Int = state.config.pageSize
val response =
when (loadType) {
LoadType.REFRESH -> {
fetchRefresh(state)
}

LoadType.PREPEND -> {
val firstItem =
state.firstItemOrNull()
?: return MediatorResult.Success(
endOfPaginationReached = true
)
timelineRepository.getHomeTimeline(
olderThanId = null,
immediatelyNewerThanId = firstItem.homeTimelineStatus.statusId,
loadSize = pageSize,
)
}

LoadType.APPEND -> {
val lastItem =
state.lastItemOrNull()
?: return MediatorResult.Success(
endOfPaginationReached = true
)
timelineRepository.getHomeTimeline(
olderThanId = lastItem.homeTimelineStatus.statusId,
immediatelyNewerThanId = null,
loadSize = pageSize,
)
}
}

val result = getInReplyToAccountNames(response.statuses)

databaseDelegate.withTransaction {
if (loadType == LoadType.REFRESH) {
timelineRepository.deleteHomeTimeline()
}

saveStatusToDatabase(result)
timelineRepository.insertAllIntoHomeTimeline(result)
}

// There seems to be some race condition for refreshes. Subsequent pages do
// not get loaded because once we return a mediator result, the next append
// and prepend happen right away. The paging source doesn't have enough time
// to collect the initial page from the database, so the [state] we get as
// a parameter in this load method doesn't have any data in the pages, so
// it's assumed we've reached the end of pagination, and nothing gets loaded
// ever again.
if (loadType == LoadType.REFRESH) {
delay(200)
}

MediatorResult.Success(
endOfPaginationReached =
when (loadType) {
LoadType.PREPEND -> response.pagingLinks?.find { it.rel == Rel.PREV } == null
LoadType.REFRESH,
LoadType.APPEND,
-> response.pagingLinks?.find { it.rel == Rel.NEXT } == null
},
)
} catch (e: Exception) {
Timber.e(e)
MediatorResult.Error(e)
}
}

private suspend fun fetchRefresh(
state: PagingState<Int, HomeTimelineStatusWrapper>,
): StatusPagingWrapper {
var olderThanId: String? = null
val pageSize = state.config.initialLoadSize

// If this is the first time we are loading the page, we need to start where
// the user last left off. Grab the lastSeenHomeStatusId
if (!firstRefreshHasHappened) {
val lastSeenId = CompletableDeferred<String>()
with(CoroutineScope(coroutineContext)) {
launch {
userPreferencesDatastore.lastSeenHomeStatusId.collectLatest {
lastSeenId.complete(it)
cancel()
}
}
}
olderThanId = lastSeenId.await()
}

val mainResponse = timelineRepository.getHomeTimeline(
olderThanId = olderThanId,
immediatelyNewerThanId = null,
loadSize = if (olderThanId != null) {
// if we are going to fetch the first status separately, we need to decrease this
// call's page size by 1
pageSize - 1
} else {
pageSize
},
)

val firstIdInMainList =
mainResponse.statuses.maxByOrNull { it.statusId }?.statusId

val topStatusResponse = if (olderThanId != null) {
timelineRepository.getHomeTimeline(
olderThanId = null,
immediatelyNewerThanId = firstIdInMainList,
loadSize = 1,
)
} else {
null
}

firstRefreshHasHappened = true

return StatusPagingWrapper(
statuses = buildList {
addAll(mainResponse.statuses)
topStatusResponse?.let { addAll(it.statuses) }
},
pagingLinks = buildList {
mainResponse.pagingLinks?.find { it.rel == Rel.NEXT }?.let {
add(it)
}
if (topStatusResponse != null) {
topStatusResponse.pagingLinks?.find { it.rel == Rel.PREV }?.let {
add(it)
}
} else {
mainResponse.pagingLinks?.find { it.rel == Rel.PREV }?.let {
add(it)
}
}
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.paging.ExperimentalPagingApi
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import social.firefly.core.datastore.dataStoreModule
import social.firefly.core.repository.mastodon.mastodonRepositoryModule
import social.firefly.core.repository.paging.notifications.AllNotificationsRemoteMediator
import social.firefly.core.repository.paging.notifications.FollowNotificationsRemoteMediator
Expand All @@ -16,9 +17,9 @@ val pagingModule = module {
includes(
mastodonRepositoryModule,
mastodonUsecaseModule,
dataStoreModule,
)

singleOf(::RefreshHomeTimeline)
singleOf(::RefreshFederatedTimeline)
singleOf(::RefreshLocalTimeline)
factoryOf(::FavoritesRemoteMediator)
Expand Down

0 comments on commit 83dd24f

Please sign in to comment.