Skip to content

Commit

Permalink
Implement mentions and events in DMs;
Browse files Browse the repository at this point in the history
Implement photo gallery in DMs;
Implement DM sending state on chat screen;
Implement profile, notes, hashtags and url clicks on DMs;
Fix hashtags in DMs and other minor ui fixes;
  • Loading branch information
AleksandarIlic committed Nov 2, 2023
1 parent 77de9e8 commit c55eb56
Show file tree
Hide file tree
Showing 23 changed files with 595 additions and 268 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.tooling.preview.Preview
Expand Down Expand Up @@ -95,6 +96,44 @@ private fun String.replaceNostrProfileUrisWithHandles(
return newContent
}

private val noteLinkLeftovers = listOf(
"https://primal.net/e/ " to "",
"https://www.primal.net/e/ " to "",
"http://primal.net/e/ " to "",
"http://www.primal.net/e/ " to "",
"https://primal.net/e/\n" to "",
"https://www.primal.net/e/\n" to "",
"http://primal.net/e/\n" to "",
"http://www.primal.net/e/\n" to "",
)

private val profileLinkLeftovers = listOf(
"https://primal.net/p/@" to "@",
"https://www.primal.net/p/@" to "@",
"http://primal.net/p/@" to "@",
"http://www.primal.net/p/@" to "@",
)

private fun String.clearParsedPrimalLinks(): String {
var newContent = this
(noteLinkLeftovers + profileLinkLeftovers).forEach {
newContent = newContent.replace(
oldValue = it.first,
newValue = it.second,
ignoreCase = false,
)
}
noteLinkLeftovers.map { it.first.trim() }.toSet().forEach {
if (newContent.endsWith(it)) {
newContent = newContent.replace(
oldValue = it,
newValue = "",
)
}
}
return newContent
}

