Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
07e6b0d
Fx ProfileScreen halo and bottom navigation bar width
tunjid Jan 28, 2026
6427c87
PR feedback
tunjid Jan 28, 2026
2966b50
Fix TimelineTitle spacing
tunjid Jan 28, 2026
cf93282
Merge pull request #902 from tunjid/tj/ui-tweaks-12
tunjid Jan 28, 2026
accb9f1
add SessionSummary model
joelmuraguri Jan 29, 2026
2ab25dd
add SessionSummary in SavedState
joelmuraguri Jan 29, 2026
3b97bd9
persist and read SessionSummary logic implementation
joelmuraguri Jan 29, 2026
a211d58
Add preference for setting if videos autoplay
tunjid Jan 29, 2026
3b7db63
Rename variables
tunjid Jan 29, 2026
f8b1ff6
lint
tunjid Jan 29, 2026
b3f5d07
Merge pull request #904 from tunjid/tj/video-autoplay-data
tunjid Jan 29, 2026
528290f
Add settings item for autoplaying timeline videos
tunjid Jan 29, 2026
c47dbf6
PR feedback
tunjid Jan 29, 2026
fea95c3
Merge pull request #905 from tunjid/tj/timeline-video-autoplay-setting
tunjid Jan 29, 2026
2f103be
Honor user's timeline autoplay preference
tunjid Jan 29, 2026
bb1b86a
Merge pull request #906 from tunjid/tj/timeline-video-autoplay
tunjid Jan 29, 2026
f13ef0d
Allow users pause a video they started playing
tunjid Jan 29, 2026
4d64dfd
Update user in current session
tunjid Jan 29, 2026
05c79dc
Merge pull request #907 from tunjid/tj/pause-video-control
tunjid Jan 29, 2026
7231ab3
Simplify logic for updating a profile after auth
tunjid Jan 29, 2026
d35500f
PR feedback
tunjid Jan 29, 2026
e83b9d7
Merge pull request #903 from tunjid/joel/preserve-last-signed-in-data…
tunjid Jan 29, 2026
2dfc444
Remember past sessions on the sign in screen
tunjid Jan 29, 2026
9b7ef62
Add avatar string resource
tunjid Jan 29, 2026
d9e5f7a
Merge pull request #908 from tunjid/tj/sign-in-past-session
tunjid Jan 29, 2026
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 @@ -18,6 +18,7 @@ package com.tunjid.heron.data.datastore.migrations

import androidx.datastore.core.okio.OkioSerializer
import com.tunjid.heron.data.core.models.Constants
import com.tunjid.heron.data.core.models.SessionSummary
import com.tunjid.heron.data.core.types.ProfileId
import com.tunjid.heron.data.repository.SavedState
import kotlinx.serialization.Serializable
Expand Down Expand Up @@ -73,6 +74,10 @@ internal data class VersionedSavedState(
-> null
else -> profileData[profileId]
}
override val pastSessions: List<SessionSummary>
get() = profileData.values
.mapNotNull { it.sessionSummary }
.sortedByDescending { it.lastSeen }

