Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -22,6 +22,8 @@ import app.bsky.actor.ProfileAssociatedChatAllowIncoming
import app.bsky.actor.ProfileView
import app.bsky.actor.ProfileViewBasic
import app.bsky.actor.ProfileViewDetailed
import app.bsky.actor.StatusView
import app.bsky.actor.StatusViewEmbedUnion
import app.bsky.actor.ViewerState
import app.bsky.feed.BlockedAuthor
import com.tunjid.heron.data.core.models.Constants
Expand Down Expand Up @@ -185,6 +187,7 @@ internal fun ProfileViewBasic.profile() = Profile(
),
labels = labels?.map(com.atproto.label.Label::asExternalModel) ?: emptyList(),
isLabeler = associated?.labeler ?: false,
status = status?.profileStatus(),
)

internal fun ProfileView.profile() = Profile(
Expand All @@ -210,6 +213,26 @@ internal fun ProfileView.profile() = Profile(
),
labels = labels?.map(com.atproto.label.Label::asExternalModel) ?: emptyList(),
isLabeler = associated?.labeler ?: false,
status = status?.profileStatus(),
)

internal fun StatusView.profileStatus() = Profile.ProfileStatus(
uri = uri?.atUri,
status = status,
embed = (embed as? StatusViewEmbedUnion.View)
?.value
?.external
?.let { ext ->
Profile.ProfileStatus.Embed(
uri = ext.uri.uri,
title = ext.title,
description = ext.description,
thumb = ext.thumb?.uri?.let(::ImageUri),
)
},
expiresAt = expiresAt,
isActive = isActive,
isDisabled = isDisabled,
)

private fun ProfileAssociated?.allowedChat(): Profile.ChatInfo.Allowed =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
package com.tunjid.heron.data.repository

import app.bsky.actor.Profile as BskyProfile
import app.bsky.actor.Status
import app.bsky.actor.StatusEmbedUnion
import app.bsky.embed.External
import app.bsky.embed.ExternalExternal
import app.bsky.graph.Block as BskyBlock
import app.bsky.graph.Follow as BskyFollow
import app.bsky.graph.GetFollowersQueryParams
Expand Down Expand Up @@ -56,6 +60,7 @@ import com.tunjid.heron.data.database.daos.ListDao
import com.tunjid.heron.data.database.daos.ProfileDao
import com.tunjid.heron.data.database.entities.PopulatedListMemberEntity
import com.tunjid.heron.data.database.entities.PopulatedProfileEntity
import com.tunjid.heron.data.database.entities.asEntity
import com.tunjid.heron.data.database.entities.asExternalModel
import com.tunjid.heron.data.database.entities.profile.ProfileViewerStateEntity
import com.tunjid.heron.data.database.entities.profile.asExternalModel
Expand All @@ -74,6 +79,7 @@ import com.tunjid.heron.data.utilities.withRefresh
import dev.zacsweers.metro.Inject
import io.ktor.utils.io.ByteReadChannel
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
Expand All @@ -87,6 +93,7 @@ import sh.christian.ozone.api.AtUri
import sh.christian.ozone.api.Did
import sh.christian.ozone.api.Nsid
import sh.christian.ozone.api.RKey
import sh.christian.ozone.api.Uri as LexiconUri