private fun String.ellipsize(
expanded: Boolean,
ellipsizeText: String,
Expand All @@ -120,89 +159,20 @@ fun FeedPostContent(
onClick: (Offset) -> Unit,
onUrlClick: (String) -> Unit,
onHashtagClick: (String) -> Unit,
highlightColor: Color = AppTheme.colorScheme.primary,
contentColor: Color = AppTheme.colorScheme.onSurface,
) {
val seeMoreText = stringResource(id = R.string.feed_see_more)
val primaryColor = AppTheme.colorScheme.primary

val imageResources = remember { mediaResources.filterImages() }
val refinedUrlResources = remember { mediaResources.filterNotImages() }
val referencedPostResources = remember { nostrResources.filterReferencedPosts() }
val referencedUserResources = remember { nostrResources.filterReferencedUsers() }

val contentText = remember {
val refinedContent = content
.withoutUrls(urls = imageResources.map { it.url })
.withoutUrls(urls = referencedPostResources.map { it.uri })
.ellipsize(expanded = expanded, ellipsizeText = seeMoreText)
.replaceNostrProfileUrisWithHandles(resources = referencedUserResources)
.trim()

buildAnnotatedString {
append(refinedContent)

if (refinedContent.endsWith(seeMoreText)) {
addStyle(
style = SpanStyle(color = primaryColor),
start = refinedContent.length - seeMoreText.length,
end = refinedContent.length,
)
}

refinedUrlResources.map { it.url }.forEach {
val startIndex = refinedContent.indexOf(it)
if (startIndex >= 0) {
val endIndex = startIndex + it.length
addStyle(
style = SpanStyle(color = primaryColor),
start = startIndex,
end = endIndex,
)
addStringAnnotation(
tag = URL_ANNOTATION_TAG,
annotation = it,
start = startIndex,
end = endIndex,
)
}
}

referencedUserResources.forEach {
checkNotNull(it.referencedUser)
val displayHandle = it.referencedUser.displayUsername
val startIndex = refinedContent.indexOf(displayHandle)
if (startIndex >= 0) {
val endIndex = startIndex + displayHandle.length
addStyle(
style = SpanStyle(color = primaryColor),
start = startIndex,
end = endIndex,
)
addStringAnnotation(
tag = PROFILE_ID_ANNOTATION_TAG,
annotation = it.referencedUser.userId,
start = startIndex,
end = endIndex,
)
}
}

HashtagMatcher(content = refinedContent, hashtags = hashtags)
.matches()
.forEach {
addStyle(
style = SpanStyle(color = primaryColor),
start = it.startIndex,
end = it.endIndex,
)
addStringAnnotation(
tag = HASHTAG_ANNOTATION_TAG,
annotation = it.value,
start = it.startIndex,
end = it.endIndex,
)
}
}
renderContentAsAnnotatedString(
content = content,
expanded = expanded,
seeMoreText = seeMoreText,
hashtags = hashtags,
mediaResources = mediaResources,
nostrResources = nostrResources,
highlightColor = highlightColor,
)
}

Column(
Expand Down Expand Up @@ -230,10 +200,12 @@ fun FeedPostContent(
)
}

val imageResources = remember { mediaResources.filterImages() }
if (imageResources.isNotEmpty()) {
FeedPostImages(imageResources = imageResources)
}

val referencedPostResources = remember { nostrResources.filterReferencedPosts() }
if (referencedPostResources.isNotEmpty()) {
FeedReferencedPosts(
postResources = referencedPostResources,
Expand All @@ -243,6 +215,101 @@ fun FeedPostContent(
}
}

fun renderContentAsAnnotatedString(
content: String,
expanded: Boolean,
seeMoreText: String,
hashtags: List<String>,
mediaResources: List<MediaResourceUi>,
nostrResources: List<NostrResourceUi>,
shouldKeepNostrNoteUris: Boolean = false,
highlightColor: Color,
): AnnotatedString {

val imageUrlResources = mediaResources.filterImages()
val otherUrlResources = mediaResources.filterNotImages()
val referencedPostResources = nostrResources.filterReferencedPosts()
val referencedUserResources = nostrResources.filterReferencedUsers()

val refinedContent = content
.withoutUrls(urls = imageUrlResources.map { it.url })
.withoutUrls(urls = if (!shouldKeepNostrNoteUris) {
referencedPostResources.map { it.uri }
} else {
emptyList()
})
.ellipsize(expanded = expanded, ellipsizeText = seeMoreText)
.replaceNostrProfileUrisWithHandles(resources = referencedUserResources)
.clearParsedPrimalLinks()
.trim()

return buildAnnotatedString {
append(refinedContent)

if (refinedContent.endsWith(seeMoreText)) {
addStyle(
style = SpanStyle(color = highlightColor),
start = refinedContent.length - seeMoreText.length,
end = refinedContent.length,
)
}

otherUrlResources.map { it.url }.forEach {
val startIndex = refinedContent.indexOf(it)
if (startIndex >= 0) {
val endIndex = startIndex + it.length
addStyle(
style = SpanStyle(color = highlightColor),
start = startIndex,
end = endIndex,
)
addStringAnnotation(
tag = URL_ANNOTATION_TAG,
annotation = it,
start = startIndex,
end = endIndex,
)
}
}

referencedUserResources.forEach {
checkNotNull(it.referencedUser)
val displayHandle = it.referencedUser.displayUsername
val startIndex = refinedContent.indexOf(displayHandle)
if (startIndex >= 0) {
val endIndex = startIndex + displayHandle.length
addStyle(
style = SpanStyle(color = highlightColor),
start = startIndex,
end = endIndex,
)
addStringAnnotation(
tag = PROFILE_ID_ANNOTATION_TAG,
annotation = it.referencedUser.userId,
start = startIndex,
end = endIndex,
)
}
}

HashtagMatcher(content = refinedContent, hashtags = hashtags)
.matches()
.forEach {
addStyle(
style = SpanStyle(color = highlightColor),
start = it.startIndex,
end = it.endIndex,
)
addStringAnnotation(
tag = HASHTAG_ANNOTATION_TAG,
annotation = it.value,
start = it.startIndex,
end = it.endIndex,
)
}
}
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FeedPostImages(
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/kotlin/net/primal/android/feed/api/FeedApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ interface FeedApi {

suspend fun getThread(body: ThreadRequestBody): FeedResponse

suspend fun getNotes(noteIds: Set<String>, extendedResponse: Boolean = true): FeedResponse

}
27 changes: 26 additions & 1 deletion app/src/main/kotlin/net/primal/android/feed/api/FeedApiImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ package net.primal.android.feed.api
import kotlinx.serialization.encodeToString
import net.primal.android.feed.api.model.FeedRequestBody
import net.primal.android.feed.api.model.FeedResponse
import net.primal.android.feed.api.model.NotesRequestBody
import net.primal.android.feed.api.model.ThreadRequestBody
import net.primal.android.networking.di.PrimalCacheApiClient
import net.primal.android.networking.primal.PrimalApiClient
import net.primal.android.networking.primal.PrimalCacheFilter
import net.primal.android.networking.primal.PrimalVerb.*
import net.primal.android.networking.primal.PrimalVerb.FEED_DIRECTIVE
import net.primal.android.networking.primal.PrimalVerb.NOTES
import net.primal.android.networking.primal.PrimalVerb.THREAD_VIEW
import net.primal.android.nostr.model.NostrEventKind
import net.primal.android.serialization.NostrJson
import net.primal.android.serialization.decodeFromStringOrNull
Expand Down Expand Up @@ -62,4 +65,26 @@ class FeedApiImpl @Inject constructor(
)
}

override suspend fun getNotes(noteIds: Set<String>, extendedResponse: Boolean): FeedResponse {
val queryResult = primalApiClient.query(
message = PrimalCacheFilter(
primalVerb = NOTES,
optionsJson = NostrJson.encodeToString(
NotesRequestBody(noteIds = noteIds.toList(), extendedResponse = true)
)
)
)

return FeedResponse(
paging = null,
metadata = queryResult.filterNostrEvents(NostrEventKind.Metadata),
posts = queryResult.filterNostrEvents(NostrEventKind.ShortTextNote),
reposts = emptyList(),
referencedPosts = queryResult.filterPrimalEvents(NostrEventKind.PrimalReferencedEvent),
primalEventStats = queryResult.filterPrimalEvents(NostrEventKind.PrimalEventStats),
primalEventUserStats = queryResult.filterPrimalEvents(NostrEventKind.PrimalEventUserStats),
primalEventResources = queryResult.filterPrimalEvents(NostrEventKind.PrimalEventResources),
)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package net.primal.android.feed.api.model

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

@Serializable
data class NotesRequestBody(
@SerialName("event_ids") val noteIds: List<String>,
@SerialName("extended_response") val extendedResponse: Boolean = true
)
3 changes: 3 additions & 0 deletions app/src/main/kotlin/net/primal/android/feed/db/PostDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ interface PostDao {
@Query("SELECT * FROM PostData WHERE postId = :postId")
fun findByPostId(postId: String): PostData?

@Query("SELECT * FROM PostData WHERE postId IN (:postIds)")
fun findPosts(postIds: List<String>): List<PostData>

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ package net.primal.android.feed.repository
import androidx.room.withTransaction
import net.primal.android.db.PrimalDatabase
import net.primal.android.feed.api.model.FeedResponse
import net.primal.android.nostr.ext.flatMapAsPostMediaResourcePO
import net.primal.android.nostr.ext.flatMapAsPostNostrResourcePO
import net.primal.android.nostr.ext.flatMapNotNullAsMediaResourcePO
import net.primal.android.nostr.ext.flatMapPostsAsMediaResourcePO
import net.primal.android.nostr.ext.flatMapPostsAsNostrResourcePO
import net.primal.android.nostr.ext.mapAsPostDataPO
import net.primal.android.nostr.ext.mapAsProfileDataPO
import net.primal.android.nostr.ext.mapNotNullAsPostDataPO
Expand Down Expand Up @@ -33,18 +33,19 @@ suspend fun FeedResponse.persistToDatabaseAsTransaction(
val eventIdMap = profileIdToProfileDataMap.mapValues { it.value.eventId }
postData.copy(authorMetadataId = eventIdMap[postData.authorId])
}
val parsedMediaResources = allPosts.flatMapPostsAsMediaResourcePO()

database.withTransaction {
database.profiles().upsertAll(data = profiles)
database.posts().upsertAll(data = allPosts)
database.mediaResources().upsertAll(data = allPosts.flatMapAsPostMediaResourcePO())
database.nostrResources().upsertAll(data = allPosts.flatMapAsPostNostrResourcePO(
database.mediaResources().upsertAll(data = parsedMediaResources)
database.mediaResources().upsertAll(data = primalMediaResources)
database.nostrResources().upsertAll(data = allPosts.flatMapPostsAsNostrResourcePO(
postIdToPostDataMap = allPosts.groupBy { it.postId }.mapValues { it.value.first() },
profileIdToProfileDataMap = profileIdToProfileDataMap
))
database.reposts().upsertAll(data = reposts)
database.postStats().upsertAll(data = postStats)
database.postUserStats().upsertAll(data = userPostStats)
database.mediaResources().upsertAll(data = primalMediaResources)
}
}
Loading

0 comments on commit c55eb56

Please sign in to comment.