override val auth: AuthTokens?
get() = activeProfileId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import app.bsky.graph.GetListQueryParams
import com.tunjid.heron.data.core.models.OauthUriRequest
import com.tunjid.heron.data.core.models.Profile
import com.tunjid.heron.data.core.models.SessionRequest
import com.tunjid.heron.data.core.models.SessionSummary
import com.tunjid.heron.data.core.models.TimelinePreference
import com.tunjid.heron.data.core.types.GenericUri
import com.tunjid.heron.data.core.types.ProfileId
Expand Down Expand Up @@ -61,6 +62,8 @@ interface AuthRepository {

val signedInUser: Flow<Profile?>

val pastSessions: Flow<List<SessionSummary>>

fun isSignedInProfile(id: ProfileId): Flow<Boolean>

suspend fun oauthRequestUri(
Expand All @@ -69,7 +72,7 @@ interface AuthRepository {

suspend fun createSession(
request: SessionRequest,
): Result<Unit>
): Outcome

suspend fun signOut()

Expand Down Expand Up @@ -112,6 +115,11 @@ internal class AuthTokenRepository(
.withRefresh(::updateSignedInUser)
}

override val pastSessions: Flow<List<SessionSummary>> =
savedStateDataSource.savedState
.map { it.pastSessions ?: emptyList() }
.distinctUntilChanged()

override fun isSignedInProfile(id: ProfileId): Flow<Boolean> =
savedStateDataSource.singleSessionFlow { signedInProfileId ->
flowOf(signedInProfileId == id)
Expand All @@ -131,21 +139,32 @@ internal class AuthTokenRepository(

override suspend fun createSession(
request: SessionRequest,
): Result<Unit> = runCatchingUnlessCancelled {
): Outcome = runCatchingUnlessCancelled {
sessionManager.createSession(request)
}
.mapCatchingUnlessCancelled { authToken ->
savedStateDataSource.setAuth(authToken)
// Suspend till auth token has been saved and is readable
savedStateDataSource.savedState.first { it.auth != null }
if (authToken is SavedState.AuthTokens.Authenticated) {
updateSignedInUser(authToken.authProfileId.id.let(::Did))

// Check if it is an authenticated session. Guest sessions are valid.
when (authToken) {
is SavedState.AuthTokens.Authenticated ->
savedStateDataSource.inCurrentProfileSession { signedInProfileId ->
if (authToken.authProfileId == signedInProfileId) updateSignedInUser(
did = signedInProfileId.id.let(::Did),
)
else expiredSessionOutcome()
}
?: expiredSessionOutcome()
else ->
Outcome.Success
}
Unit
}
.onFailure {
savedStateDataSource.setAuth(null)
}
.fold(
onSuccess = { it },
onFailure = Outcome::Failure,
)

override suspend fun signOut() {
runCatchingUnlessCancelled {
Expand All @@ -161,22 +180,40 @@ internal class AuthTokenRepository(
}

override suspend fun updateSignedInUser(): Outcome =
networkService.runCatchingWithMonitoredNetworkRetry {
getSession()
}.fold(
onSuccess = { updateSignedInUser(it.did) },
onFailure = Outcome::Failure,
)
savedStateDataSource.inCurrentProfileSession { signedInProfileId ->
if (signedInProfileId == null) return@inCurrentProfileSession expiredSessionOutcome()

networkService.runCatchingWithMonitoredNetworkRetry {
getSession()
}.fold(
onSuccess = { updateSignedInUser(it.did) },
onFailure = Outcome::Failure,
)
} ?: expiredSessionOutcome()

private suspend fun updateSignedInUser(did: Did): Outcome = supervisorScope {
private suspend fun updateSignedInUser(
did: Did,
): Outcome = supervisorScope {
val succeeded = listOf(
async {
networkService.runCatchingWithMonitoredNetworkRetry {
getProfile(GetProfileQueryParams(actor = did))
}
.getOrNull()
?.profileEntity()
?.let { profileDao.upsertProfiles(listOf(it)) } != null
?.let { profileEntity ->
profileDao.upsertProfiles(listOf(profileEntity))
savedStateDataSource.updateSignedInProfileData {
copy(
sessionSummary = SessionSummary(
lastSeen = Clock.System.now(),
profileId = profileEntity.did,
profileHandle = profileEntity.handle,
profileAvatar = profileEntity.avatar,
),
)
}
} != null
},
async {
networkService.runCatchingWithMonitoredNetworkRetry {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.tunjid.heron.data.core.models.Constants
import com.tunjid.heron.data.core.models.NotificationPreferences
import com.tunjid.heron.data.core.models.Preferences
import com.tunjid.heron.data.core.models.Server
import com.tunjid.heron.data.core.models.SessionSummary
import com.tunjid.heron.data.core.types.ProfileHandle
import com.tunjid.heron.data.core.types.ProfileId
import com.tunjid.heron.data.core.utilities.Outcome
Expand Down Expand Up @@ -65,6 +66,8 @@ abstract class SavedState {
abstract val navigation: Navigation
abstract val signedInProfileData: ProfileData?

abstract val pastSessions: List<SessionSummary>?

@Serializable
sealed class AuthTokens {
abstract val authProfileId: ProfileId
Expand Down Expand Up @@ -193,6 +196,7 @@ abstract class SavedState {
// Need default for migration
val writes: Writes = Writes(),
val auth: AuthTokens? = null,
val sessionSummary: SessionSummary? = null,
) {
companion object {
val defaultGuestData = ProfileData(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ interface UserDataRepository {
suspend fun setAutoHideBottomNavigation(
autoHideBottomNavigation: Boolean,
): Outcome

suspend fun setAutoPlayTimelineVideos(
autoPlayTimelineVideos: Boolean,
): Outcome
}

internal class OfflineUserDataRepository @Inject constructor(
Expand Down Expand Up @@ -100,6 +104,11 @@ internal class OfflineUserDataRepository @Inject constructor(
copy(local = local.copy(autoHideBottomNavigation = autoHideBottomNavigation))
}

override suspend fun setAutoPlayTimelineVideos(
autoPlayTimelineVideos: Boolean,
): Outcome = updatePreferences {
copy(local = local.copy(autoPlayTimelineVideos = autoPlayTimelineVideos))
}
private suspend inline fun updatePreferences(
crossinline updater: suspend Preferences.() -> Preferences,
): Outcome =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ data class Preferences(
val useCompactNavigation: Boolean = false,
@ProtoNumber(5)
val autoHideBottomNavigation: Boolean = true,
@ProtoNumber(6)
val autoPlayTimelineVideos: Boolean = true,
)

companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.tunjid.heron.data.core.models

import com.tunjid.heron.data.core.types.ImageUri
import com.tunjid.heron.data.core.types.ProfileHandle
import com.tunjid.heron.data.core.types.ProfileId
import kotlin.time.Instant
import kotlinx.serialization.Serializable

@Serializable
data class SessionSummary(
val lastSeen: Instant,
val profileId: ProfileId,
val profileHandle: ProfileHandle,
val profileAvatar: ImageUri?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@

package com.tunjid.heron.signin

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateBounds
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
Expand All @@ -33,24 +35,34 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import com.tunjid.heron.data.core.models.SessionSummary
import com.tunjid.heron.images.AsyncImage
import com.tunjid.heron.images.ImageArgs
import com.tunjid.heron.scaffold.scaffold.PaneScaffoldState
import com.tunjid.heron.signin.oauth.rememberOauthFlowState
import com.tunjid.heron.signin.ui.NoAccountButton
import com.tunjid.heron.signin.ui.ServerSelection
import com.tunjid.heron.signin.ui.ServerSelectionSheetState.Companion.rememberUpdatedServerSelectionState
import com.tunjid.heron.ui.shapes.RoundedPolygonShape
import com.tunjid.heron.ui.text.CommonStrings
import com.tunjid.heron.ui.text.FormField
import com.tunjid.heron.ui.text.LeadingIcon
import heron.ui.core.generated.resources.profile_avatar
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource

@Composable
internal fun SignInScreen(
Expand Down Expand Up @@ -86,7 +98,7 @@ internal fun SignInScreen(

state.fields.forEach { field ->
key(field.id) {
androidx.compose.animation.AnimatedVisibility(
AnimatedVisibility(
visible = state.isVisible(field),
enter = EnterTransition,
exit = ExitTransition,
Expand All @@ -95,6 +107,12 @@ internal fun SignInScreen(
modifier = Modifier
.fillMaxWidth(),
field = field,
leadingIcon = {
LoadingIcon(
field = field,
mostRecentSession = state.mostRecentSession,
)
},
onValueChange = { field, newValue ->
actions(
Action.FieldChanged(
Expand Down Expand Up @@ -153,6 +171,42 @@ internal fun SignInScreen(
}
}

@Composable
private fun LoadingIcon(
modifier: Modifier = Modifier,
field: FormField,
mostRecentSession: SessionSummary?,
) {
Box(
modifier = modifier,
) {
// Always show the default leading icon
// in case the avatar does not load
field.LeadingIcon()

val sessionAvatar = mostRecentSession?.profileAvatar

val isAvatarForField = field.id == Username &&
sessionAvatar != null &&
mostRecentSession.profileHandle.id == field.value

if (isAvatarForField) {
val avatarDescription = stringResource(CommonStrings.profile_avatar)
AsyncImage(
modifier = FormField.LeadingIconSizeModifier,
args = remember(sessionAvatar) {
ImageArgs(
url = sessionAvatar.uri,
contentDescription = avatarDescription,
contentScale = ContentScale.Crop,
shape = RoundedPolygonShape.Circle,
)
},
)
}
}
}

private val EnterTransition = fadeIn() + slideInVertically { -it }
private val ExitTransition =
shrinkOut { IntSize(it.width, 0) } + slideOutVertically { -it } + fadeOut()
Loading
Loading