@Serializable
data class ProfilesQuery(
Expand Down Expand Up @@ -146,6 +153,10 @@ interface ProfileRepository {
suspend fun updateProfile(
update: Profile.Update,
): Outcome

suspend fun updateStatus(
update: Profile.StatusUpdate,
): Outcome
}

internal class OfflineProfileRepository @Inject constructor(
Expand Down Expand Up @@ -518,4 +529,62 @@ internal class OfflineProfileRepository @Inject constructor(
.toOutcome()
}
} ?: expiredSessionOutcome()

override suspend fun updateStatus(
update: Profile.StatusUpdate,
): Outcome = savedStateDataSource.inCurrentProfileSession { signedInProfileId ->
if (signedInProfileId == null) return@inCurrentProfileSession expiredSessionOutcome()

when (update) {
is Profile.StatusUpdate.GoLive -> networkService.runCatchingWithMonitoredNetworkRetry {
putRecord(
PutRecordRequest(
repo = update.profileId.id.let(::Did),
collection = Nsid(Collections.ProfileStatus),
rkey = RKey(Collections.SelfRecordKey.rkey),
record = Status(
status = Profile.ProfileStatus.STATUS_LIVE,
durationMinutes = update.durationMinutes.toLong(),
embed = StatusEmbedUnion.AppBskyEmbedExternal(
value = External(
external = ExternalExternal(
uri = LexiconUri(update.streamUrl),
title = "",
description = "",
),
Comment on lines +550 to +554

Choose a reason for hiding this comment

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

medium

The title and description for the live status are hardcoded as empty strings. This is also repeated in the toOutcome block on lines 567-568.

This is a missed opportunity to provide more context about the live stream. Consider enhancing the Profile.StatusUpdate.GoLive model to accept an optional title and description. If they aren't provided, you could attempt to fetch this metadata from the streamUrl (e.g., by parsing OpenGraph tags from the HTML at the URL). This would make the feature more robust and provide a better user experience.

),
),
createdAt = Clock.System.now(),
).asJsonContent(Status.serializer()),
),
)
}.toOutcome {
profileDao.upsertStatus(
Profile.ProfileStatus(
status = Profile.ProfileStatus.STATUS_LIVE,
embed = Profile.ProfileStatus.Embed(
uri = update.streamUrl,
title = "",
description = "",
),
expiresAt = Clock.System.now().plus(update.durationMinutes.minutes),
isActive = true,
isDisabled = false,
).asEntity(update.profileId),
)
}

is Profile.StatusUpdate.EndLive -> networkService.runCatchingWithMonitoredNetworkRetry {
deleteRecord(
DeleteRecordRequest(
repo = update.profileId.id.let(::Did),
collection = Nsid(Collections.ProfileStatus),
rkey = RKey(Collections.SelfRecordKey.rkey),
),
)
}.toOutcome {
profileDao.deleteStatus(update.profileId)
}
}
} ?: expiredSessionOutcome()
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import sh.christian.ozone.api.model.JsonContent

internal object Collections {
const val Profile = "app.bsky.actor.profile"
const val ProfileStatus = "app.bsky.actor.status"
const val UploadVideo = "com.atproto.repo.uploadBlob"

val SelfRecordKey = RKey("self")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ sealed interface Writable {
profileRepository.updateRestriction(restriction)
}

@Serializable
data class StatusUpdate(
val update: Profile.StatusUpdate,
) : Writable {

override val queueId: String
get() = "status-update-${update.profileId}"

override suspend fun WriteQueue.write(): Outcome =
profileRepository.updateStatus(update)
}

@Serializable
data class TimelineUpdate(
val update: Timeline.Update,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ private fun Writable.writeTimeout() =
is Writable.Send,
is Writable.TimelineUpdate,
is Writable.RecordDeletion,
is Writable.StatusUpdate,
-> BasicWriteTimeout
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 34,
"identityHash": "1e975be4633a2c929f918f6201d3bdf4",
Copy link
Owner

Choose a reason for hiding this comment

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

If the DB identity hash changes, it means you're missing a migration. The identity has hash for a DB version shouldn't change.

"identityHash": "adbac83d33dc50b8cc208c46f3e14c77",
"entities": [
{
"tableName": "bookmarks",
Expand Down Expand Up @@ -1041,6 +1041,78 @@
}
]
},
{
"tableName": "profile_statuses",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `statusValue` TEXT NOT NULL, `embedUri` TEXT, `embedTitle` TEXT, `embedDescription` TEXT, `embedThumb` TEXT, `expiresAt` INTEGER, `isActive` INTEGER, `isDisabled` INTEGER, PRIMARY KEY(`profileId`), FOREIGN KEY(`profileId`) REFERENCES `profiles`(`did`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "profileId",
"columnName": "profileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "statusValue",
"columnName": "statusValue",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "embedUri",
"columnName": "embedUri",
"affinity": "TEXT"
},
{
"fieldPath": "embedTitle",
"columnName": "embedTitle",
"affinity": "TEXT"
},
{
"fieldPath": "embedDescription",
"columnName": "embedDescription",
"affinity": "TEXT"
},
{
"fieldPath": "embedThumb",
"columnName": "embedThumb",
"affinity": "TEXT"
},
{
"fieldPath": "expiresAt",
"columnName": "expiresAt",
"affinity": "INTEGER"
},
{
"fieldPath": "isActive",
"columnName": "isActive",
"affinity": "INTEGER"
},
{
"fieldPath": "isDisabled",
"columnName": "isDisabled",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"profileId"
]
},
"foreignKeys": [
{
"table": "profiles",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"profileId"
],
"referencedColumns": [
"did"
]
}
]
},
{
"tableName": "postLikes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`postUri` TEXT NOT NULL, `authorId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `indexedAt` INTEGER NOT NULL, PRIMARY KEY(`postUri`, `authorId`), FOREIGN KEY(`postUri`) REFERENCES `posts`(`uri`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`authorId`) REFERENCES `profiles`(`did`) ON UPDATE CASCADE ON DELETE CASCADE )",
Expand Down Expand Up @@ -3209,7 +3281,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1e975be4633a2c929f918f6201d3bdf4')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'adbac83d33dc50b8cc208c46f3e14c77')"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import com.tunjid.heron.data.database.entities.PostLikeEntity
import com.tunjid.heron.data.database.entities.PostRepostEntity
import com.tunjid.heron.data.database.entities.PostThreadEntity
import com.tunjid.heron.data.database.entities.ProfileEntity
import com.tunjid.heron.data.database.entities.ProfileStatusEntity
import com.tunjid.heron.data.database.entities.StarterPackEntity
import com.tunjid.heron.data.database.entities.ThreadGateAllowedListEntity
import com.tunjid.heron.data.database.entities.ThreadGateEntity
Expand Down Expand Up @@ -108,6 +109,7 @@ import kotlinx.coroutines.IO
PostViewerStatisticsEntity::class,
ProfileViewerStateEntity::class,
ProfileEntity::class,
ProfileStatusEntity::class,
PostLikeEntity::class,
PostRepostEntity::class,
LabelEntity::class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import androidx.room.Upsert
import com.tunjid.heron.data.core.types.BlockUri
import com.tunjid.heron.data.core.types.FollowUri
import com.tunjid.heron.data.core.types.Id
import com.tunjid.heron.data.core.types.ProfileId
import com.tunjid.heron.data.database.entities.PopulatedProfileEntity
import com.tunjid.heron.data.database.entities.ProfileEntity
import com.tunjid.heron.data.database.entities.ProfileStatusEntity
import com.tunjid.heron.data.database.entities.partial
import com.tunjid.heron.data.database.entities.profile.ProfileViewerStateEntity
import com.tunjid.heron.data.database.entities.profile.partial
Expand Down Expand Up @@ -170,4 +172,19 @@ interface ProfileDao {
suspend fun deleteBlock(
uri: BlockUri,
)

@Upsert
suspend fun upsertStatus(
entity: ProfileStatusEntity,
)

@Query(
"""
DELETE FROM profile_statuses
WHERE profileId = :profileId
""",
)
suspend fun deleteStatus(
profileId: ProfileId,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ fun ProfileEntity?.asExternalModel(
),
labels = labels,
isLabeler = associated.labeler ?: false,
status = null,
)

data class PopulatedProfileEntity(
Expand All @@ -120,6 +121,8 @@ data class PopulatedProfileEntity(
entityColumn = "uri",
)
val labelEntities: List<LabelEntity>,
@Relation(parentColumn = "did", entityColumn = "profileId")
val statusEntity: ProfileStatusEntity?,
)

fun PopulatedProfileEntity.asExternalModel() = with(entity) {
Expand All @@ -146,6 +149,7 @@ fun PopulatedProfileEntity.asExternalModel() = with(entity) {
),
labels = labelEntities.map(LabelEntity::asExternalModel),
isLabeler = associated.labeler ?: false,
status = statusEntity?.asExternalModel(),
)
}

Expand Down
Loading